Files
HeuJung eb7ff4ec6a fix(gdpr,hooks): 동의 출처 어휘 정정과 확장 문서 강제의 Codex 트리 이식
쿠키 동의 저장 요청이 서버만 기록하는 출처(가입 시 동의)를 지정할 수 있었다.
주석은 지정 불가라고 적혀 있었고 코드가 그것을 잃은 쪽이었다 — 그 값을 쓰는
곳은 회원가입 리스너뿐이고, 받는 경로는 비인증 방문자도 도달한다. 형제 값 둘이
이미 같은 이유로 막혀 있어 대칭을 회복한다.

확장 문서 동반 강제가 두 도구 트리 중 한쪽에만 있었다. 다른 쪽으로 작업하면
문서를 빠뜨려도 아무 경고가 없고, 그 결과는 이상 0건과 구분되지 않는다. 룰·
coverage·편집 시점 안내·세션 종료 검사·미채움 기준선을 이식하고, 두 사본이
같은 입력에 같은 판정을 내는지를 픽스처가 한 곳에서 단언하게 했다.
2026-09-01 08:20:36 +09:00

8.1 KiB

GDPR (일반 데이터 보호 규정) — 데이터 모델

모델·소유 테이블·마이그레이션·Enum · 진입점: AGENTS.md

모델

모델 테이블 fillable 관계 특성
GdprPolicyVersion gdpr_policy_versions 5 createdBy→User -
GdprUserConsent gdpr_user_consents 11 user→User -
GdprUserConsentHistory gdpr_user_consent_histories 9 user→User -

GdprUserConsent(mutable, "지금 동의 상태")와 GdprUserConsentHistory(immutable append-only, "동의 변경 이력")를 별도 모델·테이블로 분리한 것이 이 도메인의 핵심 결정입니다. 게이팅 판정(CookieConsentMiddleware)은 상태만 읽고, 감사 대응(Art.7(1))은 이력만 봅니다. 하나로 합쳤다면 상태를 UPDATE 할 때마다 과거 값을 별도 보존하는 로직을 매번 다시 구현해야 했을 것입니다. GdprPolicyVersion 은 정책 발행 이력이며 이 역시 immutable — 발행된 버전은 그 시점의 정책 내용을 그대로 유지해야 "그 버전에 동의했다"는 이력이 의미를 가집니다.

소유 테이블

테이블 모델
gdpr_policy_versions GdprPolicyVersion
gdpr_user_consent_histories GdprUserConsentHistory
gdpr_user_consents GdprUserConsent

3개 테이블이 전부입니다 — 쿠키 카테고리 정의(blocked_domains/cookie_categories)는 별도 테이블이 아니라 getSettingsSchema() 의 JSON 설정 값으로 저장됩니다(§settings.md). 별도 테이블로 만들지 않은 이유는 그 값이 "운영자가 조정하는 설정"이지 "사용자별로 쌓이는 데이터"가 아니기 때문입니다 — 전자는 설정 스키마, 후자만 전용 테이블을 둡니다.

마이그레이션

마이그레이션 4개.

파일 생성 테이블 변경 테이블 down()
2026_04_27_000001_create_gdpr_user_consents_table.php gdpr_user_consents gdpr_user_consents ✅
2026_04_27_000002_create_gdpr_user_consent_histories_table.php gdpr_user_consent_histories gdpr_user_consent_histories ✅
2026_05_12_000003_create_gdpr_policy_versions_table.php gdpr_policy_versions gdpr_policy_versions ✅
2026_07_14_000001_add_rejection_to_gdpr_user_consents.php - gdpr_user_consents ✅

add_rejection_to_gdpr_user_consents(2026-07-14)는 "동의 안 함"을 명시적으로 기록하기 위한 추가입니다 — 그전에는 동의 행이 없으면 "아직 응답 안 함"과 "거부함"을 구분할 수 없었습니다. ConsentAction::Rejected 케이스와 짝을 이루는 마이그레이션입니다.

Enum

Enum backing case 수 case
ConsentAction string 3 granted, revoked, rejected
ConsentSource string 6 banner, preference_center, register, mypage, mypage_renew_all, withdraw
CookieCategory string 4 cookie_necessary, cookie_functional, cookie_analytics, cookie_marketing
GdprPolicyChangeType string 3 material, non_material, initial

