LangGraph

단일 함수에서 에이전트 파이프라인으로: 손글씨 문서 변환기의 재설계

2026. 08. 26

검증을 목적으로 한 파일에 몰아 쓴 322줄 핸들러는 빠른 구조였습니다. 목적이 품질 개선으로 바뀌자 측정 지점이 없다는 성질이 병목이 됐고, 4단계 에이전트 파이프라인으로 다시 썼습니다. 분류 호출 비용을 5.1배 줄이는 과정에서 효과가 있다고 믿었던 이미지 축소가 토큰을 전혀 줄이지 않는다는 사실을 실측으로 확인했습니다.

프로젝트 배경

소속
개인 프로젝트
프로젝트
Notaformat · 손글씨 문서 변환기
기간
2025.11 최초 구현 ~ 2026.08 재작성
담당
기획·설계·구현 전 과정 단독

손으로 쓴 노트 사진을 올리면 수식·다이어그램·차트를 인식해 편집 가능한 형태로 복원하고 PDF로 조립하는 서비스입니다. 2025년 11월 Node.js 로 요청 핸들러 하나에 전 과정을 담아 구현했고, 2026년 8월 Python·LangGraph 기반의 단계별 파이프라인으로 다시 작성했습니다.

LangGraph에이전트리팩터링LLM비용파이프라인

1. 배경: 검증에 맞던 구조가 개선에는 맞지 않게 될 때

이 서비스의 가치는 손글씨 노트의 수식과 도형을 사진이 아니라 편집 가능한 형태로 복원하는 데 있습니다. 복원 품질이 떨어지면 사용자는 결과물을 다시 손보아야 하고, 그 시점에 서비스를 쓸 이유가 사라집니다.

2025년 11월 구현한 초기 버전은 서버 전체가 한 파일이었습니다. 이는 당시 조건에서 합리적인 선택이었습니다. 검증해야 할 것이 "이 아이디어가 되기는 하는가"였고, 그 답을 얻는 데 구조는 필요하지 않았습니다. 오히려 방해가 됩니다. 단계 경계를 미리 그으려면 무엇이 단계인지 알아야 하는데, 그건 만들어 보기 전에는 알 수 없습니다.

한 파일 안에서는 전 과정이 눈에 들어옵니다. 중간 자료구조를 설계할 필요가 없고, 값을 바로 다음 줄에서 씁니다. 프롬프트를 고치고 결과를 보는 왕복이 짧습니다. 탐색 단계에서는 이 구조가 더 빠릅니다. 실제로 사진을 올리면 PDF가 생성되었고 수식도 복원되었습니다. 구조를 갖춘 설계로 시작했다면 같은 시간에 여기까지 오지 못했을 것입니다.

바뀐 것은 코드가 아니라 목적입니다. 아홉 달 뒤 품질 개선에 착수하면서, 답해야 할 질문이 "되는가"에서 "어디가 얼마나 잘못되는가"로 옮겨갔습니다. 그러자 두 가지가 막혔습니다. 복원 결과가 어긋났을 때 어느 단계를 고쳐야 하는지 특정할 수 없었고, 한 번 변환에 비용이 얼마나 드는지 알 수 없었습니다. 개선할 지점을 짚지 못하면 품질을 올릴 수 없고, 비용을 모르면 사용자에게 무료 체험을 얼마나 열어줄지 정할 수 없습니다.

서버 전체는 server/index.js 한 파일 322줄이었고, 그중 핵심은 POST /api/convert-images 핸들러 하나였습니다. 이미지 업로드 처리, 모델 호출, HTML 후처리, 브라우저 렌더링, PDF 저장이 그 안에서 순서대로 일어났습니다.

이 구조가 잘못된 것은 아닙니다. 다만 측정 지점이 없습니다. 전 과정이 한 흐름이면 중간에 값을 꺼낼 자리가 없고, 자리가 없으면 계측도 없습니다. 탐색할 때는 비용이 아니었던 성질이, 개선할 때는 그대로 병목이 됩니다.

2. 문제 정의

