왜 LangGraph 도입 후 스파게티가 되었나: 실패에서 배운 구조화
목차
이 글은 LangGraph를 어떻게 쓰는지가 아니라, 어떻게 잘못 썼는지에 대한 기록이다.
프레임워크를 도입하고도 코드가 나빠질 수 있다. 그 실패가 오히려 구조를 가르쳐 줬다.
이 글은 LangGraph 시리즈의 네 번째 글이다. 앞선 세 글이 컴포넌트의 존재 이유(1편), 프로덕션 설계 패턴(2편), Deep Agents 하네스(3편)를 다뤘다면, 이 글은 그 반대편에 있다. 내가 직접 만든 시스템이 LangGraph를 도입하고도 스파게티가 된 이야기다.
백엔드는 NestJS, 워크플로우 엔진은 TypeScript 기반 @langchain/langgraph. “LangGraph를 쓰면 상태 관리와 흐름 제어가 깔끔해지겠지”라는 기대로 시작했다. 결과는 정반대였다.
도입했는데 왜 더 나빠졌나
리팩터링에 앞서 진단부터 했다. 워크플로우 실행을 담당하는 Executor 계층의 코드 규모는 이랬다.
| Executor | 코드 줄 수 | LangGraph 사용 |
|---|---|---|
| Executor A | 4,094줄 | ❌ 미사용 (LLM 직접 호출) |
| Executor B | 2,602줄 | ⚠️ 형식적 |
| Executor C | 1,595줄 | ⚠️ 형식적 |
| 공통 베이스 | 401줄 | — |
| 합계 | 8,692줄 |
실질적인 핵심 로직은 “LLM 호출 한 줄 + 프롬프트 조립 100여 줄”이 전부였다. 나머지 90%는 상태 관리, 조건 분기, 에지케이스 패치였다. 프레임워크를 도입하면 줄어야 할 보일러플레이트가 오히려 폭증한 것이다.
LangGraph를 도입한 두 Executor의 그래프는 이렇게 생겼다.
// "LangGraph를 쓴다"던 코드의 실체
const graph = new StateGraph<WorkflowState>({ channels: { /* 14개 */ } });
graph.addNode('current_step', (state) =>
this.executeDialogueNode(state, step, currentStep) // 내부에서 llm.invoke() 직접 호출
);
graph.addEdge('current_step', END); // 노드 → 바로 END
graph.setEntryPoint('current_step');
const compiled = graph.compile({ checkpointer: this.checkpointer });
const result = await graph.invoke(initialState, config);노드가 단 하나다. current_step → END. 조건부 엣지도, 분기도, 병렬 실행도 없다. graph.invoke()를 호출하는 것은 executeDialogueNode()를 직접 호출하는 것과 완전히 동일하다. LangGraph는 아무 일도 하지 않는 껍데기였다.
스파게티의 세 가지 얼굴
① LangGraph의 기능을 하나도 쓰지 않았다
LangGraph의 존재 이유는 조건부 엣지(동적 라우팅), 내장 체크포인터, Human-in-the-Loop(interrupt/resume)다. 그런데 내 구현에서는 이 세 가지가 전부 그래프 바깥에 있었다.
Step 전환이 대표적이다. “다음 단계로 넘어갈지”를 그래프 안의 조건부 엣지가 아니라, 서비스 레이어의 코드가 판단했다.
현재 (스파게티):
WorkflowService.chatInCurrentStep()
└─ executor.executeTurn(currentStep) ← 외부에서 Step 번호 결정
└─ result.nextStep 확인
└─ executor.executeTurn(nextStep) ← 외부에서 다시 호출
이상적:
LangGraph 내부에서
Step0 →[조건]→ Step1 →[조건]→ Step2 → ... → Complete
(그래프가 Step 전환을 스스로 처리)그래프가 “현재 Step 하나”만 표현하니 그래프 밖에서 전환을 관리할 수밖에 없고, 전환을 밖에서 관리하니 그래프를 조건부 엣지로 키울 이유가 사라진다. 도입 자체가 자기모순에 빠져 있었다.
② 이중 체크포인팅
같은 세션 상태를 두 시스템이 동시에 저장하고 있었다.
1. LangGraph MemorySaver (Executor 내부, 인메모리, 프로세스 재시작 시 소멸)
2. 자체 CheckpointerService (WorkflowService, PostgreSQL, 영구)
→ 동일 세션을 2번 저장, 2번 복원
→ MemorySaver의 state ≠ PostgreSQL의 state 불일치 가능
→ 어느 쪽을 신뢰해야 하는지 아무도 모름여기에 인메모리 sessionCache, params로 전달되는 stepData, 그 안의 dialogueState까지 더하면 워크플로우 상태가 다섯 군데에 흩어져 있었다. 버그를 재현하려면 다섯 곳의 상태를 동시에 머릿속에 그려야 했다.
③ 같은 로직을 세 번 구현
세 Executor는 프롬프트 조립, 확인 메뉴 처리, 완료 마커(---CONTENT_START---) 추출, <think> 태그 제거 같은 동일한 로직을 각자 다른 방식으로 재구현하고 있었다. 추정 중복률 40~60%.
결과는 명확했다. 한 Executor에서 버그를 고치면 나머지 둘에는 반영되지 않는다. 한 이슈가 Executor C에만 패치되고 Executor B에는 빠지는 사고가 반복됐다.
복잡성은 어떻게 자라났나
스파게티는 한 번에 만들어지지 않는다. 진화 과정을 되짚으면 이렇다.
| 단계 | 무슨 일이 있었나 |
|---|---|
| 1 | Executor A 하나로 시작. LangGraph 없이 LLM 직접 호출, 대화 상태를 수동 관리 |
| 2 | ”LangGraph를 활용하자”며 StateGraph 도입. 그러나 기존 외부 Step 관리와 충돌해 노드 1개로 축소 |
| 3 | 기능 추가마다 조건 분기를 덧댐 — 워크플로우별 전용 로직, 확인 메뉴 다단계, 완료 마커, CSV 동적 로드 |
| 4 | 에지케이스 패치 누적 — 마커 정규화, 모델별 태그 제거, 직렬화 호환, 쓰이지 않는 레거시 메서드 잔재 |
근본 원인은 하나로 수렴한다. 프레임워크의 핵심 기능을 활용하지 못한 채 도입했다는 것. LangGraph를 껍데기로만 두니 그것이 해줬어야 할 일(Step 전환, 체크포인팅, 사용자 대기)을 전부 손으로 다시 구현했고, 그 수동 구현이 매 기능마다 조건 분기로 불어났다. 프레임워크를 “쓰긴 썼는데 안 쓴” 상태가 가장 나쁜 조합이었다.
실패가 가르쳐 준 구조화 원칙
해부가 끝나자 방향은 오히려 선명해졌다. 시리즈 1편과 2편이 “왜”와 “패턴”을 다뤘다면, 아래는 그 이론을 실패로 검증한 뒤 실제로 채택한 결정들이다.
원칙 1 — Standalone 토폴로지: 안 쓸 라우터는 두지 마라
처음엔 상위 그래프(Parent)가 라우터로 하위 그래프(SubGraph)를 분기하는 2계층 구조를 택했다. 하지만 라우터 노드의 실체는 Command({ goto: state.agentId }) 한 줄이었다. 다음 에이전트는 이미 서비스 레이어에서 결정돼 들어오므로, 라우터는 같은 결정을 그래프 안에서 한 번 더 반복할 뿐이었다.
교훈은 이렇다. 라우터를 둘지는 “LLM이 동적으로 다음 노드를 결정하는가”로 판단한다. 외부에서 결정돼 들어오면 라우터는 잉여다. 그래서 Parent를 없애고, 단일 AgentGraph를 thread_id로만 격리하는 Standalone 토폴로지로 전환했다.
| 항목 | Parent+Sub | Standalone |
|---|---|---|
| 체크포인트 계층 | 2-layer | flat |
| 라우터 노드 | 필요 | 제거 |
| 공유 State 정의 | 필요 | 불필요 |
| 그래프 인스턴스 | 프로젝트별 캐시 | 단일 컴파일 → 전 에이전트 공유 |
그래프 구조가 모든 에이전트에 동일하면, 에이전트 간 차이는 그래프가 아니라 State 값(프롬프트, 워크플로우 정의)에서 결정된다. 부트 시 한 번 컴파일한 인스턴스를 thread_id만 바꿔가며 전 에이전트가 공유한다.
// 부트 시 1회 컴파일
this.compiledGraph = buildAgentGraph(...).compile({ checkpointer: postgresSaver });
// 모든 invoke에서 동일 인스턴스 재사용, thread_id만 다르게
await this.compiledGraph.invoke(state, {
configurable: { thread_id: `${projectId}:${agentId}:${workflowId}` },
});원칙 2 — 동시성은 락이 아니라 thread 분리로 푼다
이 프로젝트에서 가장 크게 데인 부분이다. 직관과 반대로, 같은 thread에 동시에 invoke하는 것은 “덮어쓰기”가 아니라 “브랜치 경쟁”이다. Git의 detached HEAD에서 동시 커밋이 나는 것과 똑같다.
t0: invoke A 시작 (head = chk0)
t0: invoke B 시작 (head = chk0, 같은 head에서 분기)
t1: A → chk-A1 기록 (parent = chk0)
t1: B → chk-B1 기록 (parent = chk0)
t2: A 완료 → head를 chk-A_final로 갱신
t2: B 완료 → head 갱신 시도 → 패배 (이미 A가 가져감)
결과: B의 모든 state 변경이 head에서 사라짐. 고아 브랜치로 전락.락으로 “완화”는 가능하다. 하지만 워크플로우별로 thread_id를 분리하면(${projectId}:${agentId}:${workflowId}) 다른 워크플로우끼리는 경쟁 자체가 구조적으로 불가능해진다. 완화가 아니라 원천 차단이다.
원칙 3 — State는 가볍게, 본문은 Store로
LangGraph 체크포인터는 diff가 아니라 매 노드마다 full snapshot을 직렬화한다. State에 무거운 본문(문서 전문, 산출물 원본)을 넣으면 노드마다 그 크기만큼 DB write가 발생한다.
| State 구성 | 노드당 크기 | 30턴 대화 누적 |
|---|---|---|
| 메타데이터만 | ~5KB | 750KB |
| 본문 포함 | ~260KB | 39MB |
50배 차이다. 원칙은 명확하다 — State에는 step 제어 + messages + 설정만 남기고, 비즈니스 데이터는 전부 Store로. Store는 thread와 무관한 네임스페이스(['projects', projectId, 'artifact', ...])에 자체 영속되며, 필요할 때만 lazy load한다. 이 분리 하나로 체크포인트가 극적으로 가벼워진다.
원칙 4 — regex JSON 파싱을 tool_use로 대체
초기에는 LLM이 응답에 {"skill": "..."} JSON을 뱉으면 정규식으로 파싱했다. LLM의 환각과 형식 이탈로 끊임없이 깨졌다. LangChain 네이티브 bindTools + tool_use로 교체하자 이 문제가 통째로 사라졌다. 구조화 출력이 필요하면 withStructuredOutput + Zod로 스키마를 강제한다. LLM 출력을 문자열로 긁지 말고, 프레임워크가 제공하는 도구 호출 규약을 쓴다.
원칙 5 — 단일 노드를 역할별로 쪼갠다
레거시는 단일 노드에 8단계 로직이 다 들어 있었다. 이를 역할별 5노드(Context / Terminal / Validation / Interrupt Check / Output)로 분리했다. 얻은 것은 네 가지다.
- 테스트 용이성 — 노드별 단위 테스트가 가능해진다
- 재시도 명확성 — “검증 실패 → 재실행” 루프백을 엣지로 선언적으로 표현
- 체크포인트 세분화 — 어느 노드에서 멈췄는지 정확히 알 수 있다
- 진행 표시 — 노드 진입 이벤트를 그대로 사용자 진행 메시지로 중계
노드 1개짜리 그래프가 “LangGraph를 안 쓰는 것”이었다면, 역할별 노드 분리는 비로소 그래프가 그래프답게 일하기 시작한 지점이었다.
회고: 한 줄로 남기는 교훈
| 영역 | 핵심 |
|---|---|
| 도입 판단 | 프레임워크의 핵심 기능을 쓸 게 아니면 도입하지 마라. 껍데기 도입이 최악 |
| State | full snapshot 직렬화. 가볍게 유지, 본문은 Store로 |
| 체크포인터 | 하나만. 이중 체크포인팅은 상태 불일치의 지름길 |
| 동시성 | 같은 thread = 브랜치 경쟁. thread 분리가 가장 확실 |
| 라우터 | 외부에서 결정된 라우팅이면 잉여. Standalone 검토 |
| Tool Use | regex 파싱 금지, bindTools + tool_use |
| 노드 | 단일 거대 노드 대신 역할별 분리 → 테스트·재시도·관측성 |
돌아보면 스파게티의 원인은 LangGraph가 아니었다. 프레임워크가 대신 해줄 일을 손으로 다시 구현하면서, 그 수동 구현이 기능마다 조건 분기로 불어난 것이 원인이었다. 프레임워크는 “도입했다”가 아니라 “그 설계 의도대로 썼다”일 때만 값을 한다. 8,692줄이 가르쳐 준 건 결국 그 한 문장이었다.
그런데, 왜 이렇게까지 됐나
여기까지가 기술적 진단이라면, 한 층 더 내려가면 더 불편한 질문이 남는다. 왜 이런 구조가 만들어졌는가.
솔직히 고백하면 이 시스템은 대부분 바이브 코딩으로 만들어졌다. 내부 코드를 깊이 들여다보지 않고, 아키텍처나 설계 원칙을 명시하지 않은 채, 매번 요구사항과 의도만 정리해서 구현을 이어갔다. 그러니 “바이브 코딩이 원인이었나?”라고 물을 수 있다. 하지만 그렇게 단정하는 건 정확하지 않다.
문제는 바이브 코딩 그 자체가 아니라, 검증 없는 바이브 코딩이었다. AI가 쓴 코드든 사람이 쓴 코드든, 설계 철학에 맞게 구조를 검증하지 않으면 복잡성은 더 빨리 쌓인다. AI는 리팩터링보다 증분 개발에 강하다. 그래서 초반엔 멀쩡히 굴러가도, 요구사항만 계속 얹다 보면 AI는 눈앞의 기능을 로컬 최적화할 뿐 전체 구조의 책임 경계는 지켜주지 않는다. 이중 체크포인팅도, 다섯 군데로 흩어진 상태도, 세 번 중복된 로직도 전부 그 로컬 최적화의 누적이었다.
그렇다고 “설계자가 없었다”는 말도 맞지 않는다. 설계자는 있었다. 다만 프레임워크가 무엇을 책임지도록 설계됐는지를 충분히 이해하지 못한 채 그 위에서 지시만 내렸다. LangGraph를 신뢰하고 체크포인팅과 흐름 제어를 맡겼어야 하는데, 신뢰하지 못하니 그 위에 또 하나의 워크플로우 엔진을 손으로 얹었다. ‘설계자 부재’가 아니라 ‘프레임워크에 책임을 넘기지 못한 상태’가 더 정확한 표현이다.
그래서 이 경험에서 가장 크게 남은 교훈은 이렇다.
프레임워크를 쓴다는 건 API를 아는 게 아니라, 그 프레임워크가 무엇을 책임지도록 설계됐는지를 이해하는 것이다. AI는 구현은 잘하지만 아키텍처의 책임 경계까지 자동으로 지켜주지는 않는다. 중요한 프레임워크일수록, 그 설계 철학을 이해한 사람이 무엇을 맡기고 무엇을 애플리케이션이 책임질지 먼저 결정해야 한다.
이건 LangGraph만의 이야기가 아니다. Spring, Kubernetes, Terraform — 무엇을 프레임워크에 맡기고 무엇을 직접 책임질지 사람이 먼저 명확히 긋지 못하면, 어떤 도구든 같은 방식으로 스파게티가 된다.
그렇다면 실전에서는 어떻게 막나. 현실적으로 AI의 빠른 속도를 체감하기 시작하면 사람은 자꾸 다음 기능으로 넘어가고 싶어지고, 아키텍처를 들여다보는 느린 흐름은 뒤로 밀린다. 그래서 나는 이걸 의지의 문제라기보다 프로세스의 문제로 본다. 기능 구현·테스트·버그 수정 같은 로컬 최적화는 빠른 AI 루프에 맡기되, “이 책임을 누가 져야 하는가” 같은 글로벌 최적화는 느린 아키텍처 루프로 따로 돌리는 것이다. 그리고 그 느린 루프조차 AI에게 리뷰어로 맡길 수 있다. 이를테면 2주에 한 번, 혹은 CI에서 주기적으로 “새로운 중복 코드가 있는지”, “상태 관리가 여러 곳으로 퍼졌는지”, “설계 철학에 어긋난 곳이 있는지”를 AI에게 되묻는 식이다. AI에게 코드를 쓰게 하는 것을 넘어, AI에게 코드를 비판하게 하는 쪽으로.
결국 이 글은 단순한 실패담이라기보다, AI 시대에 왜 아키텍트의 자리가 사라지지 않는지에 대한 한 사례에 가깝다.