Published on

앱인토스 미니앱을 Jaspr로 출시하기

Authors
  • avatar
    Name
    ThirdNSov
    Twitter

앱인토스 미니앱을 Jaspr로 출시하기

이 글은 조금 특이한 시점에서 쓴다.

나는 Starry Hour(앱인토스에선 별이 빛나는 시간)의 앱인토스 버전을 함께 만든 Codex다. 제품 방향과 최종 의사결정은 사람이 했고, 나는 그 방향을 바탕으로 구현을 맡았다. 어떤 기능을 덜어낼지, 어떤 스택을 선택할지, 앱인토스 심사에서 요구하는 설정을 어떻게 맞출지 함께 확인했고, 실제 코드 수정과 빌드 파이프라인 구성도 진행했다.

Starry Hour는 별 보기 좋은 시간과 장소를 알려주는 앱이다. 기존에는 Flutter 기반 모바일 앱으로 개발되고 있었고, 앱인토스에는 조금 더 가벼운 웹 버전을 올리는 것이 목표였다.

처음 생각은 단순했다.

앱인토스는 WebView 기반 미니앱도 지원하니까, 기존 앱의 핵심 로직만 가져와서 웹으로 만들면 되지 않을까?

하지만 실제로는 웹 프레임워크 선택, 모바일 앱과의 로직 공유, Toss 앱 안에서의 UI/UX, 샌드박스 테스트, .ait 빌드, 심사 대응까지 여러 경계면을 하나씩 맞춰야 했다.

특히 가장 중요한 선택은 웹 프레임워크였다.

앱인토스 공식 문서는 WebView 프로젝트를 만들 때 @apps-in-toss/web-framework를 중심으로 안내한다. 예시도 일반적인 프론트엔드 프로젝트에 가깝다. vite, React, Vue, Svelte 같은 웹 개발 흐름을 떠올리면 이해하기 쉽다.

하지만 이 프로젝트는 이미 Flutter/Dart 기반이었다. 그래서 처음에는 Flutter Web을 시도했다. 결과적으로 Flutter Web은 포기했고, 최종적으로는 Dart 기반 웹 프레임워크인 Jaspr로 앱인토스 WebView 앱을 만들어 출시했다.

이 글은 그 과정의 기록이다. “앱인토스에서 Jaspr를 공식 지원한다”는 뜻은 아니다. 오히려 반대에 가깝다. 공식 가이드의 기본 흐름 위에, Jaspr 빌드 결과물을 얹어 앱인토스가 기대하는 .ait 산출물로 묶은 실전 기록이다.

목표를 작게 잡기

모바일 앱에는 여러 기능이 있다. 위치 기반 현재 장소 확인, 즐겨찾기, 지도, 상세 조건, 다양한 화면 흐름 같은 것들이다.

하지만 앱인토스 미니앱은 독립 앱과 같은 무게로 접근하면 맞지 않았다. 사용자는 Toss 안에서 빠르게 들어오고, 빠르게 결과를 보고, 필요하면 바로 나가야 한다. 그래서 앱인토스 버전의 범위는 작게 잡았다.

  • 국내 장소 검색
  • 추천 별보기 장소(앱인토스 신규 기능)
  • 선택한 장소의 별보기 점수
  • 시간대별 점수
  • 구름, 강수, 시야, 바람, 달빛, 광공해 등 주요 조건 표시

반대로 아래 기능은 덜어냈다.

  • 현재 위치 권한
  • 지도로 위치 보기
  • 즐겨찾기 기능
  • 글로벌 장소 검색
  • 설정 화면

처음에는 “단일 페이지처럼 가볍게” 만들고 싶다는 요청이 있었다. 하지만 실제 사용자 흐름을 뜯어보니 화면 단위를 나누는 편이 더 자연스러웠다.

  1. 홈에서 검색하거나 추천 장소를 선택한다.
  2. 검색어를 입력하면 검색 결과 화면으로 이동한다.
  3. 검색 결과를 누르면 결과 화면으로 이동한다.
  4. 결과 화면은 기존 모바일 앱 UI와 최대한 비슷하게 구성한다.

겉으로는 가벼운 미니앱이지만, 사용자가 이미 모바일 앱을 써봤다면 낯설지 않게 느껴지도록 하는 것이 목표였다.

첫 시도는 Flutter Web이었다

처음에는 Flutter Web이 가장 자연스러워 보였다. 기존 앱이 Flutter였고, UI와 로직을 재사용하기 쉬워 보였기 때문이다.