2.1 실패 지점을 특정할 수 없었습니다

변환 결과가 이상할 때 물어야 할 질문은 셋입니다.

  • 텍스트를 잘못 읽었는가
  • 영역 종류를 잘못 판정했는가
  • 판정은 맞았는데 재생성이 실패했는가

이 셋이 한 번의 모델 호출로 뭉뚱그려져 있었습니다. 중간 산출물을 남기지 않았으므로 어느 단계에서 어긋났는지 사후에 확인할 방법이 없었습니다. 개선 방법이 프롬프트를 조금씩 바꿔 보는 것으로 좁혀졌습니다.

2.2 비용 구조가 보이지 않았습니다

토큰 사용량을 기록하는 코드가 어디에도 없었습니다. 한 번 변환에 얼마가 드는지 추정만 가능한 상태였습니다.

계측이 없으면 최적화의 효과도 측정할 수 없습니다. 무엇을 고쳐야 하는지 판단할 근거가 없었습니다.

2.3 사용자에게는 진행률 막대 하나였습니다

내부적으로는 여러 단계가 도는데 화면에는 퍼센트 하나만 표시되었습니다. 수십 초가 걸리는 작업에서 지금 무엇을 하고 있는지 알 수 없으면 사용자는 멈춘 것으로 판단합니다.

3. 기술적 난점

이 작업의 성격 때문에 단순히 함수를 쪼개는 것으로는 부족했습니다.

첫째, 단계마다 요구 조건이 다릅니다. 영역 종류를 판정하는 일은 다섯 개 라벨 중 하나를 고르는 분류 문제입니다. 반면 Mermaid 코드나 LaTeX 수식을 생성하는 일은 문법이 맞아야 하는 생성 문제입니다. 두 작업에 같은 설정을 쓰는 것은 낭비이거나 부족합니다.

둘째, 실패가 부분적입니다. 페이지에 영역이 여덟 개 있는데 그중 하나의 재생성이 실패했다고 전체를 버릴 수는 없습니다. 실패한 영역만 원본으로 대체하고 나머지는 살려야 합니다.

셋째, 순차 실행의 지연이 누적됩니다. 영역마다 모델을 호출하면 영역 수에 비례해 시간이 늘어납니다.

4. 대안 검토

4.1 파이프라인 구성

1안. 함수 분리만 수행

  • 장점: 변경 범위가 작고 의존성이 늘지 않습니다.
  • 단점: 상태 전달을 인자로 직접 해야 합니다. 단계가 늘어날 때마다 시그니처가 바뀌고, 중간 산출물을 어디에 둘지 매번 결정해야 합니다.

2안. LangGraph StateGraph

  • 장점: 상태 스키마를 한 곳에 정의하고 각 노드가 그것을 읽고 씁니다. 단계 추가가 노드 추가로 끝납니다. 진행 상황을 상태에 실어 보내기 쉽습니다.
  • 단점: 의존성이 하나 늘고, 단순한 선형 흐름에는 과할 수 있습니다.

2안을 채택했습니다. 결정적인 이유는 중간 산출물이 상태에 남는다는 점이었습니다. 실패 지점을 특정하지 못했던 문제가 여기서 풀립니다.

4.2 병렬 실행

영역별 재생성은 서로 독립적이므로 동시에 처리할 수 있습니다. ThreadPoolExecutor를 썼습니다. 대부분의 시간이 외부 API 응답 대기이므로 GIL의 영향을 받지 않습니다.

동시 실행 수는 4로 제한했습니다. 제한이 없으면 영역이 많은 페이지에서 API 속도 제한에 걸립니다.

5. 설계 결정 및 근거

5.1 네 단계로 나눈 기준

extract_pages → classify_regions → render_regions → assemble_document

"틀렸을 때 무엇을 의심할 것인가"를 기준으로 잘랐습니다. 결과가 이상하면 이제 단계별로 확인할 수 있습니다. 추출 결과에 글자가 빠졌는지, 분류 라벨이 잘못 붙었는지, 렌더링만 실패했는지가 각각 남습니다.

