57 KiB
이벤트 접속 국가별 언어 필터 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-developmentorsuperpowers:executing-plansto 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은 기존Langenum을 재사용하고 별도 언어 타입을 만들지 않는다.- 관리자 등록은
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 backfillEvent엔티티의Lang.KO기본 언어- 관리자 이벤트 등록
lang입력·검증·저장 - 관리자 이벤트 목록
lang응답과 언어 전체 조회 - 앱 이벤트 목록·팝업의 접속 국가별 KO/JA 필터
- 기존 강제 국가 매핑과 비로그인 KR 기본값 재사용
- 콘텐츠 메인 탭 6곳의 언어 전체 조회 비회귀
제외
- 이벤트 수정 시 언어 변경
- 레거시
POST /event의langrequest 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, DBevent.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생성자와langproperty가 없어 발생하는 컴파일 실패를 확인한다.
./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완료, PRDEVENT-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/bannerrequest 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를 실행해
langrequest 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:
GetAdminEventResponse에lang을 추가하고 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.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 완화.
./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의
where에event.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완료, PRDEVENT-LANG-004~006,EVENT-LANG-009. - 완료 증거: service 언어 전달, Repository 목록·팝업 필터, 요청 헤더부터 API 응답까지의 RED/GREEN 테스트.
- 범위 밖: 콘텐츠 메인 탭의
getEventList(isAdult)경로와 이벤트 응답 DTO. - 범위 내 보정: 실제 security chain에서 두 앱 GET API의 기존 비로그인 접근 계약을 검증하고, 누락된
GET /event/popupexact 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에 국가 판정JP가Lang.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?서비스 시그니처, Repositorylang인자와 필터가 없어 발생하는 컴파일·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:
EventService에MemberContentPreferenceService를 생성자 주입하고 앱 진입점 두 개가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완료, PRDEVENT-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.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 -
RED: security-on 통합 테스트에 인증 없는
GET /event/popup이 HTTP 401을 반환하는 케이스를 추가한다. -
RED: JP·국가 누락 팝업 언어 테스트를 기존
MemberAdapter인증 방식으로 전환해 인증 사용자 경로의 KO/JA 응답을 유지한다. -
RED 확인:
EventControllerIntegrationTest를 실행해 익명 401 테스트가 현재 HTTP 200으로 실패하고, 인증 사용자 팝업 테스트는 통과함을 확인한다. -
GREEN:
SecurityConfig에 추가된GET /event/popupexactpermitAll()한 줄만 제거한다. -
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 /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.mdDecision 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 code1이고 whitespace 오류 출력은 없었다. ./gradlew tasks --all은 최초 sandbox에서 Gradle wrapper lock 접근 제한으로 실패했고, 승인된 동일 명령 재실행에서BUILD SUCCESSFUL을 확인했다.git status --short에는 이 작업의 신규 문서 디렉터리만 표시되었다.
- 남은 항목:
P1-T1이후 구현 전체. production·test·DDL 파일은 아직 변경하지 않았다. - 다음 행동: 별도 구현 요청이 있을 때
P1-T1RED부터 시작한다.
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: lang2건과Cannot find a parameter with this name: lang1건으로: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 code0이며 출력이 없었다.- 운영 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 code0이며 출력이 없었다.
- RED:
- 남은 항목:
P1-T2이후 구현 전체. 어떤 Gate도 완료 처리하지 않았다. - 다음 행동: 별도 구현 요청이 있을 때
P1-T2RED부터 시작한다.
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,shouldReturnAllActiveNonExpiredLanguagesForAdmin4개가 기능 미지원 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 code0이며 출력이 없었다.- 수동 diff에서
PUT /admin/event/banner,PUT /event,POST /event요청 파라미터와EventController·EventService·EventRepository가 변경되지 않았고, 관리자 Repositorywhere에 언어 조건이 없음을 확인했다. - 전체 회귀 테스트는 관리자 이벤트 등록·projection과 직접 영속성 경계를 focused Spring 통합·Repository 테스트가 모두 포함하므로 계획 기준에 따라 생략했다.
- RED:
- 남은 항목:
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 code0,BUILD SUCCESSFUL in 3s, 6개 테스트 대상../gradlew ktlintCheck— exit code0,BUILD SUCCESSFUL in 10s../gradlew tasks --all— exit code0,BUILD SUCCESSFUL in 9s.git diff --check— exit code0, 출력 없음.- 수동 검증 1: diff에서
PUT /admin/event/banner,PUT /event,POST /eventrequest parameter에lang추가 없음. - 수동 검증 2: 관리자 Repository
where는event.isActive.isTrue와event.endDate.goe(now)만 유지하고event.lang조건 없음. - 수동 검증 3: DDL이 컬럼 추가 →
KObackfill →NOT NULL DEFAULT 'KO'순서이며 신규 컬럼 정의에 COMMENT 포함. git status --short --untracked-files=all과 diff를 확인했으며, 요청대로 plan-task.md만 수정했다. Gradle은 deprecated features 경고를 출력했지만 모든 명령은 exit code0이었다.
- 다음 행동:
P2-T1RED부터 시작한다. P2 체크박스는 변경하지 않았다.
P2-T1 앱 목록·팝업 국가별 언어 필터 — 2026-08-20
- 상태: 완료
- 무엇을:
GET /event,GET /event/popup이 기존 국가 판정 결과JP에서는Lang.JA, 그 외에는Lang.KO를 QueryDSLwhere에서 적용하도록 구현했다. - 왜: 정렬과
fetchFirst()전에 언어를 제한해 더 최신인 다른 언어 팝업이 요청 언어 팝업을 가리는 문제를 막기 위해서다. - 어떻게: controller는
Member?를 그대로 전달하고, service는 기존MemberContentPreferenceService.resolveCountryCode(member)와 한 줄 언어 선택 helper를 재사용했다. 저수준 service·Repository 메서드는 nullablelang = 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, Repositorylang인자가 없어: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-scopedCountryContext를 통과시켰다. - 통합 테스트는 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 code0이며 출력이 없었다.- 전체 회귀 테스트는 변경이 Event controller/service/Repository와 해당 persistence·Spring MVC 통합 경계에 한정되고 focused 11개 테스트로 직접 영향 범위를 확인할 수 있어 계획 기준에 따라 생략했다.
- RED:
- 남은 항목:
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의 nullablelangguard에 임시else event.lang.eq(Lang.KO)를 추가한 뒤 Repository focused test가EventRepositoryTest.kt:101assertion failure로 종료됨을 확인했다. - GREEN: mutation을 즉시 제거해 원래
if (lang != null)guard를 복구한 뒤 focused EventService/EventRepository test가BUILD SUCCESSFUL로 통과했다. - 콘텐츠 메인 탭 production 6곳 변경 여부와
EventRepository복구 상태를 diff에서 확인했다. ./gradlew ktlintCheck와git diff --check를 재실행했다.
- service test
- 남은 항목:
P2-GATE. Gate는 완료 처리하지 않았다. - 다음 행동:
P2-GATE를 실행한다.
P2-T1 QA 보안 경계 발견 — 2026-08-20
- 상태: 교정 진행 중
- 발견:
EventControllerIntegrationTest가@AutoConfigureMockMvc(addFilters = false)로 전체 security filter를 제외해 실제 익명 접근 계약을 검증하지 못했다. 실제SecurityConfig에는GET /event만permitAll()이고GET /event/popupexact matcher가 없어 익명 팝업 요청이 401이었다. - 계획 보정: P2-T1 production Files에
SecurityConfig.kt를 추가하고, 범위를GET /event/popupexact 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/popupexactpermitAll()matcher만 추가했다. - RED:
./gradlew cleanTest test --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest"결과 4개 중shouldReturnOnlyJapanesePopupForAnonymousJapanRequest,shouldReturnOnlyKoreanPopupWhenCountryHeaderIsMissing2개가 각각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 code0이며 출력이 없었다. - 수동 검증:
@AutoConfigureMockMvc가 실제 security chain을 사용하고 요청은 security postprocessor 없이 익명으로 전송된다.CountryInterceptor와 request-scopedCountryContext경로를 유지했으며/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 code0,BUILD SUCCESSFUL in 48s,11 actionable tasks: 2 executed, 9 up-to-date.cleanTest후 5개 클래스의 27개 테스트를 실제 실행했고 클래스별4 + 4 + 5 + 4 + 10, failures0, errors0, skipped0이었다../gradlew ktlintCheck— exit code0,BUILD SUCCESSFUL in 1s,7 actionable tasks: 7 up-to-date../gradlew tasks --all— exit code0,BUILD SUCCESSFUL in 6s,1 actionable task: 1 executed.git diff --check— exit code0, 출력 없음.- security-on 익명 검증:
EventControllerIntegrationTest는@AutoConfigureMockMvc에서 filter를 끄지 않고 security postprocessor 없이GET /event,GET /event/popup을 호출한다. JP·국가 누락 목록·팝업 4개가 모두 통과했고SecurityConfigdiff는GET /event/popupexactpermitAll()한 줄뿐이다. - 수동 검증 1:
EventControllerdiff는getEventList,getEventPopup의 service 전달만 변경했다. 레거시POST /event,PUT /event,DELETE /event/{id}와 관리자 수정·삭제 파라미터는 변경되지 않았다. - 수동 검증 2: 목록·팝업의
event.lang.eq(lang)은 QueryDSLwhere에 합성되고, 팝업.where(where)뒤의.orderBy(event.id.desc()).fetchFirst()보다 먼저 적용된다. - 수동 검증 3:
GetEventResponse와 같은 파일의EventItemdiff는 없었다.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()를 적용받지만, 구현 중 exactpermitAll()이 추가되어 익명 공개로 확장된 점을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/popupexactpermitAll()한 줄을 제거했다. 같은 controller 통합 테스트 5개가 모두 통과했고BUILD SUCCESSFUL in 38s였다. - 직접 영향 회귀:
./gradlew --no-daemon cleanTest test에 관리자 controller, 이벤트 service/repository/controller, 기존 국가 판정 통합 5개 클래스를 지정해4 + 4 + 5 + 5 + 10 = 28개를 실행했다. failures0, errors0, skipped0,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 code0이고 출력이 없었다. - 수동 검증: 기준 HEAD 대비
SecurityConfig.ktdiff가 없고,GET /event익명 matcher와 마지막anyRequest().authenticated()가 유지된다. 언어 predicate 두 곳은 QueryDSLwhere에 있으며 콘텐츠 메인 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 경로를 적는다.
- 성공을 추정하지 않고 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 전달한다.