Skip to content

Latest commit

 

History

History
619 lines (438 loc) · 99.1 KB

File metadata and controls

619 lines (438 loc) · 99.1 KB

아키텍처

ReadMates는 여러 독서모임의 공개 소개, 멤버 세션 준비, 호스트 운영, 공개 기록, 참석자 전용 피드백 문서를 하나의 로그인 세션과 클럽별 권한으로 묶습니다.

  • 이 문서는 현재 구조의 source of truth입니다. 다른 문서와 충돌하면 코드와 테스트를 확인한 뒤 함께 고칩니다.
  • 아키텍처 변경은 frontend/server guide, 경계 테스트, 배포 문서까지 맞아야 완료입니다.
  • 운영값과 외부 provider 설정은 실제 값 대신 placeholder나 runbook 링크로 적습니다.

제품 표면

표면 주요 route 사용자 역할
공개 사이트 /clubs/:slug, /clubs/:slug/about, /clubs/:slug/records, /clubs/:slug/sessions/:sessionId, /, /about, /records, /sessions/:sessionId, /login, /auth/error, /clubs/:slug/invite/:token, /invite/:token, /reset-password/:token, /living-archive-preview 게스트, 로그인 사용자 클럽 소개와 PUBLIC_RECORD 공개 기록, Google OAuth 시작과 오류 안내, 클럽 context가 있는 초대 수락, 종료된 비밀번호 경로 안내. Unscoped route는 호환성을 위해 baseline club을 씁니다. /living-archive-preview는 직접 URL로만 여는 noindex 디자인 프리뷰입니다.
클럽 게스트 앱 /clubs/:slug/app, /clubs/:slug/app/session/current, /clubs/:slug/app/notes, /clubs/:slug/app/archive, /clubs/:slug/app/sessions/:sessionId, /clubs/:slug/app/me, /clubs/:slug/app/me/records 로그인하지 않은 게스트 ACTIVE + PUBLIC 클럽에서 GUEST_READABLE 현재·예정 세션과 기록을 읽습니다. 개인 화면은 preview, 설정·알림·피드백은 정식 멤버 안내, 호스트 route는 거절합니다. 공개 사이트의 PUBLIC_RECORD 배치와는 별도 표면입니다.
로그인 후 진입 /app, /clubs/:slug/app, 등록된 club host의 /app 로그인 사용자 가입 클럽이 하나면 해당 클럽 앱으로 이동하고, 여러 개면 클럽 선택 화면을 보여주며, 선택한 클럽 context로 앱에 진입
멤버 앱 /clubs/:slug/app, /clubs/:slug/app/pending, /clubs/:slug/app/session/current, /clubs/:slug/app/notes, /clubs/:slug/app/archive, /clubs/:slug/app/sessions/:sessionId, /clubs/:slug/app/feedback/:sessionId, /clubs/:slug/app/feedback/:sessionId/print, /clubs/:slug/app/me, /clubs/:slug/app/me/records, /clubs/:slug/app/me/settings, /clubs/:slug/app/notifications, /clubs/:slug/app/notifications/settings, 등록된 club host의 /app/** 둘러보기 멤버(VIEWER), 정식 멤버, 호스트 현재 세션 확인, 게스트 공개 예정 세션 확인, 둘러보기 멤버 승인 안내, 정식 멤버의 RSVP·읽은 분량·질문·서평, 아카이브, 참석 회차 피드백 문서, 개인 기록과 계정·멤버십 정보, 알림 설정과 알림함을 제공합니다.
호스트 앱 /clubs/:slug/app/host, /clubs/:slug/app/host/notifications, /clubs/:slug/app/host/members, /clubs/:slug/app/host/invitations, /clubs/:slug/app/host/sessions, /clubs/:slug/app/host/sessions/new, /clubs/:slug/app/host/sessions/:sessionId/edit, /clubs/:slug/app/host/sessions/:sessionId/closing, /clubs/:slug/app/host/sessions/:sessionId/feedback-document, 등록된 club host의 /app/host/** 현재 클럽의 호스트 전용 세션 기록 장부에서 과거/예정 회차 검색, 예정 세션 생성/수정, 공개 범위 설정, 현재 세션 시작, 참석 확정, 진행 세션 닫기, staged 기록 초안 검토·적용·revision 복원, AI 생성 또는 외부 JSON을 공통 초안으로 가져오기, live 피드백 문서 미리보기, 회차별 클로징 상태 확인, 초대 관리, 멤버 상태와 표시 이름 관리, 알림 발송 운영
플랫폼 관리 /admin(→ /admin/today), /admin/today, /admin/health, /admin/notifications, /admin/clubs, /admin/clubs/:clubId, /admin/support, /admin/ai-ops, /admin/audit, /admin/analytics platform admin /admin/today는 클럽 readiness·domain·첫 호스트, 알림 실패, AI job 이상, 회차 마감 위험을 운영 케이스 queue로 보여주고 acknowledge·snooze·resolve를 처리합니다. 그 밖에 클럽 생성·공개 상태·소개 정보, domain alias, 첫 호스트 온보딩, 운영 health, 알림 outbox/delivery, support access grant, AI job 조회·강제 취소, 통합 감사 ledger를 다룹니다. /admin/analytics는 활성 멤버, 세션 완료율, RSVP 응답률, AI 비용/세션, 알림 도달률을 7/30/90일 window로 보여주는 aggregate-only 표면입니다. 클럽 내부 운영(세션, 멤버, 알림 발송)은 호스트 앱 책임이고, platform admin은 aggregate 진단과 감사 가능한 복구만 다룹니다.

프런트엔드 route-first 경계

프런트엔드는 React Router route를 중심으로 front/src/app -> front/src/pages -> front/features -> front/shared 방향을 목표 경계로 유지합니다. front/tsconfig.json과 Vite 설정에서 @/* alias는 front/*를 가리키므로, feature와 shared source는 front/src 안이 아니라 front/features, front/shared 아래에 있습니다. src/app은 router, layout, guard, provider wiring을 담당하고, src/pages는 route compatibility shell로서 feature route module을 re-export하거나 얇게 위임합니다.

shared/ui는 재사용 가능한 presentation primitive만 소유하며 src/app, src/pages, features를 import하지 않습니다. Router, route continuity, provider context처럼 app이 소유한 의존성은 app/page/feature route composition에서 주입하거나 shared boundary 밖의 primitive로 옮긴 뒤 사용합니다.

Feature는 가능한 범위에서 api, queries, model, route, ui로 나눕니다.

  • features/<name>/api는 해당 feature의 BFF endpoint 호출과 request/response contract만 담당합니다.
  • features/<name>/queries는 TanStack Query key, queryOptions, mutation hook, invalidation policy를 담당합니다. UI와 route module을 import하지 않습니다.
  • features/<name>/model은 React, React Router, TanStack Query, API client를 import하지 않는 순수 화면 모델 계산만 둡니다.
  • features/<name>/route는 loader/action, route error/loading state, query seeding, API/model 호출, UI props 조립을 담당합니다.
  • features/<name>/ui는 props와 callback으로만 렌더링하며 fetch, shared/api, feature API, feature queries, route module을 직접 import하지 않습니다.

shared/api/readmates compatibility module은 제거되었고, feature route/page는 feature-owned API contract 또는 shared/api primitive를 사용해야 합니다. features/*/components는 ui로 이동하지 않은 legacy presentation surface에만 남길 수 있습니다. ui directory가 있는 feature에서는 외부 source가 features/<name>/components를 public surface처럼 import하지 않습니다. Host feature는 features/host/ui가 공개 presentation surface이며, host components legacy public surface는 없습니다.

이 경계는 front/tests/unit/frontend-boundaries.test.ts에서 일부 강제합니다 (ADR-0003). 테스트는 shared-to-app/page/feature import, feature 간 직접 import, feature model/route/ui layer import, 제거된 shared/api/readmates compatibility import, ui가 있는 feature의 components public import를 확인합니다.

요청 흐름

(ADR-0001: Cloudflare Pages Functions BFF 채택 이유와 보안 경계는 adr/0001-cloudflare-pages-functions-bff.md를 참고합니다.)

