docs(event): 이벤트 언어 필터 계획과 검증을 기록한다

This commit is contained in:
2026-08-20 13:49:56 +09:00
parent 972bd8ca9d
commit a5f3db487d
3 changed files with 1186 additions and 0 deletions

View File

@@ -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<EventItem>`, `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 경로를 적는다.
- 성공을 추정하지 않고 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 전달한다.