공통 스키마를 버리니 8개 항공사 표가 담겼다는, 유류할증료 조회 미니앱에서 정규화를 포기하고 항공사별 유연 스키마로 데이터 모델을 설계한 개발기입니다.
하나의 표로 묶으려다 막힌 지점
fuelsurcharge("유류할증료 조회하기")는 8개 항공사의 이번 달 국제선 유류할증료(YQ)를 항공사별 표와 노선 비교로 보여주는 토스 미니앱입니다. 핵심 기능이 "같은 노선인데 항공사마다 얼마씩 차이 나지?"를 한눈에 보여주는 것이다 보니, 처음에는 당연히 이렇게 생각했습니다. 모든 항공사를 하나의 거리 구간표로 정규화(normalize)해두면 비교가 깔끔하게 떨어지겠다고요. 실제로 기획서 리스크 대응란(§15)에는 아직도 그 흔적이 "항공사 공지 형식 상이 → 표준 스키마로 정규화"라고 남아 있습니다.
그런데 8개사 표를 한 줄씩 옮겨 담기 시작하자마자 그 전제가 어긋났습니다. 항공사들의 표는 구간 라벨, 통화, 거리대라는 세 축에서 하나도 겹치지 않았거든요. 그대로 옮겨 적어보면 이렇습니다.
- 대한항공·아시아나: 원화(KRW),
대권거리(mile)기준.~499부터6,500~9,999까지 9개 구간. - 제주항공: 달러(USD),
운항거리 구간기준인데 라벨은1구간~6구간. - 티웨이항공: 원화,
부과군기준.1군~5군다음이 곧장7군입니다. 6군이 없어요. - 에어부산: 달러,
권역(거리)기준인데근거리·중거리 1·중거리 2·원거리라 거리 숫자 자체가 없습니다. - 이스타항공: 달러,
군기준으로1군~6군. 역시 거리값 없이 군 이름만 있습니다.
세어보니 통화는 KRW 4곳·USD 4곳으로 딱 반반이고, 구간 기준 명칭(bandLabel)만 대권거리(mile), 운항거리 구간, 운항거리(mile), 부과군, 권역(거리), 군까지 여섯 가지였습니다. 이걸 하나의 정규화 스키마로 욱여넣으려면 없는 거리 숫자를 지어내고, 달러와 원을 한 컬럼에 뭉개고, 원문에 있지도 않은 6군을 만들어 채워야 했습니다. 정규화의 대가가 곧 데이터 왜곡이 되는 지점이었죠.
원문 라벨은 그대로, 거리만 선택으로
그래서 방향을 반대로 틀었습니다. 정규화를 포기하고, 각 항공사의 표를 원문 라벨 그대로 저장하기로요. 기획서도 §15의 옛 문장과 달리 §6·§10에서는 이미 "하나의 고정 스키마로 강제하지 않고 항공사별 표를 유연하게 저장한다"로 정리돼 있었고, 데이터 파일 맨 위 주석에도 그 결정을 그대로 박아뒀습니다.
// 항공사마다 통화(원/USD)와 구간 체계(대권거리 mile / 구간 / 군 / 권역)가
// 다르므로 하나의 공통 스키마로 강제하지 않고,
// 각 항공사의 표를 원문 라벨 그대로 저장한다. (docs/기획서.md §10 참고)
이 결정을 코드로 옮긴 인터페이스는 이렇게 생겼습니다. 핵심은 band는 원문 문자열이고, 거리(distanceMinMi/distanceMaxMi)는 선택(optional) 이라는 점입니다.
export type CurrencyUnit = "KRW" | "USD";
export interface SurchargeRow {
/** 원문 구간 라벨 (예: "~499", "1구간", "1군", "근거리") */
band: string;
/** 노선 매핑·비교용(선택). 거리(mile) 기반 구간일 때만 채움 */
distanceMinMi?: number;
distanceMaxMi?: number | null;
sampleRoutes: string[];
/** "YYYY-MM" → 편도 금액 */
amount: Record<string, number>;
}
export interface Airline {
code: string;
name: string;
unit: CurrencyUnit; // 항공사마다 다름 (KE=KRW, 7C=USD…)
bandLabel: string; // 대권거리 / 부과군 / 권역 …
bandUnit?: string; // "mile" 등. 군·구간 체계면 비움
rows: SurchargeRow[];
}
포인트를 하나씩 보면요. band를 정수나 enum이 아니라 그냥 string으로 둔 덕분에 "~499"든 "1구간"이든 "근거리"든 원문 그대로 들어갑니다. 티웨이의 "7군"도 억지로 6번을 채울 필요 없이 문자열이니 그만이죠. 통화는 행이 아니라 항공사 단위 unit 하나로 분기하니, 렌더링할 때 formatAmount(value, unit)가 KRW면 "46,400원", USD면 "USD 30"으로 알아서 갈라줍니다. distanceMaxMi에 number | null을 허용한 것도 이유가 있는데, 아시아나의 5,000~이나 제주의 6구간처럼 상한이 열린 최상단 구간을 null로 표현하기 위해서입니다.
선택 필드가 벌어준 것 — 표시와 비교를 나누다
이 유연 스키마가 실제로 무엇을 벌어줬는지는 두 항공사의 한 행을 나란히 놓으면 분명해집니다. 대한항공 첫 행은 거리 기반이라 distanceMin/MaxMi가 채워지고, 제주항공 첫 행은 통화와 라벨 체계가 통째로 다릅니다.
// 대한항공(KE) — 원화·대권거리(mile)
{ band: "~499", distanceMinMi: 0, distanceMaxMi: 499,
amount: { "2026-06": 61500, "2026-07": 46400 } }
// 제주항공(7C) — 달러·운항거리 구간
{ band: "1구간", distanceMinMi: 1, distanceMaxMi: 500,
amount: { "2026-06": 43, "2026-07": 30 } }
같은 앱, 같은 화면 구조인데 표의 체계가 다르다는 걸 실제 앱 화면으로 보면 이렇습니다.

거리 필드를 선택으로 둔 두 번째 이점은 "표시"와 "비교"를 깔끔히 나눈 것입니다. 거리 범위가 있는 행은 노선 매핑·비교의 근거가 되고, 에어부산의 근거리나 이스타의 1군처럼 거리값이 없는 행은 그냥 표시 전용으로 남습니다. 없는 거리를 억지로 추정해 잘못된 비교를 만드느니, 표에 있는 그대로 보여주고 비교 대상에서만 빼는 편이 훨씬 정직하니까요.
돌아보면, 처음의 정규화 스키마를 밀어붙였다면 8개사 중 절반은 데이터를 뒤틀어야 담겼을 겁니다. 특히 거리 숫자가 아예 없는 권역·군 체계 두 곳에서 반드시 터졌겠죠. 기획서 §15에 남은 "정규화"라는 단어는 그래서 저에게는 일종의 화석입니다. 공통 스키마가 늘 정답은 아니라는 것, 데이터가 저마다의 언어로 말할 때는 그 언어를 지워버리는 대신 담을 그릇을 느슨하게 만드는 편이 낫다는 걸 이 표 8개가 가르쳐줬습니다. 정규화는 목적이 아니라 도구이고, 원문을 왜곡하면서까지 지킬 원칙은 아니었던 거죠.