백엔드 없는 토스 미니앱을 만들며 파악한 앱인토스 미니앱 개발 환경 뜯어보기 — Granite·ait·TDS·Vite가 어떻게 맞물리는지 부트스트랩부터 배포까지 정리한 개발기입니다.
지난달 유류할증료 조회하기라는 미니앱을 앱인토스(Apps in Toss)에 출시했습니다. 8개 국적 항공사의 유류할증료(YQ)를 항공사별 표와 노선별 비교, 그리고 계산기로 보여 주는 작은 앱이죠. 서버는 한 대도 쓰지 않고, 요금 데이터는 전부 타입이 붙은 정적 상수로 앱 안에 넣었습니다. 로그인도 없습니다. 그만큼 기능은 단순하지만, 정작 처음 며칠을 잡아먹은 건 기능이 아니라 개발 환경 그 자체였습니다. granite dev, ait build, TDS, Vite가 한 프로젝트 안에 뒤섞여 있는데 누가 무엇을 하는지가 한눈에 안 들어왔거든요.

이 글은 그 툴체인을 하나씩 뜯어보며 정리한 부트스트랩 기록입니다.
Granite가 Vite를 감싸는 구조
가장 먼저 헷갈린 건 "그래서 번들러가 뭐냐"였습니다. package.json의 스크립트를 보면 우리가 흔히 아는 vite가 안 보입니다.
"scripts": {
"dev": "granite dev",
"build": "ait build",
"deploy": "ait deploy"
}
dev도 build도 전부 낯선 명령이죠. 답은 granite.config.ts에 있었습니다. Granite는 자체 번들러가 아니라, Vite를 안에서 실행해 주는 오케스트레이터에 가깝습니다. 설정 파일에서 웹 개발 명령을 그대로 vite에 위임하도록 적어 두거든요.
export default defineConfig({
appName: "fuelsurcharge",
web: {
host: "localhost",
port: 5173,
commands: {
dev: "vite dev",
build: "vite build",
},
},
permissions: [],
outdir: "dist",
webViewProps: {
type: "partner", // 비게임 미니앱 내비게이션 바
},
});
그러니까 npm run dev가 부르는 granite dev는 결국 localhost:5173에서 vite dev를 띄웁니다. 실제로 vite.config.ts는 React 플러그인 한 줄이 전부예요. 순수한 웹 빌드는 온전히 Vite가 맡고, Granite는 그 위에서 토스 앱으로서의 껍데기를 씌우는 역할만 합니다.
의존성 목록에도 이 이중 구조가 그대로 드러납니다. @apps-in-toss/web-framework는 ^2.10.4로 버전 범위를 두고, Vite는 ^6.2.0, React는 18, TypeScript는 ~5.7.2로 흔한 최신 웹 스택을 그대로 씁니다. 반면 TDS 계열 패키지(@toss/tds-mobile, @toss/tds-colors, @toss/tds-mobile-ait)는 죄다 latest로 박혀 있죠. 디자인 시스템만큼은 토스가 최신으로 계속 맞춰 준다는 전제라, 처음엔 버전을 고정하지 않은 게 조금 불안했지만 미니앱이 결국 토스 앱 안에서 도는 걸 생각하면 그 편이 오히려 자연스럽더군요.
배포 단계에서 이 분업이 분명해집니다. ait build를 돌리면 Vite 결과물(dist)을 토스가 이해하는 .ait 번들 하나로 묶어 줍니다. 제 프로젝트에서는 fuelsurcharge.ait 파일 하나가 약 4.4MB(4,450,322바이트)로 떨어졌죠. 이어서 ait deploy가 이 번들을 앱인토스 콘솔로 올려 배포합니다. Vite가 "웹앱"을 만들면, ait가 그걸 "토스 미니앱"으로 포장해 부치는 셈이죠.

