빌드

폴더 하나가 앱이다 — 번들에는 코드가 없다

무인매장 점주 앱을 만들었다. 소스는 JSON 네 개, 컴파일 없음. 라우트를 따라 두 번째 화면으로 가고, 텍스트를 고치면 화면이 바뀐다 — 빌드가 돌지 않는다. 다만 화면만 바뀌고 품목은 그대로다. 그 갈라짐이 이 편의 주제다.

작성: makemind · 2026년 8월 20일

앞선 다섯 편은 전부 코드였다. C 펌웨어, Dart 서버, Flutter 클라이언트. 매번 컴파일이 돌았다.

이번 편에는 컴파일이 없다. 무인매장 점주가 보는 앱을 만들 건데, 소스는 JSON 파일 네 개다.

unmanned_store.mbd/
  manifest.json            누구인지
  ui/app.json              라우트
  ui/pages/main.json       화면 하나
  ui/pages/restock.json    화면 둘

그리고 이 글 후반에는, 그 JSON을 고치면 앱이 바뀌는 것까지 실제로 돌려 본다. 빌드는 돌지 않는다.

결과부터

점주가 보는 첫 화면. 오늘 매출과 재고, 그리고 채워 넣어야 할 게 몇 개인지.

Ice cream — Yeonnam branch · Shelf. 재고 열에 LOW 표식이 붙고, 오른쪽 위에 오늘 매출. 실제 렌더 캡처
Ice cream — Yeonnam branch · Shelf. 재고 열에 LOW 표식이 붙고, 오른쪽 위에 오늘 매출. 실제 렌더 캡처

「What needs a visit」를 누르면 같은 폴더의 다른 화면으로 간다.

보충 화면 — 가져올 품목 3. 줄마다 on hand · reorder at 과 함께 `bring N` 이 강조된다
보충 화면 — 가져올 품목 3. 줄마다 on hand · reorder at 과 함께 bring N 이 강조된다

「Order the lot」을 누르면 목록이 비워진다.

주문을 걸면 목록이 비고 "Ordered 3 line(s)" 만 남는다
주문을 걸면 목록이 비고 "Ordered 3 line(s)" 만 남는다

이제 JSON을 고치고 다시 돌린다. 컴파일러는 실행되지 않았다.

JSON 한 줄을 고치고 다시 열었을 뿐인데 매장 이름과 문구가 바뀐다 — 빌드는 없었고 품목은 그대로다
JSON 한 줄을 고치고 다시 열었을 뿐인데 매장 이름과 문구가 바뀐다 — 빌드는 없었고 품목은 그대로다

마지막 화면을 잘 봐 주기 바란다. 라벨은 세탁소가 됐는데 품목은 여전히 아이스크림이다. 반쯤 바뀐 이 화면이 이 편에서 가장 중요한 그림이다. 뒤에서 다시 온다.

번들은 무엇인가

manifest.json 은 이 앱이 누구인지를 말한다. 코드가 아니라 신원이다.

{
  "schemaVersion": "1.0.0",
  "manifest": {
    "id": "com.makemind.sample.unmanned_store",
    "name": "Unmanned Store",
    "type": "application",
    "entryPoint": "ui.app",
    "description": "What the owner of an unmanned store sees: stock, takings and what needs a visit. No code, only declarations.",
    "category": "business",
    "tags": ["retail", "unmanned", "sample"]
  }
}

ui/app.json 은 화면 지도다.

{
  "type": "application",
  "title": "Unmanned Store",
  "initialRoute": "/",
  "routes": {
    "/": "ui://pages/main",
    "/restock": "ui://pages/restock"
  }
}

그리고 페이지 하나하나가 화면이다. 버튼이 무엇을 하는지도 여기 적힌다 — 함수 이름이 아니라 도구 이름으로.

{
  "type": "button",
  "label": "What needs a visit",
  "variant": "elevated",
  "onTap": { "type": "navigation", "action": "push", "route": "/restock" }
},
{
  "type": "button",
  "label": "Refresh",
  "variant": "outlined",
  "onTap": { "type": "tool", "tool": "store.today", "params": {} }
}

