upsert 두 개가 모두 문서가 없다고 판단할 수 있다

월 사용량은 (year, month, user_login)마다 한 문서에 쌓인다. 아직 그 달의 문서가 없다면 upsert가 새 문서를 만든다. 두 요청이 동시에 시작하면 양쪽 모두 같은 키의 문서가 없다고 읽고 삽입을 시도할 수 있다. 유일 인덱스는 문서 두 개가 남는 것을 막지만, 뒤의 삽입 시도는 중복 키 오류를 받을 수 있다.

아래는 이 경합을 설명하는 실행 순서이며, 특정 사용자의 요청을 재현한 운영 기록은 아니다.

  1. A와 B가 같은 월 키로 upsert두 요청 모두 처음에는 매칭 문서를 찾지 못한다.
  2. A의 삽입이 먼저 성공해당 키의 월 문서가 생긴다. B의 삽입은 유일 인덱스에 걸려 code 11000을 받는다.
  3. B가 같은 월 upsert를 한 번 더 호출이제 생긴 문서를 대상으로 갱신할 수 있다. 두 번째 호출의 오류는 그대로 상위로 전달한다.

실패를 확인한 삽입만 좁게 다시 시도한다

수정은 중복 키 판별과 한 번의 재시도를 작은 헬퍼로 묶었다. 조건은 오류 객체의 code === 11000이다. 문자열 "11000", 메시지에 중복이라는 단어가 있는 오류, 네트워크 오류는 이 분기에 들어오지 않는다. 다음은 실제 헬퍼의 재시도 부분이며 판별 함수 정의는 생략했다.

export const retryOnDuplicateKey = async <T>(
  fn: () => Promise<T>,
): Promise<T> => {
  try {
    return await fn();
  } catch (e: unknown) {
    if (!isDuplicateKeyError(e)) throw e;
    return fn();
  }
};

호출부는 유일 인덱스와 같은 월 키로 upsert한다. 요청 허용 검사에서는 한도 스냅샷을 $set하고, 정산에서는 월 사용량을 $inc한다. 재시도하는 것은 해당 DB 연산 하나다. 성공한 앞 단계나 정산 전체를 다시 호출하지 않는다.

중복 삽입이 거부됐다는 응답과, 갱신은 적용됐지만 네트워크에서 응답만 잃었다는 상황은 다르다. 뒤의 경우에 $inc를 무조건 반복하면 사용량을 두 번 더할 수 있다. 이 헬퍼는 결과가 불명인 요청의 재실행 정책으로 일반화할 수 없다. 중복 키가 반복되면 계속 돌지 않고 두 번째 오류를 전달하며, 추가 DB 호출도 최대 한 번이다.

월 문서 생성 경합을 해결해도 정산 전체가 원자적이지는 않다

정산은 먼저 중복 방지 키가 있는 정산 기록을 삽입하고, 월·추가 할당량·계약 카운터를 차례로 갱신한다. 기록 삽입과 카운터들은 하나의 트랜잭션으로 묶이지 않는다. 월 카운터가 성공한 뒤 계약 카운터에서 실패하면, 정산 기록과 일부 카운터만 남을 수 있다.

이때 정산 전체를 다시 호출해도 자동으로 나머지를 완성하지 않는다. 이미 있는 정산 기록의 중복 키를 만나면 중복 정산으로 반환하고 카운터 단계에 들어가지 않기 때문이다. 실패한 단계와 idempotency_key를 남기는 로그는 기록에서 카운터를 다시 맞출 단서다. 해당 코드에 자동 대사 완료 기능은 없다.

헬퍼 테스트는 첫 호출 성공, 중복 한 번 뒤 성공, 연속 중복 두 번, 다른 오류와 문자열 코드를 구분한다. 허용 검사·정산 서비스 테스트도 중복 키를 모킹해 월 연산이 두 번 호출되는지 확인한다. 실제 MongoDB에서 두 동시 요청을 실행한 통합 검증이나 응답 시간 측정은 아니다.

집계 시각과 체크포인트가 읽을 데이터의 경계를 다룬다면, 이 수정은 첫 월 문서를 만드는 삽입 경합을 다룬다. AI Chat / Tutor의 사용량 일치를 평가할 때는 이런 개별 경합과 정산 기록 이후의 부분 실패를 따로 봐야 한다.