← 목차 · 케이스 1
항공권 취소 수수료 자동 산출
2025.09 ~ 2026.06 · Next.js · TypeScript · FastAPI · OpenAI · Supabase · Cloud Run
팀 구성 — 개발 1(본인) · PO 1 · 운영 1 · 3인
상담사가 규정을 직접 읽어 계산하던 취소 수수료를 고객이 직접 조회하는 기능으로 옮기고,
응답 p50을 27.9초에서 104ms로(99.6%↓), 호출당 토큰을 2,709에서 302로(88.9%↓) 줄였습니다.
규정을 읽는 일을 LLM에서 파서로 한 항공사씩 옮겨서입니다.
이 문서는 배경 → 문제정의 → 해결방안 → 결과 → 정직하게 순서입니다. 이력서의 항공권 절과 같은 틀이고, 이력서의 각 줄은 아래 ①②③ 블록의 첫 문장입니다.
배경 — 상담사가 직접 읽어 계산하던 일
취소 수수료 문의는 원래 이렇게 처리됐습니다. 고객이 1:1 문의나 상담 채널로 묻습니다 → 상담사가 예약을 조회합니다 → 해당 항공사의 규정을 찾아 읽습니다 → 직접 계산합니다 → 답변합니다. 같은 질문이 1:1 문의에 반복해서 들어왔습니다.
이 흐름을 고객이 마이페이지에서 예약번호로 직접 확인하는 기능으로 옮기는 것이 이 프로젝트였습니다.
문제정의 — 왜 어려운가
한 문장으로 줄이면 고객이 조회하는 시점에 맞는 금액을 규정 문장에서 골라내는 일입니다. 규정은 출발까지 남은 시간에 따라 금액이 달라지는 표라서, 같은 예약도 언제 조회하느냐에 따라 답이 다릅니다. 금액을 틀리면 환불 클레임이 되고, 고객은 화면 앞에서 기다립니다. "대충 맞는 답"도, 배치 처리의 속도도 허용되지 않습니다. 이 일을 셋으로 나눴습니다.
① 규정을 어떤 데이터로 표현할까
규정은 사람이 읽으라고 쓴 자연어로 내려옵니다. 그걸 그대로 두고 항공사마다 계산 코드를 따로 짜면 화면·로그·검증·합산 로직이 전부 항공사별로 갈립니다. 모든 항공사 규정을 하나의 정형화된 형태로 표현해야 항공사 전부를 같은 방식으로 다룰 수 있다고 판단했고, 그래서 "무엇을 뽑을지"부터 정해야 했습니다. 그런데 그 형태가 간단하지 않았습니다.
- 규정이 두 가지 시간 표현을 섞어 씁니다. 계단식 기간표(
구매 다음날 ~ 출발 61일 전 : 60,000…출발 50분 전 ~ 출발 이후 : 210,000)와 전/후형(출발 이후,체크인 후)입니다. - 같은 항공사라도 국내 출발은 기간표, 해외 출발은 전/후 2단으로 옵니다. 통화도 원화에서 엔화로 바뀝니다.
- 기준이 일 단위가 아니라 분 단위인 곳이 있습니다. 출발 50분 전, 수속 마감.
- 다구간 여정은 구간마다 통화가 다를 수 있습니다.
② 그 데이터를 문장에서 어떻게 뽑을까
- 규정은 자유 텍스트로 옵니다. 사내 항공 시스템이 주는 것은 구조화된 값이 아니라 원문 덩어리입니다.
- 항공사·출발지·운임등급마다 문장 형식이 다릅니다. 공백이 든 도시명, 괄호, 쉼표, "각 구간당" 같은 전처리 순서, 환불 섹션 자체가 없는 규정까지 — 실데이터가 문서보다 지저분합니다.
- 고객이 기다리므로 대화형 응답 속도가 필요합니다.
③ 항공사마다 다른 정책과 계산법을 어떻게 한 시스템에 담을까
- 공급사 4종(DOM · LCC · NDC · GDS) × 항공사 16곳. 정책 축이 제각각입니다 — 운임등급별, 항공료의 백분율, 분 단위 시각, 구간당, 통화, 노쇼를 카운터에서 했는지 게이트에서 했는지.
- 여정 구간별 수수료를 하나로 합치는 방식도 공급사마다 다릅니다. 전 구간 합산, 첫 구간만, 가장 큰 값, 기준 시각만 첫 구간 출발일.
- 규칙을 한 곳에 모아 두면 한 항공사를 위한 수정이 전 항공사의 해석에 닿습니다.
이 셋이 겹쳐서, 규칙을 하드코딩하기에는 조합이 너무 많고 모델에 통째로 맡기기에는 정확도와 속도가 못 미치는 구간이었습니다.
해결방안 — 무엇을 했나
전체 그림부터 봅니다. 아래는 지금 돌고 있는 모습입니다. 어떻게 이 모양이 됐는지는 ①②③에서 다루고, 여기서는 조각의 위치만 봅니다.
고객은 마이페이지에서 계산기로 들어옵니다. 화면 뒤에는 산출 API가 따로 있고, 규정 원문과 예약 정보는 사내 항공 시스템에서 옵니다.
- 분기는 화면이 아니라 API 안에 있습니다. 화면은 예약번호를 보내고 금액을 받을 뿐, 파서로 갔는지 LLM으로 갔는지 모릅니다. 그래서 경로를 바꿔도 화면은 그대로입니다(→ ③).
- 내부 운영 화면이 같은 앱에 있습니다. 새 파서를 넣을 때 같은 곳에서 규정 원문을 확인하고 두 경로의 결과와 속도를 비교했습니다. 한 항공사씩 넓혀 갈 수 있었던 것은 이 화면 덕입니다.
- 운영 로그는 화면이 남깁니다. 응답 봉투에 어느 경로가 처리했는지와 얼마나 걸렸는지가 함께 실려 오기 때문에, 화면이 그대로 받아 적재합니다. 1년 뒤 경로별 비교가 가능했던 근거가 여기 있습니다(→ 결과).
시간순으로는 이렇게 지나왔습니다 — 2025-08 전 항공사 공용 프롬프트 → 09-04 항공사별 프롬프트 분리 → 09-25 첫 파서 → 10-28 파서 프로덕션 진입 → 11-12 등록 스위치 최종형 → 2026-06 외화 결함과 표본 재추출. 아래는 이 순서 대신 문제 ①②③ 순서로 묶었습니다.
① 규정을 담을 타입을 먼저 정하고, 파서와 LLM이 같은 형태로 답하게 했습니다
첫 LLM 호출부터 프롬프트에 "이 TypeScript 인터페이스에 맞는 JSON으로 답하라"는 지시가 있었습니다(2025-08-22). 타입은 규정을 만날 때마다 고쳤습니다. 나흘 뒤 "두 표시 방식은 동시에 쓸 수 없다, 하나만 골라라" 를 명시했고(08-26), 열흘 뒤 구간의 시작·끝과 시점 표현을 넓혔습니다(09-04).
최종 형태는 구분자를 둔 두 형태의 합집합입니다. 수수료 한 건은 둘 중 하나입니다.
// 기간형 — "출발 60일 전 ~ 6일 전 : 80,000"
{ display_type: "days_before",
range_start, range_end, range_start_unit: "day" | "hour" | "minute", range_end_unit: ...,
airline_fee, airline_fee_currency, refundable }
// 전후형 — "출발 이후", "체크인 후"
{ display_type: "relative_timing",
timing: "before_departure" | "after_departure" | "after_checkin", offset_hours, display_text }- 시간 단위를
day고정으로 두지 않고 day / hour / minute 혼용으로 둔 것은 "출발 50분 전", "수속 마감" 때문입니다. - 통화를 수수료 한 건마다 붙인 것은 구간마다 통화가 다른 여정 때문입니다.
- 이 타입이 파서와 LLM이 공통으로 쓰는 응답 형식입니다. LLM 프롬프트 전부에 같은 타입 정의가 문자열로 들어가 있고, 파서도 같은 형태를 냅니다. 아래 계산·화면·로그 계층은 결과가 어느 경로에서 왔는지 모릅니다.
- 엄격함은 같은 입력이면 늘 같은 답을 내는 쪽에 뒀습니다. 파서 결과가 형식을 어기면 실패로 처리하고, LLM 출력은 경고만 남기고 통과시킵니다.
- 도메인 사실을 데이터가 만들어지는 곳에서 강제했습니다. 한국 국내선에는 "환불 불가 운임"이 없습니다. 그런데 입력에 섞여 온
환불 불가값을 화면 직전에서 가리고 있었고, 로그와 FAQ 같은 다른 소비자에게는 모순 데이터가 그대로 흘러갔습니다. 가리는 코드를 걷어내고 국내선 계산기가 항상 "환불 가능"을 내되, 오염된 입력이 들어오면 경고를 남기게 했습니다(2026-05). 가리는 것은 버그를 고치는 게 아니라 숨기는 것입니다.
대신 포기한 것 — 타입이 둘이라 소비하는 코드가 매번 분기합니다. 단순함을 내주고 정확한 표현을 샀습니다.
② 규정을 읽는 일을 LLM에서 파서로 한 항공사씩 옮기되, 파서가 실패해도 LLM으로 넘기지 않았습니다
규정은 전부 글로 옵니다. 그 글을 ①에서 정한 타입으로 옮기는 방법이 둘입니다 — LLM에 읽히거나, 항공사별로 손으로 짠 파서(정규식)로 읽거나. 착수 시점에는 모든 조합을 LLM으로 읽혔습니다. 응답 속도가 사용자 대기 화면에서 쓸 수 있는 수준이 아니었습니다 — 운영 로그로 되짚어 보면 그 무렵 p50이 27.9초입니다. 프롬프트에서 출력 형식 지시를 통째로 빼는 튜닝까지 해 봤지만 30초 아래로 내려오지 않았고, 빼면 출력이 깨졌습니다. 튜닝으로는 안 되는 문제라고 판단했습니다.
그래서 규정을 읽는 일을 LLM에서 파서로 옮기기 시작했습니다. 파서는 항공사마다 하나씩 짜야 해서 한 번에 갈 수 없었고, 예약이 가장 많은 공급사의 항공사부터 갔습니다. 티웨이가 첫 번째였습니다(2025-09-25). 파서가 있는 항공사는 파서로, 없는 항공사는 LLM으로 읽습니다. 규정의 형식이 기준이 아니라 파서를 구현했느냐가 기준이고, 아직 못 옮긴 항공사가 LLM에 남아 있는 것입니다. 파서 경로는 LLM 호출이 없어 토큰이 0이고, 같은 입력이면 늘 같은 답을 냅니다.
파서가 실패하면 LLM으로 넘기는 대체 경로(폴백)를 만들지 않았습니다. 실패는 에러로 노출하고 1:1 문의로 안내합니다. 폴백이 있으면 답은 늘 보이지만 파서가 깨진 사실이 드러나지 않고, 비용과 지연이 슬그머니 돌아옵니다. 틀린 금액을 자신 있게 말하느니 못 구했다고 말하는 편이 이 도메인에서 맞습니다. 그래서 파서 실패를 별도 에러 카테고리로 승격시켜 다른 실패와 섞이지 않게 했습니다(2025-12).
파서는 규정 문장 형식이 바뀌면 깨집니다. 합성 데이터로는 그 변형을 예측할 수 없어, 실제 예약 표본으로 돌리는 회귀 검증을 머지 조건으로 뒀습니다. 그 검증에서 나온 사례 하나가 이 케이스에서 가장 하고 싶은 이야기입니다.
실패율 27%가 사실은 100%였습니다. 운영 담당이 제보했습니다 — 해외에서 발권된 예약 하나가 "수수료 정보 없음"으로 뜬다는 것이었습니다. 실제 적용값은 USD 50이었습니다. 원인은 통화였습니다. 해외 출발 예약은 수수료가 외화로 내려오는데, 파서는 원화만 가정하고 있었습니다. REFUND FEE: CHARGE USD50 PER SEGMENT 같은 영문 인라인, 항공사 수수료 TWD 2100 같은 한글 섹션 + 외화, 출발지에 따라 통화가 갈리는 이중 통화 표기까지 세 형태가 전부 미처리였습니다.
여기까지는 흔한 버그입니다. 문제는 그 버그의 크기를 잰 방법이었습니다.
| 표본을 어떻게 잡았나 | 실패율 | 그때 내린 판단 |
|---|---|---|
| 국제선 전체 200건 | 55건 실패 = 27.5% | "가끔 깨지는군" |
| 해외 출발만으로 좁힌 200건 | 200건 전부 실패 = 100% | "전부 깨져 있었다" |
국제선에는 인천에서 후쿠오카로 가는 것처럼 한국에서 출발하는 예약이 섞여 있고, 이 예약들은 수수료가 원화라 파서가 잘 읽습니다. 잘 되는 케이스가 표본에 섞여 실패율을 네 배 가까이 낮춰 보이게 한 것입니다. 같은 표본의 통화 분포가 그것을 증명합니다 — 해외 출발 200건의 통화는 JPY 70 · USD 63 · THB 34 · CNY 25 · TWD 15 · HKD 6, 그리고 원화는 0건이었습니다.
고친 뒤 다시 재니 0/200 → 199/200이었습니다. 남은 1건은 통화와 무관한 별개 결함이라 따로 분리해 남겼습니다. 그리고 "파싱됐다"와 "값이 맞다"는 다른 검증이라, 규정 원문에서 금액과 통화를 따로 뽑아 파서 결과와 맞춰 보는 확인을 한 번 더 거쳤습니다(불일치 0). 변경 수수료나 노쇼 수수료가 아니라 환불 수수료를 집었는지까지 봐야 했기 때문입니다.
중요한 건 그다음입니다. 이 교훈을 사람의 기억에 두지 않고 도구에 박았습니다. 표본을 뽑는 내부 도구에 출발지 구분 값을 파라미터로 넣고, 출발지로 나누지 않으면 실패율이 희석된다는 경고를 문서에 붙였습니다. 다음에 같은 방식으로 표본을 뽑는 사람은 이 함정을 다시 밟지 않습니다.
대신 포기한 것 — 가용성입니다. 파서가 깨지면 그 항공사는 답을 못 냅니다. 관측성을 샀습니다.
③ 항공사마다 규칙을 따로 두고, 등록 한 줄로 어느 경로로 갈지 정하게 했습니다
공용 프롬프트 하나로 시작했습니다(2025-08). 항공사 분기가 전혀 없이 범용 해석 원칙 몇 개로 전부 읽혔습니다. 에어아시아 하나 때문에 "수수료가 백분율이면 항공료 × 백분율" 규칙을 그 공용 프롬프트에 넣었는데(09-02), 이 지시문은 정액 수수료 항공사들도 함께 읽습니다. 한 항공사를 위한 수정이 전 항공사의 해석에 닿는 구조였습니다.
이틀 뒤 항공사별 프롬프트로 갈랐습니다(09-04). 분리하면 프롬프트끼리 내용이 겹칩니다 — 실측해 보니 국내선 항공사끼리는 많게는 91%가 같은 문장이었습니다. 그래도 갈랐습니다. 겹쳐 보이는 것과 실제로 같은 것을 구분해서, 진짜 공통인 것(출력 형식)만 따로 뺐습니다.
항공사들은 겉만 비슷하고 정책 해석이 실제로 다릅니다. 하나로 묶는 순간 한 곳을 고치면 다른 곳이 깨집니다. 잘못된 추상화보다 중복이 쌉니다.
파서도 같은 원칙입니다. 항공사별 클래스로 두고, 등록 목록에 한 줄 넣으면 파서로, 빼면 LLM으로 갑니다. 별도 플래그가 없습니다.
위 전체 그림의 산출 API 안쪽을 확대한 것입니다. 넓혀 간 단계는 넷이었습니다 — 티웨이 개발환경 한정 → 두 항공사로 프로덕션 진입(2025-10-28) → 하드코딩 목록을 동적 조회로 → 최종형(11-12). 자동으로 넘기지 않고 손으로 한 항공사씩 넣었습니다. 검증이 필요했기 때문입니다. 되돌리기는 그 한 줄을 빼는 것이고, 그 항공사만 LLM 경로로 돌아갑니다. 나머지 항공사는 영향받지 않습니다.
합산 방식은 공급사 단위로만 갈랐습니다. 구간별 수수료를 하나로 합치는 순서 여섯 단계는 공통으로 고정하고, 공급사마다 다른 두 단계(구간 합산 · 총액)만 갈아끼웁니다. 새 공급사가 와도 건드릴 곳이 그 둘입니다.
| 공급사 | 구간별 수수료를 합치는 방식 | 왜 |
|---|---|---|
| 국내선(DOM) | 전 구간 합산, 분 단위 기준 | 국내선은 시간 기준이 분 단위입니다 |
| 저비용항공사(LCC) 일반 | 전 구간 합산 | |
| 에어아시아 | 첫 구간만 | 백분율 환불을 전 구간에 균등 적용합니다 |
| 에어부산 | 전 구간 합산, 기준 시각은 첫 구간 출발일 | 항공사 정책 |
| NDC | 가장 큰 값 | 정책이 다층이라 가장 보수적으로 잡습니다 |
| GDS | 항공사별로 합산 또는 가장 큰 값 |
환율 환산은 하지 않습니다. 외화 수수료를 원화로 바꿔 보여주지 않고 통화별로 따로 합산해 표시합니다(₩120,000 + $50). 환율은 조회 시점마다 변해서, 환산해 보여주면 "그때는 12만 원이라더니"라는 클레임을 만듭니다. 항공사가 청구하는 것은 원 통화 금액입니다.
대신 포기한 것 — 중복입니다. 새 항공사는 기존 파일을 고치지 않고 새 파일을 추가하는 일이라 회귀 위험은 구조적으로 0이지만, 같은 문장을 여러 파일이 들고 있습니다.
무엇을 버리고 무엇을 샀나
| 어디 | 버린 것 | 산 것 | 왜 |
|---|---|---|---|
| ① | 단순함 — 타입이 둘 | 정확한 표현 — 기간표와 전/후형을 뭉개지 않음 | 하나로 뭉개면 같은 항공사의 국내·해외 출발이 한 타입에 안 들어갑니다 |
| ② | 가용성 — 파서 실패 시 LLM 폴백을 만들지 않음 | 관측성 — 실패가 별도 에러로 그대로 드러남 | 폴백을 두면 파서가 조용히 실패하고 비용·지연이 슬그머니 돌아옵니다 |
| ② | 범용성 — 파서는 규정 문장 형식이 바뀌면 깨짐 | 속도와 비용 — 파서 경로는 LLM 호출 자체가 없어 토큰 0 | 파서는 항공사마다 따로 짜야 해서 한 항공사씩 검증하며 옮겼고, 아직 못 옮긴 항공사는 LLM으로 읽습니다 |
| ③ | DRY — 프롬프트 중복 최대 91% | 영향 범위 — 한 곳을 고쳐도 그 항공사에서 끝남 | 새 항공사는 새 파일 추가라 회귀 위험이 구조적으로 0입니다 |
| ③ | 편의 — 원화로 환산한 한 줄 금액 | 클레임 방지 — 항공사가 청구하는 원 통화 그대로 | 환율은 조회 시점마다 변합니다 |
결과 — 측정과 지금 상태
운영 로그 189,131건(2025-08 ~ 2026-08)을 직접 집계했습니다.
| 구간 | p50 | p95 | 호출당 토큰 |
|---|---|---|---|
| 전환 전 (2025-09 · 파서 0%) | 27,909ms | 56,777ms | 2,709 |
| 전환 후 (2026-01~06) | 104ms | 3,693ms | 302 |
- 응답시간 p50 99.6% 감소 · p95 93.5% 감소 · 호출당 토큰 88.9% 감소
- 경로별로 보면 파서 p50 97ms, LLM p50 9,928ms — 102배 차이
- 월 총 토큰은 68.7M(2025-10) → 18.6M(11월)로 72.9% 줄었습니다. 두 달의 호출 수가 거의 같아(14,903 vs 14,710) 비교가 성립합니다
- 조회 건수는 출시 첫 달 4,143건에서 두 달 만에 월 1.5만 건에 올라선 뒤 월 1.5만 건 안팎으로 유지되고 있습니다(2026-05~08: 15,032 · 15,144 · 15,391 · 15,332)
어떻게 쟀나. 로그에 처리시간만 남기지 않고 어느 경로가 처리했는지를 태그로 함께 적재해 뒀습니다. 그래서 1년 뒤에 경로별·기간별로 갈라 볼 수 있었습니다. 집계할 때 계측이 기록되지 않는 경로 하나(전건 0ms)는 제외했습니다 — 포함하면 p50이 4ms처럼 왜곡됩니다. 교차검증도 됩니다. 파서의 첫 프로덕션 로그가 2025-10-28인데, 커밋 기록의 "개발환경 게이트 제거" 날짜와 일치합니다.
지금 상태
- 누적 189,131건 처리 · 하루 약 437건
- 파서 비중 79~90% 유지. LCC 전량과 GDS·국내선 일부가 LLM 호출 0회
- LLM의 역할이 '계산'에서 '검증'으로 옮겨갔습니다 — 지금은 파서 결과를 확인하는 쪽에 씁니다
- 되돌리기 — 등록 목록에서 한 줄 제거 → 해당 항공사만 LLM 경로로 복귀. 플래그를 따로 만들지 않아서, 되돌리는 단위가 곧 넓혀 간 단위입니다
- 탐지 — 파서 실패는 별도 에러 카테고리라 어느 항공사가 언제부터 깨졌는지 골라낼 수 있습니다. 다만 거기에 알람까지 걸지는 않았습니다(→ 정직하게)
- 지금 누가 고치나 — 지금도 제가 고칩니다. 마지막 코드 변경은 2026-06입니다
정직하게 — 한계와 다음 문제
- 출시 뒤 문의는 줄지 않았습니다. 유형이 바뀌었습니다. PO와 함께 젠데스크 문의 유형과 수수료 관련 콜 수를 봤고, 취소수수료 관련 티켓을 직접 읽었습니다. 금액 자체를 묻는 문의는 사라졌고, 소아가 포함됐을 때·인원 한 명을 뺐을 때처럼 조건이 붙는 문의가 남았습니다 — 계산기가 다루지 않는 경우들입니다. 조회는 월 1.5만 건 수준으로 자리 잡았는데 문의가 그대로였다는 것은, 조회가 문의를 대체했다기보다 전화하지 않던 사람까지 확인하게 만든 것으로 읽힙니다. 이 관찰은 기록으로 남기지 못했습니다. 구두 확인이라 건수와 기간을 숫자로 말할 수 없고, 남은 문제(부분 취소·소아 포함 계산)는 이슈로 만들지 못한 채 다음에 하자는 구두 합의로 끝났습니다.
- "한 항공사 프롬프트를 고쳤더니 다른 항공사가 깨졌다"는 직접 관측 기록(로그·이슈)은 남기지 못했습니다. 해결방안 ③의 서술은 커밋 연쇄로 재구성한 정황입니다. 백분율 규칙 투입 → 이틀 뒤 분리 → 2주 뒤 "문제되는 프롬프트 삭제" 순서로 확인됩니다.
- 알람을 만들지 않았습니다. 파서 실패를 골라낼 수 있게까지만 해 두고, 급증했을 때 사람에게 알리는 경로는 만들지 않았습니다. 지금은 에러 카테고리를 조회해야 알 수 있습니다. 폴백을 일부러 없앤 설계라면 알람은 그 짝이어야 하는데, 짝을 맞추지 못했습니다.