docs(recommendation): 노출 정책과 구현 검증 결과를 기록한다

This commit is contained in:
2026-10-31 21:53:54 +09:00
parent 969f999ae9
commit 610a8edce4
3 changed files with 608 additions and 0 deletions
@@ -0,0 +1,344 @@
# 추천 화면 AI 캐릭터 및 이벤트 배너 노출 정책 구현 계획
> 실행 전제: 구현을 시작하려면 사용자의 별도 실행 승인이 필요하다. 승인 전 본 계획의 모든 실행 체크박스는 미완료 상태로 둔다.
**Goal:** 홈 추천 aggregate에서는 AI 캐릭터를 항상 빈 배열로 반환하고, 홈·오디오 추천의 EVENT 배너에는 기존 이벤트 상세 노출 정책을 적용한다.
**Architecture:** 홈 aggregate는 AI 추천 query 경로를 호출하지 않는다. 각 추천 repository의 기존 배너 QueryDSL 조회에만 EVENT 적격 조건을 추가한다. 이벤트 시간은 상세 조회와 같은 system-default `LocalDateTime` 의미로 별도 평가하고, 기존 배너 예약용 UTC clock은 변경하지 않는다.
**Tech Stack:** Kotlin 1.6.21, Spring Boot 2.7.14, Java 17, JUnit 5, QueryDSL 5.0.0, Spring Data JPA, H2 MySQL mode 테스트.
**Spec:** [`prd.md`](prd.md)
---
## 문서 및 실행 상태
| 항목 | 상태 |
|---|---|
| 문서 상태 | 구현 및 검증 완료 · 전체 빌드는 UTC 환경 기준 |
| 작성일 | 2026-10-31 |
| 요구사항 기준 | `docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/prd.md` |
| API 기준 | PRD §8, 기존 응답 schema 유지 |
| 현재 Phase | Phase 1 · 추천 응답 노출 정책 일치 |
| 현재 활성 Goal | 없음 · `P1-GATE` 완료 |
| 실행 순서 | `P1-T1` → `P1-T2` → `P1-T3` → `P1-GATE` |
계획 작성 당시에는 구현하지 않았으며, 이후 사용자의 `문서대로 진행` 및 `ulw` 승인에 따라 구현했다. 개별 실행 기록은 당시 상태를 보존하고, 현재 상태는 상단 표와 최신 Gate 기록을 따른다.
## Global Constraints
- `REC-AI-001`에 따라 홈 aggregate의 `aiCharacters`는 익명·회원·ADMIN 모두 `[]`이고 null 또는 생략이 아니다. 해당 경로에서 AI query/fallback을 호출하지 않는다.
- 오디오 추천 DTO에 AI 캐릭터 필드를 추가하지 않는다. 전용 `/api/v2/home/recommendations/ai-characters` 흐름을 보존한다.
- EVENT 배너의 적격 조건은 연결 이벤트 존재, 활성, `startDate <= eventNow <= endDate`, 회원 해석 국가의 언어, 성인 대상 정책이다.
- 인증 대상은 `AuthRepository.getAuthIdByMemberId(memberId) != null`과 같다. 익명은 미인증이다. `isAdult == null`은 모두, `true`는 인증, `false`는 미인증만 적격이다.
- ADMIN은 이벤트 audience 조건만 우회하며 존재·활성·시간·언어 조건과 배너 자체 조건은 우회하지 않는다.
- 기존 banner-own 활성·tab·language·reservation·성인 노출 가능 여부·creator-block 조건은 이벤트 조건과 AND로 유지한다. 기존 정렬, 동순위 random tie, 적격 후보에 대한 limit 20을 유지한다.
- 기존 공개 service/port 서명은 보존한다. per-event `EventDetailService.getEvent` 호출, 신규 abstraction, N+1 또는 별도 application query를 만들지 않는다.
- 양 repository의 별도 clock 의존성은 `eventNowProvider: () -> LocalDateTime = { LocalDateTime.now() }`를 기존 `nowProvider` 바로 앞에 추가한다. event clock은 query당 한 번 캡처하고, 기존 trailing-lambda가 주입하는 banner `nowProvider`는 UTC 예약 판정 전용으로 보존한다.
- 이벤트 비교는 `EventDetailService`처럼 system-default `LocalDateTime.now()` 의미와 sub-second 정밀도를 사용한다. 기존 banner reservation의 UTC/초 정밀도, 전역 timezone 및 formatter는 바꾸지 않는다.
- 구현 Task는 각자 RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR 순서다. Task마다 실패 재현, 최소 구현, focused 검증 증거를 남긴다.
- 전체 회귀는 공통 인증·보안·예외·설정 경계 변경, 여러 domain 변경, release/final 전수 증거 요구, 또는 targeted test로 영향 범위를 판정할 수 없는 실패 때만 수행한다. 그 외에는 생략 사유와 focused 대체 검증을 Progress에 기록한다.
## Review Focus
- 인증 기록과 `canViewAdultContent`가 서로 다른 조회자: 이벤트는 인증 기록, 배너 자체는 기존 성인 노출 가능 여부를 각각 적용한다. `P1-T2`, `P1-T3`에서 인증된 `canViewAdultContent=false` 및 미인증 `canViewAdultContent=true` 조합을 고정한다.
- 이벤트 경계 시각의 clock/정밀도 차이: 두 clock을 서로 다르게 고정하고 nanos를 포함하여 정확한 양끝은 포함, 직전·직후는 제외한다. 기존 예약 UTC clock 값과 이벤트 clock 값을 다르게 설정한다. `P1-T2`, `P1-T3`에서 확인한다.
- 대상 없는 EVENT, 부적격 우선순위 후보, 전부 제외: 기존 다른 배너는 실패 없이 유지되고 필터가 limit 전에 실행되며 전부 제외 시 `[]`인지 각각 `P1-T2`, `P1-T3`에서 확인한다.
- ADMIN 권한 범위: 인증 audience만 우회하고 잘못된 언어·비활성·기간 밖 이벤트와 배너 자체 성인/활성 조건은 통과시키지 않는지 `P1-T2`, `P1-T3`에서 확인한다.
- request language와 국가 판정 불일치 및 오디오 schema: 홈·오디오 endpoint가 `resolveCountryCode`로 결정된 이벤트 언어를 적용하고 오디오 응답에 `aiCharacters`가 없음을 `P1-GATE`에서 확인한다.
## 파일 책임 및 작업 범위
| 파일 | 책임 |
|---|---|
| `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt` | 홈 aggregate의 AI 목록을 빈 목록으로 고정하고 aggregate AI 조회 호출 제거 |
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt` | facade가 AI recommendation 경로를 호출하지 않으며 응답은 빈 목록임을 검증 |
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` | 홈 API AI 목록/전용 endpoint 유지 검증 및 홈 배너 정책 endpoint 회귀 검증 |
| `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` | 홈 배너 EVENT 조건과 별도 상세 정책 clock을 기존 QueryDSL 필터에 반영 |
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` | 홈 배너 QueryDSL의 audience·시간·언어·교집합·정렬/limit 검증 |
| `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` | 콘텐츠 추천 배너 EVENT 조건과 별도 상세 정책 clock을 기존 QueryDSL 필터에 반영 |
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt` | 오디오 배너 QueryDSL의 audience·시간·언어·교집합·정렬/limit 검증 |
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt` | 오디오 추천 응답에 AI 필드가 없고 배너 API 계약이 유지됨을 검증 |
## Phase 1: 추천 응답 노출 정책 일치
**Phase 결과:** 기존 응답 schema와 일반 배너 정책을 보존하면서 홈 aggregate AI 노출을 끄고 양쪽 추천 이벤트 배너의 상세 조회 노출 적격성을 일치시킨다.
**선행조건:** PRD 요구사항 확정 및 이 계획에 대한 별도 구현 실행 승인.
**Phase 완료 조건:** `P1-T1`, `P1-T2`, `P1-T3` 및 `P1-GATE` 완료와 실제 검증 결과 기록.
### Task 1: 홈 aggregate AI 빈 배열
- [x] **Task 1.1: 홈 aggregate AI 빈 배열**
**Goal 실행 `P1-T1`:** 홈 aggregate에서 AI recommendation 조회를 제거하고 응답의 기존 `aiCharacters` 필드를 모든 viewer에게 빈 배열로 유지한다.
- **시작 조건:** 사용자의 구현 실행 승인, `REC-AI-001~002` 확인.
- **완료 증거:** facade 단위 테스트에서 빈 배열과 query 비호출 확인, HTTP 테스트에서 seeded AI 대상에도 aggregate `[]`, 전용 AI 목록은 기존 식별자를 계속 반환.
- **범위 밖:** 전용 AI endpoint, snapshot/scheduler/data, 오디오 응답 schema.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
**Interfaces:**
- Consumes: 기존 `HomeRecommendationFacade.getHomeRecommendations(member: Member?, lang: Lang): HomeRecommendationResponse` 및 `HomeRecommendationResponse.aiCharacters: List<HomeAiCharacterItem>`.
- Produces: 동일한 외부 서명과 DTO schema. aggregate constructor에 `aiCharacters = emptyList()`; 전용 AI paging은 기존 `queryService.findAiCharacterRecommendations(...)` 호출을 보존.
- [x] **RED:** facade test `shouldReturnEmptyAiCharactersWithoutQueryingAiRecommendations`를 추가한다. fake query port의 snapshot/detail/random AI 호출을 계수 또는 fail-fast로 계측하고 응답의 `aiCharacters`가 비었으며 호출이 0회임을 assert한다. Controller test의 기존 `shouldReturnTwentyAiCharactersOnHomeRecommendations`도 aggregate가 seeded AI 22개에도 `[]`인지, 필드가 존재하는지 확인하도록 바꾼다. `shouldExposeCreatorIdOnAiCharacterRecommendations`는 aggregate의 빈 배열을 확인하고 `characterId`/`creatorId` 검증을 dedicated endpoint 호출로 옮긴다. seeded 데이터와 dedicated 검증은 유지한다.
- [x] **RED 확인:** 두 기존 assertion 변경과 새 facade test가 구현 전 의도대로 실패하는지 두 focused class를 실행한다.
```bash
./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" --tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest"
```
- [x] **GREEN:** facade aggregate에서 `aiCharacters = emptyList()`를 설정하고 aggregate의 `findAiCharacterRecommendations` 호출만 제거한다. 이제 쓰이지 않는 `HOME_AI_CHARACTER_LIMIT`만 제거한다.
- [x] **GREEN 확인:** 같은 두 focused class를 다시 실행해 facade와 controller test가 통과하는지 확인한다. Controller 응답은 익명, 미인증 회원, 인증 회원, ADMIN 각각 `aiCharacters: []`이며 KO/JA 변형에서도 동일하고, dedicated endpoint는 식별자를 반환한다.
```bash
./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" --tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest"
```
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 위 두 focused class를 재실행해 결과를 기록한다.
#### P1-T1 검증 기록 — 2026-10-31
- 공통 명령은 위 두 focused class 명령을 그대로 사용했다. 각 실행에서 `TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/recommendation-ulw.nT00w2/p1-t1.UhWqcw`, `NODE_COMPILE_CACHE=$TMPDIR/node-compile-cache`, `BUN_RUNTIME_TRANSPILER_CACHE_PATH=$TMPDIR/bun-transpiler`를 export하고 `set -o pipefail` 및 `tee`로 로그를 보존했다.
- Java는 기본 OpenJDK `17.0.15`를 사용했다. `/usr/libexec/java_home -v17`은 등록 런타임을 찾지 못했지만 기본 Java가 이미 17이어서 변경하지 않았다.
- BASELINE: 제품/테스트 수정 전 facade 6개 + controller 28개 = 34개 통과, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 50s`. 원문 `baseline.log`.
- RED: 제품 코드 변경 전에 동일 명령으로 42개 실행, 10개 실패, errors/skipped 0, 종료 코드 1, `BUILD FAILED in 46s`. facade 7개 중 신규 1개 실패, controller 35개 중 정책 변경 대상 9개만 실패했다. 원문 `red.log`.
- RED 원인: facade의 빈 응답 자체는 통과하지만 AI 최신 snapshot 조회·snapshot 존재 확인·상세 조회·fallback이 각각 1회여서 0회 assertion 4개가 실패했다. 기존 22개 seeded fixture의 익명/미인증/인증/ADMIN × KO/JA 8개와 기존 단일 fixture 집계는 비어 있지 않은 AI 배열 때문에 실패했다. dedicated `characterId`/`creatorId` assertion과 나머지 기존 검증은 삭제하지 않았다.
- GREEN: 집계의 AI query/map을 `emptyList()`로 교체하고 고아 `HOME_AI_CHARACTER_LIMIT`만 제거했다. 같은 focused 명령에서 facade 7개 + controller 35개 = 42개 통과, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 44s`. `compileKotlin` 및 `compileTestKotlin`이 실제 실행되어 통과했다. 원문 `green.log`, 결과 XML/HTML은 기존 `build/test-results/test/`, `build/reports/tests/test/`에 남긴다.
- GREEN 확인 범위: fake의 AI snapshot/존재 확인/detail/random/fallback 호출은 모두 0회다. MockMvc에서 실제 seeded AI 22개에도 익명/미인증/인증/ADMIN × Accept-Language KO/JA 8개가 존재하는 빈 배열을 반환하고, 별도 단일 seeded AI는 dedicated endpoint에서 기존 `characterId`/`creatorId`를 반환했다. 기존 dedicated paging 및 익명 접근 거부 테스트도 통과했다. 인증 fixture는 실제 Auth row를 persist하고 flush/clear했다.
- REFACTOR/자체 검토: 수정한 facade·두 테스트를 다시 읽고 호출 경로와 assertion을 대조했다. 제품 변경은 집계 할당 및 고아 상수에만 한정되며 shared mapper, dedicated paging, DTO, snapshot/scheduler, 배너는 그대로다. 이번 변경으로 정리할 추가 중복이 없어 별도 코드 수정이나 추가 재실행은 하지 않고 위 GREEN 실행을 최종 focused 증거로 사용한다. scoped 자체 검토의 차단 이슈는 없다.
- 검증 제한: P1-T1 위임 범위와 중단 조건에 따라 전체 suite/build, `ktlintCheck`, 실제 서버 HTTP QA 및 P1-T2 이후 작업은 수행하지 않았다. 공유 경계 변경이 없는 단일 aggregate 변경이므로 두 focused class로 대체했고 최종 Gate 검증은 상위 실행자가 소유한다. `lsp_diagnostics` 도구는 없으며 실행했다고 주장하지 않는다. BASELINE부터 있던 Gradle deprecation과 embedded Redis 종료 reconnect 경고는 GREEN에도 존재하지만 실패는 아니다. commit/add/push 및 임시 증거 삭제는 하지 않았다.
### Task 2: 홈 추천 EVENT 배너 SQL 적격성
- [x] **Task 1.2: 홈 추천 EVENT 배너 SQL 적격성**
**Goal 실행 `P1-T2`:** 기존 홈 배너 DB 조회에 이벤트 상세 적격 조건을 limit 전 적용하고 기존 banner-own 필터·정렬 계약을 보존한다.
- **시작 조건:** `P1-T1` 완료, `REC-EVT-001~007` 확인.
- **완료 증거:** repository integration test에서 QueryDSL `EXISTS` 정책과 clock 경계를 검증하고 controller integration test에서 정책에 맞는 응답 확인.
- **범위 밖:** 오디오 repository, per-event service 조회, public port 변경, 일반 배너 조건 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
**Interfaces:**
- Consumes: 기존 `findHomeBanners(limit: Int, memberId: Long?, includeAdultBanners: Boolean, lang: Lang)` 및 기존 회원의 resolved banner `lang`.
- Produces: 공개 메서드 서명 불변. 생성자에 기본값 clock `eventNowProvider`를 `nowProvider` 바로 앞에 추가하고 EVENT predicates에서만 query당 한 번 사용한다. 기존 최종 `nowProvider` parameter/trailing lambda는 reservation 검증에 그대로 쓴다.
- [x] **RED:** `shouldApplyHomeEventVisibilityPolicyBeforeLimit` parameterized DB integration test와 경계 테스트를 추가한다. 익명·미인증·인증·ADMIN × `isAdult=null/false/true`, active/hidden/future/expired/wrong-language, EVENT 대상 없음, 비-EVENT 불변을 검증한다. Auth/Member fixture를 저장하고 flush/clear한다. 교차 검증으로 인증+`includeAdultBanners=false`, 미인증+`includeAdultBanners=true`를 포함한다. 후보 1이 숨겨지고 후보 2가 limit=1로 반환되는 경우 및 전체 후보 숨김 시 `[]`를 확인한다. 기존 positive EVENT fixtures도 고정 event clock에서 적격인 값으로 정렬하되 검증 강도는 유지한다. `HomeRecommendationControllerTest.shouldApplyEventBannerDetailPolicyForHomeAggregate`는 네 조회자와 KO/JA 국가, Accept-Language 불일치까지 확인한다.
- [x] **RED 확인:** repository와 새 controller endpoint 테스트를 함께 실행해 현 구현에서 기간·언어·대상·limit assertion이 실패함을 확인한다.
```bash
./gradlew test --tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest"
```
- [x] **GREEN:** `DefaultHomeRecommendationQueryRepository`의 홈 배너 query에만 EVENT 분기 predicate를 추가한다. `QMember` alias와 `JPAExpressions`로 audience를 `isAdult == null || viewer is ADMIN || (isAdult == true && matching Auth exists) || (isAdult == false && matching Auth absent)`로 판정한다. 익명은 Auth 없음으로 본다. event 존재/활성·시간·resolved banner language 조건 및 audience는 기존 predicate와 함께 order/limit 전에 적용한다.
- [x] **GREEN 확인:** 같은 두 focused class를 다시 실행해 repository 및 endpoint assertion의 성공을 확인한다. `eventNowProvider`와 기존 `nowProvider`를 서로 다른 시간으로 주입하여 event 시간의 포함 양끝·직전/직후, non-zero nanos 및 기존 UTC reservation 필터가 각각 독립됨을 검증한다.
```bash
./gradlew test --tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest"
```
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 동순위 random 순서에 의존하지 않게 집합과 orders로 확인한다. 기존 positive/negative 배너 및 API 테스트를 다시 실행한다. 기존 두 홈 repository 직접 생성 위치의 기본 인자와 trailing-lambda 호환성도 유지한다.
#### P1-T2 검증 기록 — 2026-10-31
- 상태: 구현·두 focused class 전체 통과·작성자 자체 검토 완료. 상위 실행자의 독립 리뷰·commit·최종 Gate는 대기하며 `P1-T3`는 착수하지 않았다. 수정 범위는 위 제품 파일 1개·테스트 2개와 본 Task 체크리스트/증거 및 활성 Goal뿐이다.
- 모든 테스트 실행은 아래 동일 명령을 사용했다. `TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/recommendation-ulw.nT00w2/p1-t2.Z5xdEM`, `NODE_COMPILE_CACHE=$TMPDIR/node-compile-cache`, `BUN_RUNTIME_TRANSPILER_CACHE_PATH=$TMPDIR/bun-transpiler`, `JAVA_HOME=/Users/klaus/.local/share/mise/installs/java/liberica-17.0.15+10`를 매 shell에서 export했다. `set -o pipefail`과 `tee`로 원문을 보존했으며 확장 RED/GREEN의 화면 출력만 `rg --line-buffered '(^> Task|FAILED|tests completed|BUILD )'`로 요약했다.
```bash
./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
```
- BASELINE: 수정 전 repository 98개 + controller 35개 = 133개, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 36s`. 원문 `baseline.log`.
- 최초 RED: 제품/생성자를 변경하지 않고 2000~2100의 넓은 event window와 실제 Member/Auth fixture로 실행했다. repository 120개 중 14개 + controller 43개 중 8개 = 163개 중 22개 assertion 실패, errors/skipped 0, 종료 코드 1, `BUILD FAILED in 57s`. 기간/언어 부적격 이벤트에 `expected [] / actual [hidden]`, 인증 대상에 `expected [all,false] 또는 [all,true] / actual [all,false,true]`, limit=1에 `expected visible-second / actual hidden-first`, endpoint 배너 수에 `expected 2/3 / actual 6` 등을 확인했다. 원문 `red.log`. 기존 AI 빈 배열·dedicated endpoint assertion은 그대로 유지됐다.
- 추가 RED: 최초 assertion RED 뒤에만 optional clock 인자를 추가하고 repository 테스트에 명시적으로 주입했다. event predicate 구현 전 nanos 경계 15개를 추가해 시작/종료/동일 양끝의 포함과 1ns 밖 제외를 검증했다. 첫 실행은 `compileTestKotlin` 중 shell 제한 120000ms로 중단됐으며 assertion 결과로 세지 않았다(`red-boundaries.log`). 수정 없이 360000ms 제한으로 재실행하여 repository 135개 중 20개 + controller 43개 중 8개 = 178개 중 28개 assertion 실패, errors/skipped 0, 종료 코드 1, `BUILD FAILED in 4m 3s`를 확인했다. BEFORE/AFTER 6개가 추가로 `expected [] / actual [boundary]`였다. 원문 `red-boundaries-retry.log`.
- GREEN: EVENT 분기에만 활성·`startDate <= eventNow <= endDate`·resolved `lang`·audience AND를 추가했다. 익명은 null/false, 회원은 null 또는 `QMember("eventViewer")` ADMIN EXISTS 또는 true/Auth EXISTS 또는 false/Auth NOT EXISTS다. Auth는 `auth.member.id == memberId`로 조회하여 기존 AuthRepository 판정과 같다. query당 event clock을 한 번 캡처하며 `LocalDateTime.now()` 기본값의 system-zone/nanos를 보존한다. 기존 `nowProvider`는 마지막 UTC lambda이고 배너 예약의 초 정밀도 판정은 변경하지 않았다.
- GREEN 결과: 위 동일 명령(600000ms 제한)에서 repository 135개 + controller 43개 = 178개 모두 통과, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 1m 27s`. `compileKotlin`·`compileTestKotlin`·`:test`가 실제 실행됐다. 원문 `green.log`; 최종 XML은 `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.xml`과 `TEST-kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.xml`, HTML은 `build/reports/tests/test/`다. 임시 root의 `evidence.md`에도 counts·경계·제한을 보존했다.
- DB 증거: flush/clear한 Member/Auth와 이벤트/배너로 4 viewer × null/false/true × KO/JA, 다른 회원의 Auth가 있는 미인증 조회자, 인증+배너 성인 노출 false / 미인증+true, ADMIN audience만 우회, 배너 자체 활성/탭/언어/예약/성인 조건, 누락·비활성·미래·만료·다른 언어 이벤트, hidden-first/eligible-second limit=1, 전부 제외 `[]`, LINK/CREATOR/SERIES 불변을 검증했다. 생성 SQL에서 EVENT의 시간/언어/audience 조건과 `member.id/role` EXISTS 및 `auth.member_id` EXISTS/NOT EXISTS가 `orders asc, rand() asc limit ?` 앞에 있음을 직접 확인했다. 운영 DB에는 접근하지 않았다.
- clock/저장 정밀도 증거: event clock `2026-10-31T23:59:59`의 nanos 0/123456789/999999999와 banner UTC clock `2026-10-27T23:59:59.999999999`를 별도로 사용했다. H2 테스트의 event start/end만 `timestamp(9)`로 설정해 flush/clear/reload 후 고정 기대 시각과 정확히 같음을 확인했다. event 1ns 비교와 기존 banner 시작=종료의 초 포함 경계가 함께 통과했다. 운영 Event DDL은 저장소에서 찾지 못했으며 이 증거는 query 비교 정밀도 검증이지 운영 MySQL 저장 정밀도 검증이 아니다. 운영 schema·timezone·formatter 변경은 없다. 기존 positive saveEvent도 고정 event clock ±1일로 맞췄다.
- endpoint 증거: 기존 `@SpringBootTest` MockMvc에서 익명/미인증/인증/ADMIN × `CloudFront-Viewer-Country` KR/JP와 반대 `Accept-Language` ja/ko 8개가 성공했고 적격 이벤트 ID의 전체 개수와 정확한 순서를 검증했다. 모두 `aiCharacters`가 존재하는 배열이면서 비었다. 실제 Auth와 preference row를 저장/flush/clear했고 국가 고정 매핑 대상 ID(16/17 등)는 정책 테스트 fixture에서 피했다. 기존 강제 국가 정책 자체는 변경하지 않았다.
- REFACTOR/자체 검토: PRD/Global Constraints·호출자(facade→query service→repository)·AuthRepository 및 EventDetailService와 제품 diff/테스트 전체를 대조했다. 조건은 EVENT OR 분기에만 있고 banner-own 조건·다른 target 분기·creator-block·정렬/limit는 보존됐다. 기존 3인자 기본 생성과 named event clock + trailing banner clock 생성 모두 컴파일됐고 전체 focused 검증을 통과했다. 추가 중복 정리가 필요하지 않아 GREEN 뒤 제품/테스트를 다시 변경하지 않았다. 작성자 자체 검토의 차단 이슈는 없다.
- 검증 제한: 위임 중단 조건에 따라 전체 suite/build·ktlint·실제 서버 HTTP QA·오디오/상세/subBanner 회귀 및 독립 리뷰는 상위 Gate에 남긴다. 이번 검증은 실제 H2 QueryDSL과 SpringBootTest MockMvc endpoint 증거다. `lsp_diagnostics` 도구는 없으며 실행했다고 주장하지 않는다. BASELINE부터의 Gradle deprecation 및 embedded Redis 종료 reconnect 경고는 GREEN에도 있으나 실패는 아니다. `git diff --check` 종료 코드 0; commit/add/push, subagent 호출 및 임시 증거 삭제는 하지 않았다.
#### P1-T2 커밋 훅 포맷 보정 — 2026-10-31
- 상위 실행자가 독립 기능 리뷰 후 시도한 commit은 ktlint 훅에서 실패했고 commit은 생성되지 않았다. `build/reports/ktlint/ktlintTestSourceSetCheck/ktlintTestSourceSetCheck.txt`의 신규 테스트 포맷 오류 17건을 확인했다. 두 테스트의 새 CSV 인자 줄바꿈, multiline if/else 중괄호, persist/apply 괄호 줄바꿈만 `apply_patch`로 수정했다. 제품 동작·fixture 값·assertion·테스트 수는 변경하지 않았으며 전체 formatter는 실행하지 않았다.
- 기존 P1-T2 임시 경로·Java 17·cache 환경 변수를 매 shell에서 동일하게 export하고 `set -o pipefail`/`tee`로 보존했다. `./gradlew ktlintCheck`는 종료 코드 0, `BUILD SUCCESSFUL in 29s`; main/test source check가 실제 실행됐고 test ktlint 보고서는 0줄이다. 원문 `p1-t2.Z5xdEM/lint-fix-ktlint.log`.
- 이어서 위 P1-T2의 동일한 두 클래스 focused 명령을 실행했다. repository 135개 + controller 43개 = 178개 모두 통과, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 1m 8s`; `compileTestKotlin`과 `:test`가 실제 실행됐다. 원문 `p1-t2.Z5xdEM/lint-fix-tests.log`. 최종 XML timestamp는 repository `2026-10-31T11:31:59`, controller `2026-10-31T11:31:46`다.
- 보정 diff를 확인해 신규 테스트 포맷 변경만 있음을 대조했고 `git diff --check` 종료 코드 0을 확인했다. 이번 보정은 formatting-only로 별도 RED/신규 테스트를 추가하지 않고 기존 focused 테스트로 회귀를 확인했다. 전체 suite/build·subagent·git add/commit/amend는 실행하지 않았으며 상위 실행자가 수정분을 stage하고 새 commit을 재시도한다.
### Task 3: 오디오 추천 EVENT 배너 SQL 적격성
- [x] **Task 1.3: 오디오 추천 EVENT 배너 SQL 적격성**
**Goal 실행 `P1-T3`:** 오디오 추천의 기존 `findBanners` 조회에 홈과 같은 이벤트 상세 정책을 limit 전에 적용하고 현재 audio banner 조건과 응답 schema를 보존한다.
- **시작 조건:** `P1-T2` 완료, PRD `REC-EVT-001~007` 확인.
- **완료 증거:** 오디오 repository integration test가 홈과 같은 eligibility matrix를 통과하고 `AudioRecommendationsResponse` schema가 유지됨.
- **범위 밖:** 홈 query 변경, `AudioRecommendationsResponse` 필드 추가, subBanner·LINK 동작 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`
**Interfaces:**
- Consumes: 기존 `findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean, lang: Lang)` 및 현재 banner language.
- Produces: 공개 repository/facade/DTO 서명 불변. `eventNowProvider`를 `nowProvider` 바로 앞 constructor parameter로 추가하고 EVENT 조건만 사용; 기존 `nowProvider` trailing lambda는 banner reservation 전용 유지.
- [x] **RED:** `shouldApplyAudioEventVisibilityPolicyBeforeLimit` parameterized DB integration test를 추가한다. Task 2와 같은 viewer × audience, 이벤트 active/time/lang, admin 제한, 누락 EVENT, non-EVENT, banner adult 교차 조합을 flush/clear 후 검사한다. 후보 1이 숨겨지고 후보 2가 limit=1로 반환되는 경우와 전부 숨겨져 `[]`인 경우를 검증한다. 기존 positive EVENT fixtures를 고정 event clock에서 적격인 값으로 맞추되 검증 강도는 유지한다. `AudioRecommendationEndToEndTest.shouldApplyEventBannerDetailPolicyAndKeepAudioResponseSchema`를 추가해 KO/JA country와 Accept-Language 불일치 및 조회자별 결과, `aiCharacters` 부재를 확인한다.
- [x] **RED 확인:** repository와 새 end-to-end endpoint 테스트를 함께 실행해 현재 query가 기간·언어·성인 audience를 강제하지 않아 추가한 assertion이 실패함을 확인한다. 기존 audio 필드 부재 assertion은 현재도 통과하는 계약 보존 검사로 구분한다.
```bash
./gradlew test --tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest"
```
- [x] **GREEN:** 오디오 repository의 기존 EVENT active branch에만 홈과 같은 `QMember`/`JPAExpressions` audience (`null || ADMIN || (true && Auth exists) || (false && Auth absent)`), event time, event language predicate를 추가한다. ADMIN은 audience만 제외한다. 기존 `adultBannerCondition(canViewAdultContent)`, tab/lang/reservation/target predicate 및 order/limit은 보존한다.
- [x] **GREEN 확인:** 같은 두 focused class를 다시 실행해 repository와 endpoint assertion의 성공을 확인한다. 별도 event clock의 system-default 시각 및 nanos 경계와 banner UTC reservation clock을 서로 다르게 설정해 독립성을 검증한다.
```bash
./gradlew test --tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest"
```
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 random tie 고정 순서 assertion을 피한다. 아래 두 focused class를 실행하고 기존 DTO field set 및 repository 생성자의 trailing-lambda 호환성을 확인한다.
```bash
./gradlew test --tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest"
./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest"
```
#### P1-T3 검증 기록 — 2026-10-31
- 상태: 오디오 EVENT 정책 구현·두 focused class 전체·`ktlintCheck`·작성자 자체 검토 완료. 상위 실행자의 독립 리뷰·commit·최종 Gate는 대기한다. 변경은 본 Task의 제품 1개·테스트 2개와 본 문서의 활성 Goal/Task3 체크리스트·증거에 한정했다. 홈 구현 `1f24a4b7`은 의미상 기준으로 읽기만 했다.
- 각 shell에서 `TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/recommendation-ulw.nT00w2/p1-t3.6bJWqa`, `NODE_COMPILE_CACHE=$TMPDIR/node-compile-cache`, `BUN_RUNTIME_TRANSPILER_CACHE_PATH=$TMPDIR/bun-transpiler`, `JAVA_HOME=/Users/klaus/.local/share/mise/installs/java/liberica-17.0.15+10`를 export했다. 아래 동일 명령을 매번 600000ms 제한으로 실행했고 `set -o pipefail`/`tee`로 원문을 보존했다. 화면만 `rg --line-buffered '(^> Task|FAILED|tests completed|BUILD )'`로 요약했다.
```bash
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest
```
- BASELINE: 제품·테스트 수정 전 repository 29개 + E2E 2개 = 31개 통과, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 44s`. 원문 `baseline.log`.
- 최초 RED: 기존 생성자 그대로, 이벤트 기간 2000~2100 및 실제 Member/Auth를 저장·flush/clear한 테스트로 repository 51개 중 14개 + E2E 10개 중 8개 = 61개 중 22개 assertion 실패, errors/skipped 0, 종료 코드 1, `BUILD FAILED in 55s`. `expected all|false 또는 all|true / actual all|false|true`, 숨김 이벤트 `expected [] / actual [hidden]`, limit=1 `expected visible-second / actual hidden-first`, endpoint 배너 `expected 2 또는 3 / actual 6`을 확인했다. AI 필드 부재 assertion은 배너 수 assertion보다 먼저 통과했다. 원문 `red.log`.
- 경계 RED: 최초 assertion RED 이후에만 optional `eventNowProvider`를 추가하고 주입했다. predicate 구현 전 고정 event clock ±1일 fixture 및 nanos 경계 15개를 추가해 repository 66개 중 20개 + E2E 10개 중 8개 = 76개 중 28개 assertion 실패, errors/skipped 0, 종료 코드 1, `BUILD FAILED in 2m`. BEFORE/AFTER 6개가 추가로 `expected [] / actual [boundary]`였고 DB 시각의 정확한 왕복·포함 경계 9개는 통과했다. 원문 `red-boundaries.log`.
- GREEN: 홈과 같은 EVENT 활성·포함 기간·resolved `lang`·audience 조건을 추가했다. 익명은 null/false, 로그인 회원은 null 또는 `QMember("eventViewer")` ADMIN EXISTS 또는 true/Auth EXISTS 또는 false/Auth NOT EXISTS이며 Auth는 `auth.member.id == memberId`를 따른다. repository 66개 + E2E 10개 = 76개 모두 통과, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 54s`. `compileKotlin`·`compileTestKotlin`·`:test`가 실제 실행됐다. 원문 `green.log`; XML timestamp는 E2E `2026-10-31T11:42:37`, repository `2026-10-31T11:42:45`다. 최종 결과는 `build/test-results/test/`의 두 클래스 XML 및 `build/reports/tests/test/`에 있고 임시 root의 `evidence.md`에도 요약했다.
- DB 검증: 4 viewer × KO/JA × null/false/true audience, 인증+`canViewAdultContent=false` / 미인증+true, 다른 회원의 인증 row, ADMIN audience만 우회, 누락·비활성·미래·만료·잘못된 언어 이벤트, hidden-first/eligible-second limit=1, 모두 숨김 `[]`, LINK/CREATOR/SERIES 불변과 배너 자체 활성·tab2·언어·UTC 예약·성인 조건을 확인했다. 생성 SQL에서도 EVENT 조건 및 `member.id/role` EXISTS와 `auth.member_id` EXISTS/NOT EXISTS가 `orders asc, rand() asc limit ?` 앞에 있음을 직접 읽었다.
- clock/저장 정밀도: event clock `2026-10-31T23:59:59`의 nanos 0/123456789/999999999와 banner UTC clock `2026-10-27T23:59:59.999999999`를 다르게 주입했다. H2 event start/end만 `timestamp(9)`로 설정하고 flush/clear/reload 값과 고정 시각을 정확히 비교했다. 이벤트 시작·종료·양끝 동일 포함, 1ns 직전/직후 제외 및 배너 예약 시작=종료의 기존 초 경계를 함께 검증했다. 이 H2 설정은 predicate 나노초 비교 증거이지 운영 저장 정밀도 증거가 아니다. 운영 Event DDL은 저장소에서 찾지 못했으며 운영 schema·timezone·formatter는 변경하지 않았다.
- endpoint 검증: 기존 `@SpringBootTest`/MockMvc와 별도 `TransactionTemplate` fixture로 실제 persistence를 거쳐 익명/미인증/인증/ADMIN × KR/JP 및 반대 Accept-Language ja/ko 8개가 적격 이벤트의 정확한 ID·개수·순서를 반환하고 `aiCharacters`는 필드 자체가 없음을 확인했다. Auth/preference row를 저장·flush/clear·commit하고 강제 국가 매핑 ID는 피했다. 새 fixture는 이 클래스 전용 H2 DB의 기존 배너를 정리해 parameterized 실행을 격리한다. 기존 추천 섹션·번역 테스트는 약화하지 않았다.
- lint/자체 검토: `./gradlew ktlintCheck` 종료 코드 0, `BUILD SUCCESSFUL in 24s`이며 test/main lint 실행이 성공했다. 원문 `ktlint.log`. 제품·테스트 diff와 facade→query service→repository의 memberId/resolved lang 흐름, 상세 서비스/AuthRepository, 기존 DTO를 대조했다. 공개 port 인자·DTO·홈·상세·subBanner·설정·schema는 불변이고 event clock은 query당 한 번, 절삭 없이 사용한다. 마지막 UTC `nowProvider` trailing lambda와 Spring 기본 인자 생성 모두 컴파일·실행됐다. 추가 중복 정리가 필요하지 않아 GREEN 후 제품·테스트를 다시 변경하지 않았으며 위 GREEN을 최종 focused 증거로 사용한다. 자체 검토 차단 이슈 없음, `git diff --check` 종료 코드 0.
- 검증 제한: 위임 중단 조건과 단일 오디오 조회 변경 범위에 따라 전체 suite/build·실제 서버 HTTP QA·홈/상세/subBanner 직접 회귀·독립 리뷰는 상위 Gate에 남긴다. 실행 증거는 H2 QueryDSL과 MockMvc이며 운영 MySQL 또는 실제 HTTP 검증으로 주장하지 않는다. LSP 도구는 없고 실행하지 않았다. BASELINE부터의 Gradle deprecation 및 embedded Redis 종료 reconnect 경고는 GREEN에도 있으며 테스트 실패는 없다. GREEN XML에는 오디오 변경과 무관한 H2 `chat_character_value` DDL 및 scheduler `skip locked` 로그도 있으나 suite errors/failures는 0이다. commit/add/push·subagent·임시 증거 삭제는 하지 않았다.
### Phase 1 Gate: 양 endpoint 정책 및 회귀 판정
**Goal 실행 `P1-GATE`:** 모든 확정 요구사항이 두 추천 endpoint의 응답과 repository 정책에서 일치하고 범위 밖 응답이 보존되는지 판정한다.
- **시작 조건:** `P1-T1`, `P1-T2`, `P1-T3` 완료 및 각 focused evidence 기록.
- **완료 증거:** 아래 회귀 테스트와 `ktlintCheck` 통과, 정책 매트릭스·AI 미호출·schema 유지 증거 Progress 기입.
- **범위 밖:** API 계약, 전용 AI endpoint, event detail 정책 자체 변경; 테스트 삭제·skip·완화.
- **TDD 예외 사유:** Gate는 새 동작을 구현하지 않는 validation-only 판정 goal이다. Gate에서 처음 발견된 요구사항 assertion은 소유 Task (`P1-T1`의 홈 테스트, `P1-T2`의 홈 테스트, `P1-T3`의 오디오 테스트)의 RED cycle에 추가한 뒤 gate를 재실행한다.
- **대체 검증 방법:** 아래 focused·직접 영향 회귀 명령 및 수용 기준 대조 결과를 Progress에 기록한다.
**수행 검증 명령:**
```bash
./gradlew test --tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest"
./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest" --tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest"
./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest" --tests "kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.RecommendationSubBannerIntegrationTest"
./gradlew test --tests "kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest"
./gradlew ktlintCheck
```
Event detail regression은 기존 상세 endpoint가 바뀌지 않았음을 검증한다. subBanner regression은 두 추천 API의 기존 subBanner 동작을 검증한다. API test는 익명·미인증·인증·ADMIN, KO/JA와 country/request-language 불일치, 배너 자체 성인/활성 조건, audio에 AI 필드 없음 및 dedicated AI paging 존속을 확인한다. 전체 suite/build는 위 변경으로 공통 경계까지 영향받거나 targeted 회귀로 실패 원인을 판정할 수 없을 때만 실행하고 생략 근거를 기록한다. 이 문서 작성에서는 어떤 명령도 실행하지 않는다.
- [x] 위 focused repository/API 및 직접 영향 회귀 범위를 최종 전체 테스트에서도 확인했다.
- [x] 두 endpoint의 이벤트 정책 매트릭스, 시간 clock 독립성, limit 전 필터, 빈 결과 동작을 기록했다.
- [x] 홈 aggregate AI empty/query 미호출과 dedicated AI endpoint 보존을 검증했다.
- [x] 오디오 schema 및 양쪽 `subBanners`가 변하지 않았음을 검증했다.
- [x] `ktlintCheck`와 전체 빌드 결과를 기록했다. 전체 회귀를 생략하지 않았으며 기본 환경 실패 및 UTC 재검증 조건을 구분했다.
## Dependency Order
| 순서 | Goal | 의존성 | 상태 |
|---:|---|---|---|
| 1 | `P1-T1` 홈 aggregate AI empty | 구현 승인 | 완료 · `4a6b2ee9` |
| 2 | `P1-T2` 홈 EVENT query | `P1-T1` | 완료 · `1f24a4b7` |
| 3 | `P1-T3` 오디오 EVENT query | `P1-T2` | 완료 · `969f999a` |
| 4 | `P1-GATE` endpoint parity/regression | `P1-T1`~`P1-T3` | 완료 · UTC 전체 빌드 및 실제 HTTP PASS |
## Progress
### 구현 실행 승인 — 2026-10-31
- 사용자 `문서대로 진행` 및 `ulw` 지시에 따라 `P1-T1`부터 순서대로 실행한다. 아래 기존 문서 작성 기록은 당시 증거로 보존한다.
- 현재 `test` 브랜치에서 진행하며 새 worktree를 자동 생성하지 않는다. 구현 전 tracked 파일 변경은 없고 기존 문서 두 개만 untracked였다.
- 최종 Gate는 사용자 요청에 따라 전체 테스트·빌드·실제 HTTP QA를 포함한다. `lsp_diagnostics`는 현재 도구에 없으므로 실행했다고 보고하지 않고 Kotlin 컴파일·ktlint 결과로 가능한 진단을 기록한다.
### 초기 문서 작성 당시 기록 (구현 승인 전)
- 문서 작성: PRD와 plan-task를 정합시켰고 sample plan, EventDetailService, 두 repository, DTO 및 관련 테스트 경로를 확인했다.
- 구현 상태: 미착수. 제품 코드·테스트·설정 변경 없음.
- 검증 상태: PRD 독립 검토, 계획 자체 검토 및 두 문서 공백 검사를 완료했다. Gradle test, `ktlintCheck`, build는 실행하지 않았다.
- 다음 조건: 사용자의 별도 구현 실행 승인 후 `P1-T1`부터 하나씩 활성화한다.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-10-31 | `PLAN-001` | 범위 확정 | 기존 public service/port signature를 유지하고 두 repository의 기존 QueryDSL EVENT 조건에만 SQL predicate를 적용한다. | 기존 repository 구조를 따른 구현 계획 선택 | `P1-T2`, `P1-T3` |
| 2026-10-31 | `PLAN-002` | 범위 확정 | 이벤트 시간용 system-default clock을 각 repository에 분리 주입하며 기존 UTC `nowProvider`는 예약 필터에 유지한다. 시간은 query당 한 번 캡처하고 nano를 절삭하지 않는다. | `EventDetailService`의 local `LocalDateTime.now()`와 repository의 UTC reservation clock 사용처 대조 | `P1-T2`, `P1-T3` |
| 2026-10-31 | `PLAN-003` | 보존 | 인증은 `AuthRepository.getAuthIdByMemberId`와 같은 `Auth.member.id` 존재 subquery로 판단하며 ADMIN은 이벤트 audience predicate만 우회한다. | AuthRepository의 inner join 및 member ID 필터와 제품 결정 | `P1-T2`, `P1-T3` |
## 문서 검증 기록
### P1-GATE 실행 기록 — 2026-10-31
- 세 구현 Task의 독립 리뷰가 모두 명세·품질 승인으로 종료됐다. T2 포맷만 보정한 후 178개 회귀와 ktlint를 재검증했고 커밋 훅도 통과했다.
- 실제 HTTP 검증: `http-qa.md`에 기록한 테스트 서버 요청 104건·판정 216건 모두 PASS. 임시 harness의 Redis 설정 호출 오류를 고친 후 제품 코드 수정 없이 30초에 통과했다.
- 최초 `./gradlew build`는 bootJar 생성 및 lint 단계를 거친 뒤 `:test` 중 도구 제한 600초로 종료됐다. 테스트 assertion 실패나 성공으로 기록하지 않는다. 남은 test executor가 없음을 확인하고 1800초 제한의 전체 빌드로 재실행 중이다.
- 최종 Oracle 리뷰 및 전체 빌드 결과, 자원 정리 확인은 완료 후 아래에 누적한다.
#### 전체 실행 실패 분리 및 최종 구현 리뷰
- 1800초 제한의 기본 환경 전체 `./gradlew build`는 18분 20초에 종료 코드 1로 끝났다. XML 336개, 테스트 2892개 중 2890개 통과·2개 실패·오류/skip 0이다.
- 실패 1: `LegacyCurrentNameReadIntegrationTest.shouldLocalizeCreatorDonorsIndependentlyOfTheChannelOwner`의 `totalCount`는 기대 2, 실제 0이었다. 해당 기존 테스트와 후원 서비스는 이번 변경 대상이 아니다. `ChannelDonationService`는 KST 월 범위를 UTC 값으로 비교하지만 fixture는 기본 시간대 `LocalDateTime`으로 생성한다. 월말 KST 늦은 시각에 나타나는 환경 의존성을 확인했다.
- 실패 2: init script 없이 일반 빌드에서 발견된 임시 `RecommendationSocketQaTest` 컴파일 잔존물이었다. QA 전용 system property가 없어 HTTP 실행 전 NPE가 났다. 작업 소유 `RecommendationSocketQaTest*.class`만 삭제하고 `build/classes`·`build/tmp/kapt3`에서 해당 클래스가 남지 않았음을 확인했다. 테스트 제외 옵션이나 skip을 추가하지 않았다.
- 후원 테스트 한 개를 기본 환경에서 분리 실행해 동일한 기대 2/실제 0을 재현했다. 같은 코드·assertion을 `JAVA_TOOL_OPTIONS=-Duser.timezone=UTC ./gradlew test --rerun --tests 'kr.co.vividnext.sodalive.v2.creatorname.application.LegacyCurrentNameReadIntegrationTest.shouldLocalizeCreatorDonorsIndependentlyOfTheChannelOwner'`로 실행하면 42초, 종료 코드 0이다. 범위 밖 테스트나 서비스 코드를 고치지 않았다.
- Ruling: 운영·저장소 시간대 설정은 보존하고 최종 전체 검증 명령에만 UTC JVM 환경을 명시한다. 근거는 위 단일 실패의 환경 분리 결과다. 한계는 기본 KST 환경의 월말 기존 테스트 결함이 해결되는 것은 아니라는 점이며 최종 보고에 남긴다.
- Oracle 작업은 완료 알림 후 결과 조회가 두 번 실패하고 세션도 조회되지 않아 승인 근거로 사용할 수 없었다. 사용 가능한 고성능 reviewer로 한 번 대체한 최종 읽기 전용 리뷰는 세 커밋 전체 제품 3개·테스트 5개를 검토해 명세·품질을 무조건 승인했고 구현 차단 사항은 없었다. 변경된 테스트 261개 및 인접 회귀 94개, 총 355개가 직전 전체 실행에서 통과했음을 XML로 대조했다.
- QA 판정과 기본 환경 전체 실행의 실패 사실은 보존한다. UTC 환경의 전체 재검증은 별도 결과로 아래에 누적하며 기본 환경의 전체 성공으로 바꾸어 기록하지 않는다.
#### 최종 UTC 검증 완료
- 명령: `JAVA_TOOL_OPTIONS=-Duser.timezone=UTC ./gradlew build`. init script·테스트 skip/filter·rerun 옵션 없이 실행했다. 실행 후 환경 변수가 후속 셸에 남지 않았음을 확인했다.
- 결과: `BUILD SUCCESSFUL in 16m 34s`, 종료 코드 0. XML 335개·테스트 2891개 모두 이번 실행에서 갱신됐고 failures/errors/skipped 0이다. 임시 QA 클래스는 실행 대상에 없다.
- `LegacyCurrentNameReadIntegrationTest`도 UTC에서 8개 전부 통과했다. 기본 KST 월말 환경의 기존 실패는 별도 후속 사항으로 남긴다.
- main ktlint 검사 완료·위반 0. test 및 Kotlin script 검사는 변경 없는 입력으로 UP-TO-DATE이며 해당 보고서도 위반 0이다. 앞선 Task3의 명시적 `ktlintCheck` 역시 통과했다.
- bootJar: `build/libs/sodalive-0.0.1-SNAPSHOT.jar`, 148263078 bytes, SHA-256 `627ae298d255d00586b217b00e481f419484426bcfd4728e2de119ff13d44366`. 최초 기본 빌드에서 생성한 동일 제품 산출물이며 마지막 빌드에서는 UP-TO-DATE였다.
- 실제 HTTP 104건·216판정 PASS, 개별 및 최종 구현 리뷰 승인으로 요청 범위의 Gate를 완료한다. LSP 도구 없음 및 운영 MySQL Event 저장 정밀도 미확인은 그대로 명시한다.
- 마지막 빌드의 runner 76003·Gradle client 76012·test executor 76029가 모두 종료됐음을 확인했다. 공유 Gradle/Kotlin daemon은 건드리지 않았다. 임시 디렉터리 정리 영수증은 아래에 이어 기록한다.
- 정리 완료: 작업 소유 `recommendation-ulw.nT00w2`와 그 아래 6개 Task/QA 디렉터리 및 notepad를 삭제하고 부재를 확인했다. 임시 QA 컴파일 클래스도 제거됐다. 마지막 프로세스 조회에서 Gradle wrapper·test executor·Redis 서버는 남지 않았다. 공유 캐시·daemon 및 기존 다른 작업 디렉터리는 변경하지 않았다.
- 원문 임시 로그를 정리하기 전에 실행 명령·집계·실패 원인·재검증 조건은 본 계획에, 실제 HTTP 응답 발췌는 `http-qa.md`에 보존했다. 영구 테스트 XML/HTML과 bootJar는 기존 `build/` 산출물로 유지한다.
### 초기 문서 전용 검증 기록 (구현 승인 전)
- 2026-10-31 문서 전용 검증: `REC-AI-001`은 `P1-T1`, `REC-AI-002`는 `P1-T3` 및 Gate, `REC-EVT-001~007`은 `P1-T2`·`P1-T3` 및 Gate로 추적함을 확인했다. 파일 책임표·검증 명령·기존 API 계약도 대조했다.
- PRD 독립 builder check는 중요한 미결정 사항 및 소스 모순이 없다고 판정했다. 계획은 자체 검토로 RED/GREEN 순서, 기존 테스트 보존, 시각 기준 분리 및 요구사항 추적을 확인했다. 최종 계획에 대한 별도 독립 리뷰나 구현 동작 검증으로 확대 해석하지 않는다.
- TDD 예외 사유: 현재 산출물은 문서 두 개뿐이다. 대체 검증은 정합성 대조와 신규 파일 각각의 공백 검사다.
- 아래 명령에서 공백 오류 출력은 없었다. 최종 재확인한 `--no-index` 종료 코드는 신규 파일 차이를 뜻하는 1이며, 이전의 종료 코드 0 기록은 정정한다. 신규 파일은 일반 `git diff --check`만으로 검사되지 않으므로 `/dev/null`과 직접 비교했고, staging 후 `git diff --cached --check`로 다시 검증한다.
```bash
GIT_MASTER=1 git diff --no-index --check /dev/null docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/prd.md
GIT_MASTER=1 git diff --no-index --check /dev/null docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/plan-task.md
```
- 문서 전용 지침에 따라 Gradle test/build/lint와 실행 QA를 생략했다. 위 Phase 명령은 향후 구현 검증 계획일 뿐 실행 결과가 아니다. 구현 체크박스와 기능 수용 기준은 미완료다.