APPS IN TOSS · HRUM API · VIBE CODING SERIES

흐름 API 로 토스 미니앱 만들기
— 앱인토스 「오늘의 운세」

AI 코딩 도구에게 아래 시작 문장(AI용 지도 링크)을 주면, AI 가 이 책을 단계별로 읽으며 앱인토스 미니앱 화면과 HRUM API 키를 지키는 서버를 만들고 토스 앱 QR 테스트와 검수 준비까지 함께 가는 실행형 전자책.
구조: 미니앱 번들에는 화면만 들어가고 키는 없습니다. 미니앱은 RIDM Apps 에 올린 내 서버(내앱.ridm.ai)만 부르고, 서버가 HRUM API 키로 오늘의 사주 API 를 부릅니다. 가입·약관 동의·본인 확인·비밀값 입력·콘솔 버튼은 여러분이 직접 하며, AI 가 대신 가입하거나 검수 결과를 보장하지는 않습니다.
AI 코딩 도구에 붙여넣을 시작 문장 (예시 — 말은 달라도 링크만 들어가면 됩니다)
https://console.hrum.ai/tossEbook/ 을 열고, 그 지도의 단계 순서대로 해당 페이지만 읽으며 [AI 시작 지침]대로 나와 함께 앱인토스 미니앱 「오늘의 운세」를 만들어줘. 코드는 내가 결정 질문에 답하고 승인한 뒤에만 써.
AI용 지도: https://console.hrum.ai/tossEbook/ — AI는 지도의 단계마다 필요한 장만 읽습니다. AI가 링크를 못 열면 이 전체본을 저장(Ctrl/⌘+S)해 파일로 첨부하면 됩니다.
HRUM 콘솔 가입하고 시작하기

PART 0 — 이 책의 사용법 (꼭 읽고 시작)

이 책은 HRUM(흐름) API 하나로 토스 앱 안에서 열리는 미니앱을 만드는 실행형 전자책입니다. 예시 앱은 생년월일을 넣으면 그날의 흐름·행운 색·좋은 시간을 보여 주는 「오늘의 운세」입니다. 미니앱 화면(번들)은 앱인토스 콘솔에 올리고, HRUM API 키를 가진 작은 서버는 RIDM Apps 에 올립니다.

먼저 알아 두세요: HRUM(흐름)과 RIDM Apps 는 플로트 주식회사가 운영하는 서비스이고, 토스(비바리퍼블리카)와 제휴하지 않은 독립 서비스입니다. 앱인토스 콘솔 가입·앱 등록·검수·출시는 여러분이 앱인토스 정책에 따라 직접 진행하며, 이 책은 검수 통과를 보장하지 않습니다. 앱인토스에 관한 내용은 공식 개발자센터 문서를 요약한 것이고, 문서와 이 책이 다르면 문서가 맞습니다.

0-1. 누가 무엇을 하나

여러분이 직접AI 코딩 도구가
앱인토스 콘솔 가입·워크스페이스·앱 등록(PART 2)
HRUM 콘솔 가입·이메일 인증·API 키 발급(PART 2)
RIDM Apps 환경변수 칸에 키 넣기(PART 5)
번들 업로드·토스 앱 QR 테스트·검수 요청·출시하기 버튼(PART 6~7)
결정 질문 정리, 책 코드 받기, 테스트 실행, 번들 만들기, 배포 뒤 확인 명령 실행, 체크리스트 점검, PROGRESS.md 기록

가입·약관 동의·본인 확인·비밀값 입력은 사람만 합니다. AI 가 대신 가입하거나 콘솔에 로그인하지 않습니다.

0-2. [AI 시작 지침]

  1. 먼저 묻는다, 한 번에. 아래 결정 질문을 추천값과 함께 한 번에 묻고, 사용자가 승인하기 전에는 코드를 한 줄도 쓰지 않는다. ① 앱인토스 appName(콘솔에 등록할 값과 똑같아야 하고, 등록 뒤에는 바꿀 수 없다 — 추천: 짧은 영문 소문자와 하이픈, 예 my-today) ② 미니앱 이름(국문, 토스 앱에 보이는 이름) ③ RIDM Apps 앱 이름(서버 주소 <이름>.ridm.ai 가 된다) ④ 태어난 시각 입력(추천: 받되 「몰라요」 허용 — 책 코드 그대로).
  2. 책 코드는 다시 쓰지 않는다. 「[완전 코드] 경로」 블록은 node scripts/get-book-code.mjs 로 원문 그대로 받는다(처음에는 부록 A 의 curl 한 줄). 고쳐도 되는 파일은 miniapp/apps-in-toss.config.ts(appName), miniapp/index.html(문구), miniapp/src/style.css(모양), miniapp/.env.example 뿐이다. AI용 분할본의 코드 자리는 「경로」 한 줄이다 — 받은 파일을 cat 으로 읽는다.
  3. HRUM API 키는 서버 환경변수 HRUM_API_KEY 에만 있다. 미니앱 코드·.env·채팅·커밋·로그·PROGRESS.md 에 쓰지 않는다. 미니앱은 api.hrum.ai 를 직접 부르지 않는다 — 미니앱 빌드의 check-bundle 이 그런 번들을 막는다.
  4. 앱인토스 내용은 공식 문서가 우선이다. 각 장 끝 출처의 문서와 이 책이 다르면 문서를 따르고, 무엇이 달랐는지 사용자에게 알린다.
  5. 확인은 명령 출력으로 한다. 「됐다」고 말하기 전에 지도에 적힌 증거 명령을 실행해 출력을 그대로 보인다. 진행 상황은 프로젝트 폴더의 PROGRESS.md 에 적고, 이어서 할 때는 PROGRESS.md 의 다음 단계부터 간다.
  6. 콘솔은 사용자가 누른다. 앱인토스 콘솔·HRUM 콘솔 화면에서 할 일은 순서대로 안내하고, 사용자가 화면에 무엇이 보이는지 말해 주면 다음으로 간다. 짐작하지 않는다.
  7. 홍보 문구 규칙을 지킨다. 미니앱 안팎의 문구에 「토스 공식」「공식 파트너」「제휴」「입점」 같은 표현과 토스 로고를 쓰지 않는다(PART 7-5).

0-3. 이 책으로 만드는 것과 만들지 않는 것

출처

앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.

PART 1 — 무엇을 만들고, 왜 서버가 필요한가

1-1. 한 장으로 보는 구조

요청이 흐르는 길
토스 앱
  └ 미니앱 번들 (<appName>.ait — 화면만, 키 없음)
       │  POST https://<이름>.ridm.ai/api/today   (생년월일)
       ▼
  내 서버 (RIDM Apps — HRUM_API_KEY 를 환경변수로 보관)
       │  POST https://api.hrum.ai/api/v1/partners/v1/today
       │  X-Partner-Api-Key: (서버만 아는 키)
       ▼
  HRUM 오늘의 사주 API → 결과 → 서버가 필요한 칸만 골라 미니앱에 돌려줌

미니앱 번들은 미니앱을 여는 모든 휴대폰에 내려가는 파일입니다. 그 안에 든 것은 누구나 꺼내 볼 수 있다고 봐야 합니다. HRUM 시작 가이드도 키는 서버에서만 쓰고, 브라우저나 앱 코드에 넣으면 누구나 꺼내 쓸 수 있다고 안내합니다. 그래서 이 책은 키를 서버에만 두고, 미니앱은 내 서버 주소만 압니다.

1-2. 앱인토스 쪽 구조 — 이 책이 쓰는 것

앱인토스 「시작하기」 문서에 따르면 앱인토스는 클라이언트 SDK 와 서버 API 두 방식으로 연동합니다. SDK 는 토스 앱 안 미니앱 실행 환경을 만들고, 서버 API 는 로그인·결제처럼 파트너 서버와 앱인토스 서버가 직접 주고받는 기능(mTLS 로 보호)을 맡습니다. SDK 는 WebView SDK 와 React Native SDK 중 하나를 고릅니다.

1-3. 만들어지는 앱

1-4. 시작 전에 알아야 할 앱인토스 정책 요점

항목문서 내용 요약이 책의 예시
로그인미니앱 안 로그인은 토스 로그인만 쓸 수 있고, 다른 로그인·소셜 로그인은 쓸 수 없음(서비스 오픈 정책 2-3)로그인 없음
결제실물 상품은 토스페이만, 디지털 상품은 인앱 결제만(2-3)결제 없음
광고앱인토스 전면형·보상형·배너 광고만, 외부 광고 네트워크는 불가(2-3)광고 없음
외부 링크·자사 앱 설치 유도앱 설치 유도 불가, 외부 링크는 허용된 경우에만. 콘솔에 적은 「앱 내 기능」은 미니앱 안에서 완결돼야 함(2-2)외부 링크 없음, 결과까지 미니앱 안에서 끝남
생성형 AIAI 가 만든 결과를 보여 주면 처음 쓸 때 고지하고 결과에 AI 표시를 해야 함(2-4)규칙 계산 값만 씀. HRUM 의 LLM 한 줄 옵션(llm: true)을 켜면 고지·표시를 넣어야 함
중복 앱같은 워크스페이스에서 핵심 기능이 비슷한 앱을 여러 개 낼 수 없음(2-1)앱 하나
단순 홍보기존 회사·서비스를 단순히 홍보하려고 미니앱을 낼 수 없음(3장)운세 결과가 미니앱의 기능
사업자 등록필수는 아니지만 토스 로그인·인앱 결제·토스페이·프로모션·비즈 월렛에는 필수. 인앱 광고는 등록 없이 시작할 수 있고, 수익이 일정 금액을 넘으면 필요. 부가세 면세 사업자는 등록 불가(사업자 등록하기)해당 기능을 쓰지 않음
이용 연령지금 앱인토스는 만 19세 이상 사용자에게만 제공(미니앱 등록하기)—

출시가 안 되는 서비스(금융·투자 정보·의료·사행성·정치 등)와 조건이 붙는 서비스 목록은 「서비스 오픈 정책」 원문에 있습니다. 정책은 바뀔 수 있으니 등록 전에 원문을 다시 봅니다.

