"왜 느리지?"라는 질문에는 보통 명쾌한 한 가지 답이 있을 거라 기대한다. 하지만 실무에서 성능 문제는 대부분 여러 곳에서 조금씩 새는 물처럼 찾아온다. 이번에 OCR 문서 처리 서비스를 전면 점검하면서 느낀 건, 성능 최적화의 핵심은 거창한 아키텍처 변경이 아니라 "당연히 되겠지"라고 넘긴 것들을 하나씩 의심하는 것이라는 점이다.

인덱스 하나면 될 걸, 왜 안 만들었을까

PostgreSQL에는 재미있는 특성이 있다. MySQL과 달리 외래 키(FK)를 걸어도 인덱스를 자동으로 만들지 않는다. 그래서 fields.document_id로 조인하는 모든 쿼리가 — 통계 조회, 리뷰 큐, 문서 삭제까지 — 풀 테이블 스캔을 하고 있었다.

-- 이 쿼리가 매번 fields 테이블 전체를 훑는다고?
SELECT count(*), avg(confidence)
FROM fields
WHERE document_id IN (SELECT id FROM documents WHERE tenant_id = ?);

인덱스 4개를 추가하는 Alembic 마이그레이션 하나로 해결했다. 코드 한 줄 안 바꾸고 쿼리 성능이 5~10배 개선된다. 교훈: "ORM이 알아서 해주겠지"는 위험한 가정이다.

같은 사진을 두 번 현상하는 사람

PDF 문서를 처리할 때, 파이프라인이 1페이지 이미지를 두 번 추출하고 있었다. 한 번은 "이게 영수증인지 계약서인지" 분류하려고, 또 한 번은 실제 OCR을 위해서. PDF를 이미지로 변환하는 데 약 500ms가 걸리는데, 같은 작업을 반복하고 있었던 거다.

비유하자면 이런 상황이다. 사진관에서 사진을 인화한 뒤 "이 사진 인물 사진이네요"라고 분류하고, 바로 옆에서 같은 사진을 다시 인화해서 보정 작업을 시작하는 것.

해결은 간단했다. 분류용으로 추출한 이미지를 캐시에 넣어두고 OCR 단계에서 재사용하는 것이다. 단, 주의할 점이 있었다. 분류는 원본 이미지로 하고 OCR은 전처리된 이미지로 하고 있었는데, 둘 다 전처리된 이미지를 써도 분류 품질에는 차이가 없다는 것을 확인한 뒤 통합했다.

브라우저는 기억력이 좋은데, 왜 매번 물어보게 하나

검수(Review) 화면에서 문서 이미지를 보여줄 때, 서버 응답 헤더에 Cache-Control: no-cache가 박혀 있었다. 검수자가 필드를 하나 승인할 때마다, 줌을 조절할 때마다, 같은 페이지 이미지를 서버에서 새로 받아오고 있었다. 이미지 한 장 생성하는 데 PDF 변환 + 전처리 + 크롭까지 1~2초가 걸리는데.

Cache-Control: private, max-age=86400으로 바꾸면 24시간 동안 브라우저가 캐시한다. 문제는 재처리(reprocess) 후에도 구 이미지를 보여줄 수 있다는 것. 이건 프론트엔드에서 ?v=2026-03-28T14:30처럼 타임스탬프를 쿼리에 붙여 해결했다. 재처리가 완료되면 타임스탬프가 바뀌니까 브라우저가 새 이미지를 받아온다.

여기서 public이 아니라 private을 쓴 이유가 있다. 이 API는 인증이 필요한 엔드포인트다. public으로 설정하면 프록시나 CDN이 다른 사용자의 문서 이미지를 캐시할 수 있다. 보안은 작은 디테일에서 갈린다.

매 렌더마다 배열을 12번 순회하는 리액트 컴포넌트

React로 만든 검수 화면에서 필드 목록을 보여주는 컴포넌트가 있었다. 상태별 개수(미검수/완료/거부)와 신뢰도별 개수(높음/중간/낮음)를 세기 위해 .filter()를 12번 호출하고 있었다. 100개 필드 기준으로 매 렌더마다 1,200회의 비교 연산. 필드 하나 승인할 때마다, 키보드로 이동할 때마다 전부 다시 계산한다.

