AI용 분할본 · 지도 7단계(운영·개정 — 필요할 때만) — 6단계에서 결정서에 테이블 추가·가족 원국·리포트 보관이 있으면 연다 · 이 페이지 전에 읽을 페이지: PART 0 사용법·AI 시작 지침, PART 7 보안·테스트 · 코드 파일은 여기 없다 — 코드 자리의 줄을 따른다(폴더에 책 파일이 하나도 없는 처음이면 부록 A-2 의 curl 한 줄부터)
핵심 규칙 — AI 는 어느 페이지에서든 이것을 지킨다
node scripts/get-book-code.mjs 가 책 그대로 만든다(첫 실행은 부록 A-2 의 curl 한 줄). AI용 분할본의 코드 자리는 " 경로" 한 줄이다 — 그 파일을 cat 으로 읽는다. 고쳐도 되는 파일은 PART 0 [AI 시작 지침] 5-b 의 목록뿐이다.node scripts/verify-book-code.mjs 를 실행해 "불일치 0개" 출력을 그대로 보인다.앱이 커져서(9-5의 신호) 저장할 것이 늘어나면, RIDM Apps가 준 앱 전용 PostgreSQL에 테이블을 추가하면 됩니다. 외부 DB 서비스를 새로 붙일 필요가 없습니다. 단 하나의 철칙: 모든 쿼리는 서버 코드에서 WHERE user_id = 세션 회원 id로 소유권을 건다.DATABASE_URL은 서버에만 있음) 오직 여러분의 API 라우트만 읽고 쓰므로, 남의 데이터를 막는 것은 라우트의 WHERE 절입니다. 브라우저가 보낸 user_id나 다른 회원의 id로 조회하는 라우트를 만드는 순간 전 회원 데이터가 열립니다.
테이블 추가는 lib/schema-extra.ts의 SCHEMA_EXTRA 문자열에 CREATE TABLE IF NOT EXISTS …를 적는 것으로 끝납니다 — lib/db.ts가 책의 테이블을 만든 다음 따로 실행하므로 배포 뒤 첫 요청 때 자동으로 만들어지고, 이미 있으면 건너뜁니다. 단, 여기 적은 문장이 틀리면 책의 테이블은 지워지지 않지만 고칠 때까지 DB 를 쓰는 모든 요청(가입·로그인·원국)이 실패합니다 — 앱 전체가 멈춘 것처럼 보이니 배포 전에 확인합니다. lib/db.ts는 책 그대로 둡니다(get-book-code --force가 책 원문으로 되돌리는 파일입니다). 내 테이블을 추가한 뒤에는 테스트용 빈 DB로 TEST_DATABASE_URL=… npx vitest run을 돌려 틀린 문장을 배포 전에 잡습니다(7-5 — Docker 가 있으면 한 줄로 구합니다). 테스트 DB 가 없으면 테스트는 경고와 함께 건너뛰니, 배포 직후 smoke-live 로 가입·원국 저장이 되는지 바로 보고, 완료 보고에 "DB 테스트: 미실행(테스트 DB 없음)"이라고 적습니다.
책의 라우트는 고치지 않습니다. 프록시(app/api/float/[...path]/route.ts)·대화 이력(app/api/history/route.ts) 같은 [완전 코드]를 고치면 verify-book-code가 영원히 "다름"을 내고, 책 개정 때 --force가 여러분 코드를 되돌립니다. 그 대신 두 라우트가 부르는 lib/app-hooks.ts(수정 허용)만 고칩니다 — memoryKey는 상담 기억 열쇠(endUserId)를 정하고, afterFloat는 과금 호출이 성공할 때마다 한 번 불립니다. 새 라우트·새 화면 파일을 만드는 것은 자유입니다. 기존 테이블에 컬럼을 더할 때는 ALTER TABLE … ADD COLUMN IF NOT EXISTS …를 같은 문자열 끝에 붙입니다(지우거나 이름을 바꾸는 변경은 데이터가 날아가니 AI에게 먼저 말하라고 하세요).
| 단계 | 어디에 저장되나 | 기억 열쇠(endUserId) |
|---|---|---|
| 기본 — 내 원국 1개 | birth_profiles (회원당 1행, 부록 A) | 세션 회원 id |
| 확장 — 여러 원국 | saju_charts (원국마다 1행, chartId로 구분) | 서버가 소유권을 확인한 뒤 파생한 ${user.id}:${chartId} |
"엄마 원국도 저장하고 싶어요" 같은 요청이 오면 그때 옮기세요. 처음부터 만들 필요는 없습니다.
[프롬프트 — 원국 여러 개 저장(가족·지인)]
지금은 제 원국 하나만 birth_profiles 에 저장돼 있어요. 여러 사람의 원국을 저장하고 전환할 수 있게 해 주세요.
고치는 책 파일은 lib/schema-extra.ts·lib/app-hooks.ts·app/page.tsx 뿐이다. 프록시·history 등 다른 [완전 코드]는 고치지 않는다.
1. lib/schema-extra.ts 의 SCHEMA_EXTRA 에 적는다(lib/db.ts 는 고치지 않는다):
CREATE TABLE IF NOT EXISTS saju_charts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label text NOT NULL, -- "나", "엄마" (30자 이하)
profile jsonb NOT NULL, -- birth_profiles.profile 과 같은 모양
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS saju_charts_user ON saju_charts (user_id);
2. 라우트 app/api/charts/route.ts (GET 목록·POST 추가) 와 app/api/charts/[id]/route.ts (PATCH 이름·원국 수정, DELETE):
- 전부 getSessionUser 로 로그인 확인, 비로그인 401
- 모든 SELECT/UPDATE/DELETE 에 WHERE user_id = $1 (세션 회원 id). 남의 id 면 영향 행 0 → 404
- POST 본문은 { label, profile } — profile 검증은 lib/profile.ts 의 parseProfile 을 import 해 재사용. 회원당 20개 제한. 응답은 { id, label, profile }
- DELETE 는 saju_charts 행만 지운다. 그 원국으로 나눈 상담 기억은 FLOAT 에 남는다(열쇠가 사라져 안 보일 뿐) — 화면에 "대화 기록도 지워져요"라고 쓰지 않는다
3. 기억 열쇠는 lib/app-hooks.ts 의 memoryKey(userId, chartId) 만 고쳐서 정한다 — 프록시(상담)와 /api/history 가 이미 이 함수를 부른다:
- chartId 가 없으면(undefined) 지금처럼 userId 를 돌려준다("나" 의 기존 기억 그대로)
- chartId 가 문자열이 아니거나 UUID 형식이 아니면 null(→ 404)
- SELECT 1 FROM saju_charts WHERE id = $1 AND user_id = $2 (sql() 사용)로 소유권 확인 — 없으면 null(→ 404)
- 확인되면 `${userId}:${chartId}` 를 돌려준다. chartId 만으로 키를 만들지 않는다 — 남의 chartId 로 남의 기억을 여는 구멍이 된다
4. 화면: 원국 목록(label) + 추가·이름변경·삭제 + 상단 "지금 보는 원국" 전환. 만세력·상담은 선택된 원국의 profile 을 쓴다.
원국 추가 폼은 책의 ProfileForm 을 그대로 쓴다(비슷한 폼을 새로 만들지 않는다 — 시간 모름 처리가 그 안에 있다). 이름(label) 입력칸만 옆에 두고:
<ProfileForm initial={null} title="원국 추가" save={(b) => postJson<{ profile: BirthProfile }>('/api/charts', { label, profile: b })} onSaved={…목록 다시 불러오기} />
상담은 책의 Chat 에 chartId 를 넘긴다: <Chat key={chartId ?? 'me'} profile={선택된 profile} chartId={chartId} /> (key 로 전환 때 대화를 새로 불러온다).
기존 birth_profiles 의 제 원국은 "나" 로 계속 보여주고(chartId 없음 = 기존 기억 그대로), 새로 추가한 원국만 saju_charts 로 간다
5. 완료 기준(스스로 실행해 증명):
- 원국 2개 저장 → 로그아웃·재로그인 뒤 목록 그대로
- 다른 회원으로 로그인 → 앞 회원의 원국 목록이 안 보이고, 앞 회원의 chartId 로 PATCH/DELETE·상담·이력 요청 → 404
- 원국 A 로 질문한 상담이 원국 B 의 대화 이력에 섞이지 않음
- node scripts/verify-book-code.mjs → 불일치 0개 (책 파일을 안 고쳤다는 증거)
기억 "필요"를 고른 앱은 이 절이 필요 없습니다 — 대화 이력은 HRUM 서버에 있고 /api/history로 가져옵니다(6-2). 기억 "불필요"(/reading)를 골랐는데 "지난 질문과 답을 다시 보고 싶다"는 요청이 오면 그때 붙입니다.
[프롬프트 — 질문·답변 기록 저장]
상담 기록을 우리 앱 DB에 저장해서 어느 기기에서 로그인해도 지난 질문과 답이 보이게 해 주세요.
고치는 책 파일은 lib/schema-extra.ts·lib/app-hooks.ts·app/page.tsx(기록 화면) 뿐이다. 프록시 등 다른 [완전 코드]는 고치지 않는다(새 라우트·새 컴포넌트 파일은 만들어도 된다).
1. lib/schema-extra.ts 의 SCHEMA_EXTRA 에 적는다(lib/db.ts 는 고치지 않는다):
CREATE TABLE IF NOT EXISTS chat_logs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
request_id uuid NOT NULL,
question text NOT NULL,
answer text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, request_id)
);
2. lib/app-hooks.ts 의 afterFloat 에 저장을 넣는다 — 프록시가 과금 호출 성공마다 한 번, 답을 돌려주기 전에 부른다:
call.key 가 'reading' 일 때만(기억 불필요 앱의 <Chat memory={false} /> 가 부르는 경로 — 'reading/memory' 대화는 FLOAT 가 이미 보관한다), question = call.body.question, answer = call.result.data.interpretation(문자열일 때만 — 아니면 저장하지 않는다).
call.result 는 FLOAT 원 응답 { success, data } 그대로라 답은 data 안에 있다 — call.result.interpretation 은 늘 비어 있어 한 건도 저장되지 않는다
INSERT … VALUES (call.userId, call.requestId, …) ON CONFLICT (user_id, request_id) DO NOTHING (같은 requestId 재조회가 행을 두 번 만들지 않게).
쿼리는 lib/db.ts 의 sql 로, 값은 $1·$2 자리에 배열로 넘긴다 — sql('INSERT … VALUES ($1, $2)', [a, b]). 태그드 템플릿(sql`…`)이 아니고, 값을 문장에 이어 붙이지 않는다.
브라우저가 기록을 직접 쓰는 라우트는 만들지 않는다. 저장이 실패해도 회원은 답을 받는다(프록시가 오류를 서버 로그에만 남긴다)
3. 새 라우트 GET /api/logs?before=<id> — WHERE user_id = 세션 회원 id ORDER BY id DESC LIMIT 20(before 는 양의 정수만). 화면은 위로 스크롤하면 더 불러오기
4. 회원이 지우고 싶어 하면 DELETE /api/logs (자기 것 전부) — 역시 WHERE user_id
5. 완료 기준: 질문 2개 → 다른 브라우저에서 같은 회원으로 로그인해 두 개가 보임 / 다른 회원에게는 0개 /
node scripts/verify-book-code.mjs → 불일치 0개 / DB 테스트: TEST_DATABASE_URL 로 npx vitest run → skipped 0 (테스트 DB 가 없으면 "미실행"으로 보고)
이 저장을 확인하는 테스트는 실제 응답 모양을 넣는다: afterFloat 에 result: { success: true, data: { interpretation: '답' } } → chat_logs 1행. { interpretation: '답' } 처럼 봉투 없는 가짜로 시험하면 실서버에서 0건인 코드도 통과한다.
이 테스트는 sql 을 mock 하지 않는다 — tests/db.test.ts 처럼 TEST_DATABASE_URL 에 새 스키마를 만들어 실제 lib/db.ts 의 sql 로 INSERT 하고 SELECT 로 1행을 확인한다(가짜 sql 은 틀린 SQL 도 통과시킨다)
B-6 리포트는 한 번 만드는 데 크레딧이 드니, 다시 열어볼 수 있게 저장해 두면 좋습니다.
[프롬프트 — 리포트 보관]
B-6 에서 만든 사주 리포트를 저장해서 다시 볼 수 있게 해 주세요.
1. lib/schema-extra.ts 의 SCHEMA_EXTRA 에 적는다(lib/db.ts 는 고치지 않는다):
CREATE TABLE IF NOT EXISTS saju_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
request_id uuid NOT NULL,
report text NOT NULL,
sections jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, request_id)
);
2. app/api/report/route.ts 가 FLOAT 성공 직후 서버에서 FLOAT 응답의 data.report·data.sections 로(응답은 { success, data } 봉투째 온다)
INSERT … ON CONFLICT (user_id, request_id) DO UPDATE SET request_id = EXCLUDED.request_id RETURNING id 로 저장된 행의 id 를 받아 응답에 포함한다
(DO NOTHING 이면 같은 requestId 재시도 때 RETURNING 이 빈 결과라 id 를 못 얻는다)
3. GET /api/report — 내 리포트 목록(id, created_at, sections 첫 항목), GET /api/report/[id] — 본문.
둘 다 WHERE user_id = 세션 회원 id. 남의 id 는 404
4. /report 화면: 저장된 리포트가 있으면 목록부터 보여주고 "새로 만들기" 는 따로
5. 완료 기준: 리포트 1개 생성 → 새로고침·재로그인 뒤에도 FLOAT 호출 없이 다시 열림(콘솔 사용량 그대로) /
다른 회원이 그 id 로 GET → 404
chat_logs를 지우는 정리 라우트를 두거나 플랜을 올립니다(무료 100MB · 베이직 1GB · 프로 3GB)grep -rn "FROM \|UPDATE \|DELETE FROM" app lib → 회원 데이터 테이블을 건드리는 모든 쿼리에 user_id = $ 조건이 있는가lib/db.ts의 sql())만 씁니다 — 새 테이블 쿼리도 이 sql()로 합니다. 라우트마다 새 Pool을 만들면 연결 3개 한도에 바로 걸립니다AI용 분할본 · 지도 7단계(운영·개정 — 필요할 때만) — 6단계에서 결정서에 테이블 추가·가족 원국·리포트 보관이 있으면 연다 · 이 페이지 전에 읽을 페이지: PART 0 사용법·AI 시작 지침, PART 7 보안·테스트 · 코드 파일은 여기 없다 — 코드 자리의 줄을 따른다(폴더에 책 파일이 하나도 없는 처음이면 부록 A-2 의 curl 한 줄부터)