Table of Contents
이어드림스쿨 8주차 개인 프로젝트로 여행 챗봇 에이전트를 만들었다. 검색·항공·숙소·일정·예약 확인을 별도 화면으로 나누지 않고 전부 채팅 화면 안에서 처리하는 구조로 잡았다. 이 글은 그때 백엔드 응답을 어떤 계약으로 정했고 그 계약이 프론트 reducer를 어떻게 바꿨는지 정리한 것이다.

왼쪽 대화에는 Day별 일정 카드가 쌓이고 오른쪽 “내 여행” 패널은 선택한 항목을 따라 갱신된다. 화면을 옮기지 않고 채팅 안에서 이어진다.
채팅 안에서 다 처리한다는 것
채팅 안에서 여행의 전 과정을 다루려면 에이전트 응답이 단순 텍스트일 수 없다. “항공이랑 숙소 보여줘” 한 번에 목적지 캐러셀, 항공권 목록, 숙소 목록이 함께 나와야 한다. 사용자 발화 하나가 여러 개의 카드로 펼쳐진다.

명소 후보가 카드 캐러셀로 대화 안에 뜬다. 카드마다 “담기”를 눌러 오른쪽 “내 여행”에 넣는다.
채팅 스트림에 카드를 렌더하는 것 자체는 이전에 구축했던 챗봇 프로젝트에서도 했다. 제품 카드, 비교 테이블, 견적 폼을 대화 안에 띄웠다. 이번에 정해야 했던 건 프론트와 백엔드 사이의 경계를 어디에 긋느냐였다.
초기 구조와 문제
초기엔 백엔드 없이 데모부터 돌리려고 제주·보홀 시나리오를 프론트 reducer에 하드코딩했다(scenarios/jeju.js, bohol.js). “이 발화 다음엔 이 카드, 그다음엔 저 카드”라는 분기가 reducer 안에 쌓였다. 시나리오를 하나 추가할 때마다 reducer가 무거워졌고 백엔드가 붙었을 때 이 분기들을 실제 응답과 어떻게 맞출지도 정해지지 않은 상태였다.
원인은 하나였다. 백엔드 응답과 프론트 렌더 사이에 경계가 없어서 프론트가 “다음에 뭘 그릴지”를 스스로 알고 있어야 했다. 그래서 백엔드가 바뀌면 프론트도 같이 흔들렸다.
응답을 계약으로 고정
백엔드 응답의 형태를 계약으로 고정했다. 최종 텍스트 하나를 주는 게 아니라 노드별 발화 리스트(turns)를 순서대로 준다. 각 발화는 네 필드다.
class AgentTurn(BaseModel):
"""대화 흐름 중 한 노드(에이전트)의 발화 (프론트 렌더 계약)."""
agent: str | None = Field(default=None, description="노드/에이전트 이름")
content: str = Field(..., description="발화 내용(텍스트)")
type: str = Field(default="text", description="렌더 카드 타입 (destination_carousel 등)")
payload: dict | None = Field(default=None, description="카드 데이터")
class ChatResponse(BaseModel):
answer: str # 에이전트 최종 응답
turns: list[AgentTurn] # 노드별 발화 순서 (coordinator·워커 등 전체)
conversation_id: str
agent: str | None # 최종 응답을 낸 노드 이름
필드마다 나눠 둔 이유가 있다.
turns를 리스트로 뒀다. 한 응답이 여러 발화로 펼쳐지기 때문이다. coordinator가 한마디 하고 명소 워커가 캐러셀을 반환한 다음 일정 워커가 타임라인을 반환하는 일이 한 응답 안에서 순서대로 일어난다. 이걸 answer 하나로 합쳐 내리면 프론트가 다시 쪼개야 한다. 애초에 쪼개진 채로 내려준다. 순서는 그래프에서 발화가 나온 순서 그대로다.type을 따로 뒀다. 프론트가 텍스트 내용을 보고 “이건 항공 목록 같은데” 하고 추측하게 두면 표현이 조금만 바뀌어도 렌더링이 깨진다.type은 백엔드가 명시하는 렌더 라벨이고 프론트는 이 라벨만 보고 어떤 카드 컴포넌트로 그릴지 정한다.content와payload를 나눴다.content는 사람이 읽는 발화이고payload는 카드 컴포넌트가 그릴 구조화 데이터다. 일반/chat응답에서는 둘 다 완성된turn안에 담겨 온다. 스트리밍 경로는 전송 방식이 다르다. 텍스트는text_start·text_delta·text_end이벤트로 나눠 보내고 카드는 완성된 뒤card이벤트로 한 번에 보낸다. 렌더 계약과 스트리밍 이벤트 계약을 같은 형태로 억지로 맞추지는 않았다.
type이 가리키는 카드 종류는 프론트와 백엔드가 같은 어휘를 공유한다. reducer 상단 주석에 그대로 적어뒀다.
백엔드 turns 계약: [{ agent, content, type, payload }]
type: text | destination_carousel | itinerary | flight_results | hotel_results | confirmation
각 카드 payload 는 프론트 컴포넌트 계약에 맞춰 백엔드에서 정형화되어 온다.
백엔드가 type을 채우고 프론트가 type → 컴포넌트로 매핑한다. 어느 쪽도 자기 마음대로 새 어휘를 만들지 않는다. 실제 매핑은 MessageRenderer.jsx가 맡고 카드 컴포넌트는 components/messages/ 아래 타입별로 나뉜다.
type | 렌더 컴포넌트 | 담기는 것 |
|---|---|---|
text | TextBubble | 말풍선 텍스트 |
destination_carousel | DestinationCarousel | 명소 후보 카드 목록 |
itinerary | ItineraryTimeline | Day별 일정 타임라인 |
flight_results | FlightResults | 항공편 목록 |
hotel_results | HotelResults | 숙소 목록 |
confirmation | ConfirmationCard | 예약 확정 내역 |
type이 문자열이라 잘못된 값이 들어가도 걸러지지는 않는다. 타입 시스템이 잡아 주는 게 아니라 양쪽이 약속을 지켜서 유지되는 계약이다. 처음에는 표에 적은 여섯 개로 시작했고 이후 백엔드 렌더 타입에 route_plan과 destination_reco가 추가됐다. 프론트에는 결제 화면을 띄우는 payment처럼 로컬 UI에서만 쓰는 타입도 따로 있다. 백엔드는 결제가 끝난 뒤 confirmation을 반환한다.


