docs(content-preference): 노출 정책 통합 결과를 기록한다

This commit is contained in:
2026-09-17 20:20:54 +09:00
parent 17b7f0281e
commit 11f2e51c33
3 changed files with 496 additions and 0 deletions
@@ -0,0 +1,299 @@
# 성인 콘텐츠 노출 정책 Deprecated 함수 제거 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | Phase 1~3 완료, P3-R1 구현 완료·실행 검증 대기 |
| 작성일 | `2026-09-17` |
| 요구사항 기준 | `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/prd.md` |
| API 기준 | 기존 API 응답 스키마 변경 없음 |
| 현재 Phase | Phase 3 후속 실행 검증 대기 |
| 현재 활성 Goal | `P3-R1` 실행 검증 |
| 다음 Goal | 없음 |
## 목표
`MemberContentPreferencePolicy.isAdultVisibleByPolicy(...)` / `resolveCountryCodeByPolicy(...)` Deprecated 경로를 `MemberContentPreferenceService` 정식 경로로 이전하고, Deprecated 함수와 전용 테스트를 제거한다. 사용자가 확정한 JP 강제 매핑의 `2L` 제거를 제외하고 성인 콘텐츠 노출 판정 결과는 유지한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `1/1` | 없음 | 없음 |
| 2 | 완료 | `4/4` | 없음 | 없음 |
| 3 | 완료 | `1/1` | 없음 | 없음 |
| 3 후속 | 진행 중 | `0/1` | 활성: `P3-R1` 실행 검증 | 회귀 테스트 구현·소스 검토 완료, 자동 실행 대기 |
## 범위
### 포함
- `MemberContentPreferenceService`에 `isAdultVisibleForQuery(member, isAdultContentVisible)` 추가.
- production 12개 파일 27곳의 `isAdultVisibleByPolicy(...)` 호출 치환 및 `MemberContentPreferenceService` 생성자 주입.
- `MemberContentPreferencePolicy.kt`, `MemberContentPreferencePolicyTest.kt` 제거와 고유 검증 이전.
- 영향 범위 focused test 및 ktlint 검증.
### 제외
- `isAdult` 판정 알고리즘 변경 및 JP 강제 매핑의 `2L` 제거 외 국가 강제 매핑 회원 ID 변경.
- 각 서비스 public 메서드 시그니처 변경, 공개 API 스키마 변경.
- `CanController`의 통화 강제 로직.
## 기술적 제약
- 기술 스택: Kotlin, Spring Boot 2.7.14, Gradle Wrapper, ktlint.
- 신규 abstraction을 만들지 않는다. 기존 `MemberContentPreferenceService`의 `resolveCountryCode` + `calculateIsAdultForQuery`를 재사용한다.
- `CountryContext`는 `@RequestScope`이므로 마이그레이션 대상은 웹 요청 경로에서만 호출되는 서비스로 한정한다(`RISK-001`).
- 모든 production 변경 Task는 `RED → GREEN → REFACTOR` 순서로 진행한다.
- 테스트 실행은 변경 범위 focused test를 우선하고, 전체 회귀는 실행하지 않으며 생략 근거를 검증 기록에 남긴다.
## Phase 1: 정식 경로 단일 진입점 확보
**Phase 결과:** `MemberContentPreferenceService.isAdultVisibleForQuery(...)`가 Deprecated 함수와 동일한 판정을 제공한다.
**선행조건:** PRD 확정.
**Phase 완료 조건:** `P1-T1` 완료 및 검증 기록 누적.
### 구현 항목
#### Task 1.1 `isAdultVisibleForQuery` 추가
**Goal 실행 `P1-T1`:** `MemberContentPreferenceService`에 접속 국가 계산과 성인 노출 판정을 묶은 단일 메서드를 추가한다.
- **시작 조건:** `DEPREM-001` 확정.
- **완료 증거:** KR + 미인증 → `false`, KR + 인증 → 전달값, 비KR → 전달값, 강제 매핑 회원은 헤더보다 강제 국가 우선인 focused test 통과.
- **범위 밖:** 호출부 치환.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
**Interfaces:**
- `fun isAdultVisibleForQuery(member: Member, isAdultContentVisible: Boolean): Boolean`
- 구현은 `calculateIsAdultForQuery(member, resolveCountryCode(member), isAdultContentVisible)`로 한다. DB 조회를 추가하지 않는다.
- [x] **RED:** `MemberContentPreferenceServiceTest`에 `isAdultVisibleForQuery` 판정 테스트 4건(KR 미인증/KR 인증/비KR/강제 매핑)을 작성한다.
- [x] **RED 확인:** `MemberContentPreferenceServiceTest` 실행 결과 `Unresolved reference: isAdultVisibleForQuery` 컴파일 실패를 확인했다.
- [x] **GREEN:** `MemberContentPreferenceService`에 `isAdultVisibleForQuery`를 추가했다.
- [x] **GREEN 확인:** `MemberContentPreferenceServiceTest` 25/25 통과.
- [x] **REFACTOR:** `resolveCountryCode` + `calculateIsAdultForQuery` 재사용만으로 구현해 중복 계산이 없음을 확인했고 ktlint 통과.
## Phase 2: production 호출부 마이그레이션
**Phase 결과:** production 코드에서 `isAdultVisibleByPolicy(...)` 호출이 0건이 된다.
**선행조건:** Phase 1 완료.
**Phase 완료 조건:** `P2-T1`~`P2-T4`와 `P2-GATE` 완료 및 검증 기록 누적.
### 구현 항목
#### Task 2.1 메인 탭 서비스 7개 치환
**Goal 실행 `P2-T1`:** 콘텐츠 메인 탭 서비스 7개가 `MemberContentPreferenceService`로 성인 노출 여부를 계산한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** 7개 파일에서 `isAdultVisibleByPolicy` import/호출 0건, 컴파일 및 영향 범위 테스트 통과.
- **범위 밖:** `AudioContentService`, `ContentSeriesService`.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/free/AudioContentMainTabFreeService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt`
**Interfaces:**
- 각 서비스 생성자에 `private val memberContentPreferenceService: MemberContentPreferenceService`를 추가한다.
- `isAdultVisibleByPolicy(member, isAdultContentVisible)` → `memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)`.
- `AudioContentMainTabHomeService`의 nullable member 처리(`member?.let { ... } ?: false`)는 유지한다.
- [x] **RED:** 7개 서비스가 각각 대응 `*Controller`에서만 주입됨을 확인해 `RISK-001` 위반 경로가 없음을 검증했다.
- [x] **GREEN:** 7개 파일의 호출부와 생성자를 치환했다.
- [x] **GREEN 확인:** 테스트 컴파일과 `src/test/kotlin/kr/co/vividnext/sodalive/content` 42/42 통과로 확인했다.
- [x] **REFACTOR:** `isAdultVisibleByPolicy` import를 `MemberContentPreferenceService` import로 교체했고 ktlint 통과.
#### Task 2.2 메인/큐레이션/테마 서비스 치환
**Goal 실행 `P2-T2`:** `AudioContentMainService`, `AudioContentCurationService`, `AudioContentThemeService`를 정식 경로로 치환한다.
- **시작 조건:** `P2-T1` 완료.
- **완료 증거:** 3개 파일에서 `isAdultVisibleByPolicy` 0건, 컴파일 및 영향 범위 테스트 통과.
- **범위 밖:** 다른 Task 대상 파일.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt`
**Interfaces:**
- `P2-T1`과 동일한 치환 규칙을 적용한다.
- [x] **RED:** 3개 서비스의 치환 대상이 모두 `member`를 받는 웹 조회 메서드임을 확인했다.
- [x] **GREEN:** 3개 파일을 치환했다.
- [x] **GREEN 확인:** 테스트 컴파일 성공 및 `content` 패키지 회귀 통과.
- [x] **REFACTOR:** import 정리 후 ktlint 통과.
#### Task 2.3 `AudioContentService` 치환
**Goal 실행 `P2-T3`:** `AudioContentService`의 3곳을 정식 경로로 치환한다.
- **시작 조건:** `P2-T2` 완료.
- **완료 증거:** `AudioContentServiceTest`가 갱신된 생성자로 통과한다.
- **범위 밖:** `ContentSeriesService`.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
**Interfaces:**
- `P2-T1`과 동일한 치환 규칙을 적용하고, 테스트에서는 `MemberContentPreferenceService`를 mock으로 주입한다.
- [x] **RED:** `AudioContentServiceTest.kt:118`에서 `No value passed for parameter 'memberContentPreferenceService'` 컴파일 실패를 확인했다.
- [x] **GREEN:** `getDetail`, `getLatestCreatorAudioContent`, `getAudioContentList` 3곳을 치환하고 테스트에 mock을 주입했다.
- [x] **GREEN 확인:** `AudioContentServiceTest` 16/16 통과.
- [x] **REFACTOR:** import 정리 후 ktlint 통과.
#### Task 2.4 `ContentSeriesService` 치환
**Goal 실행 `P2-T4`:** `ContentSeriesService`의 5곳을 정식 경로로 치환한다.
- **시작 조건:** `P2-T3` 완료.
- **완료 증거:** 시리즈 관련 영향 범위 테스트 통과, `isAdultVisibleByPolicy` 0건.
- **범위 밖:** Deprecated 함수 제거.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt`
- Test: 필요 시 `src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainControllerTest.kt` 등 영향 테스트 갱신
**Interfaces:**
- `P2-T1`과 동일한 치환 규칙을 적용한다.
- [x] **RED:** 테스트 컴파일을 실행해 `ContentSeriesService`를 직접 생성하는 테스트가 없어 추가 수정이 필요 없음을 확인했다.
- [x] **GREEN:** 5곳(`getSeriesList`, `getSeriesListByGenre`, `getSeriesDetail`, `getSeriesContentList`, `getRecommendSeriesList`)을 치환했다.
- [x] **GREEN 확인:** 테스트 컴파일 성공 및 Spring 컨텍스트 로딩 테스트 통과.
- [x] **REFACTOR:** import 정리 후 ktlint 통과.
### Phase 2 Gate
**Goal 실행 `P2-GATE`:** production에서 Deprecated 호출이 0건임을 확인한다.
- [x] `isAdultVisibleByPolicy|resolveCountryCodeByPolicy` 검색 결과가 `src/main/kotlin`에서 0건이다.
- [x] Phase 2 대상 파일의 영향 범위 테스트가 통과한다(`content` 42/42).
## Phase 3: Deprecated 함수 제거와 테스트 정리
**Phase 결과:** Deprecated 함수와 전용 테스트가 제거되고 고유 검증이 정식 경로 테스트에 남는다.
**선행조건:** Phase 2 완료.
**Phase 완료 조건:** `P3-T1`, `P3-GATE` 완료 및 검증 기록 누적.
### 구현 항목
#### Task 3.1 Deprecated 정의 제거와 검증 이전
**Goal 실행 `P3-T1`:** `MemberContentPreferencePolicy.kt`와 `MemberContentPreferencePolicyTest.kt`를 제거하고 고유 검증을 이전한다.
- **시작 조건:** `P2-GATE` 완료.
- **완료 증거:** 두 파일이 삭제되고 `member/contentpreference` 테스트가 모두 통과한다.
- **범위 밖:** `MemberContentPreferenceCountryResolver.kt` 변경.
**Files:**
- Delete: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt`
- Delete: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicyTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
**Interfaces:**
- 이전 대상 검증: 헤더 공백/소문자 정규화(`" us "` → `US`), 국가 컨텍스트 없음 → `KR` fallback, 비KR에서 저장 `countryCode` 무시.
- 이미 정식 경로 테스트에 존재하는 검증은 중복 추가하지 않는다.
- [x] **RED:** 로그인 회원의 헤더 공백/소문자 정규화와 KR fallback 검증을 `MemberContentPreferenceServiceTest`에 추가했다. 나머지 항목(비로그인 정규화, 저장 `countryCode` 미사용)은 기존 테스트가 이미 커버함을 확인해 중복 추가하지 않았다.
- [x] **GREEN:** `MemberContentPreferencePolicy.kt`와 `MemberContentPreferencePolicyTest.kt`를 삭제했다.
- [x] **GREEN 확인:** `member/contentpreference` 36/36 통과.
- [x] **REFACTOR:** ktlint 통과 및 잔여 참조 0건 확인.
### Phase 3 Gate
**Goal 실행 `P3-GATE`:** 전체 정리 상태를 확인한다.
- [x] `isAdultVisibleByPolicy|resolveCountryCodeByPolicy` 참조가 `src/main/kotlin`, `src/test/kotlin`에서 0건이다.
- [x] `member/contentpreference` 테스트와 Phase 2 영향 범위 테스트가 통과한다.
- [x] `./gradlew ktlintMainSourceSetCheck ktlintTestSourceSetCheck`가 통과한다.
- [x] PRD 성공 기준 체크박스를 갱신했다.
## Phase 3 후속: 리뷰 검증 보강
### Task 3.R1 저장 국가 무시 검증 이전
**Goal 실행 `P3-R1`:** 저장 국가가 KR인 미인증 회원이 US에서 조회할 때 저장 국가를 무시하는 기존 정책을 정식 서비스 테스트로 검증한다.
- **시작 조건:** DEPREM-004 및 `reviews/문서대비구현-review.md`의 REV-002 확정. REV-001은 사용자 확인으로 결함 판정 철회; `2L` 제거 유지.
- **완료 증거:** 아래 테스트 추가 및 조건·assertion 소스 검토, 후속 실행이 허용된 경우 focused test 결과 기록. 실행하지 않았으면 실행 검증 대기로 남기고 완료 처리하지 않는다.
- **범위 밖:** production 정책·resolver·매핑 변경, API 변경, 테스트 helper 리팩터링, 전체 회귀 실행.
- **TDD 예외:** 현재 production은 올바르게 저장 국가를 무시하므로 신규 테스트는 처음부터 통과하는 것이 정상이다. 실패를 만들려고 정상 구현을 변경하지 않는다. 삭제된 테스트와 새 테스트의 입력·assertion 대조로 누락 검증 이전을 확인한다.
- **실행 제약:** 이번 문서 갱신에서는 테스트를 실행하지 않는다. 기존 자동 테스트 미실행 지시가 유지되는 동안 후속 작업도 소스 검토까지만 진행하고 실행 상태를 별도로 남긴다.
**Files:**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
- Update: `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/plan-task.md`
- Update: `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/reviews/문서대비구현-review.md`
**추가할 테스트:** 기존 `createMember`, `countryContext`, `service`, JUnit import를 재사용한다.
```kotlin
@Test
@DisplayName("비KR 요청에서는 회원의 저장 국가와 무관하게 전달한 성인 노출값을 사용한다")
fun shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr() {
val member = createMember(id = 2401L, withAuth = false).apply {
countryCode = "KR"
}
countryContext.setCountryCode("US")
assertEquals("US", service.resolveCountryCode(member))
assertTrue(service.isAdultVisibleForQuery(member, isAdultContentVisible = true))
assertFalse(service.isAdultVisibleForQuery(member, isAdultContentVisible = false))
}
```
- [x] **누락 확인:** 삭제된 정책 테스트와 비교해 저장 KR·요청 US·미인증 조건이 기존 정식 테스트에 없음을 확인했다(TDD 예외 적용).
- [x] **최소 구현:** 위 테스트 1건만 추가했다. 강제 매핑이 없는 ID 2401을 사용하고 production은 변경하지 않았다.
- [x] **소스 검토:** 국가 결과 US 및 노출 전달값 true/false 보존 assertion을 확인했다. 저장 국가 KR을 우선하면 국가 assertion과 `true` 전달값 assertion이 실패한다.
- [ ] **실행 검증:** 자동 테스트 실행이 허용되는 후속 단계에서 아래 focused test와 ktlint를 실행하고 결과를 기록한다. 미실행이면 완료 표시하지 않는다.
- [x] **기록 갱신:** P3-T1의 기존 완료 기록을 유지하고, REV-002 보강 결과와 실행 대기 상태를 본 Task와 리뷰에 누적했다.
**후속 실행용 명령(이번에는 미실행):** 저장소와 호환되는 JDK 환경에서 실행한다.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest'
./gradlew ktlintTestSourceSetCheck
```
기대 결과: 신규 테스트를 포함한 서비스 테스트 및 테스트 소스 ktlint 성공. 테스트 한 파일의 검증 보강이므로 전체 회귀는 생략한다.
## 검증 기록
- 2026-09-17 Phase 3 후속 P3-R1: 삭제된 정책 테스트와 현재 서비스 테스트를 대조해 저장 KR·요청 US·미인증 조건의 누락을 확인하고 `MemberContentPreferenceServiceTest.shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr` 1건을 추가했다. 국가 결과 US와 전달값 true/false 보존 assertion을 소스로 검토했다. production은 변경하지 않았으며 기존 자동 테스트 미실행 지시에 따라 focused test와 ktlint는 실행하지 않아 Task 완료 처리는 보류했다.
- 2026-09-17 리뷰 후속 문서 갱신: 사용자가 JP 매핑의 `2L` 제거를 의도한 변경으로 확정했다. REV-002 검증 누락은 `P3-R1`로 추가하고 REV-003 호출 수는 27곳으로 정정했다. 문서와 테스트 소스의 식별자·조건을 대조했으며 사용자 지시에 따라 자동 테스트 및 Gradle 명령은 실행하지 않았다.
- 2026-09-17 Phase 1: `MemberContentPreferenceServiceTest` 실행 → RED에서 `Unresolved reference: isAdultVisibleForQuery` 확인, GREEN 후 25/25 통과. 검증 이유: 신규 판정 메서드가 Deprecated 함수와 동일한 결과를 내는지 확인.
- 2026-09-17 Phase 2: `AudioContentServiceTest.kt:118` 컴파일 실패로 생성자 변경 영향을 확인한 뒤 mock 주입. `src/test/kotlin/kr/co/vividnext/sodalive/content` 42/42 통과, `AudioContentServiceTest` 16/16 통과.
- 2026-09-17 Phase 3: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference` 36/36 통과(Deprecated 테스트 5건 삭제, 신규 3건 추가 반영).
- 2026-09-17 빈 주입 회귀: `SpringBootIntegrationSampleTest` 1/1 통과로 12개 서비스에 추가된 `MemberContentPreferenceService` 주입이 애플리케이션 컨텍스트에서 정상 해석됨을 확인(순환 의존 없음).
- 2026-09-17 스타일: `JAVA_HOME=<Android Studio JBR 21> ./gradlew ktlintMainSourceSetCheck ktlintTestSourceSetCheck` BUILD SUCCESSFUL. JetBrains JBR 25는 Gradle 8.1.1과 비호환이어서 Java 21 JBR을 사용했다.
- 2026-09-17 전체 회귀 생략: `./gradlew test` 전체 회귀는 실행하지 않았다. 근거는 변경이 `isAdult` 계산 위임 경로 치환으로 한정되고 공개 스키마/DB/보안 설정 변경이 없으며, 대신 변경 범위 focused test(`content` 42, `member/contentpreference` 36, `AudioContentServiceTest` 16)와 컨텍스트 로딩 테스트, ktlint 전체 소스셋 검사를 실행했다.