메시지 생성과 사용량 확정은 동시에 일어나지 않는다
AI Chat은 스트리밍 답변을 시작하기 전에 빈 답변 메시지를 만든다. 모델이 응답을 보내는 동안 사용량은 아직 확정되지 않았고, 완료 후 메시지에 토큰 사용량을 가산한다. 통계 집계가 메시지 생성 시각만 보고 진행하면, 존재하는 행을 읽고도 비용을 놓칠 수 있었다.
기존 집계는 실행 시각까지의 메시지를 조회해 통계에 $inc로 더했다. 완료 전 메시지는 사용량 0으로 처리되고 체크포인트는 앞으로 이동했다. 나중에 원본 메시지의 사용량이 채워져도 생성 시각은 그대로다. 다음 집계는 그 메시지를 다시 읽지 않는다. 스트리밍 화면이 정상이어도 누적 통계에서는 사용량이 사라지는 순서다.
아래 시각은 이 순서를 설명하는 예시이며 운영 기록이 아니다.
- 10:00 · 메시지 생성createdAt은 10:00이다. 스트림은 진행 중이고 사용량은 아직 0이다.
- 10:01 · 기존 집계 실행10:01까지 읽고 체크포인트도 10:01로 옮긴다. 이 메시지는 0으로 집계된다.
- 10:03 · 스트림 완료사용량이 채워지지만 createdAt은 10:00이다. 다음 증분 범위 밖에 남는다.
- 지연 집계 적용 후10:01 실행에서는 09:41까지만 읽는다. 메시지는 이후 cutoff가 10:00을 지난 집계에서 읽힌다.
읽지 않은 구간을 처리했다고 기록하지 않는다
변경은 집계의 상한을 실행 시각에서 기본 20분만큼 뒤로 당겼다. 이 상한이 cutoff다. 스트림이 끝나고 사용량을 저장할 시간을 확보하는 지연 정책이며, 설정 옵션으로 조정할 수 있다. 옵션 조회 실패나 유효하지 않은 값은 기본값을 사용한다.
조회 상한만 바꾸면 충분하지 않다. 10:01에 실행하면서 09:41까지만 읽었다면 체크포인트도 09:41이어야 한다. 체크포인트에 10:01을 남기면 읽지 않은 20분 구간을 처리했다고 기록하는 셈이다. 다음 실행은 그 구간을 영구히 건너뛴다.
cutoff = 실행 시각 - 지연 시간
createdAt: { $gt: previousCutoff, $lte: cutoff }
집계 반영 후 last_updated_at = cutoff 주기 집계, 튜터 일일 집계, 수동 실행이 같은 정책을 사용한다. 다시 실행했을 때 새 cutoff가 기존 체크포인트보다 앞에 있으면 그 키는 건너뛰고 이력을 되돌리지 않는다. 시간 옵션 변경도 이미 집계한 구간을 다시 더하는 이유가 되어서는 안 됐다.
지난 상한을 다음 하한으로 그대로 쓴다
기존 증분 집계에는 하한에서 1밀리초를 빼는 코드도 있었다. 조회 연산은 하한을 제외하는 $gt였다. 이전 상한이 09:40:00.000이면 다음 하한을 09:39:59.999로 만드는 순간, 이전 집계에 포함된 09:40:00.000 메시지가 다시 포함된다. $inc는 같은 메시지를 두 번 더한 사실을 스스로 구분하지 못한다.
증분 범위를 (이전 cutoff, 새 cutoff]로 맞췄다. 이전 상한에 정확히 있는 메시지는 앞 구간에서 한 번 처리하고, 다음 구간은 그 이후부터 시작한다. 최초 집계만 예외다. 이전 체크포인트가 없고 가장 이른 메시지를 하한으로 삼는다면 $gt가 첫 메시지를 제외한다. 1밀리초 보정은 그 최초 경로에만 남겼다.
첫 실행에서 모든 데이터를 포함하는 문제와, 증분 실행에서 경계 데이터를 중복하지 않는 문제는 다른 조건이었다. 하나의 보정값을 두 경로에 적용한 것이 오히려 중복 구간을 만들었다.
집계가 늦어져도 날짜 귀속은 생성 시각을 따른다
집계 대상에 들어오는 시점과 일·월 통계에 들어가는 날짜는 다르다. 버킷은 메시지의 createdAt을 KST 기준으로 해석해 결정한다. 자정 직전에 만든 메시지가 자정 이후 완료됐어도 집계가 늦었다는 이유로 다음 날 사용량으로 옮기지 않는다.
회귀 테스트에는 KST 자정과 월 경계를 넘는 조회 범위, cutoff와 체크포인트 일치, 배타 하한, 최초 메시지 포함이 들어 있다. 이는 필터와 날짜 경계의 검증이다. 모든 스트림이 20분 안에 사용량을 확정한다는 운영 측정은 아니다. 기본값은 기록된 응답 생성 상한에 여유를 둔 정책이다.
지연은 완료를 기다리는 정책이지 멱등성을 대신하지 않는다
20분보다 늦게 사용량이 확정되거나 과거 메시지를 정정하면 여전히 놓칠 수 있다. 통계에 가산한 다음 체크포인트 저장 전에 프로세스가 종료되면, 같은 구간을 다시 읽어 중복할 수도 있다. 이 수정은 생성과 확정 시각의 차이를 다루며, 집계 반영과 체크포인트를 하나의 원자적 변경으로 만들지는 않았다.
월 카운터 중복 키 재시도는 같은 달의 첫 문서를 동시에 만드는 경계를 다룬다. 최초 통계를 중간에 실패한 뒤 다시 쌓는 문제와는 다른 조건이다.
확정 이벤트를 별도로 적재하거나, 일정 구간을 원본에서 재계산해 교체하는 방식은 후속 대안이다. 어떤 데이터를 다시 더할 수 있는지보다 어떤 결과를 다시 만들어도 같은 상태가 되는지가 복구의 기준이어야 한다. AI Chat / Tutor에서는 먼저 너무 이른 집계와 잘못된 경계부터 바로잡았다.
최초 집계는 부분 결과를 지우고 다시 만든다
이력이 없는 최초 집계도 중간 상태를 남길 수 있다. 튜터 사용자 월 통계에 $inc로 일부 결과를 저장한 뒤 업데이트 이력 저장 전에 실패하면, 다음 실행도 해당 키를 최초 집계로 판단한다. 전체 과거 메시지를 다시 읽어 남아 있는 부분 결과에 더하므로 이미 반영한 양이 중복된다.
- 최초 집계 도중 일부 월 통계 저장대상 컬렉션에 부분 결과가 생겼지만 업데이트 이력은 아직 없다.
- 이력 저장 전 실행 실패다음 실행은 해당 통계 키를 다시 최초 집계 그룹에 넣는다.
- 재구성 전에 목적 컬렉션 초기화남은 부분 결과를 물리 삭제해 같은 과거 사용량을 다시 더할 바탕을 없앤다.
- 원본에서 최초 통계 재구성기존 cutoff 정책에 따라 통계를 쌓고, 완료된 범위의 이력을 남긴다.
초기 집계 복원 경로는 최초 그룹에 튜터 사용자 월 통계 키가 있을 때만 resetMonthlyTutorUser를 먼저 호출한다. 이 키의 이력이 이미 있으면 다른 통계 키가 최초 그룹에 있어도 초기화하지 않는다. 증분 실행 때 누적 결과를 지우는 수정은 아니다.
삭제는 soft delete가 아닌 컬렉션의 deleteMany()다. 뒤의 bulkWrite 필터는 삭제 표시를 제외하지 않으므로 행을 남기는 soft delete는 이전 $inc 값을 유지할 수 있다. 목적 컬렉션을 비운 뒤 다시 쌓으므로, 재구성 동안 조회에는 빈 결과나 부분 결과가 보일 수 있다. 별도 컬렉션에서 완성한 뒤 교체하는 방식은 그 가시성을 줄이는 대안이지만 이 구현에는 포함돼 있지 않다.
테스트는 모킹한 호출 순서로 초기화가 최초 bulk write보다 앞서는지, 완전 증분 실행과 다른 키만 최초인 실행에서는 초기화하지 않는지 확인한다. StatisticsService 테스트는 물리 deleteMany 호출을 확인한다. 실제 프로세스 종료·재시작을 재현하거나 여러 인스턴스의 동시 실행을 배제하는 검증은 아니다. 이 재구성 경로도 분산 락을 제공하지 않으며, 모든 증분 구간의 반영과 체크포인트를 원자적으로 묶는 보장은 여전히 별도 문제다.