fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • langgraph 페이지로 이동
    • 사람용 CLI와 AI 에이전트용 CLI는 설계가 다르다
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code 메모리: CLAUDE.md와 .claude/rules를 규칙으로 쓰는 법
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • Docling — IBM Research 의 문서 파싱 toolkit 상세 정리
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
    • OpenClaw는 context와 memory를 어떻게 관리하나 — 나만의 에이전트를 구성하는 법
    • OpenClaw vs Hermes Agent — 갈아탈까 고민하며 정리한 비교
    • 스킬 문서를 신경망처럼 학습시킨다 — Microsoft SkillOpt 분석
  • ai 페이지로 이동
    • agent 페이지로 이동
    • [초안] AI 제품 백엔드 안정성 — 지연·비용·권한·관측·도구 실패·폴백/재시도/사람 에스컬레이션
    • [초안] LLM 평가 프레임워크: 골든셋, 회귀 테스트, LLM-as-a-judge, 사람 피드백 루프
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • apartment 페이지로 이동
    • 구리 럭키아파트 24평 인테리어 레퍼런스 모음
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 도메인 모델링: 주문·재고·노출의 세 축을 분리해서 설계하기
    • 커머스 주문 상태와 데이터 정합성 기본기
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] Event Sourcing과 CQRS — 상태가 아니라 변화를 저장한다는 발상
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리
    • [초안] F&B 주문/매장/픽업 상태머신 설계
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 대규모 회원과 메가 프로모션을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis [Cache-Aside](../database/redis/cache-aside.md) × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — 디지털 채널 백엔드 관점
    • [초안] Spring Batch vs Event-Driven — 같은 비동기처럼 보이지만 전혀 다른 두 패러다임
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • [초안] DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 한국어 형태소 분석기 Nori vs Lindera — OpenSearch에서 Milvus로 갈 때 어휘 검색은 유지되나
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 5종을 실제로 벤치마크했다 — 같은 recall에서 QPS는 얼마나 갈리나
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • [초안] 운영 데이터 정합성 장애 대응 — 결제 취소 누락과 중복 적재 런북
    • Envoy Proxy
    • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    • Graceful Shutdown
    • [초안] 시니어 백엔드를 위한 SLO와 Error Budget 기반 장애 대응
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS는 어떻게 안전한가 — TLS, 인증서, 그리고 termination
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
    • [초안] Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • mlops 페이지로 이동
    • llm-serving 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • 한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPS
  • network 페이지로 이동
    • Connection reset by peer는 누가 보낸 걸까 — 리버스 프록시 홉마다 TCP 연결은 따로 논다
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ThreadLocal 에서 contextvars 로 — Python 의 요청 컨텍스트 전파
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS 가 안 줄어드는 이유 — gc.collect 의 한계와 malloc_trim
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
    • [초안] Spring Security 6.x OAuth2 + JWT 상용 인증 설계 — Grant 선택, Resource Server, Refresh Rotation, 로그아웃
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/task/문서 파싱 서비스 — 코드 구조 정리와 파싱…
system

문서 파싱 서비스 — 코드 구조 정리와 파싱 품질 회귀 검증

진행 기간: 2026.05 \ 2026.07 모놀리식 파서 서비스 하나를 두 달 동안 도메인별로 쪼개고, 그 과정에서 파싱 결과가 안 깨졌는지 확인할 방법을 처음부터 다시 설계한 기록이다. 문서 파싱 서비스(Playground의 OCR 기반 문서→markdown 변환 API)는 PDF·이미지·오피스 문서를 마크다운으로 변환하는 FastAPI 서비스다. 이...

2026.07.13·7 min read·0 views·SERIES · AI 서비스 실전 구축·운영 · 6/8

진행 기간: 2026.05 ~ 2026.07

모놀리식 파서 서비스 하나를 두 달 동안 도메인별로 쪼개고, 그 과정에서 파싱 결과가 안 깨졌는지 확인할 방법을 처음부터 다시 설계한 기록이다.