분류 단계의 라벨은 다섯 개입니다.

라벨처리
math_graph수식으로 조판
mermaid다이어그램 코드로 복원
chartjs차트로 재생성
illustration_redraw그림 재생성
preserve_original원본 유지

마지막 라벨이 중요합니다. 재생성이 어려운 영역을 억지로 바꾸지 않고 원본을 그대로 두는 선택지입니다. 이것이 없으면 모델은 항상 무언가를 생성하려 하고, 애매한 영역에서 원본보다 나쁜 결과를 만듭니다.

5.2 작업별 모델 프로필

처음에는 텍스트 작업 다섯 곳에서 모두 같은 모델을 파라미터 없이 호출했습니다. 작업 성격에 맞춰 나눴습니다.

python_TASK_PROFILES = {
    "classify": {"default": "gpt-4o-mini", "temperature": 0.0, "max_tokens": 400},
    "generate": {"default": "gpt-4o",      "temperature": 0.2, "max_tokens": 4000},
    "vision":   {"default": "gpt-4o",      "temperature": 0.1, "max_tokens": 4000},
}

분류를 하위 모델로 내린 근거는 작업의 난이도입니다. 다섯 개 라벨 중 하나를 고르는 일에 상위 모델의 추론 능력이 필요하지 않습니다. 그리고 이 호출은 영역 수만큼 반복되므로 단가 차이가 그대로 곱해집니다.

temperature도 함께 나눴습니다. 분류는 0.0입니다. 같은 영역이 실행할 때마다 다른 라벨을 받으면 결과를 신뢰할 수 없습니다. 생성은 0.2를 줬습니다.

모든 값은 환경변수로 덮어쓸 수 있게 했습니다. 모델을 올리고 내리는 실험에 코드 수정이 필요 없어야 하기 때문입니다.

5.3 이미지 축소를 한 지점으로 모음

리사이즈가 없어 휴대폰 원본 해상도가 그대로 전송되고 있었습니다. 6MB짜리 사진을 그대로 올리면 업로드에만 수 초가 걸립니다.

긴 변 1536px로 제한했습니다. 손글씨 판독에는 이 정도면 충분하고, 전송량이 6MB에서 0.9MB로 줄어 응답 시간이 4.0초에서 1.5초가 됩니다.

착수 당시에는 이것을 토큰 절감 수단으로 생각했으나, 6.3에서 보듯 사실이 아닙니다. 해상도를 낮춰도 토큰은 그대로이고 줄어드는 것은 전송 시간입니다.

문제는 어디서 축소할 것인가였습니다. 호출부마다 축소 함수를 부르게 하면 빠뜨리는 곳이 생깁니다. 그래서 이미지가 모델로 나가는 유일한 통로를 만들고 거기서 처리했습니다.

pythondef image_content_part(image_b64: str, detail: str = "high") -> dict:
    return {
        "type": "image_url",
        "image_url": {
            "url": f"data:image/png;base64,{downscale_b64_image(image_b64)}",
            "detail": detail,
        },
    }

detail 파라미터도 여기서 다룹니다. 명시하지 않으면 고해상도 모드로 처리됩니다. 구조만 파악하면 되는 분류 작업에는 low로 충분하고, 실측 결과 이 한 줄이 입력 토큰을 82.7% 줄였습니다.

축소에 실패하면 원본을 반환합니다. 최적화 코드 때문에 파이프라인이 멈추면 안 되기 때문입니다.

5.4 원본과 전송본을 분리

여기서 한 가지 함정이 있었습니다. 축소한 이미지를 원본 대신 저장하면 안 됩니다.

영역을 잘라내는 좌표 계산은 원본 해상도를 기준으로 하기 때문입니다. 축소본으로 덮어쓰면 좌표가 어긋납니다. 그래서 모델에 보내는 사본만 축소하고 원본은 그대로 유지했습니다.

5.5 진행 상황을 상태에 실음

파이프라인 상태에 현재 단계와 세부 진행을 넣고, 프런트가 폴링으로 읽어 갑니다.

