STEP 05 OF 06

MindLog 직접 만들고 배포하기

설계부터 백엔드, 모바일 UI, 실제 기기 실행과 APK 배포까지 직접 진행합니다.

75분 실습5 / 6 단계
워크숍 진행 경로 현재 5 / 6 단계 · 83%
  1. 01개요
  2. 02Part 1
  3. 03Part 2
  4. 04Part 3
  5. 05Part 4
  6. 06부록

4.0 전체 로드맵

단계작업시간
4.1사전 준비 체크리스트 (환경 확인)5분
4.2앱 설계 — 화면, 데이터 모델, API 규약10분
4.3Claude Code로 프로젝트 뼈대 구성5분
4.4Cloudflare 백엔드 구축 (Worker + D1 + OpenRouter)15분
4.5모바일 앱 UI 제작 (채팅, 음성, 일일 로그)20분
4.6Expo Go로 진짜 휴대폰에서 테스트5분
4.7배포: 백엔드는 Cloudflare에, 앱은 APK 빌드 후 휴대폰에 직접 설치10분
4.8마무리 & 확장 아이디어5분
아키텍처 (화이트보드에 그려보세요):
┌─────────────┐ HTTPS ┌─────────────────────┐ HTTPS ┌──────────────────────────┐ │ MindLog │ ─────────► │ Cloudflare Worker │ ─────────►│ OpenRouter API │ │ (Expo 앱) │ │ (우리 백엔드) │ │ nvidia/nemotron-3.5- │ └─────────────┘ └──────────┬──────────┘ │ lightning:free │ │ └──────────────────────────┘ ┌──────────▼──────────┐ ┌──────────────────┐ │ Cloudflare D1 │ │ Open-Meteo API │ │ (SQL 데이터베이스) │ │ (날씨) │ └─────────────────────┘ └──────────────────┘

텍스트 답변은 nemotron-3.5-lightning:free, 음성 받아쓰기는 오디오 입력을 지원하는 별도 무료 모델을 사용합니다.

4.1 사전 준비 체크리스트 (워크숍 전에 공유)

모두가 준비해야 할 것:

중요 — Node 23은 사용하지 마세요. Node 23은 지원이 종료된 홀수 버전이며 현재 Expo·Vitest 의존성과 호환되지 않습니다.

node -vnpm -v로 버전을 확인하고, 만약 Node 23을 사용 중이라면 Node 24로 바꾸세요.

node -v
npm -v
# 권장 예시: v24.x / npm 11.x

nvm을 사용한다면 운영체제에 맞는 명령으로 Node 24를 설치하고 기본 버전으로 지정합니다:

# macOS / Linux (nvm)
nvm install 24
nvm use 24
nvm alias default 24
node -v
npm -v
# Windows PowerShell (nvm-windows)
nvm install 24
nvm use 24
node -v
npm -v

nvm이 없다면 macOS/Linux는 nvm, Windows는 nvm-windows를 먼저 설치하거나 Node.js 공식 사이트에서 Node 24 LTS 설치 파일을 사용하세요.

📧
강사 참고: 이 목록을 워크숍 최소 2일 전에 이메일로 보내세요. 늦게 온 참가자는 10분 휴식 시간을 활용해 따라잡게 합니다.

4.2 1단계 — 코드보다 설계 먼저 (10분)

교훈: 프로는 코드를 치기 전에 설계합니다. 10분의 설계가 1시간의 재작업을 막아줍니다.

화면 (3개만 — 최소한으로)

  1. 채팅/기록 화면 (홈) — 채팅 인터페이스. 각 메시지가 기록(텍스트 또는 음성)이며, AI의 답변이 그 아래에 표시됩니다.
  2. 일일 로그 화면 — 달력 날짜별 보기: 오늘의 모든 기록이 시간 순서대로 나열되고, 상단에 메타데이터(날짜, 요일, 날씨, 위치, 기록 수)가 표시됩니다.
  3. 히스토리 화면 — 지난 날들의 목록. 탭하면 그날의 로그를 열 수 있습니다.

데이터 모델 (워크숍 전체를 위해 테이블 하나면 충분)

