작동하는 기능과 믿을 수 있는 제품 사이 — ToC 인식 개발기
들어가며
모바일 앱을 만들다 보면 가장 큰 저항은 결국 입력이다. 특히 콘텐츠 소비가 주목적인 앱이 아닌 기능성 앱이라면, 카테고리를 막론하고 유저의 입력 마찰이 이탈의 첫 번째 원인이 된다. 가계부 앱이라면 소비 내역 입력, 생산성 앱이라면 시간 설정과 할 일 추가 — 전부 입력이다. AI 시대가 되어 모델 성능이 대폭 올라갔지만, 유저 맞춤 데이터는 결국 유저가 입력해야 한다.
하지만 B2C 모바일 앱은 그런 유저 데이터를 알 리 만무하고, 우리가 만드는 입장에서 구글이나 메타 같은 개인 데이터를 보유할 수도 없다. 결국 AI 시대에도 모바일 앱은 UX에서 edge를 가져야 하며, 유저 맞춤 데이터를 가장 적은 마찰로 정확하게 수집하는 문제는 아직 풀리지 않았다.
목차(ToC)를 직접 입력(타이핑)하게 하는 순간 이 기능은 실패한다
이 문제를 풀려고 한 건 5년 전이다. 현재 재개발 중인 서비스의 두 번째 버전이었다. (지금은 세 번째 버전이다.) 유저에게 맞춤형 계획을 제공하기 위해, 유저가 보려고 하는 책 또는 교재의 목차 데이터(이하 ToC)가 필요했다. 페이지 등의 분량으로도 비슷하게 기능을 제공하고 있었지만, 고도화를 위해서는 책의 ToC가 필요했다. 상식적으로 생각해도 책의 목차를 직접 입력하게 하는 행위는 — 심지어 모바일에서 — 바로 유저 이탈로 이어질 게 뻔하다.
사전 ToC 수집 (feat. QWEN)
제일 먼저 생각한 건 사전 ToC DB 수집이었다. 이번 글에서 다룰 작업 이전에 많은 시간을 들여 작업을 했는데, 국내 도서 사이트와 달리 해외, 특히 영미권 도서 사이트(우리 앱의 현재 1차 타겟은 영미권이다)는 ToC를 애초에 제공하지 않는 곳이 대부분이다. (물론 국내 도서 API도 ToC를 제공해주는 API는 없다.) 또한 특정 출판사에 종속되어 있는 게 아니기 때문에, 일반적인 수집기를 API 기반으로 쉽게 만드는 방법은 존재하지 않았다.
두 번째 카드로 꺼낸 게 AI였다. 그 AI가 이 글의 주인공이 될 Qwen 3.5 Flash였다. 그 모델을 기반으로 광범위한 ToC 수집 파이프라인을 만들었다. (모델 소개는 뒤에서 다시 한다.)
qwen.ai에서 몇 개 기준이 되는 책을 검색해보고, 생각보다 수집이 잘 되는 것을 발견하고 직접 LLM API를 기반으로 개발을 진행했다. 처음엔 OpenRouter를 사용했다. 가격도 비슷하고 다른 모델 호환성도 있다 보니 진행을 했는데, OpenRouter에서 제공하는 Qwen 모델은 말 그대로 바닐라 모델이고 그 외의 툴체인 옵션들이 제공되지 않았다. (바닐라 모델 결과는 처참했다.) 결국 AlibabaCloud로 옮겨 DashScope으로 다시 API를 연결했다. DashScope에는 Qwen 모델의 web_search와 web_extract 툴체인이 모두 갖춰져 있었고, 해당 기능을 기준으로 꽤 정확하게 데이터를 수집하는 파이프라인을 만들 수 있었다.
ISBN13을 입력하면 관련 메타데이터와 ToC를 자동으로 수집하는 파이프라인을 만들었다. 테스트 케이스에 있던 7권짜리 묶음 책 같은 경우에는 ToC를 모두 가져오면 한 번에 가져올 수 있는 토큰이 초과하기 때문에, 큰 기준으로 챕터를 수집하고 이걸 세부 챕터 수집으로 한 번 더 진행하는 방식으로 처리했다. 테스트 케이스가 많지는 않았지만, 다양한 분야의 책에서 대량의 ToC 데이터를 정확하게 수집하는 파이프라인이 동작하기 시작했다.
ISBN13을 입력하면 depth가 포함된 ToC를 JSON으로 뽑아내는 수집기를 만들게 되었다. 간단히 설명했지만, LLM 모델 자체의 비결정성 때문에 여러 가드레일이 필요했고 파이프라인을 구축하는 데 2, 3일은 꼬박 밤을 새면서 만들었던 것 같다.
하지만 해당 프로젝트를 실제 데이터 수집까지 연결하지 못했다. 가장 큰 문제는 비용이었다. 테스트 과정에서만 쓴 비용이 2000달러가 넘는다. 이건 Qwen 3.5 Flash의 저렴한 토큰 값과 별개로 web_search 툴 콜링 비용 때문이다. Qwen의 web_search 툴 콜링에는 자체 compact 기능이 없기 때문에 web_search를 하면서 들어가는 비용이 족족 토큰 비용으로 계산된다. 토큰 비용만 생각하고 모델을 채택했지, 그 외 툴체인이나 사이드 이펙트 비용은 전혀 생각하지 않았기 때문에 적잖이 당황했다.
사용자가 어떤 책을 입력할지는 모르기 때문에 최대한 많은 책을 수집해야 했고, 프로덕션 퀄리티로 사용하려면 주기적으로 검증 파이프라인도 필요하다. 비용이 몇 배가 될지 모른다. 수집해야 하는 데이터는 무한한데 (물론 롱테일이겠지만 니치 데이터가 없으면 해당 유저는 바로 이탈한다) 수집 비용은 어마어마하다. Codex로 전체 검증 테스트를 돌려놓고 자고 일어났더니 한화 300만 원 청구서가 기다리고 있었다. 솔직히 멘붕이었다.
그 뒤로 포기하지 않고 구독 모델인 Codex나 Claude Code에서 자체 스킬을 만들어서 시도했지만, API 모델이 아니라서 그런지 Qwen과는 비교도 안 될 뛰어난 모델 성능에도 불구하고 결과는 좋지 않았다. Qwen의 툴체인에 비해 클라이언트 위주(Playwright 등)의 스킬이나 플러그인의 한계로 web_search를 따라가지 못했다. GPT-5.3-Codex-Spark 모델에 Playwright 스킬을 붙여서 돌렸을 때는 Chrome이 메모리를 다 먹어버리면서, M4 Max CTO 맥북이 처음으로 먹통이 됐다.
이 실패는 단순한 기술 실패가 아니었다. 운영 비용과 데이터 커버리지를 동시에 고려하지 않으면 어떤 기술도 제품이 될 수 없다는 첫 번째 교훈이었다. 3일의 삽질, 2000달러의 비용(하필 환율 오른 시기에 청구되어 300만 원), 이렇게 사전 DB를 구축해도 니치한 유저 맞춤 ToC는 여전히 사각지대라는 여러 가지 이유가 겹쳐 해당 프로젝트를 중단했다.
사용자가 직접 ToC 인식
결국 사용자가 입력하게 한다
꽤 많은 비용을 치르고 결국 원점으로 돌아왔다. “사용자가 입력한다.” 차선책이었지만 나쁘지 않은 결정이다. 당연히 유저가 모든 목차를 직접 입력하는 건 말도 안 된다. OCR 기능도 좋아졌기 때문에 그냥 유저가 사진을 찍으면 된다. 유저 본인만의 데이터가 되는 건 덤이다.
문제는 텍스트 입력이 아니라 구조 입력이었다. 목차 구조는 텍스트 리스트가 아닌 depth level을 가진 일종의 그래프 노드이다. 사진을 찍는다고 iOS VisionKit이 구조까지 인식해주지는 않는다. 예전 OCR 모델들에 비해 텍스트 인식 자체는 뛰어났고, 심지어 어느 정도 구조화된 문서도 인식했다. 하지만 그 “어느 정도”가 유저에게 최악의 경험을 제공했다.
LLM 이전에는 왜 안 됐나
앞서 말했듯이, 이 문제를 처음 시도한 건 아니었다. OCR + 정규화는 5년 전 그때에도 여러 가지 방법으로 시도했다. 당시 OCR 라이브러리나 서비스로도 flat list 추출 정도는 가능했다. 하지만 실제 필요한 건:
- Part / Chapter / Section (depth가 더 있을 수도 있고)
- depth
- page
- 계층 관계
이다. 단순히 텍스트 덩어리가 아니라 위계 구조였다.
당시에도 언어 모델을 이용해서 분류 체계를 만들려고 노력했다. 지금과 비교하면 당시에는 언어 모델의 태동 시기였다. 막 트랜스포머, attention 개념이 제품에 적용되던 시기라서 GCP에서 제공하는 ML 플랫폼을 이용해 목차 데이터를 최대한 많이 수집해서 언어 모델을 만들려고 시도했었다. 언어 모델에게 Flat List의 ToC를 입력하면 각 아이템의 고유 패턴을 학습해, 텍스트 리스트를 던지면 구조가 반환되도록 학습시키려 했다. 하지만 이미 Flat List로 변환된 텍스트에서 해당 모델이 추론을 해도, 케이스의 다양함이나 데이터의 부족으로 문제를 전혀 해결할 수 없었다.
줄바꿈, 들여쓰기, 번호 체계, 로마자/숫자 혼용 같은 구조 신호들이 OCR을 거치는 순간 모두 뭉개졌고, 페이지 번호까지 매칭해야 하는 추가 요구사항은 아예 시도조차 하지 못했다.
ToC 인식의 문제는 OCR 텍스트 인식률이 아니라 구조화였다. 글자를 읽는 것이 아닌 글자 사이의 위계를 읽는 것. 또한 혼자서 해당 문제를 풀기에는 비용 대비 ROI가 나오지 않는 아주 비효율적인 작업이었다.
왜 지금 가능해졌나
GPT, Claude, Gemini 등 메이저 모델들의 성능이 어마어마하게 발전했다. 하지만 여전히 AI를 제대로 활용하는 서비스가 나오기 어려운 이유는 API 비용 때문일 것이다. 200달러짜리 GPT Pro 모델을 구독하고 있지만, 현재 Codex를 사용하는 토큰을 API 종량제로 사용했다면 아마 몇천 달러 청구서를 구경했을 것이다.
요즘 다들 간과하는 게 있다. 현재 사용 모델이 항상 SOTA 기준이다 보니 “에이전틱 엔지니어링이 중요하다, 프롬프트 엔지니어링은 중요하지 않다”라고 말하지만, 상용 서비스에 LLM API를 연동하고 비즈니스 ROI까지 맞추려면 절대 SOTA 모델을 사용할 수 없다. 최소 한 세대, 많이는 두 세대 전 API 모델을 사용할 수밖에 없다. 지금 이 모든 건 비용 문제다.
2026년 2월 23일에 Alibaba Cloud에서 Qwen 3.5를 발표했다. 그중에 Qwen 3.5 Flash 모델이 공개되었는데, Pro 모델이 보통 Claude Sonnet과 비교되는 모델이고 Flash는 그것보다 훨씬 하위의 모델이다. 멀티턴에서 성능이 급격하게 떨어지고 예전 세대 모델들처럼 단일 요청에 대한 답을 잘한다. 하지만 앞서 언급했던 web_search, web_extract 외에도 Vision 모델도 같이 이식되어서, 단순 작업에 있어서는 굉장히 빠르고 정확하며 API 비용은 경쟁 모델 대비 압도적으로 저렴하다. (중국발 모델은 개인정보 우려가 있지만, Qwen은 로컬 오픈소스 모델도 별도로 제공하고 있다.)
왜 Qwen 3.5 Flash였는가
아래는 각사의 경량(Flash/mini/nano) 라인 모델끼리의 비교다. 같은 급에서 비교하는 게 공정하기 때문이다. (2026년 2월 기준 공식 가격)
| Claude Haiku 4.5 | GPT-5-nano | Gemini 3 Flash | Qwen 3.5 Flash | |
|---|---|---|---|---|
| 라인업 급 | 경량 (Haiku) | 경량 (nano) | 경량 (Flash) | 경량 (Flash) |
| Input (per 1M tokens) | $1.00 | $0.05 | $0.50 | $0.10 |
| Output (per 1M tokens) | $5.00 | $0.40 | $3.00 | $0.40 |
| 비전 (이미지 인식) | 우수 | 우수 | 우수 | 충분 |
| 구조화 JSON 출력 | 우수 | 좋음 | 우수 | 충분 (단일 요청 기준) |
| 속도 | 빠름 | 매우 빠름 | 빠름 | 매우 빠름 |
| 멀티턴 성능 | 좋음 | 보통 | 좋음 | 급격히 떨어짐 |
표만 보면 GPT-5-nano가 Input 단가에서 Qwen보다 유리하다. 하지만 실제 운영 비용은 Input 단가만으로 결정되지 않는다. 구조화 JSON 출력의 품질이 낮으면 재시도, 후처리, 다른 모델로의 fallback 호출이 늘어나고, 이게 곧 숨은 비용으로 누적된다. 이번 과제에서 GPT-5-nano는 구조화 출력이 “좋음” 수준에 머문 반면, Qwen 3.5 Flash는 “단일 요청 기준으로 실제 프로덕션 테스트를 통과할 정도”의 안정적인 구조화를 보여줬다. 멀티턴을 전제로 한 복잡한 대화가 아니라, “이미지를 한 번 보내고, 구조화된 JSON을 한 번에 받는” 단일 요청 패턴이 핵심이었기 때문에 이 차이가 결정적이었다.
ToC 인식 과제의 워크플로도 멀티턴을 요구하지 않는다. 사용자가 책이나 교재 사진을 찍으면, 시스템은 그 이미지를 한 번에 받아 ToC 구조를 JSON으로 뽑아내기만 하면 된다. 이 시나리오에서는 멀티턴 추론 능력보다 “비전+구조화 출력” 한 방의 신뢰도가 훨씬 중요하다. Qwen 3.5 Flash는 이 단일 요청 + 구조화 출력 조합에서 같은 급 모델들 대비 충분히 만족스러운 결과를 냈고, 이게 선택의 핵심 근거가 됐다. (모든 작업에서 Qwen Flash가 최고라는 뜻이 아니라, 이 특정 과제에 잘 맞았다는 의미다.)
속도도 빼놓을 수 없다. 사용자가 카메라로 책 페이지를 찍어 올리고 결과를 기다리는 상황에서, 응답 시간은 곧 UX다. 동일한 이미지를 보냈을 때 Haiku 4.5는 대략 10~15초 정도가 걸렸고, Qwen 3.5 Flash는 5~8초 안에 응답이 돌아왔다. 체감상 두 배 가까운 차이다. 비용 측면에서도 Qwen Flash는 경량급 모델 중에서 여전히 저렴한 축에 속한다. 이 과제의 요구사항(경량, 저렴, 빠름, 단일 요청 구조화)에 맞춰 보면, Flash를 쓰지 않을 이유를 찾기가 더 어려웠다.
남은 걱정은 OCR/비전 품질이었다. 솔직히 처음에는 Flash급 비전이 실제 책 사진 — 조명 불균일, 곡면 왜곡, 작은 글씨 — 을 제대로 처리할 수 있을지 의문이었다. 실제 테스트 결과, 텍스트 인식률 자체는 충분히 실용적이었다. 문제는 인식률보다 “어떻게 구조화해서 내보내느냐”에 가까웠고, 이 부분은 프롬프트 설계와 후처리 파이프라인으로 보완해야 할 영역이었다. 모델이 80점을 내면, 나머지 20점은 엔지니어링으로 채우면 된다. (이 문장이 이 글 전체의 주제이기도 하다.)
왜 DashScope였는가
OpenRouter로 먼저 붙였다. 같은 모델인데 결과가 처참했다. 알고 보니 DashScope 네이티브로 쓰는 것과 아예 다른 물건이었다. 바닐라 모델로 같은 프롬프트를 던지면 쓸 수 없는 수준인데, DashScope에서는 web_search, web_extract, Vision까지 네이티브 툴체인이 전부 붙어 있었다. 같은 모델인데 플랫폼 차이가 이 정도라는 게 충격이었다. 수집기 때도 이 차이가 결정적이었고, 인식 파이프라인에서도 마찬가지였다.
비용 예측도 중요했다. DashScope은 리전별 가격 정책이 명확하고, Free Quota도 제공한다. 상용 서비스에 LLM API를 연동할 때 가장 무서운 건 “이번 달 청구서가 얼마일지 모른다”는 거다. DashScope은 그 불확실성이 적었다. Singapore 리전에서 최신 모델을 바로 쓸 수 있어서 운영 비용 예측이 가능했다.
운영 안정성도 따졌다. Alibaba Cloud는 인프라 기업이고 DashScope은 그 위에 올라간 서비스라서, 최소한 API가 갑자기 사라질 걱정은 덜했다. 프록시 레이어가 하나 더 들어가면 그만큼 장애 포인트도 늘어난다. 이미 수집기 때 OpenRouter → DashScope 마이그레이션을 한 번 겪었기 때문에 처음부터 DashScope으로 갔다.
LLM API 모델 연동을 고려하고 있다면 테스트해보면 좋다. 미국발 SOTA 모델 외에도 Kimi, Qwen, GLM 같은 중국발 모델도 써볼 만하다. 같은 모델이라도 어떤 플랫폼에서 쓰느냐에 따라 결과가 완전히 달라진다는 건 직접 겪어봐야 안다.
작동하는 기능에서 믿을 수 있는 제품까지 (개발기)
아래부터는 실제 개발 흐름이다. 기술 디테일을 모두 따라가지 않아도 괜찮다. 하지만 “왜 이렇게까지 복잡해야 했는가”의 감각은 가져갔으면 한다. 내가 정말 전하고 싶은 건 기술 디테일 자체가 아니라, “작동한다”에서 “믿을 수 있다”까지의 거리가 얼마나 먼지에 대한 이야기다.
Do Work — 일단 동기 API로 붙여봤다
일단 파싱은 된다.
좋은 구조를 처음부터 완성한 게 아니다. 먼저 작동하는 버전을 만들었다.
가장 단순한 흐름부터 시작했다. iOS에서 사진을 찍으면 이미지를 서버로 올리고, 서버는 DashScope API에 이미지를 보내고, 동기 응답으로 JSON을 받아서 다시 iOS로 내려주는 구조다. 프롬프트에는 bookTitle과 totalPages를 힌트로 넣었다. 책 제목을 알려주면 모델이 맥락을 더 잘 잡고, 전체 페이지 수를 알려주면 페이지 번호 추론이 정확해진다. 이런 작은 힌트 하나가 결과 품질에 생각보다 큰 영향을 줬다.
처음 테스트를 돌렸을 때의 느낌을 아직 기억한다. 사진 한 장을 보내고 돌아온 JSON을 봤는데 — Chapter, Section, depth, startPage가 꽤 정확하게 잡혀 있었다. “이거 되는데?” 하는 확신을 얻는 순간이었다. 사전 수집 파이프라인에서 2000달러를 태우고 나서 만난 이 순간은 꽤 감격스러웠다.
다중 이미지가 의외로 중요했다
처음엔 “사진 한 장 찍으면 되겠지” 싶었다. 하지만 실제 책 목차를 열어보면 한 장에 안 끝나는 경우가 훨씬 많다. 특히 전문 서적이나 교재는 목차만 5~6페이지(테스트 했던 책 하나는 10페이지였다)에 걸쳐 있다. multi-image 파싱은 있으면 좋은 게 아니라, 없으면 실제 사용이 불가능한 기능이었다.
문제는 여러 이미지의 파싱 결과를 하나로 합치는 게 단순 concat이 아니라는 거다.
첫째, 이미지 순서를 보존해야 한다. 사용자가 1페이지부터 찍었다는 보장이 없다.
둘째, 챕터 merge가 필요하다. 이미지 A의 마지막 챕터와 이미지 B의 첫 챕터가 같은 챕터일 수 있다. Chapter 3이 첫 번째 사진의 끝에서 시작되고 두 번째 사진에서 하위 섹션이 계속되는 경우다. 이걸 중복으로 처리하면 챕터가 두 개가 되고, 무시하면 섹션이 날아간다.
셋째, 중복 dedupe와 startPage 기반 정렬이다. 같은 챕터가 여러 이미지에서 등장할 수 있고, 페이지 번호가 겹칠 수 있다.
넷째, warning 처리다. 모델이 보낸 이미지가 목차가 아니라고 판단하면 not_toc를 반환해야 하고, 챕터 수가 비정상적으로 적으면 too_few_chapters 경고를 내야 하고, 페이지 순서를 강제 조정했으면 page_order_adjusted를 알려줘야 한다. 이런 warning 없이 조용히 결과만 주면 사용자가 잘못된 데이터를 그대로 쓰게 된다.
multi-image가 들어가면서 prompt 설계, merge 로직, dedupe 규칙, 정렬 알고리즘, warning 체계 — 전부 한 단계씩 복잡해졌다. “한 장 찍으면 되겠지”가 얼마나 순진한 생각이었는지 깨닫는 데는 오래 걸리지 않았다.
Do work, 하지만 제품은 아니었다
기능은 동작했다. 이미지 3장을 보내면 merge된 ToC JSON이 돌아왔다. 정확도도 나쁘지 않았다. 하지만 치명적인 문제가 있었다. 이미지 3장을 보내면 모델 응답까지 수십 초가 걸린다. 그동안 HTTP 커넥션은 열려 있고, 서버 워커 하나가 묶여 있고, 유저는 빈 화면을 보고 있다.
더 심각한 건 타임아웃이다. 모바일 환경에서 30초 넘게 HTTP 커넥션을 들고 있으면 네트워크 상태에 따라 연결이 끊긴다. 끊기면? 처음부터 다시. 모델 호출 비용은 이미 발생했는데 결과는 사라졌다.
이건 기능이지 제품이 아니다. 데모에서는 “오 대박” 할 수 있지만, 실제 유저에게 제공하면 한 번 쓰고 다시 안 쓴다.
Good — 비동기 parse-job으로 분리했더니 그제야 제품 비슷해졌다
기다릴 수 있는 기능이 된다.
동기 요청은 서버도 답답하고 사용자도 답답했다. 모델 호출 시간을 줄일 수는 없다. 그렇다면 기다리는 방식을 바꿔야 한다.
작업 시스템으로의 진화
parse-jobs 비동기 모델로 전환했다. 흐름은 이렇다:
- 클라이언트가 이미지를 업로드하고 파싱을 요청한다
- 서버는 즉시 job을 생성하고 jobId를 반환한다 (여기까지 1초 이내)
- 실제 파싱은 백그라운드 worker가 처리한다
- 클라이언트는 jobId로 상태를 주기적으로 조회한다 (polling)
이 전환만으로도 사용자 경험이 확 달라졌다. 요청을 보내면 즉시 “접수됨” 상태를 받고, 앱은 “처리 중” UI를 보여줄 수 있다. 기능이 “모델 호출”에서 “작업 시스템”으로 진화한 순간이다.
중복 실행 방지 — 단순 비동기화가 아니다
비동기화만 한 게 아니었다. 같은 이미지 세트로 이미 파싱이 진행 중이거나 완료된 job이 있다면 새로 만들지 않고 기존 job을 재사용했다.
- 사용자가 같은 사진을 실수로 두 번 보낸다 → 모델 호출 비용이 2배
- 네트워크 이슈로 앱이 재시도한다 → 동일한 job이 2개 생긴다
- 사용자가 “아까 그 결과”를 다시 보고 싶어한다 → 이미 있는 결과를 찾아서 주면 된다
신규든 재사용이든 클라이언트에는 동일하게 200을 반환하되, reused 여부를 명시했다. 클라이언트는 “200이 왔으니 jobId를 들고 상태를 조회하면 된다”만 알면 된다. (디버깅할 때는 필요하니까 표시는 해둔다.)
이건 비용과 직결되는 결정이다. LLM API 호출은 공짜가 아니다. 중복 방지 없이 운영하면 비용이 예측 불가능해진다.
실패 계약 — 잘 될 때만 잘 되면 되는 게 아니다
iOS 쪽에서도 단순히 “성공하면 보여준다”로 끝나지 않았다. 실패 계약을 명확히 해야 했다.
HTTP 200이 아니면 로컬 폴백으로 전환한다. 서버가 죽어도 앱이 동작해야 한다. 사용자에게 “서버 에러입니다”를 보여주는 건 최악이다. 차라리 “자동 인식에 실패했으니 직접 입력하세요”라고 대안을 주는 게 낫다. (물론 직접 입력은 최악의 UX지만, 에러 메시지만 보여주는 것보다는 낫다.)
다행히도 Apple VisionKit이라는 로컬 MLKit이 있었기 때문에 직접 입력보다는 그나마 나은 경험을 제공해준다. (물론 구조화는 안된다.)
백엔드는 “잘 될 때만 잘 되면 되는” 서비스가 아니다. 실패했을 때 클라이언트가 어떻게 반응할지까지 합의해야 한다. 이건 API 스펙이 아니라 제품 계약이다.
Good, 하지만 아직은 부족했다
Do work가 “돌아가는 기능”이었다면, Good은 “기다릴 수 있는 기능”이 되는 단계였다. 하지만 여전히 유저는 기다리는 동안 아무것도 보지 못했다. 언제 끝나는지도 모른다. polling으로 “아직 처리 중”만 보여주는 건 2026년에 유저에게 제공할 경험이 아니었다.
“이 정도면 괜찮지” 하고 멈출 수도 있었다. 사실 많은 서비스들이 여기서 멈춘다. 하지만 유저가 기다리는 그 시간을 어떻게 만들 것인가. 그게 Good과 Great의 차이라고 생각했다.
Great — 실시간 경험, 부하 분산, 운영 리스크까지 감당하면서 비로소 프로덕션이 됐다
믿고 쓸 수 있는 제품 경험이 된다.
SSE를 붙이면서 기능이 아니라 경험이 됐다
polling의 한계는 명확했다. 유저는 “지금 어디까지 됐는지” 모른다. polling 주기를 짧게 잡으면 서버 부하가 올라가고, 길게 잡으면 체감 지연이 커진다.
그래서 SSE(Server-Sent Events)를 도입했다. 클라이언트가 연결을 열어두면 서버가 이벤트를 실시간으로 밀어준다.
- snapshot : 연결 시점의 현재 상태 전체
- status : job 상태 변경 (queued → processing → completed/failed)
- preview : 파싱이 진행되는 동안 점진적으로 인식된 목차 구조
- heartbeat : 연결이 살아있다는 신호
- usage : 토큰 사용량 등의 메타 정보
- completed : 최종 결과 확정
- failed : 실패와 에러 정보
사용자 경험에 가장 큰 영향을 준 건 preview였다. 모델이 목차를 인식해가는 과정을 실시간으로 보여주는 것이다. Chapter 1이 먼저 나타나고, Section 1.1이 그 아래 생기고, 다음 Chapter가 추가되는 — 마치 ChatGPT가 답변을 한 글자씩 스트리밍하는 것처럼, 목차가 점진적으로 완성되어가는 경험. 이게 붙는 순간 “기능”이 “경험”이 됐다. 기다리는 시간이 지루한 대기에서 기대감 있는 관찰로 바뀌었다.
처음엔 “나오는 대로 보여주면 되겠지” 싶을 만큼 단순해 보였다. 하지만 실제로 만들어보니 진짜 어려운 건 그 다음부터였다.
preview와 truth는 분리해야 했다
실시간으로 보여주는 것과, 믿고 저장하는 것은 다른 문제였다.
이걸 깨달은 건 preview를 그대로 final result로 사용하려다가 문제가 터졌을 때다. preview에는 nodeId도 없고, order도 없다. UI에서 보여주기에는 충분하지만, downstream — 계획 생성, checkItems 변환, 사용자 커스텀 — 에서 필요한 메타데이터가 빠져 있다.
그래서 원칙을 세웠다:
- preview는 UI용이다. non-authoritative.
- final result만이 truth다. DB에 저장되고,
GET parse-jobs/{jobId}read path로만 접근한다. - preview와 worker queue stream은 분리한다.
preview를 truth로 취급하면, 아직 파싱이 끝나지 않은 불완전한 데이터로 계획이 생성될 수 있다. 이걸 구분하지 않으면? 사용자에게 “Chapter 3까지 인식됨”이라고 보여줬는데, 실제로는 5까지 있었으면 불완전한 계획을 받게 된다. 보여주는 것과 저장하는 것을 같은 채널로 처리하면 안 된다는 건 — 말하면 당연한데, 만들다 보면 놓치기 쉬운 부분이다.
진짜 어려웠던 건 SSE가 아니라 preview emit 정책이었다
SSE를 붙이는 것 자체는 어렵지 않다. 진짜 어려워진 건 그 다음이다. 얼마나 자주, 어떤 변화에서 preview를 내보낼지.
preview 하나를 보낼 때마다 두 가지 비용이 발생한다. latest preview cache에 저장하는 것과, event stream에 append하는 것. preview를 많이 보내면 UX는 화려하지만 서버에서는 write가 폭발한다. 반대로 적게 보내면 SSE를 붙인 의미가 없다. 처음에 빈 화면이고 한참 뒤에 갑자기 전체 결과가 뿅 나타나면 — 그게 polling이랑 뭐가 다른가.
특히 모바일 환경이 문제다. 지하철 타다가 끊기고, 와이파이에서 LTE로 전환되면서 끊기고. reconnect가 일어날 때마다 서버는 latest preview를 조회하고, 이전 이벤트를 replay한다. reconnect가 잦아질수록 서버 읽기 부하가 커진다. 실시간 지연보다 이게 더 큰 문제일 수 있었다.
결국 PreviewAssembler 레벨에 적응형 emit 정책을 넣었다:
- throttle: 최소 emit 간격. 너무 자주 보내면 서버가 힘들다.
- chapterThrottle: 챕터 단위 변화가 있을 때만 emit. 섹션 하나 추가된 건 보류.
- maxSilence: 너무 오래 안 보내면 heartbeat 대용으로 preview를 하나 보냄. 사용자가 “멈춘 건 아닌지” 불안해하지 않도록.
- fingerprint 비교: 이전 preview와 hash를 비교해서 실제 변화가 있을 때만 보냄. 모델이 같은 내용을 반복 출력하는 경우가 있기 때문.
- pending preview 보류: emit 조건을 만족하지 않으면 보류, 다음 emit 시점에 최신 것만 보냄.
전략은 이중 구조다. incremental strategy로 최대한 싸게 처리하다가, 깨지면 whole replay fallback으로 안전하게 복구한다. 증분 처리가 가능한 동안은 비용을 아끼고, 상태가 꼬이면 전체를 다시 보내서 일관성을 보장한다.
실시간으로 보이는 건 화려했지만, 진짜 어려운 건 덜 보내면서도 더 좋아 보이게 만드는 것이었다. SSE의 품질은 연결 유무가 아니라, preview를 얼마나 신중하게 흘려보내느냐에 달려 있었다.
실시간 UX를 만들자 서버 설계가 다시 시작됐다
SSE는 예쁜 기술이 아니다. 대기와 부하를 다른 방식으로 관리하는 기술이다. SSE를 붙이면서 서버 아키텍처의 상당 부분을 다시 설계해야 했다.
worker queue와 SSE event stream의 분리. 처음에는 기존 worker queue의 Redis Stream을 SSE source로 재사용하려 했다. 하지만 이렇게 하면 worker의 처리 단위와 SSE의 emit 단위가 결합된다. worker가 빨라지면 SSE가 과도하게 emit하고, worker가 느려지면 SSE도 답답해진다. 이 둘은 독립적이어야 한다. 그래서 Redis event stream을 별도 key로 분리했다.
replay, reconnect, Last-Event-ID. 모바일 환경에서 reconnect는 예외가 아니라 정상이다. 재연결 시 Last-Event-ID를 보내면 서버는 그 이후의 이벤트만 replay한다. 이게 없으면? 끊길 때마다 사용자는 처음부터 다시 보게 된다. 아까 Do Work에서 겪었던 그 문제가 SSE 레이어에서 반복되는 거다.
Nginx 설정. proxy_buffering off, X-Accel-Buffering: no. 이걸 빼먹으면 Nginx가 SSE 이벤트를 몰래 모아뒀다가 한 번에 보낸다. “실시간”이라고 만들었는데 실제론 지연된 배치였다. 이런 걸 모르면 SSE를 붙인 의미가 없다.
graceful shutdown과 배포. shutdown timeout을 30초로 잡고, blue/green 전환으로 처리한다. 배포 중 목표는 “무중단”이 아니라 재연결 가능한 시스템이었다. 완벽한 무중단은 비용이 너무 크다. 끊어져도 복구되는 시스템이면 충분하다. 연결이 끊겨도 클라이언트는 자동 재연결하고, snapshot-first + replay + live tail 구조로 이전 상태를 복구한다.
SSE 세션의 전체 흐름. 세션이 열리면: job 조회 → latest preview 조회 → snapshot event 구성 → replay → live tail. 이 다섯 단계가 매 연결마다 반복된다. UX는 좋아졌지만, reconnect가 잦아질수록 이 initial handshake 비용이 누적된다. 그래서 최근 최적화의 초점은 “SSE를 더 붙이는 것”이 아니라 preview emit 빈도와 reconnect 비용 제어로 이동했다. 실시간 UX는 공짜가 아니다. 보여주는 것의 화려함 뒤에는 서버가 감당해야 할 비용이 있다.
진짜 품질 개선은 모델 교체보다 30권 테스트셋에서 나왔다
시스템은 완성됐다. 하지만 프로덕션 레벨로 올리려면 “잘 된다”로는 부족했다.
실제 책 30권을 전부 돌려봤다. 다양한 분야(CS, 수학, 문학, 경영), 다양한 출판사(O’Reilly, Pearson, 국내 출판사), 다양한 레이아웃. 그러자 실패 패턴이 보이기 시작했다.
- 챕터 누락: 모델이 일부 챕터를 통째로 건너뜀 → prompt 보강 + merge-normalize에서 누락 감지
- 로마자 인식 오류: Part Ⅳ를 Part IV로 혼동 → numbering normalization 규칙 보완
- depth 오판: Section을 Chapter로 올리거나 내림 → parser/post-process에서 depth 보정 로직 추가
- 페이지 순서 흔들림: 이미지 순서와 실제 페이지 순서 불일치 → startPage 기반 정렬 강화
- 줄바꿈/들여쓰기 오해: OCR이 줄바꿈을 구조 구분으로 해석 → 프롬프트에 명시
- 디버깅 어려움: 같은 이미지인데 비결정적 결과 → live integration test + 상세 logging
모델을 바꿨다고 정확도가 저절로 올라간 게 아니었다. 품질은 테스트셋을 만들고 회귀를 막는 과정에서 올라갔다. canonical fixture를 만들고, batch integration test를 돌리고, 실패한 케이스로 live integration test를 재현하고, prompt builder를 수정하고, 다시 전체를 돌린다. 이 루프를 수십 번 반복했다.
“개발 환경 디버깅 지원”이라는 커밋 메시지가 있는데, 이건 핵심이었다. 실데이터로 실패 패턴을 재현 가능하게 만들어야 품질을 올릴 수 있었다. 재현할 수 없는 버그는 고칠 수 없다. 이건 모델의 일이 아니라 엔지니어의 일이었다.
테스트는 green이었지만, 프로덕션은 release blocker를 내놨다
30권 테스트셋을 통과하고 SSE 흐름도 안정적이었다. 테스트는 다 green이었다. 이제 올려도 되겠다 싶었다.
하지만 final review에서 release blocker가 터졌다.
- terminal event 유실 시 영구 대기 (critical): completed나 failed가 유실되면 클라이언트는 영원히 “처리 중”에 머문다. 사용자는 앱을 강제 종료해야 한다.
- 이벤트 timestamp/message 누락: replay 시 순서가 꼬일 수 있다.
- Redis MAXLEN 미설정: 이벤트가 무한히 축적된다. 메모리가 서서히 차오르다가 어느 날 Redis가 죽는다.
- worker 실패 시 retry 부재: DashScope API가 실패하면 job이 영원히 processing 상태로 남는다.
- preview partial JSON repair 취약점: 불완전한 JSON이 그대로 내려갈 수 있었다.
이것들 중 하나만 프로덕션에서 터져도 사용자 경험이 심각하게 망가진다. 테스트는 “정상 경로가 작동하는가”를 검증하지만, 프로덕션은 “비정상 경로에서도 안전한가”를 요구한다. release blocker를 전부 수정하고, manual runbook을 작성하고, 배포 체크리스트를 만들고, live integration transcript로 실제 SSE 흐름을 검증한 뒤에야 — 비로소 “이제 올려도 되겠다”는 판단이 섰다.
파이프라인의 마지막 조각 — hierarchy selection UX
여기까지 하면 끝일 것 같지만, 하나 더 남아있었다.
모델이 아무리 정확하게 ToC 구조를 뽑아줘도, 그 결과물을 사용자에게 그대로 던져주면 안 된다. 사용자마다 필요한 depth가 다르기 때문이다. 어떤 사용자는 Chapter 레벨만 필요하고, 어떤 사용자는 Section 레벨까지 필요하다.
그래서 hierarchy selection이 필요했다. 기본 동작은 이렇다:
- 리프 노드(가장 하위 항목)를 기본 선택 상태로 보여준다
- 상위 노드를 토글하면 하위 전체가 선택/해제된다
- 기본 상태가 이미 “대부분의 경우에 맞는 선택”이어야 한다
사진 → 구조화 JSON → hierarchy selection → Items 생성
이 전체 흐름이 하나의 파이프라인이다. 모델이 잘하는 건 사진 → JSON 변환이고, 나머지는 전부 엔지니어링이다.
AI 시대의 제품 엔지니어링이란 무엇인가?
여기까지 읽으면서 느꼈겠지만, 이건 단순히 OCR 기능이 아니다. 사진 → 구조화 JSON → hierarchy selection → Items 생성이라는 흐름을 엔지니어링한 것이다.
물론 겉으로만 보면 이 작업은 단순히 LLM API를 붙이고 SSE로 스트리밍 UX를 얹은 기능처럼 보일 수 있다. “요즘 다 하는 거 아닌가?” 싶을 수 있다. 이 과정을 통해 보여주고 싶은 건 느리고 비결정적이며 비용이 드는 LLM 호출을 — 사용자가 기다릴 수 있고, 이해할 수 있고, 믿고 쓸 수 있는 제품 경험으로 바꾸는 일이었다.
이건 아래 문제들을 푼 것이다:
- 모바일 입력 마찰을 줄이는 UX 문제
- OCR이 아니라 구조화 정확도를 다루는 인식 문제
- 느린 LLM 응답을 제품 흐름에 맞게 바꾸는 작업 시스템 문제
- polling/SSE/reconnect를 포함한 실시간 UX 문제
- preview와 truth를 분리하는 데이터 신뢰성 문제
- emit 빈도, Redis write, reconnect 비용 같은 운영 문제
- 실패 시 fallback과 사용자 계약에 대한 제품 문제
제품 엔지니어의 집요함은 거대한 기술 이름에서 증명되는 것이 아니다. 사소해 보이는 기능 하나를 끝까지 파고들어, 사용자가 불편함을 느끼지 않게 만드는 과정에서 드러난다.
ToC 인식 기능은 내가 지금 만드는 서비스에 아주 작은 부분이다. 사실 필수 기능도 아니다. 이전이라면 초기에는 제외했을 수도 있다. (그리고 결국 백로그에만 있었겠지.) 예전에 이 정도로 개발한다고 하면 내가 극구 말렸을 것이다. 비용이 안 나오는 일이다. 하지만 지금 AI 코딩 에이전트 시대에는 하루, 아니 몇 시간이면 만들 수 있는 기능이다. 그 정도면 비용을 지불할 만하다.
AI 시대의 제품 엔지니어링이란 무엇인가? 핵심은 단순히 동작하는(Do Work)을 넘어, Good을 거쳐 Great까지 디테일을 파고드는 과정이다. “이 정도면 괜찮지”(Good)에서 머물지 않고 최대의 디테일을 파고들어 극대화시키는 것(Great)이 AI 시대의 제품 엔지니어링일 것이다.
하네스를 아무리 잘 구축해도, 에이전트 성능이 아무리 올라가도 — 결국 고민하고 결정하는 건 엔지니어 본인이다. 개발은 Codex를 대부분 사용했고 Superpowers 스킬(Codex의 자율 실행 모드)을 적극 활용했다. 하지만 해당 구현 흐름은 하네스 엔지니어링이 아니라, 실제 개발 흐름 — 요구사항 분석, 구현, 최적화 — 을 내가 계속 파고들고 결정한 부분이다.
내가 직접 코드를 작성하지 않는다고 Craftsmanship(장인정신)이 사라지는 건 아니다. “이쯤 하면 완료되지 않았을까” 할 때 모든 걸 처음부터 점검해보고 테스트해보고 2%를 더 고민해보는 것. 그게 필요하다.
나가며
개발을 처음 배울 때는 참 신이 난다. 아마 지금 바이브 코딩으로 입문하는 대부분의 사람들도 신이 날 것이다. 처음 내가 의도한 대로 동작하는 서비스를 만들었을 때의 그 기분은 잊을 수가 없다. (나는 2011년에 Ruby on Rails로 웹 버전 Dropbox를 스스로 클로닝했었다. 그때의 뿌듯함이란.)
하지만 작동하는 기능과 믿고 쓸 수 있는 제품 사이에는 굉장히 큰 갭이 있다. 특히 이번 글에서 다룬 AI 모델 연동은 처음이 제일 신나는 순간이다. 벤더가 제공하는 서비스에서 직접 모델을 테스트했을 때 성능이 나오는 걸 보면서 “이걸로 뭐든 만들 수 있겠다” 싶은 순간이고 설렌다. 그러나 곧바로 좌절을 맛본다. LLM API 비용부터 실제 프로덕션 환경에서의 운영 방식까지, 그냥 API 붙이면 된다고 생각했던 것보다 80%는 더 고민하고 시행착오를 겪어야 했다.
내가 만든 제품이 Great라고 생각하지 않는다. 하지만 유저 입장에서 “이건 불편할 건데”, “직관적이지 않은데” 하고 느끼는 포인트에서 그걸 무시하느냐 마지막까지 붙잡고 늘어지느냐 — 그게 Great로 가는 갈림길이라고 생각한다.
개발 비용이 비싼 시대에서는 오버 엔지니어링이라는 말로 디테일을 파고드는 걸 경시했다. 하지만 3주가 아니라 하루를 투자해서 디테일을 만들 수 있다면 그걸 포기할 필요가 있을까? 기능이 10개라면 3개만 제공하더라도 그 3개가 유저 입장에서 훌륭한 제품 경험을 제공해준다면 그게 더 맞는 방향이라고 생각한다.
AI가 코드를 짜주는 시대에도, 잘 만든 제품을 만든 엔지니어들을 보면 결국 밤을 새고 있다. 나에게 iOS 개발은 어렵다. 다년간 Flutter 개발을 하다가 iOS를 선택했고, Codex를 아무리 두들겨 패도 UI 트랜지션 코드는 한 번 삐끗하면 답이 없다. 무한루프가 시작된다. 하루를 날 새고도 간단한 이슈를 수정하지 못할 때, 그제야 기초를 건너뛴 걸 반성하고 로그도 찍어보고 비슷한 데모를 검색해본다. 옛날처럼. 결국 AI가 못 풀면 내가 찾아야 한다. 밤을 새면서 preview emit 정책을 고민하고 있었던 것처럼.
나만 그런 건 아니다. OpenClaw 개발자는 60일 동안 43개의 프로젝트를 만들고 버린 뒤에야 하나가 터졌다. 그 60일간의 커밋 히스토리를 보면 새벽 3시, 4시 커밋이 수두룩하다. AI가 코드를 짜주는 시대에도 “broken at least once, and eventually fixed at 1 AM while questioning my life choices”라고 쓰는 개발자들이 진짜 제품을 만들고 있다. Zed 에디터 팀은 작년에 “The Case for Software Craftsmanship in the Era of Vibes”라는 글을 썼다. 바이브 코딩 시대에도 장인정신이 필요하다는 선언이다. 심지어 Artisanal Coding(職人コーディング)이라는 매니페스토까지 나왔다. 그 시대에 일본식 장인정신을 소환하다니. 과하다 싶으면서도 공감한다.
AI 코딩 에이전트가 생산성을 30~60%까지 올려준다고 한다. 맞다. 나도 체감한다. 하지만 그 절약된 시간을 어디에 쓰느냐가 갈림길이다. 기능을 더 많이 찍어내느냐, 아니면 하나의 기능을 더 깊이 파느냐. 나는 후자를 택했고, 그게 맞다고 아직도 생각한다. (물론 매출이 그걸 증명해줘야 하는데, 그건 아직 모른다.)
남들은 한 달에도 앱을 10개씩 찍어내는데, 하나의 제품을 만든 지 벌써 세 달째로 접어들었고 그마저도 처음 기획했던 기능들을 상당수 걷어냈다. 코딩 에이전트가 옆에 앉아 있는 시대에도, 엔지니어링은 장인 정신이다. AI가 그럴듯한 80점짜리 평균을 답변할 때 나머지 20점을 채우는 건 당신이다.
References
- 에이전틱 엔지니어링 시대의 생존 스킬 9가지 — Karpathy가 제시한 에이전틱 엔지니어링 시대, 엔지니어가 갖춰야 할 9가지 능력
- Claude Code에 날개를 달아줘라! - Superpowers 소개 — Codex의 자율 실행 모드 Superpowers 설치법과 7단계 워크플로우
- 15년차 CTO가 바이브 코딩하는 방법 — 켄트 백의 증강형 코딩 철학 기반, AI와 페어 프로그래밍하는 방식
- AI는 당신만큼만 똑똑하다 — 같은 AI를 쓰는데 왜 격차가 벌어지는가. AI의 아웃풋은 당신의 인풋으로 결정된다
- AI 에이전트 자비스, 내 두 번째 두뇌가 되기까지 — OpenClaw 프레임워크로 24시간 AI 에이전트를 구축한 실전 경험기
- The Case for Software Craftsmanship in the Era of Vibes — Zed 에디터 팀의 소프트웨어 장인정신 선언 (2025.06)
- Artisanal Coding(職人コーディング): A Manifesto — AI 시대의 소프트웨어 장인정신 매니페스토 (2025.10)
- OpenClaw: One Developer, 43 Failed Projects — Peter Steinberger가 43개의 프로젝트를 만들고 버린 뒤 OpenClaw를 만든 이야기
- Qwen 3.5 모델 소개 — Alibaba Cloud Qwen 3.5 Flash 모델 공식 발표 (2026.02.23)
- DashScope Model Studio — AlibabaCloud DashScope API 문서 및 가격 정책
댓글