① 텍스트 추출   ✓
② 영역 분류     ✓
③ 요소 재생성   ● 5/8
④ 문서 조립     ○

막대 하나를 네 줄로 늘린 것이 아니라, 이미 있던 내부 단계를 그대로 드러낸 것입니다. 구조를 나눴기 때문에 보여줄 것이 생겼습니다. 단일 함수였다면 표시할 단계 자체가 없었습니다.

6. 실험 결과

6.1 구조

항목이전이후
서버 구성index.js 322줄 단일 파일오케스트레이터 405줄 + 도구 8종
실행 흐름요청 핸들러 내 순차 처리StateGraph 4노드
중간 산출물남지 않음상태에 보존
영역 처리순차병렬 (최대 4)
진행 표시퍼센트 1개단계 4개 + 세부 카운트

6.2 비용

분류 호출 하나를 조건별로 실측했습니다. 입력은 동일한 노트 이미지입니다.

설정입력 토큰1회 비용영역 8개
gpt-4o · detail 미지정822$0.002275$0.0182
gpt-4o · detail low142$0.000555$0.0044
gpt-4o-mini · detail low2,890$0.000443$0.0035

호출당 비용은 5.1배 줄었습니다. 영역 여덟 개짜리 페이지 기준으로 분류 비용이 $0.0182에서 $0.0035가 됩니다.

6.3 예상과 어긋난 두 가지

측정하지 않았다면 그대로 틀린 채 남았을 내용입니다.

첫째, 이미지 축소는 토큰을 줄이지 않았습니다.

해상도만 바꿔 가며 같은 조건으로 호출한 결과입니다.

해상도입력 토큰
4032×3024 (원본)822
2048×1536822
1536×1152822
768×576822

전부 같습니다. 비전 토큰은 서버 측에서 정해진 타일 규격으로 환산되므로, 그 규격을 넘는 해상도를 보내도 토큰은 늘지 않습니다. 축소로 줄어드는 것은 토큰이 아니라 업로드 용량과 전송 시간입니다. 실제로 6MB 사진이 0.9MB가 되어 응답이 4.0초에서 1.5초로 빨라졌습니다. 축소를 유지할 이유는 있지만, 그 근거는 비용이 아니라 지연 시간입니다.

둘째, `detail` 지정이 모델 교체보다 효과가 컸습니다.

같은 gpt-4o에서 detail: low를 지정하는 것만으로 입력 토큰이 822에서 142로, 82.7% 줄었습니다. 반면 gpt-4o-mini는 같은 이미지에 2,890토큰을 부과했습니다. 모델마다 이미지 토큰 환산 방식이 다르기 때문입니다.

정리하면 이렇습니다.

  • 토큰 기준으로는 gpt-4o + low가 가장 적습니다 (142)
  • 비용 기준으로는 gpt-4o-mini + low가 가장 쌉니다 ($0.000443)

gpt-4o-mini는 토큰이 20배 많지만 단가가 그보다 더 낮아 총액에서 이깁니다. 토큰 수와 비용이 같은 방향으로 움직이지 않으므로, 둘 중 무엇을 최적화하는지 먼저 정해야 합니다.

이 프로젝트에서는 비용을 기준으로 gpt-4o-mini를 택했습니다. 다만 차이가 $0.0009로 크지 않고, 분류 품질이 떨어지면 MODEL_CLASSIFY로 되돌립니다.

7. 결론 및 시사점

7.1 구조를 나누면 측정 지점이 생깁니다

가장 크게 얻은 것은 성능이 아니라 관찰 가능성입니다.

단일 함수일 때는 "결과가 이상하다"는 관찰밖에 할 수 없었습니다. 단계를 나눈 뒤에는 어느 단계의 출력이 어긋났는지 확인할 수 있습니다. 개선을 시도할 때 무엇을 바꿨고 무엇이 달라졌는지 대응시킬 수 있게 되었습니다.

진행률 UI도 같은 맥락입니다. 없던 정보를 만든 것이 아니라, 이미 내부에 있었으나 밖으로 나올 통로가 없던 정보를 드러낸 것입니다.