문서 파싱 서비스(Playground의 OCR 기반 문서→markdown 변환 API)는 PDF·이미지·오피스 문서를 마크다운으로 변환하는 FastAPI 서비스다. 이 기간 동안 이 서비스를 사실상 혼자 만졌다. 코드를 도메인별로 쪼개는 일과, 쪼갠 뒤에도 파싱 결과가 그대로인지 확인하는 일 두 가지를 했는데, 결국 더 오래 걸리고 더 많이 배운 쪽은 후자였다. 검증 인프라를 한 번 설계했다가 인프라 제약으로 통째로 버리고 다시 설계한 경험이 이 글의 핵심이다.

진입점부터 도메인별로 쪼갰다

원래 API 엔드포인트가 전부 app.py 한 파일에 몰려 있었다. 신규 기능을 추가할 때마다, 리뷰할 때마다, 테스트를 짤 때마다 이 한 파일 안에서 서로 다른 관심사가 충돌했다. 파싱 로직을 보다가 헬스체크 코드를 지나쳐야 하고, PR 하나가 관리자 API와 파싱 API를 동시에 건드리는 일이 흔했다.

파싱, 헬스체크, 상태 조회, 관리자, 개발용 다섯 개 도메인으로 나눠 src/ 밑에 각각 라우터를 만들었다.

분리 자체보다 이행 과정이 까다로웠다. 백그라운드 스레드가 모듈 전역 변수를 직접 참조하는 코드가 있어서 from app import xxx 형태의 import를 from src.core.state import xxx로 바꿔야 했는데, 이 경로 하나가 어긋나면 런타임에만 터진다. 그래서 각 phase가 끝날 때마다 import가 정상인지 전수로 확인하고, /health가 200을 반환하는지, 그 도메인의 curl 호출이 실제로 동작하는지까지 확인한 뒤에만 다음 phase로 넘어갔다. app.py와 src/가 공존하는 중간 상태를 여러 PR에 걸쳐 점진적으로 안정화하는 방식이었고, 분리 전후로 동작이 달라진 적은 없었다.

1939줄 파일 하나를 지웠다

라우터를 나누고 나서도 document_parser.py라는 파일 하나가 그대로 남아 있었다. 35개 함수가 한 파일에 몰려 1939줄이었다. 이 파일을 손대는 PR은 거의 항상 다른 사람이 작업 중인 부분과 겹쳤고, 리뷰어 입장에서도 이 함수가 다른 함수와 어떤 관계인지 파악하기 어려웠다.

한 번에 옮기지 않고 책임 단위로 7단계에 나눠 이동했다.

phase내용
1src/converters/factory.py 신설 — 컨버터 팩토리 함수 4개 + 캐시 dict 3개
2src/converters/warmup.py 신설 — 워밍업 함수 5개 + 설정값
3src/converters/timings.py 신설 — 파이프라인 타이밍 로그
4src/core/measurements.py — GPU 측정·정리 함수 5개
5src/core/worker_pool/init.py — 워커 프로세스 초기화
6src/ocr, src/parse에 OCR·이미지·PDF 청크 관련 함수 13개 분산
7남은 로더 함수를 src/parse/loader.py에 흡수 + document_parser.py 완전 삭제

마지막 7단계에서 파일을 지우고 나니 여기저기서 더는 쓰이지 않는 import가 38건 남았다. 이건 손으로 하나씩 찾지 않고 ruff --select F401로 일괄 정리했다. 리팩토링을 반복하다 보니 미사용 변수, except:로 다 잡아버리는 예외 처리 같은 문제가 리뷰에서만 걸러지던 것도 있어서, 이 김에 ruff의 unused import/variable(F)와 bare except(E722) 규칙까지 pre-commit·PR CI에 걸어 커밋 단계에서부터 자동 차단되게 했다.

리팩토링 뒤에 테스트가 없었다

책임을 나누는 작업을 하면서 계속 불안했던 이유는 단순했다. 이 프로젝트에 기존 단위 테스트가 7건뿐이었다. 함수를 이 파일에서 저 파일로 옮기면서 그 함수가 원래 하던 일을 그대로 하는지 확인할 안전망이 사실상 없었다.