출처

앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.

PART 2 — 계정과 준비물

2-1. 앱인토스 콘솔 — 가입·워크스페이스·앱 등록

① 가입 — 앱인토스 콘솔에 접속해 회원가입합니다. 토스 비즈니스 회원을 기반으로 하며, 만 19세 이상이고 본인 명의로 로그인된 토스 앱이 있어야 합니다.

② 워크스페이스 — 팀이 함께 앱을 관리하는 공간입니다. 사업자당 1개만 만들 수 있고 이름은 겹칠 수 없습니다. 이어서 제작자 이름을 넣습니다. 미니앱 상세 화면에 「누가 만들었는지」로 보이는 이름이고, 공백을 포함해 10자 이내, 한글·영문·숫자만 쓸 수 있고, 토스 브랜드를 도용하거나 헷갈리게 하는 이름(예: 「토스OO」)은 쓸 수 없습니다. 나중에 워크스페이스 「내 정보」에서 바꿀 수 있습니다.

③ 앱 등록 — 워크스페이스의 앱 메뉴에서 +등록하기를 누릅니다. 개발이 끝나지 않은 앱도 먼저 등록할 수 있습니다.

테스트용 앱과 실제 배포용 앱을 같은 워크스페이스에 따로 등록해 나눠 관리할 수도 있습니다. 다만 서비스 오픈 정책은 핵심 기능이 비슷한 앱을 여러 개 출시하는 것을 막습니다 — 출시는 하나만 합니다.

사업자 등록: 이 책의 예시(로그인·결제·광고 없음)에는 필수가 아닙니다. 나중에 토스 로그인·인앱 결제·토스페이 등을 붙이려면 워크스페이스 「내 정보」에서 사업자 등록이 필요하고, 사업자 등록증의 업종과 미니앱 서비스 업종이 같아야 합니다. 부가세 면세 사업자는 등록할 수 없습니다.

2-2. HRUM 콘솔 — 가입·API 키·요청 제한

  1. HRUM 콘솔 로그인 화면에서 회원가입 탭을 고르고 이메일과 비밀번호를 넣습니다. 받은 편지함의 이메일 인증 메일에서 인증 버튼을 누른 뒤 다시 로그인합니다
  2. 콘솔 → API 키에서 키 제목(예: 토스 미니앱 서버)을 적고 발급합니다. 새 키는 발급 직후 한 번만 보입니다. 바로 복사해 비밀번호 관리자 같은 안전한 곳에 둡니다. AI 채팅창·파일·메모에 붙여넣지 않습니다 — PART 5 에서 RIDM Apps 환경변수 칸에 여러분이 직접 넣습니다
  3. 같은 화면의 요청 제한에서 초당·일일 요청 수를 정해 둡니다. 키가 새거나 코드가 잘못 반복해도 잔액을 지키는 마지막 울타리입니다
  4. 호출 비용은 선불 크레딧에서 차감됩니다. 크레딧은 콘솔 → 빌링에서 충전하고, 상품별 금액은 요금표와 콘솔 → 사용량에서 봅니다. 금액은 늘 이 화면들의 값을 기준으로 합니다

2-3. 내 컴퓨터

출처

앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.

PART 3 — 서버 만들기: HRUM 키를 지키는 곳

3-0. 책 코드 받기

빈 프로젝트 폴더에서 부록 A 의 curl 한 줄을 실행하면 이 책의 [완전 코드] 파일이 전부 만들어집니다(서버 파일은 폴더 맨 위, 미니앱 파일은 miniapp/ 아래). 그다음부터는 node scripts/get-book-code.mjs 가 책과 다른 파일을 알려 줍니다.

3-1. 부를 API — HRUM 오늘의 사주

HRUM 응답뜻이 책의 서버가 미니앱에 주는 것
200 success: true성공화면에 쓸 칸만 골라 200
400잘못된 입력400 invalid_input — 같은 요청을 다시 보내지 않음
401 · 403 · 404키 문제 · 열리지 않은 기능503 temporarily_unavailable(서버 로그에 상태 코드만)
402 INSUFFICIENT_BALANCE크레딧 부족503 temporarily_unavailable — 빌링에서 충전
429요청 한도 초과429 + Retry-After
5xx · 시간 초과일시 오류502 upstream_unavailable

3-2. 서버가 하는 일

Origin 검사는 브라우저가 지키는 규칙입니다. curl 처럼 Origin 을 보내지 않는 도구는 막지 않으므로, 서버 주소가 알려져도 위의 울타리와 HRUM 요청 제한이 크레딧을 지킵니다.

[완전 코드] package.json — 서버 — 의존성 없음, Node 22 이상
{
  "name": "toss-today-server",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "start": "node server.mjs",
    "mock": "node mock-hrum.mjs",
    "test": "node --test test/server.test.mjs"
  }
}
[완전 코드] server.mjs — 서버 본체 — 키 보관·CORS·검사·캐시·HRUM 호출
// server.mjs — the mini-app's own server. It holds the HRUM API key and calls HRUM for the mini-app.
// The key never goes to the mini-app bundle: the bundle only knows this server's https address.
// Node 22, no dependencies. Run: node server.mjs  (PORT defaults to 3000, as RIDM Apps expects)
import { createServer } from 'node:http';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';

const MAX_BODY_BYTES = 2048;
const UPSTREAM_TIMEOUT_MS = 10_000;
const CACHE_MAX_ENTRIES = 5000;
const PER_VISITOR_PER_MINUTE = 20;
const VISITORS_MAX = 10_000; // distinct visitors tracked per minute (memory bound)

/** Read settings from the environment (names only; values are never logged). */
export function loadConfig(env = process.env) {
  const appName = (env.TOSS_APP_NAME || '').trim();
  const extra = (env.EXTRA_ORIGINS || '').split(',').map((s) => s.trim()).filter(Boolean);
  return {
    apiKey: env.HRUM_API_KEY || '',
    hrumBase: (env.HRUM_BASE_URL || 'https://api.hrum.ai/api/v1/partners/v1').replace(/\/+$/, ''),
    appName,
    // Mini-app origins from the Apps-in-Toss docs (SDK 3.1.0+ and 1.x~2.x): live + console QR test.
    origins: new Set([
      ...(appName ? [`https://${appName}.apps.tossmini.com`, `https://${appName}.private-apps.tossmini.com`] : []),
      ...extra,
    ]),
    // Upper bound of HRUM calls per day for this server (a leaked address cannot drain the credit).
    // Use the X-Real-IP header only behind a proxy that overwrites it (RIDM Apps does). Anywhere else a
    // visitor could send a fake header per request and never hit the per-visitor limit.
    trustProxy: env.TRUST_PROXY === '1',
    dailyCap: Number.isFinite(Number(env.DAILY_UPSTREAM_CAP)) && env.DAILY_UPSTREAM_CAP ? Number(env.DAILY_UPSTREAM_CAP) : 2000,
  };
}

/** YYYY-MM-DD in Korea time (fixed +9, no daylight saving). */
export function kstToday(now = new Date()) {
  return new Date(now.getTime() + 9 * 3600 * 1000).toISOString().slice(0, 10);
}

const isInt = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;

/** Validate the mini-app's input; returns { body } for HRUM or { error }. */
export function buildTodayRequest(input, date) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) return { error: 'body must be a JSON object' };
  const { year, month, day, hour, minute, gender, calendar, isLeapMonth } = input;
  if (!isInt(year, 1900, 2100) || !isInt(month, 1, 12) || !isInt(day, 1, 31)) return { error: 'year/month/day' };
  if (hour !== undefined && hour !== null && !isInt(hour, 0, 23)) return { error: 'hour' };
  if (minute !== undefined && minute !== null && !isInt(minute, 0, 59)) return { error: 'minute' };
  if (gender !== 'male' && gender !== 'female') return { error: 'gender' };
  if (calendar !== undefined && calendar !== 'solar' && calendar !== 'lunar') return { error: 'calendar' };
  if (isLeapMonth !== undefined && typeof isLeapMonth !== 'boolean') return { error: 'isLeapMonth' };
  const body = { year, month, day, timezone: 'Asia/Seoul', gender, date };
  // Unknown birth time: leave hour out (HRUM calculates it as noon and says so in birthHour).
  if (hour !== undefined && hour !== null) {
    body.hour = hour;
    body.minute = minute ?? 0;
  }
  if (calendar) body.calendar = calendar;
  if (calendar === 'lunar' && isLeapMonth) body.isLeapMonth = true;
  return { body };
}

/** Only what the screen shows — not the whole HRUM response. */
export function pickToday(data) {
  const s = data?.summary ?? {};
  const o = data?.omen ?? {};
  return {
    date: data?.date ?? null,
    dayGanji: s.today_ganji ?? null,
    dailyFlow: s.daily_flow ?? null,
    relation: o.relation?.label ?? null,
    color: o.color ? { name: o.color.name, hex: o.color.hex } : null,
    food: o.food ?? null,
    keyword: o.keyword ?? null,
    message: o.message ?? null,
    luckyHour: o.luckyHour ?? null,
    avoidHour: o.avoidHour ?? null,
    activity: o.activity ?? null,
  };
}

function send(res, status, payload, headers = {}) {
  const text = payload === null ? '' : JSON.stringify(payload);
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });
  res.end(text);
}

function readBody(req) {
  return new Promise((resolve, reject) => {
    let size = 0;
    const chunks = [];
    req.on('data', (c) => {
      size += c.length;
      if (size > MAX_BODY_BYTES) { reject(Object.assign(new Error('too large'), { status: 413 })); req.destroy(); return; }
      chunks.push(c);
    });
    req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
    req.on('error', reject);
  });
}