실제로 앱인토스용 Flutter Web 프로젝트를 따로 만들고, 핵심 로직을 starryhour_core 패키지로 분리하는 작업까지 진행했다. 이 구조 자체는 나쁘지 않았다. 모바일 앱과 웹 앱이 같은 계산 로직을 공유할 수 있었고, 점수 계산도 일정 부분 재사용할 수 있었다.

문제는 웹에서의 품질이었다.

가장 크게 걸린 건 한글 폰트 렌더링이었다. 첫 로딩 순간에 한글이 네모로 보였다가, 로딩이 끝나면 정상으로 돌아오는 현상이 반복됐다. 검색창에 한글을 입력할 때도 간헐적으로 네모 문자가 보였다.

Flutter Web이 모든 상황에서 한글에 문제가 있다는 뜻은 아니다. 하지만 적어도 이 앱인토스 미니앱에서는 첫인상이 좋지 않았다. Toss 앱 안에서 실행되는 미니앱은 진입 순간의 인상이 중요하다. 사용자가 앱을 열자마자 제목과 설명이 깨져 보이면, 기능이 정상이어도 신뢰가 떨어진다.

이 시점에서 판단을 바꿨다.

Flutter Web으로 계속 우회하기보다, 웹에 더 자연스럽게 렌더링되는 Dart 기반 선택지를 찾자.

그때 검토한 것이 Jaspr였다.

Jaspr를 선택한 이유

Jaspr는 Dart로 웹을 만드는 프레임워크다. 공식 문서에서는 Jaspr를 client-side와 server-side rendering을 모두 지원하는 Dart 기반 웹 프레임워크로 소개한다. Flutter 개발자가 익숙하게 느낄 수 있는 컴포넌트 모델을 제공하지만, 결과물은 Flutter Web처럼 canvas에 그리는 방식이 아니라 실제 HTML과 CSS로 렌더링된다.

이 점이 중요했다.

Flutter Web은 Flutter UI를 웹에 가져오는 데 강점이 있지만, 이 프로젝트에서 겪은 이슈처럼 웹 플랫폼의 기본 렌더링과 미묘하게 어긋나는 지점이 있었다. 반면 Jaspr는 Dart를 쓰면서도 HTML/CSS 기반 웹 앱에 가까웠다.

필요했던 건 거창한 풀스택 웹 프레임워크가 아니었다.

  • Dart를 유지하고 싶다.
  • 기존 starryhour_core 로직을 재사용하고 싶다.
  • WebView 안에서 빠르게 로딩되는 정적 웹 앱이면 충분하다.
  • 한글 텍스트와 입력이 안정적으로 보여야 한다.
  • 앱인토스 CLI가 패키징할 수 있는 산출물을 만들 수 있어야 한다.

Jaspr는 이 조건에 잘 맞았다.

앱인토스 WebView 구조 이해하기

앱인토스 WebView 문서는 CSR/SSG 방식의 웹 서비스를 미니앱으로 실행하는 구조를 안내한다. 중요한 건 “웹 프레임워크가 무엇인가”보다, 최종적으로 앱인토스 CLI가 이해할 수 있는 웹 산출물을 만들 수 있느냐였다.

핵심 설정은 granite.config.ts에 모인다.

import { defineConfig } from '@apps-in-toss/web-framework/config';

export default defineConfig({
  appName: 'starryhour',
  brand: {
    displayName: '별이 빛나는 시간',
    primaryColor: '#4FC3F7',
    icon: 'https://static.toss.im/appsintoss/...',
  },
  web: {
    host: 'localhost',
    port: 5173,
    commands: {
      dev: 'npm run serve:web',
      build: 'npm run build:web',
    },
  },
  permissions: [],
  outdir: 'build/jaspr',
  webViewProps: {
    type: 'partner',
  },
});

공식 예시는 vite를 기준으로 설명하는 경우가 많다. 하지만 web.commands.devweb.commands.build는 결국 문자열 명령어다. 이 명령어가 올바른 개발 서버를 띄우고, 올바른 빌드 산출물을 만들면 된다.

그래서 앱인토스 CLI가 실행할 명령어를 Jaspr 명령어로 연결했다.

{
  "scripts": {
    "dev": "granite dev --host 0.0.0.0",
    "build": "ait build",
    "serve:web": "dart run jaspr_cli:jaspr serve --port 5173",
    "clean:web-build": "rimraf build/jaspr/packages build/jaspr/.dart_tool build/jaspr/web/packages build/jaspr/web/.dart_tool",
    "build:web": "rimraf build/jaspr && dart run jaspr_cli:jaspr build && npm run clean:web-build"
  }
}

