개요
명령 이력 목록 GET /v3/command/list에서 기존 결함 두 가지를 확인했다.
- 문서 한 건의 필드 타입이 다르면 해당 페이지 전체가 500 오류를 냈다.
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가 숫자 | 500 | 200, 해당 행만 제외 |
title이 숫자 | 500 | 200, title=null로 행 유지 |
target_node_count가 문자 | 500 | 200, 해당 필드만 제외 |
created가 문자 | 500 | 200, 해당 행만 제외 |
command_data가 문자 | 500 | 200, 해당 행만 제외 |
schedule_id가 ObjectId 또는 숫자 | 500 | 200, 해당 필드만 제외 |
run_seq가 실수 또는 객체 | 500 | 200, 해당 필드만 제외 |
아홉 가지 오류를 한꺼번에 넣어도 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_desccreated_desc_scheduledcreated_desc_manualschedule_id_created
같은 이름으로 정의만 바꿀 수 없어 하나씩 삭제하고 다시 만들었다. 한 시점에 없는 인덱스는 하나뿐이다.
수정 후 같은 60건을 7건씩 조회했다.
| 조회 | 수집 | 중복 |
|---|---|---|
| 전체 | 60/60 | 0 |
| 자동만 | 40/40 | 0 |
| 수동만 | 20/20 | 0 |
| 특정 스케줄 | 20/20 | 0 |
두 번 조회한 문서 순서도 같았다. 네 조회 모두 IXSCAN을 사용했고 blocking sort는 없었다.
조회 경로 변경 검증
조회 경로를 paginate에서 원시 조회와 문서별 파싱으로 바꿨기 때문에 기존 동작과 같은지를 중점적으로 확인했다.
_CommandProjection의 모든 필드를 하나씩 제거해 mapper까지 실행. 빠진 필드 때문에 예외가 생긴 경우 0건- Beanie 질의와 원시 pymongo 질의의 필터 5종 결과 수 일치
- 새 경로와 기존
paginate를size3종,page3종, 필터 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 + SORT | created_desc_scheduled |
| 특정 스케줄 | 해당 없음 | schedule_id_created |
코드의 복합 정렬과 인덱스를 같이 적용한 뒤 전량 스캔과 blocking sort가 사라졌다.
Orange Platform 명령 이력 조회의 오염 문서 처리와 안정적인 페이지네이션 분석 리포트입니다.