/** Build the request handler. Everything that varies (env, fetch, clock) can be injected for tests. */
export function createApp({ config = loadConfig(), fetchImpl = fetch, now = () => new Date(), log = console } = {}) {
  const cache = new Map(); // key → { day, value }
  const inflight = new Map(); // key → Promise
  const visitors = new Map(); // ip → requests in the current minute (emptied when the minute changes)
  let visitorsMinute = -1;
  let upstream = { day: '', count: 0 };

  function corsHeaders(origin) {
    return origin && config.origins.has(origin)
      ? { 'Access-Control-Allow-Origin': origin, Vary: 'Origin' }
      : { Vary: 'Origin' };
  }

  function clientIp(req) {
    const header = config.trustProxy ? req.headers['x-real-ip'] : undefined;
    return String((typeof header === 'string' && header) || req.socket.remoteAddress || 'unknown');
  }

  function tooMany(ip) {
    const minute = Math.floor(now().getTime() / 60000);
    if (minute !== visitorsMinute) { visitors.clear(); visitorsMinute = minute; }
    const count = (visitors.get(ip) ?? 0) + 1;
    // Too many different visitors in one minute: refuse newcomers instead of growing without bound.
    if (count === 1 && visitors.size >= VISITORS_MAX) return true;
    visitors.set(ip, count);
    return count > PER_VISITOR_PER_MINUTE;
  }

  async function callHrum(body) {
    const res = await fetchImpl(`${config.hrumBase}/today`, {
      method: 'POST',
      headers: { 'X-Partner-Api-Key': config.apiKey, 'Content-Type': 'application/json', 'User-Agent': 'toss-today-miniapp/1.0' },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
    });
    let json = null;
    try { json = await res.json(); } catch { /* non-JSON error page */ }
    return { status: res.status, json, retryAfter: res.headers.get('retry-after') };
  }

  async function today(req, res, cors) {
    if (!config.apiKey) { log.error('HRUM_API_KEY is not set'); return send(res, 500, { error: 'server_not_configured' }, cors); }
    if (tooMany(clientIp(req))) return send(res, 429, { error: 'too_many_requests' }, { ...cors, 'Retry-After': '60' });

    let input;
    try { input = JSON.parse(await readBody(req)); } catch (e) {
      return send(res, e.status === 413 ? 413 : 400, { error: e.status === 413 ? 'body_too_large' : 'invalid_json' }, cors);
    }
    const date = kstToday(now());
    const { body, error } = buildTodayRequest(input, date);
    if (error) return send(res, 400, { error: 'invalid_input', field: error }, cors);

    // Same birth data + same day = same result: HRUM is called once per day per person.
    const key = createHash('sha256').update(JSON.stringify(body)).digest('hex');
    const hit = cache.get(key);
    if (hit && hit.day === date) return send(res, 200, { ...hit.value, cached: true }, cors);

    // Two taps at once share one HRUM call.
    let pending = inflight.get(key);
    if (!pending) {
      if (upstream.day !== date) upstream = { day: date, count: 0 };
      if (upstream.count >= config.dailyCap) { log.error('daily upstream cap reached'); return send(res, 503, { error: 'daily_limit' }, cors); }
      upstream.count += 1;
      pending = callHrum(body).finally(() => inflight.delete(key));
      inflight.set(key, pending);
    }
    let r;
    try { r = await pending; } catch (e) {
      log.error('HRUM call failed:', e.name);
      return send(res, 502, { error: 'upstream_unavailable' }, cors);
    }
    if (r.status === 200 && r.json?.success) {
      const value = pickToday(r.json.data);
      if (cache.size >= CACHE_MAX_ENTRIES) cache.delete(cache.keys().next().value);
      cache.set(key, { day: date, value });
      return send(res, 200, value, cors);
    }
    const code = r.json?.code ?? '';
    log.error(`HRUM answered ${r.status} ${code}`); // status + code only — never the key or the birth data
    if (r.status === 400) return send(res, 400, { error: 'invalid_input' }, cors);
    if (r.status === 429) return send(res, 429, { error: 'too_many_requests' }, { ...cors, 'Retry-After': r.retryAfter || '60' });
    if (r.status === 401 || r.status === 402 || r.status === 403 || r.status === 404) return send(res, 503, { error: 'temporarily_unavailable' }, cors);
    return send(res, 502, { error: 'upstream_unavailable' }, cors);
  }

  async function handle(req, res) {
    const origin = req.headers.origin;
    const cors = corsHeaders(origin);
    const path = (req.url || '/').split('?')[0];
    try {
      if (req.method === 'GET' && (path === '/' || path === '/health')) {
        return send(res, 200, { ok: true, keySet: Boolean(config.apiKey), appNameSet: Boolean(config.appName) });
      }
      if (path !== '/api/today') return send(res, 404, { error: 'not_found' });
      // A browser sends Origin; only the mini-app origins may read the answer (CORS).
      if (origin && !config.origins.has(origin)) return send(res, 403, { error: 'origin_not_allowed' }, { Vary: 'Origin' });
      if (req.method === 'OPTIONS') {
        return send(res, 204, null, { ...cors, 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Max-Age': '600' });
      }
      if (req.method !== 'POST') return send(res, 405, { error: 'method_not_allowed' }, { ...cors, Allow: 'POST, OPTIONS' });
      return await today(req, res, cors);
    } catch (e) {
      log.error('unexpected error:', e.name);
      if (!res.headersSent) send(res, 500, { error: 'internal' }, cors);
    }
  }
  // For tests: how many entries the in-memory maps hold.
  handle.stats = () => ({ visitors: visitors.size, cache: cache.size });
  return handle;
}

// Started directly (node server.mjs) → listen. Imported (tests) → nothing runs.
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
  const config = loadConfig();
  const port = Number(process.env.PORT || 3000);
  createServer(createApp({ config })).listen(port, process.env.HOSTNAME || '0.0.0.0', () => {
    console.log(`listening on ${port} · key ${config.apiKey ? 'set' : 'MISSING'} · origins ${config.origins.size}`);
  });
}
[완전 코드] mock-hrum.mjs — 키 없이 시험하는 가짜 HRUM(HRUM 문서의 응답 예시를 그대로 돌려줌)
// mock-hrum.mjs — a local stand-in for POST https://api.hrum.ai/api/v1/partners/v1/today.
// It answers with the response example of the HRUM docs (오늘의 사주 API) so you can build and test
// without a key and without spending credit. It does NOT calculate anything — the values are fixed.
// Run: node mock-hrum.mjs  (port 4010) → then HRUM_BASE_URL=http://127.0.0.1:4010/api/v1/partners/v1
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';

export const MOCK_KEY = 'mock-key-for-local-tests';

export function mockData(date) {
  return {
    date,
    summary: {
      ilju: '경진', day_master: '경', today_date: date, today_ganji: '정사', today_day_ohaeng: '화',
      today_sipsin: '정관', flow_score: 60, flow_tier: 'good',
      daily_flow: '오늘(정사일) 화 에너지가 흘러 소통·확장에 유리해.',
    },
    omen: {
      relation: { type: 'GEUK', label: '조율의 날' },
      color: { name: '주황', hex: '#FFA45B', ohaeng: '화' },
      food: '고추장비빔밥',
      keyword: '공개',
      message: '표현하고 싶은 마음과 주변 반응이 엇갈릴 수 있는 날이에요. 즉흥적인 말보다 한 박자 늦춘 표현이 좋아요.',
      luckyHour: '오전 11시 ~ 오후 1시',
      avoidHour: '밤 11시 이후',
      activity: '칭찬 먼저 하기',
    },
    taskAffinity: [],
  };
}

export function createMock() {
  const state = { calls: 0, lastBody: null, failWith: null };
  const server = createServer((req, res) => {
    let raw = '';
    req.on('data', (c) => { raw += c; });
    req.on('end', () => {
      const reply = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
      if (req.method !== 'POST' || req.url !== '/api/v1/partners/v1/today') return reply(404, { success: false, statusCode: 404, code: 'NOT_FOUND' });
      if (req.headers['x-partner-api-key'] !== MOCK_KEY) return reply(401, { success: false, statusCode: 401, code: 'UNAUTHORIZED', message: 'Invalid API key' });
      state.calls += 1;
      try { state.lastBody = JSON.parse(raw); } catch { return reply(400, { success: false, statusCode: 400 }); }
      if (state.failWith) return reply(state.failWith, { success: false, statusCode: state.failWith, code: state.failWith === 402 ? 'INSUFFICIENT_BALANCE' : 'ERROR' });
      reply(200, { success: true, data: mockData(state.lastBody.date) });
    });
  });
  return { server, state };
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
  createMock().server.listen(4010, '127.0.0.1', () => console.log(`mock HRUM on http://127.0.0.1:4010 (key: ${MOCK_KEY})`));
}
[완전 코드] test/server.test.mjs — 서버 자동 테스트 — 가짜 HRUM 으로
// test/server.test.mjs — runs the real server against the local mock HRUM (no key, no credit).
// Run: npm test
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { createApp, loadConfig, buildTodayRequest, kstToday } from '../server.mjs';
import { createMock, MOCK_KEY } from '../mock-hrum.mjs';

const APP = 'my-today';
const LIVE = `https://${APP}.apps.tossmini.com`;
const QR = `https://${APP}.private-apps.tossmini.com`;
const quiet = { error() {}, log() {} };
let mock, server, base;

function start(handler) {
  return new Promise((resolve) => {
    const s = createServer(handler).listen(0, '127.0.0.1', () => resolve(s));
  });
}

before(async () => {
  mock = createMock();
  await new Promise((r) => mock.server.listen(0, '127.0.0.1', r));
  const config = loadConfig({
    HRUM_API_KEY: MOCK_KEY,
    TOSS_APP_NAME: APP,
    HRUM_BASE_URL: `http://127.0.0.1:${mock.server.address().port}/api/v1/partners/v1`,
  });
  server = await start(createApp({ config, log: quiet }));
  base = `http://127.0.0.1:${server.address().port}`;
});
after(() => { server.close(); mock.server.close(); });

const birth = { year: 1990, month: 5, day: 15, hour: 12, minute: 0, gender: 'male' };
const post = (body, origin = LIVE) =>
  fetch(`${base}/api/today`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify(body) });

test('health answers on / without showing the key', async () => {
  const r = await fetch(`${base}/`);
  assert.equal(r.status, 200);
  const j = await r.json();
  assert.deepEqual(j, { ok: true, keySet: true, appNameSet: true });
});