그래서 리팩토링 도중에 테스트부터 두 계층으로 크게 얹었다.

  • Tier 1 — 순수 함수 84건: 파일 헬퍼, PDF 청크 분할, 이미지 유틸, Excel 파서, OCR 변환 함수, 마크다운 생성기, 타이밍 로그처럼 입력과 출력만 있는 함수들
  • Tier 2 — mock 기반 44건: 워커 풀, 컨버터 팩토리, 모니터링, 파싱 로더, OCR 호출, FastAPI lifespan처럼 외부 자원(GPU, 프로세스, 네트워크)에 의존하는 코드는 monkeypatch로 그 의존성만 갈아 끼워서 검증

기존 7건까지 합쳐 135건이 전부 통과하는 걸 확인한 뒤에야 다음 리팩토링 phase로 넘어갔다. 그 뒤로도 hwp 로더 교체, xlsx 스트리밍, 표 파서 3계층 분할 같은 리팩토링마다 테스트를 얹었고, 지금 tests/unit을 실행하면 241건이 돈다.

돌아보면 순서가 중요했다. 1939줄을 먼저 쪼개고 테스트를 나중에 붙였다면, 쪼개는 도중에 뭔가 깨져도 알아챌 방법이 없었을 것이다. 테스트를 먼저 깔고 리팩토링해야, 리팩토링 자체가 "테스트가 계속 통과하는 상태를 유지하며 코드를 옮기는 작업"으로 바뀐다.

회귀 검증과 품질 평가는 다른 질문이다

단위 테스트로 함수 단위 로직은 잡을 수 있어도, 이 서비스의 핵심 산출물인 "파싱된 문서 내용"이 맞는지는 다른 문제다. 여기서 두 축을 구분해야 한다는 걸 깨달았다.

  • 회귀 검증: 코드를 바꾸기 전과 후에 같은 문서를 넣었을 때 출력이 그대로인가
  • 품질 평가: 지금 출력이 정답에 얼마나 가까운가

전자는 "달라졌는가"만 보고, 후자는 "얼마나 정확한가"를 본다. 리팩토링처럼 동작이 그대로여야 하는 변경은 회귀 검증으로 충분하지만, OCR 모델을 바꾸거나 표 파서를 개선하는 변경은 회귀 검증만으로는 좋아졌는지 나빠졌는지 알 수 없다 — 이전과 다르다는 것만 알 뿐이다. 이 차이를 구분하지 못하면 "출력이 바뀌었다"는 사실과 "출력이 나아졌다"는 판단을 섞어 버리게 된다.

품질 평가를 위해 문서 17건에 정답지(golden)를 만들었다. 핵심 문서 13건에서 시작해 일본어 문서(ja_doc_001), 병합 셀이 있는 복잡한 표(ko_table_complex_001), 영문 스캔본(en_scan_001), 수식이 포함된 영문 문서(en_formula_001)를 신규 4건 더 추가했다. 채점은 두 지표를 같이 본다.

  • 문자열 유사도(NED) — difflib.SequenceMatcher.ratio()로 정답지와 파싱 결과 전체 텍스트를 비교한 값. 1.0이면 동일, 0.0이면 완전히 다름
  • 표 셀 정확도(F1) — 표를 (블록 인덱스, 행 인덱스, 열 인덱스, 셀 텍스트) 튜플 집합으로 바꾼 뒤, 정답지와 파싱 결과 사이 일치하는 셀의 정밀도·재현율을 F1로 계산

830페이지짜리 국내 장문서 같은 표본은 golden 채점에는 넣지 않았다 — 정답지를 사람이 만들기 비현실적인 분량이라, 이런 표본은 뒤에서 다룰 benchmark 전용 표본으로 따로 뒀다. 채점 로직 자체도 단위 테스트 15건으로 검증해 뒀다.

검증 자동화를 한 번 설계했다가 통째로 버렸다

가장 크게 삽질한 부분이 여기다. 처음에는 회귀 검증을 CI에서 자동으로 돌리려고 했다. 설계는 이랬다.

  • CI 자동층: 외부 사내 OCR을 호출하는 대신, 미리 캡처해 둔 OCR 응답 값을 저장해 두고 그대로 재사용(mock)해서 PR마다 in-process로 파싱을 돌린다
  • 운영층: 실제 GPU 인스턴스에서 실제 OCR로 파싱한 결과를 태그별로 캡처해 누적 비교한다(지금의 test-flow)

