Files
sodalive-backend-spring-boot/docs/20260819_이벤트_접속국가별_언어필터/plan-task.md

57 KiB

이벤트 접속 국가별 언어 필터 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: 기존 LangMemberContentPreferenceService.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/popupJP -> 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 /eventlang 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: EventLang을 필수로 저장하고 기존 데이터를 손실 없이 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'.

  • RED: EventRepositoryTest에 명시적 Lang.JA 저장과 기본 Lang.KO 저장을 검증하는 테스트를 작성한다.

@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는 아래 계약으로 정의한다.

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)
    )
}
  • RED 확인: 아래 focused test를 실행해 Event 생성자와 lang property가 없어 발생하는 컴파일 실패를 확인한다.
./gradlew test --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"

Expected RED: Cannot find a parameter with this name: lang 또는 Unresolved reference: lang.

  • GREEN: Event에 다음 필드를 추가한다. 기존 생성자는 기본값으로 KO를 사용하므로 변경하지 않는다.
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
var lang: Lang = Lang.KO,
  • GREEN: docs/20260819_이벤트_접속국가별_언어필터/20260819_event_lang_ddl.sql에 다음 3단계 DDL을 작성한다.
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;
  • GREEN 확인: 같은 focused test를 재실행해 JA와 KO 저장이 통과하는지 확인한다.
  • 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 목록.

  • RED: 관리자 통합 테스트에 lang=en으로 등록한 이벤트가 Lang.EN으로 저장되는 케이스를 작성한다.

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)
  • RED: lang=fr을 보내면 success=false이고 DB row·S3 upload이 생성되지 않는 케이스를 추가한다.
  • RED: KO·JA·EN 활성 이벤트를 준비한 뒤 GET /admin/event/banner가 세 항목의 lang을 모두 반환하는 케이스를 추가한다.
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")))
  • RED 확인: 아래 focused test를 실행해 lang request parameter·service parameter·response projection이 없어 발생하는 컴파일 또는 assertion 실패를 확인한다.
./gradlew test \
  --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" \
  --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"
  • GREEN: controller의 등록 메서드에 @RequestParam("lang") lang: String을 추가하고 service 호출의 마지막 인자로 전달한다.
  • GREEN: service는 엔티티 생성과 S3 업로드 전에 다음 기존 패턴으로 언어를 변환한다.
val eventLang = try {
    Lang.fromCode(lang)
} catch (_: IllegalArgumentException) {
    throw SodaException(messageKey = "common.error.invalid_request")
}
  • GREEN: Event 생성 시 lang = eventLang을 넘긴다. 레거시 EventService.save는 수정하지 않아 Lang.KO 기본값을 사용하게 둔다.
  • GREEN: GetAdminEventResponselang을 추가하고 QueryDSL projection에 event.lang을 동일 순서로 추가한다.
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
)
  • GREEN 확인: focused test를 재실행해 소문자 코드 저장, 잘못된 코드 거부, 언어 전체 목록과 lang 응답이 통과하는지 확인한다.
  • REFACTOR: 수정 API에 lang을 추가하지 않고, 기존 Lang.fromCodecommon.error.invalid_request만 재사용한다. focused test·ktlintCheck·git diff --check를 재실행해 결과를 기록한다.

Phase 1 Gate

Goal 실행 P1-GATE: 이벤트 언어 저장과 관리자 등록·전체 목록 계약을 최종 판정한다.

  • 시작 조건: P1-T1, P1-T2 완료.
  • 완료 증거: 아래 명령과 수동 diff 검토 통과, Progress 기록.
  • 범위 밖: Gate 통과를 위한 테스트 삭제·skip·assertion 완화.
./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 등록과 목록 응답이 통과하고, 잘못된 언어는 저장·업로드 전에 거부된다.

수동 검증:

  • git diff에서 PUT /admin/event/banner, PUT /event, POST /event의 request parameter에 lang이 추가되지 않았는지 확인한다.
  • 관리자 Repository의 whereevent.lang 조건이 없고 기존 활성·종료 시각 조건이 유지되는지 확인한다.
  • 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로 비로그인 접근을 허용한다.

  • RED: EventServiceTest에 국가 판정 JPLang.JA로 목록 Repository에 전달되는 케이스를 추가한다.

EventServiceTest의 fixture에 MemberContentPreferenceService mock을 추가하고 EventService 생성자에 전달한다.

private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
@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)
}
  • RED: 비로그인·KR 판정 팝업이 Lang.KO를 조회하는 케이스와 관리자 목록의 기존 isAdult = null 정책을 추가한다.
  • RED: EventRepositoryTest에 KO·JA 활성 이벤트가 함께 있어도 요청 언어의 목록만 반환하는 케이스를 추가한다.
  • RED: JA 팝업을 먼저 저장하고 더 최신 ID인 KO 팝업을 나중에 저장한 뒤, JA 조회가 앞선 JA 팝업을 반환하는 케이스를 추가한다.
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)
  • RED: EventControllerIntegrationTest에 비로그인 CloudFront-Viewer-Country: JP의 목록·팝업이 JA만 반환하고, 헤더 누락 요청이 KO만 반환하는 케이스를 추가한다.
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는 다음처럼 해당 테스트 파일 내에 정의한다.

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)
        )
    )
}
  • RED 확인: 아래 focused test를 실행해 MemberContentPreferenceService 의존성, Member? 서비스 시그니처, Repository lang 인자와 필터가 없어 발생하는 컴파일·assertion 실패를 확인한다.