CREATE TABLE entries (
  id          TEXT PRIMARY KEY,        -- uuid
  user_id     TEXT NOT NULL,           -- 워크숍용 간단한 기기 ID
  day         TEXT NOT NULL,           -- '2026-08-24' (현지 날짜)
  created_at  TEXT NOT NULL,           -- ISO 타임스탬프
  type        TEXT NOT NULL,           -- 'text' | 'voice'
  content     TEXT NOT NULL,           -- 기록된 생각 (음성은 받아쓰기된 텍스트)
  ai_reply    TEXT,                    -- LLM의 답변
  weather     TEXT,                    -- '{"temp_c": 24, "condition": "Clear"}'
  city        TEXT,
  lat         REAL,                    -- 위도
  lon         REAL                     -- 경도
);
CREATE INDEX idx_entries_day ON entries(user_id, day);

API 규약 (앱과 백엔드의 약속)

엔드포인트메서드요청 → 응답
/logPOST{ userId, type, content?, audioBase64?, audioFormat?, city?, lat?, lon? }{ entry, aiReply }
/log/todayGET?userId=&day?={ day, weather, city, lat, lon, entries: [...] }
/log/historyGET?userId={ days: [{ day, count }] }

활동 (2분): 참가자가 종이에 채팅 화면을 스케치합니다. 2~3개를 공유하세요. 못생긴 스케치가 제일 좋습니다.

4.3 2단계 — Claude Code로 프로젝트 뼈대 잡기 (5분)

두 개의 프로젝트를 담은 폴더를 만듭니다 — app(Expo)와 server(Cloudflare Worker):

mkdir mindlog && cd mindlog

# 1. 모바일 앱 — 현재 Expo Go와 맞는 SDK 54 고정
npx create-expo-app@latest app --template blank-typescript@sdk-54

# 2. 백엔드 — create-cloudflare 생성기 대신 검증된 최소 구성
mkdir server && cd server
npm init -y
npm install --save-dev wrangler@latest typescript
mkdir src
cd ..

# 3. git 초기화 (Claude Code가 git을 가장 잘 활용합니다)
git init

이제 이 폴더에서 Claude Code를 실행하고 마스터 프롬프트를 입력합니다:

claude
마스터 프롬프트 (Claude Code에 복사붙여넣기):
We are building "MindLog", a mobile logging app for a beginner workshop.

Structure: ./app is an Expo (React Native, TypeScript) app. ./server is a
Cloudflare Worker (TypeScript).

The app lets a user log thoughts/ideas/facts by text or voice in a chat UI.
Each entry gets an AI reply. Entries are grouped per day and stored with
metadata (date, time, weather, city, lat, lon).

Backend endpoints: POST /log, GET /log/today, GET /log/history, GET /health.
The Worker calls OpenRouter model nvidia/nemotron-3.5-lightning:free for text
replies. For audio input it uses the separate audio-capable model
nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free. It calls Open-Meteo only
for weather; the Expo app obtains the city with expo-location reverseGeocodeAsync
and sends city to the Worker. Entries are stored in D1 with server/schema.sql.

Add MOCK_AI support: when server/.dev.vars has MOCK_AI="true", return a
deterministic fake transcript and reply so the complete local D1/API flow can
be tested without an API key or model availability.

First, only create the plan as TODO.md with small, ordered tasks. Do not
write code yet.
막힐 때 쓰는 검증된 백엔드: Claude가 만든 파일에서 오류가 계속되면 MindLog 백엔드 스타터 ZIP 다운로드 후 그 안의 server 폴더를 기준으로 비교하세요. 이 스타터는 D1·CORS·텍스트·음성 mock·오늘 조회·히스토리를 로컬 통합 테스트했습니다.
교훈: AI에게 항상 먼저 계획을 요청하세요. 계획을 검토하고 수정한 뒤, "1번 작업을 구현해줘"라고 말하세요. 초보자도 이렇게 하면 주도권을 잡을 수 있습니다.

체크포인트: mindlog/ 폴더에 app/, server/, 그리고 여러분이 읽고 승인한 TODO.md가 있어야 합니다.

4.4 3단계 — 백엔드 만들기 (15분)

3a. 로컬 D1 설정 및 스키마 적용

1) 아래 schema 내용을 확인합니다.

CREATE TABLE IF NOT EXISTS entries (
  id          TEXT PRIMARY KEY,
  user_id     TEXT NOT NULL,
  day         TEXT NOT NULL,
  created_at  TEXT NOT NULL,
  type        TEXT NOT NULL CHECK (type IN ('text', 'voice')),
  content     TEXT NOT NULL,
  ai_reply    TEXT,
  weather     TEXT,
  city        TEXT,
  lat         REAL,
  lon         REAL
);

