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

앱인토스 | 서버, DB없이 미니앱 출시하기

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

서버도 DB도 없이, 타입을 스키마 삼아 출시한 미니앱 「유류할증료 조회하기」를 정적 타입 데이터만으로 만든 아키텍처 선택과 트레이드오프를 정리해봤습니다.

서버를 두지 않은 이유

토스 미니앱 유류할증료 조회하기를 만들며 가장 먼저 내린 결정은, 역설적이게도 "무엇을 만들까"가 아니라 "무엇을 만들지 않을까"였습니다. 결론부터 말하면 이 앱에는 백엔드가 없습니다. API 서버도, 데이터베이스도 없고, 8개 국적 항공사의 유류할증료(YQ)를 보여주는 앱인데도 정작 그 값을 받아 오는 네트워크 요청이 한 줄도 없죠. 데이터는 전부 빌드 시점에 자바스크립트 번들 안으로 들어가 있습니다.

 

이렇게 정한 데엔 몇 가지 이유가 있었습니다. 먼저 초기 앱은 로그인과 결제가 없는 익명 조회 유틸입니다. 기획 단계에서 토스 로그인을 MVP에 넣지 않기로 했고, 요청 권한도 아예 비워 뒀습니다(permissions: []). 사용자가 남기는 개인정보가 없으니 지켜야 할 서버 상태도 없죠. 두 번째는 검수·운영 부담입니다. 서버가 없으면 관리할 인프라, 장애 지점, 스케일링 고민이 통째로 사라집니다. 앱인토스 검수를 준비하는 입장에서도 외부로 나가는 요청과 권한이 적을수록 설명할 것이 줄어듭니다.

세 번째가 사실 가장 결정적이었는데, 유류할증료라는 데이터의 성질 때문이었습니다. 국제선 유류할증료는 매월 16일부터 다음 달 15일까지의 항공유 평균가로 산정돼 그 다음 달 1개월간 적용됩니다. 즉 한 번 정해지면 한 달 내내 고정이죠. 실시간으로 초당 갱신되는 주가 같은 데이터가 아니라, 한 달에 딱 한 번 바뀌는 값입니다. 이런 데이터를 위해 상시 켜진 서버와 DB를 두는 건 명백한 과설계였습니다. 그럴 바엔 그달의 확정된 표를 코드에 박아 두고, 달이 바뀔 때만 갱신해 다시 배포하는 편이 훨씬 정직하다고 봤습니다.

 

미니앱 출시하기 - 화면
미니앱 출시하기 - 화면

타입이 곧 스키마 — 정적 데이터를 DB처럼

서버가 없다고 데이터가 아무렇게나 흩어져 있는 건 아닙니다. 오히려 반대로, 데이터베이스에서 하던 일을 타입으로 옮겨 왔습니다. DB에 스키마가 있듯 이 앱에는 인터페이스가 있고, src/data/fuelSurcharge.ts 한 파일이 스키마이자 테이블이자 시드 데이터 역할을 겸합니다.

먼저 스키마 노릇을 하는 인터페이스입니다.

export type CurrencyUnit = "KRW" | "USD";

export interface Airline {
    code: string;
    name: string;
    region: "국제선" | "국내선";
    unit: CurrencyUnit;
    bandLabel: string;   // 구간 기준 명칭(대권거리/군/권역…)
    bandUnit?: string;   // "mile" 등, 군/구간 체계면 비움
    source: string;
    sourceTier: "official" | "aggregated";
    available: boolean;  // false면 화면에 '준비 중'
    rows: SurchargeRow[];
}

DB로 치면 CREATE TABLE에 해당합니다. unit"KRW" | "USD" 두 값만 허용하는 리터럴 유니온이라, 오타로 "KRWW"를 넣으면 컴파일이 막히죠. NOT NULL·CHECK 제약을 SQL이 아니라 타입으로 거는 셈입니다. 항공사마다 구간 체계가 제각각(대한항공은 대권거리 mile, 제주항공은 USD·구간, 티웨이는 부과군)이라 하나의 고정 스키마로 억지로 통일하지 않고 bandUnit?·distanceMinMi?처럼 선택 필드로 유연하게 열어 뒀는데, 이 부분은 다음 편에서 따로 다루겠습니다.

그리고 실제 '행(row)'은 이렇게 상수 배열로 존재합니다.

export const CURRENT_MONTH = "2026-07";
export const PREV_MONTH = "2026-06";

export const AIRLINES: Airline[] = [KE, OZ, JJ, LJ, TW, BX, RS, ZE];

export function getAirline(code: string): Airline | undefined {
    return AIRLINES.find((a) => a.code === code);
}