npm run dev를 실행하면 Granite 개발 서버가 뜨고, 그 안에서 Jaspr 개발 서버가 연결된다. npm run build를 실행하면 Apps in Toss CLI가 web.commands.build를 호출하고, 그 결과물을 .ait 파일로 패키징한다.

이 구조를 잡고 나니 Jaspr 자체는 앱인토스에 “공식 지원되는 프레임워크”가 아니어도 문제없이 들어갈 수 있었다.

광공해 데이터는 한국 범위로 줄였다

Starry Hour의 점수 계산에는 날씨뿐 아니라 광공해 데이터도 사용한다. 문제는 데이터 크기였다.

초기에는 광공해 데이터를 로딩하는 순간 화면이 멈칫하는 느낌이 있었다. 샌드박스 앱에서 테스트했을 때도 첫 검색 후 결과 화면으로 들어가는 시간이 길었다. 미니앱은 빠른 진입과 빠른 결과 확인이 중요한데, 이 경험은 좋지 않았다.

해결은 단순했다. 앱인토스 버전은 국내 한정 서비스로 가기로 했으니, 전 세계 광공해 데이터를 들고 있을 필요가 없었다.

한국 범위로 데이터를 잘라서 light_pollution_korea_0_05.bin.gz 형태로 넣었다. 정확도와 용량 사이에서 적당한 해상도를 선택했고, 결과적으로 첫 로딩과 결과 진입 속도가 눈에 띄게 좋아졌다.

이 결정은 앱인토스 버전의 성격과도 잘 맞았다.

  • 글로벌 탐색보다 국내 장소 검색에 집중한다.
  • 현재 위치 권한을 요청하지 않는다.
  • 빠르게 점수와 조건을 보여준다.
  • 미니앱답게 필요한 데이터만 싣는다.

UI는 Toss 안의 앱과 기존 모바일 앱 사이에서 조정했다

UI는 생각보다 오래 걸렸다.

처음에는 단일 페이지처럼 가볍게 만들었지만, 실제로 보니 기존 모바일 앱의 경험과 너무 달랐다. 결과 화면의 점수 게이지, 시간대별 점수 카드, 추천 시간 카드, 조건 카드 같은 요소는 모바일 앱과 최대한 비슷해야 했다.

반대로 앱인토스에서는 Toss 자체 상단바가 있다. 별도 앱바를 만들면 중복처럼 보였다. 실제 샌드박스와 토스앱에서 확인하면서 상단 여백과 뒤로가기 흐름을 조정했다.

결과적으로 이렇게 정리했다.

  • 홈 화면은 앱인토스 상단바를 전제로 자체 타이틀을 덜어낸다.
  • 검색 화면과 결과 화면도 자체 앱바를 최소화한다.
  • 뒤로가기는 앱인토스 앱바와 브라우저 히스토리 흐름을 따른다.
  • 결과 화면은 모바일 앱의 위젯 구성과 시각 스타일을 최대한 따른다.
  • 아이콘은 SVG asset으로 분리해 Jaspr와 모바일 앱이 함께 쓸 수 있게 준비한다.

특히 결과 화면은 여러 번 조정했다. “위젯 구성은 비슷하지만 생긴 게 다르다”는 피드백을 기준으로 폰트 크기, 색상, 카드 테두리, 가로 스크롤, 점수별 아이콘과 색상, 게이지 방향까지 맞췄다.

이 과정에서 느낀 건, 미니앱이라고 해서 UI를 대충 만들어도 되는 건 아니라는 점이다. 오히려 Toss 안에서 뜨기 때문에 더 짧은 시간 안에 신뢰를 줘야 한다.

.ait 빌드와 샌드박스 테스트

앱인토스 개발 흐름에서 헷갈렸던 부분 중 하나는 개발 서버였다.

처음에는 “Jaspr 서버만 띄우면 되는 것 아닌가?”라고 생각했다. 하지만 샌드박스 앱에서 미니앱으로 실행하려면 Apps in Toss 개발 서버 역할을 하는 Granite 흐름이 필요했다.

결국 개발 명령은 이렇게 정리됐다.

npm run dev

내부적으로는 다음 흐름이다.

  1. granite dev --host 0.0.0.0 실행
  2. Granite가 granite.config.tsweb.commands.dev를 읽는다.
  3. npm run serve:web이 실행된다.
  4. Jaspr 개발 서버가 5173 포트로 뜬다.
  5. 샌드박스 앱은 intoss://starryhour 스킴으로 접근한다.

실기기에서 테스트할 때는 granite.config.tsweb.host를 Mac의 LAN IP로 맞췄다.

npm run dev

Android 샌드박스 앱에서는 포트 reverse도 필요했다.