다만 이것을 "처음부터 나눴어야 했다"로 읽지는 않습니다. 나눌 지점을 알려면 무엇이 단계인지 알아야 하고, 그건 한 번 만들어 본 뒤에야 보입니다. 초기 구조가 남긴 것은 부채가 아니라 경계를 어디에 그어야 하는지에 대한 정보였습니다. 네 단계로 나눈 기준(5.1)은 설계에서 나온 것이 아니라 아홉 달 동안 이 코드를 고치면서 반복해서 걸린 지점에서 나왔습니다.

구조는 좋고 나쁨이 아니라 목적과 맞는지의 문제입니다. 검증이 목적일 때 한 파일은 빠른 구조였고, 개선이 목적이 되자 느린 구조가 되었습니다. 바뀐 것은 코드의 품질이 아니라 코드에 요구하는 일입니다.

7.2 비용은 모델 선택이 아니라 호출 구조에서 결정됩니다

절감의 대부분은 프롬프트를 줄여서 나오지 않았습니다. 효과가 컸던 순서는 이렇습니다.

  1. 비전 `detail` 지정. 한 줄로 입력 토큰 82.7% 감소
  2. 반복되는 호출의 모델 교체. 분류는 영역 수만큼 곱해집니다
  3. 응답 길이 상한

"어떤 호출이 몇 번 일어나는가"를 먼저 세는 것이 프롬프트를 다듬는 것보다 효과가 큽니다.

7.3 최적화는 단일 통로에서 강제해야 합니다

이미지 축소를 각 호출부에 맡겼다면 새 도구를 추가할 때 빠뜨렸을 것입니다. 선택적으로 적용되는 최적화는 시간이 지나면 적용되지 않습니다.

모델로 나가는 통로를 하나로 만들고 거기서 처리하면, 이후에 추가되는 코드도 자동으로 같은 규칙을 따릅니다. 같은 이유로 사용량 로깅도 저수준 호출 함수에 넣었습니다.

7.4 근거가 틀린 코드는 다음 사람을 잘못 이끕니다

이번에 가장 크게 배운 것은 재 보기 전까지는 모른다는 점입니다.

이미지 축소가 토큰을 줄인다고 생각했습니다. 비전 토큰이 해상도에 비례한다는 것은 널리 알려진 설명이고, 구현할 때 의심하지 않았습니다. 실제로 재 보니 해상도를 4032px에서 768px로 낮춰도 입력 토큰은 822로 동일했습니다.

축소 자체는 유지할 가치가 있었습니다. 응답이 4.0초에서 1.5초로 빨라졌기 때문입니다. 코드는 그대로 두고 근거만 바뀌었습니다.

동작이 맞으면 근거가 틀려도 드러나지 않습니다. 대신 다음 사람이 그 근거를 읽고 판단합니다. "토큰을 더 줄여야 한다"며 해상도를 1536에서 768로 내리면, 인식률만 떨어지고 비용은 그대로입니다. 잘못된 주석은 틀린 설명이 아니라 틀린 방향의 지시입니다.

그래서 이 경우 고쳐야 할 것은 코드가 아니라 주석이었습니다. 비용 최적화라고 적어 둔 것을 지연 시간 최적화로 바꿨습니다.

7.5 확신이 없을수록 되돌리는 비용을 낮춰야 합니다

모델과 해상도 상한을 환경변수로 뺀 것은 작은 결정이었지만 실효가 있었습니다.

분류 모델을 내린 판단이 옳은지는 실측 전까지 확정할 수 없습니다. 코드에 상수로 박아 두었다면 되돌리는 데 배포가 필요합니다. 값으로 빼 두었으므로 판단이 틀렸을 때 설정만 바꾸면 됩니다.

판단의 정확도를 높이는 것과 틀렸을 때의 복구 비용을 낮추는 것은 다른 작업입니다. 실측 전까지 확정할 수 없는 결정이라면 후자가 더 확실한 투자입니다.