이 문서의 결정은 ADR로 이관되었습니다. 새 결정은
docs/development/adr/에 ADR로 추가합니다. 인덱스: docs/development/adr/README.md
반복해서 참고하는 기술 선택과 배경을 모읍니다. 구현 경계는 architecture.md, 로컬 실행과 운영 변수는 local-setup.md와 ../deploy/README.md가 기준입니다.
- 현재 코드, 테스트, 배포 문서와 맞는 결정만 적습니다. 과거 계획은 맥락일 뿐 기준이 아닙니다.
- 각 결정은 결정, 이유, trade-off를 함께 남깁니다. 결정을 바꾸면 관련 문서와 검증 명령도 고칩니다.
- 새 결정은 이 문서가 아니라 ADR로 추가합니다.
- 운영 secret, 실제 멤버 데이터, 배포 상태, DB dump, 로컬 절대 경로, OCI OCID는 예시에 넣지 않습니다.
- Cloudflare, OCI, Google, GitHub 같은 외부 서비스의 한도·가격·API 동작은 바뀔 수 있으니 운영 판단 전에 공식 문서로 다시 확인합니다.
아래 결정은 개별 ADR로 옮겼습니다. 컨텍스트, 근거, 대안, 결과는 각 ADR에 있습니다. 빠진 번호(0011, 0012, 0017~)는 ADR 인덱스의 후보입니다.
| ADR | 제목 | 상태 |
|---|---|---|
| ADR-0001 | Cloudflare Pages Functions BFF | Accepted |
| ADR-0002 | Server clean architecture + ArchUnit | Accepted |
| ADR-0003 | Frontend route-first architecture | Accepted |
| ADR-0004 | Transactional outbox + Kafka relay | Accepted |
| ADR-0005 | BFF shared secret + rotation | Accepted |
| ADR-0006 | Server-side hashed session cookie | Accepted |
| ADR-0007 | MySQL + Flyway | Accepted |
| ADR-0008 | Multi-club domain with host resolution | Accepted |
| ADR-0009 | Frontend-backend contract via Zod | Accepted |
| ADR-0010 | Public repo safety automation | Accepted |
| ADR-0013 | BFF host header policy | Accepted |
| ADR-0014 | BFF secret rotation lifecycle | Accepted |
| ADR-0015 | Notification outbox dedupe policy | Accepted |
| ADR-0016 | Deploy ledger event schema | Accepted |
| ADR-0072 | Feedback document template v2 | Accepted |
아직 별도 ADR이 없는 결정입니다. 나중에 ADR로 옮길 수 있습니다.
결정: Spring은 /api/** 요청에서 X-Readmates-Bff-Secret을 검증하고, mutating method인 POST, PUT, PATCH, DELETE에는 허용된 Origin 또는 Referer도 요구합니다.
이유: BFF를 통과한 요청과 브라우저에서 발생한 state-changing 요청을 함께 확인하기 위해서입니다. BFF secret은 Cloudflare Pages Functions와 Spring runtime 설정에만 있고, VITE_ 또는 NEXT_PUBLIC_ 변수로 만들지 않습니다.
Trade-off: preview, local, production 환경마다 secret과 allowed origin 설정을 분리해야 합니다. 설정 누락은 API 실패로 드러나므로 배포 체크리스트와 E2E가 함께 필요합니다.
관련 문서와 검증: architecture.md, ../deploy/README.md, pnpm --dir front test:e2e
결정: Redis는 기본 비활성화이며, 켜더라도 rate limit counter, auth session metadata cache, public/notes read-through cache, read-cache invalidation, AI generation job handoff/cost counter처럼 짧은 TTL의 보조 상태에만 사용합니다.
이유: Redis 장애나 cache 유실이 서비스의 핵심 데이터 손실로 이어지지 않게 하기 위해서입니다. Cache decode 실패 또는 Redis 오류가 발생하면 best-effort 정리 후 MySQL fallback을 사용합니다. AI generation job은 Redis가 transcript/turn/result/evidence handoff와 revision/CAS를 보관하는 workflow state라 Redis 장애 시 commit 전 생성 job은 provider 호출 전에 실패하거나 만료될 수 있습니다. 호스트가 commit한 검토 완료 snapshot과 기존 session/publication/member 데이터는 MySQL에 남아야 합니다.
Trade-off: Redis를 켜도 모든 조회가 항상 빨라지는 구조는 아닙니다. Source of truth를 MySQL에 유지하므로 invalidation과 fallback 경로를 함께 테스트해야 합니다. AI generation의 검토 전 콘텐츠는 job-store adapter의 :transcript, :turns, :result, :evidence 값에 6시간 TTL로 두고 commit/cancel에서 즉시 정리를 시도합니다. Commit cleanup 실패는 cleanupPending으로 재시도하며 TTL이 최종 backstop입니다. Commit한 검토 완료 snapshot만 공통 staged draft에 내구 저장하며 raw transcript/turn/evidence는 저장하지 않습니다. Metadata hash와 Redis key/metric label에는 transcript, 표시 이름, raw session token, 초대 token 원문, BFF secret, OAuth code, private feedback document body, 이메일을 넣지 않습니다.
관련 문서와 검증: architecture.md, test-guide.md, targeted Redis adapter tests
결정: sessions.state는 DRAFT, OPEN, CLOSED, PUBLISHED 운영 단계를 구분합니다. 공개 범위는 두 축으로 나눕니다. 앱 열람은 sessions.access_scope(HOST_ONLY | GUEST_READABLE), 공개 사이트 배치는 public_session_publications.site_visibility(HIDDEN | PUBLIC_RECORD)가 기준입니다. 기존 sessions.visibility와 is_public은 rolling deploy 호환을 위한 dual-write 컬럼입니다.
이유: 예정 세션, 현재 세션, 닫힌 기록, 발행된 공개 기록이 동시에 존재합니다. 상태와 공개 범위를 나눠야 여러 예정 세션을 준비하면서도 클럽당 OPEN 세션을 하나로 유지하고, 닫힌 기록을 검토한 뒤 발행할 수 있습니다. 게스트 앱 열람과 공개 사이트 노출도 서로 독립적으로 정할 수 있습니다.
Trade-off: route별 조회 조건이 복잡해집니다. 공개 사이트, 게스트 앱, member archive, notes, 예정 세션이 서로 다른 상태·범위 조합을 쓰므로 서버 contract와 frontend 모델을 함께 맞춰야 합니다.
관련 문서와 검증: architecture.md, pnpm --dir front test, ./scripts/server-ci-check.sh, ./server/gradlew -p server integrationTest
결정: 게스트, 둘러보기 멤버, 정식 멤버, 호스트의 route/API 권한을 나누고, 피드백 문서는 같은 클럽의 active 정식 멤버와 호스트만 읽게 합니다.
이유: 초대 없이 로그인한 사용자도 일부 기록은 읽을 수 있지만, 피드백 문서는 공개 기록보다 민감합니다. 그래서 viewer, suspended, inactive 상태에는 열지 않습니다.
Trade-off: 같은 화면에서도 read-only, locked, unavailable 상태를 구분해야 합니다. API 권한과 UI guard가 엇갈리면 버튼은 보이는데 서버가 거절하므로 route loader와 server test를 함께 관리합니다.
관련 문서와 검증: architecture.md, architecture.md, pnpm --dir front test:e2e
결정: Prometheus metric tag 값은 NotificationEventType처럼 enum 또는 pending/failed 같은 고정 문자열만 사용합니다. club_id, user_id, membership_id, recipient_email, event_id, delivery_id, session_id 같은 row-level identifier는 tag로 넣지 않습니다.
이유: Prometheus는 tag 값 조합마다 time series를 만듭니다. row-level ID를 tag로 쓰면 series 수가 끝없이 늘어 storage를 소진하고 scrape/query가 느려집니다.
Trade-off: metric만으로 특정 사용자의 알림 내역은 볼 수 없습니다. row-level 조회는 notification_deliveries audit table이나 admin 알림 ledger를 씁니다.
관련 문서와 검증: server/src/main/kotlin/com/readmates/notification/application/service/ReadmatesOperationalMetrics.kt KDoc 참고
현재 상태: web, scheduler, Kafka listener가 한 Spring Boot process에서 함께 뜹니다. 그래서 process가 죽으면 web과 알림 발송이 함께 멈춥니다. 2-service 분리 운영은 아직 하지 않으며 ADR-0017 후보로 남아 있습니다.
준비된 것: readmates.notifications.worker.enabled(READMATES_NOTIFICATIONS_WORKER_ENABLED, 기본 true)가 false면 notificationWorkerRuntime bean이 등록되지 않아 relay/backlog scheduler가 뜨지 않습니다. Kafka listener와 relay는 별도로 readmates.notifications.enabled와 readmates.notifications.kafka.enabled로 켭니다.
방향과 trade-off: 같은 jar를 web 인스턴스와 worker 인스턴스로 나눠 띄우면 추가 인프라 비용 없이 장애를 분리할 수 있습니다. 대신 classpath 분리는 없고 bean 등록만 조건부로 건너뜁니다. Gradle multi-module 분리는 인스턴스를 2개 이상 운영할 때 다시 봅니다.
관련 문서와 검증: server/src/main/kotlin/com/readmates/notification/application/config/NotificationWorkerConfiguration.kt, NotificationEventRelaySchedulerTest
결정: RateLimitFilter의 IP 해시는 ClientIpHashing.hashClientIp를 통해 ${READMATES_IP_HASH_BASE_SECRET}::${year}-W${week} 형식의 salt로 생성합니다. salt는 ISO 주차가 바뀔 때마다 자동으로 변경되며, base secret은 환경 변수 READMATES_IP_HASH_BASE_SECRET으로 주입합니다. production-like 환경(spring.profiles.active가 비어 있거나 production을 포함)에서 base secret이 비어 있으면 startup은 실패합니다. local/test 같은 비운영 환경에서도 빈 값은 readmates.security.ip-hash.allow-empty-secret=true를 명시한 경우에만 허용되며, 이 경우 WARN을 출력합니다.
이유: salt가 정적이면 같은 IP의 요청 패턴이 장기간 누적되어 교차 분석 가능성이 생깁니다. 주 단위 salt 회전으로 cross-week linking을 차단해 IP 해시 공간이 week 경계에서 분리됩니다.
Trade-off: token bucket이 주 경계에서 reset되는 의도된 부작용이 있습니다. 율 제한은 단기(분~시간 단위) 정책이므로 실질적인 영향은 없습니다. 토큰·세션 ID 해시에는 여전히 stableHash(salt 없음)를 사용해 주 경계 영향을 받지 않습니다.
관련 문서와 검증: ./server/gradlew -p server unitTest --tests '*ClientIpHashing*'
- 서비스가 소유합니다. 업무 트랜잭션 경계는 application service가 가집니다. controller는 HTTP를 해석해 use case를 부르고, persistence adapter는 SQL과 mapping만 합니다. 서비스가 쓰기 port를 여럿 조합하면 그 메서드가 트랜잭션을 가져서 상태 변경, cache invalidation, 알림 event 기록이 한 경계를 공유합니다.
- Adapter
@Transactional은 예외입니다. scheduler, Kafka listener처럼 서비스 트랜잭션을 거치지 않는 경로에서 부를 때만 둡니다. 서비스와 adapter 둘 다 붙어 있으면 서비스 경계가 기준이고, 테스트로 동작을 고정한 뒤 adapter 쪽을 좁게 정리합니다. - Isolation은 필요한 곳에만 지정합니다. claim이나 read-modify-write처럼 기본값보다 강한 보장이 필요한 경우만입니다(예: 세션/로그인 복원, 알림 delivery claim). 새로 지정하면 서비스 코드나 결정 기록에 이유를 남깁니다.
검증은 ./scripts/server-ci-check.sh(detekt, unitTest, architectureTest)와 필요 시 ./server/gradlew -p server integrationTest로 합니다.