diff --git a/docs/20260819_이벤트_접속국가별_언어필터/plan-task.md b/docs/20260819_이벤트_접속국가별_언어필터/plan-task.md new file mode 100644 index 00000000..9f538323 --- /dev/null +++ b/docs/20260819_이벤트_접속국가별_언어필터/plan-task.md @@ -0,0 +1,834 @@ +# 이벤트 접속 국가별 언어 필터 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. + +**Goal:** 관리자가 언어를 지정해 이벤트를 등록·식별하고, 앱 이벤트 목록과 팝업이 기존 국가 판정에 맞는 언어만 반환하게 한다. + +**Architecture:** 기존 `Lang`과 `MemberContentPreferenceService.resolveCountryCode(member)`를 재사용한다. `EventRepository`에 nullable `lang` 조건을 추가해 두 앱 API는 KO/JA를 전달하고, 관리자 목록과 콘텐츠 메인 탭의 기존 내부 조회는 null을 전달해 언어 전체 조회를 유지한다. + +**Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, JPA/QueryDSL, JUnit 5, Mockito, MockMvc, Gradle Wrapper, MySQL + +**Spec:** `docs/20260819_이벤트_접속국가별_언어필터/prd.md` + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현 및 회귀 수정 완료 | +| 작성일 | 2026-08-19 | +| 요구사항 기준 | `docs/20260819_이벤트_접속국가별_언어필터/prd.md` | +| API 기준 | PRD 8절 | +| 현재 Phase | Phase 2 완료 | +| 현재 활성 Goal | 없음 | +| 다음 Goal | 없음 | + +## Global Constraints + +- `Event.lang`은 기존 `Lang` enum을 재사용하고 별도 언어 타입을 만들지 않는다. +- 관리자 등록은 `KO`, `EN`, `JA`를 허용하고 누락·잘못된 값은 저장·S3 업로드 전에 거부한다. +- 이벤트 수정 API와 레거시 `POST /event`의 요청 계약은 변경하지 않는다. +- 기존 이벤트와 레거시 등록의 기본 언어는 `KO`다. +- 관리자 목록은 기존 활성·종료 시각 조건을 유지하고 언어 조건을 추가하지 않는다. +- `GET /event`, `GET /event/popup`만 `JP -> Lang.JA`, 그 외 `Lang.KO`를 사용한다. +- 로그인 회원은 기존 강제 KR/JP 매핑을 유지하고, 비로그인·국가 누락은 기존 정규화·KR 기본값을 사용한다. +- 콘텐츠 메인 탭 6곳의 `EventService.getEventList(isAdult)`는 언어 필터 없이 유지한다. +- 언어 필터는 QueryDSL `where`에서 정렬·`fetchFirst()` 전에 적용하고 메모리 후처리를 추가하지 않는다. +- 신규 dependency·cache·resolver abstraction·관련 없는 refactoring을 추가하지 않는다. +- 모든 구현 Task는 RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR 순서를 지킨다. +- 전체 회귀는 focused·직접 영향 테스트로 범위를 판단할 수 없거나 공통 경계 회귀가 발생할 때만 실행한다. + +--- + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `3/3` | 없음 | 없음 | +| 2 | 완료 | `2/2` + Gate + `P2-R1` | 없음 | 없음 | + +- 동시에 하나의 미완료 goal만 운용한다. +- 구현 완료 즉시 해당 Task 체크박스와 현재 상태를 갱신한다. +- 실제 검증 결과는 기존 기록을 덮어쓰지 않고 `Progress`에 누적한다. + +## 범위 + +### 포함 + +- `event.lang` 운영 DDL과 KO backfill +- `Event` 엔티티의 `Lang.KO` 기본 언어 +- 관리자 이벤트 등록 `lang` 입력·검증·저장 +- 관리자 이벤트 목록 `lang` 응답과 언어 전체 조회 +- 앱 이벤트 목록·팝업의 접속 국가별 KO/JA 필터 +- 기존 강제 국가 매핑과 비로그인 KR 기본값 재사용 +- 콘텐츠 메인 탭 6곳의 언어 전체 조회 비회귀 + +### 제외 + +- 이벤트 수정 시 언어 변경 +- 레거시 `POST /event`의 `lang` request parameter +- 콘텐츠 메인 탭 6곳의 국가별 이벤트 필터 +- EN 이벤트를 반환할 앱 국가 정책 +- 다른 언어 fallback, `Accept-Language` 판정, 신규 dependency·공통 abstraction +- 기존 이벤트 성인·활성·게시 기간·정렬·URL 정책 변경 + +## 요구사항별 Goal 매핑 + +| 요구사항 | 소유 Goal | 완료 증거 | +|---|---|---| +| `EVENT-LANG-001` | `P1-T2` | KO/EN/JA 등록과 잘못된 언어 거부 통합 테스트 | +| `EVENT-LANG-002` | `P1-GATE` | 관리자·레거시 수정 API request 스키마 diff 검토 | +| `EVENT-LANG-003` | `P1-T2` | KO/EN/JA 관리자 목록과 `lang` 응답 통합 테스트 | +| `EVENT-LANG-004` | `P2-T1` | JP·비JP 목록 Repository·controller 통합 테스트 | +| `EVENT-LANG-005` | `P2-T1` | 다른 언어의 더 최신 팝업 fixture를 포함한 Repository·controller 통합 테스트 | +| `EVENT-LANG-006` | `P2-T1`, `P2-GATE` | 기존 강제 국가 매핑 회귀와 service Lang 전달 테스트 | +| `EVENT-LANG-007` | `P2-T2` | `lang = null`의 KO/EN/JA 전체 조회 mutation RED·GREEN | +| `EVENT-LANG-008` | `P1-T1`, `P1-GATE` | KO 기본 저장 테스트와 KO backfill DDL 대조 | +| `EVENT-LANG-009` | `P2-T1`, `P2-GATE` | QueryDSL `where` 조건과 정렬·`fetchFirst()` 선행 검증 | + +## 파일 구조 + +| 책임 | 파일 | +|---|---| +| 엔티티 언어 저장 | `src/main/kotlin/kr/co/vividnext/sodalive/event/Event.kt` | +| 운영 DB 이관 | `docs/20260819_이벤트_접속국가별_언어필터/20260819_event_lang_ddl.sql` | +| 관리자 등록·목록 API | `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerController.kt` | +| 관리자 등록 검증·저장 | `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt` | +| 관리자 언어 전체 목록 | `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerRepository.kt` | +| 관리자 응답 | `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/GetAdminEventResponse.kt` | +| 앱 HTTP 진입점 | `src/main/kotlin/kr/co/vividnext/sodalive/event/EventController.kt` | +| 앱 성인·국가·언어 조립 | `src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt` | +| 앱 DB 언어 필터 | `src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt` | +| 관리자 API 통합 검증 | `src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt` | +| 앱 서비스 언어 전달 검증 | `src/test/kotlin/kr/co/vividnext/sodalive/event/EventServiceTest.kt` | +| 앱 Repository 필터·비회귀 검증 | `src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt` | +| 앱 요청 헤더부터 응답까지 검증 | `src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt` | + +## Phase 1: 이벤트 언어 저장과 관리자 API + +**Phase 결과:** 이벤트가 언어를 안전하게 저장하고, 관리자가 KO/EN/JA를 지정해 등록한 뒤 언어 전체 목록에서 식별한다. + +**선행조건:** 승인된 PRD `EVENT-LANG-001~003`, `EVENT-LANG-008`. + +**Phase 완료 조건:** `P1-T1`, `P1-T2`, `P1-GATE` 완료와 실제 검증 결과 누적. + +### Task 1.1 이벤트 언어 모델과 운영 DDL + +**Goal 실행 `P1-T1`:** `Event`가 `Lang`을 필수로 저장하고 기존 데이터를 손실 없이 KO로 이관할 수 있게 한다. + +- **시작 조건:** PRD `EVENT-LANG-008`, `DEC-006`, `DEC-007` 확정. +- **완료 증거:** 엔티티 저장 테스트 RED/GREEN, 재실행 가능한 MySQL DDL, 문서·포맷 검증 기록. +- **범위 밖:** 관리자 request 처리와 앱 언어 조회. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/event/Event.kt` +- Create: `docs/20260819_이벤트_접속국가별_언어필터/20260819_event_lang_ddl.sql` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt` + +**Interfaces:** + +- Consumes: `kr.co.vividnext.sodalive.i18n.Lang`. +- Produces: `Event.lang: Lang = Lang.KO`, DB `event.lang VARCHAR(10) NOT NULL DEFAULT 'KO'`. + +- [x] **RED:** `EventRepositoryTest`에 명시적 `Lang.JA` 저장과 기본 `Lang.KO` 저장을 검증하는 테스트를 작성한다. + +```kotlin +@Test +@DisplayName("이벤트 언어는 명시적 값을 저장하고 누락 시 KO를 사용한다") +fun shouldPersistEventLanguageAndDefaultToKorean() { + val japanese = repository.saveAndFlush(activeEvent("ja").apply { lang = Lang.JA }) + val korean = repository.saveAndFlush(activeEvent("ko")) + + entityManager.clear() + + assertEquals(Lang.JA, repository.findById(japanese.id!!).orElseThrow().lang) + assertEquals(Lang.KO, repository.findById(korean.id!!).orElseThrow().lang) +} +``` + +같은 테스트 파일에 사용할 fixture helper는 아래 계약으로 정의한다. + +```kotlin +private fun activeEvent( + seed: String, + isPopup: Boolean = false +): Event { + return Event( + thumbnailImage = "$seed-thumbnail.png", + detailImage = "$seed-detail.png", + popupImage = if (isPopup) "$seed-popup.png" else null, + link = null, + title = seed, + isAdult = false, + isPopup = isPopup, + startDate = LocalDateTime.now().minusDays(1), + endDate = LocalDateTime.now().plusDays(1) + ) +} +``` + +- [x] **RED 확인:** 아래 focused test를 실행해 `Event` 생성자와 `lang` property가 없어 발생하는 컴파일 실패를 확인한다. + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" +``` + +**Expected RED:** `Cannot find a parameter with this name: lang` 또는 `Unresolved reference: lang`. + +- [x] **GREEN:** `Event`에 다음 필드를 추가한다. 기존 생성자는 기본값으로 KO를 사용하므로 변경하지 않는다. + +```kotlin +@Column(nullable = false) +@Enumerated(value = EnumType.STRING) +var lang: Lang = Lang.KO, +``` + +- [x] **GREEN:** `docs/20260819_이벤트_접속국가별_언어필터/20260819_event_lang_ddl.sql`에 다음 3단계 DDL을 작성한다. + +```sql +SET @schema_name := DATABASE(); + +SET @lang_column_exists := ( + SELECT COUNT(1) + FROM information_schema.columns + WHERE table_schema = @schema_name + AND table_name = 'event' + AND column_name = 'lang' +); + +SET @add_lang_column_sql := IF( + @lang_column_exists = 0, + 'ALTER TABLE `event` ADD COLUMN lang VARCHAR(10) NULL COMMENT ''이벤트 노출 언어'' AFTER title', + 'SELECT ''event.lang already exists'' AS message' +); + +PREPARE add_lang_column_stmt FROM @add_lang_column_sql; +EXECUTE add_lang_column_stmt; +DEALLOCATE PREPARE add_lang_column_stmt; + +UPDATE `event` +SET lang = 'KO' +WHERE lang IS NULL; + +SET @lang_column_nullable := ( + SELECT IS_NULLABLE + FROM information_schema.columns + WHERE table_schema = @schema_name + AND table_name = 'event' + AND column_name = 'lang' +); + +SET @alter_lang_column_sql := IF( + @lang_column_nullable = 'YES', + 'ALTER TABLE `event` MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''이벤트 노출 언어 (KO 기본, EN/JA 등록 가능)''', + 'SELECT ''event.lang already normalized'' AS message' +); + +PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql; +EXECUTE alter_lang_column_stmt; +DEALLOCATE PREPARE alter_lang_column_stmt; +``` + +- [x] **GREEN 확인:** 같은 focused test를 재실행해 JA와 KO 저장이 통과하는지 확인한다. +- [x] **REFACTOR:** 새 언어 타입·converter·migration framework를 추가하지 않고 import와 필드 위치만 정리한다. focused test·`ktlintCheck`·`git diff --check`를 재실행해 `Progress`에 기록한다. + +DDL은 실행 대상 운영 MySQL에 접속하지 않는 코드 작성 단계에서는 자동 실행하지 않는다. 운영 반영 전에 `information_schema` 조회 결과와 백업을 확인하고 검증 환경에서 2회 실행해 재실행 결과를 기록한다. + +### Task 1.2 관리자 언어 등록과 언어 전체 목록 + +**Goal 실행 `P1-T2`:** 관리자가 KO/EN/JA 이벤트를 등록하고 현재 목록 조건 안의 모든 언어를 `lang`과 함께 조회한다. + +- **시작 조건:** `P1-T1` 완료, PRD `EVENT-LANG-001~003`. +- **완료 증거:** 관리자 multipart 등록·잘못된 언어 거부·언어 전체 목록 통합 테스트 RED/GREEN, 수정 API 스키마 미변경 diff 확인. +- **범위 밖:** 수정·삭제 동작 변경, 앱 국가별 조회. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/GetAdminEventResponse.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt` + +**Interfaces:** + +- Consumes: multipart request parameter `lang: String`, `Lang.fromCode(value: String): Lang`. +- Produces: `AdminEventBannerService.save(..., lang: String): Long`, `GetAdminEventResponse.lang: Lang`. +- Preserves: `AdminEventBannerService.update` 시그니처와 `PUT /admin/event/banner` request parameter 목록. + +- [x] **RED:** 관리자 통합 테스트에 `lang=en`으로 등록한 이벤트가 `Lang.EN`으로 저장되는 케이스를 작성한다. + +```kotlin +val thumbnail = MockMultipartFile( + "thumbnail", + "thumbnail.png", + "image/png", + "thumbnail".toByteArray() +) + +mockMvc.perform( + multipart("/admin/event/banner") + .file(thumbnail) + .param("link", "https://event.test/en") + .param("isPopup", "false") + .param("startDate", "2099-01-01 00:00") + .param("endDate", "2099-01-02 00:00") + .param("lang", "en") + .with(user("admin").roles("ADMIN")) +) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + +assertEquals(Lang.EN, eventRepository.findAll().single().lang) +``` + +- [x] **RED:** `lang=fr`을 보내면 `success=false`이고 DB row·S3 upload이 생성되지 않는 케이스를 추가한다. +- [x] **RED:** KO·JA·EN 활성 이벤트를 준비한 뒤 `GET /admin/event/banner`가 세 항목의 `lang`을 모두 반환하는 케이스를 추가한다. + +```kotlin +mockMvc.perform( + get("/admin/event/banner") + .with(user("admin").roles("ADMIN")) +) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.length()").value(3)) + .andExpect(jsonPath("$.data[*].lang").value(containsInAnyOrder("KO", "EN", "JA"))) +``` + +- [x] **RED 확인:** 아래 focused test를 실행해 `lang` request parameter·service parameter·response projection이 없어 발생하는 컴파일 또는 assertion 실패를 확인한다. + +```bash +./gradlew test \ + --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" \ + --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" +``` + +- [x] **GREEN:** controller의 등록 메서드에 `@RequestParam("lang") lang: String`을 추가하고 service 호출의 마지막 인자로 전달한다. +- [x] **GREEN:** service는 엔티티 생성과 S3 업로드 전에 다음 기존 패턴으로 언어를 변환한다. + +```kotlin +val eventLang = try { + Lang.fromCode(lang) +} catch (_: IllegalArgumentException) { + throw SodaException(messageKey = "common.error.invalid_request") +} +``` + +- [x] **GREEN:** `Event` 생성 시 `lang = eventLang`을 넘긴다. 레거시 `EventService.save`는 수정하지 않아 `Lang.KO` 기본값을 사용하게 둔다. +- [x] **GREEN:** `GetAdminEventResponse`에 `lang`을 추가하고 QueryDSL projection에 `event.lang`을 동일 순서로 추가한다. + +```kotlin +data class GetAdminEventResponse @QueryProjection constructor( + val id: Long, + val title: String? = null, + val lang: Lang, + val thumbnailImageUrl: String, + val detailImageUrl: String? = null, + val popupImageUrl: String? = null, + val startDate: String, + val endDate: String, + val link: String? = null, + val isAdult: Boolean? = null, + val isPopup: Boolean +) +``` + +- [x] **GREEN 확인:** focused test를 재실행해 소문자 코드 저장, 잘못된 코드 거부, 언어 전체 목록과 `lang` 응답이 통과하는지 확인한다. +- [x] **REFACTOR:** 수정 API에 `lang`을 추가하지 않고, 기존 `Lang.fromCode`와 `common.error.invalid_request`만 재사용한다. focused test·`ktlintCheck`·`git diff --check`를 재실행해 결과를 기록한다. + +### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** 이벤트 언어 저장과 관리자 등록·전체 목록 계약을 최종 판정한다. + +- **시작 조건:** `P1-T1`, `P1-T2` 완료. +- **완료 증거:** 아래 명령과 수동 diff 검토 통과, `Progress` 기록. +- **범위 밖:** Gate 통과를 위한 테스트 삭제·skip·assertion 완화. + +```bash +./gradlew test \ + --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" \ + --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" +./gradlew ktlintCheck +./gradlew tasks --all +git diff --check +``` + +**Expected:** 모든 명령이 exit code `0`으로 끝난다. KO/EN/JA 등록과 목록 응답이 통과하고, 잘못된 언어는 저장·업로드 전에 거부된다. + +수동 검증: + +- [x] `git diff`에서 `PUT /admin/event/banner`, `PUT /event`, `POST /event`의 request parameter에 `lang`이 추가되지 않았는지 확인한다. +- [x] 관리자 Repository의 `where`에 `event.lang` 조건이 없고 기존 활성·종료 시각 조건이 유지되는지 확인한다. +- [x] DDL이 컬럼 추가 → KO backfill → `NOT NULL DEFAULT 'KO'` 순서이고 모든 신규 컬럼에 COMMENT가 있는지 확인한다. + +## Phase 2: 앱 접속 국가별 이벤트 조회 + +**Phase 결과:** `GET /event`, `GET /event/popup`이 기존 국가 판정에 따라 JA 또는 KO만 반환하고, 콘텐츠 메인 탭 6곳은 기존 언어 전체 조회를 유지한다. + +**선행조건:** `P1-GATE` 완료, PRD `EVENT-LANG-004~007`, `EVENT-LANG-009`. + +**Phase 완료 조건:** `P2-T1`, `P2-T2`, `P2-GATE` 완료와 실제 검증 결과 누적. + +### Task 2.1 앱 목록·팝업 국가별 언어 필터 + +**Goal 실행 `P2-T1`:** 두 앱 API가 판정 국가 JP에서 JA, 그 외에서 KO 이벤트만 DB에서 조회한다. + +- **시작 조건:** `P1-GATE` 완료, PRD `EVENT-LANG-004~006`, `EVENT-LANG-009`. +- **완료 증거:** service 언어 전달, Repository 목록·팝업 필터, 요청 헤더부터 API 응답까지의 RED/GREEN 테스트. +- **범위 밖:** 콘텐츠 메인 탭의 `getEventList(isAdult)` 경로와 이벤트 응답 DTO. +- **범위 내 보정:** 실제 security chain에서 두 앱 GET API의 기존 비로그인 접근 계약을 검증하고, 누락된 `GET /event/popup` exact matcher만 복구한다. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/event/EventController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventServiceTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt` + +**Interfaces:** + +- Consumes: `Member?`, `MemberContentPreferenceService.resolveCountryCode(member: Member?): String`. +- Produces: `EventService.getEventList(member: Member?): GetEventResponse`, `EventService.getEventPopup(member: Member?): EventItem?`. +- Produces: `EventRepository.getEventList(isAdult: Boolean?, lang: Lang?): List`, `getMainEventPopup(isAdult: Boolean, lang: Lang?): EventItem?`. +- Preserves: `EventService.getEventList(isAdult: Boolean?, lang: Lang? = null)`의 null 언어 조회. +- Preserves: 기존 `GET /event` 비로그인 접근과 다른 SecurityConfig matcher 순서·인증 정책. `GET /event/popup`만 exact path로 비로그인 접근을 허용한다. + +- [x] **RED:** `EventServiceTest`에 국가 판정 `JP`가 `Lang.JA`로 목록 Repository에 전달되는 케이스를 추가한다. + +`EventServiceTest`의 fixture에 `MemberContentPreferenceService` mock을 추가하고 `EventService` 생성자에 전달한다. + +```kotlin +private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) +``` + +```kotlin +@Test +@DisplayName("JP 판정 회원의 이벤트 목록은 JA를 조회한다") +fun shouldQueryJapaneseEventListForJapan() { + val member = Member(password = "password", nickname = "jp-user").apply { id = 10L } + Mockito.`when`(authRepository.getAuthIdByMemberId(10L)).thenReturn(null) + Mockito.`when`(preferenceService.resolveCountryCode(member)).thenReturn("JP") + Mockito.`when`(repository.getEventList(false, Lang.JA)).thenReturn(emptyList()) + + service.getEventList(member) + + Mockito.verify(repository).getEventList(false, Lang.JA) +} +``` + +- [x] **RED:** 비로그인·KR 판정 팝업이 `Lang.KO`를 조회하는 케이스와 관리자 목록의 기존 `isAdult = null` 정책을 추가한다. +- [x] **RED:** `EventRepositoryTest`에 KO·JA 활성 이벤트가 함께 있어도 요청 언어의 목록만 반환하는 케이스를 추가한다. +- [x] **RED:** JA 팝업을 먼저 저장하고 더 최신 ID인 KO 팝업을 나중에 저장한 뒤, JA 조회가 앞선 JA 팝업을 반환하는 케이스를 추가한다. + +```kotlin +val japanesePopup = repository.saveAndFlush(activeEvent("ja-popup", Lang.JA, isPopup = true)) +repository.saveAndFlush(activeEvent("newer-ko-popup", Lang.KO, isPopup = true)) + +val result = repository.getMainEventPopup(isAdult = false, lang = Lang.JA) + +assertEquals(japanesePopup.id, result?.id) +``` + +- [x] **RED:** `EventControllerIntegrationTest`에 비로그인 `CloudFront-Viewer-Country: JP`의 목록·팝업이 JA만 반환하고, 헤더 누락 요청이 KO만 반환하는 케이스를 추가한다. + +```kotlin +val japaneseEventId = saveControllerEvent("controller-ja", Lang.JA).id!! +saveControllerEvent("controller-ko", Lang.KO) + +mockMvc.perform( + get("/event") + .header("CloudFront-Viewer-Country", "JP") + .with(anonymous()) +) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.eventList.length()").value(1)) + .andExpect(jsonPath("$.data.eventList[0].id").value(japaneseEventId)) +``` + +`EventControllerIntegrationTest`의 fixture helper는 다음처럼 해당 테스트 파일 내에 정의한다. + +```kotlin +private fun saveControllerEvent(seed: String, lang: Lang): Event { + return eventRepository.saveAndFlush( + Event( + thumbnailImage = "$seed-thumbnail.png", + detailImage = "$seed-detail.png", + popupImage = "$seed-popup.png", + link = null, + title = seed, + lang = lang, + isAdult = false, + isPopup = true, + startDate = LocalDateTime.now().minusDays(1), + endDate = LocalDateTime.now().plusDays(1) + ) + ) +} +``` + +- [x] **RED 확인:** 아래 focused test를 실행해 `MemberContentPreferenceService` 의존성, `Member?` 서비스 시그니처, Repository `lang` 인자와 필터가 없어 발생하는 컴파일·assertion 실패를 확인한다. + +```bash +./gradlew test \ + --tests "kr.co.vividnext.sodalive.event.EventServiceTest" \ + --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" \ + --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest" +``` + +- [x] **GREEN:** `EventService`에 `MemberContentPreferenceService`를 생성자 주입하고 앱 진입점 두 개가 `Member?`를 받도록 최소 변경한다. + +```kotlin +@Transactional(readOnly = true) +fun getEventList(member: Member?): GetEventResponse { + val isAdult = if (member?.role == MemberRole.ADMIN) { + null + } else { + member?.id?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false + } + return getEventList(isAdult = isAdult, lang = resolveEventLang(member)) +} + +@Transactional(readOnly = true) +fun getEventPopup(member: Member?): EventItem? { + val isAdult = member?.id?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false + return getEventPopup(isAdult = isAdult, lang = resolveEventLang(member)) +} + +private fun resolveEventLang(member: Member?): Lang { + return if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO +} +``` + +- [x] **GREEN:** 기존 저수준 조회 메서드는 nullable `lang`을 받고 Repository에 전달한다. + +```kotlin +fun getEventList(isAdult: Boolean? = null, lang: Lang? = null): GetEventResponse + +fun getEventPopup(isAdult: Boolean, lang: Lang? = null): EventItem? +``` + +- [x] **GREEN:** Repository 계약과 구현에 nullable `lang`을 추가하고, null이 아닐 때만 기존 `where`에 언어 조건을 합성한다. + +```kotlin +if (lang != null) { + where = where.and(event.lang.eq(lang)) +} +``` + +- [x] **GREEN:** `EventController.getEventList`, `getEventPopup`은 인증 principal에서 얻은 `Member?`를 그대로 새 service 진입점에 전달한다. + +```kotlin +service.getEventList(member = member) +service.getEventPopup(member = member) +``` + +- [x] **GREEN 확인:** 같은 focused test를 재실행해 JP·비JP 언어 전달, Repository 목록·팝업 필터, CloudFront 헤더부터 응답까지 통과하는지 확인한다. +- [x] **REFACTOR:** 국가→언어 선택은 두 호출이 공유하는 한 줄 helper로만 유지하고 신규 resolver·DTO·response field를 만들지 않는다. focused test·`ktlintCheck`를 재실행해 결과를 기록한다. + +### Task 2.2 콘텐츠 메인 탭 언어 전체 조회 비회귀 + +**Goal 실행 `P2-T2`:** 국가별 필터가 지정된 두 API 밖으로 확산되지 않아 `getEventList(isAdult)` 기존 경로가 KO/EN/JA 전체를 조회함을 고정한다. + +- **시작 조건:** `P2-T1` 완료, PRD `EVENT-LANG-007`. +- **완료 증거:** service null 언어 전달과 Repository 언어 전체 조회 테스트, mutation RED·복구 후 GREEN 기록. +- **범위 밖:** 콘텐츠 메인 탭 service 6곳의 시그니처·응답 변경. +- **TDD 예외 사유:** `P2-T1`에서 nullable 언어로 호환성을 이미 구현한 뒤 기존 경로를 고정하는 테스트 전용 Task다. +- **대체 검증 방법:** null 조건 테스트 추가 후 Repository에 일시로 KO 강제 조건을 넣어 assertion 실패를 확인하고 즉시 복구한 뒤 동일 테스트의 통과를 확인한다. + +**Files:** + +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventServiceTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt` +- Mutation 후 원상 복구 확인: `src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt` + +**Interfaces:** + +- Consumes: `EventService.getEventList(isAdult: Boolean?, lang: Lang? = null)`. +- Produces: `lang = null`이면 `event.lang` 조건을 추가하지 않는 호환성 계약. + +- [x] `EventService.getEventList(isAdult = false)`가 `repository.getEventList(false, null)`을 호출함을 검증한다. +- [x] KO·EN·JA 활성 이벤트를 저장하고 `repository.getEventList(isAdult = false, lang = null)`이 세 건을 모두 반환함을 검증한다. +- [x] Repository의 null guard를 일시로 KO 강제 조건으로 변경해 언어 전체 테스트가 의도한 assertion 실패를 보이는지 확인한다. +- [x] 임시 mutation을 즉시 복구하고 아래 focused test를 재실행해 통과를 확인한다. +- [x] `git diff`에 콘텐츠 메인 탭 6곳의 production 파일 변경이 없는지 확인한다. + +```bash +./gradlew test \ + --tests "kr.co.vividnext.sodalive.event.EventServiceTest" \ + --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" +``` + +### Phase 2 Gate + +**Goal 실행 `P2-GATE`:** 두 앱 API의 국가별 언어 필터와 지정 밖 경로의 비회귀를 최종 판정한다. + +- **시작 조건:** `P2-T1`, `P2-T2` 완료. +- **완료 증거:** 아래 focused·직접 영향 test, lint, 문서 명령, 수동 diff 검토 통과와 `Progress` 기록. +- **범위 밖:** 관련 없는 패키지 테스트 수정과 Gate 통과를 위한 테스트 완화. + +```bash +./gradlew test \ + --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" \ + --tests "kr.co.vividnext.sodalive.event.EventServiceTest" \ + --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" \ + --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest" \ + --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest" +./gradlew ktlintCheck +./gradlew tasks --all +git diff --check +``` + +**Expected:** 모든 명령이 exit code `0`으로 끝난다. 강제 매핑을 포함한 JP 판정은 JA, 그 외은 KO 목록·팝업만 반환하고 null 언어 내부 조회는 KO/EN/JA 모두를 반환한다. + +수동 검증: + +- [x] `EventController`의 변경이 `getEventList`, `getEventPopup`에만 있고 레거시 관리자 등록·수정·삭제 계약은 유지되는지 확인한다. +- [x] `EventRepository`의 언어 조건이 `where`에 있고 팝업 `orderBy(event.id.desc()).fetchFirst()`보다 먼저 적용되는지 확인한다. +- [x] `GetEventResponse`, `EventItem`에 신규 필드가 없고 기존 URL 변환과 성인 조건이 유지되는지 확인한다. +- [x] 콘텐츠 메인 탭 6곳의 production 파일과 응답 DTO가 변경되지 않았는지 확인한다. + +전체 회귀 `./gradlew test`는 기본 생략한다. 변경이 이벤트·관리자 이벤트·기존 국가 판정 소비 경로에 한정되고 Gate가 persistence·service·controller integration을 모두 포함하기 때문이다. focused test로 영향 범위를 판단할 수 없는 실패, 공통 인증·예외·설정 회귀, 또는 사용자 요청이 있을 때만 실행하고 생략 근거와 대체 명령을 `Progress`에 기록한다. + +### Task 2.R1 팝업의 기존 인증 경계 복원 + +**Goal 실행 `P2-R1`:** `REV-EVENT-LANG-001`에서 확인한 `/event/popup` 익명 공개를 제거하고, 인증 사용자의 국가별 언어 필터만 유지한다. + +- **시작 조건:** `P2-GATE` 완료, `reviews/implementation-review.md`의 `REV-EVENT-LANG-001` 확정. +- **완료 증거:** 익명 팝업 401 RED, exact `permitAll()` 제거 후 controller focused GREEN, 직접 영향 5개 클래스와 lint·문서 명령 통과, review·Progress 기록. +- **범위 밖:** `GET /event` 익명 접근, JWT 구현, 언어 선택·Repository 조건, 다른 SecurityConfig matcher. + +**Files:** + +- Modify: `docs/20260819_이벤트_접속국가별_언어필터/prd.md` +- Modify: `docs/20260819_이벤트_접속국가별_언어필터/plan-task.md` +- Modify: `docs/20260819_이벤트_접속국가별_언어필터/reviews/implementation-review.md` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt` + +- [x] **RED:** security-on 통합 테스트에 인증 없는 `GET /event/popup`이 HTTP 401을 반환하는 케이스를 추가한다. +- [x] **RED:** JP·국가 누락 팝업 언어 테스트를 기존 `MemberAdapter` 인증 방식으로 전환해 인증 사용자 경로의 KO/JA 응답을 유지한다. +- [x] **RED 확인:** `EventControllerIntegrationTest`를 실행해 익명 401 테스트가 현재 HTTP 200으로 실패하고, 인증 사용자 팝업 테스트는 통과함을 확인한다. +- [x] **GREEN:** `SecurityConfig`에 추가된 `GET /event/popup` exact `permitAll()` 한 줄만 제거한다. +- [x] **GREEN 확인:** `EventControllerIntegrationTest`를 재실행해 익명 401, 익명 목록 200, 인증 사용자 JP→JA·국가 누락→KO가 모두 통과함을 확인한다. +- [x] **REFACTOR:** 추가 abstraction 없이 테스트 helper만 최소화하고 직접 영향 5개 클래스, `ktlintCheck`, `tasks --all`, `git diff --check`를 실행한다. +- [x] PRD 접근 계약, review 상태와 Progress에 실제 결과를 누적한다. + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P1-T1` | 승인된 PRD | 아니요 | `Event` 실제 테이블·생성자·기존 DDL 패턴 재확인 | +| 2 | `P1-T2` | `P1-T1` | 아니요 | 관리자 controller → service → entity → projection 순으로 계약 대조 | +| 3 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패를 소유한 Task에 회귀 수정 Goal 추가 | +| 4 | `P2-T1` | `P1-GATE` | 아니요 | 국가 판정 → Lang 전달 → QueryDSL 조건 순으로 대조 | +| 5 | `P2-T2` | `P2-T1` | 아니요 | null 조건 호출자와 mutation 복구 상태 확인 | +| 6 | `P2-GATE` | Phase 2 Task 전체 | 아니요 | 실패를 소유한 Task에 회귀 수정 Goal 추가 | +| 7 | `P2-R1` | `P2-GATE`, `REV-EVENT-LANG-001` | 아니요 | 기준 HEAD matcher와 security-on controller test 재대조 | + +```text +P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE → P2-R1 +``` + +## 변경 금지 항목 + +- 확정된 PRD와 API 계약을 근거 없이 변경하지 않는다. +- 이벤트 수정 API와 레거시 `POST /event`에 `lang`을 추가하지 않는다. +- 관리자 목록과 콘텐츠 메인 탭 내부 조회에 언어 조건을 추가하지 않는다. +- `EN` 노출 국가, 다른 언어 fallback, `Accept-Language` 정책을 추가하지 않는다. +- `Lang`, `MemberContentPreferenceService`, `CountryContext`와 중복되는 enum·converter·resolver를 만들지 않는다. +- 조회 후 collection filter로 언어를 제거하지 않는다. +- 요청 범위 밖 production 파일·dependency·설정을 변경하지 않는다. +- 테스트를 삭제·skip·완화하거나 타입 오류를 우회해 Gate를 통과하지 않는다. +- JWT, password, signed URL, 파일 본문과 실제 회원 식별자를 문서·fixture·로그에 남기지 않는다. + +## 의사결정 및 중단 규칙 + +- PRD와 현재 구현이 충돌하면 `prd.md` Decision Log와 이 계획을 먼저 갱신한 뒤 구현한다. +- `CloudFront-Viewer-Country`, 기존 강제 KR/JP 매핑 또는 누락 시 KR 정책을 변경해야 하면 범위 확장으로 보고 사용자 승인 전 중단한다. +- 수정 API 언어 변경, 레거시 등록 언어 입력, 콘텐츠 메인 탭 필터가 필요해지면 구현 전에 새 Decision Log와 Task를 추가한다. +- 실제 DB 테이블명이 `event`가 아니거나 DDL 권한·백업이 준비되지 않았으면 DDL 반영만 중단하고 확인 결과를 기록한다. +- 동일한 차단 사유가 최초 시도와 자동 후속을 포함해 3회 연속 반복되고 문서화나 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다. +- 체크박스·focused test·완료 증거·Progress 기록이 모두 충족된 뒤에만 goal을 `complete`로 갱신한다. + +## Progress + +기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다. + +### 문서 작성 — 2026-08-19 + +- 상태: 구현 대기 +- 무엇을: 사용자 인터뷰 결정을 반영한 PRD와 goal 실행형 구현 계획을 작성했다. +- 왜: 구현 전 PRD·`plan-task.md` 준비 규칙과 코드 구현 금지 요청을 충족하기 위해서다. +- 어떻게: 기존 이벤트·콘텐츠 배너·국가 판정·DDL·테스트 패턴을 대조했고, 모호성 0.02까지 인터뷰한 설계를 사용자가 승인했다. +- 검증: + - placeholder·트레일링 공백·요구사항 ID 누락 검사 결과 문제가 없었다. + - 두 신규 문서에 `git diff --no-index --check` 적용 시 신규 파일 diff를 의미하는 exit code `1`이고 whitespace 오류 출력은 없었다. + - `./gradlew tasks --all`은 최초 sandbox에서 Gradle wrapper lock 접근 제한으로 실패했고, 승인된 동일 명령 재실행에서 `BUILD SUCCESSFUL`을 확인했다. + - `git status --short`에는 이 작업의 신규 문서 디렉터리만 표시되었다. +- 남은 항목: `P1-T1` 이후 구현 전체. production·test·DDL 파일은 아직 변경하지 않았다. +- 다음 행동: 별도 구현 요청이 있을 때 `P1-T1` RED부터 시작한다. + +### P1-T1 이벤트 언어 모델과 운영 DDL — 2026-08-20 + +- 상태: 완료 +- 무엇을: `Event.lang`을 `EnumType.STRING`, `nullable = false`, 기본값 `Lang.KO`로 추가하고, JA/KO 영속성 테스트와 3단계 멱등 MySQL DDL을 작성했다. +- 왜: 신규 이벤트의 언어를 필수 저장하고 기존 행은 손실 없이 KO로 이관하기 위해서다. +- 어떻게: 기존 `Lang`, JPA enum 매핑, `@DataJpaTest(properties = ["spring.cache.type=none"])`, `QueryDslConfig` 패턴만 재사용했다. +- 검증: + - RED: `./gradlew test --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"` 실행 시 `Unresolved reference: lang` 2건과 `Cannot find a parameter with this name: lang` 1건으로 `:compileTestKotlin`이 실패했고 `BUILD FAILED in 20s`를 확인했다. + - GREEN: 같은 focused test 재실행 결과 `BUILD SUCCESSFUL in 48s`, `10 actionable tasks: 8 executed, 2 up-to-date`를 확인했다. + - REFACTOR: `./gradlew ktlintCheck` 결과 `BUILD SUCCESSFUL in 22s`, `7 actionable tasks: 5 executed, 2 up-to-date`를 확인했다. + - `git diff --check`는 exit code `0`이며 출력이 없었다. + - 운영 DDL은 계획 범위에 따라 데이터베이스에 실행하지 않았다. + - 전체 회귀 테스트는 엔티티 필드·focused persistence test·DDL만 변경했고 직접 영향 범위를 focused test로 확인할 수 있어 생략했다. + - 품질 수정: fixture의 `lang` 기본 파라미터와 `Event` 전달을 제거해 KO 케이스가 실제 `Event.lang = Lang.KO` 생성자 기본값을 사용하도록 했다. JA 케이스는 생성 후 `Lang.JA`를 명시 설정해 비기본 enum round-trip을 유지했다. + - 품질 수정 재검증: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"` 결과 `BUILD SUCCESSFUL in 51s`, `10 actionable tasks: 3 executed, 7 up-to-date`를 확인했다. + - 품질 수정 재검증: `./gradlew --no-daemon ktlintCheck` 결과 `BUILD SUCCESSFUL in 22s`, `7 actionable tasks: 2 executed, 5 up-to-date`를 확인했고, `git diff --check`는 exit code `0`이며 출력이 없었다. +- 남은 항목: `P1-T2` 이후 구현 전체. 어떤 Gate도 완료 처리하지 않았다. +- 다음 행동: 별도 구현 요청이 있을 때 `P1-T2` RED부터 시작한다. + +### P1-T2 관리자 언어 등록과 언어 전체 목록 — 2026-08-20 + +- 상태: 완료 +- 무엇을: 관리자 이벤트 등록에 필수 `lang`을 추가해 소문자 코드를 `KO`·`EN`·`JA`로 저장하고, 관리자 목록 응답에 모든 활성·미만료 이벤트의 `lang`을 추가했다. +- 왜: 잘못된 언어가 DB나 S3에 부작용을 만들기 전에 거부되고 관리자가 언어별 이벤트를 식별하게 하기 위해서다. +- 어떻게: 기존 `Lang.fromCode`와 `SodaException(messageKey = "common.error.invalid_request")`만 재사용하고, 관리자 QueryDSL projection에 `event.lang`만 추가했다. 조회 `where`는 기존 활성·종료 시각 조건만 유지했다. +- 검증: + - RED: `./gradlew test --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest"` 결과 `shouldPersistLowercaseLanguageWhenAdminCreatesEventBanner`, `shouldRejectInvalidLanguageWithoutPersistenceOrUpload`, `shouldRejectMissingLanguageWithoutPersistenceOrUpload`, `shouldReturnAllActiveNonExpiredLanguagesForAdmin` 4개가 기능 미지원 assertion으로 실패했고 `BUILD FAILED in 49s`를 확인했다. + - RED: 계획의 두 focused test를 함께 실행한 결과 `EventRepositoryTest.kt:48 Unresolved reference: lang`으로 `:compileTestKotlin`이 실패했고 `BUILD FAILED in 7s`를 확인했다. + - GREEN: `./gradlew test --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"` 결과 6개 테스트가 통과했고 `BUILD SUCCESSFUL in 36s`, `10 actionable tasks: 3 executed, 7 up-to-date`를 확인했다. + - 최종 focused 재검증: `./gradlew cleanTest test --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"`로 `shouldPersistLowercaseLanguageWhenAdminCreatesEventBanner`, `shouldRejectInvalidLanguageWithoutPersistenceOrUpload`, `shouldRejectMissingLanguageWithoutPersistenceOrUpload`, `shouldReturnAllActiveNonExpiredLanguagesForAdmin`와 Repository의 `shouldPersistEventLanguageAndDefaultToKorean`, `shouldReturnAllActiveNonExpiredLanguagesForAdmin`을 실제 재실행했다. 6개 모두 통과했고 `BUILD SUCCESSFUL in 58s`, `11 actionable tasks: 2 executed, 9 up-to-date`를 확인했다. + - REFACTOR: 최초 `ktlintCheck`가 관리자 projection 들여쓰기 5건을 검출해 바로 정리했고, 재실행 결과 `BUILD SUCCESSFUL in 23s`, `7 actionable tasks: 2 executed, 5 up-to-date`를 확인했다. + - `git diff --check`는 exit code `0`이며 출력이 없었다. + - 수동 diff에서 `PUT /admin/event/banner`, `PUT /event`, `POST /event` 요청 파라미터와 `EventController`·`EventService`·`EventRepository`가 변경되지 않았고, 관리자 Repository `where`에 언어 조건이 없음을 확인했다. + - 전체 회귀 테스트는 관리자 이벤트 등록·projection과 직접 영속성 경계를 focused Spring 통합·Repository 테스트가 모두 포함하므로 계획 기준에 따라 생략했다. +- 남은 항목: `P1-GATE` 이후 구현 전체. `P1-GATE`는 완료 처리하지 않았다. +- 다음 행동: 별도 요청이 있을 때 `P1-GATE`를 실행한다. + +### P1-GATE 최종 검증 — 2026-08-20 + +- 상태: 완료 +- 무엇을: P1-T1·P1-T2 구현의 focused test, lint, Gradle task 목록, diff whitespace와 세 가지 수동 계약을 최종 검증했다. +- 검증: + - `./gradlew test --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"` — exit code `0`, `BUILD SUCCESSFUL in 3s`, 6개 테스트 대상. + - `./gradlew ktlintCheck` — exit code `0`, `BUILD SUCCESSFUL in 10s`. + - `./gradlew tasks --all` — exit code `0`, `BUILD SUCCESSFUL in 9s`. + - `git diff --check` — exit code `0`, 출력 없음. + - 수동 검증 1: diff에서 `PUT /admin/event/banner`, `PUT /event`, `POST /event` request parameter에 `lang` 추가 없음. + - 수동 검증 2: 관리자 Repository `where`는 `event.isActive.isTrue`와 `event.endDate.goe(now)`만 유지하고 `event.lang` 조건 없음. + - 수동 검증 3: DDL이 컬럼 추가 → `KO` backfill → `NOT NULL DEFAULT 'KO'` 순서이며 신규 컬럼 정의에 COMMENT 포함. + - `git status --short --untracked-files=all`과 diff를 확인했으며, 요청대로 plan-task.md만 수정했다. Gradle은 deprecated features 경고를 출력했지만 모든 명령은 exit code `0`이었다. +- 다음 행동: `P2-T1` RED부터 시작한다. P2 체크박스는 변경하지 않았다. + +### P2-T1 앱 목록·팝업 국가별 언어 필터 — 2026-08-20 + +- 상태: 완료 +- 무엇을: `GET /event`, `GET /event/popup`이 기존 국가 판정 결과 `JP`에서는 `Lang.JA`, 그 외에는 `Lang.KO`를 QueryDSL `where`에서 적용하도록 구현했다. +- 왜: 정렬과 `fetchFirst()` 전에 언어를 제한해 더 최신인 다른 언어 팝업이 요청 언어 팝업을 가리는 문제를 막기 위해서다. +- 어떻게: controller는 `Member?`를 그대로 전달하고, service는 기존 `MemberContentPreferenceService.resolveCountryCode(member)`와 한 줄 언어 선택 helper를 재사용했다. 저수준 service·Repository 메서드는 nullable `lang = null` 기본값을 유지했으며 콘텐츠 메인 production 호출자 6곳은 수정하지 않았다. +- 검증: + - RED: `./gradlew test --tests "kr.co.vividnext.sodalive.event.EventServiceTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest"` 실행 시 `memberContentPreferenceService` 생성자 인자, `Member?` service overload, Repository `lang` 인자가 없어 `:compileTestKotlin`이 예상대로 실패했고 `BUILD FAILED in 12s`를 확인했다. + - GREEN: `./gradlew cleanTest test --tests "kr.co.vividnext.sodalive.event.EventServiceTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest"` 결과 11개 테스트가 통과했고 `BUILD SUCCESSFUL in 39s`를 확인했다. + - service test ID: `shouldQueryJapaneseEventListForJapan`, `shouldQueryKoreanEventPopupForAnonymousKorea`, `shouldKeepAdminAdultFilterNullWhileApplyingResolvedLanguage`. + - Repository test ID: `shouldFilterEventListByKoreanAndJapaneseLanguage`, `shouldFilterPopupByLanguageBeforeSelectingNewestEvent`. JA 팝업을 먼저, 더 최신 KO 팝업을 나중에 저장한 뒤 JA를 반환함을 확인했다. + - controller 통합 test ID: `shouldReturnOnlyJapaneseEventListForAnonymousJapanRequest`, `shouldReturnOnlyJapanesePopupForAnonymousJapanRequest`, `shouldReturnOnlyKoreanEventListWhenCountryHeaderIsMissing`, `shouldReturnOnlyKoreanPopupWhenCountryHeaderIsMissing`. `MemberContentPreferenceService`를 stubbing하지 않고 실제 `CountryInterceptor`와 request-scoped `CountryContext`를 통과시켰다. + - 통합 테스트는 P2-T1 국가·언어 경계에 집중하도록 security filter를 제외하고 인증 정보 없는 요청을 사용했다. 실제 security filter 포함 진단에서 기존 `/event/popup` 익명 요청은 401이었으며 SecurityConfig 변경은 이 Goal의 production 파일 범위 밖이라 변경하지 않았다. + - REFACTOR: 최초 `./gradlew ktlintCheck`가 신규 Repository fixture 들여쓰기 6건을 검출해 정리했고, 재실행 결과 `BUILD SUCCESSFUL in 22s`를 확인했다. + - 최종 focused 재검증: 같은 `cleanTest` 포함 3개 테스트 클래스 명령으로 11개 테스트를 다시 실행해 `BUILD SUCCESSFUL in 1m 26s`를 확인했다. + - 최종 lint 재검증: `./gradlew ktlintCheck` 결과 `BUILD SUCCESSFUL in 13s`를 확인했다. + - `./gradlew tasks --all` 결과 `BUILD SUCCESSFUL in 9s`를 확인했다. + - `git diff --check`는 exit code `0`이며 출력이 없었다. + - 전체 회귀 테스트는 변경이 Event controller/service/Repository와 해당 persistence·Spring MVC 통합 경계에 한정되고 focused 11개 테스트로 직접 영향 범위를 확인할 수 있어 계획 기준에 따라 생략했다. +- 남은 항목: `P2-T2`, `P2-GATE`. 두 Goal은 완료 처리하지 않았다. + +### P2-T2 콘텐츠 메인 탭 언어 전체 조회 비회귀 — 2026-08-20 + +- 상태: 완료 +- 무엇을: 저수준 `EventService.getEventList(isAdult = false)`의 `repository.getEventList(false, null)` 전달과 null 언어의 활성 KO·EN·JA 전체 조회 회귀 테스트를 추가했다. +- 검증: + - service test `shouldQueryNonAdultEventListWithoutLanguage`가 `getEventList(false, null)` 호출을 검증한다. + - Repository test `shouldReturnAllActiveLanguagesWhenLanguageIsNull`이 활성 KO·EN·JA 이벤트를 저장하고 세 이벤트 ID와 count 3을 검증한다. + - RED mutation: `EventRepository.kt`의 nullable `lang` guard에 임시 `else event.lang.eq(Lang.KO)`를 추가한 뒤 Repository focused test가 `EventRepositoryTest.kt:101` assertion failure로 종료됨을 확인했다. + - GREEN: mutation을 즉시 제거해 원래 `if (lang != null)` guard를 복구한 뒤 focused EventService/EventRepository test가 `BUILD SUCCESSFUL`로 통과했다. + - 콘텐츠 메인 탭 production 6곳 변경 여부와 `EventRepository` 복구 상태를 diff에서 확인했다. + - `./gradlew ktlintCheck`와 `git diff --check`를 재실행했다. +- 남은 항목: `P2-GATE`. Gate는 완료 처리하지 않았다. +- 다음 행동: `P2-GATE`를 실행한다. + +### P2-T1 QA 보안 경계 발견 — 2026-08-20 + +- 상태: 교정 진행 중 +- 발견: `EventControllerIntegrationTest`가 `@AutoConfigureMockMvc(addFilters = false)`로 전체 security filter를 제외해 실제 익명 접근 계약을 검증하지 못했다. 실제 `SecurityConfig`에는 `GET /event`만 `permitAll()`이고 `GET /event/popup` exact matcher가 없어 익명 팝업 요청이 401이었다. +- 계획 보정: P2-T1 production Files에 `SecurityConfig.kt`를 추가하고, 범위를 `GET /event/popup` exact matcher 한 줄로 제한했다. `/event/**` 광역 허용, JWT/auth filter 수정, 다른 matcher 변경은 범위 밖이다. +- 다음 행동: security filter를 켠 controller 통합 테스트로 목록 통과와 팝업 401 RED를 확인한 뒤 exact matcher를 추가한다. + +### P2-T1 QA 보안 경계 교정 완료 — 2026-08-20 + +- 상태: 완료 +- 무엇을: `EventControllerIntegrationTest`에서 security filter 제외를 제거하고, `SecurityConfig`에 `HttpMethod.GET`, `/event/popup` exact `permitAll()` matcher만 추가했다. +- RED: `./gradlew cleanTest test --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest"` 결과 4개 중 `shouldReturnOnlyJapanesePopupForAnonymousJapanRequest`, `shouldReturnOnlyKoreanPopupWhenCountryHeaderIsMissing` 2개가 각각 `Status expected:<200> but was:<401>`로 실패했다. 목록 2개는 통과했고 `BUILD FAILED in 46s`였다. +- GREEN: exact matcher 추가 후 같은 security-on controller 통합 테스트 4개가 모두 통과했고 `BUILD SUCCESSFUL in 52s`였다. +- 최종 focused: `./gradlew cleanTest test --tests "kr.co.vividnext.sodalive.event.EventServiceTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest"` 결과 11개 테스트가 통과했고 `BUILD SUCCESSFUL in 1m 21s`였다. +- 최종 검증: `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 36s`, `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 13s`, `git diff --check`는 exit code `0`이며 출력이 없었다. +- 수동 검증: `@AutoConfigureMockMvc`가 실제 security chain을 사용하고 요청은 security postprocessor 없이 익명으로 전송된다. `CountryInterceptor`와 request-scoped `CountryContext` 경로를 유지했으며 `/event/**` 광역 허용이나 다른 SecurityConfig 규칙·순서 변경은 없다. +- 남은 항목: `P2-T2`, `P2-GATE`. 두 Goal은 완료 처리하지 않았다. +- 다음 행동: `P2-T2`에서 nullable 언어의 콘텐츠 메인 전체 조회 비회귀를 mutation RED·GREEN으로 고정한다. + +### P2-GATE 최종 검증 — 2026-08-20 + +- 상태: 완료 +- 무엇을: 앱 이벤트 목록·팝업의 국가별 언어 필터, 관리자 이벤트 직접 영향 경로, 기존 국가 판정, 콘텐츠 메인 언어 전체 조회와 네 가지 수동 계약을 최종 판정했다. +- 검증: + - `./gradlew cleanTest test --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" --tests "kr.co.vividnext.sodalive.event.EventServiceTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"` — exit code `0`, `BUILD SUCCESSFUL in 48s`, `11 actionable tasks: 2 executed, 9 up-to-date`. `cleanTest` 후 5개 클래스의 27개 테스트를 실제 실행했고 클래스별 `4 + 4 + 5 + 4 + 10`, failures `0`, errors `0`, skipped `0`이었다. + - `./gradlew ktlintCheck` — exit code `0`, `BUILD SUCCESSFUL in 1s`, `7 actionable tasks: 7 up-to-date`. + - `./gradlew tasks --all` — exit code `0`, `BUILD SUCCESSFUL in 6s`, `1 actionable task: 1 executed`. + - `git diff --check` — exit code `0`, 출력 없음. + - security-on 익명 검증: `EventControllerIntegrationTest`는 `@AutoConfigureMockMvc`에서 filter를 끄지 않고 security postprocessor 없이 `GET /event`, `GET /event/popup`을 호출한다. JP·국가 누락 목록·팝업 4개가 모두 통과했고 `SecurityConfig` diff는 `GET /event/popup` exact `permitAll()` 한 줄뿐이다. + - 수동 검증 1: `EventController` diff는 `getEventList`, `getEventPopup`의 service 전달만 변경했다. 레거시 `POST /event`, `PUT /event`, `DELETE /event/{id}`와 관리자 수정·삭제 파라미터는 변경되지 않았다. + - 수동 검증 2: 목록·팝업의 `event.lang.eq(lang)`은 QueryDSL `where`에 합성되고, 팝업 `.where(where)` 뒤의 `.orderBy(event.id.desc()).fetchFirst()`보다 먼저 적용된다. + - 수동 검증 3: `GetEventResponse`와 같은 파일의 `EventItem` diff는 없었다. `EventService`의 기존 URL 변환은 유지됐고 Repository의 활성·기간·성인 조건과 ID 내림차순 정렬은 언어 조건과 함께 focused test로 통과했다. + - 수동 검증 4: `git diff HEAD --name-only -- src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab`과 `GetEventResponse.kt` 확인 결과 출력이 없었다. 기존 6개 production 호출자는 모두 `eventService.getEventList(isAdult = isAdult)`를 유지한다. + - `git status --short --untracked-files=all`로 staged·unstaged·untracked 파일을 확인했으며 Gate 중 production·test·DDL은 수정하지 않았다. +- 전체 회귀 생략: 변경이 이벤트·관리자 이벤트·기존 국가 판정 소비 경로에 한정되고, 위 5개 클래스가 persistence·service·security-on controller integration과 기존 국가 강제 매핑을 직접 포함해 영향 범위를 판정할 수 있었다. 계획 592행의 확장 조건에 해당하는 실패나 공통 경계 회귀가 없어 `./gradlew test` 전체 실행은 생략했다. +- PRD 감사: 11절 8개 성공 기준을 테스트·diff·DDL로 개별 대조해 구현 증거가 충분한 7개를 완료 처리했다. 운영 DB DDL은 실행 금지 범위이므로 실제 기존 row 이관 완료 항목 1개는 증명하지 않고 미완료로 유지했다. +- 남은 구현 Goal: 없음. + +### P2-R1 팝업의 기존 인증 경계 복원 — 2026-08-20 + +- 상태: 완료 +- 발견: 독립 구현 리뷰에서 기준 HEAD의 `GET /event/popup`은 `anyRequest().authenticated()`를 적용받지만, 구현 중 exact `permitAll()`이 추가되어 익명 공개로 확장된 점을 `REV-EVENT-LANG-001`로 확정했다. +- 계획 선반영: production 변경 전에 `Task 2.R1`, `PLAN-DEC-005`, review 문서를 추가하고 PRD의 기존 접근 계약을 기준 HEAD와 일치시켰다. +- RED: security-on `EventControllerIntegrationTest`에 익명 팝업 401을 추가하고 JP·국가 누락 팝업을 `MemberAdapter` 인증 사용자로 전환했다. 5개 중 신규 테스트 1개만 `Status expected:<401> but was:<200>`으로 실패했고, 인증 팝업 2개와 익명 목록 2개는 통과했다. `BUILD FAILED in 34s`였다. +- GREEN: `SecurityConfig`의 신규 `GET /event/popup` exact `permitAll()` 한 줄을 제거했다. 같은 controller 통합 테스트 5개가 모두 통과했고 `BUILD SUCCESSFUL in 38s`였다. +- 직접 영향 회귀: `./gradlew --no-daemon cleanTest test`에 관리자 controller, 이벤트 service/repository/controller, 기존 국가 판정 통합 5개 클래스를 지정해 `4 + 4 + 5 + 5 + 10 = 28`개를 실행했다. failures `0`, errors `0`, skipped `0`, `BUILD SUCCESSFUL in 42s`였다. +- 정적 검증: `./gradlew --no-daemon ktlintCheck`는 `BUILD SUCCESSFUL in 22s`, `./gradlew --no-daemon tasks --all`은 `BUILD SUCCESSFUL in 5s`, `git diff --check HEAD`는 exit code `0`이고 출력이 없었다. +- 수동 검증: 기준 HEAD 대비 `SecurityConfig.kt` diff가 없고, `GET /event` 익명 matcher와 마지막 `anyRequest().authenticated()`가 유지된다. 언어 predicate 두 곳은 QueryDSL `where`에 있으며 콘텐츠 메인 6개 호출자는 기존 `getEventList(isAdult = isAdult)`를 유지한다. +- 독립 재리뷰: 수정 후 최종 diff·PRD·plan·review를 다시 검토해 Critical·Important·Minor 모두 추가 발견 없음, Ready to merge로 판정했다. +- 전체 회귀 생략: security production 변경을 기준 HEAD 상태로 복원했고, 직접 영향 28개가 관리자 등록·목록, persistence, service, security-on controller, 강제 국가 매핑을 포함한다. focused 결과로 영향 범위를 판정할 수 없는 실패가 없어 전체 `./gradlew test`로 확장하지 않았다. +- 남은 구현 Goal: 없음. + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| 2026-08-19 | `PLAN-DEC-001` | 확정 | 기존 `Lang`, `Lang.fromCode`, `MemberContentPreferenceService.resolveCountryCode` 조합만 재사용한다. | 중복 없는 최소 구현과 기존 콘텐츠·추천 배너 패턴 | `P1-T2`, `P2-T1` | +| 2026-08-19 | `PLAN-DEC-002` | 확정 | Repository `lang`은 nullable로 두고 두 앱 API만 non-null KO/JA를 전달한다. | 지정된 API만 필터하고 기존 내부 호출을 변경하지 않기 위함 | `P2-T1`, `P2-T2` | +| 2026-08-19 | `PLAN-DEC-003` | 확정 | 국가→언어 변환은 `EventService`의 작은 private helper로 두 앱 조회가 공유한다. | controller의 정책 중복을 피하면서 별도 resolver는 만들지 않는 최소 경계 | `P2-T1` | +| 2026-08-19 | `PLAN-DEC-004` | 확정 | 언어 필터는 QueryDSL `where`에서 적용하고 팝업 정렬·`fetchFirst()` 이전임을 테스트한다. | 잘못된 언어의 최신 팝업이 선택되는 회귀 방지 | `P2-T1`, `P2-GATE` | +| 2026-08-20 | `PLAN-DEC-005` | 확정 | `GET /event`의 익명 접근은 유지하고 `GET /event/popup`은 기준 HEAD의 인증 필수 경계를 복원한다. | 언어 필터 요청과 무관한 보안 범위 확장을 제거 | `P2-R1`, `REV-EVENT-LANG-001` | + +## 발견된 문제 + +- `REV-EVENT-LANG-001`: `GET /event/popup`이 기준 HEAD와 달리 익명 공개된 문제. `P2-R1`에서 수정 완료. + +## 최종 보고 규칙 + +- 첫 문장에 완료한 Phase와 사용자가 얻는 결과를 적는다. +- 변경 항목에 실제 수정한 주요 파일과 동작을 적는다. +- 결정 항목에 적용한 Decision Log ID와 내용을 적는다. +- 검증 항목에 실행 명령·실제 결과·수동 검증 결과를 적는다. +- 전체 회귀를 실행하지 않았으면 생략 근거와 대신 실행한 focused·직접 영향 명령을 적는다. +- 남은 항목과 갱신한 PRD·plan·review 경로를 적는다. +- 성공을 추정하지 않고 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 전달한다. diff --git a/docs/20260819_이벤트_접속국가별_언어필터/prd.md b/docs/20260819_이벤트_접속국가별_언어필터/prd.md new file mode 100644 index 00000000..61b407c4 --- /dev/null +++ b/docs/20260819_이벤트_접속국가별_언어필터/prd.md @@ -0,0 +1,204 @@ +# 이벤트 접속 국가별 언어 필터 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 기준 확정 | +| 작성일 | 2026-08-19 | +| 최종 수정일 | 2026-08-20 | +| 대상 제품 | 관리자 이벤트 배너, 앱 이벤트 목록·팝업 | +| 작성자·결정권자 | 사용자 | +| 관련 API Contract | 별도 문서 없음. 이 문서 8절을 기준으로 사용 | +| 관련 구현 계획 | `docs/20260819_이벤트_접속국가별_언어필터/plan-task.md` | +| 관련 기존 문서 | `docs/20260819_추천탭_배너_접속국가별_언어필터/prd.md` | + +## 1. Overview + +관리자가 이벤트를 등록할 때 기존 콘텐츠 배너처럼 언어를 지정한다. 관리자 목록은 언어로 필터링하지 않고 각 이벤트의 언어를 표시한다. 앱의 `GET /event`, `GET /event/popup`은 기존 접속 국가 판정 결과가 `JP`이면 일본어, 그 외에는 한국어 이벤트만 반환한다. + +## 2. Problem Statement + +- `Event`에는 언어 필드가 없어 관리자가 한국어·일본어 이벤트를 구분해 등록할 수 없다. +- 앱 이벤트 목록과 팝업 조회는 접속 국가를 고려하지 않아 여러 언어의 이벤트가 섞일 수 있다. +- 팝업을 조회한 뒤 메모리에서 언어를 걸러내면 다른 언어의 최신 팝업이 `fetchFirst()`를 차지해 필요한 팝업을 놓칠 수 있다. + +문제를 해결했다는 판단은 관리자가 허용된 언어로 이벤트를 등록·식별할 수 있고, 두 앱 API가 기존 조회 조건을 유지하면서 DB 조회 단계에서 판정 언어만 반환하는 것으로 한다. + +## 3. Goals + +- 관리자는 이벤트 등록 시 `KO`, `EN`, `JA` 중 하나를 필수로 지정한다. +- 관리자 이벤트 목록은 기존 활성·종료 시각 조건 안에서 모든 언어를 반환하고 `lang`을 포함한다. +- 기존 `MemberContentPreferenceService.resolveCountryCode(member)`로 강제 KR/JP 회원 매핑, 요청 국가 정규화, 누락 시 KR 기본값을 그대로 재사용한다. +- 판정 국가가 `JP`이면 `Lang.JA`, 그 외에는 `Lang.KO`를 선택한다. +- 언어 조건을 QueryDSL `where`에 적용해 정렬과 `fetchFirst()`보다 먼저 필터링한다. +- 기존 이벤트 데이터와 레거시 등록 경로의 기본 언어는 `KO`로 유지한다. + +## 4. Non-Goals + +- 이벤트 수정 API에서 `lang`을 변경하지 않는다. +- `EventController.createEvent`의 `POST /event` 요청 계약을 변경하지 않는다. +- 콘텐츠 메인 탭 6곳이 `EventService.getEventList(isAdult)`로 조립하는 `eventBannerList`에 언어 필터를 적용하지 않는다. +- `Lang`에 새 enum을 추가하거나 `EN`을 노출할 접속 국가 정책을 추가하지 않는다. +- `Accept-Language`를 이벤트 언어 판정에 사용하지 않는다. +- 요청 언어의 이벤트가 없을 때 다른 언어로 fallback하지 않는다. +- 기존 이벤트의 성인·활성·시작·종료·정렬 정책과 응답 이미지 URL 처리를 변경하지 않는다. + +## 5. 대상 사용자와 권한 + +| 사용자 | 주요 동작 | 국가·언어 정책 | +|---|---|---| +| 관리자 | 이벤트 등록, 언어 전체 목록 조회 | 등록 시 `KO`/`EN`/`JA` 중 하나를 지정, 목록에서 `lang` 확인 | +| 강제 JP 매핑 회원 | 앱 목록·팝업 조회 | 요청 헤더보다 우선하는 `JP` 판정, `JA` 조회 | +| 강제 KR 매핑 회원 | 앱 목록·팝업 조회 | 요청 헤더보다 우선하는 `KR` 판정, `KO` 조회 | +| 일반 로그인 회원 | 앱 목록·팝업 조회 | 정규화한 `CloudFront-Viewer-Country`가 `JP`이면 `JA`, 그 외 `KO` | +| 비로그인 사용자 | 앱 목록 조회 | 정규화한 요청 국가가 `JP`이면 `JA`, 누락·그 외 `KO` | + +- 관리자 이벤트 API의 기존 `ROLE_ADMIN` 인가를 유지한다. +- 앱 이벤트 목록은 기존 비로그인 접근을 유지하고, 팝업은 기존처럼 인증 사용자만 접근한다. + +## 6. 핵심 흐름 + +### 6.1 관리자 등록·조회 + +1. 관리자가 `POST /admin/event/banner` multipart 요청에 `lang`을 필수로 보낸다. +2. 서버는 기존 `Lang.fromCode` 규칙으로 `ko`/`KO`, `en`/`EN`, `ja`/`JA`를 처리한다. +3. 허용되지 않는 값은 `common.error.invalid_request`로 거부하고 이벤트를 저장하지 않는다. +4. `GET /admin/event/banner`는 기존처럼 활성 상태이고 종료 시각이 지나지 않은 이벤트를 언어 조건 없이 반환한다. +5. 각 관리자 응답 항목은 `lang`을 포함한다. + +### 6.2 앱 목록·팝업 조회 + +1. `CountryInterceptor`가 `CloudFront-Viewer-Country`를 `CountryContext`에 저장한다. +2. `EventController`는 인증 사용자 `Member?`를 `EventService`에 전달한다. +3. `EventService`는 `MemberContentPreferenceService.resolveCountryCode(member)`를 호출한다. +4. 국가 결과가 `JP`이면 `Lang.JA`, 그 외에는 `Lang.KO`를 선택한다. +5. `EventRepository`는 기존 성인·활성·게시 기간 조건과 언어 조건을 DB에서 함께 적용한다. +6. 해당 언어의 이벤트가 없으면 목록은 빈 `eventList`, 팝업은 `null`을 반환한다. + +### 6.3 기존 내부 조회 + +1. 콘텐츠 메인 탭 6곳은 기존 `EventService.getEventList(isAdult)`를 유지한다. +2. 이 경로는 Repository에 `lang = null`을 전달해 언어 조건을 추가하지 않는다. +3. 기존 응답 조립 범위와 순서를 변경하지 않는다. + +## 7. 기능 요구사항 + +| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 | +|---|---|---|---|---| +| `EVENT-LANG-001` | 확정 | 관리자 이벤트 등록은 `lang`을 필수로 받는다. | `KO`, `EN`, `JA`가 저장되고 누락·잘못된 값은 저장 전 거부된다. | `P1-T2` | +| `EVENT-LANG-002` | 확정 | 이벤트 언어는 등록 후 수정하지 않는다. | 두 수정 API의 요청·서비스 시그니처에 `lang`이 추가되지 않는다. | `P1-GATE` | +| `EVENT-LANG-003` | 확정 | 관리자 목록은 모든 언어를 조회하고 `lang`을 반환한다. | 기존 활성·종료 시각 조건을 만족하는 KO/EN/JA 항목이 언어 필터 없이 반환되고 각 항목에 `lang`이 있다. | `P1-T2` | +| `EVENT-LANG-004` | 확정 | `GET /event`는 판정 국가가 JP이면 JA, 그 외에는 KO 이벤트만 반환한다. | 서로 다른 언어의 활성 이벤트가 함께 있어도 판정 언어만 `eventList`에 있다. | `P2-T1` | +| `EVENT-LANG-005` | 확정 | `GET /event/popup`은 판정 국가가 JP이면 JA, 그 외에는 KO 팝업만 반환한다. | 다른 언어 팝업의 ID가 더 최신이어도 언어 필터 후 선택된 팝업을 반환한다. | `P2-T1` | +| `EVENT-LANG-006` | 확정 | 로그인 회원은 기존 강제 KR/JP 매핑을 포함한 국가 판정을 사용한다. | 강제 JP 회원은 비JP 헤더에서도 JA, 강제 KR 회원은 JP 헤더에서도 KO를 선택한다. | `P2-T1` | +| `EVENT-LANG-007` | 확정 | 콘텐츠 메인 탭의 기존 `eventBannerList`는 언어 필터 없이 조회한다. | `getEventList(isAdult)` 경로가 `lang = null`을 유지하고 KO/JA/EN 모두를 조회할 수 있다. | `P2-T2` | +| `EVENT-LANG-008` | 확정 | 기존 이벤트와 레거시 등록 경로의 언어는 KO다. | 기존 row를 KO로 backfill하고 `NOT NULL DEFAULT 'KO'`를 적용하며 `POST /event`에 `lang`을 추가하지 않는다. | `P1-T1` | +| `EVENT-LANG-009` | 확정 | 앱 언어 필터는 DB 조회 조건으로 적용한다. | QueryDSL `where` 내 `event.lang.eq(lang)`이 정렬·`fetchFirst()` 전에 적용된다. | `P2-T1` | + +## 8. API 계약 + +### 8.1 관리자 등록 + +| 항목 | 내용 | +|---|---| +| Method / Path | `POST /admin/event/banner` | +| 인증 | `ROLE_ADMIN` | +| Content-Type | `multipart/form-data` | +| 신규 필수 파라미터 | `lang`: `ko`, `en`, `ja` 또는 대소문자를 달리한 동일 enum 코드 | +| 잘못된 값 | `common.error.invalid_request`, 저장·업로드 없음 | +| 기존 파라미터 | 스키마와 필수·선택 정책 유지 | + +### 8.2 관리자 목록 + +| 항목 | 내용 | +|---|---| +| Method / Path | `GET /admin/event/banner` | +| 조회 조건 | 기존 `is_active = true`, `end_date >= now`; 언어 조건 없음 | +| 응답 변경 | `GetAdminEventResponse.lang: Lang` 추가 | +| 기존 응답 필드 | 이름·타입·URL 처리 유지 | + +### 8.3 앱 조회 + +| Method / Path | 변경 내용 | 빈 결과 | +|---|---|---| +| `GET /event` | 기존 응답 스키마를 유지하고 판정 언어 조건만 추가 | `eventList = []` | +| `GET /event/popup` | 기존 응답 스키마를 유지하고 판정 언어 조건만 추가 | `data = null` | + +- 신규 request header를 추가하지 않고 기존 `CloudFront-Viewer-Country`를 사용한다. +- `GET /event`는 기존처럼 비로그인 접근을 허용하고, `GET /event/popup`은 기존 인증 필수 정책을 유지한다. +- 앱 응답에 `lang`을 추가하지 않는다. +- `EN` 이벤트는 관리자가 등록·조회할 수 있지만 현재 두 앱 API의 국가 정책으로는 노출되지 않는다. + +## 9. 데이터 정책 + +- `Event.lang`은 기존 `kr.co.vividnext.sodalive.i18n.Lang`을 `EnumType.STRING`으로 저장한다. +- 엔티티 기본값은 `Lang.KO`로 둔다. +- 운영 DB DDL은 `event.lang VARCHAR(10)`을 nullable로 추가한 뒤 기존 `NULL`을 `KO`로 backfill하고 `NOT NULL DEFAULT 'KO'`로 변경한다. +- DDL은 두 번 실행해도 이미 적용된 단계를 건너뛸는 기존 `information_schema` + `PREPARE` 패턴을 따른다. +- 기존 row의 언어를 별도로 추론하거나 이미지·제목을 분석해 자동 분류하지 않는다. + +## 10. 성능·품질·보안 요구사항 + +- 언어 조건은 Repository QueryDSL `where`에서 적용하고 메모리 후처리를 추가하지 않는다. +- 기존 `Lang`, `MemberContentPreferenceService`, `CountryContext`를 재사용하고 신규 dependency·resolver abstraction을 추가하지 않는다. +- 언어 파라미터는 `Lang.fromCode`로 정규화·검증하고 잘못된 값을 엔티티 생성과 S3 업로드 전에 거부한다. +- 민감정보·헤더·파일 본문을 신규 로그에 기록하지 않는다. +- TDD는 관리자 등록·목록, 앱 국가별 목록·팝업, 기존 내부 전체 언어 조회 비회귀를 포함한다. +- focused test 후 직접 영향 회귀와 `ktlintCheck`를 실행한다. 전체 회귀는 targeted test로 영향 범위를 판단할 수 없거나 공통 경계 회귀가 발생할 때만 확장한다. + +## 11. 성공 기준 + +- [x] 관리자가 `KO`, `EN`, `JA` 이벤트를 등록하고 목록에서 각 언어를 확인한다. (`EVENT-LANG-001`, `EVENT-LANG-003`) +- [x] 수정 API와 레거시 `POST /event`의 요청 계약이 변경되지 않는다. (`EVENT-LANG-002`, `EVENT-LANG-008`) +- [ ] 기존 이벤트 모두가 KO로 이관되고 신규 언어 누락 row가 생성되지 않는다. (`EVENT-LANG-008`) +- [x] 판정 국가 JP에서 `GET /event`, `GET /event/popup`이 JA만 반환한다. (`EVENT-LANG-004~006`) +- [x] JP 이외와 국가 누락에서 두 API가 KO만 반환한다. (`EVENT-LANG-004`, `EVENT-LANG-005`) +- [x] 다른 언어의 최신 팝업이 있어도 언어 필터 후 선택된 팝업이 반환된다. (`EVENT-LANG-005`, `EVENT-LANG-009`) +- [x] 콘텐츠 메인 탭 6곳의 기존 `eventBannerList`는 언어 필터 없이 조회된다. (`EVENT-LANG-007`) +- [x] 기존 성인·활성·게시 기간·정렬·URL·응답 스키마 정책이 유지된다. + +### 11.1 구현 완료 검증 — 2026-08-20 + +- 관리자 등록·목록: `AdminEventBannerControllerIntegrationTest` 4개와 `EventRepositoryTest`의 관리자 전체 언어 조회가 통과했다. `Lang.fromCode`는 기존 `KO`, `EN`, `JA` enum의 code/name을 대소문자 무시로 처리하며, 누락·잘못된 값은 저장·업로드 없이 거부된다. +- 레거시 계약: controller·service diff에서 관리자 수정·삭제와 `POST /event`, `PUT /event`, `DELETE /event/{id}`에 `lang`이 추가되지 않았다. +- DB 이관: `20260819_event_lang_ddl.sql`이 nullable 컬럼 추가 → `NULL`의 KO backfill → `NOT NULL DEFAULT 'KO'` 순서를 갖고, 엔티티 기본값과 JA/KO 영속성 테스트도 통과했다. 다만 운영 DDL은 실행하지 않았으므로 실제 기존 row 전체 이관 완료는 증명하지 않고 체크하지 않았다. +- JP·강제 매핑: security-on 익명 JP 목록과 인증 JP 팝업 통합 테스트, `EventServiceTest`의 JP→JA 전달, `MemberContentPreferenceIntegrationTest`의 강제 JP/KR 매핑이 통과했다. +- 비JP·누락: 국가 누락의 익명 KO 목록과 인증 KO 팝업 통합 테스트, 일반 회원의 US·누락 판정과 비로그인 JP 정규화·누락 KR 테스트가 통과했다. service의 `JP` 이외→`KO` 분기도 diff로 확인했다. +- 팝업 선필터: 더 최신 KO와 성인 JA 팝업이 있어도 비성인 JA 조회가 대상 JA를 반환하는 Repository 테스트가 통과했고, 언어 predicate가 `where`에서 정렬·`fetchFirst()` 전에 적용됨을 확인했다. +- 콘텐츠 메인: 저수준 service의 `lang = null` 전달과 Repository의 KO/EN/JA 3건 전체 조회 테스트가 통과했다. 6개 production 호출자와 해당 응답 DTO 디렉터리 diff는 없었다. +- 기존 정책: Repository 테스트가 활성·시작·종료·성인·ID 내림차순 조건을 함께 검증했고, `EventService` URL 변환 및 `GetEventResponse`·`EventItem` diff가 없음을 확인했다. 구현 리뷰 후 익명 팝업 401과 인증 팝업의 국가별 언어를 함께 검증했고 기준 HEAD 대비 `SecurityConfig` diff가 없음을 확인했다. + +## 12. Open Questions + +- 없음. + +## 13. 요구사항 추적표 + +| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 | +|---|---:|---|---| +| `EVENT-LANG-001~003`, `EVENT-LANG-008` | 1 | `P1-T1`, `P1-T2`, `P1-GATE` | 엔티티·관리자 controller/service/repository 통합 테스트 | +| `EVENT-LANG-004~006`, `EVENT-LANG-009` | 2 | `P2-T1`, `P2-GATE` | `EventServiceTest`, `EventRepositoryTest`, `EventControllerIntegrationTest` | +| `EVENT-LANG-007` | 2 | `P2-T2`, `P2-GATE` | 언어 없는 기존 서비스 호출과 Repository null 조건 비회귀 테스트 | + +## 14. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal | +|---|---|---|---|---|---| +| 2026-08-19 | `DEC-001` | 확정 | 앱 국가 판정은 기존 `MemberContentPreferenceService.resolveCountryCode(member)`를 사용한다. | 사용자 인터뷰 A | `EVENT-LANG-004~006`, `P2-T1` | +| 2026-08-19 | `DEC-002` | 확정 | 등록 언어는 기존 `Lang`의 `KO`, `EN`, `JA` 모두를 허용한다. | 사용자가 A를 철회하고 B로 확정, 신규 언어 타입·검증 중복 방지 | `EVENT-LANG-001`, `P1-T2` | +| 2026-08-19 | `DEC-003` | 확정 | 언어는 등록할 때만 지정하고 수정하지 않는다. | 사용자 인터뷰 A, 콘텐츠 배너의 현재 패턴 | `EVENT-LANG-002`, `P1-GATE` | +| 2026-08-19 | `DEC-004` | 확정 | 관리자 목록은 언어 전체를 조회하고 `lang`을 응답한다. | 사용자 인터뷰 A | `EVENT-LANG-003`, `P1-T2` | +| 2026-08-19 | `DEC-005` | 확정 | 국가별 필터는 `EventController.getEventList`, `getEventPopup`에만 적용한다. | 사용자 인터뷰 A | `EVENT-LANG-004`, `EVENT-LANG-005`, `EVENT-LANG-007`, `P2-T1~T2` | +| 2026-08-19 | `DEC-006` | 확정 | 기존 이벤트는 KO로 backfill하고 `NOT NULL DEFAULT 'KO'`를 적용한다. | 사용자 인터뷰 A, 기존 배너 DDL 패턴 | `EVENT-LANG-008`, `P1-T1` | +| 2026-08-19 | `DEC-007` | 확정 | 언어 입력은 `AdminEventBannerController`에만 추가하고 레거시 `POST /event`는 KO 기본값을 사용한다. | 사용자 인터뷰 A | `EVENT-LANG-002`, `EVENT-LANG-008`, `P1-T1~T2` | +| 2026-08-20 | `DEC-008` | 확정 | `GET /event`의 익명 접근은 유지하고 `GET /event/popup`은 기준 HEAD의 인증 필수 정책을 유지한다. | 구현 리뷰에서 언어 필터와 무관한 익명 공개 확장을 확인 | `EVENT-LANG-005`, `P2-R1` | + +## 15. 변경 관리 + +요구사항이 변경되면 다음 순서로 갱신한다. + +1. 이 문서의 Decision Log에 변경 이유와 날짜를 추가한다. +2. 관련 요구사항·수용 기준·API 계약을 갱신한다. +3. `plan-task.md`의 범위·Files·Interfaces·체크박스를 코드 변경 전에 먼저 갱신한다. +4. 기존 Progress·검증 기록을 삭제하거나 덮어쓰지 않는다. diff --git a/docs/20260819_이벤트_접속국가별_언어필터/reviews/implementation-review.md b/docs/20260819_이벤트_접속국가별_언어필터/reviews/implementation-review.md new file mode 100644 index 00000000..f1e0c697 --- /dev/null +++ b/docs/20260819_이벤트_접속국가별_언어필터/reviews/implementation-review.md @@ -0,0 +1,148 @@ +# 이벤트 접속 국가별 언어 필터 구현 리뷰 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 1~2 전체 구현과 `P1-GATE`, `P2-GATE` | +| 기준 commit 또는 working tree | 기준 `2ad30f90699a03162000d3b87be39f45b92740af` + 미커밋 구현 변경 | +| 리뷰 일자 | 2026-08-20 | +| 리뷰어 | Codex, 독립 리뷰 agent `event_language_review` | +| 기준 문서 | `prd.md`, `plan-task.md` | +| 리뷰 상태 | 수정 검증 완료 | + +## 2. 리뷰 목적과 범위 + +### 목적 + +- 구현이 `EVENT-LANG-001~009`와 기존 API 경계를 충족하는지 확인한다. +- 완료 기록과 실제 코드·테스트 결과가 일치하는지 확인한다. + +### 포함 범위 + +- 코드: 관리자 이벤트 등록·목록, 앱 이벤트 controller/service/repository, `Event`, `SecurityConfig` +- 테스트: 관리자·앱 controller 통합, service, repository, 기존 국가 판정 통합 테스트 +- 문서: `prd.md`, `plan-task.md`, 운영 DDL + +### 제외 범위 + +- 운영 DB DDL 실제 실행과 배포 +- 이벤트 언어와 관계없는 기능 + +## 3. 판정 기준 + +| 심각도 | 기준 | +|---|---| +| Blocker | 보안·데이터 손실 위험 또는 핵심 흐름 불능 | +| High | 확정 요구사항·기존 API 경계 위반 또는 주요 회귀 | +| Medium | 제한된 조건의 기능·복구 문제 | +| Low | 비핵심 유지보수성·문서 정합성 문제 | + +## 4. 검토한 근거 + +### 문서와 코드 + +- 요구사항: `EVENT-LANG-001~009` +- 계획: `P1-T1~P2-GATE` +- 코드: `AdminEventBannerController`, `AdminEventBannerService`, `AdminEventBannerRepository`, `EventController`, `EventService`, `EventRepository`, `Event`, `SecurityConfig` +- 테스트: `AdminEventBannerControllerIntegrationTest`, `EventControllerIntegrationTest`, `EventServiceTest`, `EventRepositoryTest`, `MemberContentPreferenceIntegrationTest` + +### 실행 환경 + +```text +OS: Darwin 25.0.0 x86_64 +Java: OpenJDK 17.0.15 +Build: Gradle Wrapper 8.1.1 +DB: 테스트용 H2 MySQL mode +``` + +### 실행한 검증 + +| 명령 또는 수동 검증 | 결과 | 핵심 증거 | +|---|---|---| +| 직접 영향 5개 클래스 `cleanTest test` | 성공 | 겹침 제거 후 exit code 0, 27개 테스트 통과, `BUILD SUCCESSFUL in 46s` | +| 독립 리뷰의 동일 5개 클래스 실행 | 성공 | exit code 0, `BUILD SUCCESSFUL in 56s` | +| 전체 호출자·기준 HEAD 보안 설정 diff 대조 | 발견 | 기준 HEAD의 `/event/popup`은 `anyRequest().authenticated()` 적용 | +| 수정 후 독립 정적 재리뷰 | 성공 | Critical·Important·Minor 추가 발견 없음, Ready to merge 판정 | +| `git diff --check HEAD` | 성공 | exit code 0, whitespace 오류 없음 | + +동시에 실행된 두 Gradle `cleanTest`가 같은 XML 결과 경로를 사용해 한 차례 writer 경합이 발생했다. 테스트 assertion은 모두 통과했고, 병행 실행을 중단한 뒤 동일 명령을 재실행해 성공 종료를 확인했다. + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-EVENT-LANG-001` | High | 수정 완료 | `GET /event/popup`의 기존 인증 경계가 익명 공개로 확장됨 | `P2-T1` | `P2-R1` | + +언어 저장·관리자 전체 언어 조회·앱 KO/JA QueryDSL 필터·콘텐츠 메인 null 언어 경로에서는 추가 확정 문제를 찾지 못했다. + +## 6. 발견 사항 상세 + +### REV-EVENT-LANG-001 — `GET /event/popup`의 기존 인증 경계가 익명 공개로 확장됨 + +- **심각도:** High +- **상태:** 수정 완료 +- **관련 요구사항:** `EVENT-LANG-005`, 기존 접근 정책 유지 +- **소유 Task:** `P2-R1` + +**관찰 내용** + +기준 HEAD의 `SecurityConfig`는 `GET /event`만 `permitAll()`이고, `/event/popup`은 마지막 `anyRequest().authenticated()`를 적용받는다. 수정 전 구현은 `/event/popup` exact matcher를 추가해 익명 요청도 허용했다. 국가별 언어 필터에는 인증 정책 변경이 필요하지 않으므로 요청 범위를 넘어선 보안 경계 확장이었다. + +**근거** + +- 기준 코드: `SecurityConfig.kt`의 `/event` matcher 다음에 `/event/popup` matcher가 없고 마지막 규칙은 `anyRequest().authenticated()`다. +- 수정 전 코드: `SecurityConfig.kt`에 `GET /event/popup` `permitAll()` 한 줄이 추가됐다. +- 수정 전 테스트: `EventControllerIntegrationTest`의 팝업 2개가 익명 200을 기대해 확장된 동작을 고정했다. +- 문서: `prd.md`가 두 API 모두 기존 로그인·비로그인 접근이라고 잘못 기술했다. + +**재현 또는 검증 절차** + +1. 수정 전 security filter를 켠 상태로 인증 없이 `GET /event/popup`을 호출한다. +2. 수정 전 결과는 HTTP 200이다. +3. 기준 HEAD의 matcher 순서에서는 HTTP 401이다. +4. 요구되는 결과는 기존 인증 경계를 유지하는 HTTP 401이며, 인증 사용자의 국가별 팝업 필터는 계속 동작해야 한다. + +**영향** + +명시 승인 없이 endpoint 접근 범위가 넓어지고, PRD가 실제 기준 동작과 불일치한다. + +**권장 조치** + +`SecurityConfig`의 신규 matcher 한 줄을 제거한다. 통합 테스트에는 익명 팝업 401 회귀를 추가하고, JP·국가 누락 팝업 언어 테스트는 `MemberAdapter` 인증 사용자로 실행한다. `/event`의 기존 익명 접근과 언어 조회 구현은 변경하지 않는다. + +**판정 기록** + +- 2026-08-20 — 기준 HEAD·현재 diff·security-on 통합 테스트를 대조해 확정했다. +- 2026-08-20 — `P2-R1`에서 exact `permitAll()`을 제거하고 익명 401·인증 사용자 KO/JA 팝업과 직접 영향 28개 테스트를 통과해 수정 완료로 판정했다. + +## 7. 확정 항목의 plan·goal 전환 + +`REV-EVENT-LANG-001`을 `plan-task.md`의 `P2-R1` 회귀 수정 Goal로 전환했다. + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | 문서·production diff·전체 호출자·DDL·직접 영향 테스트 확인 | +| 후보 항목 판정 완료 | 충족 | 1건 확정, 나머지 substantive issue 없음 | +| 확정 항목 plan 반영 | 충족 | `P2-R1` 추가 | +| 검증 명령과 결과 기록 | 충족 | 4절 기록 | + +**최종 결론:** 수정 검증 완료 + +**남은 항목:** 없음 + +## 9. 수정 후 검증 기록 + +### 1차 수정 검증 — 2026-08-20 + +- 무엇을: `REV-EVENT-LANG-001`의 익명 팝업 공개를 제거하고 기존 인증 경계를 복원했다. +- 왜: 국가별 언어 필터와 무관한 보안 접근 범위 확장을 제거하기 위해서다. +- 어떻게: + - RED: `EventControllerIntegrationTest` 5개 중 익명 팝업 401만 실제 200으로 실패했고 인증 팝업·익명 목록 4개는 통과했다. + - GREEN: `SecurityConfig`의 exact matcher 한 줄 제거 후 controller 통합 테스트 5개가 `BUILD SUCCESSFUL in 38s`로 통과했다. + - 회귀: 직접 영향 5개 클래스 28개가 failures `0`, errors `0`, skipped `0`, `BUILD SUCCESSFUL in 42s`로 통과했다. + - 정적 검증: `ktlintCheck`, `tasks --all`, `git diff --check HEAD`가 모두 성공했고 기준 HEAD 대비 `SecurityConfig` diff가 없다. + - 독립 재리뷰: 수정 후 최종 diff와 문서를 다시 검토해 Critical·Important·Minor 모두 추가 발견 없음으로 판정했다. +- 남은 항목: 운영 DB DDL 실제 반영은 기존 범위대로 미실행이며, 리뷰 확정 항목은 남아 있지 않다.