Files

133 lines
11 KiB
Markdown

# 성인 콘텐츠 노출 정책 Deprecated 함수 제거 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | Phase 1~3 완료, 리뷰 후 고유 검증 보강 대기 |
| 작성일 | `2026-09-17` |
| 최종 수정일 | `2026-09-17` |
| 대상 제품 | 성인 콘텐츠 노출 여부 / 접속 국가 코드 계산 정책 |
| 작성자·결정권자 | Junie / 사용자 |
| 관련 API Contract | 기존 API 응답 스키마 변경 없음 |
| 관련 구현 계획 | `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/plan-task.md` |
| 관련 review | `reviews/문서대비구현-review.md` |
## 1. Overview
성인 콘텐츠 노출 여부(`isAdult`)와 접속 국가 코드 계산 정책은 현재 두 경로에 중복 구현되어 있다. 하나는 `@Deprecated`로 표시된 top-level 함수 `MemberContentPreferencePolicy.isAdultVisibleByPolicy(...)` / `resolveCountryCodeByPolicy(...)`이고, 다른 하나는 정식 경로인 `MemberContentPreferenceService`다. Deprecated 경로가 production 12개 파일에서 여전히 27곳 호출되고 있어 정책 변경 시 두 곳을 모두 고쳐야 하는 위험이 남아 있다. 이 작업은 Deprecated 경로 호출부를 `MemberContentPreferenceService`로 이전하고 Deprecated 함수와 그 전용 테스트를 제거한다.
## 2. Problem Statement
- `MemberContentPreferencePolicy.kt`의 두 함수는 `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`에서 `@Deprecated` 처리만 되고 호출부 이전은 완료되지 않았다.
- 접속 국가 판정 기준이 두 경로에서 다르게 조달된다. Deprecated 경로는 `RequestContextHolder`에서 `CloudFront-Viewer-Country` 헤더를 직접 읽고, 정식 경로는 `CountryContext`(요청 스코프)에서 읽는다. 값 출처(같은 헤더, `CountryInterceptor`가 주입)는 같지만 경로가 둘이다.
- Deprecated 함수의 유일한 테스트인 `MemberContentPreferencePolicyTest`의 5개 검증은 `MemberContentPreferenceServiceTest` / `MemberContentPreferenceIntegrationTest`에 이미 동등하게 존재해, 회원 ID 강제 매핑 정책을 변경할 때마다 같은 상수를 3~4개 파일에서 함께 고쳐야 한다.
- 결과적으로 정책 변경(예: 국가 강제 매핑 회원 ID 추가/삭제)이 중복 지점 누락으로 회귀를 만들 수 있다.
문제를 해결했다는 판단은 `isAdultVisibleByPolicy` / `resolveCountryCodeByPolicy` 참조가 `src/main/kotlin`과 `src/test/kotlin`에서 0건이 되고, 기존 legacy 콘텐츠 조회 API의 `isAdult` 계산 결과가 그대로 유지되는 것으로 한다.
## 3. Goals
- 성인 콘텐츠 노출 여부 계산의 단일 진입점을 `MemberContentPreferenceService`로 만든다.
- `MemberContentPreferencePolicy.kt`의 Deprecated 함수 2개와 파일, 전용 테스트를 제거한다.
- 기존 legacy 콘텐츠 조회 API의 `isAdult` / 국가 코드 계산 동작을 유지하되, 사용자가 확정한 JP 강제 매핑의 `2L` 제거는 예외로 반영한다.
- 공개 API request/response 스키마를 변경하지 않는다.
## 4. Non-Goals
- `isAdult` 판정 정책 자체(KR + 본인인증 여부, 해외 전달값 사용)를 변경하지 않는다.
- JP 강제 매핑의 `2L` 제거 외에는 회원 ID 국가 강제 매핑(`FORCED_KR_MEMBER_IDS`, `FORCED_JP_MEMBER_IDS`) 값을 변경하지 않는다.
- `isAdultContentVisible`을 서비스 파라미터에서 제거하거나 저장값 조회로 대체하는 리팩터링은 하지 않는다. 호출부 시그니처는 유지한다.
- `CanController`의 통화(currency) 강제 지정 로직은 국가 강제 매핑과 무관하므로 건드리지 않는다.
- `MemberContentPreferenceCountryResolver.kt`의 강제 매핑 알고리즘은 유지하고, JP 대상에서 `2L`만 제외한다.
## 5. 영향 범위
### 5.1 Deprecated 함수 정의
| 파일 | 대상 |
|---|---|
| `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt` | `resolveCountryCodeByPolicy(member)`, `isAdultVisibleByPolicy(member, isAdultContentVisible)` |
### 5.2 production 호출부
| 파일 | 호출 수 |
|---|---:|
| `content/series/ContentSeriesService.kt` | 5 |
| `content/AudioContentService.kt` | 3 |
| `content/main/AudioContentMainService.kt` | 2 |
| `content/main/curation/AudioContentCurationService.kt` | 2 |
| `content/theme/AudioContentThemeService.kt` | 2 |
| `content/main/tab/free/AudioContentMainTabFreeService.kt` | 4 |
| `content/main/tab/alarm/AudioContentMainTabAlarmService.kt` | 2 |
| `content/main/tab/content/AudioContentMainTabContentService.kt` | 2 |
| `content/main/tab/home/AudioContentMainTabHomeService.kt` | 2 |
| `content/main/tab/asmr/AudioContentMainTabAsmrService.kt` | 1 |
| `content/main/tab/replay/AudioContentMainTabLiveReplayService.kt` | 1 |
| `content/main/tab/series/AudioContentMainTabSeriesService.kt` | 1 |
12개 파일, 총 27곳이다. 어떤 파일도 현재 `MemberContentPreferenceService`를 주입받지 않는다.
### 5.3 테스트 호출부
| 파일 | 처리 |
|---|---|
| `src/test/kotlin/.../member/contentpreference/MemberContentPreferencePolicyTest.kt` | 삭제. 동등 검증이 `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`에 이미 존재하며, 부족한 항목은 `MemberContentPreferenceServiceTest`로 이전한다. |
## 6. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `DEPREM-001` | 확정 | `MemberContentPreferenceService`에 `isAdultVisibleForQuery(member, isAdultContentVisible)`를 추가해 `resolveCountryCode(member)` + `calculateIsAdultForQuery(...)` 조합을 단일 메서드로 제공한다. | KR + `auth == null`이면 `false`, KR + `auth != null`이면 전달값, 비KR이면 전달값. 회원 ID 강제 매핑이 접속 국가 헤더보다 우선한다. | `P1-T1` |
| `DEPREM-002` | 확정 | production 12개 파일의 `isAdultVisibleByPolicy(...)` 27곳을 `memberContentPreferenceService.isAdultVisibleForQuery(...)`로 치환하고 `MemberContentPreferenceService`를 생성자 주입한다. | 각 파일에서 `isAdultVisibleByPolicy` import와 호출이 사라지고, `isAdult` 계산 결과가 기존과 동일하다. | `P2-T1`~`P2-T4` |
| `DEPREM-003` | 확정 | `MemberContentPreferencePolicy.kt`와 `MemberContentPreferencePolicyTest.kt`를 제거한다. | `isAdultVisibleByPolicy|resolveCountryCodeByPolicy` 검색 결과가 `src/main/kotlin`, `src/test/kotlin`에서 0건이다. | `P3-T1` |
| `DEPREM-004` | 확정 | Deprecated 테스트에서 제거되는 검증 항목 중 정식 경로 테스트에 없는 것은 `MemberContentPreferenceServiceTest`로 이전한다. | 헤더 공백/소문자 정규화(`" us "` → `US`), 국가 컨텍스트 없음 → `KR` fallback, 비KR에서 저장 `countryCode` 무시 검증이 정식 경로 테스트에 존재한다. | `P3-T1` |
| `DEPREM-005` | 확정 | 기존 legacy 콘텐츠 조회 동작을 회귀 없이 유지한다. | `AudioContentServiceTest` 등 영향 범위 테스트와 `member/contentpreference` 테스트가 모두 통과하고 ktlint가 통과한다. | `P3-GATE` |
## 7. API 계약
- 변경되는 공개 request/response 필드는 없다.
- 신규 message key, 신규 endpoint는 없다.
- 각 서비스의 public 메서드 시그니처(`isAdultContentVisible` 파라미터 포함)는 유지한다. 변경되는 것은 생성자 의존성뿐이다.
## 8. 보안과 데이터 취급
- 접속 국가 판정은 계속 `CloudFront-Viewer-Country` 헤더를 `CountryInterceptor`가 `CountryContext`에 주입한 값만 사용한다.
- 성인 콘텐츠 노출은 KR에서 본인인증(`member.auth != null`)이 있는 경우에만 허용하는 기존 정책을 유지한다.
- 회원 ID, 이메일 등 개인정보는 로그에 추가하지 않는다.
## 9. 리스크
| ID | 리스크 | 대응 |
|---|---|---|
| `RISK-001` | `CountryContext`는 `@RequestScope`이므로 비웹 스레드에서 호출되면 예외가 발생할 수 있다. Deprecated 함수는 `RequestContextHolder`가 없을 때 `KR`로 fallback했다. | 대상 12개 서비스가 controller 요청 경로에서만 호출되는지 Phase 2에서 파일별로 확인한다. 스케줄러/비동기 호출 경로가 발견되면 해당 Task를 진행하지 않고 계획을 먼저 갱신한다. |
| `RISK-002` | 생성자 파라미터 추가로 기존 서비스 테스트가 컴파일 실패할 수 있다. | 해소됨. `AudioContentServiceTest` 1곳만 영향받아 mock 주입으로 해결했다. |
| `RISK-003` | 순환 의존 발생 가능성. | `MemberContentPreferenceService`는 `MemberContentPreferenceRepository`, `MemberRepository`, `CountryContext`, `CacheManager`만 의존하므로 콘텐츠 서비스와 순환이 발생하지 않음을 확인했다. |
## 10. 성공 기준
- [x] `MemberContentPreferenceService.isAdultVisibleForQuery(...)`가 기존 `isAdultVisibleByPolicy(...)`와 동일한 판정을 한다. (`DEPREM-001`)
- [x] production 12개 파일 27곳이 모두 정식 경로를 호출한다. (`DEPREM-002`)
- [x] `MemberContentPreferencePolicy.kt`, `MemberContentPreferencePolicyTest.kt`가 제거되고 참조가 0건이다. (`DEPREM-003`)
- [x] Deprecated 테스트의 고유 검증이 `MemberContentPreferenceServiceTest`에 남아 있다. (`DEPREM-004`)
- [x] 영향 범위 focused test와 ktlint가 통과한다. (`DEPREM-005`)
## 11. Decision Log
기존 성공 기준 체크박스는 당시 완료 기록으로 유지한다. 후속 리뷰에서 DEPREM-004의 저장 국가 충돌 검증 누락을 확인했으며, `plan-task.md`의 `P3-R1` 완료 전까지 해당 검증 보강은 미완료다.
| 일시 | 결정 | 근거 |
|---|---|---|
| 2026-09-17 | Deprecated 함수 제거를 위해 production 호출부를 모두 마이그레이션하는 방식(선택지 3)을 택한다. | 사용자 선택. 테스트만 정식 경로로 바꾸면 production에서 쓰이는 Deprecated 경로가 커버리지 0이 되므로 부적절 |
| 2026-09-17 | 호출부 1:1 치환을 위해 `MemberContentPreferenceService`에 `isAdultVisibleForQuery(member, isAdultContentVisible)` wrapper를 추가한다. | 호출부마다 `resolveCountryCode(...)` + `calculateIsAdultForQuery(...)` 2단계를 반복하면 중복이 27곳으로 늘어남 |
| 2026-09-17 | `MemberContentPreferencePolicyTest`는 삭제하고 고유 검증만 `MemberContentPreferenceServiceTest`로 이전한다. | 5개 검증 중 대부분이 정식 경로 테스트와 중복이며, 함수 제거 후에는 테스트 대상이 사라짐 |
| 2026-09-17 | Phase를 서비스 묶음 단위로 나눠 진행한다. | 27곳 동시 변경 시 실패 원인 추적이 어려움. 저장소 규칙("작은 단위로 안전하게 수정") 준수 |
| 2026-09-17 | `RISK-001`은 해소로 판정한다. | 치환한 27곳이 모두 controller 진입 조회 경로임을 파일별로 확인했고, 스케줄러(`Recommendation/Ranking/ChargeEvent`)는 이 경로를 호출하지 않음 |
- 2026-09-17 사용자 확정: JP 강제 매핑에서 `2L`을 제거한 것은 의도된 정책 변경이다. REV-001은 결함 판정을 철회하며 `2L`을 복원하지 않는다. 나머지 매핑은 유지한다.
- 2026-09-17 후속 계획: REV-002의 저장 국가 무시 검증을 `P3-R1`로 추가한다. 이번 작업은 문서 갱신이며 테스트 구현·실행은 하지 않는다.
## 12. 열린 질문
- 없음. Phase 2에서 비웹 호출 경로(`RISK-001`)가 발견되면 계획을 먼저 갱신하고 사용자에게 확인한다.