./gradlew test \
  --tests "kr.co.vividnext.sodalive.event.EventServiceTest" \
  --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" \
  --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest"
  • GREEN: EventServiceMemberContentPreferenceService를 생성자 주입하고 앱 진입점 두 개가 Member?를 받도록 최소 변경한다.
@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
}
  • GREEN: 기존 저수준 조회 메서드는 nullable lang을 받고 Repository에 전달한다.
fun getEventList(isAdult: Boolean? = null, lang: Lang? = null): GetEventResponse

fun getEventPopup(isAdult: Boolean, lang: Lang? = null): EventItem?
  • GREEN: Repository 계약과 구현에 nullable lang을 추가하고, null이 아닐 때만 기존 where에 언어 조건을 합성한다.
if (lang != null) {
    where = where.and(event.lang.eq(lang))
}
  • GREEN: EventController.getEventList, getEventPopup은 인증 principal에서 얻은 Member?를 그대로 새 service 진입점에 전달한다.
service.getEventList(member = member)
service.getEventPopup(member = member)
  • GREEN 확인: 같은 focused test를 재실행해 JP·비JP 언어 전달, Repository 목록·팝업 필터, CloudFront 헤더부터 응답까지 통과하는지 확인한다.
  • 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 조건을 추가하지 않는 호환성 계약.

  • EventService.getEventList(isAdult = false)repository.getEventList(false, null)을 호출함을 검증한다.

  • KO·EN·JA 활성 이벤트를 저장하고 repository.getEventList(isAdult = false, lang = null)이 세 건을 모두 반환함을 검증한다.

  • Repository의 null guard를 일시로 KO 강제 조건으로 변경해 언어 전체 테스트가 의도한 assertion 실패를 보이는지 확인한다.

  • 임시 mutation을 즉시 복구하고 아래 focused test를 재실행해 통과를 확인한다.

  • git diff에 콘텐츠 메인 탭 6곳의 production 파일 변경이 없는지 확인한다.

./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 통과를 위한 테스트 완화.
./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 모두를 반환한다.

수동 검증:

  • EventController의 변경이 getEventList, getEventPopup에만 있고 레거시 관리자 등록·수정·삭제 계약은 유지되는지 확인한다.
  • EventRepository의 언어 조건이 where에 있고 팝업 orderBy(event.id.desc()).fetchFirst()보다 먼저 적용되는지 확인한다.
  • GetEventResponse, EventItem에 신규 필드가 없고 기존 URL 변환과 성인 조건이 유지되는지 확인한다.
  • 콘텐츠 메인 탭 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.mdREV-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

  • RED: security-on 통합 테스트에 인증 없는 GET /event/popup이 HTTP 401을 반환하는 케이스를 추가한다.

  • RED: JP·국가 누락 팝업 언어 테스트를 기존 MemberAdapter 인증 방식으로 전환해 인증 사용자 경로의 KO/JA 응답을 유지한다.

  • RED 확인: EventControllerIntegrationTest를 실행해 익명 401 테스트가 현재 HTTP 200으로 실패하고, 인증 사용자 팝업 테스트는 통과함을 확인한다.

  • GREEN: SecurityConfig에 추가된 GET /event/popup exact permitAll() 한 줄만 제거한다.

  • GREEN 확인: EventControllerIntegrationTest를 재실행해 익명 401, 익명 목록 200, 인증 사용자 JP→JA·국가 누락→KO가 모두 통과함을 확인한다.

  • REFACTOR: 추가 abstraction 없이 테스트 helper만 최소화하고 직접 영향 5개 클래스, ktlintCheck, tasks --all, git diff --check를 실행한다.

  • 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 재대조
P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE → P2-R1

변경 금지 항목

  • 확정된 PRD와 API 계약을 근거 없이 변경하지 않는다.
  • 이벤트 수정 API와 레거시 POST /eventlang을 추가하지 않는다.
  • 관리자 목록과 콘텐츠 메인 탭 내부 조회에 언어 조건을 추가하지 않는다.
  • 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.langEnumType.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.fromCodeSodaException(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 whereevent.isActive.isTrueevent.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 shouldQueryNonAdultEventListWithoutLanguagegetEventList(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 ktlintCheckgit diff --check를 재실행했다.
  • 남은 항목: P2-GATE. Gate는 완료 처리하지 않았다.
  • 다음 행동: P2-GATE를 실행한다.

P2-T1 QA 보안 경계 발견 — 2026-08-20

  • 상태: 교정 진행 중
  • 발견: EventControllerIntegrationTest@AutoConfigureMockMvc(addFilters = false)로 전체 security filter를 제외해 실제 익명 접근 계약을 검증하지 못했다. 실제 SecurityConfig에는 GET /eventpermitAll()이고 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 제외를 제거하고, SecurityConfigHttpMethod.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 ktlintCheckBUILD SUCCESSFUL in 36s, ./gradlew tasks --allBUILD 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/tabGetEventResponse.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/popupanyRequest().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 ktlintCheckBUILD SUCCESSFUL in 22s, ./gradlew --no-daemon tasks --allBUILD 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 경로를 적는다.
  • 성공을 추정하지 않고 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 전달한다.