CREATE INDEX IF NOT EXISTS idx_entries_day
  ON entries(user_id, day);

2) 위 내용을 server/schema.sql 파일로 저장합니다.

3) server/wrangler.jsonc에 다음 설정을 저장합니다. 로컬 테스트에서는 원격 D1을 만들 필요가 없습니다.

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "mindlog-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-08-25",
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "mindlog-db",
      "database_id": "00000000-0000-0000-0000-000000000000",
      "preview_database_id": "mindlog-db-local"
    }
  ],
  "vars": {
    "OPENROUTER_MODEL": "nvidia/nemotron-3.5-lightning:free",
    "OPENROUTER_AUDIO_MODEL": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free"
  }
}

그다음 --local을 명시해 로컬 데이터베이스에만 스키마를 적용합니다:

cd server
npx wrangler d1 execute mindlog-db --local --file=./schema.sql

3b. 로컬 비밀 파일 추가 (절대 커밋하지 마세요!)

server/.dev.vars 파일을 만들고 먼저 mock 모드로 시작합니다:

OPENROUTER_API_KEY="replace-with-your-openrouter-key"
MOCK_AI="true"

server/.gitignore.dev.vars*, .env*, .wrangler/를 추가하세요. wrangler secret put은 배포된 Worker용이므로 로컬 실행에는 사용하지 않습니다.

3c. Worker 구현

아래 프롬프트 전체를 한 번에 복사해서 Claude Code에 입력하세요:

Implement task: the Worker. Requirements:

- POST /log: body { userId, type, content?, audioBase64?, audioFormat?, city?, lat?, lon? }
  1. audioBase64가 있으면 OpenRouter chat/completions에 input_audio 형식으로 보내고 OPENROUTER_AUDIO_MODEL로 받아쓰기를 요청합니다. audioFormat 기본값은 m4a입니다. 텍스트 답변 모델과 음성 모델을 혼용하지 않습니다.
  2. Open-Meteo로 날씨를 가져옵니다 (https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..&current=temperature_2m,weather_code). 도시명은 요청 body의 city를 저장합니다. 날씨 실패 시 null로 계속 진행합니다.
  3. 모델에게 기록에 대한 짧고 따뜻하고 도움이 되는 답변(2-3문장)을 요청합니다.
  4. D1에 기록을 INSERT하고 { entry, aiReply }를 반환합니다. 위치(lat, lon)도 함께 저장합니다.
- GET /log/today?userId=&day?= : 지정일 또는 오늘의 기록을 created_at 순서로 반환하고, 날짜 메타데이터(날짜, 요일, 첫 기록의 날씨, 위치)도 함께 줍니다.
- GET /log/history?userId= : 날짜별 기록 개수 목록을 반환합니다.
- GET /health : { ok: true }를 반환합니다.
- Expo 앱이 호출할 수 있도록 permissive CORS 헤더를 추가합니다.
- env.MOCK_AI === "true"이면 OpenRouter를 호출하지 않고 고정된 transcript와 aiReply를 반환합니다.
- JSON 파싱, userId/type/content 누락, OpenRouter 오류에 각각 400/502 JSON 오류를 반환합니다.
- 초보자를 위해 전체 Worker를 src/index.ts 하나에 넣고, 충분히 주석을 답니다.

Worker 내부의 핵심 LLM 호출은 이렇게 생겼습니다 (Claude Code가 전체 파일을 생성할 것입니다):

const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: env.OPENROUTER_MODEL,           // nvidia/nemotron-3.5-lightning:free
    messages: [
      { role: "system", content: "You are MindLog, a warm journaling companion. Reply in 2-3 sentences." },
      { role: "user", content: entryText },
    ],
  }),
});
const data = await res.json();
const aiReply = data.choices[0].message.content;

3d. 타입·로컬 D1·API 순서로 테스트

npx wrangler types
npx tsc --noEmit
npx wrangler dev          # http://localhost:8787 에서 실행

새 터미널에서:

curl http://localhost:8787/health

