본문 바로가기
카테고리 없음

앱인토스 | 노선 데이터 매칭

by 테크구루스 2026. 7. 30.

앱인토스의 미니앱에서 노선 데이터 매칭, 그 핵심인 alias 퍼지 매칭과 거리 버킷·최저가 정렬로 표기 혼재를 흡수한 과정을 정리한 글입니다.

같은 도시가 항공사마다 다른 이름, 다른 칸에 있다

유류할증료 조회하기에는 "노선별 비교" 탭이 있습니다. 사용자가 도쿄 같은 도시를 고르면, 8개 항공사가 그 노선에 매기는 유류할증료를 최저가순으로 줄 세워 보여 주죠. 화면만 보면 단순한 기능입니다. 도시 하나 받아서 표에서 값 꺼내 정렬하면 끝일 것 같으니까요.

 

앱인토스 ❘ 노선 데이터 매칭
앱인토스 ❘ 노선 데이터 매칭

 

문제는 그 "표에서 값 꺼내기"가 항공사마다 판이하다는 데 있었습니다. 유류할증료는 각 항공사가 자기 기준으로 구간을 나눠 공표합니다. 대한항공은 대권거리(mile)를 ~499, 500~999처럼 9개 구간으로 쪼개고, 제주항공은 1구간

6구간, 티웨이는 1군

7군, 에어부산은 근거리·중거리·원거리 같은 권역으로 나눕니다. 통화도 원화와 USD가 섞여 있죠. 그래서 저는 공통 스키마를 강제하지 않고, 각 항공사의 표를 원문 라벨 그대로 저장하되 구간마다 sampleRoutes(주요 노선 예시)를 붙여 뒀습니다.

진짜 골치는 이 sampleRoutes 안에서 같은 도시가 저마다 다른 얼굴로 등장한다는 것이었습니다. "도쿄" 한 도시만 봐도 이렇습니다.

  • 아시아나·에어서울·제주항공 표에는 그냥 도쿄
  • 대한항공 표에는 도쿄가 아니라 공항 이름 나리타, 그리고 김포-하네다
  • 진에어는 인천-도쿄, 티웨이는 인천-나리타
  • 에어부산은 나리타(인천 출발)나리타(부산 출발)서로 다른 구간에 따로

s === "도쿄" 같은 단순 비교로는 절반도 못 잡습니다. 나리타·하네다는 도쿄의 공항이고, 출발지가 인천-처럼 앞에 붙기도, 아예 안 붙기도 하니까요. 도시 하나를 찾는 일이 순식간에 문자열 정규화 문제로 번진 순간이었죠.

alias와 '인천 우선' 한 줄 — matchRow 뜯어보기

먼저 도시 정의부터 손봤습니다. 도시마다 표기 변형을 alias 배열로 들고 다니게 했죠. c() 헬퍼는 도시 이름 자신을 항상 첫 번째 alias로 넣습니다.

function c(id: string, name: string, aliases: string[] = []): CityDef {
  return { id, name, aliases: [name, ...aliases] };
}

c("tokyo", "도쿄", ["나리타", "하네다"]),
c("sapporo", "삿포로", ["치토세"]),
c("zhangjiajie", "장자제", ["장가계"]),
c("taipei", "타이베이", ["타이페이"]),
c("bali", "발리", ["덴파사르"]),

도쿄=나리타/하네다처럼 공항명을 흡수하고, 장자제=장가계, 타이베이=타이페이처럼 항공사마다 다른 음차도 묶었습니다. 발리는 대한항공·제주항공이 공항 도시명 덴파사르로 적고 에어부산은 발리로 적길래 둘 다 alias로 넣었죠. 실제로 진에어 표는 타이베이를 타이페이로 적어 놨는데, alias가 없었다면 진에어만 조용히 매칭에서 빠졌을 겁니다.

그다음이 핵심인 matchRow입니다. 한 항공사 표에서 이 도시가 속한 구간(행)을 찾아 주죠.

function matchRow(airline: Airline, aliases: string[]): SurchargeRow | null {
  const matched = airline.rows.filter((r) =>
    r.sampleRoutes.some((s) => aliases.some((a) => s.includes(a))),
  );
  if (matched.length === 0) return null;
  const preferred = matched.find((r) =>
    r.sampleRoutes.some(
      (s) =>
        aliases.some((a) => s.includes(a)) &&
        (s.includes("인천") || !s.includes("-")),
    ),
  );
  return preferred ?? matched[0];
}

