내가 AI 여행 플래너를 구축하며 배운 점과 실수했던 부분들
요약
Gemini를 활용한 AI 여행 플래너 Kartografer 구축 과정에서의 설계 원칙과 시행착오를 다룹니다. 데이터 무결성을 위한 상태 관리, AI의 직접적인 데이터 수정 방지 전략, 긴 일정 생성을 위한 청크 단위 처리 방식을 설명합니다.
핵심 포인트
- isSelected 플래그를 활용한 단일 데이터 구조 설계
- AI가 데이터를 직접 수정하지 않고 제안(proposal)만 생성하는 안전한 흐름 구축
- 토큰 제한 및 타임아웃 방지를 위한 청크 단위 생성 및 Zod 검증
- API 오류와 콘텐츠 오류를 구분한 효율적인 API 키 로테이션 전략
- 레이아웃 유지를 위해 Playwright를 활용한 Chromium 기반 PDF 생성
저는 Kartografer를 구축했습니다. 사용자가 여행을 설명하면 Gemini가 일자별 전체 일정(itinerary)을 생성해 주는 AI 여행 플래너입니다. 생성된 일정은 편집할 수 있고, 제안을 받기 위해 AI와 채팅할 수 있으며, 공개 링크를 공유하거나 PDF 여행 제안서로 내보낼 수 있습니다.
가장 중요했던 결정 사항들은 다음과 같습니다.
모든 것을 하나로 묶어준 단 하나의 규칙
모든 일정 항목에는 하나의 불리언(boolean) 값이 있습니다: isSelected.
isSelected: true → 최종 계획
isSelected: false → 옵션/제안
그게 전부입니다. 하지만 이 단 하나의 필드가 앱 전체에 흐릅니다. 예산은 선택된 항목들만 합산합니다. 공개 페이지, 공유 링크, PDF 내보내기는 선택된 항목들만 보여줍니다. 어떤 항목을 옵션 패널로 옮기는 것은 그것을 삭제하는 것이 아니라, 단지 플래그(flag)를 전환하는 것뿐입니다.
이 규칙이 없었다면 초안(draft)과 최종 콘텐츠를 위한 별도의 테이블이 필요했거나, 조건부 로직(conditional logic)이 곳곳에 흩어져 있었을 것입니다. 하나의 필드가 구조를 깔끔하게 유지해 주었습니다.
AI가 여행 일정을 직접 수정하지 않는 이유
뻔한 접근 방식은 이렇습니다: 사용자가 메시지를 보냄 → AI가 데이터베이스를 업데이트함. 간단하죠. 하지만 완전히 틀린 방식입니다.
만약 AI가 사용자의 일정을 직접 변경(mutate)할 수 있다면, 단 한 번의 잘못된 응답이 사용자의 계획을 소리 없이 망가뜨릴 수 있습니다. 그래서 저는 대신 제안 흐름(proposal flow)을 구축했습니다:
사용자가 채팅 메시지 전송
→ Gemini가 답변 + 구조화된 proposedChanges JSON 반환
→ 제안이 PENDING 상태로 저장됨
...
채팅 액션은 일정에 절대 손을 대지 않습니다. 적용(apply) 액션은 제안을 그대로 신뢰하지 않으며, 모든 것을 처음부터 다시 검증(validate)합니다. 최악의 경우, AI가 쓰레기 값(garbage)을 반환하더라도 사용자는 잘못된 제안 카드를 보고 이를 거절하면 그만입니다. 여행 일정은 그대로 유지됩니다.
장기 여행에는 청크 단위의 생성(chunked generation)이 필요함
14일간의 일정은 매우 큰 JSON 구조입니다. 한 번의 Gemini 요청으로는 토큰 제한(token limits)과 타임아웃(timeouts)의 위험이 있습니다. 따라서 긴 여행은 순차적인 청크(chunks) — 한 번에 며칠씩 — 로 나뉩니다. 각 청크는 다음 단계로 넘어가기 전에 Zod 스키마(schema)를 통해 검증됩니다.
신뢰성을 위해, 재시도 가능한 오류(429, 500, 502)가 발생하면 세 개의 Gemini API 키를 교체(rotate)합니다. 제가 잘한 점 하나는, 유효하지 않은 JSON 응답(invalid JSON responses) 시에는 키를 교체하지 않도록 설정한 것입니다. 이는 API 문제가 아니라 콘텐츠의 문제이기 때문입니다. 잘못된 출력값 때문에 키를 교체하는 것은 단순히 할당량(quota)만 낭비하는 일입니다.
PDF 내보내기 — 라이브러리를 사용하지 않은 이유
몇 가지 HTML-to-PDF 라이브러리를 시도해 보았습니다. 하지만 결과물은 일관되게 깨져 있었습니다. 폰트가 누락되거나 레이아웃이 무너지는 식이었죠. 대부분의 라이브러리는 실제 브라우저 엔진을 실행하지 않기 때문에, 계산된 스타일(computed styles)이나 사용자 정의 속성(custom properties)에 의존하는 모든 기능이 작동하지 않았습니다.
해결책은 실제 Chromium을 사용하는 Playwright였습니다. API 라우트가 이를 헤드리스(headless) 모드로 실행하고, 내보내기 미리보기 페이지로 이동한 뒤, 이를 A4 크기로 캡처합니다. 미리보기 페이지와 PDF는 동일한 템플릿을 사용하므로, 화면에 보이는 것이 정확히 다운로드되는 결과물과 일치합니다.
무료 티어의 장벽
Kartografer는 Gemini의 무료 티어(free tier)에서 작동합니다. 다중 키 교체(multi-key rotation) 방식은 분당 제한(per-minute limits)에는 도움이 되지만, 일일 제한(daily caps)에는 도움이 되지 않습니다. 실제 트래픽 양이 조금만 늘어나도 금방 한도에 도달하며, 현재로서는 돈을 들여 이 문제를 해결할 예산이 없습니다.
그래서 설계를 통해 이를 우회하는 방법을 고민하고 있습니다:
- 공통 일정 캐싱 (Cache common itineraries) — '파리 7일 저예산 여행' 같은 일정은 매번 새로 생성할 필요가 없습니다.
- 탐색(Explore) 기능을 시드(seed)로 활용 — 처음부터 새로 생성하는 대신, 이미 게시된 기존 일정을 수정하여 사용합니다.
- 비동기 큐 (Async queue) — 요청 도중 실패하는 대신, 급증하는 트래픽을 완만하게 처리합니다.
- BYOK (Bring Your Own Key) — 사용자가 자신의 Gemini 키를 연결하여 무제한으로 개인적인 생성을 할 수 있도록 합니다.
이 중 구현된 것은 아직 없습니다. 하지만 엄격한 리소스 제약은 AI를 다르게 생각하게 만듭니다. 즉, AI를 무제한으로 쓸 수 있는 유틸리티가 아니라, 신중하게 사용해야 하는 자원으로 인식하게 합니다.
다시 한다면 다르게 할 점
AI 제안 스키마(proposal schema)를 먼저 정의하겠습니다. 저는 제안(proposal)의 형태가 확정되기 전에 채팅 기능을 먼저 만들었습니다. 그 결과, 나중에 채팅 액션에서 직접적인 일정 수정(itinerary mutations)을 제거하기 위해 리팩터링(refactoring)을 해야 했습니다. 스키마부터 시작했다면 전체적인 통합 과정이 훨씬 깔끔했을 것입니다.
전체 소스 코드는 GitHub에서 확인할 수 있습니다. 라이브 서비스는 kartografer.com에서 확인하세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기