2026-08

측정을 믿으려면 측정 환경부터 배포와 같아야 했다 — 단일 코어와 콜드 풀 재현

앞 포스트에서 측정이 다음 수정의 우선순위를 정해줬지만, 그 우선순위를 믿으려면 측정 환경부터 배포와 같아야 했다. 로컬 부하 스택은 CPU를 덜 조이고 S3 콜드 경로를 아예 안 밟고 있었다. 단일 코어 고정(시나리오 A)과 MinIO 콜드풀 재현을 dep 단위로 하나씩 켜서, H1을 더 선명하게 재확인하고 단일스레드/GIL 가정을 처리량 수치로 갈라 확인했다.

이어서 — 측정을 믿으려면 측정 환경부터 배포와 같아야 한다

앞 포스트에서 측정은 다음 수정의 우선순위를 정해줬다 — handle_ir을 to_thread로, 캐시 상향, nginx 버퍼, gzip, 그다음에야 빌드 프로파일. 그런데 그 순서를 믿으려면 먼저 물어야 했다. 내 부하 스택은 프로덕션과 같은 제약 아래 있는가? 아니었다. 두 곳이 조용히 어긋나 있었다.

  • CPU를 덜 조였다. 배포 대상은 2 vCPU를 이웃 3스택과 공유하는데, 로컬은 api에 2코어를 통째로 줬다.
  • 콜드 경로를 안 밟았다. 프로덕션은 문서를 S3에서 처음 당길 때 이벤트 루프 위에서 동기로 내려받는데(ensure_local), 로컬은 S3가 없어 이 분기를 아예 실행하지 않았다.

측정 환경이 조용히 다르면 거기서 나온 숫자는 아무것도 설명하지 못한다. 그래서 dep을 하나씩 켜서 각 효과만 격리해 재기로 했다 — 한꺼번에 켜면 어느 dep이 숫자를 움직였는지 못 가른다.

시나리오 A — 한 코어에 못박다

단순히 cpus: 1.0 쿼터가 아니라 cpuset: "0"으로 물리적 한 코어에 고정했다. 이유는 이 워크로드의 물리에 있다. 요청 경로는 단일 스레드 asyncio이고 IR 빌드가 GIL을 잡으므로, 빌드가 도는 동안 어차피 한 코어만 쓴다. 조여야 하는 건 코어 수가 아니라 **"빌드가 도는 동안 큐를 비울 여분 코어가 있느냐"**다. 부하 드라이버는 cpuset: "1-3"으로 밀어 자기 자신을 재지 않게 했다.

결과가 예측을 정확히 갈랐다.

동시성2코어 baseline p95A(1코어) p95rps 2코어 → A
11,0591,1111.8 → 1.6
108,0488,2213.7 → 3.8
10041,68843,6827.3 → 5.2

지연은 거의 안 변했다 — 두 번째 코어가 GIL 빌드를 못 도우니 예측대로다. 변한 건 처리량: 동시성 100에서 7.3 → 5.2 rps. 여분 코어가 하던 일(nginx·루프 스케줄·이웃 컨테이너)이 사라진 몫이다. "코어 수는 요청 경로에 영향이 작다"던 한 문장짜리 가정이 지연에선 참, 처리량에선 거짓으로 쪼개졌다.

MinIO — 루프를 막는 코드 경로 하나를 되살리다

로컬 스택엔 오브젝트 스토어가 없어, 프로덕션에서 루프를 잡는 콜드 분기(동기 S3 GET + 원자적 디스크 쓰기)가 한 번도 실행되지 않았다. "무엇이 루프를 막나"를 재는 실험이 정작 그 경로를 안 밟고 있던 셈이다. MinIO를 붙여 되살렸다.

  • 프로덕션 코드에 HWPX_S3_ENDPOINT_URL 노브를 심었다(MinIO는 버킷별 DNS가 없어 path-style 주소가 필수라 addressing까지 전환).
  • 시더는 같은 slugify/document_key를 재사용한다 — 한글 슬러그(1. 연구개발계획서 양식_서울지방청)를 셸로 추측하면 키가 어긋나 콜드 풀이 안 일어난다.
  • api를 빈 문서 루트로 띄워 각 슬러그의 최초 접촉이 버킷 풀이 되게 했다. 갓 배포된 컨테이너와 같은 상태.

6개 문서 전부 pulled … from object storage. 콜드 풀은 locate 단계에 얹혔다.

문서크기콜드 풀 locate_msvs 캐시히트
사업계획서 템플릿 (15MB)15MB19–20~0.3
작은 문서들<100KB1.7–3.3~0.3

정직한 경계: localhost MinIO라 15MB도 ~20ms에 그친다. 이 오버레이가 복원한 건 경로의 형태(코드 분기·루프 블로킹·원자적 쓰기·목록이 무는 버킷 LIST)이지 지연 크기가 아니다 — 그건 S3-over-인터넷의 몫이고 배율로 환산한다. 충실하지 않은 추가는 자기만의 왜곡을 넣는다는 걸, 이 20ms가 스스로 증명한다.

같은 자리에서 H1이 더 크게 나왔다

단일 코어에서 H1(이벤트 루프 블로킹)은 더 선명했다.

  • /api/health(아무 일도 안 함): 서버 0.3ms ↔ nginx가 본 p95 60초.
  • loop_lag 35회, 최대 28.8초, 총 블로킹 ≈ 70초 — 동시성 100 구간 내내 루프가 막혔다.
  • nginx 전송(rt − urt) p95 = 1ms → 병목은 여전히 페이로드가 아니다.

단계 귀속도 그대로 재현됐다: miss의 93%가 build(802ms), hit의 89%가 json(20ms). 목록은 자기 일(manifest 4.3s + corpus 5.2s)이 느린 게 아니라 루프 차례를 못 받아 느리다. 앞 포스트가 코드를 읽고 지목했던 그림이, 이번엔 프로덕션 제약을 재현한 스택 위에서 수치로 확인됐다.

배운 것

  • 측정 환경의 충실도도 측정 대상이다. 숫자를 믿기 전에 "이 스택은 프로덕션과 어디가 다른가"를 표로 적어야 한다. 다른 점을 모르면 배율조차 매길 수 없다.
  • dep은 하나씩 켠다. CPU와 S3를 같이 바꿨으면 어느 쪽이 숫자를 움직였는지 못 갈랐다. A와 MinIO를 분리해 각 효과를 격리했다.
  • 형태와 크기를 나눠 말한다. MinIO는 콜드 경로의 형태를 되살렸지 크기를 준 게 아니다. 이 구분을 흐리면 "재현했다"가 곧 거짓이 된다.
  • 가정은 갈라질 수 있다. "코어 수는 영향이 작다"가 지연에선 맞고 처리량에선 틀렸다. 한 문장으로 뭉친 가정을 지표가 둘로 쪼갰다.

이건 앞 포스트가 남긴 우선순위 목록(첫째: handle_ir을 to_thread로)을 손대기 전에 세운 계측 기반이다. 이제 그 수정을 넣고 같은 사다리를 다시 돌리면, 개선은 추측이 아니라 이 표와의 차이로 이야기된다.