curl -X POST http://localhost:8787/log \
  -H "Content-Type: application/json" \
  -d '{"userId":"demo","type":"text","content":"아이디어: 스스로 만드는 워크숍 앱","city":"서울","lat":37.5665,"lon":126.9780}'

curl "http://localhost:8787/log/today?userId=demo"
curl "http://localhost:8787/log/history?userId=demo"

먼저 MOCK_AI="true"에서 JSON 응답과 D1 저장을 확인합니다. 이후 실제 OpenRouter 키를 넣고 MOCK_AI="false"로 바꾼 다음 Wrangler를 재시작해 실제 AI 응답을 한 번 확인하세요.

체크포인트: health 200, POST 200 + aiReply, today에 1개 이상, history에 날짜별 count가 반환되어야 합니다. 로컬 D1에는 반드시 --local, 배포 D1에는 반드시 --remote를 사용합니다.

4.5 4단계 — 모바일 앱 UI 제작 (20분)

app/ 폴더에서 작업합니다. Claude Code에게 한 번에 하나씩 다음 프롬프트를 주고, 매번 테스트하세요:

프롬프트 1 — 채팅 화면:

In ./app, build the home screen: a chat UI for logging.
- An inverted FlatList of bubbles: user entries on the right, AI replies on
  the left, each with a small timestamp.
- A bottom input bar: multiline TextInput, a Send button, and a mic button.
- On send, POST to our Worker (put the base URL in a config constant
  API_URL) with userId, type: "text", content, city, lat, and lon; append both
  the entry and the AI reply to the list. Show the server error message when
  response.ok is false.
- Use AsyncStorage to persist a random userId on first launch.
- Clean, calm design: off-white background, one accent color.

프롬프트 2 — 음성 기록:

Add voice logging to the mic button using expo-audio:
- Press-and-hold (or tap to start/stop) records an m4a file.
- Read the file as base64 with expo-file-system and POST it to /log as
  { userId, type: "voice", audioBase64, audioFormat: "m4a", city, lat, lon }.
  Get lat/lon from expo-location and city from Location.reverseGeocodeAsync.
- Show a red recording indicator and the transcribed text when the reply
  arrives. Handle the microphone permission request politely.

프롬프트 3 — 일일 로그 + 히스토리 화면:

Add a tab navigator (@react-navigation/bottom-tabs) with three tabs:
1. "Today" — the chat screen.
2. "Daily Log" — fetches GET /log/today and renders a header card with
   date, weekday, weather (map WMO weather codes to emoji + label), city,
   lat/lon location, and entry count, then a timeline of entries.
3. "History" — fetches GET /log/history, lists past days, and tapping a
   day shows that day's entries (read-only).

Claude Code가 요청한 패키지를 설치합니다:

cd app
npx expo install expo-audio expo-location expo-file-system \
  @react-navigation/native @react-navigation/bottom-tabs \
  react-native-screens react-native-safe-area-context \
  @react-native-async-storage/async-storage

체크포인트: 시뮬레이터/웹 미리보기에서 기록을 입력하면 AI 답변이 나타납니다.

4.6 5단계 — 진짜 휴대폰에서 실행 (5분)

터미널 1에서 Worker가 휴대폰에서도 보이도록 실행하고, 앱의 API_URL에는 localhost가 아닌 노트북의 LAN IP를 넣습니다:

cd server
npx wrangler dev --ip 0.0.0.0
# 예: API_URL = "http://192.168.0.23:8787"

터미널 2에서 Expo를 시작합니다:

cd app
npx expo start --tunnel
  1. 터미널에 QR 코드가 나타납니다
  2. iPhone: 카메라 앱으로 스캔 · Android: Expo Go 안에서 스캔
  3. 앱이 휴대폰에 로드됩니다. 텍스트 기록과 음성 기록을 남긴 뒤, 일일 로그 탭을 열어보세요.
증상해결책
"Network response timed out"휴대폰과 노트북이 같은 Wi-Fi여야 합니다. --tunnel 유지
휴대폰에서 API 호출 실패휴대폰에는 localhost:8787이 없습니다. 노트북의 LAN IP를 쓰고 OS 방화벽에서 Node/Wrangler 수신을 허용하세요. 교실 Wi-Fi가 기기 간 통신을 막으면 다음 단계에서 Worker를 먼저 배포하세요.
마이크가 반응 없음권한 요청 대화상자를 수락하세요. 웹이 아닌 실제 기기에서 테스트해야 합니다

