Legal Model — TermsDocument · PrivacyPolicy · Consent
SPEC #004 정합 · 버전 이력 · 예약 게시(effectiveAt) · status 는 SPEC #033 확장.
Overview
PIPA 준수 — 이용약관과 개인정보처리방침은 별도 동의 필수 → entity / table 분리. 본사 / 매장 가입 시 양쪽에 대해 동의 row 자동 생성.
TermsDocument Entity
| 컬럼 | 타입 | 비고 |
|---|---|---|
| id | UUID | BaseEntity |
| version | String | 예 “v1.0”. (terms/privacy 각 테이블) version 유일 (uq_*_version) |
| content | TEXT | markdown 원본. DB 컬럼 직접 (Azure Blob 의존 X). FE 가 react-markdown + rehype-sanitize 로 렌더 |
| publishedAt | Instant | 게시(저장) 시각 |
| effectiveAt | Instant (TIMESTAMPTZ NOT NULL) | 발효 시각 (SPEC #033). 이 시각 이후부터 “현재 유효본” 후보. 기존 row 는 published_at 으로 backfill |
| status | String(16) | SCHEDULED·ACTIVE·SUPERSEDED. 게시 시점 기준 저장값(stale 가능) — 진실은 effectiveAt |
| isActive | Boolean | 레거시 컬럼 (점진 마이그레이션 — 신규 로직은 status/effectiveAt 사용, 완전 제거는 follow-up) |
| createdAt/updatedAt/deletedAt/createdBy/updatedBy | BaseEntity |
예약 게시(effectiveAt) 도입(SPEC #033)으로 idx_*_active partial-unique(활성 단일)는 드롭 — 예약본과 충돌하기 때문. version 유일성(uq_*_version)은 유지. 발효 정렬용 인덱스 추가:
DROP INDEX idx_terms_document_active;
CREATE INDEX idx_terms_document_effective_at ON terms_document (effective_at DESC);PrivacyPolicy Entity
동일 schema. table 분리. effectiveAt·status·인덱스 변경 동일.
”현재 유효본” 판정 (스케줄러 없음)
/active 가 반환하는 “현재 유효본” = effective_at <= now() 중 effective_at DESC 첫 건(읽기 시점 계산). cron/스케줄러를 두지 않아(1인 운영·Render free-tier sleep 회피) 예약본은 발효 시각이 지나면 추가 작업 없이 자동으로 유효본이 된다.
status는 게시 시점에 기록한 보조 표시값으로, 예약본이 발효 시각을 지나도SCHEDULED로 남을 수 있다(stale). 따라서 유효본 판정·온보딩 버전 검증은 항상 effectiveAt 기준이고,status는 이력 화면 표시·필터용이다./active응답의status는 호출 시 항상ACTIVE로 보정해 내려준다.
게시 흐름
POST /api/v1/admin/terms { version, content, effectiveAt? } (OPERATOR):
effectiveAt미지정(null)·현재(±60s) → 즉시 발효:status=ACTIVE, 직전 유효본SUPERSEDED.effectiveAt미래 → 예약:status=SCHEDULED(발효 시각 도달 시 읽기 계산으로 자동 유효본).effectiveAt과거(>60s) → 400LEGAL_DOC_INVALID_EFFECTIVE_AT.- 동일 version 중복 → 409 (
uq_*_version+ 사전검증). 동일 effectiveAt 두 건은 version 다르면 허용(정렬로 판정).
별도 publish step 없음 (1인 운영 환경에서 과함). 예약본 수정은 비목표 — 취소 후 재게시로 대체한다.
예약 게시 취소 (SPEC #038)
미발효 예약본(status=SCHEDULED 이며 effective_at > now)은 취소(삭제)할 수 있다 — DELETE /api/v1/admin/{terms,privacy-policy}/{id} (OPERATOR, 204).
- hard delete — SCHEDULED row 자체를 제거한다(soft-delete 안 함). 미발효 예약은 영구 보존 대상이 아니며, 취소 사실은 감사 로그(
TERMS_SCHEDULE_CANCELED·PRIVACY_POLICY_SCHEDULE_CANCELED)가 기록한다. - 삭제 가능 조건 = SCHEDULED 이며 미발효(
effective_at > now). status 는 stale 가능(스케줄러 없음)하므로 effectiveAt 조건도 함께 원자적으로 검증해 발효 직전 경계 race 를 차단한다. 하나라도 불만족 → 409LEGAL_DOC_NOT_SCHEDULED. 미존재 → 404LEGAL_DOC_NOT_FOUND. - 발효본(ACTIVE)·대체본(SUPERSEDED)은 취소 불가 — 과거 버전 영구 보존 불변식 유지.
버전 이력
과거 버전은 같은 테이블에 영구 보존(soft-delete 안 함) — 별도 history 테이블 없음. 이력 = effectiveAt DESC, id DESC 정렬 read-only 목록(GET /api/v1/admin/{terms,privacy-policy}, 본문 제외). 단건 본문은 by-id 조회(GET .../{id})로 획득. OPERATOR-only.
Consent — 4종 entity
@MappedSuperclass
abstract class AbstractConsent : BaseEntity() {
abstract var documentVersion: String
abstract var agreedAt: Instant
abstract var agreedIp: String?
abstract var agreedUserAgent: String?
abstract var signerName: String?
}| Entity | Table | FK | unique |
|---|---|---|---|
| HqConsent | hq_consent | hqId → hq.id | (hq_id, document_version) |
| HqPrivacyConsent | hq_privacy_consent | hqId | (hq_id, document_version) |
| StoreConsent | store_consent | storeId → store.id (INDEPENDENT만 INSERT) | (store_id, document_version) |
| StorePrivacyConsent | store_privacy_consent | storeId | (store_id, document_version) |
동의 페이로드 (가입 API)
클라이언트가 보내는 값:
{ "termsVersion": "v1.0", "privacyPolicyVersion": "v1.0" }자동 캡처 (backend):
agreedIp=HttpServletRequest.remoteAddragreedUserAgent=User-Agent헤더signerName= 호출한 OPERATOR.name (또는 가입 시 입력 manager.name)agreedAt=Instant.now()
검증:
termsVersion이 실제 활성TermsDocument와 일치 → 불일치 시 400privacyPolicyVersion도 동일
버전 변경 시 기존 동의
- 기존 동의는 그대로 유효 —
HqConsent v1.0은 v1.1 게시 후에도 history 유지. - 새 버전 게시 후 사용자에게 “재동의 필요” flag 전달:
/auth/me응답에termsRequiresAgreement: boolean,privacyRequiresAgreement: boolean- SPEC #005 §2-5 에서 도입
- 계산: 현재 유효본 version 과 가장 최근 동의 version 비교 → 불일치 시 true
재동의 흐름 (SPEC #040)
위 termsRequiresAgreement·privacyRequiresAgreement 플래그를 해소하는 사용자 흐름이다. 실효 대상 = 실 HQ_MANAGER(/admin 셸). OPERATOR 는 동의 대상이 아니라 플래그가 항상 false 다.
Consent row 생성 경로 (2개)
같은 consent 테이블에 row 가 INSERT 되는 진입점은 두 곳이다:
- 온보딩 동의 (SPEC #005) — 본사/매장 가입 시 가입 페이로드(
termsVersion·privacyPolicyVersion)로 4종 consent 를 단일 트랜잭션 INSERT. - 재동의 (SPEC #040) — 약관 개정 후 기존 HQ_MANAGER 본인이 현재 유효본에 재동의.
POST /api/v1/legal/{terms,privacy}/agree { version }→ role 별(HQ/STORE) 적절 consent 테이블에 INSERT. 온보딩과 동일한 자동 캡처(ip·userAgent·signerName·agreedAt).
두 경로 모두 멱등 — (hq_id, document_version) UNIQUE + 사전검증(findByHqIdAndDocumentVersion)으로 중복 INSERT 를 차단(존재 시 204 no-op).
강제 모델 (유예 없음)
재동의 필요 시 화면을 벗어날 수 없다(passwordMustChange 미러). 유예 기간·부분 동의 저장 없음 → 신규 컬럼 불필요(마이그레이션 0).
FE 셸 gating (/admin 셸 layout)
apps/admin/src/app/admin/layout.tsx(HQShell) 가 me 로드 후 server-side 로 gating한다. 우선순위:
passwordMustChange=true→/onboarding/change-password(먼저)termsRequiresAgreement || privacyRequiresAgreement→/onboarding/reagree(그다음)
재동의 라우트는 change-password 와 동일하게 presence-only onboarding/layout 아래 두어 /admin 셸 가드(me 재호출)를 거치지 않으므로 redirect 루프가 없다. 재동의 화면(onboarding/reagree)은 자체적으로 me 를 refresh-aware 로 로드해 어떤 문서가 필요한지(플래그 true 인 것만)를 판별하고, 필요한 문서의 현재 유효본을 fetch 해 본문(MarkdownView, #033)·“동의합니다” 체크박스를 렌더한다. 둘 다 필요하면 한 화면에서 동시 동의(D7). 제출 → 필요한 항목만 agreeTerms/agreePrivacy 호출, 둘 다 204 완료 시 /admin 셸로 복귀하며 me 무효화(router.refresh)로 플래그가 false 로 재평가된다.
시각 시안:
design/screens/ops-reagree.jsx(§OpsReagree AuthCard + §ReagreeItem) 정합 — 항목별 보더 카드(체크 시 success 보더 + “동의함” 배지)·버전 배지(StatusPill info)·MarkdownView 본문 스크롤(max-h-[220px])·“동의합니다” 체크박스.onboarding/layoutfooter 는 비밀번호 변경·재동의 공유라 “필수 조치를 완료하기 전까지 닫을 수 없습니다”로 일반화(시안 §AuthShell footer).
에러 처리
- 400
INVALID_LEGAL_VERSION— 동의 사이 약관이 새로 게시됨(새로고침 후 재시도). LEGAL_AGREEMENT_NOT_APPLICABLE— 동의 대상 아님(셸로 복귀).- 403
IMPERSONATION_FORBIDDEN_ACTION— 임퍼소네이션 세션 차단(D6, changePassword 정책 미러).
API Endpoints
| Method | Path | Auth | Response |
|---|---|---|---|
| POST | /api/v1/admin/terms | OPERATOR | 200 — LegalDocumentResponse (effectiveAt·status 포함) |
| POST | /api/v1/admin/privacy-policy | OPERATOR | 200 |
| GET | /api/v1/terms/active | public | 200 현재 유효본 { version, content, publishedAt, effectiveAt, status } |
| GET | /api/v1/privacy-policy/active | public | 200 { ... } |
| GET | /api/v1/admin/terms | OPERATOR | 200 LegalDocumentHistoryResponse { items[] } (본문 제외) |
| GET | /api/v1/admin/privacy-policy | OPERATOR | 200 동상 |
| GET | /api/v1/admin/terms/{id} | OPERATOR | 200 LegalDocumentResponse (본문 포함) · 404 LEGAL_DOC_NOT_FOUND |
| GET | /api/v1/admin/privacy-policy/{id} | OPERATOR | 200 동상 |
| POST | /api/v1/legal/terms/agree | 인증(본인) | 204 (멱등) — LegalAgreeRequest { version }. 활성판 불일치 400 INVALID_LEGAL_VERSION·임퍼소네이션 403 (SPEC #040) |
| POST | /api/v1/legal/privacy/agree | 인증(본인) | 204 동상 (SPEC #040) |
Constraints
- “현재 유효본” 시스템 전체 1개 — 읽기 시점
effective_at <= now중 최신 (partial-unique 아님 · 예약본 공존 허용). - 과거 버전 영구 보관 (soft-delete 안 함).
- version 유일 (
uq_*_version). 동일 effectiveAt 두 건은 version 다르면 허용. - 동의 row 는 한 번 INSERT 후 수정 안 함 (history).
- 동일 version 중복 동의 차단 (복합 unique).
Decisions
- 약관 markdown 본문 DB 저장 vs Blob — DB 채택. 운영 단순, version 별 atomic.
- 두 약관 합치기 (한 entity) vs 분리 — 분리. PIPA 별도 동의 강제.
Roadmap
- ✅ 재동의 UI 흐름 (셸 gating + 강제 화면) — SPEC #040
- ✅ 재동의 후 consent INSERT API (
agreeTerms·agreePrivacy, 204 멱등) — SPEC #040 - STORE_MANAGER 재동의 UI — 점장 surface 부재, 후속 (BE 는 이미 role-일반 처리)
- 약관 개정 이메일/푸시 알림 — 외부 인프라
References
- SPEC #004 §2-1~§2-5
- SPEC #033 (effectiveAt·status·이력·예약 게시·마크다운)
- SPEC #038 (예약 게시 취소 · hard-delete ·
LEGAL_DOC_NOT_SCHEDULED) - SPEC #040 (재동의 흐름 ·
agreeTerms·agreePrivacy· 셸 gating · 강제 모델) - SPEC #005 §2-5 (
/auth/meflag · “현재 유효본” 기준) linkmusic-msa-space-was/.../TermsDocument.kt,PrivacyPolicy.ktlinkmusic-msa-space-was/.../HqConsent.kt,HqPrivacyConsent.kt,StoreConsent.kt,StorePrivacyConsent.kt- FE:
apps/admin/src/app/admin/layout.tsx(셸 gating) ·apps/admin/src/app/onboarding/reagree/(재동의 화면)