From 2ad30f90699a03162000d3b87be39f45b92740af Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 19 Aug 2026 17:10:42 +0900 Subject: [PATCH] =?UTF-8?q?docs(recommendation):=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20=EC=96=B8=EC=96=B4=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 518 ++++++++++++++++++ .../prd.md | 113 ++++ 2 files changed, 631 insertions(+) create mode 100644 docs/20260819_추천탭_배너_접속국가별_언어필터/plan-task.md create mode 100644 docs/20260819_추천탭_배너_접속국가별_언어필터/prd.md diff --git a/docs/20260819_추천탭_배너_접속국가별_언어필터/plan-task.md b/docs/20260819_추천탭_배너_접속국가별_언어필터/plan-task.md new file mode 100644 index 00000000..5aae95fe --- /dev/null +++ b/docs/20260819_추천탭_배너_접속국가별_언어필터/plan-task.md @@ -0,0 +1,518 @@ +# 추천 탭 배너 접속 국가별 언어 필터 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현 완료 | +| 작성일 | 2026-08-19 | +| 요구사항 기준 | `docs/20260819_추천탭_배너_접속국가별_언어필터/prd.md` | +| API 기준 | 기존 공개 API 계약 유지 | +| 현재 Phase | Phase 2: 언어 필터 limit 선행 회귀 테스트 보완 완료 | +| 현재 활성 Goal | 없음 | + +## 목표 + +메인 홈 추천과 메인 콘텐츠 추천에서 로그인 여부와 기존 강제 국가 매핑을 반영해 일본은 일본어, 그 외 국가는 한국어 배너만 조회한다. + +## Architecture + +기존 `MemberContentPreferenceService.resolveCountryCode`가 비로그인 사용자까지 처리하도록 입력만 확장하고 강제 국가 매핑과 국가 정규화를 재사용한다. 두 추천 application 진입점에서 국가를 `Lang.JA` 또는 `Lang.KO`로 변환해 기존 port에 전달하며, 두 QueryDSL Repository가 `content_banner.lang`을 정렬·limit 전 조회 조건으로 적용한다. + +## Tech Stack + +Kotlin, Java 17, Spring Boot 2.7.14, JPA/QueryDSL, JUnit 5, Mockito, Gradle Wrapper + +## Global Constraints + +- `JP`는 `Lang.JA`, 그 외 국가와 국가 정보 누락은 `Lang.KO`로 고정한다. +- 로그인 회원의 기존 강제 KR/JP 매핑은 요청 헤더보다 우선한다. +- `Accept-Language`, DB 스키마, 공개 API endpoint/DTO/응답 스키마를 변경하지 않는다. +- 기존 탭·성인·활성·차단·대상 유효성·정렬·limit 조건을 유지한다. +- 신규 dependency, 언어 resolver abstraction과 메모리 후처리를 추가하지 않는다. +- 모든 구현 Task는 RED → GREEN → REFACTOR 순서로 진행한다. + +--- + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `3/3` | 없음 | 없음 | +| 2 | 완료 | `1/1` | 없음 | 없음 | + +- 동시에 하나의 미완료 goal만 운용한다. +- 구현 완료 즉시 해당 Task 체크박스와 현재 상태를 갱신한다. +- 실제 검증 결과는 기존 기록을 덮어쓰지 않고 `Progress`에 누적한다. + +## 범위 + +### 포함 + +- 로그인·비로그인 사용자 공통 국가 코드 판정 +- 홈 추천 `tab_id IS NULL` 배너의 `KO`/`JA` 필터 +- 콘텐츠 추천 `tab_id = 2` 배너의 `KO`/`JA` 필터 +- application → port → persistence 언어 전달 +- focused application/Repository 테스트와 직접 영향 controller/E2E 회귀 + +### 제외 + +- `Lang.EN`을 선택하는 국가 정책 +- 배너 외 추천 섹션의 국가·언어 필터 +- 관리자/v1 배너 조회 변경 +- DB 데이터·스키마, API 계약과 dependency 변경 +- 기존 배너 정책의 리팩터링 + +## 파일 구조 + +| 책임 | 파일 | +|---|---| +| 공통 국가 판정 | `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`, `MemberContentPreferenceCountryResolver.kt` | +| 홈 application 언어 선택·전달 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`, `v2/recommendation/application/HomeRecommendationQueryService.kt` | +| 홈 port·조회 조건 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`, `v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` | +| 콘텐츠 추천 application 언어 선택·전달 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt` | +| 콘텐츠 추천 port·조회 조건 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`, `v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` | + +## Phase 1: 접속 국가별 배너 언어 필터 + +**Phase 결과:** 두 추천 API가 판정 국가에 대응하는 한 언어의 배너만 기존 정책대로 반환한다. + +**선행조건:** 승인된 PRD `BANNER-LANG-001~006`. + +**Phase 완료 조건:** `P1-T1`~`P1-T3`과 `P1-GATE` 완료, 실제 검증 결과 누적. + +### 구현 항목 + +#### Task 1.1 로그인·비로그인 공통 국가 판정 (`P1-T1`) + +**Goal 실행 `P1-T1`:** 기존 강제 국가 매핑을 유지하면서 비로그인 사용자도 동일한 정규화와 기본값으로 국가 코드를 판정한다. + +- **시작 조건:** PRD `BANNER-LANG-003`, `BANNER-LANG-004` 확정. +- **완료 증거:** nullable 회원 국가 판정 테스트가 RED 후 GREEN이고 기존 강제 KR/JP 매핑 테스트가 통과한다. +- **범위 밖:** 국가를 배너 언어로 변환하거나 배너를 조회하는 로직. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt` + +**Interfaces:** + +- Consumes: `CountryContext.countryCode`, 기존 강제 KR/JP 회원 ID 매핑. +- Produces: `MemberContentPreferenceService.resolveCountryCode(member: Member?): String` — 정규화한 국가 코드이며 null·blank는 `KR`. + +- [x] **RED:** 비로그인 `JP`, 소문자/공백 포함 JP, 헤더 누락을 검증하는 테스트를 추가한다. + +```kotlin +countryContext.setCountryCode(" jp ") +assertEquals("JP", service.resolveCountryCode(null)) + +countryContext.setCountryCode(null) +assertEquals("KR", service.resolveCountryCode(null)) +``` + +- [x] **RED 확인:** 아래 focused test를 실행해 현재 `Member` non-null 계약 때문에 컴파일이 실패하는지 확인한다. + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest" +``` + +**Expected:** `resolveCountryCode(null)` 호출의 타입 불일치로 실패한다. + +- [x] **GREEN:** 기존 helper가 nullable 회원을 받고, non-null 회원만 기존 ID 필수 조건을 검사하도록 최소 변경한다. + +```kotlin +fun resolveCountryCode(member: Member?): String { + if (member != null) requireMemberId(member) + return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode) +} + +fun resolveCountryCodeWithForcedMapping(member: Member?, requestCountryCode: String?): String { + val memberId = member?.id + if (memberId != null && FORCED_KR_MEMBER_IDS.contains(memberId)) { + return "KR" + } + if (memberId != null && FORCED_JP_MEMBER_IDS.contains(memberId)) { + return "JP" + } + return requestCountryCode + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.uppercase() + ?: "KR" +} +``` + +- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 비로그인과 기존 강제 매핑 테스트가 모두 통과하는지 확인한다. +- [x] **REFACTOR:** 이번 변경에서 생긴 중복만 정리하고 공개 국가 정책 메서드를 추가하지 않는다. focused test를 재실행해 결과를 `Progress`에 기록한다. + +#### Task 1.2 홈 추천 배너 언어 필터 (`P1-T2`) + +**Goal 실행 `P1-T2`:** 홈 추천 배너가 판정 국가에 맞는 `JA` 또는 `KO`만 DB에서 조회한다. + +- **시작 조건:** `P1-T1` 완료, PRD `BANNER-LANG-001`, `BANNER-LANG-005` 확정. +- **완료 증거:** facade·query service 전달 테스트와 홈 Repository 언어 필터 테스트가 RED 후 GREEN이다. +- **범위 밖:** 콘텐츠 추천 탭 배너와 홈의 다른 추천 섹션. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` + +**Interfaces:** + +- Consumes: `MemberContentPreferenceService.resolveCountryCode(member: Member?): String`. +- Produces: `findHomeBanners(..., lang: Lang = Lang.KO)` application/port 계약과 QueryDSL `audioContentBanner.lang.eq(lang)` 조건. + +- [x] **RED:** facade가 `JP`를 `Lang.JA`, 그 외를 `Lang.KO`로 전달하고 query service가 `Lang`을 port에 위임하는 테스트를 추가한다. + +```kotlin +Mockito.doReturn("JP").`when`(preferenceService).resolveCountryCode(member) + +facade.getHomeRecommendations(member) + +assertEquals(Lang.JA, queryPort.bannerLang) +``` + +- [x] **RED:** 홈 Repository fixture helper에 `lang: Lang = Lang.KO`를 추가하고 같은 조건의 KO/JA 배너 중 요청 언어만 반환하는 테스트를 추가한다. + +```kotlin +val koBanner = saveBanner("ko.png", AudioContentBannerType.LINK, 1, isActive = true, lang = Lang.KO) +val jaBanner = saveBanner("ja.png", AudioContentBannerType.LINK, 2, isActive = true, lang = Lang.JA) + +assertEquals(listOf(koBanner.thumbnailImage), repository.findHomeBanners(20, lang = Lang.KO).map { it.thumbnailImage }) +assertEquals(listOf(jaBanner.thumbnailImage), repository.findHomeBanners(20, lang = Lang.JA).map { it.thumbnailImage }) +``` + +- [x] **RED 확인:** 아래 focused test를 실행해 `Lang` 전달 계약과 Repository 조건이 없어 발생하는 컴파일/assertion 실패를 확인한다. + +```bash +./gradlew test \ + --tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" \ + --tests "kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest" \ + --tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" +``` + +- [x] **GREEN:** facade에서 국가를 언어로 변환해 전달한다. service와 port 시그니처 끝에는 기본값 `Lang.KO`인 인자를 추가하고 Repository override는 같은 `Lang` 인자를 받는다. + +```kotlin +val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO + +queryService.findHomeBanners( + limit = HOME_BANNER_LIMIT, + memberId = member?.id, + includeAdultBanners = includeAdult, + lang = bannerLang +) +``` + +```kotlin +.where( + audioContentBanner.isActive.isTrue, + audioContentBanner.tab.isNull, + audioContentBanner.lang.eq(lang), + includeAdultBannerCondition(includeAdultBanners), + activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) +) +``` + +- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 언어 전달·필터와 기존 홈 배너 조건이 통과하는지 확인한다. +- [x] **REFACTOR:** 국가→언어 변환을 별도 abstraction으로 만들지 않고 한 줄 정책으로 유지한다. 이번 Task가 만든 중복만 정리하고 focused test 결과를 `Progress`에 기록한다. + +#### Task 1.3 콘텐츠 추천 배너 언어 필터 (`P1-T3`) + +**Goal 실행 `P1-T3`:** 콘텐츠 추천 배너가 판정 국가에 맞는 `JA` 또는 `KO`만 DB에서 조회한다. + +- **시작 조건:** `P1-T1` 완료, PRD `BANNER-LANG-002`, `BANNER-LANG-005` 확정. +- **완료 증거:** query service 전달 테스트와 콘텐츠 추천 Repository 언어 필터 테스트가 RED 후 GREEN이다. +- **범위 밖:** 홈 추천 배너와 오디오 추천의 배너 외 섹션. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt` + +**Interfaces:** + +- Consumes: `MemberContentPreferenceService.resolveCountryCode(member: Member?): String`. +- Produces: `AudioRecommendationQueryPort.findBanners(..., lang: Lang = Lang.KO)`와 QueryDSL `audioContentBanner.lang.eq(lang)` 조건. + +- [x] **RED:** 기존 `shouldUseStoredPreferenceForMemberAdultVisibility`에 JP 국가 stub과 배너 호출 검증을 추가하고, 비로그인 조립 테스트는 `Lang.KO` 전달을 검증하도록 보강한다. + +```kotlin +Mockito.doReturn("JP").`when`(preferenceService).resolveCountryCode(member) + +service.getRecommendations(member) + +Mockito.verify(queryPort).findBanners( + AudioRecommendationQueryService.BANNER_LIMIT, + member.id, + true, + Lang.JA +) +``` + +- [x] **RED:** 콘텐츠 추천 Repository fixture helper에 `lang: Lang = Lang.KO`를 추가하고 동일 탭의 KO/JA 배너가 요청 언어별로 분리되는 테스트를 추가한다. + +```kotlin +val koBanner = saveBanner( + "ko.png", + AudioContentBannerType.LINK, + 1, + link = "https://ko.test", + tab = recommendationTab, + lang = Lang.KO +) +val jaBanner = saveBanner( + "ja.png", + AudioContentBannerType.LINK, + 2, + link = "https://ja.test", + tab = recommendationTab, + lang = Lang.JA +) + +assertEquals(listOf("https://cdn.test/${koBanner.thumbnailImage}"), repository.findBanners(20, null, false, Lang.KO).map { it.imageUrl }) +assertEquals(listOf("https://cdn.test/${jaBanner.thumbnailImage}"), repository.findBanners(20, null, false, Lang.JA).map { it.imageUrl }) +``` +- [x] **RED 확인:** 아래 focused test를 실행해 `Lang` 인자와 Repository 필터가 없어 발생하는 컴파일/assertion 실패를 확인한다. + +```bash +./gradlew test \ + --tests "kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest" \ + --tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest" +``` + +- [x] **GREEN:** `getRecommendations`에서 국가를 언어로 변환해 배너 호출에만 전달한다. port에는 기본값 `Lang.KO`인 인자를 추가하고 Repository override는 같은 `Lang` 인자를 받는다. + +```kotlin +val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO + +banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent, bannerLang) +``` + +```kotlin +.where( + audioContentBanner.isActive.isTrue, + audioContentBanner.tab.id.eq(2L), + audioContentBanner.lang.eq(lang), + adultBannerCondition(canViewAdultContent), + activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) +) +``` + +- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 언어 필터와 기존 탭·성인·차단 조건이 함께 통과하는지 확인한다. +- [x] **REFACTOR:** 배너 외 오디오 추천 로직을 변경하지 않는다. 이번 Task가 만든 중복만 정리하고 focused test 결과를 `Progress`에 기록한다. + +### 완료 조건 + +- [x] `P1-T1`~`P1-T3`의 체크박스와 완료 증거가 모두 충족됐다. +- [x] `BANNER-LANG-001~006`이 구현 또는 명시적 제외로 추적된다. +- [x] 공개 API와 DB 스키마 변경이 없다. +- [x] 실제 검증 결과가 `Progress`에 기록됐다. + +### 검증 방법 + +#### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** 두 추천 API의 국가별 배너 언어 필터와 기존 배너 정책의 비회귀를 최종 판정한다. + +- **시작 조건:** `P1-T1`~`P1-T3` 완료. +- **완료 증거:** 아래 focused/영향 범위 test, lint, 문서 명령과 수동 검증 통과 및 `Progress` 기록. +- **범위 밖:** Gate 통과를 위한 test 삭제·skip·완화와 관련 없는 코드 수정. + +```bash +./gradlew test \ + --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest" \ + --tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" \ + --tests "kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest" \ + --tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" \ + --tests "kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest" \ + --tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest" \ + --tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest" \ + --tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest" \ + --tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest" +./gradlew ktlintCheck +./gradlew tasks --all +git diff --check +``` + +**Expected:** 모든 명령이 exit code `0`으로 끝난다. JP 판정은 두 API에서 JA 배너만, 그 외 판정은 KO 배너만 반환하며 기존 탭·성인·활성·차단·대상 유효성·정렬·limit와 응답 스키마가 유지된다. + +수동 검증: + +- [x] `git diff --name-only`에 계획된 production/test 파일과 이 작업 문서 이외의 변경이 없는지 확인한다. +- [x] `git diff`에서 endpoint, request/response DTO, DB 스키마, dependency와 `Accept-Language` 사용이 추가되지 않았는지 확인한다. +- [x] 두 Repository 모두 `lang` 조건이 `orderBy`와 `limit`보다 앞선 `where`에 있는지 확인한다. + +전체 회귀 `./gradlew test`는 변경이 국가 판정과 두 배너 조회 경로에 한정되고 위 명령이 application, persistence, controller/E2E를 포함하므로 기본 생략한다. focused test로 영향 범위를 판단할 수 없는 실패가 발생하거나 공통 국가 판정 변경의 회귀 범위가 확대되면 전체 회귀를 실행하고 결과를 `Progress`에 기록한다. + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P1-T1` | 승인된 PRD | 아니요 | 기존 국가 정규화·강제 매핑 테스트와 구현을 다시 대조 | +| 2 | `P1-T2` | `P1-T1` | 아니요 | 홈 application/port/repository 계약을 한 단계씩 대조 | +| 3 | `P1-T3` | `P1-T1` | 아니요 | 콘텐츠 추천 application/port/repository 계약을 한 단계씩 대조 | +| 4 | `P1-GATE` | `P1-T1`~`P1-T3` | 아니요 | 실패를 소유한 Task에 회귀 수정 Goal 추가 | + +```text +P1-T1 → P1-T2 → P1-T3 → P1-GATE +``` + +## 변경 금지 항목 + +- 기존 완료 문서와 검증 기록을 삭제하거나 덮어쓰지 않는다. +- 공개 endpoint, request/response DTO와 API envelope를 변경하지 않는다. +- DB schema, 배너 데이터, 관리자/v1 배너 API를 변경하지 않는다. +- `Accept-Language`나 회원 저장 언어를 국가 판정 대신 사용하지 않는다. +- 조회 후 컬렉션 filter로 언어를 제거하지 않는다. +- 신규 dependency, enum, 공통 abstraction과 관련 없는 리팩터링을 추가하지 않는다. +- test를 삭제·skip·완화하지 않는다. + +## 의사결정 및 중단 규칙 + +- PRD와 구현이 충돌하면 `prd.md`의 Decision Log와 이 계획을 먼저 갱신한 뒤 구현한다. +- `CloudFront-Viewer-Country` 또는 기존 강제 국가 매핑 정책을 바꿔야 하면 범위 확장으로 보고 사용자 승인 전 중단한다. +- `Lang.EN`, 다른 국가별 언어 또는 배너 외 추천 섹션이 필요해지면 별도 요구사항으로 분리한다. +- 공개 API, DB schema 또는 관리자/v1 조회 변경이 필요해지면 사용자 승인 전 진행하지 않는다. +- 체크박스, test와 `Progress` 기록이 모두 충족된 뒤에만 Goal을 완료 처리한다. + +## Progress + +### 2026-08-19 문서 작성 + +- 사용자 인터뷰로 `JP → JA`, 그 외 국가 → `KO` 정책과 로그인 회원의 기존 강제 국가 매핑 적용을 확정했다. +- PRD와 구현 계획만 작성했으며 production/test 코드는 변경하지 않았다. +- placeholder 검색과 `git diff --check`에서 문제가 없음을 확인했다. +- `./gradlew tasks --all`은 최초 sandbox의 Gradle wrapper lock 접근 제한으로 실패했으며, 승인된 동일 명령 재실행에서 `BUILD SUCCESSFUL`을 확인했다. + +### 2026-08-19 P1-T1 완료 + +- RED: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"` 실행 결과 `resolveCountryCode(null)` 4곳이 `Member` non-null 타입 불일치로 `:compileTestKotlin FAILED`가 됨을 확인했다. +- GREEN/REFACTOR: `MemberContentPreferenceService.resolveCountryCode`와 `resolveCountryCodeWithForcedMapping`만 nullable 회원을 받도록 최소 변경했다. 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. + +### 2026-08-19 P1-T2 완료 + +- RED: 홈 facade/service/repository 테스트에 `Lang` 전달과 `content_banner.lang` 조건 기대를 추가한 뒤 focused test를 실행했고, `findHomeBanners`의 `lang` 파라미터 부재로 `:compileTestKotlin FAILED`가 됨을 확인했다. +- GREEN/REFACTOR: 홈 facade에서 `JP`만 `Lang.JA`, 그 외 `Lang.KO`를 한 줄로 선택해 service/port/repository에 전달하고, QueryDSL `where`에 `audioContentBanner.lang.eq(lang)`을 추가했다. 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. + +### 2026-08-19 P1-T3 완료 + +- RED: 콘텐츠 추천 service/repository 테스트에 `Lang.KO` 기본 전달, `JP`의 `Lang.JA` 전달, 추천 탭 배너 언어 분리 기대를 추가한 뒤 focused test를 실행했고, `findBanners`의 `Lang` 인자 부재로 `:compileTestKotlin FAILED`가 됨을 확인했다. +- GREEN/REFACTOR: `AudioRecommendationQueryService.getRecommendations`에서 배너 호출에만 `JP → Lang.JA`, 그 외 `Lang.KO`를 전달하고, QueryDSL `where`에 `audioContentBanner.lang.eq(lang)`을 추가했다. 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. + +### 2026-08-19 P1-GATE 완료 + +- Phase Gate focused/controller/E2E 명령 실행 결과 `BUILD SUCCESSFUL`을 확인했다. +- `./gradlew ktlintCheck`는 최초 실행에서 테스트 import 순서 2곳으로 실패했고, import 정렬 후 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. +- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. +- `git diff --check` 실행 결과 출력 없이 통과했다. +- `git diff --name-only`와 diff 검토로 계획된 production/test 파일 및 이 작업 문서 외 변경이 없고, 공개 endpoint/request/response DTO, DB 스키마, dependency, `Accept-Language` 정책이 추가되지 않았음을 확인했다. +- 두 Repository 모두 `audioContentBanner.lang.eq(lang)` 조건이 `where` 안에서 `orderBy`와 `limit`보다 먼저 적용됨을 확인했다. +- Reviewer Gate에서 요구사항 차단 이슈 없음으로 승인받았다. + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| 2026-08-19 | `DEC-001` | 확정 | 공통 국가 판정을 먼저 nullable 회원까지 확장한 뒤 두 배너 경로가 소비한다. | 기존 강제 매핑·정규화 재사용과 중복 방지 | `P1-T1`~`P1-T3` | +| 2026-08-19 | `DEC-002` | 확정 | 두 port의 `lang`은 기존 직접 호출 호환을 위해 기본값 `Lang.KO`를 사용한다. | 비JP 정책과 기존 KO fixture 일치 | `P1-T2`, `P1-T3` | +| 2026-08-19 | `DEC-003` | 확정 | 국가→언어 변환은 두 application 진입점의 한 줄 정책으로 두고 별도 abstraction을 만들지 않는다. | 사용처가 두 곳뿐인 고정 정책의 최소 구현 | `P1-T2`, `P1-T3` | + +## 발견된 문제 + +### 2026-08-19 2차 리뷰 + +- production 호출 흐름과 QueryDSL 조건에는 요구사항 차단 문제가 없다. +- 두 Repository 언어 테스트가 `limit = 20`으로 배너 2개를 모두 수용해 `BANNER-LANG-005`의 "다른 언어 배너가 limit을 차지하지 않음" 회귀를 직접 방어하지 못하는 테스트 공백을 확인했다. + +## Phase 2: 언어 필터 limit 선행 회귀 테스트 보완 + +**Phase 결과:** 두 Repository 테스트가 언어 조건이 제거되거나 limit 이후로 밀리는 회귀를 직접 검출한다. + +**선행조건:** `P1-GATE` 완료와 2차 리뷰 테스트 공백 확정. + +**Phase 완료 조건:** `P2-T1`과 `P2-GATE` 완료, mutation RED와 복구 후 GREEN 결과 누적. + +### 구현 항목 + +#### Task 2.1 언어 필터가 limit보다 먼저 적용되는 회귀 테스트 (`P2-T1`) + +**Goal 실행 `P2-T1`:** 낮은 `orders`의 반대 언어 배너가 있어도 `limit = 1` 조회는 요청 언어 배너를 반환함을 두 Repository에서 검증한다. + +- **시작 조건:** PRD `BANNER-LANG-005`, 2차 리뷰 결과 확정. +- **완료 증거:** 두 테스트가 정상 구현에서 통과하고, 각 Repository의 언어 조건 제거 mutation에서 의도한 assertion 실패를 보인 뒤 복구 후 다시 통과한다. +- **범위 밖:** production query, 배너 정렬 정책, API 계약 변경. +- **TDD 예외 사유:** production 동작은 이미 올바르고 이번 보완은 기존 테스트가 특정 회귀를 검출하는지 확인하는 테스트 전용 변경이다. +- **대체 검증 방법:** 테스트를 먼저 보강한 뒤 각 Repository의 `audioContentBanner.lang.eq(lang)`을 일시 제거해 실패를 확인하고 즉시 복구한 뒤 같은 테스트의 통과를 확인한다. + +**Files:** + +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt` +- Mutation 후 원상 복구 확인: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` +- Mutation 후 원상 복구 확인: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` + +- [x] **RED:** 각 언어 테스트에서 `orders = 1`인 반대 언어와 `orders = 2`인 요청 언어를 만들고 `limit = 1` 결과가 요청 언어 한 건인지 검증한다. +- [x] **RED 확인:** 홈 Repository의 언어 조건을 일시 제거하고 홈 단일 테스트가 반대 언어를 반환해 실패하는지 확인한 뒤 원상 복구한다. +- [x] **RED 확인:** 콘텐츠 추천 Repository의 언어 조건을 일시 제거하고 콘텐츠 추천 단일 테스트가 반대 언어를 반환해 실패하는지 확인한 뒤 원상 복구한다. +- [x] **GREEN:** production 변경 없이 두 테스트의 최종 fixture와 `limit = 1` assertion만 유지한다. +- [x] **GREEN 확인:** 아래 두 focused test class가 통과하는지 확인한다. +- [x] **REFACTOR:** 테스트 이름·fixture 중복만 최소 정리하고 production diff가 Phase 1 구현과 동일한지 확인한다. + +```bash +./gradlew test \ + --tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" \ + --tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest" +``` + +### Phase 2 Gate (`P2-GATE`) + +- **시작 조건:** `P2-T1` 완료. +- **완료 증거:** Phase 1 Gate focused/controller/E2E 테스트, `ktlintCheck`, `tasks --all`, `git diff --check` 통과와 재리뷰 결과 기록. +- **범위 밖:** 새 production 동작과 관련 없는 테스트 확장. + +```bash +./gradlew test \ + --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest" \ + --tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" \ + --tests "kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest" \ + --tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" \ + --tests "kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest" \ + --tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest" \ + --tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest" \ + --tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest" \ + --tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest" +./gradlew ktlintCheck +./gradlew tasks --all +git diff --check +``` + +**Expected:** 모든 명령이 exit code `0`으로 끝나고, 두 언어 테스트는 반대 언어가 더 낮은 `orders`여도 요청 언어 배너를 한 건 반환한다. + +## Phase 2 Progress + +### 2026-08-19 `P2-T1` 완료 + +- 테스트 보완: 두 Repository 언어 테스트를 `limit = 1`로 강화하고 이름을 limit 선행 동작이 드러나도록 변경했다. +- 홈 mutation RED: `DefaultHomeRecommendationQueryRepository`의 언어 조건을 일시 제거한 단일 테스트가 `DefaultHomeRecommendationQueryRepositoryTest.kt:276`에서 assertion 실패함을 확인하고 즉시 복구했다. +- 콘텐츠 추천 mutation RED: `DefaultAudioRecommendationQueryRepository`의 언어 조건을 일시 제거한 단일 테스트가 `DefaultAudioRecommendationQueryRepositoryTest.kt:126`에서 assertion 실패함을 확인하고 즉시 복구했다. +- GREEN: 복구 후 두 Repository focused test class를 함께 실행해 `BUILD SUCCESSFUL`을 확인했다. + +### 2026-08-19 `P2-GATE` 완료 + +- Phase 1 Gate와 같은 focused/application/Repository/controller/E2E 테스트 9개 class를 실행해 `BUILD SUCCESSFUL`을 확인했다. +- `./gradlew ktlintCheck`와 `./gradlew tasks --all`이 각각 `BUILD SUCCESSFUL`로 끝났다. +- `git diff --check` 결과 오류가 없고, 두 production Repository에 `audioContentBanner.lang.eq(lang)`이 복구된 상태임을 확인했다. +- 최종 독립 재리뷰에서 최초 Important 이슈 해소와 Critical/Important/Minor 추가 이슈 없음 판정을 받았다. +- 전체 회귀 `./gradlew test`는 실행하지 않았다. 변경이 두 기존 Repository 테스트의 limit 경계 강화에 한정되고 Phase Gate가 직접 영향 application/persistence/controller/E2E 범위를 포함하므로 생략했다. diff --git a/docs/20260819_추천탭_배너_접속국가별_언어필터/prd.md b/docs/20260819_추천탭_배너_접속국가별_언어필터/prd.md new file mode 100644 index 00000000..539f8747 --- /dev/null +++ b/docs/20260819_추천탭_배너_접속국가별_언어필터/prd.md @@ -0,0 +1,113 @@ +# 추천 탭 배너 접속 국가별 언어 필터 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 기준 확정 | +| 작성일 | 2026-08-19 | +| 최종 수정일 | 2026-08-19 | +| 대상 제품 | 메인 홈 추천 탭, 메인 콘텐츠 추천 탭 | +| 작성자·결정권자 | 사용자 | +| 관련 API Contract | 신규 없음. 기존 공개 API 계약 유지 | +| 관련 구현 계획 | `docs/20260819_추천탭_배너_접속국가별_언어필터/plan-task.md` | +| 관련 기존 문서 | `docs/20260805_추천탭_배너_조회조건/prd.md`, `docs/20260529_메인_홈_추천_API/prd.md`, `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md` | + +## 1. Overview + +메인 홈 추천 API와 메인 콘텐츠 추천 API에서 접속 국가에 맞는 언어의 배너만 반환한다. 로그인 회원은 기존 강제 국가 매핑을 포함한 국가 판정 결과를 사용하고, 비로그인 사용자는 `CloudFront-Viewer-Country` 요청 헤더를 기준으로 판정한다. + +## 2. Problem Statement + +- 두 추천 API의 현재 배너 QueryDSL 조회에는 `content_banner.lang` 조건이 없다. +- 그 결과 한국어·일본어·영어 배너가 같은 응답에 섞일 수 있다. +- 언어 필터가 조회 후 적용되면 다른 언어 배너가 limit을 차지해 필요한 배너가 누락될 수 있다. + +문제를 해결했다는 판단은 두 API가 배너 정렬과 limit 적용 전에 확정된 언어 조건을 적용하고, 기존 배너 조회 조건과 공개 응답 스키마를 유지하는 것으로 한다. + +## 3. Goals + +- 접속 국가 판정 결과가 `JP`이면 `Lang.JA` 배너만 반환한다. +- `JP` 이외의 국가와 국가 정보가 없는 경우에는 `Lang.KO` 배너만 반환한다. +- 로그인 회원은 `MemberContentPreferenceService.resolveCountryCode`의 기존 강제 국가 매핑을 유지한다. +- 비로그인 사용자도 요청 국가를 판정할 수 있도록 동일한 국가 정규화와 기본값 정책을 사용한다. +- 언어 조건을 DB 조회 단계에서 정렬·limit보다 먼저 적용한다. + +## 4. Non-Goals + +- `Lang.EN` 배너를 반환하는 국가 정책을 추가하지 않는다. +- `Accept-Language`를 배너 언어 판정에 사용하지 않는다. +- `CloudFront-Viewer-Country` 헤더 계약이나 `CountryInterceptor`를 변경하지 않는다. +- 공개 API endpoint, request/response DTO와 응답 스키마를 변경하지 않는다. +- 기존 배너의 탭·성인·활성·차단·대상 유효성·정렬·limit 정책을 변경하지 않는다. +- DB 스키마, 배너 데이터, 관리자 배너 API와 기존 v1 배너 조회를 변경하지 않는다. +- 배너 이외의 추천 섹션에 국가 또는 언어 필터를 추가하지 않는다. + +## 5. 대상 사용자와 국가 판정 + +| 사용자 | 국가 판정 | 언어 선택 | +|---|---|---| +| 강제 JP 매핑 로그인 회원 | 기존 강제 매핑 결과 `JP` | `Lang.JA` | +| 강제 KR 매핑 로그인 회원 | 기존 강제 매핑 결과 `KR` | `Lang.KO` | +| 일반 로그인 회원 | 정규화한 `CloudFront-Viewer-Country`, 누락 시 `KR` | `JP`이면 `JA`, 그 외 `KO` | +| 비로그인 사용자 | 정규화한 `CloudFront-Viewer-Country`, 누락 시 `KR` | `JP`이면 `JA`, 그 외 `KO` | + +국가 코드는 기존 정책처럼 앞뒤 공백을 제거하고 대문자로 정규화한다. + +## 6. 핵심 조회 흐름 + +1. `CountryInterceptor`가 `CloudFront-Viewer-Country`를 `CountryContext`에 저장한다. +2. `HomeRecommendationFacade.getHomeRecommendations`와 `AudioRecommendationQueryService.getRecommendations`가 회원과 요청 국가로 국가 코드를 판정한다. +3. 국가 코드가 `JP`이면 `Lang.JA`, 그 외에는 `Lang.KO`를 선택한다. +4. 선택한 `Lang`을 기존 application → port → persistence 경로로 전달한다. +5. Repository가 `content_banner.lang = :lang`을 기존 조건과 함께 적용한 뒤 정렬하고 limit을 적용한다. + +## 7. 기능 요구사항 + +| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 | +|---|---|---|---|---| +| `BANNER-LANG-001` | 확정 | `GET /api/v2/home/recommendations`는 판정 국가가 `JP`이면 `JA`, 그 외에는 `KO` 홈 배너만 반환한다. | `tab_id IS NULL`인 활성 배너 중 선택 언어와 일치하는 배너만 응답한다. | `P1-T2` | +| `BANNER-LANG-002` | 확정 | `GET /api/v2/audio/recommendations`는 판정 국가가 `JP`이면 `JA`, 그 외에는 `KO` 콘텐츠 추천 배너만 반환한다. | `tab_id = 2`인 활성 배너 중 선택 언어와 일치하는 배너만 응답한다. | `P1-T3` | +| `BANNER-LANG-003` | 확정 | 로그인 회원의 국가 판정은 기존 강제 KR/JP 매핑을 요청 헤더보다 우선한다. | 강제 JP 회원은 비JP 헤더에서도 `JP`, 강제 KR 회원은 JP 헤더에서도 `KR`로 판정된다. | `P1-T1` | +| `BANNER-LANG-004` | 확정 | 비로그인 사용자는 요청 국가를 정규화해 사용하고, 헤더가 없거나 비어 있으면 `KR`로 판정한다. | `JP`·`jp`·공백 포함 JP는 `JP`, null·blank는 `KR`로 판정된다. | `P1-T1` | +| `BANNER-LANG-005` | 확정 | 언어 필터는 정렬·limit 전에 DB 조회 조건으로 적용한다. | 다른 언어 배너가 limit을 차지하지 않고, Repository 테스트가 언어별 결과를 검증한다. | `P1-T2`, `P1-T3` | +| `BANNER-LANG-006` | 확정 | 기존 배너 조회 정책과 공개 API 계약을 유지한다. | 기존 탭·성인·활성·차단·대상 유효성·정렬·limit 테스트와 controller/E2E 회귀가 통과한다. | `P1-GATE` | + +## 8. API 계약 + +| Method | Path | 변경 내용 | +|---|---|---| +| `GET` | `/api/v2/home/recommendations` | 응답 스키마 변경 없이 접속 국가별 배너 언어 조회 조건만 추가한다. | +| `GET` | `/api/v2/audio/recommendations` | 응답 스키마 변경 없이 접속 국가별 배너 언어 조회 조건만 추가한다. | + +- 신규 request header는 추가하지 않는다. +- 기존 `CloudFront-Viewer-Country` 처리 경로를 재사용한다. +- 배너가 없으면 기존처럼 빈 목록을 반환한다. + +## 9. 성능과 품질 요구사항 + +- `content_banner.lang` 조건은 QueryDSL `where`에 포함하고 메모리 후처리를 사용하지 않는다. +- 신규 dependency, 캐시, 공통 resolver abstraction을 추가하지 않는다. +- 기존 `Lang`, `CountryContext`, `MemberContentPreferenceService`를 재사용한다. +- application 전달 테스트와 두 Repository 조회 테스트를 TDD로 보강한다. +- 직접 영향 controller/E2E 회귀와 `ktlintCheck`를 Phase Gate에서 검증한다. + +## 10. 성공 기준 + +- [x] 강제 매핑을 포함한 판정 국가가 `JP`이면 두 API의 배너가 모두 `JA`로 제한된다. (`BANNER-LANG-001~003`) +- [x] 일반 국가와 국가 정보가 없는 경우 두 API의 배너가 모두 `KO`로 제한된다. (`BANNER-LANG-001`, `BANNER-LANG-002`, `BANNER-LANG-004`) +- [x] 다른 언어 배너가 정렬·limit 대상에 포함되지 않는다. (`BANNER-LANG-005`) +- [x] 기존 배너 필터와 공개 응답 계약이 유지된다. (`BANNER-LANG-006`) + +## 11. Open Questions + +- 없음. + +## 12. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal | +|---|---|---|---|---|---| +| 2026-08-19 | `DEC-001` | 확정 | `JP`는 `JA`, 그 외 국가는 `KO` 배너만 조회한다. | 사용자 인터뷰 답변 B | `BANNER-LANG-001`, `BANNER-LANG-002`, `BANNER-LANG-005` | +| 2026-08-19 | `DEC-002` | 확정 | 로그인 회원은 기존 강제 국가 매핑을 포함한 국가 판정을 사용한다. | 사용자 인터뷰 답변 B | `BANNER-LANG-003`, `P1-T1` | +| 2026-08-19 | `DEC-003` | 확정 | 비로그인은 요청 국가를 사용하고 국가 정보가 없으면 기존 기본값 `KR`을 사용한다. | 승인된 설계 | `BANNER-LANG-004`, `P1-T1` | +| 2026-08-19 | `DEC-004` | 확정 | 언어 필터는 Repository 조회 조건으로 적용한다. | limit 이전 필터링과 기존 QueryDSL 패턴 유지 | `BANNER-LANG-005`, `P1-T2`, `P1-T3` |