ConsentSource 는 자기 docblock에 "어휘를 이 enum 밖(서비스/리스너 리터럴)에 흩어 두면 화면 필터가 실제 기록 어휘의 부분집합이 되어 일부 행이 어떤 필터로도 도달하지 못한다"고 명시합니다 (#492 과거 결함). withdraw(회원탈퇴 시 일괄 철회, GdprConsentService::revokeAllOnWithdraw() / GdprUserConsentRepository::revokeAllForUser())가 정확히 이 결함군으로 한 번 더 발생했던 case입니다 — 두 지점 모두 enum이 아닌 'withdraw' 리터럴을 직접 기록해, 그렇게 기록된 행이 관리자 동의 이력 화면의 어떤 출처 필터로도 걸러지지 않고 라벨도 원시 문자열로 노출됐습니다. ConsentSourceVocabularyParityTest(기록 경로가 enum 을 참조하는지 검사)가 이미 있었는데도 놓친 이유는 두 가지입니다 — 검사 대상 파일 목록에 GdprUserConsentRepository.php 가 빠져 있었고, 정규식이 'source' =>/'last_source' => 형태만 잡아 updateConsent(..., 'withdraw') 같은 위치 인자 형태는 못 봤습니다. Withdraw case 추가 + 두 지점을 ConsentSource::Withdraw->value 참조로 교체 + 테스트의 스캔 대상 파일 목록에 Repository 추가로 정정했습니다. 새 기록 지점을 추가할 때 위치 인자로 리터럴을 넘기면 이 가드가 여전히 못 볼 수 있다는 점을 유의하세요 — 가능하면 'source' =>/'last_source' => 형태(배열 키)를 쓰거나 이 테스트의 정규식을 함께 넓힙니다.

전체 어휘(ConsentSource::allValues())와 공개 요청이 지정할 수 있는 부분집합 (ConsentSource::requestSelectableValues())은 다릅니다. register(회원가입 시 동의) · mypage_renew_all(정책 개정 후 일괄 재동의) · withdraw(회원탈퇴 시 일괄 철회)는 서버가 스스로 기록하는 경로이므로 StoreCookieConsentRequest 의 Rule::in 에서 제외됩니다 — 이 엔드포인트는 optional.sanctum 이라 비인증 방문자도 도달하므로, 공개 요청이 이 값을 실을 수 있으면 가입하지도 탈퇴하지도 재동의하지도 않은 사람의 이력이 그렇게 기록됩니다. 동의 이력은 출처가 존재 이유이고, 그렇게 기록되어도 오류도 로그도 남지 않습니다. 반대로 관리자 동의 이력 화면의 출처 필터(IndexConsentLogRequest)는 allValues() 를 씁니다: 기록된 어휘 전부가 필터로 도달 가능해야 하기 때문입니다. 새 case 를 추가할 때 어느 쪽에 속하는지 판단하고, 제외한다면 ConsentSourceVocabularyParityTest 에 제외 단언을 함께 남기세요 — 목록에서 빠뜨려도 단언이 없으면 아무 테스트도 red 가 되지 않습니다.

Repository

클래스 종류 설명
GdprPolicyVersionRepository 구현 GDPR 정책 버전 Repository 구현체 (immutable append-only)
GdprPolicyVersionRepositoryInterface 인터페이스 GDPR 정책 버전 Repository 인터페이스 (immutable append-only)
GdprUserConsentHistoryRepository 구현 GDPR 동의 변경 이력 Repository 구현체 (immutable append-only)
GdprUserConsentHistoryRepositoryInterface 인터페이스 GDPR 동의 변경 이력 Repository 인터페이스 (immutable append-only)
GdprUserConsentRepository 구현 GDPR 사용자 현재 동의 상태 Repository 구현체
GdprUserConsentRepositoryInterface 인터페이스 GDPR 사용자 현재 동의 상태 Repository 인터페이스

GdprPolicyVersionRepository·GdprUserConsentHistoryRepository 설명에 "immutable append-only"가 반복 명시된 것은 우연이 아닙니다 — 이 두 Repository 에는 update()/delete() 류 메서드를 추가하지 않습니다(§AGENTS.md 금지 패턴). 상태를 고치는 메서드가 필요하다면 그것은 GdprUserConsentRepository(mutable) 의 몫이며, 두 종류를 같은 Repository 에 섞으면 "이 메서드가 이력을 고치는지 상태를 고치는지"를 매 호출부에서 다시 확인해야 합니다.