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,204 @@
# 이벤트 접속 국가별 언어 필터 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 기준 확정 |
| 작성일 | 2026-08-19 |
| 최종 수정일 | 2026-08-20 |
| 대상 제품 | 관리자 이벤트 배너, 앱 이벤트 목록·팝업 |
| 작성자·결정권자 | 사용자 |
| 관련 API Contract | 별도 문서 없음. 이 문서 8절을 기준으로 사용 |
| 관련 구현 계획 | `docs/20260819_이벤트_접속국가별_언어필터/plan-task.md` |
| 관련 기존 문서 | `docs/20260819_추천탭_배너_접속국가별_언어필터/prd.md` |
## 1. Overview
관리자가 이벤트를 등록할 때 기존 콘텐츠 배너처럼 언어를 지정한다. 관리자 목록은 언어로 필터링하지 않고 각 이벤트의 언어를 표시한다. 앱의 `GET /event`, `GET /event/popup`은 기존 접속 국가 판정 결과가 `JP`이면 일본어, 그 외에는 한국어 이벤트만 반환한다.
## 2. Problem Statement
- `Event`에는 언어 필드가 없어 관리자가 한국어·일본어 이벤트를 구분해 등록할 수 없다.
- 앱 이벤트 목록과 팝업 조회는 접속 국가를 고려하지 않아 여러 언어의 이벤트가 섞일 수 있다.
- 팝업을 조회한 뒤 메모리에서 언어를 걸러내면 다른 언어의 최신 팝업이 `fetchFirst()`를 차지해 필요한 팝업을 놓칠 수 있다.
문제를 해결했다는 판단은 관리자가 허용된 언어로 이벤트를 등록·식별할 수 있고, 두 앱 API가 기존 조회 조건을 유지하면서 DB 조회 단계에서 판정 언어만 반환하는 것으로 한다.
## 3. Goals
- 관리자는 이벤트 등록 시 `KO`, `EN`, `JA` 중 하나를 필수로 지정한다.
- 관리자 이벤트 목록은 기존 활성·종료 시각 조건 안에서 모든 언어를 반환하고 `lang`을 포함한다.
- 기존 `MemberContentPreferenceService.resolveCountryCode(member)`로 강제 KR/JP 회원 매핑, 요청 국가 정규화, 누락 시 KR 기본값을 그대로 재사용한다.
- 판정 국가가 `JP`이면 `Lang.JA`, 그 외에는 `Lang.KO`를 선택한다.
- 언어 조건을 QueryDSL `where`에 적용해 정렬과 `fetchFirst()`보다 먼저 필터링한다.
- 기존 이벤트 데이터와 레거시 등록 경로의 기본 언어는 `KO`로 유지한다.
## 4. Non-Goals
- 이벤트 수정 API에서 `lang`을 변경하지 않는다.
- `EventController.createEvent``POST /event` 요청 계약을 변경하지 않는다.
- 콘텐츠 메인 탭 6곳이 `EventService.getEventList(isAdult)`로 조립하는 `eventBannerList`에 언어 필터를 적용하지 않는다.
- `Lang`에 새 enum을 추가하거나 `EN`을 노출할 접속 국가 정책을 추가하지 않는다.
- `Accept-Language`를 이벤트 언어 판정에 사용하지 않는다.
- 요청 언어의 이벤트가 없을 때 다른 언어로 fallback하지 않는다.
- 기존 이벤트의 성인·활성·시작·종료·정렬 정책과 응답 이미지 URL 처리를 변경하지 않는다.
## 5. 대상 사용자와 권한
| 사용자 | 주요 동작 | 국가·언어 정책 |
|---|---|---|
| 관리자 | 이벤트 등록, 언어 전체 목록 조회 | 등록 시 `KO`/`EN`/`JA` 중 하나를 지정, 목록에서 `lang` 확인 |
| 강제 JP 매핑 회원 | 앱 목록·팝업 조회 | 요청 헤더보다 우선하는 `JP` 판정, `JA` 조회 |
| 강제 KR 매핑 회원 | 앱 목록·팝업 조회 | 요청 헤더보다 우선하는 `KR` 판정, `KO` 조회 |
| 일반 로그인 회원 | 앱 목록·팝업 조회 | 정규화한 `CloudFront-Viewer-Country``JP`이면 `JA`, 그 외 `KO` |
| 비로그인 사용자 | 앱 목록 조회 | 정규화한 요청 국가가 `JP`이면 `JA`, 누락·그 외 `KO` |
- 관리자 이벤트 API의 기존 `ROLE_ADMIN` 인가를 유지한다.
- 앱 이벤트 목록은 기존 비로그인 접근을 유지하고, 팝업은 기존처럼 인증 사용자만 접근한다.
## 6. 핵심 흐름
### 6.1 관리자 등록·조회
1. 관리자가 `POST /admin/event/banner` multipart 요청에 `lang`을 필수로 보낸다.
2. 서버는 기존 `Lang.fromCode` 규칙으로 `ko`/`KO`, `en`/`EN`, `ja`/`JA`를 처리한다.
3. 허용되지 않는 값은 `common.error.invalid_request`로 거부하고 이벤트를 저장하지 않는다.
4. `GET /admin/event/banner`는 기존처럼 활성 상태이고 종료 시각이 지나지 않은 이벤트를 언어 조건 없이 반환한다.
5. 각 관리자 응답 항목은 `lang`을 포함한다.
### 6.2 앱 목록·팝업 조회
1. `CountryInterceptor``CloudFront-Viewer-Country``CountryContext`에 저장한다.
2. `EventController`는 인증 사용자 `Member?``EventService`에 전달한다.
3. `EventService``MemberContentPreferenceService.resolveCountryCode(member)`를 호출한다.
4. 국가 결과가 `JP`이면 `Lang.JA`, 그 외에는 `Lang.KO`를 선택한다.
5. `EventRepository`는 기존 성인·활성·게시 기간 조건과 언어 조건을 DB에서 함께 적용한다.
6. 해당 언어의 이벤트가 없으면 목록은 빈 `eventList`, 팝업은 `null`을 반환한다.
### 6.3 기존 내부 조회
1. 콘텐츠 메인 탭 6곳은 기존 `EventService.getEventList(isAdult)`를 유지한다.
2. 이 경로는 Repository에 `lang = null`을 전달해 언어 조건을 추가하지 않는다.
3. 기존 응답 조립 범위와 순서를 변경하지 않는다.
## 7. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|---|---|---|---|---|
| `EVENT-LANG-001` | 확정 | 관리자 이벤트 등록은 `lang`을 필수로 받는다. | `KO`, `EN`, `JA`가 저장되고 누락·잘못된 값은 저장 전 거부된다. | `P1-T2` |
| `EVENT-LANG-002` | 확정 | 이벤트 언어는 등록 후 수정하지 않는다. | 두 수정 API의 요청·서비스 시그니처에 `lang`이 추가되지 않는다. | `P1-GATE` |
| `EVENT-LANG-003` | 확정 | 관리자 목록은 모든 언어를 조회하고 `lang`을 반환한다. | 기존 활성·종료 시각 조건을 만족하는 KO/EN/JA 항목이 언어 필터 없이 반환되고 각 항목에 `lang`이 있다. | `P1-T2` |
| `EVENT-LANG-004` | 확정 | `GET /event`는 판정 국가가 JP이면 JA, 그 외에는 KO 이벤트만 반환한다. | 서로 다른 언어의 활성 이벤트가 함께 있어도 판정 언어만 `eventList`에 있다. | `P2-T1` |
| `EVENT-LANG-005` | 확정 | `GET /event/popup`은 판정 국가가 JP이면 JA, 그 외에는 KO 팝업만 반환한다. | 다른 언어 팝업의 ID가 더 최신이어도 언어 필터 후 선택된 팝업을 반환한다. | `P2-T1` |
| `EVENT-LANG-006` | 확정 | 로그인 회원은 기존 강제 KR/JP 매핑을 포함한 국가 판정을 사용한다. | 강제 JP 회원은 비JP 헤더에서도 JA, 강제 KR 회원은 JP 헤더에서도 KO를 선택한다. | `P2-T1` |
| `EVENT-LANG-007` | 확정 | 콘텐츠 메인 탭의 기존 `eventBannerList`는 언어 필터 없이 조회한다. | `getEventList(isAdult)` 경로가 `lang = null`을 유지하고 KO/JA/EN 모두를 조회할 수 있다. | `P2-T2` |
| `EVENT-LANG-008` | 확정 | 기존 이벤트와 레거시 등록 경로의 언어는 KO다. | 기존 row를 KO로 backfill하고 `NOT NULL DEFAULT 'KO'`를 적용하며 `POST /event``lang`을 추가하지 않는다. | `P1-T1` |
| `EVENT-LANG-009` | 확정 | 앱 언어 필터는 DB 조회 조건으로 적용한다. | QueryDSL `where``event.lang.eq(lang)`이 정렬·`fetchFirst()` 전에 적용된다. | `P2-T1` |
## 8. API 계약
### 8.1 관리자 등록
| 항목 | 내용 |
|---|---|
| Method / Path | `POST /admin/event/banner` |
| 인증 | `ROLE_ADMIN` |
| Content-Type | `multipart/form-data` |
| 신규 필수 파라미터 | `lang`: `ko`, `en`, `ja` 또는 대소문자를 달리한 동일 enum 코드 |
| 잘못된 값 | `common.error.invalid_request`, 저장·업로드 없음 |
| 기존 파라미터 | 스키마와 필수·선택 정책 유지 |
### 8.2 관리자 목록
| 항목 | 내용 |
|---|---|
| Method / Path | `GET /admin/event/banner` |
| 조회 조건 | 기존 `is_active = true`, `end_date >= now`; 언어 조건 없음 |
| 응답 변경 | `GetAdminEventResponse.lang: Lang` 추가 |
| 기존 응답 필드 | 이름·타입·URL 처리 유지 |
### 8.3 앱 조회
| Method / Path | 변경 내용 | 빈 결과 |
|---|---|---|
| `GET /event` | 기존 응답 스키마를 유지하고 판정 언어 조건만 추가 | `eventList = []` |
| `GET /event/popup` | 기존 응답 스키마를 유지하고 판정 언어 조건만 추가 | `data = null` |
- 신규 request header를 추가하지 않고 기존 `CloudFront-Viewer-Country`를 사용한다.
- `GET /event`는 기존처럼 비로그인 접근을 허용하고, `GET /event/popup`은 기존 인증 필수 정책을 유지한다.
- 앱 응답에 `lang`을 추가하지 않는다.
- `EN` 이벤트는 관리자가 등록·조회할 수 있지만 현재 두 앱 API의 국가 정책으로는 노출되지 않는다.
## 9. 데이터 정책
- `Event.lang`은 기존 `kr.co.vividnext.sodalive.i18n.Lang``EnumType.STRING`으로 저장한다.
- 엔티티 기본값은 `Lang.KO`로 둔다.
- 운영 DB DDL은 `event.lang VARCHAR(10)`을 nullable로 추가한 뒤 기존 `NULL``KO`로 backfill하고 `NOT NULL DEFAULT 'KO'`로 변경한다.
- DDL은 두 번 실행해도 이미 적용된 단계를 건너뛸는 기존 `information_schema` + `PREPARE` 패턴을 따른다.
- 기존 row의 언어를 별도로 추론하거나 이미지·제목을 분석해 자동 분류하지 않는다.
## 10. 성능·품질·보안 요구사항
- 언어 조건은 Repository QueryDSL `where`에서 적용하고 메모리 후처리를 추가하지 않는다.
- 기존 `Lang`, `MemberContentPreferenceService`, `CountryContext`를 재사용하고 신규 dependency·resolver abstraction을 추가하지 않는다.
- 언어 파라미터는 `Lang.fromCode`로 정규화·검증하고 잘못된 값을 엔티티 생성과 S3 업로드 전에 거부한다.
- 민감정보·헤더·파일 본문을 신규 로그에 기록하지 않는다.
- TDD는 관리자 등록·목록, 앱 국가별 목록·팝업, 기존 내부 전체 언어 조회 비회귀를 포함한다.
- focused test 후 직접 영향 회귀와 `ktlintCheck`를 실행한다. 전체 회귀는 targeted test로 영향 범위를 판단할 수 없거나 공통 경계 회귀가 발생할 때만 확장한다.
## 11. 성공 기준
- [x] 관리자가 `KO`, `EN`, `JA` 이벤트를 등록하고 목록에서 각 언어를 확인한다. (`EVENT-LANG-001`, `EVENT-LANG-003`)
- [x] 수정 API와 레거시 `POST /event`의 요청 계약이 변경되지 않는다. (`EVENT-LANG-002`, `EVENT-LANG-008`)
- [ ] 기존 이벤트 모두가 KO로 이관되고 신규 언어 누락 row가 생성되지 않는다. (`EVENT-LANG-008`)
- [x] 판정 국가 JP에서 `GET /event`, `GET /event/popup`이 JA만 반환한다. (`EVENT-LANG-004~006`)
- [x] JP 이외와 국가 누락에서 두 API가 KO만 반환한다. (`EVENT-LANG-004`, `EVENT-LANG-005`)
- [x] 다른 언어의 최신 팝업이 있어도 언어 필터 후 선택된 팝업이 반환된다. (`EVENT-LANG-005`, `EVENT-LANG-009`)
- [x] 콘텐츠 메인 탭 6곳의 기존 `eventBannerList`는 언어 필터 없이 조회된다. (`EVENT-LANG-007`)
- [x] 기존 성인·활성·게시 기간·정렬·URL·응답 스키마 정책이 유지된다.
### 11.1 구현 완료 검증 — 2026-08-20
- 관리자 등록·목록: `AdminEventBannerControllerIntegrationTest` 4개와 `EventRepositoryTest`의 관리자 전체 언어 조회가 통과했다. `Lang.fromCode`는 기존 `KO`, `EN`, `JA` enum의 code/name을 대소문자 무시로 처리하며, 누락·잘못된 값은 저장·업로드 없이 거부된다.
- 레거시 계약: controller·service diff에서 관리자 수정·삭제와 `POST /event`, `PUT /event`, `DELETE /event/{id}``lang`이 추가되지 않았다.
- DB 이관: `20260819_event_lang_ddl.sql`이 nullable 컬럼 추가 → `NULL`의 KO backfill → `NOT NULL DEFAULT 'KO'` 순서를 갖고, 엔티티 기본값과 JA/KO 영속성 테스트도 통과했다. 다만 운영 DDL은 실행하지 않았으므로 실제 기존 row 전체 이관 완료는 증명하지 않고 체크하지 않았다.
- JP·강제 매핑: security-on 익명 JP 목록과 인증 JP 팝업 통합 테스트, `EventServiceTest`의 JP→JA 전달, `MemberContentPreferenceIntegrationTest`의 강제 JP/KR 매핑이 통과했다.
- 비JP·누락: 국가 누락의 익명 KO 목록과 인증 KO 팝업 통합 테스트, 일반 회원의 US·누락 판정과 비로그인 JP 정규화·누락 KR 테스트가 통과했다. service의 `JP` 이외→`KO` 분기도 diff로 확인했다.
- 팝업 선필터: 더 최신 KO와 성인 JA 팝업이 있어도 비성인 JA 조회가 대상 JA를 반환하는 Repository 테스트가 통과했고, 언어 predicate가 `where`에서 정렬·`fetchFirst()` 전에 적용됨을 확인했다.
- 콘텐츠 메인: 저수준 service의 `lang = null` 전달과 Repository의 KO/EN/JA 3건 전체 조회 테스트가 통과했다. 6개 production 호출자와 해당 응답 DTO 디렉터리 diff는 없었다.
- 기존 정책: Repository 테스트가 활성·시작·종료·성인·ID 내림차순 조건을 함께 검증했고, `EventService` URL 변환 및 `GetEventResponse`·`EventItem` diff가 없음을 확인했다. 구현 리뷰 후 익명 팝업 401과 인증 팝업의 국가별 언어를 함께 검증했고 기준 HEAD 대비 `SecurityConfig` diff가 없음을 확인했다.
## 12. Open Questions
- 없음.
## 13. 요구사항 추적표
| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 |
|---|---:|---|---|
| `EVENT-LANG-001~003`, `EVENT-LANG-008` | 1 | `P1-T1`, `P1-T2`, `P1-GATE` | 엔티티·관리자 controller/service/repository 통합 테스트 |
| `EVENT-LANG-004~006`, `EVENT-LANG-009` | 2 | `P2-T1`, `P2-GATE` | `EventServiceTest`, `EventRepositoryTest`, `EventControllerIntegrationTest` |
| `EVENT-LANG-007` | 2 | `P2-T2`, `P2-GATE` | 언어 없는 기존 서비스 호출과 Repository null 조건 비회귀 테스트 |
## 14. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|---|---|---|---|---|---|
| 2026-08-19 | `DEC-001` | 확정 | 앱 국가 판정은 기존 `MemberContentPreferenceService.resolveCountryCode(member)`를 사용한다. | 사용자 인터뷰 A | `EVENT-LANG-004~006`, `P2-T1` |
| 2026-08-19 | `DEC-002` | 확정 | 등록 언어는 기존 `Lang``KO`, `EN`, `JA` 모두를 허용한다. | 사용자가 A를 철회하고 B로 확정, 신규 언어 타입·검증 중복 방지 | `EVENT-LANG-001`, `P1-T2` |
| 2026-08-19 | `DEC-003` | 확정 | 언어는 등록할 때만 지정하고 수정하지 않는다. | 사용자 인터뷰 A, 콘텐츠 배너의 현재 패턴 | `EVENT-LANG-002`, `P1-GATE` |
| 2026-08-19 | `DEC-004` | 확정 | 관리자 목록은 언어 전체를 조회하고 `lang`을 응답한다. | 사용자 인터뷰 A | `EVENT-LANG-003`, `P1-T2` |
| 2026-08-19 | `DEC-005` | 확정 | 국가별 필터는 `EventController.getEventList`, `getEventPopup`에만 적용한다. | 사용자 인터뷰 A | `EVENT-LANG-004`, `EVENT-LANG-005`, `EVENT-LANG-007`, `P2-T1~T2` |
| 2026-08-19 | `DEC-006` | 확정 | 기존 이벤트는 KO로 backfill하고 `NOT NULL DEFAULT 'KO'`를 적용한다. | 사용자 인터뷰 A, 기존 배너 DDL 패턴 | `EVENT-LANG-008`, `P1-T1` |
| 2026-08-19 | `DEC-007` | 확정 | 언어 입력은 `AdminEventBannerController`에만 추가하고 레거시 `POST /event`는 KO 기본값을 사용한다. | 사용자 인터뷰 A | `EVENT-LANG-002`, `EVENT-LANG-008`, `P1-T1~T2` |
| 2026-08-20 | `DEC-008` | 확정 | `GET /event`의 익명 접근은 유지하고 `GET /event/popup`은 기준 HEAD의 인증 필수 정책을 유지한다. | 구현 리뷰에서 언어 필터와 무관한 익명 공개 확장을 확인 | `EVENT-LANG-005`, `P2-R1` |
## 15. 변경 관리
요구사항이 변경되면 다음 순서로 갱신한다.
1. 이 문서의 Decision Log에 변경 이유와 날짜를 추가한다.
2. 관련 요구사항·수용 기준·API 계약을 갱신한다.
3. `plan-task.md`의 범위·Files·Interfaces·체크박스를 코드 변경 전에 먼저 갱신한다.
4. 기존 Progress·검증 기록을 삭제하거나 덮어쓰지 않는다.