Neul 개발 문서

아키텍처

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.tslocation.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 한 곳에 모여 있습니다.

용도모델(기본)비고
chatgemini-3-flash-previewSSE 스트리밍 (env GEMINI_CHAT_MODEL 로 flash-lite 등 오버라이드)
backgroundgemini-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-routerbaseURL 로 사용).

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.tsDate 로 복원합니다(날짜 메서드 호출 화면 호환).
  • "YYYY-MM-DD" 형식은 문자열 그대로 둡니다.

범위 밖 (MVP 이후)

Slack / 푸시 / IAP / R2 등 청사진의 "MVP 이후" 항목은 현재 범위 밖입니다 — 구현하지 않습니다.

On this page