문서 한 건이 목록 전체를 막고 페이지마다 항목이 바뀌던 MongoDB 조회 수정

명령 이력 문서 한 건의 필드 타입이 다르면 페이지 전체가 500 오류를 냈고, created가 같은 문서는 skip/limit 페이지에서 중복·누락됐다. 필드별 관용 파싱과 created·_id 복합 정렬 및 인덱스로 두 문제를 해결하고 50만 건에서 조회 계획을 검증했다.

개요

명령 이력 목록 GET /v3/command/list에서 기존 결함 두 가지를 확인했다.

  1. 문서 한 건의 필드 타입이 다르면 해당 페이지 전체가 500 오류를 냈다.
  2. created 값이 같은 문서가 있으면 페이지를 넘길 때 같은 명령이 다시 나오고 다른 명령은 빠졌다.

두 문제를 실 MongoDB로 재현했다. 필드별 관용 파싱과 안정적인 복합 정렬을 적용한 뒤 오염 자료, 대량 자료, migration 재실행, 동시 읽기·쓰기를 검증했다.

문서 한 건의 타입 오류가 목록 전체를 막는다

_CommandProjection이 필드를 엄격하게 읽었다. 문서 한 건만 예상한 타입과 달라도 그 페이지의 정상 문서까지 전부 반환하지 못했다.

정상 문서 1건과 오염 문서 1건을 넣은 결과다.

어긋난 필드수정 전
status가 숫자500
title이 숫자500
target_node_count가 문자500
created가 문자500
command_data가 문자500
is_broadcast가 문자200(Pydantic 강제 변환)

현재 제품 코드에서 이런 문서를 만드는 경로는 확인되지 않았다. 운영 중 DB를 직접 수정하거나, 서버를 이전 버전으로 되돌린 뒤 새 버전이 쓴 문서가 남거나, 외부 스크립트가 command 컬렉션에 직접 쓰는 경우에는 생길 수 있다.

필드별 관용 파싱

문서를 한 건씩 읽고 타입이 어긋난 필드만 버리도록 바꿨다. 노드 목록이 이미 같은 방식을 사용하고 있었으므로 공통 코드를 app/utils/tolerant_parse.py로 옮겨 두 조회에서 같이 쓴다.

어긋난 필드수정 전수정 후
status가 숫자500200, 해당 행만 제외
title이 숫자500200, title=null로 행 유지
target_node_count가 문자500200, 해당 필드만 제외
created가 문자500200, 해당 행만 제외
command_data가 문자500200, 해당 행만 제외
schedule_id가 ObjectId 또는 숫자500200, 해당 필드만 제외
run_seq가 실수 또는 객체500200, 해당 필드만 제외

아홉 가지 오류를 한꺼번에 넣어도 200을 반환했고 읽을 수 있는 7건이 나왔다. total은 DB의 실제 문서 수 10을 유지한다. 페이지 수가 중간에 바뀌지 않게 한 선택이다. 제외된 문서는 건수와 표본을 로그에 남긴다.

target_node_count는 응답 필수값이지만 projection의 기본값이 0이다. 값이 깨진 문서는 목록에서 사라지는 대신 "대상 0대"로 보인다. 행 전체를 숨기는 것보다 낫다고 판단했고 로그에 함께 남긴다.

created가 같으면 페이지에서 중복과 누락이 생긴다

기존 정렬 키는 created 하나였다. 값이 같은 문서 사이의 순서가 정해지지 않아 skip/limit로 다음 페이지를 가져올 때 순서가 바뀔 수 있었다.

30개 시각을 두 문서씩 공유하도록 60건을 만들고 7건씩 조회했다.

수집 60건 / 서로 다른 문서 59건
중복 1건 / 누락 1건

처음에는 같은 밀리초에 명령 두 건이 생기는 일이 드물다고 봤다. 반복 실행이 한 회차에 명령 20건을 만들어도 순차 await 때문에 간격이 최소 3ms였고, 검증 환경의 229건에서도 같은 created가 없었다.

동시 요청으로 다시 측정하자 결과가 달랐다. Command.created는 datetime.now(UTC)로 만들지만 BSON은 밀리초까지만 저장한다.

동시  2건: 서로 다른 시각 1개
동시 10건: 서로 다른 시각 4개
동시 30건: 서로 다른 시각 6개

두 요청만 동시에 들어와도 같은 시각이 생겼다.

정렬과 인덱스를 함께 변경

정렬을 created 내림차순, _id 내림차순으로 바꿨다. _id가 같은 시각 안의 순서를 확정한다.

정렬만 바꾸면 기존 created 인덱스가 전체 순서를 만족시키지 못해 blocking sort가 생긴다. 문서 200,000건으로 측정했다.

