아키텍처
apps/web, apps/api(Hono on Workers), packages/db, packages/shared, 자체 인증, AI 모델 라우터, CORS/모바일 네트워킹
Neul 은 Vite SPA 프론트엔드 + Cloudflare Workers 백엔드 + Supabase Postgres 로 구성된 v5 아키텍처입니다. 프론트와 백은 타입을 공유하고, 순수 로직은 별도 패키지로 분리되어 있습니다.
apps/web — React + Vite SPA + Capacitor
- Vite 기반 React SPA. Tailwind v4(
@tailwindcss/vite),motion,@microsoft/fetch-event-source(채팅 SSE) 사용. - Capacitor 로 iOS/Android 네이티브 셸을 함께 관리합니다. 네이티브 프로젝트
(
ios/,android/,capacitor.config.ts)도 이 앱 안에 있습니다. - API 의 베이스 URL 은
apps/web/src/lib/api.ts가location.hostname으로 유추합니다. - 토큰 저장은 WebView
localStorage(apps/web/src/lib/auth-storage.ts). 청사진은 Keychain Secure Storage 였으나 cap 8.3.4 호환 플러그인 부재로 localStorage 를 사용합니다. 호환 플러그인 출시 시 이 파일만 교체하면 됩니다.
apps/api — Hono on Cloudflare Workers
- Hono 앱이 Cloudflare Workers(workerd) 에서 동작합니다. dev 는
@cloudflare/vite-plugin, 배포는wrangler입니다. - 새 라우트는 메서드 체이닝으로 추가(
apps/api/src/routes/*)해야 RPC 타입이 추론됩니다.
Hono RPC 타입 공유
웹은 API 의 타입을 직접 import 합니다:
import type { AppType } from "@neul/api/app";tsc 가 web 을 검사할 때 api 소스도 함께 검사하므로, web 의
src/vite-env.d.ts 에는 Workers 전역에 대한 최소 ambient 선언이 들어 있습니다.
라우트 추가 시 주의
체이닝을 끊고(중간 변수에 할당 후 다시 .get(...)) 라우트를 추가하면 RPC 타입
추론이 깨질 수 있습니다. 항상 체이닝으로 이어 붙이세요.
채팅 (SSE + 지속성)
채팅 응답은 클라이언트 abort 와 무관하게 DB 에 persist 됩니다(SSE 스트리밍 +
executionCtx.waitUntil). 모델 호출이 실패해도 fallbackResponseFor 로 항상
응답을 돌려줍니다.
대화 동작: 정형 시나리오(슬럼프/일정/에너지/회고/목표/집중/계획변경/시험)는 카드
시퀀스(streamObject)를 유지하고, 그 외 일반 대화(default)는 자유 서술
(streamText) + 최근 대화 히스토리(최대 12턴) + 최근 7일 데이터 컨텍스트로 동작해
실제 학습 코치처럼 답합니다(generalChatSystemPrompt). 데이터가 부족하면 분석을
강요하지 않고 "기록을 권유", 있으면 "앞으로의 학습 방향"을 제시합니다.
AI 모델 라우터
모델 선택은 apps/api/src/ai/model-router.ts 한 곳에 모여 있습니다.
| 용도 | 모델(기본) | 비고 |
|---|---|---|
| chat | gemini-3-flash-preview | SSE 스트리밍 (env GEMINI_CHAT_MODEL 로 flash-lite 등 오버라이드) |
| background | gemini-3.1-flash-lite | 회고 분석 / 온보딩 |
GA 모델로 교체할 때는 라우터 한 줄을 바꾸거나, 환경변수
GEMINI_CHAT_MODEL / GEMINI_BG_MODEL 로 덮어씁니다.
지역 차단 주의: Gemini API(generativelanguage.googleapis.com)는 일부 지역에서
"User location is not supported" 로 차단됩니다. Cloudflare Worker 의 송신 지역이
미지원 지역이면 채팅이 매번 fallback 으로 떨어집니다. 회피책: ① Smart Placement 를
빼서 사용자 근처(한국=Seoul, 지원) PoP 에서 실행, 또는 ② GEMINI_BASE_URL 에
지원 지역 프록시 / Cloudflare AI Gateway URL 을 주입(model-router 가 baseURL 로 사용).
packages/db — Drizzle + postgres-js + Supabase
- Drizzle ORM(pg-core) 스키마 +
postgres-js클라이언트 + 마이그레이션 + seed/presets. - 운영 DB 는 Supabase Connection Pooler(transaction mode,
prepare=false).
Workers 에서의 DB 클라이언트 — 요청마다 새로 생성
postgres-js 클라이언트는 요청마다 새로 생성해야 합니다(모듈 레벨 캐싱 금지).
그렇지 않으면 Workers 에서 다음 오류가 납니다:
Cannot perform I/O on behalf of a different request
nodejs_compat 플래그가 필요합니다(apps/api/wrangler.jsonc).
day-key 타임존
dailyPlan / Metric 의 날짜는 자정(day-key) 으로 비교합니다. seed 는 TZ=UTC 를
강제해 런타임(Workers=UTC) 과 경계를 맞춥니다. KST 경계가 필요하면 고정 오프셋
헬퍼를 도입합니다.
packages/shared — 프론트·백 공용 로직
프론트엔드와 백엔드가 함께 쓰는 순수 로직/타입입니다: zod 스키마, 채팅 시나리오, 인사이트 수식 등. 부수효과 없는 순수 함수라 유닛 테스트(vitest)의 주 대상입니다.
자체 인증 (JWT + refresh, PBKDF2)
인증은 자체 구현입니다. Supabase Auth 를 쓰지 않습니다. Supabase 는 Postgres (그리고 추후 Storage) 전용입니다.
- access JWT:
jose/HS256. - refresh: opaque 토큰, 회전(rotation) 방식.
- 비밀번호 해시: WebCrypto PBKDF2.
- 전송:
Authorization: Bearer <access>.
참고: 로그인 UX 자체는 Google 로그인(OAuth code flow)을 사용하지만, 세션 토큰의 발급·회전·검증은 모두 위의 자체 메커니즘으로 처리합니다.
CORS와 모바일 네트워킹
CORS 는 Worker 가 직접 처리합니다(apps/api/src/index.ts). apps/api/vite.config.ts
에서 server.cors:false 로 두어, vite 내장 CORS 가 OPTIONS preflight 를 가로채는 것을
막습니다(vite cors 는 10.0.2.2 origin 에 Allow-Origin 을 누락).
preflight 응답은 다음을 포함합니다:
- 요청 origin 을 반영한
Access-Control-Allow-Origin - Android Chromium Private Network Access 대응:
Access-Control-Allow-Private-Network: true
이 구성은 iOS 시뮬레이터 + Android 에뮬레이터 양쪽에서 Appium(7 스펙 / 30 assertion) 으로 검증되었습니다. dev 클리어텍스트 허용은 로컬 개발 › 모바일 을 참고하세요.
데이터 shape 보존 규칙
백엔드 응답은 원본과 동일한 shape 으로 보존해 프론트 화면 호환성을 유지합니다.
- 완전한 ISO 타임스탬프는
apps/web/src/lib/api.ts가Date로 복원합니다(날짜 메서드 호출 화면 호환). "YYYY-MM-DD"형식은 문자열 그대로 둡니다.
범위 밖 (MVP 이후)
Slack / 푸시 / IAP / R2 등 청사진의 "MVP 이후" 항목은 현재 범위 밖입니다 — 구현하지 않습니다.