문제는 CI 자동층이 ephemeral self-hosted runner(사내 GPU 인스턴스를 매번 발급하고 실행한 뒤 삭제하는 방식)를 전제로 설계됐다는 점이었다. 실제로 붙여보니 사내 게이트웨이가 ssh ProxyJump를 막아 새로 발급한 인스턴스에 접근할 경로가 없었고, 신규 인스턴스는 자산 인벤토리에 등록도 안 돼 있어 접근권한 신청 자체가 매번 막혔다. 그리고 이 설계가 의존하던 OCR 응답 저장소(fixtures/ocr_responses)와 CI 전용 baseline(baselines/ci)은 애초에 한 번도 만들어진 적이 없었다 — 즉 이 회귀 테스트는 로컬에서도 CI에서도 처음부터 전부 skip되고 있었다.

결국 회귀 검증을 운영층 하나로 단일화했다. 미릴리스 변경은 별도로 빌드한 이미지를 검증용 GPU 인스턴스에 띄워 머지 전에 격리 검증하고, tests/regression에는 그 인스턴스가 실제로 쓰는 코드만 남겼다 — 태그별 스냅샷을 캡처하는 capture_baseline.py, 두 태그를 정확 비교하는 compare_baseline.py, NED·표 셀 F1을 계산하는 metrics.py. 미사용으로 남아 있던 OCR mock, in-process 회귀 코드는 이 정리에서 함께 걷어냈다.

돌아보면 이 시행착오 자체가 소득이었다. 처음 설계할 때는 "외부 서비스는 mock으로 격리한다"는 원칙만 보고 ephemeral runner 쪽 인프라 제약은 고려하지 않았다. mock으로 외부 의존성을 끊는 게 정석이라는 것과, 그 mock을 CI에서 실제로 돌릴 수 있는 실행 환경이 있는지는 완전히 다른 질문이었다.

정지해 둔 GPU 인스턴스 하나로 돌린다

운영층 검증은 지금 이렇게 동작한다. 검증용 T4 GPU 인스턴스를 하나 두고, 평소에는 정지(SHUTOFF)해 뒀다가 검증할 때만 시작(start)한다. 시작한 인스턴스에 검증 대상 이미지를 NCR에서 받아 컨테이너로 띄우고, 로컬에서 그 인스턴스의 API로 회귀·품질(golden)·benchmark·정성 평가까지 전부 돌린 뒤 다시 정지한다.

당초에는 이것도 매번 새 인스턴스를 발급했다 삭제하는 일회성 러너로 설계하려고 했다. 그런데 T4 GPU 인스턴스를 새로 발급할 때마다 서버 접근권한 신청 기안을 다시 올려야 한다는 게 걸림돌이었다 — 검증 한 번 하자고 매번 기안 결재를 기다릴 수는 없었다. 그래서 인스턴스는 최초 1회만 발급해 접근권한을 확보해 두고, 이후로는 정지·시작만 반복해 재사용하는 쪽으로 방향을 바꿨다. 이미지만 그때그때 검증 대상으로 바꿔 띄우기 때문에 기안을 다시 올릴 일이 없다.

같은 인스턴스가 회귀 검증, golden 품질 채점, 벤치마크, 그리고 메모리 민감한 변경에 대한 동시 부하 테스트까지 전부 처리한다. 운영 트래픽을 받는 인스턴스는 여기서 전혀 건드리지 않는다 — 격리된 인스턴스에서만 검증하고, 검증이 끝나면 정지해 비용을 아낀다.

지금 보면

이 두 달의 작업을 관통하는 하나의 배움은, 검증 인프라를 설계할 때 "무엇을 검증할 것인가"만큼 "이 검증을 실제로 어떤 환경에서 돌릴 수 있는가"를 먼저 확인해야 한다는 것이다. ephemeral runner를 전제로 CI 자동층을 설계했을 때는 grand design이 그럴듯해 보였지만, 사내 네트워크 정책과 자산 관리 프로세스라는 전제를 검증하지 않고 그 위에 몇 주치 설계를 쌓았다. 반대로 test-flow는 처음부터 "이 회사에서 실제로 실행 가능한 절차인가"를 먼저 확인하고 그 제약 위에서 최소한의 자동화(nssh 비대화형 실행, 인스턴스 재사용)를 얹었다.