두 단계입니다. 첫째, sampleRoutes 중 하나라도 alias를 부분 포함하면 그 구간을 후보로 담습니다. includes를 쓴 덕분에 인천-나리타, 나리타(인천 출발) 같은 접두·괄호 표기가 다 걸리죠. 둘째, 후보가 여럿일 때 출발지를 가려냅니다. 이 앱은 "인천 출발 편도"가 기준이라, 인천이 들어갔거나 출발지 구분자 -가 아예 없는(=출발지 미표기) 구간을 preferred로 우선합니다. 없으면 그냥 첫 후보(matched[0])로 떨어지고요.

이 한 줄 조건이 필요했던 이유가 에어부산 같은 사례입니다. 에어부산은 나리타(부산 출발)근거리에, 나리타(인천 출발)중거리 1에 둡니다. 부산 출발이 더 가까워 값도 다르죠. 인천 우선 규칙이 없으면 표에서 먼저 나오는 구간을 집어, 인천 기준 화면에 엉뚱한 값을 물려 버립니다. 참고로 SurchargeRow에는 distanceMinMi/distanceMaxMi 거리 버킷도 함께 담아 뒀습니다. 라벨은 근거리·1군으로 제각각이어도 거리(mile)로는 같은 구간임을 나중에 대조할 수 있게 남겨 둔 안전장치죠.

최저가로 줄 세우고, 없으면 뒤로

구간을 찾았으면 금액을 꺼내 원화로 통일하고 정렬합니다. getRouteFares가 8개 항공사를 전부 돌며 matchRow → 그달 금액 → (USD면 usdToKrw로 환산) 순으로 값을 만든 뒤, 오름차순으로 세웁니다.

return results.sort((x, y) => {
  if (x.onewayKrw == null && y.onewayKrw == null) return 0;
  if (x.onewayKrw == null) return 1;   // 표에 없으면 뒤로
  if (y.onewayKrw == null) return -1;
  return x.onewayKrw - y.onewayKrw;    // 최저가 순
});

포인트는 null 처리입니다. 어느 항공사 표에도 그 도시가 없으면 onewayKrwnull인데, 이 경우 무조건 뒤로 밀어 놓습니다. 값이 있는 항공사부터 최저가순으로 보여 주고, 매칭 안 된 곳은 목록 끝에서 "직접 확인"으로 유도하는 것이죠. 도쿄를 고르면 아래처럼 에어부산이 최저로 올라오고 최저 배지가 붙습니다.

 

그런데 이 화면이 제 매칭 로직의 한계도 같이 드러냅니다. 1위 에어부산 USD 30(약 45,200원)은 사실 근거리, 즉 부산 출발 값이거든요. 앞서 인천 우선 규칙을 넣었는데도 왜 부산 값이 이겼을까요. 나리타(부산 출발)에는 구분자 -가 없어서, !s.includes("-") 가지가 이걸 "출발지 미표기"로 착각하고 preferred로 통과시켰기 때문입니다. 인천-, 부산- 같은 대시 표기는 잘 걸러 내는 규칙이, 괄호로 출발지를 적는 에어부산 표기 앞에서는 헛짚은 것이죠. [확인 필요: 이 사례를 출시 전 QA에서 잡았는지, 아니면 나중에 발견했는지]

지금이라면 출발지 판별을 대시 유무 대신 (부산 출발)·(인천 출발) 괄호까지 정규화해서 풀 겁니다. 다만 유류할증료는 참고용 지표이고 화면에도 "실제 금액은 발권 시점 항공사 최종 고지 기준"이라고 못박아 둔 만큼, 완벽한 구간 매칭보다 8개 항공사를 한 화면에서 대략 비교하는 값을 먼저 잡은 선택이었습니다. 표기 변형은 alias로, 출발지 혼재는 우선순위 한 줄로, 없는 항공사는 정렬로 밀어내기 — 세 겹으로 도시 하나의 여러 얼굴을 흡수한 셈이죠. 개인적으로는 이 작은 문자열 싸움이 이 앱에서 제일 오래 붙잡은 로직이었습니다.