test('preflight: mini-app origins are allowed, others are refused', async () => {
  for (const origin of [LIVE, QR]) {
    const r = await fetch(`${base}/api/today`, { method: 'OPTIONS', headers: { Origin: origin, 'Access-Control-Request-Method': 'POST' } });
    assert.equal(r.status, 204);
    assert.equal(r.headers.get('access-control-allow-origin'), origin);
  }
  const bad = await fetch(`${base}/api/today`, { method: 'OPTIONS', headers: { Origin: 'https://evil.example' } });
  assert.equal(bad.status, 403);
  assert.equal(bad.headers.get('access-control-allow-origin'), null);
});

test('today: sends the key in X-Partner-Api-Key, today (KST) as date, and returns only screen fields', async () => {
  const r = await post(birth);
  assert.equal(r.status, 200);
  const j = await r.json();
  assert.equal(j.relation, '조율의 날');
  assert.equal(j.color.hex, '#FFA45B');
  assert.equal(j.date, kstToday());
  assert.equal(mock.state.lastBody.timezone, 'Asia/Seoul');
  assert.equal(mock.state.lastBody.date, kstToday());
  assert.equal('summary' in j, false);
});

test('same person, same day → second answer comes from the cache (one HRUM call)', async () => {
  const before = mock.state.calls;
  const a = await post({ ...birth, day: 16 });
  const b = await post({ ...birth, day: 16 });
  assert.equal(a.status, 200);
  assert.equal((await b.json()).cached, true);
  assert.equal(mock.state.calls - before, 1);
});

test('unknown birth time: hour is left out of the HRUM request', async () => {
  const r = await post({ year: 1991, month: 2, day: 3, gender: 'female' });
  assert.equal(r.status, 200);
  assert.equal('hour' in mock.state.lastBody, false);
});

test('bad input → 400 and no HRUM call', async () => {
  const before = mock.state.calls;
  const r = await post({ ...birth, month: 13 });
  assert.equal(r.status, 400);
  assert.equal(mock.state.calls, before);
});

test('HRUM 402 (no credit) → 503 to the mini-app, nothing cached', async () => {
  mock.state.failWith = 402;
  const r = await post({ ...birth, day: 20 });
  mock.state.failWith = null;
  assert.equal(r.status, 503);
  assert.deepEqual(await r.json(), { error: 'temporarily_unavailable' });
  const again = await post({ ...birth, day: 20 });
  assert.equal(again.status, 200);
});

test('a wrong key never reaches the screen as a key problem', async () => {
  const s = await start(createApp({
    config: loadConfig({ HRUM_API_KEY: 'wrong', TOSS_APP_NAME: APP, HRUM_BASE_URL: `http://127.0.0.1:${mock.server.address().port}/api/v1/partners/v1` }),
    log: quiet,
  }));
  const r = await fetch(`http://127.0.0.1:${s.address().port}/api/today`, { method: 'POST', headers: { Origin: LIVE, 'Content-Type': 'application/json' }, body: JSON.stringify(birth) });
  s.close();
  assert.equal(r.status, 503);
});

test('no key configured → 500 server_not_configured', async () => {
  const s = await start(createApp({ config: loadConfig({ TOSS_APP_NAME: APP }), log: quiet }));
  const r = await fetch(`http://127.0.0.1:${s.address().port}/api/today`, { method: 'POST', headers: { Origin: LIVE }, body: '{}' });
  s.close();
  assert.equal(r.status, 500);
});

test('buildTodayRequest: lunar leap month is passed only with calendar lunar', () => {
  assert.equal(buildTodayRequest({ ...birth, calendar: 'lunar', isLeapMonth: true }, '2026-10-28').body.isLeapMonth, true);
  assert.equal(buildTodayRequest({ ...birth, isLeapMonth: true }, '2026-10-28').body.isLeapMonth, undefined);
  assert.equal(buildTodayRequest({ ...birth, gender: 'x' }, '2026-10-28').error, 'gender');
});

// A fresh app with its own clock: rate-limit tests must not share counters with the tests above.
async function limitApp(env, clock) {
  const app = createApp({
    config: loadConfig({ HRUM_API_KEY: MOCK_KEY, TOSS_APP_NAME: APP, HRUM_BASE_URL: `http://127.0.0.1:${mock.server.address().port}/api/v1/partners/v1`, ...env }),
    now: () => clock.t,
    log: quiet,
  });
  const s = await start(app);
  const hit = (ip) => fetch(`http://127.0.0.1:${s.address().port}/api/today`, {
    method: 'POST', headers: { Origin: LIVE, 'Content-Type': 'application/json', 'X-Real-IP': ip }, body: '{}',
  });
  return { app, s, hit };
}

test('a fake X-Real-IP does not escape the per-visitor limit (no TRUST_PROXY)', async () => {
  const { s, hit } = await limitApp({}, { t: new Date('2026-10-28T03:00:00Z') });
  const codes = [];
  for (let i = 0; i < 21; i++) codes.push((await hit(`10.0.0.${i}`)).status);
  s.close();
  assert.equal(codes.filter((c) => c === 429).length, 1); // the 21st request from the same socket
});

test('TRUST_PROXY=1: the proxy-set X-Real-IP separates visitors', async () => {
  const { s, hit } = await limitApp({ TRUST_PROXY: '1' }, { t: new Date('2026-10-28T03:00:00Z') });
  const codes = [];
  for (let i = 0; i < 21; i++) codes.push((await hit(`10.0.0.${i}`)).status);
  s.close();
  assert.equal(codes.includes(429), false);
});

test('visitor counters of a past minute are dropped', async () => {
  const clock = { t: new Date('2026-10-28T03:00:00Z') };
  const { app, s, hit } = await limitApp({ TRUST_PROXY: '1' }, clock);
  for (let i = 0; i < 5; i++) await hit(`10.0.1.${i}`);
  assert.equal(app.stats().visitors, 5);
  clock.t = new Date('2026-10-28T03:01:00Z');
  await hit('10.0.2.1');
  s.close();
  assert.equal(app.stats().visitors, 1);
});

3-3. 키 없이 돌려 보기

① 자동 테스트 — 프로젝트 폴더에서:

명령
npm test

끝에 # pass 13 · # fail 0 이 보이면 됩니다. 이 테스트는 실제 서버 코드를 가짜 HRUM 에 붙여 돌립니다 — 미니앱 주소만 허용되는지, 키가 헤더로 가는지, 같은 날 두 번째 요청이 캐시에서 나오는지, 크레딧 부족·잘못된 키가 화면에 어떻게 보이는지, 가짜 X-Real-IP 로 방문자 제한을 피할 수 없는지를 봅니다.

② 손으로 한 번 — 터미널 세 개를 엽니다.

터미널 1 — 가짜 HRUM
npm run mock
터미널 2 — 서버 (appName 자리에 내 값)
HRUM_API_KEY=mock-key-for-local-tests \
HRUM_BASE_URL=http://127.0.0.1:4010/api/v1/partners/v1 \
TOSS_APP_NAME=my-today EXTRA_ORIGINS=http://localhost:5173 \
npm start
터미널 3 — 확인
curl -s -X POST http://localhost:3000/api/today \
  -H "Content-Type: application/json" -H "Origin: http://localhost:5173" \
  -d '{"year":1990,"month":5,"day":15,"hour":12,"minute":0,"gender":"male"}'

"relation":"조율의 날" 이 든 JSON 이 나오면 됩니다. 이 값은 가짜 HRUM 이 돌려주는 고정 예시라 생년월일을 바꿔도 같습니다. mock-key-for-local-tests 는 가짜 HRUM 전용 문자열이고 진짜 키가 아닙니다.

환경변수필수뜻
HRUM_API_KEY예(비밀)HRUM 콘솔에서 발급한 키. 운영에서는 RIDM Apps 환경변수 칸에만
TOSS_APP_NAME예앱인토스 appName — 허용 Origin 두 개를 만든다
EXTRA_ORIGINS아니오쉼표로 더 허용할 주소. 내 컴퓨터 개발 때만 http://localhost:5173, 운영에서는 비운다
HRUM_BASE_URL아니오가짜 HRUM 으로 시험할 때만. 운영에서는 비운다(기본값이 실제 주소)
DAILY_UPSTREAM_CAP아니오서버 하루 HRUM 호출 상한. 비우면 2000
TRUST_PROXYRIDM Apps 에서는 예1 이면 앞단 프록시가 덮어쓴 X-Real-IP 로 방문자를 구분. 내 컴퓨터처럼 프록시 없이 바로 받는 곳에서는 비운다
이 책을 쓸 때 실제로 돌린 것 (2026-10-28, Node 22.22.2): 위 책 코드 그대로 npm test → 13개 통과·0개 실패. 서버와 가짜 HRUM 을 따로 띄워 curl → 200 과 위 JSON. 그다음 HRUM 시험용 계정에서 API 키를 하나 발급해 서버 환경변수에만 넣고, 실제 https://api.hrum.ai 에 붙여 성공 응답을 받았습니다(예시 생년월일, 결과는 PART 5-5). 같은 요청을 한 번 더 보내면 서버 캐시에서 나와 HRUM 을 다시 부르지 않는 것도 확인했고, 끝난 뒤 그 키는 폐기했습니다. 키 없이 보내면 401(success:false·statusCode:401), 틀린 키를 넣은 서버는 미니앱 쪽에 503 을 주는 것도 확인했습니다. 가짜 HRUM 의 값은 HRUM 문서의 응답 예시를 옮긴 것입니다.

출처

앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.

PART 4 — 미니앱 만들기: 화면과 번들

4-1. 공식 방법과 이 책의 파일

앱인토스 「기존 웹 프로젝트에 SDK 연동하기」 문서의 순서는 npm install @apps-in-toss/web-framework → npx ait init(설정 파일 apps-in-toss.config.ts 생성) → appName 을 콘솔에 등록한 값과 같게 → npm run dev 입니다. appName 이 등록한 앱 정보와 다르면 콘솔에 업로드할 수 없습니다. 이 책은 그 결과 파일을 miniapp/ 에 그대로 담아 두었습니다 — 받은 뒤 apps-in-toss.config.ts 의 appName 만 내 값으로 바꿉니다.

