내앱.ridm.ai)만 부르고, 서버가 HRUM API 키로 오늘의 사주 API 를 부릅니다. 가입·약관 동의·본인 확인·비밀값 입력·콘솔 버튼은 여러분이 직접 하며, AI 가 대신 가입하거나 검수 결과를 보장하지는 않습니다.https://console.hrum.ai/tossEbook/ 을 열고, 그 지도의 단계 순서대로 해당 페이지만 읽으며 [AI 시작 지침]대로 나와 함께 앱인토스 미니앱 「오늘의 운세」를 만들어줘. 코드는 내가 결정 질문에 답하고 승인한 뒤에만 써.
이 책은 HRUM(흐름) API 하나로 토스 앱 안에서 열리는 미니앱을 만드는 실행형 전자책입니다. 예시 앱은 생년월일을 넣으면 그날의 흐름·행운 색·좋은 시간을 보여 주는 「오늘의 운세」입니다. 미니앱 화면(번들)은 앱인토스 콘솔에 올리고, HRUM API 키를 가진 작은 서버는 RIDM Apps 에 올립니다.
먼저 알아 두세요: HRUM(흐름)과 RIDM Apps 는 플로트 주식회사가 운영하는 서비스이고, 토스(비바리퍼블리카)와 제휴하지 않은 독립 서비스입니다. 앱인토스 콘솔 가입·앱 등록·검수·출시는 여러분이 앱인토스 정책에 따라 직접 진행하며, 이 책은 검수 통과를 보장하지 않습니다. 앱인토스에 관한 내용은 공식 개발자센터 문서를 요약한 것이고, 문서와 이 책이 다르면 문서가 맞습니다.
| 여러분이 직접 | AI 코딩 도구가 |
|---|---|
| 앱인토스 콘솔 가입·워크스페이스·앱 등록(PART 2) HRUM 콘솔 가입·이메일 인증·API 키 발급(PART 2) RIDM Apps 환경변수 칸에 키 넣기(PART 5) 번들 업로드·토스 앱 QR 테스트·검수 요청·출시하기 버튼(PART 6~7) | 결정 질문 정리, 책 코드 받기, 테스트 실행, 번들 만들기, 배포 뒤 확인 명령 실행, 체크리스트 점검, PROGRESS.md 기록 |
가입·약관 동의·본인 확인·비밀값 입력은 사람만 합니다. AI 가 대신 가입하거나 콘솔에 로그인하지 않습니다.
appName(콘솔에 등록할 값과 똑같아야 하고, 등록 뒤에는 바꿀 수 없다 — 추천: 짧은 영문 소문자와 하이픈, 예 my-today) ② 미니앱 이름(국문, 토스 앱에 보이는 이름) ③ RIDM Apps 앱 이름(서버 주소 <이름>.ridm.ai 가 된다) ④ 태어난 시각 입력(추천: 받되 「몰라요」 허용 — 책 코드 그대로).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 으로 읽는다.HRUM_API_KEY 에만 있다. 미니앱 코드·.env·채팅·커밋·로그·PROGRESS.md 에 쓰지 않는다. 미니앱은 api.hrum.ai 를 직접 부르지 않는다 — 미니앱 빌드의 check-bundle 이 그런 번들을 막는다.앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.
토스 앱
└ 미니앱 번들 (<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 시작 가이드도 키는 서버에서만 쓰고, 브라우저나 앱 코드에 넣으면 누구나 꺼내 쓸 수 있다고 안내합니다. 그래서 이 책은 키를 서버에만 두고, 미니앱은 내 서버 주소만 압니다.
앱인토스 「시작하기」 문서에 따르면 앱인토스는 클라이언트 SDK 와 서버 API 두 방식으로 연동합니다. SDK 는 토스 앱 안 미니앱 실행 환경을 만들고, 서버 API 는 로그인·결제처럼 파트너 서버와 앱인토스 서버가 직접 주고받는 기능(mTLS 로 보호)을 맡습니다. SDK 는 WebView SDK 와 React Native SDK 중 하나를 고릅니다.
@apps-in-toss/web-framework)로 만듭니다. 웹 화면(HTML·JS)을 그대로 미니앱으로 묶는 방식입니다| 항목 | 문서 내용 요약 | 이 책의 예시 |
|---|---|---|
| 로그인 | 미니앱 안 로그인은 토스 로그인만 쓸 수 있고, 다른 로그인·소셜 로그인은 쓸 수 없음(서비스 오픈 정책 2-3) | 로그인 없음 |
| 결제 | 실물 상품은 토스페이만, 디지털 상품은 인앱 결제만(2-3) | 결제 없음 |
| 광고 | 앱인토스 전면형·보상형·배너 광고만, 외부 광고 네트워크는 불가(2-3) | 광고 없음 |
| 외부 링크·자사 앱 설치 유도 | 앱 설치 유도 불가, 외부 링크는 허용된 경우에만. 콘솔에 적은 「앱 내 기능」은 미니앱 안에서 완결돼야 함(2-2) | 외부 링크 없음, 결과까지 미니앱 안에서 끝남 |
| 생성형 AI | AI 가 만든 결과를 보여 주면 처음 쓸 때 고지하고 결과에 AI 표시를 해야 함(2-4) | 규칙 계산 값만 씀. HRUM 의 LLM 한 줄 옵션(llm: true)을 켜면 고지·표시를 넣어야 함 |
| 중복 앱 | 같은 워크스페이스에서 핵심 기능이 비슷한 앱을 여러 개 낼 수 없음(2-1) | 앱 하나 |
| 단순 홍보 | 기존 회사·서비스를 단순히 홍보하려고 미니앱을 낼 수 없음(3장) | 운세 결과가 미니앱의 기능 |
| 사업자 등록 | 필수는 아니지만 토스 로그인·인앱 결제·토스페이·프로모션·비즈 월렛에는 필수. 인앱 광고는 등록 없이 시작할 수 있고, 수익이 일정 금액을 넘으면 필요. 부가세 면세 사업자는 등록 불가(사업자 등록하기) | 해당 기능을 쓰지 않음 |
| 이용 연령 | 지금 앱인토스는 만 19세 이상 사용자에게만 제공(미니앱 등록하기) | — |
출시가 안 되는 서비스(금융·투자 정보·의료·사행성·정치 등)와 조건이 붙는 서비스 목록은 「서비스 오픈 정책」 원문에 있습니다. 정책은 바뀔 수 있으니 등록 전에 원문을 다시 봅니다.
앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.
① 가입 — 앱인토스 콘솔에 접속해 회원가입합니다. 토스 비즈니스 회원을 기반으로 하며, 만 19세 이상이고 본인 명의로 로그인된 토스 앱이 있어야 합니다.
② 워크스페이스 — 팀이 함께 앱을 관리하는 공간입니다. 사업자당 1개만 만들 수 있고 이름은 겹칠 수 없습니다. 이어서 제작자 이름을 넣습니다. 미니앱 상세 화면에 「누가 만들었는지」로 보이는 이름이고, 공백을 포함해 10자 이내, 한글·영문·숫자만 쓸 수 있고, 토스 브랜드를 도용하거나 헷갈리게 하는 이름(예: 「토스OO」)은 쓸 수 없습니다. 나중에 워크스페이스 「내 정보」에서 바꿀 수 있습니다.
③ 앱 등록 — 워크스페이스의 앱 메뉴에서 +등록하기를 누릅니다. 개발이 끝나지 않은 앱도 먼저 등록할 수 있습니다.
intoss://<appName> 주소의 ID 입니다. 한 번 등록하면 바꿀 수 없습니다. PART 0 결정서에서 정한 값을 그대로 넣습니다. 이 값은 미니앱 설정 파일과 서버 환경변수 TOSS_APP_NAME 에도 똑같이 들어갑니다테스트용 앱과 실제 배포용 앱을 같은 워크스페이스에 따로 등록해 나눠 관리할 수도 있습니다. 다만 서비스 오픈 정책은 핵심 기능이 비슷한 앱을 여러 개 출시하는 것을 막습니다 — 출시는 하나만 합니다.
사업자 등록: 이 책의 예시(로그인·결제·광고 없음)에는 필수가 아닙니다. 나중에 토스 로그인·인앱 결제·토스페이 등을 붙이려면 워크스페이스 「내 정보」에서 사업자 등록이 필요하고, 사업자 등록증의 업종과 미니앱 서비스 업종이 같아야 합니다. 부가세 면세 사업자는 등록할 수 없습니다.
토스 미니앱 서버)을 적고 발급합니다. 새 키는 발급 직후 한 번만 보입니다. 바로 복사해 비밀번호 관리자 같은 안전한 곳에 둡니다. AI 채팅창·파일·메모에 붙여넣지 않습니다 — PART 5 에서 RIDM Apps 환경변수 칸에 여러분이 직접 넣습니다@apps-in-toss/devtools·@apps-in-toss/ait-format)는 npm 패키지 정보에 Node 24 이상으로 적혀 있습니다 — 새로 설치한다면 Node 24 를 권장합니다. 이 책을 쓸 때는 Node 22.22.2 에서 경고(EBADENGINE)만 나오고 빌드는 끝까지 됐습니다앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.
빈 프로젝트 폴더에서 부록 A 의 curl 한 줄을 실행하면 이 책의 [완전 코드] 파일이 전부 만들어집니다(서버 파일은 폴더 맨 위, 미니앱 파일은 miniapp/ 아래). 그다음부터는 node scripts/get-book-code.mjs 가 책과 다른 파일을 알려 줍니다.
POST https://api.hrum.ai/api/v1/partners/v1/todayX-Partner-Api-Key 헤더에 API 키year·month·day·hour·minute·timezone·gender, 음력이면 calendar: "lunar") + date(볼 날짜, 생략하면 한국 시간 오늘). 출생 시각을 모르면 시각을 빼고 보내고, HRUM 이 정오로 계산해 birthHour 를 붙입니다summary(일진·오늘의 십성·흐름 등)와 omen(관계·행운 색·음식·키워드·좋은 시간·피할 시간·추천 활동·한 줄 메시지)| 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 |
HRUM_API_KEY 환경변수에서만 읽고, 어떤 응답·로그에도 쓰지 않습니다. /health 는 「키가 있는지(true/false)」만 알려 줍니다https://<appName>.apps.tossmini.com, 콘솔 QR 테스트 https://<appName>.private-apps.tossmini.com 입니다. 서버는 TOSS_APP_NAME 으로 이 두 주소를 만들고, 다른 Origin 에서 온 브라우저 요청은 403 으로 막습니다. 이 책의 미니앱은 SDK 3.8.0 을 쓰므로 3.0.x 용 주소(.web.tossmini.com)는 넣지 않습니다DAILY_UPSTREAM_CAP). 방문자 수를 세는 표는 1분마다 비우고, 한 분에 셀 수 있는 방문자 수에도 상한이 있어 메모리가 계속 늘지 않습니다. 진짜 마지막 울타리는 HRUM 콘솔의 요청 제한입니다(2-2)X-Real-IP 같은 헤더는 누구나 요청에 적어 보낼 수 있습니다. 서버가 이 헤더를 그냥 믿으면 요청마다 다른 값을 적어 방문자별 제한을 피할 수 있습니다. 그래서 서버는 기본으로 연결한 상대의 주소(req.socket.remoteAddress)를 쓰고, 앞단 프록시가 이 헤더를 항상 자기가 본 주소로 덮어쓰는 곳에서만 TRUST_PROXY=1 로 헤더를 씁니다. RIDM Apps 게이트웨이는 이 헤더를 덮어써서 넘기므로 PART 5 에서 켭니다(끄면 모든 방문자가 게이트웨이 주소 하나로 묶여 한 사람처럼 세집니다). 앞단 사정으로 주소가 정확하지 않을 수 있어 방문자 구분은 대략적입니다Origin 검사는 브라우저가 지키는 규칙입니다. curl 처럼 Origin 을 보내지 않는 도구는 막지 않으므로, 서버 주소가 알려져도 위의 울타리와 HRUM 요청 제한이 크레딧을 지킵니다.
{
"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 — 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 — 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 — 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);
});① 자동 테스트 — 프로젝트 폴더에서:
npm test
끝에 # pass 13 · # fail 0 이 보이면 됩니다. 이 테스트는 실제 서버 코드를 가짜 HRUM 에 붙여 돌립니다 — 미니앱 주소만 허용되는지, 키가 헤더로 가는지, 같은 날 두 번째 요청이 캐시에서 나오는지, 크레딧 부족·잘못된 키가 화면에 어떻게 보이는지, 가짜 X-Real-IP 로 방문자 제한을 피할 수 없는지를 봅니다.
② 손으로 한 번 — 터미널 세 개를 엽니다.
npm run mock
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
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_PROXY | RIDM 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 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.
앱인토스 「기존 웹 프로젝트에 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 직접 호출이 들어가면 빌드를 멈춥니다.
{
"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"
}
}
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',
});
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()],
});
<!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>
// 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 확인).';
/* 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; }
// 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`);# 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
maximum-scale=1, user-scalable=no 를 넣습니다(지도처럼 꼭 필요한 화면이 아니면 막음)https://<이름>.ridm.aiStorage.setItem·getItem 은 모바일 앱의 로컬 저장소에 문자열을 저장하고, 앱을 종료했다가 다시 실행해도 유지됩니다(값이 없으면 getItem 이 null). 문서에 기기를 바꿨을 때의 설명은 없어서, 이 책은 기기 안에만 있는 값으로 보고 만듭니다 — 사라져도 다시 입력하면 그만입니다. 저장이 실패해도 앱은 계속 동작합니다EXTRA_ORIGINS=http://localhost:5173 포함)miniapp/.env.local 파일을 만들어 한 줄을 넣습니다: VITE_API_BASE=http://localhost:3000 (내 컴퓨터 개발 전용 — 이 파일은 .gitignore 에 들어 있음)cd miniapp npm install npm run dev
터미널에 나온 http://localhost:5173 을 브라우저로 엽니다. SDK 3.x 문서대로 오른쪽 아래에 devtools 가 보이면 설정이 된 것이고, 이 화면에서 미니앱 동작을 시험할 수 있습니다. 생년월일을 넣고 오늘의 운세 보기를 누르면 결과가 나옵니다.
번들에는 실제 서버 주소가 들어가야 하므로, 토스 앱에 올릴 번들은 PART 5 를 마친 뒤 PART 6-2 에서 만듭니다. 지금은 빌드가 되는지만 봅니다.
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 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.
미니앱이 부를 서버를 https://<이름>.ridm.ai 로 공개합니다. 미니앱 화면은 여기에 올리지 않습니다 — 화면은 PART 6 에서 앱인토스 콘솔에 번들로 올립니다.
RIDM Apps 배포 가이드의 조건: 저장소 루트에 Dockerfile 이 있어야 하고, 앱은 포트 3000 에서 HTTP 로 응답하며(PORT=3000·HOSTNAME=0.0.0.0 이 강제로 설정됨), 컨테이너가 시작되고 10초 안에 / 요청에 응답해야 하고, 사용자 1000:1000 으로 실행됩니다. 이 책의 서버는 / 에서 바로 응답하고, 이미지에는 서버 파일 두 개만 담습니다.
# 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"]
node_modules miniapp test .git .env* *.log
node_modules/ dist/ *.ait .env .env.local *.log *.orig
miniapp/)를 커밋해 올립니다. .env.local·node_modules·.ait 는 .gitignore 때문에 올라가지 않습니다. 올리기 전에 git status 로 키가 든 파일이 없는지 봅니다콘솔 → 내 앱에서 앱 추가를 누릅니다. 약관 → 플랜 → 이름(PART 0 결정서의 RIDM 앱 이름) → 깃허브 → 검토 순서입니다. 깃허브 단계에서 GitHub 앱 설치 화면이 새 창으로 열립니다(아무 창도 안 뜨면 팝업 차단을 풉니다). 설치가 끝나면 저장소 연결 창에서 저장소와 브랜치를 고르고 연결을 누릅니다. 빌드가 성공하면 자동 검사를 거쳐 <이름>.ridm.ai 가 공개됩니다.
콘솔 → 내 앱 → 앱 카드 → 환경변수에서 세 개를 넣습니다. 저장하면 앱이 새 값으로 다시 시작됩니다. 저장한 값은 다시 볼 수 없고 이름만 보입니다.
| 이름 | 값 |
|---|---|
HRUM_API_KEY | PART 2-2 에서 보관해 둔 HRUM API 키 — 여러분이 콘솔 칸에 직접 붙여넣습니다. AI 에게 키를 보내지 않습니다 |
TOSS_APP_NAME | 앱인토스 appName (예: my-today) |
TRUST_PROXY | 1 — RIDM Apps 게이트웨이가 X-Real-IP 를 덮어써 넘기므로 그 값으로 방문자를 구분(PART 3-2) |
EXTRA_ORIGINS·HRUM_BASE_URL 은 운영에서 넣지 않습니다. RIDM Apps 의 「AI 연결」(앱 전용 키를 플랫폼이 넣어 주는 기능)은 배포 가이드에 준비 중으로 적혀 있어, 이 책은 직접 넣는 방법을 씁니다. 「AI 연결」이 열린 뒤 켜려면 직접 넣은 HRUM_API_KEY 를 먼저 지워야 합니다.
① 앱 깨우기 — 무료 플랜 앱은 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건으로 크레딧이 차감됩니다.
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, 키는 서버 환경변수에만 있었고 응답에는 들어 있지 않습니다). 날짜가 바뀌면 값도 바뀝니다.
{
"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 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.
miniapp/.env.production 파일에 한 줄: VITE_API_BASE=https://<이름>.ridm.ai (PART 5 주소. 비밀이 아니라 커밋해도 됩니다)miniapp 폴더에서 npm run build → check-bundle 통과 줄과 <appName>.ait 생성을 확인합니다콘솔에서 — 앱인토스 콘솔 → 내 앱 → 앱 출시 메뉴 → 등록하기 → 앱 번들(.ait)을 올립니다. 업로드가 안 되면 npm run build 로 만든 번들인지, appName 이 콘솔과 같은지 봅니다.
명령줄에서(선택) — 앱인토스 「미니앱 테스트하기」 문서의 CI/CD 명령입니다. 앱인토스 콘솔 → 워크스페이스 → 왼쪽 메뉴 키에서 API 키를 발급한 뒤:
npx ait token add npx ait deploy -m "첫 테스트"
이 키는 앱인토스 키이고 HRUM 키와 다릅니다. token add 가 물어볼 때 여러분이 직접 넣고, 저장소·채팅에 쓰지 않습니다. 정상적으로 올라가면 테스트용 앱 스킴이 나옵니다.
번들을 올린 뒤 오른쪽의 토스앱 테스트 버튼을 누르면 QR 이 나옵니다. 휴대폰으로 찍으면 토스 앱에서 미니앱이 열립니다. 조건은 셋입니다: 토스 앱에 로그인돼 있을 것, 워크스페이스 멤버일 것, 만 19세 이상일 것.
intoss://<appName> 로 열리지 않습니다. 업로드마다 새 deploymentId 가 붙은 테스트 스킴 intoss-private://…?_deploymentId=…(QR)로 엽니다https://<appName>.private-apps.tossmini.com 입니다 — 서버의 TOSS_APP_NAME 이 정확해야 요청이 통과합니다직접 확인 필요: 이 책을 쓸 때는 실제 휴대폰 토스 앱에서 QR 로 여는 테스트를 아직 하지 않았습니다(앱인토스 콘솔 계정이 필요한 단계). 아래 표는 문서와 책 코드로 정한 기대 결과이니, 여러분의 휴대폰에서 하나씩 확인합니다.
| 확인 | 기대 |
|---|---|
| 첫 화면 | 바로 열리고, 아무 창도 저절로 뜨지 않음 |
| 입력 → 오늘의 운세 보기 | 결과 화면(서버가 잠들어 있었다면 몇 초 걸릴 수 있음) |
| 미니앱을 닫았다 다시 열기 | 생년월일이 채워져 있음(Storage) |
| 뒤로가기·닫기 | 내비게이션 바로 정상 동작, 첫 화면에서 뒤로가기면 종료. 안드로이드 시스템 백버튼도 동작 |
| 비행기 모드에서 버튼 | 「연결이 원활하지 않아요」 안내, 앱이 멈추지 않음 |
| 버튼 연타 | 요청 하나만 감(콘솔 → 사용량에서 늘어난 건수 확인) |
로컬에서 되던 것이 토스 앱에서만 안 되면, QR 로 연 미니앱 오른쪽 위 내비게이션 바의 더보기 → 웹 디버그 툴즈를 엽니다. Network 탭에서 /api/today 요청과 응답(상태 코드)을, Log 탭에서 console.log 를 볼 수 있습니다. 로그를 더 보고 싶으면 코드에 남긴 뒤 번들을 다시 만들어 올립니다 — 단, 생년월일 같은 개인 정보는 로그에 남기지 않습니다.
앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.
앱인토스 콘솔 → 앱 출시 → 등록하기에서 나머지 앱 정보를 넣고 검토를 요청합니다. 항목은 임시 저장할 수 있습니다.
생년월일로 보는 오늘의 흐름생년월일과 태어난 시각(모르면 생략)을 넣고 「오늘의 운세 보기」를 누르면, 오늘 일진과 내 사주의 관계, 한 줄 메시지, 행운 색·음식·키워드, 좋은 시간과 피할 시간, 추천 활동을 한 화면에서 보여 줘요. 입력한 생년월일은 기기에 저장돼 다음에 열면 바로 다시 볼 수 있어요.intoss://<appName>/… 주소. 선택 항목입니다. 문서에는 「주요 기능 URL에 정상 접속되지 않으면 반려될 수 있어요」까지만 있고, 경로 없는 intoss://<appName> 를 받아 주는지는 확인되지 않았습니다. 넣는다면 경로까지 넣은 주소로, QR 테스트에서 실제로 열리는 것을 확인한 뒤 넣는 것을 권장합니다. 이 책의 예제는 화면이 하나라 비워 두어도 됩니다체크리스트를 지키지 않았거나 다크패턴이 확인되면 검수가 승인되지 않습니다. 아래는 이 앱에 해당하는 항목만 옮긴 것입니다 — 원문 전체를 한 번 읽습니다.
| 원문 항목(요약) | 이 앱에서 확인하는 방법 |
|---|---|
| 미니앱이 정상적으로 열리고, 「앱 내 기능」으로 소개한 기능을 미니앱에서 똑같이 쓸 수 있음 | 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 에 둡니다. 이 구조가 검수에서 문제 된 사례는 확인되지 않았습니다(문서·사례 모두) — 검수 의견이 오면 그에 따릅니다 |
.ait)을 같은 방법으로 올리고 검토 요청 → 승인 → 출시하기. 출시한 버전은 사용자에게 즉시 반영됩니다먼저 알아 두세요: HRUM(흐름)과 RIDM Apps 는 플로트 주식회사가 운영하는 서비스이고, 토스(비바리퍼블리카)와 제휴하지 않은 독립 서비스입니다. 앱인토스 콘솔 가입·앱 등록·검수·출시는 여러분이 앱인토스 정책에 따라 직접 진행하며, 이 책은 검수 통과를 보장하지 않습니다. 앱인토스에 관한 내용은 공식 개발자센터 문서를 요약한 것이고, 문서와 이 책이 다르면 문서가 맞습니다.
앱인토스 내용은 2026-10-28 에 앱인토스 개발자센터 문서를 직접 읽고 요약했고, 2026-10-30 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.
| 폴더 | 파일 |
|---|---|
| 프로젝트 맨 위(서버) | 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 |
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)은 덮어쓰지 않습니다.
// 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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
.replace(/'/g, "'").replace(/'/g, "'").replace(/&/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 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.
| 증상 | 원인과 조치 |
|---|---|
| 토스 앱에서만 「불러오지 못했어요」, 웹 디버그 툴즈 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_configured | HRUM_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 에 문서와 다시 대조했습니다. 정책·화면은 바뀔 수 있으니 시작 전에 원문을 다시 확인하세요.