Browser
  |
  | same-origin SPA, /api/bff/**, /oauth2/**, /login/oauth2/**
  v
Cloudflare Pages
  |-- Vite SPA static assets
  |-- Pages Functions BFF and OAuth proxy
        |-- /api/bff/** -> Spring /api/**
        |-- /oauth2/authorization/** -> Spring OAuth start
        |-- /login/oauth2/code/** -> Spring OAuth callback
  |
  | X-Readmates-Bff-Secret, forwarded cookies
  v
Spring Boot API
  |-- Spring Security
  |-- optional Redis-backed rate limit and read-through cache
  |-- membership, role, session authorization
  |-- feedback document parser
  |-- Flyway migrations
  |---> Redis (optional, disabled by default)
  v
MySQL

Production에서 browser-facing origin은 Cloudflare Pages입니다. 브라우저는 직접 Spring API origin을 신뢰하지 않고, 같은 origin의 /api/bff/**로 요청합니다. Pages Functions는 upstream Spring /api/**로 전달하면서 X-Readmates-Bff-Secret을 붙이고 cookie를 전달합니다. 또한 path fallback의 clubSlug query와 요청 host에서 클럽 context를 계산해 Spring으로 신뢰 가능한 X-Readmates-Club-Slug, X-Readmates-Club-Host header를 전달합니다. Cloudflare Functions의 front/functions/_shared/proxy.ts는 upstream response header 복사, 내부 x-readmates-* response header 제거, host 정규화, client IP 계산, OAuth forwarded header 생성처럼 재사용되는 trusted header/cookie/host/IP helper policy를 제공합니다. /api/bff/** route 함수는 API path와 clubSlug를 검증한 뒤 shared helper와 route-local header 구성을 조합해 upstream trusted header를 만듭니다. browser가 보낸 내부 header를 신뢰값으로 전달하지 않는 정책은 shared helper와 route tests가 함께 지킵니다.

프런트 runtime telemetry는 같은 origin의 보조 경로입니다.

  • SPA는 정규화한 route pattern, API group, status class, 안전한 error code, 짧은 message hash prefix만 /api/bff/observability/frontend-events로 보냅니다.
  • BFF가 secret을 붙여 Spring에 전달하고, Spring(observability slice)은 readmates.frontend.* Micrometer metric으로 기록합니다.
  • Raw URL, query, club slug, UUID, email, 사용자 식별자, stack trace, body, cookie, OAuth code, 배포 식별자는 보내지 않고 label로도 쓰지 않습니다.
  • 실패해도 제품 흐름을 막지 않습니다(fail-open).
  • 로컬 Vite는 이 route만 /api/observability/frontend-events로 정확히 rewrite합니다. 나머지 /api/bff/api/**는 기존 /api/** rewrite를 그대로 씁니다.

로컬 Vite dev server는 front/vite.config.ts의 proxy로 같은 구조를 흉내 냅니다. Cloudflare Pages Functions 코드는 production/preview 배포에서 실행되고, 로컬 개발에서는 Vite proxy가 /api/bff/**, /oauth2/authorization/**, /login/oauth2/code/**를 backend로 넘깁니다. 로컬 proxy도 browser-supplied club context header를 제거하고 clubSlug query에서 검증한 slug만 trusted header로 다시 붙입니다.

API 오류 계약과 화면 경계

브라우저가 해석하는 API 오류는 public-safe JSON body를 기준으로 한다.

{
  "code": "SESSION_NOT_FOUND",
  "message": "요청한 세션을 찾을 수 없습니다.",
  "status": 404
}

code는 stable uppercase identifier이고, message는 사용자에게 보여줄 수 있는 안전한 문구이며, status는 HTTP status code와 일치해야 한다. 서버는 stack trace, SQL detail, upstream host, SMTP detail, secret, token 원문, private member data, 내부 exception class name을 오류 body에 넣지 않는다.

Spring API의 application service는 Spring Web/HTTP type에 의존하지 않는다. 이번 전환 범위의 feature application/domain error는 adapter.in.web의 handler가 HTTP status와 ApiErrorResponse로 매핑한다. 이후 새로 추가하거나 전환하는 web handler도 이 contract를 따른다. 공통 framework error와 shared access-denied error는 shared web advice가 안전한 기본 code/message로 매핑한다.

Cloudflare Pages Functions BFF가 upstream Spring API에 도달하기 전에 거절하는 요청도 같은 shape를 사용한다. 예를 들어 invalid /api/bff/** path는 404 RESOURCE_NOT_FOUND, cross-origin mutation은 403 PERMISSION_DENIED, invalid clubSlug는 400 INVALID_REQUEST, 구 host-write client contract는 409 HOST_CLIENT_UPGRADE_REQUIRED를 반환한다. Upstream Spring API 응답은 status와 안전한 body를 유지하되, 내부 x-readmates-* response header와 secret은 계속 제거한다.

프런트엔드 shared/api는 non-OK 응답을 ReadmatesApiError로 변환하고 status, code, message, fallback 여부, response metadata를 보존한다. JSON body가 비어 있거나 잘못된 형태여도 HTTP status를 기준으로 안전한 fallback code/message를 만든다. React Router route boundary는 HTTP status와 public/member/host/auth context를 기준으로 404, 403, 409, 410, 5xx 화면을 보여주며, code와 message는 ReadmatesApiError에 보존한다.

OAuth start와 callback은 browser document navigation과 API-style request를 구분합니다. Pages Functions와 로컬 Vite proxy가 HTML navigation에서 invalid provider route, upstream 4xx/5xx 또는 network failure를 만나면 원래 body를 노출하지 않고 Cache-Control: no-store인 /auth/error?kind=...로 전환합니다. kind는 고정 allowlist이고 returnTo는 same-origin의 안전한 relative path만 보존하며 /auth/error 자체로의 재귀 복귀는 거절합니다. Non-HTML 요청은 ApiErrorResponse와 같은 public-safe JSON/status contract를 유지합니다.

보호 API의 401 기본값은 안전한 returnTo를 가진 login redirect입니다. 이미 성공한 내용을 화면에 유지하는 mounted current-session/archive/notes read만 recover-read, 작성 중 입력을 유지할 current-session mutation만 recover-write를 명시적으로 opt-in합니다. Read recovery는 현재 exact scoped URL의 guest capability와 public resource를 매 episode마다 다시 확인한 경우에만 게스트로 계속 보기를 제공하고, write recovery는 재로그인만 제공합니다. Host/admin/feedback/personal/profile/notification, loader-only member home과 opt-in하지 않은 호출은 기본 401 redirect를 유지합니다. Write expiry는 같은 episode의 후속 read 401로 guest 전환 상태로 낮아지지 않습니다.

Feature-specific unavailable state는 feature가 계속 소유한다. 공개 세션이 없는 상태, 피드백 문서가 없거나 권한이 없는 상태, 초대 링크 검증 오류처럼 제품 맥락이 있는 화면은 generic route error page로 대체하지 않는다.

멀티 클럽 context와 도메인 모델

clubs.slug는 클럽의 내부 canonical key입니다. Primary app origin의 공개 경로는 /clubs/:slug이고, 같은 path 전략은 primary domain을 붙였을 때도 유지됩니다. club_domains.hostname은 외부 진입 alias입니다. 등록된 alias는 Cloudflare Pages custom domain에 연결된 뒤 ACTIVE 상태일 때 host 기반으로 같은 클럽을 resolve합니다. Primary domain path fallback은 slug로 resolve하며 별도 club_domains row를 만들지 않습니다.

Platform admin의 domain 상태 확인은 https://<hostname>/.well-known/readmates-domain-check.json marker를 HTTPS로 가져와 ACTIVE 또는 FAILED로 저장합니다. Checker는 redirect를 따르지 않고, loopback/private/link-local/multicast/IPv6 ULA address와 4KB를 넘는 marker 응답을 실패로 처리합니다. 1차 구현은 Cloudflare account id, zone id, API token을 저장하지 않으며 Cloudflare API poller 대신 admin-triggered marker check를 사용합니다.

clubs.status는 운영 lifecycle이고, clubs.public_visibility는 공개 페이지 노출 여부입니다. 새 클럽은 SETUP_REQUIRED와 PRIVATE로 생성되며, 플랫폼 운영자가 필수 공개 정보와 활성 호스트를 확인한 뒤 ACTIVE와 PUBLIC으로 전환합니다. Public API는 clubs.status = ACTIVE와 clubs.public_visibility = PUBLIC을 모두 만족하는 클럽만 반환합니다.

클럽 context resolve 순서는 trusted X-Readmates-Club-Slug가 먼저이고, 없으면 trusted X-Readmates-Club-Host입니다. Spring은 BFF secret을 통과한 요청에서만 X-Readmates-Club-Slug와 X-Readmates-Club-Host를 신뢰합니다. browser가 직접 보낸 같은 이름의 header는 Pages Functions 또는 Vite proxy에서 제거되며, Spring API origin을 직접 호출하는 요청은 운영에서 BFF secret 검증을 통과할 수 없습니다.

로그인 세션은 플랫폼 전체에서 공유합니다. OAuth start는 현재 Pages 또는 registered host에서 시작될 수 있지만, Google callback redirect_uri는 READMATES_AUTH_BASE_URL의 primary auth origin으로 모읍니다. 성공 후 returnTo가 signed return state로 검증되면 클럽 path 또는 등록 host로 되돌리고, 없으면 /app smart entry로 이동합니다. Frontend guarded route, loader, API 401 흐름은 같은 origin의 안전한 relative path만 /login?returnTo=...와 OAuth start로 전달하고, absolute URL, protocol-relative URL, login/reset/invite/OAuth/root path, backslash, control character가 포함된 값은 버립니다. Absolute return URL은 primary app host, Cloudflare Pages 프로젝트 기본 origin, 또는 ACTIVE club domain이면서 session cookie domain 정책으로 세션을 공유할 수 있는 host만 허용합니다.

멤버로 시작은 일반 Google 로그인의 별칭이 아니라 명시적인 target-club join입니다. 브라우저는 먼저 same-origin JSON POST로 만료·1회용 join intent를 발급받고, 그 nonce와 joinClub을 OAuth start에 함께 전달합니다. 서버는 서명된 returnTo의 exact raw 경로가 /clubs/{같은 canonical slug}/app 또는 그 하위 경로이고 같은 session의 intent가 club·raw return path와 모두 일치할 때만 join context를 실제 provider OAuth state에 결합합니다. Callback은 해당 state의 context를 한 번만 소비하므로 crafted top-level GET, nonce 재사용, 다른 탭의 state 혼선, dot segment, percent-encoding, 대소문자 변형, 중복 separator, cross-club target은 join 권한을 만들지 않습니다. 한 탭의 성공 또는 실패 callback은 정상 redirect뿐 아니라 principal 해석이나 응답 작성 중 예상 밖 예외에서도 하나의 finally 경계로 소비한 state와 request-local context만 정리하고, Spring Security context를 제거한 뒤 servlet session ID를 정확히 한 번 회전합니다. 따라서 다른 탭의 pending authorization request/context는 보존됩니다. Provider 취소와 인지된 도메인 오류는 요청에 실린 기존 readmates_session이 여전히 유효하면 유지하며, 서버 검증에서 invalid/stale로 판명된 cookie만 명시적으로 만료합니다. 성공 시에도 target club이 ACTIVE + PUBLIC이고 정확한 (user_id, club_id) membership이 없을 때만 계정 이름·사용자 ID와 무관한 중립 표시 이름의 VIEWER를 생성합니다. 기존 VIEWER·ACTIVE는 보존하고 LEFT·SUSPENDED·INACTIVE·INVITED는 fail closed합니다. 같은 row의 동시 생성은 unique conflict 뒤 exact target을 다시 읽어 수렴합니다. raw inviteToken parameter가 존재하면 blank·malformed를 포함해 유효성 여부와 관계없이 guest join intent를 억제하며, 초대 수락 흐름이 항상 우선합니다. 초대 링크는 token만으로 전역 membership을 만들지 않고 초대가 속한 club context로 돌아오도록 return URL을 보존합니다.

사용자 역할은 club membership마다 독립적입니다. 현재 club membership role은 HOST와 MEMBER이고, platform admin 권한은 platform_admins의 OWNER, OPERATOR, SUPPORT로 별도 판정합니다. Platform OPERATOR는 club host가 아니며, 특정 클럽의 호스트 도구를 쓰려면 그 클럽 membership에서도 HOST 권한이 있어야 합니다.

Platform admin today command center는 admin.operations workflow slice가 제공하는 /api/admin/operations/cases 목록·상세와 acknowledge/snooze/resolve lifecycle API만 소비합니다. 이 slice는 클럽 readiness·domain·첫 호스트, 알림 실패·backlog, AI failed/stale job, 회차 마감 위험의 네 source를 allowlist된 signal로 투영하고 Flyway V47의 admin_operation_cases, immutable admin_operation_case_events, admin_operation_source_status ledger를 소유합니다. Case는 OPEN, ACKNOWLEDGED, SNOOZED, RESOLVED로 전이하며 동일 source identity가 다시 나타나면 같은 case가 재개방됩니다. Source 조회 실패는 UNAVAILABLE, 정책상 AI 비활성은 DISABLED로 구분하고 어느 쪽도 가짜 incident나 해결 이력을 만들지 않습니다.

OWNER와 OPERATOR만 case lifecycle을 변경하고 SUPPORT는 safe projection만 읽습니다. Resolve는 exact source identity가 사라졌음을 authoritative하게 재검증해야 하며 active 또는 확인 불가 signal은 fail closed합니다. Case API는 safe summary code, aggregate impact, source freshness, canonical detail link만 노출하고 raw email·recipient·transcript·provider error·생성 JSON·private member content를 투영하지 않습니다. Frontend는 feature-owned operations API/query/model을 거쳐 queue와 inspector를 구성하며 기존 club publication, notification replay, AI recovery, support grant mutation route는 그대로 각 domain이 소유합니다. 범용 execute endpoint는 없습니다.

Platform admin club operations는 /api/admin/clubs/{clubId}/operations에서 현재 클럽의 readiness, lifecycle, host/member/session counts, notification health, AI usage summary를 aggregate-only read model로 반환합니다. Support workbench는 /api/admin/support/search, /api/admin/support/grants를 사용해 masked email 중심 사용자 검색, active grant ledger, grant create/revoke를 처리합니다. Support access grant 생성은 OWNER 권한, 활성 platform admin grantee, eligible club, 중복 active grant 없음, 24시간 이내 만료, non-blank reason을 모두 만족해야 합니다.

Platform admin AI Ops는 readmates.aigen.enabled=true일 때 /api/admin/ai-generation/summary, /api/admin/ai-generation/jobs, /api/admin/ai-generation/jobs/{jobId}, /api/admin/ai-generation/jobs/{jobId}/force-cancel을 사용합니다. 요약과 job ledger는 provider/model/status/error/cost 중심의 안전한 projection만 반환하고, force-cancel은 platform admin actor, 이전/다음 상태, 결과, 안전한 error code를 Flyway V34 ai_generation_admin_action_audit에 기록합니다.

Public cache, 멤버 알림 deep link, host 알림 운영 ledger는 club id 또는 club slug를 포함해 scope를 나눕니다. 공개 cache key는 club id 기준으로 분리하고, 알림 link는 /clubs/:slug/app/** canonical path를 사용해 로그인 후에도 원래 클럽 화면으로 복귀합니다.

서버 내부 구조

Backend는 단일 Spring Boot 모듈을 유지하면서 feature package 안에서 클린 아키텍처 경계를 나눕니다. 완전히 전환된 서버 API slice는 feature별로 아래 방향을 따릅니다.

adapter.in.web
  -> application.port.in
  -> application.service
  -> application.port.out
  -> adapter.out.persistence

현재 이 chain을 따르는 slice는 publication, archive, browse, feedback, session, sessionclosing, sessionrecord, sessionimport, note, auth, notification, club, admin.audit, admin.analytics, admin.health, admin.operations, aigen, observability입니다. 전체 목록과 유형은 ServerArchitectureBoundaryTest의 slice registry가 기준입니다. Disabled password/password-reset/dev-invitation accept endpoint는 410 Gone stub으로 남습니다. Auth의 OAuth filter, success handler, cookie/session 보안 구성은 auth.infrastructure.security와 auth.adapter.in.security에 따로 둡니다.

Application authorization에는 Spring principal 자체가 아니라 capability-only 순수 Kotlin 값인 ClubActor와 PlatformActor를 사용합니다. 두 actor는 식별자와 capability 집합만 가지며 Spring, role/status enum, email, account name, display name, avatar 또는 profile data를 소유하지 않습니다. CurrentMember, CurrentPlatformAdmin, CurrentUser는 아직 다른 slice의 inbound carrier로 사용되므로 유지하고, 전환된 controller와 security boundary에서 필요한 actor로 변환합니다.

Auth는 trusted club slug/host header의 HTTP 추출과 우선순위를 auth.adapter.in.security에서 소유하고 club의 resolve input port를 호출합니다. 같은 auth feature의 web/security inbound package는 이 helper를 공유할 수 있지만 다른 feature의 inbound helper를 import할 수 없습니다. Auth servlet-security filter와 OAuth success handler는 concrete service가 아니라 auth input port와 application model을 주입받습니다. Membership/access policy는 club이 소유하고, application feature 방향은 auth -> club만 유지합니다. Club은 invitation-token output port를 소유하고 auth가 구현하므로 역방향 club -> auth는 없습니다.

Notification slice는 MySQL notification_event_outbox를 이벤트 source of truth로 유지합니다. Relay scheduler가 publish 가능한 row를 Kafka topic readmates.notification.events.v1로 발행하고, 같은 Spring Boot 모듈의 Kafka consumer가 이벤트별 수신자를 계산해 멤버 선호도를 적용한 뒤 notification_deliveries와 member_notifications를 만듭니다. 이메일 발송은 notification_deliveries의 EMAIL row를 기준으로 재시도 가능한 side effect로 처리하고, in-app 알림은 member_notifications가 멤버 inbox source of truth입니다. 이벤트 발행 상태는 notification_event_outbox, 채널별 발송/skip 상태는 notification_deliveries, 멤버 inbox 상태는 member_notifications에 저장합니다.

호스트가 다음 책을 공개하거나 피드백 문서·세션 기록을 적용하는 콘텐츠 mutation은 알림 발송과 분리됩니다. 다음 책 변경은 콘텐츠 revision만 갱신하고, 세션 기록 적용은 콘텐츠·immutable revision·session_record_apply_receipts만 한 트랜잭션에서 갱신합니다. 이 경로는 legacy host-action decision이나 outbox row를 만들지 않고 현재 contentRevision을 가진 composer context만 반환합니다. readmates.host-action-confirmation.required는 staged session-record capability 노출만 제어하며 dispatch를 다시 결합하지 않습니다.

호스트 수동 알림은 별도 composer를 통해 같은 outbox 파이프라인에 들어갑니다. 호스트는 /api/host/notifications/manual/options에서 세션별 템플릿, 현재 contentRevision, 대상 멤버, 최근 수동 발송 이력을 읽고, /api/host/notifications/manual/preview로 대상 수와 이메일 선호도 skip/missing 경고를 확인한 뒤, /api/host/notifications/manual로 확정합니다. Preview는 selection hash와 10분 TTL을 가진 notification_manual_dispatch_previews row로 저장되고, 확정 시 notification_manual_dispatches와 notification_event_outbox row가 같은 트랜잭션에서 만들어집니다. Stale content revision, 만료된 preview, 선택 대상 변경, 클럽 밖·비활성 회원, 같은 session/template/revision의 중복 발송은 fail closed하며, 재발송은 명시적인 resendConfirmed가 필요합니다. NEXT_BOOK_PUBLISHED, SESSION_REMINDER_DUE, FEEDBACK_DOCUMENT_PUBLISHED, SESSION_RECORD_UPDATED만 수동 템플릿으로 열리며, REVIEW_PUBLISHED는 사용자가 작성한 서평 이벤트에 묶인 자동 알림으로 유지합니다.

content mutation -> content/revision/apply receipt only
manual options -> preview -> confirm -> manual dispatch + outbox
policy ON scheduler -> automatic reminder outbox

NotificationDeliveryEngine은 claimed email delivery의 SMTP 전송, retry/dead 전환, redacted error 저장, metrics/logging을 한 곳에서 처리하고, automatic event dispatch path, manual event dispatch path, pending-delivery worker path가 같은 engine을 사용합니다. 이메일 copy는 notification.application.model의 순수 template helper가 in-app 제목/본문/deep link, 이메일 subject, plain text, HTML을 함께 렌더링하고, SMTP adapter는 HTML이 있으면 plain text fallback을 포함한 multipart MIME으로 발송합니다. 호스트 알림 상세 API는 subject, masked recipient, deep link, allowlist metadata만 노출하고 plain/HTML body는 노출하지 않습니다. 테스트 메일 audit은 별도 notification_test_mail_audit table에 masked email과 hash만 저장합니다. 발행 조건, 생성 시점, relay/consumer 주기, 재시도 정책은 OCI backend runbook을 기준으로 운영합니다. 패키지 경계는 아래처럼 web/scheduler/Kafka inbound adapter, application service, outbound port, persistence/mail/Kafka adapter로 나눕니다.

Platform admin 알림 운영은 /api/admin/notifications/snapshot, /api/admin/notifications/events, /api/admin/notifications/deliveries, /api/admin/notifications/replay-preview, /api/admin/notifications/replay-confirm을 사용합니다. Snapshot과 ledgers는 outbox/delivery 상태, relay lag, 실패 cluster, club별 health를 aggregate 중심으로 보여주며 raw email body나 원문 recipient를 노출하지 않습니다. Replay는 OWNER/OPERATOR만 사용할 수 있고, preview는 최대 1,000개(설정 범위 1..5000)의 byte-exact EMAIL + FAILED|DEAD + MAIL_RETRYABLE|MAIL_PERMANENT delivery identity와 상태를 10분 TTL, actor, optional club scope, canonical selection hash에 고정합니다. Ambiguous·expired·invalid-content·null/blank·unknown·case/padding lookalike는 고정 warning count로만 남고 target에서 제외됩니다.

Confirm은 live filter를 다시 실행하거나 새 event/outbox row를 만들지 않습니다. Preview target 중 status, attempt count, failure code, updated_at, lease가 그대로인 delivery만 직접 PENDING으로 되돌리고 달라진 row는 skip합니다. Delivery reset, 단일 ADMIN_NOTIFICATION_REPLAY_CONFIRMED platform audit, immutable confirmation receipt, preview consume는 하나의 트랜잭션이며 같은 actor/hash 명령의 응답 유실 재시도는 저장된 receipt를 반환합니다. Flyway V48은 exact targets와 receipt를 추가하고, V35 legacy v1 preview는 확정 결과를 지어내지 않으며 새 preview가 필요합니다.

Platform admin 감사 ledger는 /api/admin/audit/events와 /admin/audit에서 기존 platform_audit_events, club_audit_events, ai_generation_audit_log, admin_notification_replay_previews를 읽기 전용 cursor ledger로 통합합니다. Replay preview row는 v2 prepared/consumed 또는 v1 legacy evidence로만 투영하고, 확정 성공은 platform_audit_events.ADMIN_NOTIFICATION_REPLAY_CONFIRMED 한 건만 투영합니다. Persisted actor role과 optional club scope를 보존하며 source별 allowlist projection만 응답하므로 raw confirm reason, recipient/email, provider response, delivery ID list, raw metadata JSON, email body, transcript, generated result JSON은 노출하지 않습니다. Analytics와 호환되도록 date range, club scope, source slice, action category, actor role, outcome 필터 이름을 고정합니다.

notification
  adapter.in.web / adapter.in.scheduler / adapter.in.kafka
  application.port.in
  application.service
  application.port.out
  adapter.out.persistence / adapter.out.mail / adapter.out.kafka
영역 현재 패키지 역할
Web/scheduler/Kafka adapter publication.adapter.in.web, archive.adapter.in.web, feedback.adapter.in.web, session.adapter.in.web, sessionclosing.adapter.in.web, sessionimport.adapter.in.web, note.adapter.in.web, auth.adapter.in.web, notification.adapter.in.web, notification.adapter.in.scheduler, notification.adapter.in.kafka, admin.audit.adapter.in.web, admin.health.adapter.in.web, admin.operations.adapter.in.web, aigen.adapter.in.web, aigen.adapter.in.messaging, aigen.adapter.in.scheduling, shared.adapter.in.web HTTP request validation, CurrentMember 주입, input use case 호출, scheduler trigger, Kafka listener dispatch, response mapping; shared health endpoint
Security adapter/infrastructure auth.adapter.in.security, auth.infrastructure.security Spring Security Authentication 해석, OAuth/session filter, cookie/security wiring
Inbound port publication.application.port.in, archive.application.port.in, feedback.application.port.in, session.application.port.in, sessionclosing.application.port.in, sessionimport.application.port.in, note.application.port.in, auth.application.port.in, notification.application.port.in, club.application.port.in, admin.audit.application.port.in, admin.health.application.port.in, admin.operations.application.port.in, aigen.application.port.in controller, messaging adapter, scheduler가 concrete service 대신 의존하는 use case contract
Application service publication.application.service, archive.application.service, feedback.application.service, session.application.service, sessionclosing.application.service, sessionimport.application.service, note.application.service, auth.application/auth.application.service, notification.application.service, club.application.service, admin.audit.application.service, admin.health.application.service, admin.operations.application.service, aigen.application.service command/query orchestration과 권한 확인, retryable side effect 처리
Outbound port publication.application.port.out, archive.application.port.out, feedback.application.port.out, session.application.port.out, sessionclosing.application.port.out, sessionimport.application.port.out, note.application.port.out, auth.application.port.out, notification.application.port.out, club.application.port.out, admin.audit.application.port.out, admin.health.application.port.out, admin.operations.application.port.out, aigen.application.port.out application service가 persistence/mail/HTTP 세부사항 없이 호출하는 contract와 adapter-facing model을 소유하며, outbound adapter는 concrete application service가 아니라 이 contract/model에 의존
Persistence/mail/Kafka/HTTP adapter publication.adapter.out.persistence, archive.adapter.out.persistence, feedback.adapter.out.persistence, session.adapter.out.persistence, sessionclosing.adapter.out.persistence, sessionimport.adapter.out.persistence, note.adapter.out.persistence, auth.adapter.out.persistence, club.adapter.out.persistence, club.adapter.out.http, notification.adapter.out.persistence, notification.adapter.out.mail, notification.adapter.out.kafka, admin.audit.adapter.out.persistence, admin.health.adapter.out, admin.operations.adapter.out.persistence, admin.operations.adapter.out.source, admin.operations.adapter.out.observability, aigen.adapter.out.persistence, aigen.adapter.out.redis, aigen.adapter.out.messaging, aigen.adapter.out.llm JDBC query와 row mapping, domain marker HTTP check, 외부 provider/mail/Kafka publish 세부 구현을 소유하는 outbound adapter
Closing read model sessionclosing.adapter.in.web, sessionclosing.application.*, sessionclosing.adapter.out.persistence 기존 세션·기록·피드백·알림·공개 기록 데이터를 조합해 host-safe 회차 클로징 상태를 계산하는 read-side slice
Redis adapter auth.adapter.out.redis, publication.adapter.out.redis, note.adapter.out.redis, aigen.adapter.out.redis, shared.adapter.out.redis 선택적 Redis rate limit/cache/invalidation, AI generation job handoff/cost counter 구현. application service는 Redis adapter가 아니라 port에만 의존

표는 대표 package입니다. browse, sessionrecord, admin.analytics도 같은 adapter.in.web -> port.in -> service -> port.out -> adapter.out.* 구조이고, observability는 outbound 없이 inbound web과 application만 둡니다.

전환된 controller는 legacy repository, JdbcTemplate, persistence adapter를 직접 주입받지 않습니다. 인증된 사용자는 controller method에서 CurrentMember로 받으며, resolver가 ResolveCurrentMemberUseCase를 통해 멤버 정보를 조회합니다.

새 기능이나 전환된 slice에서는 기존 repository를 controller에 다시 주입하지 않고, 필요한 경우 inbound port, application service, outbound port와 adapter를 먼저 둡니다.

Messaging/Kafka 및 scheduling inbound adapter도 같은 방향을 따릅니다. transport와 cadence는 adapter가 소유하고 application input port만 호출하며, concrete application service나 sibling outbound adapter를 직접 주입하지 않습니다. 안전한 queue/provider/delivery failure 분류, Kafka routing envelope, Redis job-list availability 같은 내부 모델은 application이 소유합니다. 따라서 adapter는 raw provider/Redis/transport 예외나 그 message를 HTTP detail이나 metric tag로 전달하지 않습니다.

Application package는 Spring Web/HTTP type, HTTP client, adapter 구현체에 의존하지 않습니다. Application service는 feature application error를 던지고, HTTP status와 response mapping은 adapter.in.web의 controller 또는 error handler가 맡습니다. 외부 HTTP가 필요한 기능은 application outbound port를 정의하고 adapter.out.http 구현으로 분리합니다.

Outbound adapter(외부 HTTP/Redis)는 shared.adapter.out.resilience.OutboundCircuitBreakers를 통해 Resilience4j CircuitBreaker로 감싼다. CircuitBreaker 타입은 adapter.out 안에만 존재하며(application/domain 의존 금지, ArchUnit application packages do not depend on resilience4j types로 강제), 회로가 열리면 기존 fail-open 결과를 반환한다. 상태 전이는 readmates.resilience.state_transition / readmates.resilience.short_circuited Micrometer 카운터와 /admin/health의 outbound-resilience 카드로 관측한다.

Notification scheduler는 inbound adapter입니다. Relay/delivery deadline, retry, failure 분류, backlog snapshot은 notification application이 소유하고, SMTP/Kafka transport 예외는 adapter에서 bounded application result로 바꿉니다.

Notification Kafka transport도 방향별로 분리합니다. producer configuration은 notification.adapter.out.kafka에 남고, consumer factory, error handler, DLT recoverer, listener container와 consumer failure classification은 notification.adapter.in.kafka가 소유합니다. inbound consumer configuration은 producer configuration이나 outbound-owned properties alias를 경유하지 않습니다.

아키텍처 경계는 ServerArchitectureBoundaryTest에서 강제합니다 (ADR-0002). 이 테스트의 all-inbound slice registry는 web, messaging/Kafka, scheduler, adapter security, auth servlet-security package와 sessionimport WORKFLOW slice를 포함한 전환 surface를 등록합니다. 등록된 inbound adapter가 legacy repository, JdbcTemplate, outbound persistence/Redis adapter, Spring Data Redis에 직접 의존하지 않는지, application package가 adapter, Spring JDBC, Spring DAO, Spring Data Redis, Spring Web/HTTP 세부사항에 의존하지 않는지 확인합니다. admin.operations에는 같은 application dependency 규칙을 직접 적용하고, aigen.application은 CurrentMember 같은 web/session carrier 대신 application-safe actor value를 사용해야 하며, domain package는 web/JDBC/persistence 세부사항에 의존하지 않습니다.

ServerArchitectureInventoryTest(boundary/feature)와 ServerQualityRatchetTest(Detekt/ktlint)는 부채 ledger를 고정합니다. 목표 구조가 아니라 줄여 나갈 debt inventory입니다.

Ledger current + retired = approved
Boundary import (server/config/architecture/) 0 + 39 = 39
Application feature dependency 37 + 4 = 41, cyclic component 0개
Detekt baseline (server/config/detekt/) 437 + 24 = 461
ktlint baseline (server/config/ktlint/) 171 + 0 = 171
  • 부채를 없애면 같은 변경에서 current baseline의 identity를 지우고 retired ledger에 그대로 옮깁니다.
  • retired identity 삭제, approved seed 증가, 같은 크기 identity 바꿔치기, baseline 재생성은 허용하지 않습니다.
  • Detekt rule threshold(server/config/detekt/detekt.yml)는 v2.5.0에서 완화됐지만 baseline 파일과 위 partition은 그대로입니다.
  • Feature ledger의 retired edge는 club|auth, sessionrecord|sessionimport, sessionrecord|session, aigen|session입니다. 순방향 sessionimport|sessionrecord, session|sessionrecord는 current입니다.

Session-record 경계의 핵심 계약:

  • Session-record application이 소유한 ReplaceSessionRecordContentPort를 sessionimport가 구현해 live content를 교체합니다.
  • SessionRecordApplyService.apply의 바깥 @Transactional이 live 교체, immutable revision, receipt, draft 삭제를 한 transaction으로 묶습니다. Cache invalidation은 after-commit입니다.
  • Snapshot codec은 output port SessionRecordSnapshotCodec와 Jackson adapter가 소유합니다. Schema readmates-session-record:v1, deterministic JSON, exact-byte SHA-256 계약을 유지합니다.
  • History 정렬(HostSessionHistoryType.typeSort), SessionRecordVisibility, InvalidHostSessionHistoryCursorException은 session-record application model이 소유합니다.

큰 persistence/service class는 책임별 협력 객체로 나뉘어 있습니다(수동 알림 persistence, 호스트 세션 write, 플랫폼 관리자 알림 replay, Redis AI job store, 세션 기록 persistence). 각 façade(JdbcManualNotificationDispatchAdapter, JdbcHostSessionWriteAdapter, JdbcSessionRecordAdapter 등)가 기존 port bean과 transaction 경계를 유지합니다.

CQRS Read vs Write Package Split

ReadMates 서버는 도메인 패키지를 다음 두 형태로 운영합니다.

Write-side (domain/ 포함)

  • auth, club, session, notification
  • 상태를 가진 entity, 도메인 enum, 비즈니스 invariant
  • application/ 은 command use case와 도메인 객체 조작
  • 트랜잭션 mutation을 수행

Read-side (domain/ 없음)

  • note, publication, archive, browse, sessionclosing, admin.audit, admin.analytics
  • application/model/ 의 read DTO + JdbcXxxAdapter 직접 query
  • 도메인 엔티티 없이 query result 모델만 정의
  • 순수 read-only service에는 @ReadOnlyApplicationService(shared/architecture/)를 붙입니다. 현재 note, publication, archive, browse, admin.audit, admin.health에 있습니다. 규칙은 이 marker가 붙은 service에 적용됩니다.

Ops Read-side

  • observability — 프런트 telemetry batch를 받아 Micrometer metric으로 기록합니다. 영속 상태가 없습니다.
  • admin.health — 운영 상태 snapshot과 deploy ledger를 provider/adapter에서 읽어 aggregate card model로 반환합니다.
  • Mutation surface가 아니며, @ReadOnlyApplicationService인 application service는 provider 결과를 card-local failure로 격리합니다. PlatformAdminHealthRefreshScheduler는 admin.health.adapter.in.scheduling의 인바운드 어댑터이며 RefreshPlatformAdminHealthUseCase만 호출합니다. scheduler와 HTTP controller는 concrete service나 provider에 직접 의존하지 않습니다.

readmates.admin.health는 typed startup configuration입니다. 기본값은 refresh-interval=10s, freshness=30s, provider-deadline=2500ms, executor threads=4, queue-capacity=16, shutdown-await=5s, Prometheus base-url=http://prometheus:9090, connect-timeout=500ms, connection-request-timeout=500ms, read-timeout=2000ms입니다. 모든 duration은 양수여야 하고 refresh-interval < freshness, 각 Prometheus timeout은 provider-deadline 이하여야 합니다. executor thread는 1–16, queue capacity는 1–1024 범위이며 base URL은 host를 가진 absolute HTTP(S) URL이어야 합니다. 이 조건 중 하나라도 어기면 Spring context가 health executor를 만들기 전에 startup을 실패시킵니다.

Prometheus transport의 connect/socket timeout은 전용 Apache HttpClient connection configuration이 소유하고, connection-request와 response/read timeout은 Apache request configuration 및 Spring request factory에 명시적으로 설정합니다. 이는 provider-deadline의 논리 fallback과 별개로 blocking HTTP 작업을 실제로 제한합니다. health executor는 platform-admin-health- daemon thread를 쓰는 fixed-size ThreadPoolTaskExecutor이며, bounded queue와 AbortPolicy를 사용합니다. 따라서 포화 때 caller-runs로 요청 thread를 점유하지 않고 즉시 rejection을 provider-local UNKNOWN 결과로 바꿉니다. 종료 시에는 진행 task 완료를 기다리되 shutdown-await까지만 기다립니다.

lazy 및 scheduled trigger는 CAS로 설치한 정확히 하나의 in-flight future를 공유합니다. 전체 provider wave가 성공하면 snapshot과 lastSuccessfulAt을 갱신해 FRESH가 되고, 하나라도 실패하면 이전의 완전한 last-known-good snapshot은 STALE로 보존합니다. 첫 성공 전 실패는 성공한 card와 provider-local UNKNOWN card를 포함한 UNAVAILABLE snapshot을 반환합니다. stale read는 하나의 lazy refresh를 시작하되 last-known-good을 즉시 반환하며, 늦은 supplier completion은 이미 결정된 결과를 덮어쓰지 못합니다.

GET /api/admin/health/snapshot의 기존 schema, generatedAt, cards는 유지되고 lastSuccessfulAt(첫 성공 전 null), refreshState(FRESH/REFRESHING/STALE/UNAVAILABLE), staleAgeSeconds가 additive metadata로 제공됩니다. platform-admin authorization, database schema, migration, live AI-provider invocation과 email-send behavior는 변경하지 않습니다. frontend는 server-supplied state와 age를 표시하며 TanStack Query의 fetching 상태는 새로고침 버튼의 transport hint일 뿐 server freshness를 대체하지 않습니다.

Mixed / Workflow-side

  • admin.operations — 네 운영 source의 allowlist signal을 case/event/source-freshness ledger로 조정하고, 낙관적 version을 가진 lifecycle mutation과 exact-source resolve 검증을 수행합니다. Application layer는 source adapter, JDBC, Micrometer, Spring Web detail이 아니라 outbound port에 의존합니다.
  • feedback — 피드백 문서 조회, 호스트 미리보기·상태, template parser를 소유합니다. 별도 업로드 API는 없고 live 문서 교체는 session-record apply가 맡습니다. ArchUnit registry 유형은 WORKFLOW입니다.
  • sessionrecord — /app/host/sessions 장부와 editor/history API가 기본 정보·출석의 metadata-only audit, 공개 기록 공통 draft, immutable applied revision, restore-to-draft를 소유합니다. 적용 전 member/public live projection은 변경하지 않습니다.
  • sessionimport — 호스트 세션 편집기의 preview는 검증 전용 read path이고 commit은 공개 요약, 공개 범위, 하이라이트, 한줄평, 피드백 문서를 sessionrecord 공통 draft에 원자적으로 저장합니다. live 반영은 별도의 검토·알림 결정·apply 단계입니다.
  • aigen — 외부 LLM provider, Redis job handoff, Kafka worker, commit/recovery orchestration을 포함합니다. AI 결과 commit은 sessionrecord 공통 draft에 저장하고 live 반영을 수행하지 않습니다. Application layer는 provider SDK, Redis, JDBC, Kafka detail이 아니라 outbound port에 의존합니다.

강제 규칙

  • ArchUnit ServerArchitectureBoundaryTest 가 다음을 차단:
    • read-only service의 mutation port 의존 (read-only application services must not depend on mutation ports — *SavePort, *UpdatePort, *DeletePort, *WriterPort, *StorePort, *WritePort suffix 의 *.port.out.* 클래스)
    • read-only service의 @Transactional 부착 (read-only application services must not be Transactional)
  • 위반 사례: archive/application/service/MemberArchiveReviewService 는 MemberArchiveReviewWritePort 의존 + @Transactional 이므로 marker를 부착하지 않음 (write-side service).

Flyway migration 불변성

운영 schema의 source of truth는 server/src/main/resources/db/mysql/migration/이다. 현재 catalog는 V1, V9V48의 41개 versioned SQL이며 V2V8 gap은 의도적으로 유지한다. V48은 기존 migration을 수정하지 않고 관리자 알림 재실행의 exact target과 confirmation receipt schema를 additive하게 추가한다.

과거 migration은 두 개의 보완 통제로 보호한다. scripts/check-flyway-migration-immutability.py는 명시한 trusted base ref의 merge base에 존재하던 migration을 현재 index와 worktree까지 비교해 수정·삭제·rename·이동을 merge 전에 차단한다. CI는 pull request의 base SHA와 main push의 before SHA를 사용하고, 비교 job에 complete Git history를 제공한다. Flyway는 적용된 database의 flyway_schema_history checksum을 startup에 검증해 repository gate 이후의 runtime drift를 다시 차단한다.

위반을 flyway repair, baseline 증가, 과거 파일 복원 없는 삭제·rename, 낮거나 재사용한 version으로 우회하지 않는다. checker가 보고한 base 최고 version보다 큰 새 V{N}__{lower_snake_case_description}.sql forward-only migration으로 보정한다. Testcontainers는 clean install과 populated V42/V44 schema에서 V48까지의 upgrade를 유지하고, 합성 checksum fixture는 변조 거부와 정상 successor 적용을 검증한다.

인증과 세션

운영 로그인은 Google OAuth입니다.

  1. 브라우저가 /oauth2/authorization/google로 이동합니다. 초대 수락처럼 원래 클럽으로 돌아와야 하는 흐름은 returnTo를 함께 보냅니다.
  2. Pages Functions가 Spring /oauth2/authorization/google로 proxy하고, 현재 host 또는 slug에서 계산한 club context header를 붙입니다.
  3. Spring Security가 Google OAuth flow를 시작합니다.
  4. callback은 READMATES_AUTH_BASE_URL이 가리키는 primary auth origin의 /login/oauth2/code/google로 돌아옵니다. Primary auth origin을 따로 두지 않은 환경은 Cloudflare Pages 프로젝트 기본 origin(https://<pages-origin>/login/oauth2/code/google)을 씁니다.
  5. Pages Functions가 callback을 Spring으로 proxy합니다.
  6. Spring이 Google 사용자 정보를 확인하고 platform session을 복원하거나 생성합니다.
  7. returnTo가 검증된 signed return state로 저장되어 있으면 허용된 primary origin, Pages 기본 origin fallback, 또는 등록된 active club host로 redirect합니다. 없으면 /app smart entry로 redirect합니다.

readmates_session cookie는 HttpOnly, SameSite=Lax, production Secure posture를 사용합니다. 서버는 raw token을 저장하지 않고 hash를 auth_sessions에 저장합니다. API 요청마다 session cookie에서 현재 사용자를 복원하고, membership/role 상태를 기준으로 route와 API 접근을 제한합니다.

Password login과 password reset endpoint는 현재 운영 경로가 아니며 410 Gone을 반환합니다. Dev-login은 dev profile의 fixture flow입니다. production auth로 취급하지 않습니다.

BFF 보안 경계

Spring은 /api/** 요청에서 X-Readmates-Bff-Secret을 검사할 수 있습니다. 운영 기본값은 READMATES_BFF_SECRET_REQUIRED=true이며, READMATES_BFF_SECRETS가 있으면 쉼표로 구분된 후보 목록 전체를 허용하고 없을 때만 READMATES_BFF_SECRET을 fallback으로 사용합니다. Cloudflare Pages Functions는 같은 목록의 첫 번째 non-empty 값을 primary secret으로 Spring에 전달합니다.

Mutating method인 POST, PUT, PATCH, DELETE는 Origin 또는 Referer가 READMATES_ALLOWED_ORIGINS 또는 READMATES_APP_BASE_URL에서 파생된 허용 origin에 포함되어야 합니다.

Production은 READMATES_HOST_WRITE_CLIENT_CONTRACT_REQUIRED=true로 mutating /api/host/**에 현재 client contract를 추가로 요구합니다. 새 browser bundle은 X-Readmates-Client-Contract: v2를 선언하고 Pages Functions는 정확한 선언만 새 upstream header로 재생성합니다. BFF가 값을 무조건 부여하지 않으므로 구 browser + 새 BFF, 새 browser + 구 BFF, 구 browser + 구 BFF는 모두 새 backend에서 409로 fail closed하고 새 browser + 새 BFF만 통과합니다. 이 header는 인증이나 권한을 대신하지 않으며 BFF secret, same-origin 검증, session, club-scoped HOST 권한을 모두 통과해야 합니다.

클럽 context header도 BFF 신뢰 경계 안에 있습니다. Spring은 X-Readmates-Club-Slug와 X-Readmates-Club-Host를 browser input으로 취급하지 않고, BFF secret을 통과한 요청에서만 context resolve 입력으로 사용합니다. 허용 origin은 primary auth/app origin, Pages 기본 origin, 등록된 active club host를 명시적으로 포함해야 하며 wildcard suffix로 넓게 열지 않습니다.

BFF secret audit은 readmates.security.bff.audit-mode로 조정합니다. 기본값 rotation-only는 secondary/index_N처럼 non-primary alias 사용만 비동기로 기록하므로 평상시 bff_secret_rotation_audit 적재량은 0에 수렴합니다. all은 짧은 incident window에서만 사용하고, off는 audit table이나 DB 압박이 있을 때의 임시 우회로 취급합니다. Cloudflare 진단 라우트 GET /api/bff/__internal/secret-status는 configured secret count, rotation stage, primary fingerprint 앞 6자만 반환하고 raw secret은 노출하지 않습니다.

READMATES_BFF_SECRET과 READMATES_BFF_SECRETS는 Cloudflare Pages Functions와 Spring runtime 설정에만 둡니다. VITE_ 또는 NEXT_PUBLIC_ 접두사로 만들어 브라우저 bundle에 노출하지 않습니다.

Optional Redis 계층

Redis는 선택 계층이며 기본 설정에서는 꺼져 있습니다. Redis가 없어도 서버는 MySQL을 source of truth로 사용해 session, membership, publication, notes 데이터를 읽고 씁니다. AI generation처럼 Redis를 handoff/state store로 쓰는 기능은 별도 feature flag까지 켜진 환경에서만 동작합니다. Redis-backed 기능은 READMATES_REDIS_ENABLED=true와 기능별 flag를 함께 켰을 때만 동작합니다.

Redis 사용 범위는 재생성 가능한 보조 데이터 또는 짧은 TTL의 비동기 workflow 상태로 제한합니다. Redis 유실이 핵심 session/publication/member 데이터를 손상시키면 안 됩니다.

기능 저장/사용 데이터 장애 기준
Rate limit hash된 client/session 식별자 기반 counter 기본 fail-open, 민감 요청은 설정으로 fail-closed 가능
Auth session cache sessionId, userId, expiresAt 같은 session metadata MySQL session row를 계속 검증하고, cache 유실 시 MySQL fallback
Public cache 공개 API read model decode 실패 또는 Redis 장애 시 key 삭제/best-effort 후 MySQL fallback
Notes cache 멤버 공개 PUBLISHED notes feed/session 목록 read model decode 실패 또는 Redis 장애 시 key 삭제/best-effort 후 MySQL fallback
Read-cache invalidation public/notes cache key 삭제 mutation commit 이후 best-effort, 실패해도 domain mutation은 유지
AI generation job handoff content-free job hash, transcript/turns/result/evidence TTL payload, revision/CAS, LLM call counter, cost admission counter/lease Redis가 불명확하면 provider 호출 전에 fail closed. Commit한 검토 완료 snapshot은 MySQL staged draft에 유지하고, commit/cancel에서 네 Redis payload 정리를 시도합니다. Commit cleanup 실패는 재시도하고 TTL을 backstop으로 사용합니다.

Redis key와 metric label에는 raw session token, 초대 token 원문, BFF secret, OAuth code, private feedback document body, 이메일, 표시 이름을 넣지 않습니다. AI generation의 transcript, parsed turns, evidence와 검토 전 result는 job-store adapter가 관리하는 :transcript, :turns, :result, :evidence 네 값에만 짧은 TTL로 저장하고 Kafka, MySQL audit/receipt, metrics, metadata hash, operator log로 복사하지 않습니다. Commit한 검토 완료 snapshot만 공통 session_record_drafts에 내구 저장됩니다. Cache invalidation은 command transaction commit 이후 실행해 pre-commit DB 상태가 cache로 다시 채워지는 race를 줄입니다.

AI Kafka exhaustion과 restart recovery는 application input port를 호출하는 두 scheduling adapter와 단일 Redis authority로 구성합니다. Generic Kafka failure는 총 10회 뒤 content-free routing identity가 안전할 때만 hash-only recovery를 수행하고, durable terminal/no-op 결과 뒤에만 offset을 commit합니다. 살아 있는 provider attempt, 상태 변경, 아직 deadline 이전 job, corrupt/persistence failure는 commit하지 않으며 DLT를 두지 않습니다. Scheduler는 processing-deadline의 inclusive cutoff(lastUpdatedAt <= cutoff)를 쓰고 provider attempt stale 판정은 기존 strict cutoff(startedAt < providerStaleBefore)를 유지합니다.

현재 writer는 active/processing index와 epoch를 상태 변경 Lua 안에서 함께 갱신합니다. Legacy self-healing은 global active set을 최대 recovery-index-repair-max-members로 제한한 persisted worklist로 snapshot하고 wave마다 recovery-index-repair-batch-size만 처리합니다. completedEpoch == activeIndexEpoch인 full pass가 있고 ceiling 이내이며 quarantine이 비어 있을 때만 queue depth가 authoritative합니다. Redis failure, epoch 미완료, over-cap, quarantine은 0이 아니라 unavailable(NaN depth, availability 0)이며, sampling service가 injected Clock과 하나의 immutable snapshot을 소유해 gauge callback에서 Redis I/O를 하지 않습니다. 이 경계는 single-node Redis 전용이고 mixed-version writer overlap과 wall-clock repair 완료 시간은 보장하지 않습니다.

공개 API 2계층 캐시

공개 API 응답은 Spring과 Cloudflare Pages Functions BFF 두 계층에서 캐시합니다.

1계층: Spring Cache-Control 헤더

/api/public/clubs/{slug}, /api/public/clubs/{slug}/sessions/{sessionId} 등 PublicController의 GET endpoint는 Cache-Control: public, max-age=120, stale-while-revalidate=600을 응답 헤더에 포함합니다. CDN 또는 브라우저가 이 지시어를 해석해 120초간 신선한 캐시를 사용하고, 이후 최대 600초간 오래된 캐시를 허용하면서 백그라운드 재검증을 수행합니다.

2계층: BFF caches.default 저장

Cloudflare Pages Functions BFF(front/functions/api/bff/[[path]].ts)는 /api/public/clubs/, /api/public/records/ 경로 prefix에 해당하는 GET 요청에 대해 caches.default를 사용합니다.

  • 캐시 히트 시 upstream fetch 없이 즉시 반환합니다.
  • 캐시 미스 시 upstream을 fetch하고, 응답이 캐시 가능한 조건(response.ok, Cache-Control: public 또는 max-age 포함, Set-Cookie 없음, Vary: Cookie/Authorization 없음)을 만족하면 context.waitUntil로 비동기 저장합니다.
  • 캐시 키는 pathname과 search만 포함하고 cookie, authorization 같은 사용자 컨텍스트를 제거합니다.
  • 저장하는 응답은 copyUpstreamHeaders로 내부 x-readmates-* 헤더와 set-cookie를 제거한 sanitized 응답입니다.
  • 변이 메서드(POST, PUT, PATCH, DELETE)와 prefix에 해당하지 않는 경로는 캐시 계층을 거치지 않습니다.

멤버십과 역할 모델

ReadMates의 사용자 상태는 club membership의 status와 role을 함께 봅니다. Membership row는 club별로 독립적이고, platform admin 권한은 platform_admins에서 별도로 확인합니다.

상태/역할 의미
GUEST 로그인과 membership row가 없는 익명 audience입니다. 공개 사이트와 club-scoped 게스트 앱의 허용된 읽기만 사용하며 모든 응답 capability는 canWrite=false입니다.
VIEWER Google 로그인은 했지만 정식 초대를 수락하지 않은 둘러보기 멤버입니다. 읽기 가능한 일부 멤버 화면과 멤버 공개 예정 세션은 볼 수 있지만 현재 세션 쓰기, 피드백 문서 열람, 호스트 도구는 제한됩니다.
ACTIVE + MEMBER 정식 멤버입니다. 현재 세션 참여, 멤버 공개 예정 세션 확인, RSVP, 체크인, 질문, 한줄평, 장문 서평, 클럽 피드백 문서 열람이 가능합니다.
ACTIVE + HOST 호스트입니다. 정식 멤버 권한에 운영 권한이 추가됩니다.
SUSPENDED 제한된 멤버 상태입니다. route guard와 API authorization에서 쓰기/운영 권한을 제한합니다.
LEFT, INACTIVE 떠났거나 비활성화된 계정 상태입니다. 멤버 앱과 쓰기 기능에서 제외됩니다.
INVITED 초대 발급 또는 수락 전후의 중간 상태로 사용됩니다.

같은 사용자라도 클럽마다 다른 role과 status를 가질 수 있습니다. 예를 들어 한 사용자는 reading-sai에서는 HOST, sample-book-club에서는 MEMBER일 수 있고, platform admin이라도 특정 클럽에서 호스트 API를 쓰려면 해당 클럽 membership 권한을 별도로 통과해야 합니다.

프런트엔드의 club app audience는 GUEST | VIEWER | MEMBER | HOST 네 가지입니다. 익명 GUEST는 membership status가 아니며, 로그인된 VIEWER와 같은 것으로 취급하지 않습니다. 네 audience의 /clubs/:slug/app/**는 모두 검색 결과용 공개 페이지가 아니므로 route가 mount된 동안 robots=noindex를 적용합니다. 서버가 반환하는 게스트 내비게이션 capability는 홈·현재 세션·노트·아카이브·회차 상세를 OPEN, 내 공간·개인 기록을 PREVIEW, 설정·알림·피드백을 LOCKED, 호스트를 DENY로 고정합니다. 게스트 direct URL도 같은 capability를 적용하므로 메뉴를 숨겨 우회시키지 않고, 잠긴 표면은 정식 멤버 안내를 보여주며 protected API를 호출하지 않습니다.

현재 세션 참여 여부는 session_participants와 SessionParticipationStatus로 관리합니다. 호스트는 같은 클럽의 정식 멤버를 현재 세션에 추가하거나 제거할 수 있고, 참석 확정 후 공개 기록과 멤버 활동 통계에 반영됩니다.

세션 lifecycle과 공개 범위

ReadMates는 클럽별로 하나의 현재 OPEN 세션과 여러 개의 예정 DRAFT 세션을 함께 다룹니다. 호스트가 새 세션을 만들면 기본 상태는 DRAFT, canonical app access는 HOST_ONLY, public-site placement는 HIDDEN입니다. 앱 열람과 공개 사이트 배치는 서로 독립된 두 축입니다.

  • sessions.access_scope: HOST_ONLY | GUEST_READABLE. GUEST_READABLE은 익명 게스트 앱, VIEWER, 정식 멤버와 호스트의 공통 읽기 후보입니다.
  • public_session_publications.site_visibility: HIDDEN | PUBLIC_RECORD. PUBLIC_RECORD는 CLOSED 또는 PUBLISHED에서만 저장할 수 있고, 실제 공개 사이트 query는 PUBLISHED + PUBLIC_RECORD만 반환합니다.
  • 호스트는 /api/host/sessions/{sessionId}/access-scope로 app access를, publication request의 siteVisibility로 public-site placement를 저장합니다. 호스트 목록·상세 응답은 accessScope, siteVisibility와 rolling-deploy용 legacy visibility를 함께 반환합니다.

sessions.state는 운영 단계를 구분합니다.

상태 의미 주요 전환
DRAFT 예정 세션입니다. GUEST_READABLE이면 scoped 게스트 앱과 로그인된 reader의 예정 목록에 보입니다. Public site record가 될 수는 없습니다. /api/host/sessions/{sessionId}/open으로 OPEN 전환
OPEN 현재 참여 세션입니다. RSVP, 읽은 분량, 질문, 서평, 참석 확정이 이 상태를 기준으로 동작합니다. /api/host/sessions/{sessionId}/close로 CLOSED 전환
CLOSED 모임은 끝났지만 기록은 아직 최종 발행 전입니다. GUEST_READABLE이면 게스트·멤버 archive에서 읽을 수 있지만 notes와 공개 사이트에는 아직 나오지 않습니다. 공개 요약과 허용된 exposure를 저장한 뒤 /api/host/sessions/{sessionId}/publish로 PUBLISHED 전환
PUBLISHED 기록 발행이 완료된 세션입니다. GUEST_READABLE은 게스트·멤버 notes/archive를 열고, PUBLIC_RECORD가 추가된 경우에만 공개 사이트에도 배치됩니다. 되돌림 API는 없습니다.

Canonical source of truth는 access_scope와 site_visibility입니다. V45는 기존 sessions.visibility in (MEMBER, PUBLIC)을 GUEST_READABLE로 backfill하고, 기존 공개 publication 중 허용된 lifecycle만 PUBLIC_RECORD로 backfill합니다. 한 릴리즈의 rolling deploy와 rollback을 위해 sessions.visibility, public_session_publications.visibility, is_public을 유지하며 모든 새 host write가 같은 transaction에서 dual-write합니다. Mapping은 HOST_ONLY + HIDDEN -> HOST_ONLY/MEMBER/false, GUEST_READABLE + HIDDEN -> MEMBER/MEMBER/false, GUEST_READABLE + PUBLIC_RECORD -> PUBLIC/PUBLIC/true입니다. HOST_ONLY + PUBLIC_RECORD와 DRAFT/OPEN + PUBLIC_RECORD는 거절합니다. 구 {visibility} request도 이 호환 기간에만 받으며, 다음 릴리즈에서 old frontend 사용이 없음을 확인한 뒤 request parser와 compatibility column read/write를 별도 migration으로 제거합니다. V45 column을 이번 rollout에서 즉시 제거하거나 legacy 값만 source of truth로 되돌리지 않습니다.

호스트는 /api/host/sessions/{sessionId}/open으로 DRAFT 세션 하나를 현재 세션으로 시작합니다. 같은 클럽에 이미 OPEN 세션이 있으면 다른 draft를 동시에 열 수 없습니다. 이미 열린 세션에 대한 open 요청은 같은 세션 detail을 반환하고, CLOSED나 PUBLISHED 세션을 현재 세션으로 되돌리지는 않습니다.

호스트는 /api/host/sessions/{sessionId}/close로 OPEN 세션을 닫습니다. 이미 CLOSED인 세션에 대한 close 요청은 같은 detail을 반환하지만, DRAFT나 PUBLISHED 세션은 닫을 수 없습니다. 닫기 SQL은 state='OPEN' 조건이 맞을 때만 CLOSED로 바꾸므로, 다른 트랜잭션이 먼저 PUBLISHED로 바꾼 상태를 덮어쓰지 않습니다.

호스트는 /api/host/sessions/{sessionId}/publish로 CLOSED 세션을 발행합니다. 발행은 같은 club의 publication row가 있고, canonical access_scope=GUEST_READABLE이며, public_summary가 비어 있지 않을 때만 허용됩니다. HOST_ONLY, DRAFT, OPEN, publication row가 없는 CLOSED 세션은 발행할 수 없습니다. site_visibility=PUBLIC_RECORD면 compatibility is_public과 published_at도 함께 맞춥니다.

호스트 앱은 /clubs/:slug/app/host/sessions/:sessionId/closing에서 회차별 클로징 상태를 보여줍니다. 이 화면은 세션 종료, 기록 패키지, 피드백 문서, 멤버 알림, 공개 기록 노출을 하나의 host-safe read model로 묶고, member/public 표면에는 권한에 맞는 진입과 공개 가능한 기록만 노출합니다. 서버의 sessionclosing slice는 새 영속 상태나 DB migration 없이 기존 세션·공개 기록·피드백 문서·알림 outbox/inbox 데이터를 읽어 checklist, next action, Host/Member/Public surface 상태, evidence ledger를 계산합니다.

로그인 audience의 멤버 홈은 /api/sessions/upcoming, 익명 audience의 scoped guest app은 /api/public/clubs/{slug}/browse/sessions/upcoming을 사용합니다. 두 경로 모두 DRAFT + GUEST_READABLE인 같은 클럽 세션을 반환하며 public-site placement를 요구하지 않습니다. 공개 클럽에 OPEN + GUEST_READABLE current session이 없으면 guest current API는 404가 아니라 {currentSession:null}을 반환해 정상 empty state를 표시하고, 존재하지 않거나 비공개·비활성인 클럽만 404로 숨깁니다. GUEST와 VIEWER는 읽을 수 있지만 RSVP/체크인/질문/서평 쓰기와 /api/host/** 운영 도구는 사용할 수 없습니다.

발행된 회차의 장문 서평은 archive-owned endpoint인 PUT /api/archive/sessions/{sessionId}/my-long-review에서 저장합니다. 이 경로는 PUBLISHED + GUEST_READABLE 세션에 참여한 정식 멤버만 사용할 수 있으며, 새로 저장한 서평은 PUBLIC long review가 됩니다. 기존 PRIVATE/SESSION row를 migration으로 일괄 공개하지 않고 작성자가 다시 저장한 뒤부터 guest projection에 포함합니다. 같은 작성자의 같은 세션 서평은 처음 공개 상태가 될 때만 REVIEW_PUBLISHED 알림을 만들고, 작성자 본인은 수신 대상에서 제외합니다.

이메일 알림, 멤버 알림함, 호스트 운영

멤버 알림 설정은 /api/me/notifications/preferences에서 읽고 저장합니다. 선호도 row가 없으면 운영 알림(NEXT_BOOK_PUBLISHED, SESSION_REMINDER_DUE, FEEDBACK_DOCUMENT_PUBLISHED, SESSION_RECORD_UPDATED)은 켜짐, 서평 공개 알림(REVIEW_PUBLISHED)은 꺼짐으로 해석합니다. FEEDBACK_DOCUMENT_PUBLISHED와 SESSION_RECORD_UPDATED는 같은 feedback_document_published_enabled 선호도를 공유합니다. /app/notifications/settings는 정식 멤버와 호스트에게 이 설정을 보여주며, 둘러보기 멤버는 저장 가능한 알림 설정을 보지 않습니다.

멤버 알림함은 /api/me/notifications, /api/me/notifications/unread-count, /api/me/notifications/{id}/read, /api/me/notifications/read-all을 사용합니다. /app/notifications는 member_notifications를 source of truth로 읽고, unread count, 개별 읽음 처리, 전체 읽음 처리를 제공합니다. /app/notifications/settings와는 받은 알림·수신 설정 tab으로 연결합니다. 알림 route는 /app/me의 utility 목록에서 진입하고, 두 route 모두 현재 club scope를 보존하는 고정 내 공간 상위 경로를 제공하며 영구 앱 내비게이션에서도 내 공간 current state를 유지합니다. 각 알림의 deep link를 열면 해당 알림을 읽음 처리한 뒤 대상 화면으로 이동합니다.

호스트 알림 운영 페이지는 /app/host/notifications입니다. 기본 화면은 서버에서 확인된 리마인더 정책과 pending/failed/dead/최근 24시간 발송 지표를 한 줄에 묶은 상태 레일, 회차 → 알림 종류 → 대상과 채널 3단계 작업대, 최근 수동 발송 원장으로 구성됩니다. Preview는 desktop 오른쪽 side sheet와 mobile bottom sheet로 열리며 수신 인원 기반 확정 CTA를 사용합니다. 닫기, Escape, backdrop, route navigation은 dispatch를 만들지 않습니다. Event outbox/channel delivery 전체 원장, 이메일 pending/failed 처리, 개별 retry, DEAD delivery 복구, 테스트 메일과 audit은 이상 상태 또는 명시적 운영 점검 때 펼치는 운영 상세에 둡니다. 같은 화면과 콘텐츠 변경 직후 열린 composer에서 호스트는 세션을 선택하고 수동 템플릿, 대상 그룹(ALL_ACTIVE_MEMBERS, SESSION_PARTICIPANTS, CONFIRMED_ATTENDEES, SELECTED_MEMBERS), 채널(IN_APP, EMAIL, BOTH)을 조합해 새 알림을 발송할 수 있습니다. 이벤트별 기본 대상은 NEXT_BOOK_PUBLISHED·SESSION_REMINDER_DUE의 ALL_ACTIVE_MEMBERS, FEEDBACK_DOCUMENT_PUBLISHED·SESSION_RECORD_UPDATED의 CONFIRMED_ATTENDEES이고 기본 채널은 모두 BOTH입니다. 피드백 문서와 세션 기록은 전달 계획에서도 같은 feedback_document_published_enabled 멤버 선호도를 사용합니다. FEEDBACK_DOCUMENT_PUBLISHED의 CTA/options/preview는 session_feedback_documents의 최신 live 문서가 있을 때 제공되며, OPEN 세션도 그 current live 문서가 있으면 manual options → preview → confirm을 사용할 수 있습니다. SELECTED_MEMBERS는 호스트가 명시적으로 선택해야 하며 같은 클럽의 중복 없는 활성 membership ID를 한 명 이상 요구합니다. Preview는 최종 대상 수, in-app/email 예상 건수, 이메일 설정으로 인한 skip, 이메일 누락, 중복 발송 여부를 보여주며, confirm 후 생성된 수동 dispatch는 event ledger에서 source=MANUAL과 manual metadata로 구분됩니다. 호스트 API 응답은 recipient email을 masked 값으로만 반환하고, detail metadata는 sessionNumber, bookTitle처럼 allowlist된 제품 metadata만 노출합니다.

클럽별 예약 리마인더 정책은 GET/PUT /api/host/notifications/policy로 읽고 저장합니다. Policy row가 없으면 sessionReminderEnabled=false이며, 호스트가 명시적으로 켠 클럽만 NotificationReminderScheduler가 SESSION_REMINDER_DUE outbox row를 만듭니다. 같은 대상 날짜의 scheduler 재실행은 기존 dedupe key로 중복 event를 만들지 않습니다.

테스트 메일은 SMTP 호출 전 audit row를 먼저 예약해 host membership 단위 60초 cooldown을 직렬화합니다. 실패한 테스트 메일은 같은 audit row를 FAILED로 갱신하고, 저장/응답되는 error는 email, secret, token, credential 형태를 redaction한 뒤 길이를 제한합니다.

페이지된 목록 API contract

범위가 있는 목록 endpoint는 cursor 기반 page object를 반환합니다. 공통 응답 필드는 { "items": [...], "nextCursor": string | null }이고, 다음 page가 없으면 nextCursor는 null입니다. Endpoint에 따라 /api/me/notifications의 unreadCount처럼 목록 전체 상태를 나타내는 추가 field가 붙을 수 있습니다. Request query는 endpoint별 기본값과 최대값을 둔 limit, cursor를 사용합니다.

이 contract를 따르는 목록은 guest browse의 upcoming/notes/archive, archive의 /api/archive/sessions, /api/archive/me/questions, /api/archive/me/reviews, notes의 /api/notes/sessions, /api/notes/feed, feedback의 /api/feedback-documents/me, host의 /api/host/sessions, /api/host/members, /api/host/members/viewers, /api/host/members/pending-approvals, /api/host/invitations, notification의 /api/me/notifications, /api/host/notifications/items, /api/host/notifications/events, /api/host/notifications/deliveries, /api/host/notifications/manual/dispatches, /api/host/notifications/test-mail/audit, platform admin의 /api/admin/notifications/events, /api/admin/notifications/deliveries, /api/admin/audit/events입니다. Guest cursor는 opaque payload에 club slug와 exact key set을 포함해 다른 club에서 재사용하거나 key가 더해진 cursor를 거절합니다. GET /api/host/notifications/manual/options도 멤버 선택 목록을 같은 cursor page shape로 반환합니다. 예를 들어 GET /api/host/members/pending-approvals?limit=2는 pending viewer approval 목록의 첫 page를 반환하고, 다음 page는 응답의 nextCursor를 cursor query로 넘겨 요청합니다.

위 scoped endpoint에는 legacy array response contract가 없습니다. 프런트엔드 loader와 route action은 items를 누적하고 nextCursor로 명시적인 더보기 control을 보여줘야 하며, 새 scoped 목록 API도 같은 공통 page field를 사용합니다.

나의 서재와 개인 독서 여정

GET /api/archive/me/journey?limit=12&cursor=<cursor>는 현재 멤버의 archive read projection입니다. 응답은 날짜·회차·ID 내림차순의 cursor items와, page 크기와 무관하게 같은 club의 전체 열람 가능 기록에서 계산한 summary를 함께 반환합니다. Item에는 책·회차 metadata, 본인의 독서 진도와 질문·장문 서평 수, 피드백 문서의 가용성·열람 가능 상태만 들어가며 피드백 본문이나 다른 멤버 정보는 포함하지 않습니다. Canonical 의미는 CLOSED|PUBLISHED + GUEST_READABLE이며 HOST_ONLY와 다른 club row는 제외됩니다. 한 릴리즈 호환 기간에는 기존 member archive adapter가 dual-write된 MEMBER|PUBLIC compatibility 값을 읽을 수 있지만, 새 product decision은 access_scope로 기록합니다.

Archive JDBC adapter는 요청마다 page query와 전체 summary query를 정확히 하나씩, 합계 두 statement로 실행하며 page 크기나 item 수에 따라 query 수가 늘지 않습니다. 이 projection은 기존 테이블만 읽으므로 Flyway migration이나 새 영속 상태를 추가하지 않습니다.

/app/me loader는 profile과 limit=3 journey를 병렬 로드하며, 누적 summary는 page 크기와 무관하게 유지됩니다. 화면은 읽기 전용 프로필 요약, 현재 클럽 멤버십 맥락, 누적 독서 성취가 이어지는 하나의 overview와 서버 정렬 순서를 따르는 최근 개인 기록을 최대 3건 보여줍니다. 프로필 요약의 단일 진입점은 표시 이름과 아바타를 함께 편집하는 adaptive dialog/bottom sheet를 열며, 최근 row는 club scope를 유지한 회차 상세로 연결되고 section의 전체 보기는 canonical /app/archive?view=sessions로 이어집니다.

/app/me/records는 limit=12부터 cursor continuation을 누적하는 개인 활동 기반 전체 목록으로 직접 접근과 기존 deep link를 유지하지만 /app/me의 새 사용자 진입점으로 노출하지 않습니다. 개인 journey와 archive sessions의 포함 조건이 다르므로 이 route를 삭제하거나 archive로 redirect하지 않습니다.

계정·멤버십 정보와 탈퇴는 /app/me/settings, 알림 수신 설정은 알림함과 나란한 /app/notifications/settings가 소유합니다. /app/me는 알림과 계정 설정 utility를 제공하고, 두 알림 route와 /app/me/settings는 scoped 내 공간을 고정 상위 경로로 사용합니다. 전역 계정 메뉴는 identity, 알림, 계정 설정, 로그아웃을 소유하며 알림은 club-scoped 알림함으로 연결합니다. 호스트 권한이 있을 때만 계정 control과 분리된 작업 공간 전환 control을 노출합니다. 표시 이름과 아바타의 통합 편집은 /app/me만 소유하며 atomic profile update controller, auth refresh, route revalidation 계약을 유지합니다. /app/me/settings에는 프로필 편집과 로그아웃을 중복하지 않습니다.

멤버 프로필과 표시 이름

ReadMates에서 멤버를 부르는 앱 표시 이름은 displayName입니다. displayName은 현재 클럽 membership에 붙은 이름이며, 현재 스키마에서는 기존 memberships.short_name 컬럼에 저장합니다. Google 계정이나 초대에서 온 원본 이름은 accountName으로만 다루고, 멤버를 식별할 때는 email을 우선 보여줍니다.

멤버 아바타는 memberships.avatar_key에 저장한 30개 allowlist 로컬 book-club key 중 하나입니다. Forward-only V46은 V44를 수정하지 않고 기존 membership을 클럽별 안정적 순서에 따라 30개 key로 결정적 재배정하며 named check constraint를 새 집합으로 교체합니다. Membership 생성 또는 재활성화 transaction은 유효한 이전 저장 key를 우선 보존하고, 그렇지 않으면 현재 노출 대상 멤버가 쓰지 않는 key 중 하나를 무작위로 배정해 영속화합니다. 30개가 모두 사용 중이면 전체 후보에서 무작위로 고릅니다. /app/me의 adaptive 프로필 편집기는 명시적으로 해석된 현재 클럽 context와 PUT /api/me/profile을 사용해 본인 membership의 표시 이름과 아바타를 한 transaction에서 함께 변경하며, 현재 클럽 context가 없으면 fail closed합니다. 수동 선택은 같은 클럽 안의 아바타 key 중복을 허용합니다. 서버의 auth, 현재·호스트 세션, archive, notes, public record projection은 필요한 멤버 표시 이름과 함께 avatarKey를 전달합니다. 프런트엔드는 이를 privacy-safe 로컬 /assets/avatars/book-club/*.webp로만 해석하고, 누락·알 수 없는 key·이미지 decode 실패는 로컬 기본 아바타로 정규화합니다. Google 프로필 이미지 URL은 멤버 아바타 렌더링에 사용하지 않습니다.

프로필 수정 API는 atomic 본인 편집 endpoint와 호환성 window, 호스트 endpoint로 구성됩니다.

  • PUT /api/me/profile: 인증된 사용자가 명시적인 현재 클럽 context에 속한 본인 membership의 displayName과 30-key avatarKey를 원자적으로 교체합니다. 둘 중 하나라도 validation, 중복, 권한 검사를 통과하지 못하면 두 DB 컬럼 모두 유지합니다. VIEWER, ACTIVE, SUSPENDED처럼 멤버 앱을 읽을 수 있는 상태에서만 허용합니다.
  • 현재 클럽 context가 없거나 membership을 해석할 수 없으면 fail closed하며, MEMBER_NOT_FOUND는 클럽 또는 membership 존재 여부를 구분해 노출하지 않는 의도적인 오류 계약입니다.
  • PATCH /api/me/profile과 PATCH /api/me/avatar: cached old client를 위한 제한된 호환성 window입니다. 각각 기존 단일 필드 contract를 유지하지만 새 frontend는 사용하지 않으며 후속 release에서 제거할 수 있습니다. Avatar PATCH도 명시적인 현재 클럽 context와 30-key allowlist를 요구합니다.
  • PATCH /api/host/members/{membershipId}/profile: 활성 호스트가 같은 클럽 멤버의 displayName을 수정합니다. 호스트 멤버 목록 row를 갱신할 수 있도록 HostMemberListItem 형태를 반환합니다.

서버는 표시 이름을 trim한 뒤 필수값, 20자 이하, 제어문자, 이메일 형태, URL/domain 형태, 예약어(탈퇴한 멤버, 관리자, 호스트, 운영자)를 검증합니다. 같은 클럽 안의 displayName 중복은 현재 memberships(club_id, short_name) unique constraint와 application-level lock/check로 막습니다. 공개 문서와 seed에는 실제 멤버 이름 대신 public-safe sample name만 둡니다.

공개 사이트, 게스트 앱, 비공개 기록 경계

공개 사이트와 scoped 게스트 앱은 모두 anonymous-safe이지만 같은 projection이 아닙니다.

  • 공개 사이트는 /api/public/clubs/{slug}, /api/public/clubs/{slug}/sessions/{sessionId}를 사용하고 PUBLISHED + PUBLIC_RECORD만 반환합니다. site_visibility=HIDDEN인 GUEST_READABLE 기록은 공개 사이트 목록·상세에 나오지 않습니다.
  • scoped 게스트 앱은 /api/public/clubs/{slug}/browse/** 전용 read slice를 사용합니다. ACTIVE + PUBLIC 클럽에서 current는 OPEN, upcoming은 DRAFT, archive는 CLOSED|PUBLISHED, notes는 PUBLISHED인 access_scope=GUEST_READABLE 세션만 반환합니다. 게스트 앱은 멤버 /api/sessions/**, /api/archive/**, /api/notes/**를 호출하지 않습니다.
  • 게스트 현재 세션에는 책·회차·시간·질문 마감, 참석자의 표시 이름·로컬 avatar key·RSVP·실제 참석 상태, 작성자 이름이 붙은 질문·draftThought·공개 장문 서평을 포함할 수 있습니다. Archive/detail과 notes에는 공개 가능한 질문, 하이라이트, 새 PUBLIC 한줄평·장문 서평과 참석 집계가 포함됩니다.
  • 게스트 DTO는 userId, membershipId, email, accountName, 정확한 locationLabel, meetingUrl, meetingPasscode, 읽은 분량, 피드백 문서 본문과 host/admin metadata를 포함하지 않습니다. 정확한 장소·접속 링크·비밀번호는 공개 소개용 별도 요약으로 대체하지 않고 응답에서 제외합니다.
  • 모든 guest view model은 canWrite=false입니다. 개인 공간은 샘플 preview이고 계정 설정·알림·피드백은 locked 안내만 보여줍니다. 피드백 direct URL도 protected API를 호출하지 않으며, 호스트 direct URL은 scoped guest home으로 돌아갑니다.
  • HOST_ONLY는 guest/member read projection에서 제외합니다. CLOSED + GUEST_READABLE은 archive에서 읽을 수 있지만 notes와 공개 사이트에는 아직 나오지 않습니다.
  • 로그인 멤버 앱의 /api/archive/**, /api/notes/**, /api/sessions/current/**는 인증과 membership 상태를 확인하고, 호스트 /api/host/**는 현재 club context의 active HOST role을 요구합니다.

Guest browse GET은 Spring Security의 anonymous permit 경로지만 검증된 club slug를 사용하고, rate limit이 켜진 환경에서는 trusted client IP hash와 club hash를 합친 key로 분당 120회 제한합니다. 거절 응답은 429, bounded Retry-After, Cache-Control: no-store를 반환합니다. Guest controller의 정상·오류 응답도 no-store이며, Pages BFF는 upstream Cache-Control directive를 대소문자와 무관하게 해석해 no-store/private, Set-Cookie, Vary: Cookie|Authorization 응답을 cache하지 않습니다. 공개 marketing cache prefix 안에 browse URL이 들어가더라도 이 upstream 정책 때문에 guest DTO는 edge cache에 저장되지 않습니다.

이 경계는 공개 저장소 전환에도 중요합니다. 문서와 예시는 실제 멤버 데이터나 실제 운영 club domain을 사용하지 않고, API origin은 https://api.example.com 같은 placeholder를 사용합니다.

피드백 문서 흐름

피드백 문서는 모임 후 운영 산출물을 저장하고 읽기 좋게 제공하기 위한 기능입니다.

Session record final live apply
  |
  | applies the current staged package (including AI or JSON import results)
  v
`session_feedback_documents` 최신 live 문서
  |
  | host preview: GET /api/host/sessions/{sessionId}/feedback-document/preview
  v
GET /api/sessions/{sessionId}/feedback-document
  |
  v
Readable response for active full member or host
  • 피드백 문서만 따로 올리는 경로는 없습니다. AI 생성 또는 readmates-session-import:v1 JSON commit이 문서를 공통 staged draft에 저장합니다.
  • 멤버가 읽는 live 문서는 세션 기록 final apply가 성공할 때 함께 바뀝니다.
  • Host preview route는 staged draft가 아니라 session_feedback_documents의 최신 live 문서를 읽습니다. 그래서 OPEN 세션도 live 문서가 있으면 피드백 알림 CTA와 manual composer를 쓸 수 있습니다.

문서 템플릿 (v1 / v2)

FeedbackDocumentParser는 두 marker를 읽습니다 (ADR-0072).

Marker 내용
<!-- readmates-feedback:v1 --> 메타, 관찰자 노트, 참여자별 피드백의 필수 구조
<!-- readmates-feedback:v2 --> v1 필수 구조 + 선택 섹션: 한눈에 보기, 오늘의 하이라이트, 모임 피드백, 모임의 흐름, 이어갈 질문, 참여자별 배지·변화 흐름·지난 과제에서 해낸 것·첫 기록 기준점·이번 모임의 발언
  • API 응답은 기존 필드를 유지하고 templateVersion과 선택 섹션 필드만 추가합니다. DB schema 변경은 없습니다.
  • 프런트는 선택 섹션이 있을 때만 새 영역을 그리고, 없으면 기존 화면을 씁니다.
  • 관리자 마감 위험 판정(JdbcAdminClubOperationsAdapter의 FEEDBACK_DOCUMENT_MARKERS)은 두 marker를 모두 유효하게 봅니다.
  • 외부 JSON import는 v1과 v2를 모두 받습니다. In-app AI 생성과 그 validator는 v1만 만들고 검사합니다.
  • 작성 형식은 session-import-generator.md를 따릅니다.

/app/feedback/:sessionId/print route와 print helper는 남아 있지만 front/shared/config/readmates-feature-flags.ts의 feedbackDocumentPdfDownloadsEnabled가 false라 사용자에게 PDF로 저장이 보이지 않습니다. 다시 켤 때는 archive, my page, feedback route, E2E print smoke를 함께 확인합니다.

열람 경계는 다음과 같습니다.

  • 호스트는 같은 club의 세션 피드백 문서 상태와 본문을 관리할 수 있습니다.
  • active 정식 멤버는 같은 club의 피드백 문서를 읽을 수 있습니다.
  • 익명 GUEST와 둘러보기 멤버 VIEWER는 피드백 문서를 읽을 수 없습니다.
  • 문서가 없거나 권한이 없으면 UI는 locked 또는 unavailable state를 보여야 합니다.

세션 기록 JSON 가져오기

호스트는 앱 밖에서 정리한 세션 기록 JSON을 /app/host/sessions/:sessionId/edit의 기록 작업대에서 초안 만들기 → 외부 JSON으로 불러올 수 있습니다. 이 기능은 production 앱에서 AI API를 호출하지 않습니다. 앱은 최종 JSON만 preview/commit API로 전달하고, commit 뒤에는 공통 작업 중 초안에서 직접 작성·AI 결과와 같은 방식으로 검토합니다.

External transcript/AI workflow
  |
  | readmates-session-import:v1 JSON
  v
Host editor records workspace preview
  |
  | POST /api/host/sessions/{sessionId}/session-import/preview
  v
Spring validation
  |
  | session metadata, record visibility, attendee author names, feedback parser
  v
Import into shared draft
  |
  | POST /api/host/sessions/{sessionId}/session-import/commit
  v
Replace shared staged draft; live record remains unchanged until separate session-record apply

Commit은 활성 호스트만 사용할 수 있고, HOST_ONLY 공개 범위에서는 저장을 거절합니다. JSON의 회차 번호, 책 제목, 모임 날짜는 현재 편집 중인 세션과 일치해야 하며, 하이라이트와 한줄평의 authorName은 해당 회차의 활성 참석자 이름과 매칭되어야 합니다. Commit은 기존 live 공개 요약, 하이라이트, 한줄평, 피드백 문서를 직접 바꾸지 않고 공통 staged draft를 교체합니다. 이후 session-record apply가 expected draft/live revision과 draft hash를 검증해 live content와 immutable revision을 갱신하고, public/notes cache invalidation을 best-effort로 실행합니다. 파일 형식과 운영 검토 체크는 session-import-generator.md를 기준으로 합니다.

AI-assisted 콘텐츠 운영

ReadMates 호스트 세션 편집기는 세션 기록을 채우는 두 가지 입력을 같은 validation과 staged-draft 저장 경계(SaveValidatedSessionRecordDraftUseCase.saveValidated)로 흘려 보냅니다. 외부 JSON 또는 AI commit은 live 기록을 직접 바꾸지 않고 공통 session_record_drafts를 교체하며, 별도 session-record apply가 live 콘텐츠와 immutable revision을 갱신합니다.

모드 입력 LLM 호출 위치 운영 게이트
외부 정리된 산출물 호스트가 앱 밖에서 정리한 readmates-session-import:v1 JSON 앱 외부 항상 사용 가능
In-app AI 생성 호스트가 업로드한 UTF-8/BOM TXT(≤ 1 MiB, ≤ 3시간) + 모델 선택 서버 측 Spring AI provider adapter kill switch + provider allowlist + provider API key + Google paid-tier 확인

외부 JSON 흐름은 세션 기록 JSON 가져오기 섹션에서 설명합니다. 현재 provider, 호출·비용·복구, trace/privacy 구조는 Spring AI 2 provider architecture, 운영 절차는 AI 세션 생성 runbook을 기준으로 합니다.

In-app AI 세션 생성 컴포넌트

In-app AI 생성은 com.readmates.aigen feature 모듈에 응집됩니다. 다른 feature module과 같은 hexagonal 경계 (adapter.in.* → application.port.in → application.service → application.port.out → adapter.out.*)를 따르며, 외부 도메인(session, sessionimport, notification)과는 commit/notification 경계에서만 만납니다.

Browser (host editor AI 모드)
  | multipart TXT + server-provided model ID
  v
Cloudflare Pages BFF (forward, multipart preserved)
  v
AiGenerationController
  v
TranscriptPreflightService
  |-- host/session authorization
  |-- parse + exact ACTIVE membership binding
  |-- canonical render + local capability/input budget guard
  |-- reject: typed 422/503, no job/Redis/Kafka/provider/cost side effect
  v
AiGenerationOrchestrator
  |-- Redis metadata hash + transcript/turns payload
  `-- Kafka AiGenerationJobMessage { jobId, sessionId, clubId, hostUserId, provider, model, kind }
          |
          v
AiGenerationWorker -> GroundedGenerationExecutor / GroundedProviderCallPolicy
  |-- ResilientProviderCallGate (circuit + provider semaphore)
  |-- Redis atomic slot + worst-case cost reservation
  |-- GroundedProviderCallCoordinator
  `-- SpringAiWholeTranscriptGroundedGenerator -> ChatClient -> provider
  v
GroundingValidator -> server-owned result/evidence projection -> Redis
  |
  v
Host review ledger (four sections + revision-scoped evidence)
  |
  v
AiGenerationCommitService
  |-- Redis revision CAS + bounded COMMITTING lease
  `-- one MySQL transaction
       |-- ACTIVE membership revalidation + participant upsert
       |-- validated snapshot -> session_record_drafts (AI_GENERATED)
       `-- content-free job/revision + draft/base revision receipt
  v
COMMITTED -> four-payload cleanup; live record changes only on later apply
  • Feature module 위치: server/src/main/kotlin/com/readmates/aigen/. 도메인 model, port, service, controller, Redis/JDBC adapter, Kafka adapter, LLM adapter가 한 패키지 트리 안에 있습니다.
  • Provider adapter: grounded-only WholeTranscriptGroundedGenerator 하나의 port를 provider별 SpringAiWholeTranscriptGroundedGenerator bean으로 구성합니다. OpenAI, Claude, Gemini는 같은 renderer/schema/codec을 사용하며 application이 retry/fallback/correction/repair를 소유합니다. Spring AI는 한 번의 non-streaming structured-output 호출과 응답 변환만 수행하고 raw error는 SpringAiErrorMapper가 content-free typed error로 바꿉니다. 직접 SDK 경로와 legacy pipeline/runtime selector는 없습니다.
  • Model trust gate: pricing catalog과 grounded capability catalog은 별도입니다. GroundedInputBudgetGuard는 provider call 전에 실제 renderer request와 16,384 output reserve, safety margin, context/output capability를 검증합니다. Capability 불명은 503, request budget 초과는 422이며 chunking하지 않습니다. Browser model list도 서버 catalog에서 받습니다.
  • Kafka topic: readmates.aigen.jobs.v1 message는 AiGenerationJobMessage{jobId, sessionId, clubId, hostUserId, provider, model, kind}의 routing metadata만 저장합니다. Transcript, turns, member/display name, prompt/instructions, result, evidence/excerpt는 Kafka와 notification payload에 들어가지 않습니다.
  • Redis 키:
    • aigen:job:<jobId> (Hash, TTL 6h) — status/stage/revision, provider/model, counters, safe grounding status, commit lease, cleanupPending 같은 content-free metadata.
    • aigen:job:<jobId>:transcript (String, TTL 6h) — normalized raw transcript.
    • aigen:job:<jobId>:turns (String, TTL 6h) — membership에 bind된 parsed turn/source context.
    • aigen:job:<jobId>:result (String, TTL 6h) — validated SessionImportV1Snapshot.
    • aigen:job:<jobId>:evidence (String, TTL 6h) — revision-scoped target/turn mapping과 서버가 만든 excerpt.
    • aigen:club:<clubId>:monthly_cost_usd (String, sliding TTL 31d) — 클럽 누적 비용 BigDecimal USD.
    • aigen:host:<userId>:daily (String, sliding TTL 24h) — 호스트 일일 provider admission 횟수.
    • aigen:host:<userId>:minute (String, TTL 60s) — AI endpoint 전용 분당 admission 횟수.
    • aigen:job:<jobId>:provider-attempts (Hash, TTL 6h) — attempt ID/ordinal/provider/model/mode/state/reserved cost/cost basis/safe error/timestamp의 content-free ledger.
    • aigen:club:<clubId>:provider_admission (String, TTL 5m) — provider reservation owner token. 현재 single-node Redis에서 job/admission/monthly-cost/ledger를 한 Lua operation으로 묶습니다. Redis Cluster 호환을 주장하지 않습니다.
  • Job state machine: 정상 commit은 PENDING -> RUNNING -> SUCCEEDED -> COMMITTING -> COMMITTED입니다. Redis revision CAS가 worker save/regeneration/commit 경합을 막습니다. Receipt 없는 DB 실패 또는 만료 COMMITTING lease는 COMMIT_RETRY로 복구하고, receipt가 있으면 staged-draft write 없이 COMMITTED로 수렴합니다. Commit/cancel은 네 payload 정리를 즉시 시도하고 terminal hash만 TTL까지 남깁니다. DB commit 후 cleanup 실패는 COMMITTED + cleanupPending이며 draft write를 반복하지 않고 payload TTL을 최종 backstop으로 사용합니다.
  • LLM call cap/cost: permit -> atomic reservation -> exactly one HTTP -> ACTUAL 또는 ESTIMATED_UNKNOWN 순서를 지킵니다. Primary, retry, fallback, schema correction, section repair, regeneration은 같은 최대 3회와 cost cap을 사용합니다. 응답 유실/timeout/crash는 예상 비용을 유지하고, 확실한 pre-transport rejection만 slot/cost를 해제할 수 있습니다. 내부 token은 non-cached input/cache-write/cache-read/output 4채널이며 공개 REST는 기존 input/cachedInput/output 3필드입니다.
  • MySQL 테이블 (Flyway V30/V31/V34/V37–V46; 아래 목록은 AI와 기록 workflow 소유 테이블):
    • ai_generation_audit_log — 기존 business-audit identity와 provider/model/status/token/cost/latency를 보존하며 V38이 trace ID, attempt ordinal, call mode, cost basis, cache-write input token을 additive하게 추가합니다. Transcript, name, prompt, result, evidence, excerpt, raw provider error 컬럼은 없습니다.
    • ai_generation_commit_receipts — unique job_id + revision, session/club, draft/base live revision, request hash, committed time을 저장하는 content-free cross-store recovery source of truth입니다. Participant upsert, staged-draft save, receipt insert는 하나의 MySQL transaction입니다.
    • ai_generation_club_defaults — 클럽별 default provider/model. clubs(id) FK.
    • ai_generation_admin_action_audit — platform admin AI Ops action ledger. job_id, club_id, session_id, admin_user_id, admin_role, action/result, 이전/다음 상태, safe error code만 저장합니다.
    • session_record_drafts — JSON import, AI commit, manual edit, revision restore가 공유하는 검토 완료 snapshot과 draft/base live revision을 저장합니다. AI raw transcript, parsed turns, evidence, provider response는 포함하지 않습니다.
    • session_record_revisions / session_record_apply_receipts — apply된 immutable snapshot history와 idempotent apply request를 결속합니다. Apply가 완료되어야 live 공개 요약·하이라이트·한줄평·피드백 문서가 바뀝니다.
  • Frontend 모듈: front/features/host/aigen/ 안의 API/query/model/route/UI 경계를 유지합니다. Grounded draft는 revision을 포함한 local recovery envelope로만 저장하고 evidence/transcript를 localStorage에 넣지 않습니다. Review ledger의 네 section이 모두 AI_GROUNDED_REVIEWED 또는 편집 후 USER_EDITED_CONFIRMED일 때만 expected revision/result와 함께 commit합니다. Evidence 확장은 현재 revision이 참조한 단일 turn만 허용하며 transcript search/download API는 없습니다.
  • Trace/privacy: Spring MVC -> Kafka producer -> consumer/worker -> application provider observation -> Spring AI/provider span을 W3C context로 연결하고 OTLP로 internal Tempo에 비동기 export합니다. AI span/log/metric/baggage에는 prompt, completion, transcript, evidence, raw error, user/session/club identity를 넣지 않습니다. requestId는 별도 lookup ID입니다.
  • 운영 표면: 기존 AI meter에 physical call/cost basis/gate rejection/circuit/exporter delivery 지표가 추가됩니다. Prometheus exemplar storage, Grafana Tempo datasource, seven-day Tempo retention을 사용합니다. 로컬 포트는 loopback, OCI Tempo/OTLP는 internal network only입니다.
  • 운영·권한 경계: readmates.aigen.enabled와 enabled-providers가 실행 경계입니다. Provider key/live billable smoke/production deploy는 별도 승인 대상입니다. Platform admin은 content-free metadata만 보고 조작합니다. 상세 설정, rollback, 잔여 위험은 living architecture와 runbook을 따릅니다.