SDK 3.x 는 설정 파일이 apps-in-toss.config.ts 이고, build 스크립트가 ait build 를 함께 실행해 <appName>.ait 번들을 만듭니다. 이 책의 build 는 그 사이에 check-bundle 을 넣어, 번들에 HRUM 키나 api.hrum.ai 직접 호출이 들어가면 빌드를 멈춥니다.

[완전 코드] miniapp/package.json — 미니앱 — SDK 3.8.0 · devtools · Vite (버전 고정)
{
  "name": "toss-today-miniapp",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build && node scripts/check-bundle.mjs dist && ait build"
  },
  "dependencies": {
    "@apps-in-toss/web-framework": "3.8.0"
  },
  "devDependencies": {
    "@apps-in-toss/devtools": "3.8.0",
    "vite": "7.3.7"
  }
}
[완전 코드] miniapp/apps-in-toss.config.ts — 앱인토스 설정 — appName 을 내 값으로 (고쳐도 되는 파일)
import { defineConfig } from '@apps-in-toss/web-framework/config';

// appName must be exactly the appName registered in the Apps-in-Toss console (it cannot be changed later).
export default defineConfig({
  appName: 'my-today',
  brand: {
    primaryColor: '#3182F6',
  },
  webView: {},
  permissions: [],
  webBundleDir: 'dist',
});
[완전 코드] miniapp/vite.config.ts — 로컬 브라우저에서 SDK 기능을 쓰게 해 주는 devtools 플러그인
import { defineConfig } from 'vite';
import aitDevtools from '@apps-in-toss/devtools/unplugin';

// devtools: Apps-in-Toss SDK calls (Storage etc.) work in a local browser while developing.
export default defineConfig({
  plugins: [aitDevtools.vite()],
});
[완전 코드] miniapp/index.html — 화면 뼈대 — 문구는 고쳐도 됨
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="color-scheme" content="light">
<title>오늘의 운세</title>
</head>
<body>
<main class="page">
  <header class="intro">
    <h1>오늘의 운세</h1>
    <p>생년월일을 넣으면 오늘의 흐름과 행운 색·시간을 알려 줘요. 재미로 보는 운세예요.</p>
  </header>

  <form id="birth" class="card" novalidate>
    <label>생년월일 <input id="date" type="date" min="1900-01-01" max="2100-12-31" required></label>
    <label>태어난 시각 <input id="time" type="time"></label>
    <label class="check"><input id="noTime" type="checkbox"> 태어난 시각을 몰라요</label>
    <fieldset>
      <legend>성별</legend>
      <label class="check"><input type="radio" name="gender" value="female" required> 여성</label>
      <label class="check"><input type="radio" name="gender" value="male"> 남성</label>
    </fieldset>
    <button id="submit" type="submit">오늘의 운세 보기</button>
    <p id="status" class="status" role="status" aria-live="polite"></p>
  </form>

  <section id="result" class="card result" hidden aria-live="polite">
    <p class="relation" id="relation"></p>
    <p class="message" id="message"></p>
    <dl class="facts">
      <div><dt>행운 색</dt><dd><span id="swatch" class="swatch"></span><span id="color"></span></dd></div>
      <div><dt>좋은 시간</dt><dd id="lucky"></dd></div>
      <div><dt>피할 시간</dt><dd id="avoid"></dd></div>
      <div><dt>추천 활동</dt><dd id="activity"></dd></div>
      <div><dt>행운 음식</dt><dd id="food"></dd></div>
      <div><dt>키워드</dt><dd id="keyword"></dd></div>
    </dl>
    <p class="flow" id="flow"></p>
    <button id="edit" type="button" class="ghost">생년월일 다시 넣기</button>
  </section>
</main>
<script type="module" src="/src/main.js"></script>
</body>
</html>
[완전 코드] miniapp/src/main.js — 화면 동작 — 내 서버만 부르고, 생년월일은 기기에 저장
// src/main.js — the mini-app screen. It talks only to OUR server (VITE_API_BASE), never to HRUM directly:
// the HRUM API key lives on the server, so nothing secret is inside this bundle.
import { Storage } from '@apps-in-toss/web-framework';
import './style.css';

const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/+$/, '');
const SAVED_KEY = 'birth-v1';
const $ = (id) => document.getElementById(id);

// Saved birth data stays on the phone (Apps-in-Toss Storage). If storage fails, the app still works.
async function loadSaved() {
  try {
    const raw = await Storage.getItem(SAVED_KEY);
    return raw ? JSON.parse(raw) : null;
  } catch {
    return null;
  }
}
async function save(birth) {
  try { await Storage.setItem(SAVED_KEY, JSON.stringify(birth)); } catch { /* keep going without saving */ }
}

function readForm() {
  const date = $('date').value; // YYYY-MM-DD
  const gender = document.querySelector('input[name="gender"]:checked')?.value;
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return { error: '생년월일을 넣어 주세요.' };
  if (!gender) return { error: '성별을 골라 주세요.' };
  const [year, month, day] = date.split('-').map(Number);
  const birth = { year, month, day, gender };
  if (!$('noTime').checked && $('time').value) {
    const [hour, minute] = $('time').value.split(':').map(Number);
    Object.assign(birth, { hour, minute });
  }
  return { birth };
}

function fillForm(b) {
  $('date').value = `${b.year}-${String(b.month).padStart(2, '0')}-${String(b.day).padStart(2, '0')}`;
  if (b.hour !== undefined) $('time').value = `${String(b.hour).padStart(2, '0')}:${String(b.minute ?? 0).padStart(2, '0')}`;
  $('noTime').checked = b.hour === undefined;
  $('time').disabled = $('noTime').checked;
  const g = document.querySelector(`input[name="gender"][value="${b.gender}"]`);
  if (g) g.checked = true;
}

const MESSAGES = {
  400: '입력한 생년월일을 다시 확인해 주세요.',
  429: '잠시 후 다시 시도해 주세요.',
  503: '지금은 운세를 불러올 수 없어요. 잠시 후 다시 시도해 주세요.',
};

async function fetchToday(birth) {
  const res = await fetch(`${API_BASE}/api/today`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(birth),
    signal: AbortSignal.timeout(15000),
  });
  if (!res.ok) throw new Error(MESSAGES[res.status] || '운세를 불러오지 못했어요. 잠시 후 다시 시도해 주세요.');
  return res.json();
}

function show(t) {
  $('relation').textContent = t.relation ? `오늘은 ${t.relation}` : '오늘의 운세';
  $('message').textContent = t.message ?? '';
  $('color').textContent = t.color?.name ?? '-';
  $('swatch').style.background = /^#[0-9a-f]{6}$/i.test(t.color?.hex ?? '') ? t.color.hex : 'transparent';
  $('lucky').textContent = t.luckyHour ?? '-';
  $('avoid').textContent = t.avoidHour ?? '-';
  $('activity').textContent = t.activity ?? '-';
  $('food').textContent = t.food ?? '-';
  $('keyword').textContent = t.keyword ?? '-';
  $('flow').textContent = t.dailyFlow ?? '';
  $('birth').hidden = true;
  $('result').hidden = false;
}

let busy = false; // one request at a time — no repeated calls from double taps
async function run(birth) {
  if (busy) return;
  busy = true;
  $('submit').disabled = true;
  $('status').textContent = '불러오는 중이에요…';
  try {
    show(await fetchToday(birth));
    $('status').textContent = '';
  } catch (e) {
    $('status').textContent = e.name === 'TimeoutError' || e.name === 'TypeError'
      ? '연결이 원활하지 않아요. 잠시 후 다시 시도해 주세요.'
      : e.message;
  } finally {
    busy = false;
    $('submit').disabled = false;
  }
}

$('noTime').addEventListener('change', () => { $('time').disabled = $('noTime').checked; });
$('birth').addEventListener('submit', async (ev) => {
  ev.preventDefault();
  const { birth, error } = readForm();
  if (error) { $('status').textContent = error; return; }
  await save(birth);
  await run(birth);
});
$('edit').addEventListener('click', () => { $('result').hidden = true; $('birth').hidden = false; });

