Dev Dashboard에 찍힌 평균 처리시간을 보고 한숨이 나왔다. 100.1초. 문서 하나 처리하는 데 1분 40초. 사용자가 업로드하고 커피 한 잔 내리고 올 때쯤 겨우 끝나는 속도다.
그런데 막상 파이프라인 코드를 열어보면, 각 단계가 특별히 무거워 보이지는 않는다. PDF 분류 2초, 문서 유형 분류 5초, Gemini API 10초, bbox 스냅핑 15초... 개별로 보면 괜찮은데, 다 더하면 100초가 된다. 성능 문제의 전형적인 패턴 — 각자는 무죄인데 합치면 유죄.
범인은 대기 시간이었다
파이프라인을 뜯어보니 모든 게 한 줄로 서 있었다. 4페이지짜리 PDF가 들어오면:
[페이지1 Gemini 호출 10초] → [페이지2 Gemini 호출 10초] → [페이지3 10초] → [페이지4 10초]
40초를 꼬박 기다린다. Gemini API 호출은 네트워크 I/O다. 요청을 보내고 응답이 올 때까지 CPU는 아무것도 안 한다. 그냥 기다린다. 4번을.
"이걸 동시에 보내면 되잖아?"
맞다. ThreadPoolExecutor로 4페이지를 동시에 날리면 40초가 ~10초로 줄어든다. CPU나 메모리 부담? 거의 없다. 네트워크 응답을 기다리는 시간이 겹치는 것뿐이니까.
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {pool.submit(process_page, pn): pn for pn in pages}
for fut in as_completed(futures):
page_results[fut.result()[0]] = fut.result()[1]
단, 품목 번호 재정렬(품목_1, 품목_2, ...)은 페이지 순서에 의존하므로 결과를 모은 뒤 순차적으로 처리한다. 병렬화할 수 있는 것과 없는 것을 구분하는 게 핵심이다.
같은 OCR을 두 번 돌리고 있었다
파이프라인에는 PaddleOCR을 두 번 실행하는 구간이 숨어 있었다.
한 번은 텍스트를 추출하기 위해, 또 한 번은 bbox(바운딩 박스)를 스냅핑하기 위해. 그런데 gemini_direct 모드에서는 텍스트 추출을 Gemini가 하므로, 첫 번째 OCR 실행이 필요 없다. 그러면서도 bbox 스냅핑 단계에서 PaddleOCR을 또 돌린다.
해결은 간단했다. OCR 결과를 미리 계산해서 함수에 넘기는 것.
# 변경 전: _run_gemini_direct 내부에서 매번 OCR 실행
ocr_regions = _detect_text_regions(image_bytes, lang=lang)
# 변경 후: 외부에서 미리 계산하여 전달
def _run_gemini_direct(..., ocr_regions=None):
_regions = ocr_regions if ocr_regions is not None else _detect_text_regions(...)
파라미터 하나 추가한 것뿐인데, 페이지당 10~15초의 중복 연산이 사라졌다.
병목을 고치니 새 병목이 보였다
1차 최적화 결과, 평균이 139.9초에서 53.4초로 내려갔다. 62% 감소. 좋았다.
그런데 스테이지별 타이밍을 찍어보니 이상한 게 보였다. Gemini API는 310초밖에 안 걸리는데, 93%**를 잡아먹고 있었다.ocr_regions(PaddleOCR bbox 감지)가 전체 시간의 **70
invoice_ja_payment-slip-barcode.png
ocr_regions : 28.3s (86%)
gemini_extract: 3.7s (11%)
이전에는 Gemini의 긴 대기 시간에 가려져 보이지 않던 병목이, Gemini가 빨라지니까 수면 위로 올라온 거다. 최적화의 역설 — 하나를 고치면 다음 병목이 드러난다.
PaddleOCR에게 "대충 해"라고 말하기
bbox 스냅핑용 OCR은 텍스트의 정확한 위치만 알면 된다. 글자 하나하나를 완벽하게 인식할 필요가 없다. 그래서 두 가지를 바꿨다.
1. 경량 OCR 인스턴스 분리
기존 _get_ocr()는 품질을 위해 고해상도 + 회전 감지를 켜놓았다. bbox 스냅핑에는 과한 설정이다.
def _get_ocr_light(lang):
"""bbox snapping 전용 — 회전 감지 OFF, 해상도 960px"""
return PaddleOCR(
use_doc_orientation_classify=False, # 이미 전처리된 이미지
text_det_limit_side_len=960, # 기본값 (vs 1280)
)
2. 입력 이미지 다운스케일
2048px 이미지를 1024px로 줄여서 넘긴다. bbox 좌표는 0~1 normalized이므로 이미지 크기가 달라져도 좌표계에 영향이 없다.
이 두 조합으로 OCR 연산량이 ~30% 줄었다. 기존 OCR 품질에는 영향 없이.
숫자로 보는 결과
| 문서 | Before | After | 절감 |
|---|---|---|---|
| 청구서 (1p) | 116.1s | 23.2s | -80% |
| 보험증명서 (2p) | 414.7s | 116.2s | -72% |
| 계약서 (1p) | 54.0s | 29.4s | -46% |
| 보험공제증명 (1p) | 87.0s | 31.2s | -64% |
| 워터마크 문서 (1p) | 27.6s | 16.0s | -42% |
| 평균 | 139.9s | 43.2s | -69% |
측정하지 않으면 개선할 수 없다
이번에 가장 잘한 일은 스테이지별 타이밍 계측을 추가한 것이다.
_stage_durations["ocr_regions"] = int((time.monotonic() - _t_ocr) * 1000)
_stage_durations["gemini_extract"] = int((time.monotonic() - _t_gemini) * 1000)
이 몇 줄의 코드가 없었다면, "OCR이 전체의 93%를 차지한다"는 사실을 발견하지 못했을 것이다. Dev Dashboard에 바 차트로 시각화해두니, 다음에 어디를 개선해야 할지 한눈에 보인다.
배운 것들
1. I/O-bound는 공짜로 빨라진다. Gemini API 병렬화는 CPU나 메모리를 거의 안 쓰면서 처리 시간을 반으로 줄였다. 네트워크 대기를 겹치는 것뿐이니까.
2. 캐시는 거창할 필요 없다. 파라미터 하나 추가해서 이미 계산한 결과를 넘기는 것만으로도 충분하다.
3. 최적화는 재귀적이다. 하나를 고치면 다음 병목이 드러난다. 그래서 계측이 먼저다.
4. "대충 해도 되는 일"을 찾아라. bbox 스냅핑은 정밀한 OCR이 필요 없었다. 용도에 맞게 품질을 조절하면 큰 성능 이득을 얻는다.
5. 병렬화할 때 동시 접근을 주의하라. PDF 분류와 이미지 추출을 병렬로 돌렸더니 같은 파일 동시 접근으로 크래시가 났다. I/O 자원 공유는 항상 확인해야 한다.
100초를 43초로 만드는 데 거창한 인프라 변경은 없었다. ThreadPoolExecutor 몇 줄, 파라미터 하나, OCR 설정 두 개. 중요한 건 "어디가 느린지"를 정확히 아는 것이었다.