AIRLINES는 테이블의 전체 레코드고, getAirline("KE")는 기본키로 한 행을 집어 오는 SELECT ... WHERE code = ?입니다. 노선별 비교를 맡는 getRouteFares(city)는 8개 항공사를 훑어 금액을 뽑고 최저가 오름차순으로 정렬하는데, 이건 사실상 정렬이 붙은 조회 쿼리죠. 다른 점이 있다면 이 '쿼리'들이 네트워크 왕복도, 커넥션 풀도 없이 메모리 위 배열에서 즉시 끝난다는 것입니다. CURRENT_MONTH는 이번 달 데이터를 가리키는 파티션 키 같은 포인터고요.

앱 쪽에서는 이 데이터를 그냥 정적으로 import해 씁니다.

import {
  AIRLINES, getAirline, getRouteFares,
  CURRENT_MONTH, PREV_MONTH, /* … */
} from "./data/fuelSurcharge";

fetch도, axios도, useEffect로 감싼 로딩 상태도 없습니다. 데이터가 이미 손안에 있으니 로딩 스피너가 필요 없고, 네트워크 실패에 대비한 에러 처리도 재시도 로직도 없죠. 화면은 언제나 즉시 그려집니다. 타입 안전은 덤입니다. 필드명을 하나 바꾸면 그 필드를 쓰는 모든 화면에서 컴파일 에러가 나기 때문에, 데이터 구조와 UI가 어긋난 채로 배포될 일이 없습니다.

대가: 실시간성 대신 월 1회 재배포

정적 데이터의 청구서는 아주 분명한 곳에서 날아옵니다. 바로 갱신이 곧 배포라는 점이죠. 유류할증료가 바뀌는 매달 1일, 저는 서버의 값을 고치는 게 아니라 fuelSurcharge.ts의 숫자와 CURRENT_MONTH 상수를 직접 손으로 고친 뒤 다시 빌드해 배포해야 합니다. 관리자 페이지 같은 건 없습니다. 데이터 갱신과 코드 배포가 물리적으로 같은 행위인 것이죠.

 

이건 또렷한 트레이드오프입니다. 실시간성을 완전히 포기하는 대신, 운영의 단순함과 앱의 속도·안정성을 얻은 거래죠. 유류할증료가 월 1회만 바뀌기에 성립하는 거래이기도 합니다. 만약 이 값이 하루에도 몇 번씩 요동치는 데이터였다면 이 구조는 처음부터 성립하지 않았을 겁니다.

가장 현실적인 위험은 기술이 아니라 사람에게 있었습니다. 바로 수기 갱신을 깜빡하는 것이죠. 서버라면 스케줄러가 대신 값을 긁어 올 수도 있지만, 여기선 매달 제가 직접 표를 갱신하지 않으면 앱은 지난달 숫자를 아무 일 없다는 듯 계속 보여줍니다. 틀린 값을 자신 있게 보여주는 게 가장 나쁜 시나리오죠. 기획서에도 이 위험을 "매월 수기 갱신 누락"으로 명시하고, 대응으로 매월 15일 리마인더와 데이터에 출처 갱신일(sourceUpdatedAt)을 노출하는 방안을 적어 뒀습니다.

여기서 솔직한 삽질 하나를 고백하면, 기획서 초안(§10)의 데이터 모델은 지금 코드와 사뭇 달랐습니다. 처음엔 각 행 안에 months 맵을 두고 그 안에 sourceUpdatedAt 같은 메타데이터를 월별로 중첩하는 구조를 그렸었죠. 그런데 막상 구현해 보니 앱이 화면에 보여주는 건 결국 "이번 달"과 "전월" 두 개뿐이었습니다. 중첩 맵은 렌더할 때마다 키를 두 번 꺼내 쓰기 번거로웠고, 결국 CURRENT_MONTH/PREV_MONTH라는 최상위 상수 두 개와 각 행의 amount: Record<string, number> 월맵으로 납작하게 폈습니다. [확인 필요: 이 단순화가 정확히 어느 커밋 시점에 이뤄졌는지]. 백엔드가 없으니 스키마 마이그레이션도, 데이터 이관도 없이 타입 정의와 상수만 고치면 그만이었죠. 서버였다면 컬럼 하나 바꾸는 데도 마이그레이션 스크립트를 썼을 겁니다.

정리하면 이 앱은 "작게 만들려고 서버를 뺀" 게 아니라 "데이터의 성질에 구조를 맞춘" 결과였습니다. 월 1회 바뀌는 확정된 표에는, 상시 켜진 서버보다 타입으로 스키마를 강제한 정적 데이터 한 파일이 더 정직한 답이었죠. 다음 편에서는 이 정적 데이터가 왜 하나의 공통 스키마를 포기하고 항공사별 유연 구조를 택했는지를 이어서 풀어 보겠습니다.