// On open: show the form filled with the saved birth data. Nothing pops up by itself.
const saved = await loadSaved();
if (saved) fillForm(saved);
if (!API_BASE) $('status').textContent = 'VITE_API_BASE 가 비어 있어요 (.env 확인).';
[완전 코드] miniapp/src/style.css — 모양 — 라이트 모드 (고쳐도 되는 파일)
/* Light theme only (Apps-in-Toss non-game checklist: the mini-app theme is light mode). */
:root {
  --bg: #f4f5f7;
  --card: #ffffff;
  --ink: #191f28;
  --muted: #6b7684;
  --line: #e5e8eb;
  --accent: #3182f6;
  --on-accent: #ffffff;
  --radius: 16px;
  color-scheme: light;
}
* { box-sizing: border-box; }
body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
  line-height: 1.6;
  word-break: keep-all;
}
.page { max-width: 480px; margin: 0 auto; padding: 20px 16px 40px; }
.intro h1 { font-size: 24px; margin: 8px 0 4px; }
.intro p { margin: 0 0 16px; color: var(--muted); font-size: 15px; }
.card { background: var(--card); border-radius: var(--radius); padding: 20px; display: grid; gap: 14px; }
label { display: grid; gap: 6px; font-size: 14px; color: var(--muted); }
input[type='date'], input[type='time'] {
  font: inherit; font-size: 16px; color: var(--ink);
  padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: #fff; width: 100%;
}
input:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.check { display: flex; align-items: center; gap: 8px; color: var(--ink); font-size: 15px; }
fieldset { border: 0; padding: 0; margin: 0; display: flex; gap: 20px; flex-wrap: wrap; }
legend { font-size: 14px; color: var(--muted); margin-bottom: 6px; width: 100%; }
button {
  font: inherit; font-weight: 700; font-size: 16px; border: 0; border-radius: 12px; padding: 14px;
  background: var(--accent); color: var(--on-accent); cursor: pointer;
}
button:active { transform: scale(0.99); }
button:disabled { opacity: 0.6; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.status { margin: 0; min-height: 1.4em; font-size: 14px; color: var(--muted); }
.relation { font-size: 20px; font-weight: 700; margin: 0; }
.message { margin: 0; font-size: 16px; }
.facts { margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.facts div { background: var(--bg); border-radius: 12px; padding: 10px 12px; min-width: 0; }
.facts dt { font-size: 12px; color: var(--muted); }
.facts dd { margin: 2px 0 0; font-weight: 600; overflow-wrap: anywhere; display: flex; align-items: center; gap: 6px; }
.swatch { width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--line); flex: 0 0 auto; }
.flow { margin: 0; color: var(--muted); font-size: 14px; }
[hidden] { display: none !important; }
[완전 코드] miniapp/scripts/check-bundle.mjs — 번들에 키가 들어가면 빌드를 멈추는 검사
// scripts/check-bundle.mjs — stops the build when the mini-app bundle could expose the HRUM key.
// The bundle is downloaded to every phone: anything in dist/ is public. Run by "npm run build".
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';

const DIR = process.argv[2] || 'dist';
const RULES = [
  [/X-Partner-Api-Key/i, 'calls HRUM directly with a key header — call your own server instead'],
  [/api\.hrum\.ai/i, 'calls api.hrum.ai directly — only the server may call HRUM'],
  [/HRUM_API_KEY/, 'mentions HRUM_API_KEY — the key belongs to the server environment only'],
];

function* files(dir) {
  for (const name of readdirSync(dir)) {
    const p = join(dir, name);
    if (statSync(p).isDirectory()) yield* files(p);
    else yield p;
  }
}

let bad = 0;
for (const file of files(DIR)) {
  const text = readFileSync(file, 'utf8');
  for (const [re, why] of RULES) {
    if (re.test(text)) { console.error(`BLOCKED ${file}: ${why}`); bad++; }
  }
}
if (bad) process.exit(1);
console.log(`check-bundle: ${DIR} has no HRUM key or direct HRUM call`);
[완전 코드] miniapp/.env.example — 서버 주소 예시 — 비밀이 아님 (고쳐도 되는 파일)
# The https address of YOUR server (part 5). Not a secret — it goes into the bundle.
# Never put the HRUM API key here: every VITE_* value ends up inside the mini-app.
VITE_API_BASE=https://my-today.ridm.ai

4-2. 화면을 이렇게 만든 이유 (비게임 출시 가이드)

4-3. 내 컴퓨터에서 실행

  1. PART 3-3 의 가짜 HRUM 과 서버를 켜 둡니다(EXTRA_ORIGINS=http://localhost:5173 포함)
  2. miniapp/.env.local 파일을 만들어 한 줄을 넣습니다: VITE_API_BASE=http://localhost:3000 (내 컴퓨터 개발 전용 — 이 파일은 .gitignore 에 들어 있음)
  3. 미니앱 폴더에서 설치·실행합니다
명령
cd miniapp
npm install
npm run dev

터미널에 나온 http://localhost:5173 을 브라우저로 엽니다. SDK 3.x 문서대로 오른쪽 아래에 devtools 가 보이면 설정이 된 것이고, 이 화면에서 미니앱 동작을 시험할 수 있습니다. 생년월일을 넣고 오늘의 운세 보기를 누르면 결과가 나옵니다.

4-4. 번들(.ait) 만들기

번들에는 실제 서버 주소가 들어가야 하므로, 토스 앱에 올릴 번들은 PART 5 를 마친 뒤 PART 6-2 에서 만듭니다. 지금은 빌드가 되는지만 봅니다.

명령 (miniapp 폴더)
VITE_API_BASE=https://내앱.ridm.ai npm run build

check-bundle: dist has no HRUM key or direct HRUM call 줄과 「앱인토스 빌드가 완료되었습니다. (<appName>.ait)」가 보이면 됩니다. 폴더에 <appName>.ait 파일이 생깁니다.

이 책을 쓸 때 실제로 돌린 것 (2026-10-28): 책 코드 그대로 npm install(SDK 3.8.0·devtools 3.8.0·Vite 7.3.7) → npm run build → Vite 빌드, check-bundle 통과, my-today.ait 생성까지 확인했습니다. 일부러 dist 에 api.hrum.ai 를 부르는 줄을 넣었을 때 check-bundle 이 BLOCKED 로 멈추는 것도 확인했습니다. npm run dev 화면을 가짜 HRUM·서버와 함께 띄워 휴대폰 폭 브라우저에서 열고, 입력 → 결과 화면, 새로고침 뒤 저장된 생년월일 채움(devtools 의 Storage), 오른쪽 아래 AIT 버튼까지 눈으로 확인했습니다. 토스 앱 안에서 이 번들을 열어 보는 것(PART 6)은 앱인토스 콘솔 계정이 필요해 이 책에서 돌리지 않았습니다.

출처

앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.

PART 5 — 서버를 RIDM Apps 에 올리기

미니앱이 부를 서버를 https://<이름>.ridm.ai 로 공개합니다. 미니앱 화면은 여기에 올리지 않습니다 — 화면은 PART 6 에서 앱인토스 콘솔에 번들로 올립니다.

5-1. 배포 파일

RIDM Apps 배포 가이드의 조건: 저장소 루트에 Dockerfile 이 있어야 하고, 앱은 포트 3000 에서 HTTP 로 응답하며(PORT=3000·HOSTNAME=0.0.0.0 이 강제로 설정됨), 컨테이너가 시작되고 10초 안에 / 요청에 응답해야 하고, 사용자 1000:1000 으로 실행됩니다. 이 책의 서버는 / 에서 바로 응답하고, 이미지에는 서버 파일 두 개만 담습니다.

[완전 코드] Dockerfile — RIDM Apps 이미지 — 서버만
# RIDM Apps: Dockerfile at the repository root, app answers HTTP on port 3000, runs as user 1000.
# Only the server goes into the image — the mini-app (miniapp/) is uploaded to the Apps-in-Toss console instead.
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --chown=1000:1000 package.json server.mjs ./
USER 1000
EXPOSE 3000
CMD ["node", "server.mjs"]
[완전 코드] .dockerignore — 이미지에서 뺄 것 — miniapp·테스트·.env
node_modules
miniapp
test
.git
.env*
*.log
[완전 코드] .gitignore — 저장소에 올리지 않을 것 — node_modules·dist·.ait·.env
node_modules/
dist/
*.ait
.env
.env.local
*.log
*.orig

5-2. GitHub 에 올리기

  1. GitHub 에서 Private 저장소를 만듭니다
  2. 프로젝트 폴더(서버 파일 + miniapp/)를 커밋해 올립니다. .env.local·node_modules·.ait 는 .gitignore 때문에 올라가지 않습니다. 올리기 전에 git status 로 키가 든 파일이 없는지 봅니다
  3. RIDM Apps 는 저장소에 비밀값(키·토큰·비밀번호)이 들어 있으면 빌드를 실패시킵니다

5-3. 앱 추가 · 저장소 연결

콘솔 → 내 앱에서 앱 추가를 누릅니다. 약관 → 플랜 → 이름(PART 0 결정서의 RIDM 앱 이름) → 깃허브 → 검토 순서입니다. 깃허브 단계에서 GitHub 앱 설치 화면이 새 창으로 열립니다(아무 창도 안 뜨면 팝업 차단을 풉니다). 설치가 끝나면 저장소 연결 창에서 저장소와 브랜치를 고르고 연결을 누릅니다. 빌드가 성공하면 자동 검사를 거쳐 <이름>.ridm.ai 가 공개됩니다.

5-4. 환경변수 넣기 — 사람이 직접

콘솔 → 내 앱 → 앱 카드 → 환경변수에서 세 개를 넣습니다. 저장하면 앱이 새 값으로 다시 시작됩니다. 저장한 값은 다시 볼 수 없고 이름만 보입니다.

이름값
HRUM_API_KEYPART 2-2 에서 보관해 둔 HRUM API 키 — 여러분이 콘솔 칸에 직접 붙여넣습니다. AI 에게 키를 보내지 않습니다
TOSS_APP_NAME앱인토스 appName (예: my-today)
TRUST_PROXY1 — RIDM Apps 게이트웨이가 X-Real-IP 를 덮어써 넘기므로 그 값으로 방문자를 구분(PART 3-2)

EXTRA_ORIGINS·HRUM_BASE_URL 은 운영에서 넣지 않습니다. RIDM Apps 의 「AI 연결」(앱 전용 키를 플랫폼이 넣어 주는 기능)은 배포 가이드에 준비 중으로 적혀 있어, 이 책은 직접 넣는 방법을 씁니다. 「AI 연결」이 열린 뒤 켜려면 직접 넣은 HRUM_API_KEY 를 먼저 지워야 합니다.

5-5. 배포 확인

① 앱 깨우기 — 무료 플랜 앱은 15분 동안 요청이 없으면 멈추고, 멈춘 뒤 첫 방문은 몇 초 늦습니다. curl·모니터링·HEAD 요청은 앱을 깨우지 않아 「대기 중」 안내를 볼 수 있습니다. 먼저 브라우저로 https://<이름>.ridm.ai/health 를 한 번 엽니다.

② 설정 확인 — 브라우저 화면이나 아래 명령의 결과가 {"ok":true,"keySet":true,"appNameSet":true} 이면 키와 appName 이 들어간 것입니다(키 값은 나오지 않습니다).

명령
curl -s https://내앱.ridm.ai/health

③ 실제 호출 한 번 — 콘솔 QR 테스트 Origin 을 붙여 진짜 HRUM 까지 한 번 부릅니다. 이 요청은 HRUM 호출 1건으로 크레딧이 차감됩니다.

명령 (주소·appName 은 내 값)
curl -s -X POST https://내앱.ridm.ai/api/today \
  -H "Content-Type: application/json" \
  -H "Origin: https://<appName>.private-apps.tossmini.com" \
  -d '{"year":1990,"month":5,"day":15,"hour":12,"minute":0,"gender":"male"}'

이 책을 쓸 때 같은 명령(예시 생년월일 1990-05-15 12:00, 남성)을 실제 HRUM 에 붙인 서버로 보내 받은 응답입니다(2026-10-28, 키는 서버 환경변수에만 있었고 응답에는 들어 있지 않습니다). 날짜가 바뀌면 값도 바뀝니다.

실제 응답 (2026-10-28)
{
  "date": "2026-10-28",
  "dayGanji": "을해",
  "dailyFlow": "오늘(을해일) 목 에너지가 흘러 성장·시작에 유리해.",
  "relation": "만남의 날",
  "color": {
    "name": "아이보리",
    "hex": "#FFF4D9"
  },
  "food": "흰살생선 구이",
  "keyword": "투명",
  "message": "만남이 선택과 정리로 이어지는 날이에요. 좋은 인연과 아닌 인연, 필요한 일과 덜어낼 일을 구분하기 좋아요.",
  "luckyHour": "오후 5시 ~ 오후 7시",
  "avoidHour": "오전 3시 ~ 오전 5시",
  "activity": "결산·회계 정리"
}

"relation"·"message" 가 든 JSON 이 오면 서버 준비는 끝입니다. 콘솔 → 사용량에 today 호출이 한 건 보입니다. 같은 명령을 한 번 더 보내면 "cached":true 가 붙고 사용량은 늘지 않습니다. 503 이 오면 부록 D 를 봅니다.

항상 켜 두려면: 무료 플랜은 잠들기 때문에 첫 사용자가 몇 초 기다릴 수 있습니다. 미니앱은 15초까지 기다리고 안내를 띄우게 만들어 두었습니다. 늘 깨어 있어야 하면 앱 카드에서 유료 플랜으로 바꿉니다 — 플랜별 금액은 요금표에서 봅니다.

출처

앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.

PART 6 — 토스 앱에서 테스트하기

6-1. 테스트 환경 정리

6-2. 실제 서버 주소로 번들 만들기

  1. miniapp/.env.production 파일에 한 줄: VITE_API_BASE=https://<이름>.ridm.ai (PART 5 주소. 비밀이 아니라 커밋해도 됩니다)
  2. miniapp 폴더에서 npm run build → check-bundle 통과 줄과 <appName>.ait 생성을 확인합니다
  3. 번들은 압축 해제 기준 100MB 이하만 올릴 수 있습니다. 이 앱의 번들은 아주 작습니다

6-3. 번들 올리기

콘솔에서 — 앱인토스 콘솔 → 내 앱 → 앱 출시 메뉴 → 등록하기 → 앱 번들(.ait)을 올립니다. 업로드가 안 되면 npm run build 로 만든 번들인지, appName 이 콘솔과 같은지 봅니다.

명령줄에서(선택) — 앱인토스 「미니앱 테스트하기」 문서의 CI/CD 명령입니다. 앱인토스 콘솔 → 워크스페이스 → 왼쪽 메뉴 키에서 API 키를 발급한 뒤:

명령 (miniapp 폴더)
npx ait token add
npx ait deploy -m "첫 테스트"

이 키는 앱인토스 키이고 HRUM 키와 다릅니다. token add 가 물어볼 때 여러분이 직접 넣고, 저장소·채팅에 쓰지 않습니다. 정상적으로 올라가면 테스트용 앱 스킴이 나옵니다.

6-4. QR 로 토스 앱에서 열기

번들을 올린 뒤 오른쪽의 토스앱 테스트 버튼을 누르면 QR 이 나옵니다. 휴대폰으로 찍으면 토스 앱에서 미니앱이 열립니다. 조건은 셋입니다: 토스 앱에 로그인돼 있을 것, 워크스페이스 멤버일 것, 만 19세 이상일 것.

6-5. 휴대폰에서 확인할 것

직접 확인 필요: 이 책을 쓸 때는 실제 휴대폰 토스 앱에서 QR 로 여는 테스트를 아직 하지 않았습니다(앱인토스 콘솔 계정이 필요한 단계). 아래 표는 문서와 책 코드로 정한 기대 결과이니, 여러분의 휴대폰에서 하나씩 확인합니다.
확인기대
첫 화면바로 열리고, 아무 창도 저절로 뜨지 않음
입력 → 오늘의 운세 보기결과 화면(서버가 잠들어 있었다면 몇 초 걸릴 수 있음)
미니앱을 닫았다 다시 열기생년월일이 채워져 있음(Storage)
뒤로가기·닫기내비게이션 바로 정상 동작, 첫 화면에서 뒤로가기면 종료. 안드로이드 시스템 백버튼도 동작
비행기 모드에서 버튼「연결이 원활하지 않아요」 안내, 앱이 멈추지 않음
버튼 연타요청 하나만 감(콘솔 → 사용량에서 늘어난 건수 확인)

6-6. 웹 디버그 툴즈

로컬에서 되던 것이 토스 앱에서만 안 되면, QR 로 연 미니앱 오른쪽 위 내비게이션 바의 더보기 → 웹 디버그 툴즈를 엽니다. Network 탭에서 /api/today 요청과 응답(상태 코드)을, Log 탭에서 console.log 를 볼 수 있습니다. 로그를 더 보고 싶으면 코드에 남긴 뒤 번들을 다시 만들어 올립니다 — 단, 생년월일 같은 개인 정보는 로그에 남기지 않습니다.

출처

앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.

PART 7 — 검수와 출시

7-1. 앱 정보 채우기

앱인토스 콘솔 → 앱 출시 → 등록하기에서 나머지 앱 정보를 넣고 검토를 요청합니다. 항목은 임시 저장할 수 있습니다.

7-2. 비게임 출시 가이드 점검표

체크리스트를 지키지 않았거나 다크패턴이 확인되면 검수가 승인되지 않습니다. 아래는 이 앱에 해당하는 항목만 옮긴 것입니다 — 원문 전체를 한 번 읽습니다.

원문 항목(요약)이 앱에서 확인하는 방법
미니앱이 정상적으로 열리고, 「앱 내 기능」으로 소개한 기능을 미니앱에서 똑같이 쓸 수 있음QR 로 열어 입력 → 결과(6-5)
등록한 스킴이 정상 연결, 스킴으로 들어온 뒤 뒤로가기 정상주요 기능 주소로 열어 뒤로가기
앱인토스 내비게이션 바 사용, 자체 뒤로가기와 동시에 보이지 않음, 파트너 자체 로고 추가 불가책 코드에 자체 뒤로가기·로고 없음
앱 브릿지(SDK API)를 과도하게·반복해서 부르지 않음, 실패 시 재시도 횟수·간격 제어Storage 는 열 때 1번·누를 때 1번, 자동 재시도 없음
외부 코드 실행(eval 등) 불가, 토스 도메인 밖 콘텐츠 렌더링 불가, SSR 불가(CSR·SSG 만)정적 번들(CSR), 외부 화면을 불러오지 않음
API 통신은 HTTPS 만서버 주소가 https://…ridm.ai
안드로이드 시스템 백버튼으로 뒤로가기·종료, 제스처 확대·축소 비활성6-5, viewport 설정(4-2)
라이트 모드, 인터랙션 반응이 2초 이상 지연되지 않음버튼을 누르면 「불러오는 중」이 바로 보임
비속어·은어·과도한 유행어 없음문구를 바꿨다면 다시 읽기
진입하자마자 바텀시트가 자동으로 뜨지 않음, 모든 화면에서 나가는 방법이 명확, CTA 결과를 예측할 수 있음버튼 문구가 「오늘의 운세 보기」
자사 서비스 이동·자사 앱 설치 유도 없음외부 링크 없음
사용자 식별자 값을 확인해 저장하고, 다시 접속해도 필요한 데이터가 유지됨(사용자 식별키 발급)이 앱은 로그인·사용자 식별키를 쓰지 않고 생년월일만 기기 Storage 에 둡니다. 이 구조가 검수에서 문제 된 사례는 확인되지 않았습니다(문서·사례 모두) — 검수 의견이 오면 그에 따릅니다

7-3. 검수 요청 → 출시하기

  1. 토스 앱 테스트를 한 번 이상 마친 뒤 검토를 요청합니다
  2. 검수 결과는 콘솔과 이메일로 옵니다. 검토 기간은 문서마다 다르게 적혀 있습니다 — 「미니앱 등록하기」는 영업일 기준 3~7일, 「자주 묻는 질문」은 영업일 기준 3~5일(주말·공휴일에는 검수하지 않음)입니다. 검토 요청은 번들 하나만 걸 수 있어, 검토 중에 오류를 찾았다면 요청을 취소하고 다시 올린 뒤 요청합니다. 반려되면 사유대로 고친 뒤 출시 관리의 「재검수 요청」을 누릅니다. 나중에 인앱 결제를 붙인다면 정산 정보 검토가 따로 있고, 문서 기준 영업일 평균 2~3일입니다(인앱 결제 가이드)
  3. 승인되면 콘솔의 출시하기 버튼을 눌러야 사용자에게 공개됩니다. 출시하면 1시간 뒤 토스 미니앱 리스트에 반영됩니다

7-4. 출시 뒤

7-5. 밖에서 홍보할 때

먼저 알아 두세요: HRUM(흐름)과 RIDM Apps 는 플로트 주식회사가 운영하는 서비스이고, 토스(비바리퍼블리카)와 제휴하지 않은 독립 서비스입니다. 앱인토스 콘솔 가입·앱 등록·검수·출시는 여러분이 앱인토스 정책에 따라 직접 진행하며, 이 책은 검수 통과를 보장하지 않습니다. 앱인토스에 관한 내용은 공식 개발자센터 문서를 요약한 것이고, 문서와 이 책이 다르면 문서가 맞습니다.

출처

앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.

부록 A — 책 코드 받기

A-1. 받는 파일

폴더파일
프로젝트 맨 위(서버)package.json · server.mjs · mock-hrum.mjs · test/server.test.mjs · Dockerfile · .dockerignore · .gitignore · scripts/get-book-code.mjs
miniapp/(미니앱)package.json · apps-in-toss.config.ts · vite.config.ts · index.html · src/main.js · src/style.css · scripts/check-bundle.mjs · .env.example

A-2. 처음 한 번 — 빈 폴더에서

명령 (Node 22 이상)
curl -sO https://console.hrum.ai/tossEbook/get-book-code.mjs && node get-book-code.mjs && rm get-book-code.mjs

「[완전 코드] N개 — 새로 만듦 N」이 나오면 끝입니다. 그다음부터는 node scripts/get-book-code.mjs 가 없는 파일을 만들고 책과 다른 파일을 알려 줍니다(종료 코드 0 = 책과 같음). --force 는 다른 파일을 책 원문으로 되돌리고 원래 내용은 <파일>.orig 로 남깁니다. 고쳐도 되는 파일(PART 0 지침 2)은 덮어쓰지 않습니다.

A-3. 스크립트 전문

[완전 코드] scripts/get-book-code.mjs — 책 코드 받기·비교 스크립트
// scripts/get-book-code.mjs — writes every "[완전 코드]" file of the book into this project, exactly as printed.
// First run (empty folder):  curl -sO https://console.hrum.ai/tossEbook/get-book-code.mjs && node get-book-code.mjs && rm get-book-code.mjs
// Later:  node scripts/get-book-code.mjs          → adds missing files, lists files that differ from the book
//         node scripts/get-book-code.mjs --force  → puts the book's text back into differing files (the old text stays in <file>.orig)
// Files the book lets you design (YOURS below) are created when missing but never overwritten.
// package.json files: only "name" is yours — --force keeps it and puts the book's versions and scripts back.
// This script updates itself first: an old copy must not decide what a newer book protects.
import { readFileSync, writeFileSync, existsSync, mkdirSync, realpathSync, copyFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const BOOK_URL = 'https://console.hrum.ai/tossEbook.html';
// Files the reader may reshape: never overwritten, never compared
const YOURS = new Set([
  'miniapp/apps-in-toss.config.ts', 'miniapp/index.html', 'miniapp/src/style.css', 'miniapp/.env.example',
]);
const SELF = 'scripts/get-book-code.mjs';

// The project is found from where this file sits, not from the current folder (running it from inside scripts/ is fine).
// realpath + lower case: a symlinked folder or macOS's case-insensitive disk must not make the scripts/ copy look like a stray one
const here = realpathSync(fileURLToPath(import.meta.url));
const isSelf = existsSync(join(here, '../..', SELF)) && realpathSync(join(here, '../..', SELF)).toLowerCase() === here.toLowerCase();
const ROOT = isSelf ? dirname(dirname(here)) : dirname(here);
const at = (path) => join(ROOT, path);
if (!isSelf && existsSync(at(SELF))) {
  // The first-run copy in the project root goes stale; only the scripts/ copy keeps itself up to date
  console.error(`${SELF} 로 실행하세요 (루트 사본은 지우세요: git rm --cached --ignore-unmatch get-book-code.mjs && rm get-book-code.mjs)`);
  process.exit(2);
}

const unescape = (s) =>
  s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
    .replace(/&#x27;/g, "'").replace(/&#39;/g, "'").replace(/&amp;/g, '&');
// The book's package.json with your "name" in it (a different dependency must show up as a difference)
const withYourName = (path, text, file) => {
  if (!(path === 'package.json' || path.endsWith('/package.json')) || !existsSync(file)) return text;
  try {
    const book = JSON.parse(text);
    return JSON.stringify({ ...book, name: JSON.parse(readFileSync(file, 'utf8')).name ?? book.name }, null, 2) + '\n';
  } catch { return text; }
};

const args = process.argv.slice(2);
const force = args.includes('--force');
const src = args.find((a) => !a.startsWith('--'));
const html = src ? readFileSync(src, 'utf8') : await (await fetch(BOOK_URL)).text();
const blocks = [...html.matchAll(/\[완전 코드\] ([^\s<]+)[^<]*<\/div><pre>\n?([\s\S]*?)<\/pre>/g)];
if (blocks.length === 0) {
  console.error('책에서 [완전 코드] 블록을 찾지 못했어요 — 주소나 파일을 확인하세요');
  process.exit(1);
}

const selfBlock = blocks.find(([, path]) => path === SELF);
if (selfBlock && isSelf) {
  const text = unescape(selfBlock[2]).trimEnd() + '\n';
  if (readFileSync(at(SELF), 'utf8').trim() !== text.trim()) {
    writeFileSync(at(SELF), text);
    console.log('이 스크립트가 책의 최신판으로 바뀌었어요 — 같은 명령을 한 번 더 실행하세요');
    process.exit(3);
  }
}

const created = [], differs = [], restored = [];
for (const [, path, body] of blocks) {
  if (path.startsWith('/') || path.split('/').includes('..')) continue; // only paths inside this project
  const file = at(path);
  const text = withYourName(path, unescape(body).trimEnd() + '\n', file);
  if (!existsSync(file)) {
    mkdirSync(dirname(file), { recursive: true });
    writeFileSync(file, text);
    created.push(path);
    continue;
  }
  if (readFileSync(file, 'utf8').trim() === text.trim() || YOURS.has(path)) continue;
  if (!force) { differs.push(path); continue; }
  let orig = `${file}.orig`; // your version is kept (*.orig is in .gitignore); an older .orig is never overwritten
  for (let i = 1; existsSync(orig); i++) orig = `${file}.${i}.orig`;
  copyFileSync(file, orig);
  writeFileSync(file, text);
  restored.push([path, path + orig.slice(file.length)]);
}
console.log(`[완전 코드] ${blocks.length}개 — 새로 만듦 ${created.length} · 책 원문으로 되돌림 ${restored.length} · 책과 다름 ${differs.length}`);
// Quoted: paths are printed in quotes so they can be pasted into any shell
for (const [p, orig] of restored) console.log(`  되돌림  "${p}"   (전 내용: "${orig}")`);
// Named only when updating an existing project (a first run creates everything): these must be committed
if (created.length < blocks.length) for (const p of created) console.log(`  새로 만듦  "${p}"   (커밋에 넣으세요)`);
if (restored.some(([p]) => p.endsWith('package.json'))) console.log('  package.json 은 name 줄만 그대로 두고 나머지가 책판으로 바뀌었어요 — 그 폴더에서 npm install 로 package-lock.json 도 맞추세요');
for (const p of differs) console.log(`  다름  "${p}"   (책 원문으로: node scripts/get-book-code.mjs --force)`);
process.exit(differs.length === 0 ? 0 : 1);

출처

앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.

부록 D — 트러블슈팅

증상원인과 조치
토스 앱에서만 「불러오지 못했어요」, 웹 디버그 툴즈 Network 에 CORS 오류서버의 허용 Origin 에 미니앱 Origin 이 없습니다. TOSS_APP_NAME 이 앱인토스 appName 과 글자 하나까지 같은지 봅니다(QR 은 …private-apps.tossmini.com, 출시 뒤는 …apps.tossmini.com). SDK 를 3.0.x 로 내렸다면 그 버전의 Origin(.web.tossmini.com·.private-web.tossmini.com)을 EXTRA_ORIGINS 에 넣습니다
내 컴퓨터에선 되는데 토스 앱에선 통신이 안 됨라이브 환경은 HTTPS 만 허용합니다. VITE_API_BASE 가 http:// 나 localhost 인 채로 빌드하지 않았는지 봅니다(6-2)
서버가 503 temporarily_unavailable서버 로그(RIDM 앱 로그)의 HRUM answered … 줄을 봅니다. 401 = 키가 틀림·폐기됨 → 콘솔 → API 키에서 새로 발급해 환경변수를 다시 넣기. 402 = 크레딧 부족 → 콘솔 → 빌링. 404 = 계정에 기능이 열려 있지 않음(과금 없음)
서버가 500 server_not_configuredHRUM_API_KEY 환경변수가 없습니다(5-4). /health 의 keySet 이 true 인지 봅니다
서버가 503 daily_limit그날 HRUM 호출이 DAILY_UPSTREAM_CAP 에 닿았습니다. 사용량이 정상이면 값을 올리고, 갑자기 늘었다면 주소가 퍼졌는지 보고 HRUM 콘솔 요청 제한을 확인합니다
429 가 자주 남방문자별 1분 제한 또는 HRUM 요청 한도입니다. 같은 요청을 짧게 반복하는 코드가 없는지 보고, RIDM Apps 에 TRUST_PROXY=1 이 들어가 있는지 봅니다(없으면 모든 방문자가 한 사람처럼 세집니다)
첫 요청만 느림 · curl 에 「대기 중」 안내무료 플랜 앱이 잠든 상태입니다. 브라우저로 한 번 열면 깨어납니다(5-5)
번들이 콘솔에 안 올라감apps-in-toss.config.ts 의 appName 이 콘솔 등록값과 다르거나, npm run build 로 만든 번들이 아닙니다
npm run build 가 BLOCKED 로 멈춤check-bundle 이 번들에서 HRUM 키·api.hrum.ai 직접 호출을 찾았습니다. 미니앱 코드에서 그 부분을 지우고 내 서버(VITE_API_BASE)만 부르게 합니다. 키가 이미 번들·저장소에 들어간 적이 있다면 HRUM 콘솔에서 그 키를 폐기하고 새로 발급합니다
iOS 에서 흰 화면앱인토스 문서는 Sentry 로 오류 수집, 리소스 용량·메모리 점검을 권합니다. 이 앱은 작지만, 문구·이미지를 늘렸다면 용량을 봅니다
토스 앱에서 미니앱이 안 열림토스 앱을 최신 버전으로 올리고, QR 테스트 조건(로그인·워크스페이스 멤버·만 19세 이상)을 봅니다
npm install 에 EBADENGINE 경고미니앱 빌드 도구가 Node 24 이상을 요구합니다. 경고만 나오고 빌드가 되면 진행해도 되지만, Node 24 로 올리는 것을 권장합니다
RIDM 빌드 실패: secrets found저장소에 키·토큰 같은 비밀값이 커밋됐습니다. 그 값을 지우고, 이미 올라간 키는 폐기·재발급합니다

출처

앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.

© 플로트 주식회사 (FLOAT Co., Ltd.) · console.hrum.ai — HRUM API 와 RIDM Apps 호스팅