투어비스 항공 수수료
Architecture
Stack
- Next.js 15
- React 19
- TypeScript
- TanStack Query
- Zustand
- OpenAI
External integrations
- Stella Tourvis API
- OpenAI GPT-4o
- Supabase 로깅
- Vercel
Highlights
- orderId 첫 글자로 공급사 분류 (DOM/LCC/GDS/NDC)
- Parser 16종 · LLM 폴백 2-way 라우팅
- 공통 FeeCalculator Strategy로 결과 수렴
문제정의
투어비스 상담사는 고객 전화를 받으면서 항공권 취소·변경 수수료를 즉석에서 안내해야 한다. 21개 항공사 × 4개 공급사(DOM·LCC·GDS·NDC)의 84가지 조합 — 각 항공사별로 운임 등급(BASIC/STANDARD/...)·통화·NO-SHOW 규정이 다르다. 사람이 매번 정확히 외울 수 없다.
초기 시스템은 GPT-4o 단일 프롬프트로 21개 항공사 규정을 처리했다. 운영하며 세 가지 근본적 한계가 드러났다.
| 한계 | 수치 | 영향 |
|---|---|---|
| 속도 | 건당 평균 53.5초 | 1분 가까운 대기 — 통화 흐름에서 사용 불가 |
| 비용 | 69M 토큰 누적 | 일평균 454건 × 매 호출 = 비용 증가 곡선 |
| 비결정성 | 같은 OrderId 두 번 조회 시 금액이 달라지는 케이스 | 상담사 신뢰도 손상 |
핵심 재정의: "AI를 어떻게 최적화할까"가 아니라 "엔진 자체를 교체할까" 였다. 항공사 규정의 본질이 if/else 분기의 집합이라면, 확률적 AI 추론보다 결정론적 파싱이 구조적으로 더 적합하다.
구현
Phase 1 → 2 → 3 진화
7개월 동안 시스템은 세 단계로 진화했다. 각 단계는 직전 단계의 누적된 운영 데이터가 강제한 전환이었다.
Phase 1 → 2 (7d6e9d0f): 단일 프롬프트에 에어아시아(AK)의 백분율 환불 규정을 추가했더니, 다른 LCC 항공사의 정액 수수료 계산에 사이드 이펙트가 생겼다 — 항공사별 프롬프트 25개로 격리.
Phase 2 → 3 (ff50c154): CoT + Few-shot으로 정확도는 잡혔지만 속도는 그대로. 프롬프트 최적화는 느린 엔진을 튜닝하는 것이지 엔진을 교체하는 것이 아니다 — 엔진 자체를 결정론적 파서로 교체.
Strategy + Builder + Template Method
세 가지 디자인 패턴을 조합해 공급사 4개 × 항공사 21개 조합을 관리한다.
- Strategy —
FeeCalculationStrategy인터페이스로 공급사별(DOM/LCC/NDC/GDS) 전략 분리. AK(에어아시아)·BX(에어부산)는 LCC 내부 특수 규정 때문에 별도 전략으로 등록 우선순위 보유. - Builder — Fluent Interface로 컨텍스트 단계 조립:
FeeCalculatorBuilder.create().withData(...).withSupplier("LCC").withAirline("TW").build().calculate() - Template Method —
BaseFeeStrategy가 흐름(준비 → 그룹화 → 통합 → 정렬 → 집계)을 고정하고 각 전략이consolidateFees()등을 오버라이드. 파서 측도BaseParser상속 (DOM 2·LCC 8·GDS 5).
확장 포인트: 새 항공사 추가 시 수정 파일 3개(파서·프롬프트·JSONL)뿐. 기존 Strategy/Calculator 코드는 안 건드린다 (OCP).
AI를 보조 역할로 재배치
AI를 제거하지 않고 세 가지 역할로 재배치한 것이 핵심이었다.
| 역할 | 동작 | 트리거 |
|---|---|---|
| 폴백 엔진 | Phase 2의 25개 프롬프트가 작동 | 파서가 처리 못 하는 5% 케이스 (미지원 항공사, 비정형 규정) |
| 검증 게이트 | 27개 검증 프롬프트로 파서 결과 교차 검증 | 파서 정확도 지속 모니터링 |
| 데이터 생성기 | 비정형 규정을 JSONL로 구조화 | 신규 항공사 추가 시 파서 입력 생성 |
Phase 2에서 만든 25개 프롬프트는 폐기되지 않고 위 세 역할로 재활용됐다.
도메인 모델링
- Discriminated Union —
CancellationFee = DaysBeforeFee | RelativeTimingFee. 항공사 규정이 "N일 전"과 "출발 N시간 전"을 혼용하는 현실을 두 축으로 인정. (47d10475, 3번의 revert 끝에 정착) - MultiCurrencyTotal —
{ fees: CurrencyAmount[], isSingleCurrency }스키마로 외화 수수료 관통. 29개 파일 변경. (31f84c5e) - 7개 커스텀 에러 +
isRetryable— 재시도 가능 여부를 타입 레벨에서 결정.ParsingFailedError만 폴백 트리거. (509390a1) - MU 구간 불일치 —
selectMostRestrictiveFee()로 보수적 추정. 외부 데이터 불일치는 수정이 아니라 대응 전략을 설계하는 문제. (e0ec44c0)
출시
운영 중인 시스템(일평균 454건)을 한 번에 전환할 수는 없었다. 공급사별 단계적 마이그레이션으로 트래픽 비중이 높은 순서대로 전환하면서, 각 단계마다 검증 대시보드로 95% 일치율 게이트를 통과해야 다음으로 넘어가게 했다.
핵심 안전장치는 자동 폴백이다. 95% 미만이면 자동으로 AI 폴백으로 우회 — 파서 버그가 나도 서비스는 중단되지 않는다. 검증 대시보드(/parser/validation)에서 운영 OrderId를 대량 입력하면 p-limit으로 3건씩 병렬 처리하면서 genByAPI vs genByParser 일치율을 실시간 집계한다.
운영 실적 (57일)
- 총 처리: 25,851건 · 일평균 454건 · 최대 720건
- 국내선(B): 2,473건 (9.6%)
- 해외선(C): 23,378건 (90.4%)
- 343개 테스트 케이스 (22 파일) 지속 검증
결과학습
| 항목 | Before (Phase 1) | After (Phase 3) | 개선 |
|---|---|---|---|
| 처리 속도 | 53.5초 / 건 | 12.3초 / 건 | ↓ 77% |
| 토큰 비용 | 69M | 19M | ↓ 72% |
| 테스트 케이스 | 145개 | 343개 | ↑ 136% |
| 결정성 | 같은 OrderId, 다른 금액 | 같은 OrderId, 같은 금액 | 상담사 신뢰 회복 |
기술적·전략적으로 가져간 학습은 다섯 가지다.
- 구조화된 규제 데이터는 결정론적 파싱이 더 적합하다. AI의 강점은 비정형 데이터 해석이지 정형 규칙의 반복 적용이 아니다.
- AI는 제거가 아니라 재배치. 주 엔진에서 빼고 폴백·검증·데이터 수집으로 옮기니 오히려 AI의 가치가 분명해졌다 — 파서가 못 푸는 5%에서 결정적 역할.
- 도메인 모델은 현실을 단순화하지 말고 인정한다. "시간 기준 단일 축으로 통일"이라는 깔끔한 설계 3번 revert 끝에 "두 축을 Union 타입으로 인정"이 정답이었다.
- 에러도 도메인 모델이다.
instanceof+isRetryable플래그 조합으로 폴백 분기를 컴파일 타임에 결정. 에러 메시지 문자열 비교는 오타에 취약하고 리팩토링에 깨진다. - 마이그레이션은 리스크 관리. 완벽한 전환보다 안전한 전환. 단계적 + 자동 폴백으로 "실패해도 서비스는 유지"되는 구조가 운영의 본질.
Next
- 검증 대시보드 자동화 — CI에 343개 테스트 + genByParser vs genByAPI 일치율 게이트를 묶어 회귀 차단.
- 신규 항공사 추가 파이프라인 — AI 데이터 생성기가 만든 JSONL을 자동으로 파서 입력에 합류시키는 셀프-서브 흐름. 신규 항공사 온보딩이 코드 PR 한 번으로 끝나는 것이 목표.
- 다중 통화 환율 통합 — KRW 단일 화면 vs 외화 분리 표시의 UX 결정을 운영 데이터로 검증.