로더는 40줄이다

번들을 읽는 코드 전부다. 짧은 이유는 형식이 더 요구하지 않아서다 — 런타임은 이미 화면 정의를 그릴 줄 알고, 번들은 화면 정의에 목차를 붙인 것이다.

factory Bundle.load(String path) {
  final root = Directory(path);
  if (!root.existsSync()) throw ArgumentError('no bundle at $path');
  final manifest = _readJson(File('${root.path}/manifest.json'));
  final app = _readJson(File('${root.path}/ui/app.json'));

  final pages = <String, Map<String, dynamic>>{};
  final pageDir = Directory('${root.path}/ui/pages');
  if (pageDir.existsSync()) {
    for (final f in pageDir.listSync().whereType<File>()) {
      if (!f.path.endsWith('.json')) continue;
      final name = f.uri.pathSegments.last.replaceAll('.json', '');
      pages['ui://pages/$name'] = _readJson(f);
    }
  }
  return Bundle._(root, manifest, app, pages);
}

라우트를 푸는 곳에서 한 가지를 일부러 한다. 없는 페이지를 가리키는 라우트는 던진다.

/// 라우트 뒤의 화면. null 을 돌려주지 않고 던지는 이유: `app.json` 의 라우트가
/// 아무도 쓰지 않은 페이지를 가리키면 그건 깨진 번들이고, 사용자가 탭했을 때
/// 알게 되는 것보다 로드할 때 알게 되는 편이 낫다.
Map<String, dynamic> screenFor(String route) {
  final uri = routes[route];
  if (uri == null) throw ArgumentError('no route "$route" in ${app['title']}');
  final page = _pages[uri];
  if (page == null) throw StateError('route "$route" points at $uri, missing');
  return page;
}

검증 스크립트도 같은 걸 본다. 번들이 실려 나가기 전에 라우트가 전부 풀리는지 확인한다.

# 빌드 단계가 있으면 이 글의 논지가 무너지므로, 빌드할 게 없는지부터 본다
BUILDISH=$(find unmanned_store.mbd -type f ! -name '*.json' | wc -l | tr -d ' ')
[ "$BUILDISH" -eq 0 ] || { echo "bundle contains non-json files"; exit 1; }

화면을 옮기면 상태를 다시 채워야 한다

처음 돌렸을 때 두 번째 화면이 비어 있었다. 로그에는 분명히 low=3 이라고 찍혀 있는데 캡처는 "Nothing is low. No trip needed today." 였다.

원인은 형식 안에 있었다. 페이지마다 자기 initialState 를 들고 있다.

"initialState": { "low": [], "lowCount": 0, "notice": "" }

라우트를 옮기면 새 화면이 자기 초기값으로 시작한다. 앞 화면이 받아 둔 데이터가 따라오지 않는다. 실제 앱이라면 페이지의 생명주기가 진입 시 채우고, 여기서는 호스트가 명시적으로 채운다.

await go('/restock');
// 페이지는 자기 initialState 를 선언하므로, 도착하면 누군가 채우기 전까지
// 빈 화면이다. 출시된 앱에서는 페이지 생명주기가 ready 에 이 일을 하고,
// 여기서는 호스트가 명시적으로 한다. 이걸 빼먹으면 세 개가 부족하다는
// 로그 옆에 빈 목록 스크린샷이 남는다.
await callTool('store.today');
await shoot('02_restock');

검증에도 넣었다. 로그와 그림이 어긋나면 실패한다.

# restock 화면은 찍히기 전에 채워져 있어야 한다 — 아니면 로그는 셋이
# 부족하다는데 캡처는 빈 목록인 상태가 남는다
grep -A1 'route "/restock"' captures/run.log | grep -q 'low=3' \
  || { echo "the restock screen was captured without its data"; exit 1; }

이 콘텐츠는 개발자 이상이 필요합니다

로그인 후 플랜을 업그레이드하면 계속 읽을 수 있습니다.

플랜 보기
Twitter