// Before: O(12n) per render
const doneCount = fields.filter(f => f.status === "approved").length;
const rejectedCount = fields.filter(f => f.status === "rejected").length;
const highCount = fields.filter(f => f.confidence_level === "high").length;
// ... 9번 더

useMemo 하나로 단일 순회(single pass)에서 모든 카운트를 동시에 계산하도록 바꿨다. 의존성 배열에 fields만 넣으면 필드가 실제로 변할 때만 재계산한다.

// After: O(n), only when fields change
const { doneCount, highCount, ... } = useMemo(() => {
  let done = 0, high = 0;
  for (const f of fields) {
    if (f.status === "approved") done++;
    if (f.confidence_level === "high") high++;
  }
  return { doneCount: done, highCount: high, ... };
}, [fields]);

"달라진 거 없는데" 5초마다 서버에 노크하는 화면

문서 목록 화면(Home)이 5초마다 무조건 서버에 문서 목록을 요청하고 있었다. 처리 중인 문서가 하나도 없는데도. 사용자가 화면을 켜놓으면 시간당 720회의 불필요한 API 호출이 발생한다.

수정은 단순하다. processing 상태 문서가 있을 때만 폴링하고, 모두 완료되면 멈추는 것. useEffect의 의존성을 docs 배열 전체가 아닌 hasProcessing boolean으로 좁혀서, 상태 전환 시에만 interval을 생성/해제하도록 했다.

재처리할 때마다 "이 문서 뭔지" 다시 물어보는 AI

문서를 재처리하면 파이프라인이 처음부터 다시 돌아가는데, 이미 DB에 "이건 영수증이야"라고 저장해둔 분류 결과를 무시하고 Gemini API를 다시 호출하고 있었다. 한 번에 약 20초와 $0.02가 소요된다.

_get_document_doc_type(document_id) 헬퍼를 추가하고, 기존 분류가 있으면 스킵한다. 단, 1페이지 이미지 추출은 항상 수행해야 한다 — OCR 캐시 주입에 필요하니까. 분류만 건너뛰는 것이다.

다 쓴 물을 버리지 않는 습관

MinIO에서 파일을 다운로드하면 file_bytes에 전체 내용이 올라온다. 이걸 임시 파일에 쓴 뒤에도 del file_bytes를 안 하고 있었다. 이후 파이프라인이 1030초 동안 이미지 처리, OCR, LLM 호출을 하는 동안 수수십 MB가 RAM에 그냥 앉아 있는 거다.

한 줄이다.

del file_bytes  # 임시 파일에 기록 완료 — RAM 즉시 해제

나머지 세 가지

resize 이벤트 debounce — 브라우저 창 크기를 조절할 때 이벤트가 초당 수십 번 발생하고, 매번 React state를 업데이트하고 있었다. 200ms debounce와 동일 크기 체크(prev?.w === el.clientWidth)를 추가했다.

AuditLog 쿼리 통합 — 통계 화면에서 "총 리뷰 수"와 "수정 건수"를 별도 쿼리로 가져오고 있었다. SQL의 COUNT(CASE WHEN ... THEN 1 END) 패턴으로 한 번에 계산한다.

MinIO 커넥션 풀 — 기본값 10개에서 25개로 늘리고, retry 3회를 추가했다. 여러 문서를 동시에 처리할 때 커넥션 고갈을 방지한다.

결론: 성능 최적화는 의심에서 시작된다

10가지 개선 중 어느 하나도 대단한 알고리즘 변경이 아니었다. 인덱스 하나, del 한 줄, useMemo 하나, HTTP 헤더 한 줄. 하지만 합치면 체감 성능이 완전히 달라진다.

성능 최적화에서 가장 중요한 능력은 "이게 당연하다"고 느끼는 것들을 의심하는 것이다. ORM이 인덱스를 만들어줄 거라는 가정, 캐시가 알아서 될 거라는 믿음, filter를 여러 번 호출해도 괜찮을 거라는 판단 — 이런 것들이 쌓이면 "왜 느리지?"가 된다.

코드에 느린 부분이 있다면, 거기엔 항상 이유가 있다. 그리고 그 이유는 대부분 생각보다 단순하다.