DomainLegal Model (약관 · 동의)

Legal Model — TermsDocument · PrivacyPolicy · Consent

SPEC #004 정합 · 버전 이력 · 예약 게시(effectiveAt) · status 는 SPEC #033 확장.

Overview

PIPA 준수 — 이용약관과 개인정보처리방침은 별도 동의 필수 → entity / table 분리. 본사 / 매장 가입 시 양쪽에 대해 동의 row 자동 생성.

TermsDocument Entity

컬럼타입비고
idUUIDBaseEntity
versionString예 “v1.0”. (terms/privacy 각 테이블) version 유일 (uq_*_version)
contentTEXTmarkdown 원본. DB 컬럼 직접 (Azure Blob 의존 X). FE 가 react-markdown + rehype-sanitize 로 렌더
publishedAtInstant게시(저장) 시각
effectiveAtInstant (TIMESTAMPTZ NOT NULL)발효 시각 (SPEC #033). 이 시각 이후부터 “현재 유효본” 후보. 기존 row 는 published_at 으로 backfill
statusString(16)SCHEDULED·ACTIVE·SUPERSEDED. 게시 시점 기준 저장값(stale 가능) — 진실은 effectiveAt
isActiveBoolean레거시 컬럼 (점진 마이그레이션 — 신규 로직은 status/effectiveAt 사용, 완전 제거는 follow-up)
createdAt/updatedAt/deletedAt/createdBy/updatedByBaseEntity

예약 게시(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):

  1. effectiveAt 미지정(null)·현재(±60s) → 즉시 발효: status=ACTIVE, 직전 유효본 SUPERSEDED.
  2. effectiveAt 미래 → 예약: status=SCHEDULED (발효 시각 도달 시 읽기 계산으로 자동 유효본).
  3. effectiveAt 과거(>60s) → 400 LEGAL_DOC_INVALID_EFFECTIVE_AT.
  4. 동일 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 를 차단한다. 하나라도 불만족 → 409 LEGAL_DOC_NOT_SCHEDULED. 미존재 → 404 LEGAL_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.

@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?
}
EntityTableFKunique
HqConsenthq_consenthqId → hq.id(hq_id, document_version)
HqPrivacyConsenthq_privacy_consenthqId(hq_id, document_version)
StoreConsentstore_consentstoreId → store.id (INDEPENDENT만 INSERT)(store_id, document_version)
StorePrivacyConsentstore_privacy_consentstoreId(store_id, document_version)

동의 페이로드 (가입 API)

클라이언트가 보내는 값:

{ "termsVersion": "v1.0", "privacyPolicyVersion": "v1.0" }

자동 캡처 (backend):

  • agreedIp = HttpServletRequest.remoteAddr
  • agreedUserAgent = User-Agent 헤더
  • signerName = 호출한 OPERATOR.name (또는 가입 시 입력 manager.name)
  • agreedAt = Instant.now()

검증:

  • termsVersion 이 실제 활성 TermsDocument 와 일치 → 불일치 시 400
  • privacyPolicyVersion 도 동일

버전 변경 시 기존 동의

  • 기존 동의는 그대로 유효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 가 INSERT 되는 진입점은 두 곳이다:

  1. 온보딩 동의 (SPEC #005) — 본사/매장 가입 시 가입 페이로드(termsVersion·privacyPolicyVersion)로 4종 consent 를 단일 트랜잭션 INSERT.
  2. 재동의 (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한다. 우선순위:

  1. passwordMustChange=true/onboarding/change-password (먼저)
  2. 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/layout footer 는 비밀번호 변경·재동의 공유라 “필수 조치를 완료하기 전까지 닫을 수 없습니다”로 일반화(시안 §AuthShell footer).

에러 처리

  • 400 INVALID_LEGAL_VERSION — 동의 사이 약관이 새로 게시됨(새로고침 후 재시도).
  • LEGAL_AGREEMENT_NOT_APPLICABLE — 동의 대상 아님(셸로 복귀).
  • 403 IMPERSONATION_FORBIDDEN_ACTION — 임퍼소네이션 세션 차단(D6, changePassword 정책 미러).

API Endpoints

MethodPathAuthResponse
POST/api/v1/admin/termsOPERATOR200 — LegalDocumentResponse (effectiveAt·status 포함)
POST/api/v1/admin/privacy-policyOPERATOR200
GET/api/v1/terms/activepublic200 현재 유효본 { version, content, publishedAt, effectiveAt, status }
GET/api/v1/privacy-policy/activepublic200 { ... }
GET/api/v1/admin/termsOPERATOR200 LegalDocumentHistoryResponse { items[] } (본문 제외)
GET/api/v1/admin/privacy-policyOPERATOR200 동상
GET/api/v1/admin/terms/{id}OPERATOR200 LegalDocumentResponse (본문 포함) · 404 LEGAL_DOC_NOT_FOUND
GET/api/v1/admin/privacy-policy/{id}OPERATOR200 동상
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/me flag · “현재 유효본” 기준)
  • linkmusic-msa-space-was/.../TermsDocument.kt, PrivacyPolicy.kt
  • linkmusic-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/ (재동의 화면)