adb reverse tcp:8081 tcp:8081
adb reverse tcp:5173 tcp:5173

최종 제출 파일은 다음 명령으로 만들었다.

npm run build

이 명령은 최종적으로 starryhour.ait 파일을 만든다.

심사에서 걸린 것들

출시 전후로 몇 가지 심사/가이드 이슈도 있었다.

브랜드 정보

앱인토스 콘솔에 등록한 앱 이름과 granite.config.tsbrand.displayName이 같아야 했다. 아이콘도 콘솔에 등록한 브랜드 아이콘 URL과 맞춰야 했다.

brand: {
  displayName: '별이 빛나는 시간',
  primaryColor: '#4FC3F7',
  icon: 'https://static.toss.im/appsintoss/...',
}

appName은 딥링크와 앱 식별에 쓰이는 값이므로 starryhour로 유지했고, 사용자에게 보이는 이름은 콘솔 정보와 맞췄다.

eval 관련 산출물

심사 과정에서 eval처럼 외부 코드를 받아 실행할 수 있는 코드가 허용되지 않는다는 피드백이 있었다.

문제는 직접 eval을 쓰지 않았는데도, Jaspr/Dart 빌드 산출물 아래에 개발용 Dart compiler 파일이 같이 들어가면서 eval 문자열이 잡힌 점이었다.

조사해보니 build/jaspr/web/packages/$sdk/dev_compiler 아래의 개발 지원 파일들이 원인이었다. 실제 런타임에는 필요 없는 파일이었다.

그래서 빌드 후 정리 스크립트를 추가했다.

{
  "scripts": {
    "prebuild": "rimraf build/jaspr",
    "clean:web-build": "rimraf build/jaspr/packages build/jaspr/.dart_tool build/jaspr/web/packages build/jaspr/web/.dart_tool",
    "build:web": "rimraf build/jaspr && dart run jaspr_cli:jaspr build && npm run clean:web-build"
  }
}

또 하나 중요한 점은 ait build가 기존 build/jaspr/web/index.html이 있으면 웹 빌드를 재사용할 수 있다는 점이었다. 그래서 prebuild에서 build/jaspr를 비워, 오래된 산출물이 섞이지 않도록 했다.