같은 turns 리스트라도 type이 flight_results면 항공 카드로 렌더되고 itinerary면 타임라인으로 렌더된다. 프론트는 type만 보고 컴포넌트를 고른다.
지난 프로젝트와는 경계를 다른 자리에 그었다
이전 챗봇 프로젝트에선 이 경계를 다른 자리에 그었다. 그때는 카드 데이터를 텍스트 content 안에 JSON 문자열로 실어 보내고 이게 텍스트인지 카드인지는 메시지에 붙은 interactionType 같은 enum으로 구분했다. 프론트는 content에서 코드 펜스로 감싼 JSON을 걷어내 파싱해 카드를 그렸고 렌더 순서는 클라이언트가 큐로 맞췄다. 한 필드에 텍스트와 카드를 함께 실으니 응답 스키마가 단순했고 카드 종류가 늘어도 필드 구조는 그대로 둘 수 있었다. 그 프로젝트 흐름에는 이 방식이 맞았다.
이번엔 type과 payload를 필드로 나눴다. content는 사람이 읽는 텍스트로 두고 렌더 종류는 type이 맡는다. 카드 데이터는 payload에 담는다. 사용자 발화 하나가 여러 turn으로 펼쳐지고 각 turn은 하나의 렌더 타입을 갖는다. 프론트는 turns에 담긴 순서대로 텍스트와 카드를 쌓는다. 같은 “카드를 대화에 넣는다”는 목표라도 응답이 어떻게 생겼느냐에 따라 경계를 긋는 자리가 달라진다.
계약이 생기니 reducer가 줄었다
계약이 생기자 reducer는 “다음에 뭘 그릴지”를 알 필요가 없어졌다. 백엔드가 준 turns를 그대로 믿고 메시지로 반영하기만 하면 된다. 하드코딩해 두던 시나리오 분기가 통째로 필요 없어졌다.
프론트-백엔드를 완전히 연동한 커밋에서 reducer가 이렇게 줄었다.

