2026-08
문서 목록이 코퍼스 전체를 빌드하고 있었다 — 그리고 그 수정은 절반이었다
AI와 함께 짠 목록 API가 아무도 보지 않는 숫자를 위해 목록 요청마다 코퍼스 전체를 빌드하고 있었다. 명백한 실수를 덮은 뒤 멈추지 않고 계측을 붙여 측정하고 나서야, 그 수정이 절반이었음을 알았다 — 진짜 병목은 이벤트 루프를 막는 동기 IR 빌드였고, 측정만이 그 사실과 다음 수정의 우선순위를 알려줬다.
실수 — 목록이 아무도 안 보는 숫자를 위해 코퍼스 전체를 빌드했다
바이브코딩으로 문서 목록 API(GET /api/documents)를 짰습니다. 목록은 문서마다 섹션 수·블록 수·레이아웃 캐시 완성 여부·직렬화된 IR 크기를 실어 보냈습니다. 그 숫자를 만들려면 문서를 열어야 하니, 목록 한 번에 코퍼스의 모든 문서를 build_document_ir로 빌드했다는 뜻입니다.
"목록이니 각 문서를 요약해주자"는 자연스러운 본능이고, 코드는 그럴듯하게 돌아갔습니다. 문제는 세 겹으로 숨어 있었습니다.
- 캐시는 3칸, 코퍼스는 6개+. 4번째 문서가 첫 번째를 밀어내므로, 다음 목록 요청은 다시 전부 파싱합니다. 상한 있는 캐시에 무한한 일을 시킨 꼴입니다.
- 뷰어는 문서를 열기 전에 목록을 먼저 부릅니다. 그래서 이 비용이 "문서 하나 열기" — 사용자가 실제로 기다리는 그것 — 에도 그대로 붙었습니다.
- 그 숫자를 보는 사람이 없었습니다. 값비싼데 아무도 소비하지 않는 과설계였습니다.
덮은 커밋 — 목록은 stat만 한다
manifest()가 유일한 목록이 됐습니다. .hwpx를 열지 않고, sizeKb는 직렬화된 IR 길이가 아니라 파일 stat입니다. 버킷에만 있는 문서는 이름을 짓자고 내려받지 않고 null로 답합니다.
여기서 멈췄다면 "성능 버그를 고쳤다"고 적었을 겁니다. 그런데 "IR 빌드가 병목"이라는 문장은 코드를 읽은 추측이지 측정이 아니었습니다. 방금 고친 게 진짜 문제였는지, 아니면 더 큰 문제의 한 증상이었는지 알 방법이 없었습니다.
측정 — 판정할 계측기를 먼저 붙였다
새 의존성도 수집기도 없이(배포 대상은 4GB Lightsail 한 대를 세 스택과 공유합니다) 요청당 JSON 한 줄, 이벤트 루프 lag 감시자, Server-Timing 헤더, 프런트 upstream 계측을 붙이고, 배포를 재현한 로컬 스택에서 동시성 사다리를 돌렸습니다.
| 동시성 | 성공 | 실패 | rps | 클라이언트 total p95 |
|---|---|---|---|---|
| 1 | 27 | 0 | 1.8 | 1.06초 |
| 10 | 56 | 0 | 3.7 | 8.05초 |
| 100 | 110 | 0 | 7.3 | 41.7초 |
| 1,000 | 480 | 525 | 32.0 | 55.5초 |
처리량이 1.8 → 7.3 rps에서 멈춥니다 — 코어가 남아돌아도 늘지 않습니다. 그리고 결정적인 한 줄:
| route | 서버가 기록한 p95 | 클라이언트가 기다린 p95 |
|---|---|---|
/api/health (아무 일도 안 함) | 0.8ms | 60초 |
그 60초는 핸들러 안이 아니라 핸들러에 들어가기 전에 흘렀습니다.
진단 — 목록 수정은 호출자 하나를 없앴을 뿐이었다
CPU 바운드 동기 IR 빌드에는 await 지점이 없어, 빌드가 도는 동안 이벤트 루프가 통째로 막힙니다. 그래서 아무 일도 하지 않는 /api/health조차 남의 빌드 뒤에서 60초를 기다립니다(루프 lag 이벤트 62회, 최대 27초, 누적 84초).
목록을 고친 건 빌드의 호출자 하나를 없앤 것이지, 빌드 자체에 숨 쉴 틈을 준 게 아니었습니다. 캐시 스래싱도 그대로였습니다 — 288번의 IR 요청 중 65%가 miss(캐시 3칸, 코퍼스 6개).
측정은 덤으로 다음 수정의 우선순위까지 정해줬습니다.
handle_ir을asyncio.to_thread로 옮긴다 — 빌드가 빨라지진 않지만, 그동안 남의 요청이 처리된다- 캐시 상한을 올리거나 IR을 디스크에 캐시한다 — miss 65%는 640MB 안에서 푸는 문제다
- nginx
proxy_buffers를 3–4MB 응답에 맞춰 디스크 왕복을 없앤다 - gzip — 로컬에선 효과가 없었지만 실사용자는 인터넷 너머에 있다
- 그다음에야 빌드 자체를 프로파일한다
1–2는 처리량 문제, 5는 지연 문제입니다. 순서를 바꾸면 제일 어려운 쪽부터 붙잡게 됩니다.
배운 것
- 눈에 보이는 실수를 고치는 것과 문제를 고치는 것은 다르다. 목록 수정은 옳았지만 절반이었고, 그게 절반임을 알려준 건 오직 측정이었습니다.
- "병목"은 측정하기 전까진 가설이다. 코드를 읽고 지목한 병목은 맞을 수도 틀릴 수도 있습니다 — 여기선 맞았지만, 그 확신은
/api/health의0.8ms ↔ 60초가 준 것이지 코드 리딩이 준 게 아니었습니다. - 아무 일도 안 하는 경로가 가장 많은 걸 말한다.
/api/health는 큐잉 지연의 순수 측정기였습니다. 정상 경로만 봤다면 원인을 계속 빌드 안에서 찾았을 겁니다. - AI와 함께 짠 그럴듯한 코드일수록 "이 값을 누가 보는가"를 묻는다. 이 사고의 씨앗은 아무도 안 보는 숫자였습니다.
이 경험은 이 프로젝트의 첫 번째 교훈 — "됐다"를 판정할 방법을 먼저 만들고 나서 고친다 — 의 성능판 재현입니다. 대상이 레이아웃 정확도에서 응답 시간으로 바뀌었을 뿐, 계측기가 나온 뒤에야 그동안의 "고쳤다"가 절반이었음이 드러나는 구조는 똑같았습니다.