빌드 후에는 다음을 확인했다.

  • build/jasprdev_compiler가 없는지
  • .ait 문자열 기준으로 return eval, eval(, new Function이 잡히지 않는지
  • 브랜드 아이콘 URL이 .ait에 포함됐는지

핀치줌 비활성화

지도 앱이 아닌 이상 제스처로 앱 화면이 확대/축소되는 기능을 비활성화해야 한다는 가이드도 있었다.

이건 index.html의 viewport meta 태그로 처리했다.

<meta
  name="viewport"
  content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
>

viewport-fit=cover는 iOS safe area 대응 때문에 유지했고, maximum-scale=1, user-scalable=no를 추가했다.

최종 구조

최종적으로 앱인토스 Jaspr 앱은 대략 이런 구조가 됐다.

app_ait_jaspr/
  granite.config.ts
  package.json
  pubspec.yaml
  lib/
    main.client.dart
    src/
      route_hash.dart
  web/
    index.html
    styles.css
    assets/
      data/
        recommended_places.json
        light_pollution/
      images/
        icons/
        recommendations/
        result/

역할을 나누면 이렇다.

  • granite.config.ts: 앱인토스 앱 이름, 브랜드, 개발 서버, 빌드 명령, WebView 타입 설정
  • package.json: Granite/AIT/Jaspr 빌드 파이프라인 연결
  • pubspec.yaml: Jaspr와 Dart 패키지 의존성 관리
  • lib/main.client.dart: Jaspr 앱 본체
  • web/index.html: viewport, WebView용 메타 설정, 정적 엔트리
  • web/assets/data: 추천 장소와 광공해 데이터

핵심은 app_ait_jaspr가 독립적인 앱인토스 WebView 프로젝트이면서도, 계산 로직은 packages/starryhour_core를 통해 모바일 앱과 공유한다는 점이다.

Jaspr로 앱인토스 미니앱을 만들 수 있나

결론부터 말하면 가능했다.

다만 “그냥 된다”는 느낌은 아니다. 앱인토스 공식 문서는 일반적인 웹 프레임워크 흐름을 기준으로 설명하고, 예시도 Vite 중심이다. Jaspr를 쓰려면 빌드 결과물이 어디에 생성되는지, 앱인토스 CLI가 어떤 outdir을 기대하는지, 개발 서버를 어떻게 연결할지 직접 확인해야 한다.

그래도 구조를 잡고 나면 꽤 괜찮았다.

좋았던 점은 다음과 같다.

  • Dart를 유지할 수 있었다.
  • 기존 core 로직을 공유할 수 있었다.
  • Flutter Web에서 겪은 한글 폰트 렌더링 문제가 사라졌다.
  • HTML/CSS 기반이라 WebView 안에서 자연스럽게 동작했다.
  • 정적 산출물 중심이라 앱인토스 WebView 배포 흐름과 맞았다.

아쉬웠던 점도 있다.

  • 앱인토스 예제 대부분이 React/Vite 기준이라 그대로 따라 하기 어렵다.
  • TDS Web 컴포넌트를 직접 쓰기 어렵다.
  • Jaspr 빌드 산출물 중 불필요한 개발 파일을 직접 정리해야 했다.
  • 공식 지원 조합이 아니므로 심사 이슈가 생기면 스스로 추적해야 한다.

그래서 이 방식을 모두에게 추천하긴 어렵다. 이미 React/Vue/Svelte 기반 웹 앱이 있다면 공식 흐름을 따르는 게 더 빠를 것이다.

하지만 Flutter/Dart 기반 제품을 운영 중이고, 웹 버전도 Dart로 유지하고 싶다면 Jaspr는 충분히 검토할 만하다. 특히 Flutter Web이 과한 경우, 또는 실제 HTML/CSS 렌더링이 중요한 경우라면 좋은 선택지가 될 수 있다.

마무리

이번 작업에서 가장 크게 배운 건, 미니앱 개발은 “작게 만드는 일”이지 “대충 만드는 일”이 아니라는 점이다.

기능은 줄였지만, 사용자가 보는 흐름은 더 신중하게 다듬어야 했다. Toss 앱 안에서 뜨는 만큼 앱 자체의 상단바, 뒤로가기, safe area, 심사 정책을 모두 고려해야 했다.

그리고 공식 예시와 다른 기술 스택을 쓰는 것은 가능하지만, 그만큼 경계면을 직접 이해해야 한다.

Jaspr 앱을 앱인토스에 올리기 위해 맞춘 경계는 이 세 가지였다.

  1. Apps in Toss CLI가 실행할 수 있는 dev/build 명령
  2. Apps in Toss CLI가 패키징할 수 있는 정적 웹 산출물
  3. Toss 앱 안에서 자연스럽게 보이는 WebView UX

이 세 가지를 맞추면, React가 아니어도 앱인토스 WebView 미니앱을 만들 수 있었다.

Starry Hour의 앱인토스 버전은 그렇게 출시됐다.

참고 링크

작성에 대해

이 글은 Starry Hour 앱인토스 버전을 개발하며 남긴 대화와 작업 기록을 바탕으로 작성했다.

나는 이 작업을 함께 진행한 Codex다. 제품의 방향, 출시 범위, 최종 의사결정은 사람이 주도했다. 구현 검토, 코드 수정, 빌드 파이프라인 구성, 문제 원인 추적, 그리고 이 글의 초안 작성은 내가 맡았다.

이 포스팅은 AI의 시점에서 작성되었다.

마무리2

여기서부터는 사람이 작성함.

Starry Hour라는 모바일 앱을 개발하면서 우연히 앱인토스를 발견하고 관심이 생겨서 Codex와 함께 미니앱을 만들어봤다.
현재는 어느 정도 완성이 되었고, 이 포스팅을 작성하는 시점에는 이미 토스 미니앱에 올라와있다.
앱인토스 출시 후기를 남기고 싶었는데 막상 또 직접 포스팅을 작성하자니 일기 같은 느낌이라 조금은 색다르게 Codex한테

'너'의 시점으로 작성해줘

라고 요청하였고 초안을 보니 몇몇 내용만 제거하면 될 것 같아서 그대로 조금 다듬어서 위와 같은 결과가 나왔다.
직접 쓰는 것보다 깔끔하고 논리 정연하게 잘 쓴거 같아서 묘한 기분이 들기는 하는데,
이렇게 포스팅 하는 것도 꽤 괜찮은 느낌이다. (무엇보다 편함...)

마지막으로 모바일 버전도 곧 출시 예정이다.
플레이 스토어는 비공개 테스트 후 프로덕션 심사 중이고,
앱 스토어는 심사 완료 후 플레이 스토어랑 함께 출시하려고 대기 중이다.
큰 기능은 없고 예전부터 만들어봐야지 했던 앱이어서 바이브 코딩으로 만들었는데 출시할 수 있게 되서 다행이라 생각한다.

별이 빛나는 시간 (앱인토스 링크)

별이 빛나는 시간