TDS와 브랜드 컬러, 그리고 권한 없는 익명 앱
껍데기의 두 번째 축은 UI입니다. 의존성에 @toss/tds-mobile과 @toss/tds-mobile-ait가 들어가 있는데, 앞은 토스의 모바일 디자인 시스템(TDS)이고 뒤는 그걸 앱인토스 웹뷰에 맞춘 어댑터입니다. 진입점인 main.tsx가 이 둘을 어떻게 엮는지가 흥미로웠어요.
import { TDSMobileAITProvider } from "@toss/tds-mobile-ait";
import config from "../granite.config.ts";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<TDSMobileAITProvider brandPrimaryColor={config.brand.primaryColor}>
<App />
</TDSMobileAITProvider>
</StrictMode>,
);
눈여겨볼 부분은 brandPrimaryColor에 넘기는 값입니다. 색을 여기 하드코딩하지 않고 granite.config.ts의 brand.primaryColor를 그대로 import 해서 넘겼죠. 브랜드 컬러(#9575CD, 은은한 라벤더 톤)를 설정 파일 한 곳에만 두고, 화면에 노출될 앱 기본 색과 앱 내부 버튼·강조 색이 같은 출처를 바라보게 한 겁니다. 나중에 색을 바꿀 때 한 군데만 고치면 되니, 사소하지만 마음에 드는 구조였습니다.
권한 설정도 이 앱의 성격을 잘 보여 줍니다. permissions: [], 즉 비어 있죠. 카메라도, 위치도, 사용자 식별 정보도 요구하지 않습니다. 유류할증료는 그냥 조회만 하면 되는 정보라 로그인이 필요 없고, 그래서 이 앱은 철저히 익명으로 도는 앱입니다. 서버가 없다는 선택이 권한 목록의 공백으로까지 이어진 셈이죠. webViewProps.type을 "partner"로 둔 것도 같은 맥락입니다. 게임이 아닌 일반 미니앱용 내비게이션 바를 쓰겠다는 선언이라, 조회형 도구 앱에 딱 맞습니다.
처음 헤맨 곳 — 줌 잠금과 콘솔 키 배포
정작 시간을 쓴 건 화면을 못 움직이게 막는 일이었습니다. index.html의 viewport 메타에 maximum-scale=1.0, user-scalable=no를 넣어 확대를 막아 뒀는데, 토스 웹뷰 안에서는 이 설정이 늘 지켜지지는 않더군요. 표가 많은 앱이라 손가락으로 핀치 줌이 되면 레이아웃이 흔들렸습니다. 결국 main.tsx에 자바스크립트로 제스처를 한 번 더 막았습니다.
// 손가락 핀치/더블탭 줌 잠금 (웹뷰가 viewport 설정을 무시하는 경우 대비)
["gesturestart", "gesturechange", "gestureend"].forEach((evt) =>
document.addEventListener(evt, (e) => e.preventDefault(), { passive: false }),
);
메타 태그와 이벤트 차단, 두 겹으로 막고 나서야 화면이 얌전해졌습니다. 웹에서는 당연히 되던 게 웹뷰에선 한 번 더 손이 가는, 미니앱 특유의 경험이었죠. [확인 필요: 어떤 기기·OS에서 핀치 줌이 재현됐는지 정확한 조건]
마지막은 배포였습니다. 앱인토스 콘솔의 워크스페이스 > API 키에서 콘솔 API 키를 발급받아 등록해 두면, 나머지는 명령 두 줄로 끝납니다.
npm run build # ait build → fuelsurcharge.ait 생성
npm run deploy # ait deploy → 콘솔로 업로드
CI도, 도커도 없이 로컬에서 .ait 하나를 만들어 올리는 이 단출함이, 백엔드 없는 정적 미니앱과 잘 어울렸습니다. 툴체인을 다 뜯어보고 나니, 결국 앱인토스 개발이란 "Vite로 만든 웹앱에 토스라는 얇은 껍데기를 씌우는 일"이라는 한 문장으로 정리되더군요.