프론트-백엔드 완전 연동 커밋의 conversationReducer.js diff. scenarios/jeju·bohol import와 pendingAdvance·tokenCounter 같은 시나리오 하드코딩이 통째로 빠졌다.
131줄을 추가하고 527줄을 삭제해 파일 기준으로는 396줄이 줄었다. 제주·보홀 시나리오를 reducer 안에서 돌리던 코드를 걷어내고 실 API turns 기반으로 기본 동작을 바꾸면서 대부분이 사라졌다. 계약 하나가 이만큼을 줄인 건 아니다. 원래 프론트가 시나리오를 그만큼 떠안고 있었다. mock 파일은 지우지 않고 데모와 레퍼런스용으로 남겨뒀다.
남은 reducer는 네트워크 호출을 하지 않는다. 백엔드에서 받은 turns를 메시지 목록에 반영하고 카드 선택에 따라 오른쪽 “내 여행” 패널의 상태를 계산하는 일만 맡는다. 비동기 /chat 호출과 스트리밍 수신은 App.jsx의 useConversation 훅으로 뺐다. 덕분에 네트워크 처리와 화면 상태 전이를 따로 확인할 수 있게 됐다.
엄밀히 말하면 이 reducer가 완전한 순수 함수인 것은 아니다. 메시지 ID를 만드는 nextId()가 모듈 안의 카운터를 바꾸기 때문에 같은 turns를 다시 넣어도 ID까지 똑같지는 않다. 여기서 분리하려고 했던 것은 비동기 통신과 상태 계산이었다.
백엔드가 떠안은 부담
얻은 것만 있진 않다. 카드 payload를 백엔드가 만들기로 하면서 화면에 뜨는 것 중 꽤 많은 부분을 백엔드가 갖게 됐다. 항공 payload를 만드는 함수의 독스트링에는 “프론트 FlightResults roundtrip 계약”이라고 적혀 있다. 백엔드 서비스가 프론트 컴포넌트 이름을 알고 있는 셈이다. 최저가 배지에 붙는 “최저가”라는 문구와 숙소 카드 썸네일이 쓸 그라디언트 번호도 백엔드가 정해서 내려보낸다.
편도였던 항공을 왕복으로 바꿀 때 그 대가를 치렀다. 커밋 하나가 백엔드 5개 파일과 프론트 6개 파일을 같이 건드렸고 FlightResults.jsx만 188줄이 바뀌었다. payload에 outDep·inDep가 생기니 그걸 그리는 쪽도 함께 움직여야 했다.
혼자 양쪽을 다 만지는 지금은 이게 비용으로 느껴지지 않는다. 한 번에 고치니 오히려 빠르다. 다만 프론트와 백엔드가 나뉘어 있었다면 카드 하나를 바꾸는 데도 양쪽이 계약부터 맞춰야 한다. 필드 이름 하나, 배지 문구 하나에도 협의가 필요해지고 그게 쌓이면 카드를 늘리는 속도가 양쪽이 말을 맞추는 속도에 묶인다.
카드 데이터는 백엔드에서 만들었다
두 방식을 나란히 놓으면 카드 데이터를 누가 만들고 렌더 순서를 누가 맞추는지가 보인다. 이전 방식에서 백엔드는 데이터와 종류까지만 책임졌고 화면에 어떻게 보일지는 프론트가 맡았다. 카드 모양을 바꾸는 일은 프론트 안에서 끝났지만 JSON을 파싱하고 렌더 순서를 맞추는 일도 프론트가 담당했다.
이번 방식에서 프론트는 추측하고 파싱하고 순서를 맞추지 않는다. 그 대신 카드 payload를 조립하는 일을 백엔드가 맡았다. reducer에서 527줄이 빠진 그 커밋에서 백엔드의 payload 빌더(travel_service.py)에는 142줄이 새로 생겼다. 시나리오 하드코딩은 없어졌지만 카드 조립까지 없어진 것은 아니다. 담당하는 쪽이 프론트에서 백엔드로 바뀌었다.
중개 계층이 있던 구조와 없던 구조
이전 챗봇들은 Google ADK로 만들었다. 구조는 프론트 → WebFlux 중개 서비스 → ADK 에이전트의 3계층이었다. 가운데 WebFlux 서비스가 에이전트의 SSE 스트림을 프론트 렌더 계약으로 바꾸고 메타데이터와 대화 이력도 관리했다.
이번에는 별도 중개 서비스 없이 FastAPI 백엔드 안에 LangGraph 그래프를 바로 얹었다. 개인 프로젝트를 완성하는 데 필요한 구성만 두려는 선택이었다. ADK에서는 상위 에이전트가 하위 에이전트의 이벤트 스트림을 concat·merge로 조립했다. 이번 LangGraph에서는 노드와 엣지, 공유 State와 Command(goto=...)로 흐름을 제어했다.
오케스트레이션 방식은 달랐지만 응답과 화면 사이에 계약을 둔다는 점은 같았다. 이전에는 WebFlux 중개 서비스가 그 계약을 맡았고 이번에는 FastAPI 백엔드가 직접 맡았다.
다시 그린다면
이 구조를 혼자가 아니라 팀에서 쓴다면 세 가지를 손볼 것 같다.
지금 payload는 여러 클라이언트가 함께 쓰는 범용 API가 아니라 이 웹 화면을 위한 렌더 계약이다. 그래서 백엔드가 카드에 필요한 문구와 날짜 라벨까지 만들어 보낸다. 가격은 프론트에서 합계를 계산해야 해서 숫자로 남겼다. 웹 클라이언트 하나를 혼자 개발하는 지금은 이 경계로 충분했다. 다만 모바일 앱이나 다국어 화면처럼 같은 데이터를 다른 방식으로 보여줄 클라이언트가 생긴다면 계약도 달라져야 한다. 그때는 백엔드가 isCheapest와 날짜 같은 사실을 보내고 각 클라이언트가 표현을 정하는 구조를 검토할 것 같다.
type도 문자열에서 Literal로 좁히고 타입별 payload 모델을 두면 좋겠다. 지금은 타입 이름에 오타가 나도 응답은 그대로 나가고 프론트에서 카드가 안 그려지는 것으로만 뒤늦게 드러난다. 클라이언트가 따로 배포되기 시작하면 런타임 호환성도 정해야 한다. 백엔드에 새 type이 먼저 추가됐을 때 이전 프론트가 텍스트로 대신 보여줄지 카드를 건너뛸지 같은 규칙이 필요하다.
계약을 코드 주석에 흩어두지 않고 한 곳에서 관리하는 방법도 찾아야 한다. reducer 주석에 적어둔 백엔드 타입은 여섯 개인데 실제로 내보내는 타입은 여덟 개다. 나중에 추가한 route_plan과 destination_reco가 주석에는 반영되지 않았다. 프론트에는 payment 같은 로컬 UI 타입도 섞여 있어서 백엔드 계약과 프론트 내부 타입의 경계도 코드만 보고는 바로 알기 어렵다. 혼자 만들면서도 이만큼 달라졌으니 계약을 읽는 사람이 늘면 주석만으로 관리하기는 어려울 것이다.
남은 것
한계가 하나 있다. turns는 지금 DB에 그대로 저장하지 않고 대화 기록에는 최종 answer만 남긴다. 그래서 예전 대화를 다시 열면 텍스트는 되살아나도 그 안에 있던 항공·숙소 카드는 돌아오지 않는다. “대화 자체가 여행 기록”이 되는 게 이 프로젝트의 방향인데 그 기록의 절반인 카드가 재입장에서 빠지는 셈이다.
지금 만든 계약은 실시간 응답을 화면에 그리는 데까지만 이어져 있다. 대화를 여행 기록으로 남기려면 turns를 응답 계약에서 저장 계약까지 확장해야 한다. 어떤 카드를 어떤 순서로 보여줬는지 저장하고 대화를 다시 열 때 같은 렌더 계약으로 복원하는 일이 다음 숙제다.
여행 챗봇 시리즈
- #conversational-ui
- #langgraph
- #react
- #agent
- #frontend