4.7 6단계 — APK 빌드해서 휴대폰에 직접 설치 (10분)

6a. 백엔드 배포 (2분)

cd server
npx wrangler login
npx wrangler d1 create mindlog-db
# 출력된 database_id를 wrangler.jsonc의 0000... 자리와 교체
npx wrangler d1 execute mindlog-db --remote --file=./schema.sql
npx wrangler secret put OPENROUTER_API_KEY
npx wrangler deploy
# ✓ Published mindlog-api  https://mindlog-api.<당신의-서브도메인>.workers.dev

--remote가 빠지면 로컬 DB만 초기화되어 배포 후 no such table: entries가 납니다. 배포가 끝나면 앱의 API_URL을 이 주소로 업데이트하고 같은 curl POST를 배포 URL에도 한 번 실행하세요.

6b. EAS로 APK 빌드하기 (5분)

cd app
npm install -g eas-cli
eas login
eas build:configure
eas update:configure
eas build --platform android --profile preview   # 설치 가능한 APK 생성

빌드는 클라우드에서 실행됩니다 — Android Studio도, Mac도 필요 없습니다. 빌드가 진행되는 동안(10~20분) 수업을 이어가세요. APK 링크가 이메일이나 EAS 대시보드에 도착하면, 휴대폰에서 해당 링크를 열어 APK를 다운로드하고 직접 설치하세요. Android에서는 "출처를 알 수 없는 앱" 설치를 허용해야 할 수 있습니다.

이 워크숍에서는 스토어 배포까지 하지 않습니다. APK를 직접 빌드해서 휴대폰에 설치하는 것으로 충분합니다. 실제 App Store나 Google Play에 올리는 과정은 이 워크숍의 범위를 벗어나며, Part 3에서 파이프라인 개념만 설명합니다.

6c. 즉시 업데이트 (2분)

eas update --channel preview --message "workshop day 1"

eas update:configure 후 만든 preview APK만 preview 채널의 업데이트를 받습니다. 색상이나 텍스트 한 줄을 바꾸고 게시한 뒤 앱을 완전히 종료·재실행하면 업데이트가 다운로드되고, 한 번 더 재실행할 때 적용됩니다.

6d. 실제 스토어 출시는 이렇게 합니다 (워크숍 이후, 개념 설명용)

이 워크숍에서는 APK 직접 설치까지만 진행하지만, 실제 상용 배포를 원한다면 다음 과정을 거칩니다:

  1. Apple Developer 계정(연 $99) 및/또는 Google Play 계정(일회 $25) 가입
  2. eas build --platform all --profile production으로 프로덕션 바이너리 생성
  3. 간단한 개인정보 처리방침 작성 (Cloudflare Pages에 무료로 호스팅) — 오디오를 녹음하는 앱은 필수입니다
  4. eas submit --platform ios / eas submit --platform android로 스토어에 제출
  5. 스토어 심사 질문에 답변 (데이터 수집, 마이크 사용 목적 등)
  6. 심사 통과 후 공개 출시

4.8 마무리 & 확장 아이디어 (5분)

한 문장으로 정리하기:
  • 모바일 앱 = 설치형, 기기 기능 활용, 스토어 배포; 웹 앱 = 즉시 접속, 보편적, 브라우저 기반
  • 크로스플랫폼 프레임워크(React Native + Expo)로 두 휴대폰 플랫폼을 하나의 코드로 커버
  • 배포는 파이프라인: 빌드 → 제출 → 심사 → 출시, 거기에 OTA 업데이트로 속도를 더함
  • 작은 서버리스 백엔드(Cloudflare Worker + D1)가 비밀을 안전하게 지키고 이 규모에서는 비용이 전혀 들지 않음
  • LLM 게이트웨이(OpenRouter)가 AI 모델을 교체 가능한 설정값으로 만듦 — 한 번의 결정이 아닌 선택

숙제로 해볼 확장 아이디어:

  1. 매일 밤 9시 푸시 알림: "오늘은 어떤 생각을 했나요?" (expo-notifications)
  2. Cloudflare Cron Trigger로 매일 AI가 쓴 일기 요약을 이메일로 보내기
  3. 기록별 감정 감지, 주간 감정 차트
  4. 과거 기록 의미 검색 ("그때 그 아이디어가 언제였더라...?")