회귀(전후 동일한가)와 품질(정답 대비 얼마나 정확한가)을 하나의 지표로 뭉뚱그리지 않고 처음부터 다른 축으로 분리해 둔 것도 지금 보면 잘한 선택이다. 표 파서를 바꾸는 변경은 회귀 검증만 통과해서는 의미가 없고, 반대로 리팩토링은 품질 점수가 조금이라도 흔들리면 그 자체가 버그다. 두 질문에 하나의 답만 준비했다면 둘 중 하나는 계속 잘못된 판단을 내렸을 것이다.

OCR이 파싱 파이프라인에서 어떤 위치를 차지하는지는 OCR 동작 원리에 따로 정리해 뒀다.

on this page
  • 01진입점부터 도메인별로 쪼갰다
  • 021939줄 파일 하나를 지웠다
  • 03리팩토링 뒤에 테스트가 없었다
  • 04회귀 검증과 품질 평가는 다른 질문이다
  • 05검증 자동화를 한 번 설계했다가 통째로 버렸다
  • 06정지해 둔 GPU 인스턴스 하나로 돌린다
  • 07지금 보면
tags
📚 AI 서비스 실전 구축·운영
← PREVIOUS문서 파싱 서비스, 리소스가 새는 자리를 하나씩 틀어막은 기록NEXT →Python 서버 RSS 가 안 줄어들어 malloc_trim 을 박은 이야기

이런 글도

  • 문서 파싱 서비스 성능 최적화 — OCR 병렬화부터 GPU 직렬 추론 실측까지
    진행 기간: 2026.05 2026.07 문서 파싱 서비스(Playground 문서 파싱 파이프라인 — 기여 개요에서 전체 맥락을 다뤘다)의 처리 속도를 여섯 차례에 걸쳐 손봤다. OCR 호출 구조를 병렬로 바꾸고, 안 쓰는 렌더링을 지우고, PPTX 이미지 처리 방식을 통째로 갈아엎었다. 그리고 마지막 최적화에서는 로컬 벤치에서 30% 빨라진 것을 운영...
    📁 system
    system
    2026.07.13
  • 문서 파싱 서비스 관측성 체계 구축 — 초기화 순서 함정과 지표 단일화
    진행 기간: 2026.05 2026.07 문서 파싱 서비스(Playground의 OCR 기반 문서→markdown 변환 API)를 운영하면서 관측성(observability) 체계를 처음부터 만들었다. 다음 다섯 가지를 다뤘다. - /metrics 엔드포인트 신규 도입 - 운영 배포 직후 터진 메트릭 누락 사고 대응 - 무중단 컨테이너 교체를 위한 drai...
    📁 system
    system
    2026.07.13
  • 문서 파싱 서비스, 리소스가 새는 자리를 하나씩 틀어막은 기록
    진행 기간: 2026.05 2026.07 > 기여 전체 개요는 Playground 문서 파싱 파이프라인 — 기여 개요 참고. 본 글은 그중 메모리·프로세스·이미지 크기 안정화만 모은 기록이다. 문서 파싱 서비스를 운영하면서 겪은 리소스 문제 다섯 건을 정리한다. 다룬 문제는 다음과 같다. - 워커 강제 종료 - 대용량 엑셀 메모리 - 컨테이너 좀비 프로세스...
    📁 system
    system
    2026.07.13
  • Playground 문서 파싱 파이프라인 — 기여 개요
    진행 기간: 2026.05 (진행 중) AI 서비스 개발팀의 사내 LLM 워크플로 제품 Playground는 다양한 형식의 문서를 입력으로 받는다. 이 입력을 LLM이 다룰 수 있는 markdown으로 정규화하는 문서 파싱 서비스를 맡아 운영·개선했다. OCR 제품을 만드는 게 아니라, OCR을 활용해 문서를 markdown으로 변환하는 서비스다. - 스택...
    📁 system
    system
    2026.06.17

댓글 (0)