인덱스 그대로 + 정렬만 변경   60~118ms   검사 160,000~200,000건
인덱스에 _id 포함              0.43ms    검사 20건

그래서 migration v0049에서 네 인덱스에 _id를 추가했다.

  • created_desc
  • created_desc_scheduled
  • created_desc_manual
  • schedule_id_created

같은 이름으로 정의만 바꿀 수 없어 하나씩 삭제하고 다시 만들었다. 한 시점에 없는 인덱스는 하나뿐이다.

수정 후 같은 60건을 7건씩 조회했다.

조회수집중복
전체60/600
자동만40/400
수동만20/200
특정 스케줄20/200

두 번 조회한 문서 순서도 같았다. 네 조회 모두 IXSCAN을 사용했고 blocking sort는 없었다.

조회 경로 변경 검증

조회 경로를 paginate에서 원시 조회와 문서별 파싱으로 바꿨기 때문에 기존 동작과 같은지를 중점적으로 확인했다.

  • _CommandProjection의 모든 필드를 하나씩 제거해 mapper까지 실행. 빠진 필드 때문에 예외가 생긴 경우 0건
  • Beanie 질의와 원시 pymongo 질의의 필터 5종 결과 수 일치
  • 새 경로와 기존 paginate를 size 3종, page 3종, 필터 3종으로 비교. ID 순서와 total 모두 일치
  • page = max(1, int(page or 1)), size = max(1, min(int(size or 20), 200)) 클램핑 유지
  • 네 필터에서 전 페이지를 수집해 원본 집합과 일치하고 중복이 없는지 확인
  • 같은 created를 가진 59쌍이 _id 내림차순인지 확인

규모와 비용

  • 문서 500,000건에서 v0049 인덱스 재생성 1.2초
  • 네 조회 모두 0.5~0.7ms, 검사 문서 20건, blocking sort 없음
  • 관용 파싱 추가 비용은 20건 0.010ms, 100건 0.027ms, 200건 0.067ms
  • 오염 문서가 200건이어도 로그는 건수와 표본을 담은 1줄

관용 파싱 비용은 약 0.5ms인 DB 왕복보다 작았다.

migration과 적대 검증

깨끗한 DB, v0046→v0048→v0049, v0048을 건너뛴 DB, 세 번 재실행한 DB에서 최종 인덱스 정의가 같았다. 같은 정의로 이름만 다른 수동 인덱스가 있거나, 같은 이름으로 다른 정의가 있는 경우도 처리했다.

  • 랜덤 오염 300회: 예외 0, 정상 문서 손실 0, 반환된 문서는 모두 mapper 통과
  • 필드 10종 × 잘못된 값 8종, 80조합에서 정상 문서 유지
  • 변이 6종 검출: 잘못된 필드 유지, 예외 재발생, 재시도 1회, 상위 블록 삭제, 보조 정렬키 제거, 보조키 방향 반전
  • 실제 runner가 만든 회차 3건과 수동 명령 2건으로 목록·필터·스케줄별 조회 확인
  • 보존 정리가 회차를 삭제한 뒤에도 합집합 유지
  • schedule_id 필드가 없는 기존 문서 229건에서 수동 229건, 자동 0건, 20건씩 조회해 중복 0
  • 쓰기 2개와 읽기 3개를 2.5초 동안 동시에 실행해 비정상 응답 0

전체 테스트 2,402건, 깨끗한 worktree에서 2,394건, 별도 검사 7종의 단정 87개가 통과했다. 공통 파싱 코드를 사용하는 노드 목록 테스트 208건도 통과했다.

배포 확인

배포본에서 app/utils/tolerant_parse.py가 존재하고, 명령 목록이 parse_tolerant를 두 번 호출하며, 보조 정렬키를 사용하는 것을 확인했다. 노드 목록에 남은 중복 구현은 0곳이었다.

실제 v0049를 적용해 네 인덱스를 created 단독에서 created + _id로 바꾸는 데 0.47초가 걸렸다. 인덱스 수는 9개로 전과 같았고 부분 조건도 유지됐다.

조회적용 전적용 후
전체COLLSCAN + SORT, 229건 검사created_desc, 20건 검사
수동만COLLSCAN + SORT, 229건 검사created_desc, 20건 검사
자동만schedule_id_created + SORTcreated_desc_scheduled
특정 스케줄해당 없음schedule_id_created

코드의 복합 정렬과 인덱스를 같이 적용한 뒤 전량 스캔과 blocking sort가 사라졌다.

Orange Platform 명령 이력 조회의 오염 문서 처리와 안정적인 페이지네이션 분석 리포트입니다.