Files
sodalive-backend-spring-boot/docs/20261027_콘텐츠배너예약기간/plan-task.md
T

300 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 오디오 콘텐츠 배너 예약 기간 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-10-27 |
| 요구사항 기준 | `docs/20261027_콘텐츠배너예약기간/prd.md` |
| API 기준 | 관리자 예약 필드 추가, 기존 공개 API 응답 schema 유지 |
| 현재 Phase | Phase 1: 예약 저장과 노출 판정 |
| 현재 활성 Goal | 없음 (`P1-GATE` 완료) |
## 목표
관리자는 오디오 콘텐츠 배너 예약 기간을 저장·관리하고, 네 공개 오디오 배너 조회는 현재 시각에 노출 가능한 항목만 기존 조건과 순서로 반환한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `2/2` | 없음 (`P1-GATE` 완료) | 없음 |
- 동시에 하나의 미완료 goal만 실행한다. 순서: `P1-T1` → `P1-T2` → `P1-GATE`.
- 완료 체크박스와 검증 기록은 구현 때 실제 결과로 갱신하며, 이 계획 작성 시 검증을 실행한 것으로 간주하지 않는다.
## 범위
### 포함
- `content_banner` 예약 시각 nullable 필드와 MySQL DDL, 관리자 입력·응답, 값 검증 및 부분 수정 semantics.
- 네 공개 조회의 기간 필터 및 focused repository/service 테스트.
- DDL 선행 적용을 포함한 rollout 순서 문서화.
### 제외
- 오디오 이외 banner, scheduler, 공개 response 변경, security·권한 변경, 새 dependency, 전역 시간 설정 변경.
- live DB 실행 또는 기존 레코드 backfill.
## 기술 제약 및 구현 결정
- Kotlin/Spring/JPA/QueryDSL 기존 구조를 유지한다. 신규 architecture/dependency는 추가하지 않는다. 네 조회가 재사용하는 작은 기간 predicate는 허용한다.
- 필드는 `startDate`, `endDate`, 컬럼은 `start_date`, `end_date`, 각각 `TIMESTAMP NULL DEFAULT NULL` 초 정밀도다. 모든 컬럼에 한국어 COMMENT를 기록한다.
- 기존 관리자 이벤트 입력 관례의 KST `yyyy-MM-dd HH:mm` 문자열을 사용한다. UTC 저장 및 KST 동일 포맷 응답을 적용하고, 종료는 해당 입력 분의 `:59`로 정규화한다.
- update는 기존 `UpdateContentBannerRequest` DTO를 유지하면서 raw JSON에서 필드 존재 여부를 확인해 생략은 유지, 명시 null은 clear 처리한다. 역전 범위와 형식 검증은 이미지 upload 전에 수행하고 `common.error.invalid_request`를 사용한다.
- 공개 조회 조건은 NULL 경계의 inclusive 규칙을 따르며 `.limit()`보다 앞선 DB `.where()`에 포함한다. 기존 모든 필터·정렬을 보존한다.
- DDL migration이 애플리케이션 rollout보다 먼저다. DB 작업은 운영 자동화에 연결하지 않고 별도 승인된 절차로 실행한다.
- 구현 테스트는 JUnit 5 TDD. 공개 banner port에는 now 인자가 없으므로, 기존 nowProvider 기본 인자 관례를 참고해 해당 query 경계의 최소 clock seam을 테스트에서 고정한다. 공개 조회의 UTC now는 초 단위로 비교한다. persistence 검증은 `TIMESTAMP(0)` 컬럼에 저장 후 `flush`/`clear`/재조회한다. 초 정밀도는 완화 비교하지 않는다.
- 이 host에는 `lsp_diagnostics`가 없어 Kotlin main/test 컴파일과 ktlint로 정적 검증을 대체한다. LSP 검증으로 보고하지 않는다.
- focused 명령은 아래 Gate에 기록한 여섯 테스트 클래스와 Progress의 실제 combined 명령을 기준으로 실행한다.
### Phase 1: 예약 저장과 노출 판정
**Phase 결과:** 관리자 설정에서 시작/종료 시각을 저장하고 공개 경로가 기간 경계에 맞는 배너만 반환한다.
**선행조건:** 없음. PRD 요구사항 SCH-001~007 확정.
### Task 1.1 관리자·저장·응답
**Goal 실행 `P1-T1`:** 예약 범위를 생성·조회·부분 수정할 수 있고 DB에 정확히 왕복 저장한다.
- **시작 조건:** PRD §7~8 확정, 기존 관리자 이벤트 날짜 입력 convention 확인.
- **완료 증거:** 모든 체크박스 완료, focused test 통과, DDL 파일 작성, DB 왕복 및 업로드 전 검증 결과 기록.
- **범위 밖:** 공개 조회 필터, 공개 응답 변경, 운영 DB 적용.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateContentBannerRequest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/GetAdminContentBannerResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerRepository.kt`
- Create: `docs/20261027_콘텐츠배너예약기간/alter-content-banner-reservation.sql`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerServiceTest.kt`
- Create/Test: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerControllerIntegrationTest.kt`
**Interfaces:**
- Consumes: 기존 `createAudioContentMainBanner(image, requestString)`, `updateAudioContentMainBanner(image, requestString)`, `getAudioContentMainBannerList(tabId)`와 `GetAdminContentBannerResponse` projection.
- Produces: entity `startDate/endDate: LocalDateTime?`; create/update 선택 필드 `startDate/endDate: String?`; update 생략/null 구분; admin response `startDate/endDate: String?`. 포맷 `yyyy-MM-dd HH:mm` KST.
- [x] **RED:** create 값 저장, update 생략 유지 및 explicit null clear, 포맷/역전 오류와 업로드 미호출을 검증하는 focused test를 작성한다.
- [x] **RED 확인:** `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest'`로 미구현 동작의 HTTP/assertion 실패를 확인한다. 기존 mock 서비스 테스트는 회귀 확인으로 유지한다.
- [x] **GREEN:** nullable entity 컬럼, 선택적 create 필드, 최소 raw JSON presence 판독, KST/UTC parsing·formatting, effective 범위 검증, 응답 projection을 구현하고 DDL을 추가한다.
- [x] **GREEN 확인:** 같은 focused test를 통과시킨다. persistence 테스트는 고정 시각으로 저장 후 `flush`/`clear`/재조회해 두 경계가 초 단위로 왕복하는지 검증한다.
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 관리자 test 및 `./gradlew ktlintCheck`를 실행한다. DDL의 nullable default/comment와 PRD 일치 여부를 확인한다.
#### Task 1.1 회귀 수정: 예약 JSON 타입 오류 (`P1-T1-R1`)
- **Objective:** 예약 값이 문자열/null이 아닌 JSON 객체·배열이어도 업로드·저장 전에 `common.error.invalid_request`로 거부한다.
- **시작 조건:** P1-T1 리뷰에서 DTO 역직렬화가 예약 검증보다 먼저 실패해 `common.error.unknown`이 되는 경로 확인.
- **Files:** `AdminContentBannerService.kt`, `AdminContentBannerControllerIntegrationTest.kt` (위 Task 1.1의 전체 경로와 동일).
- **완료 증거:** create/update × start/end × 객체/배열의 실제 HTTP RED/GREEN 및 기존 focused 회귀 통과, scoped 재검토.
- **범위 밖:** 다른 DTO 필드/전역 JSON 오류 처리 변경.
- [x] **RED:** 예약 객체·배열 입력의 HTTP 공통 오류 계약 실패를 확인한다.
- [x] **GREEN:** 해당 예약 필드의 타입 검증만 최소 보강해 같은 테스트를 통과시킨다.
- [x] **REFACTOR:** `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain` 및 ktlint 결과를 누적한다.
### Task 1.2 공개 조회 기간 필터
**Goal 실행 `P1-T2`:** 공개 네 조회가 현재 시각의 예약 가능 여부를 DB에서 limit 전에 판정한다.
- **시작 조건:** `P1-T1` 완료, entity QueryDSL metamodel 재생성 또는 빌드 convention 반영.
- **완료 증거:** 네 경로 각각의 경계·무기한·조건 보존 테스트, focused test 통과.
- **범위 밖:** 관리자 목록 예약 필터, 공개 응답 schema 또는 기존 필터/정렬 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt` (`AudioContentQueryRepositoryImpl.getAudioContentMainBannerList(isAdult)`)
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerVisibility.kt` (네 query의 NULL 허용 기간 조건과 초 정밀도 비교를 공유하는 작은 QueryDSL 함수)
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepositoryTest.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/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryBannerTest.kt` (해당 query method에 대한 신규 통합 테스트)
- Create/Test fixture: `src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerReservationFixtures.kt` (네 JPA 테스트가 같은 고정 UTC 예약 경계 matrix만 공유하며 기존 fixture/flush·clear를 재사용)
**Interfaces:**
- Consumes: `AudioContentBanner.startDate/endDate` UTC 저장 값; 구형 `AudioContentQueryRepositoryImpl.getAudioContentMainBannerList(isAdult)`와 `AudioContentBannerQueryRepositoryImpl.getAudioContentMainBannerList(tabId, isAdult, lang)`에 해당 query 경계의 테스트 가능한 now seam, v2의 `findHomeBanners(limit, memberId, includeAdultBanners, lang)`와 `findBanners(limit, memberId, canViewAdultContent, lang)`도 같은 고정 시각 seam. 공개 API signature는 유지.
- Produces: 기존 공개 method signature와 반환 타입 유지. 예약 기간 필터는 공개 조회 결과에만 적용.
- [x] **RED:** 네 경로에서 양쪽 NULL, 시작만/종료만, inclusive 경계, 기간 밖 배너 및 limit 전에 걸러짐을 테스트한다. 기존 성인/언어/탭/active/target 조건과 정렬 유지 사례를 포함한다.
- [x] **RED 확인:** 네 repository 클래스의 신규 예약 테스트를 combined `./gradlew test --tests '*AudioContentBannerRepositoryTest.shouldExcludeFutureAndExpiredReservations' --tests '*AudioContentRepositoryBannerTest.shouldExcludeFutureAndExpiredReservations' --tests '*DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --tests '*DefaultAudioRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --console=plain`로 실행해 예약 조건 assertion 실패를 확인한다.
- [x] **GREEN:** 각 기존 QueryDSL query의 `.where(...)`에 현재 시각 기준 nullable inclusive 조건을 추가한다. 별도 조회 후 메모리 필터링은 하지 않는다.
- [x] **GREEN 확인:** 각 focused test를 통과시킨다. 시각은 고정된 UTC now를 사용하고 시작·종료 동등 경계를 포함한다.
- [x] **REFACTOR:** 기존 where 조건·정렬·limit 순서와 공개 response 변화를 확인하고 네 focused test 및 `./gradlew ktlintCheck`를 다시 실행한다.
### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 관리자 설정, DB migration 및 공개 조회 전체 계약이 맞는지 판정한다.
- **시작 조건:** `P1-T1`, `P1-T2` 완료.
- **완료 증거:** 아래 focused 테스트 통과, DDL과 rollout 순서 검토, 문서/변경 범위 확인 결과 기록.
- **범위 밖:** 전체 build/test 실행, 운영 DB 변경, 구현 범위 외 수정.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerServiceTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerControllerIntegrationTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerRepositoryTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryBannerTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest'
./gradlew ktlintCheck
```
**Expected:** 모든 명령 exit code 0. 공개 네 경로가 예약으로 선필터링되고 기존 조건/정렬 및 response schema가 보존된다. 관리자 목록에서 예약과 무관하게 기존 isActive/tab 조건으로 행이 조회된다. DDL은 nullable NULL 기본값이며 앱 rollout 전 적용이 필요하고 live DB 자동 실행은 없다.
- [x] **자동 검증:** focused 테스트 재실행, Kotlin 컴파일, ktlint 및 `bootJar` 패키징이 통과한다.
- [x] **계약 대조:** NULL 경계 네 조합, 관리자 예약 무관 조회, 네 공개 query의 limit 전 조건과 기존 필터 보존을 확인한다.
- [x] **리뷰:** P1-T2와 전체 변경 통합 검토에서 미해결 blocking finding이 없다.
- [x] **배포/문서:** DDL 선행 적용과 미검증 운영 환경 경계를 명시하고 PRD·계획·리뷰 상태를 갱신한다.
## 실행 순서
| 순서 | Goal | 선행조건 | 차단 시 행동 |
|---:|---|---|---|
| 1 | `P1-T1` | 없음 | PRD/기존 convention의 불일치만 문서화하고 임의 계약 금지 |
| 2 | `P1-T2` | `P1-T1` | 기존 query 조건을 유지하며 공개 경로 단위로 계속 검증 |
| 3 | `P1-GATE` | Task 1.1, 1.2 전체 | 실패 소유 Task에 회귀 검증 추가 |
```text
P1-T1 → P1-T2 → P1-GATE
```
## 변경 금지 항목
- 예약 기간으로 관리자 목록을 필터링하지 않는다. 기존 `isActive` 및 tab 조건은 그대로 유지한다.
- 공개 response schema, 권한, security, 다른 banner 유형은 변경하지 않는다.
- 실패 테스트를 삭제·skip·완화하지 않는다. 새 dependency·scheduler·전역 시간대 설정을 추가하지 않는다.
- 운영 DB에 DDL을 자동 실행하지 않는다. migration 적용은 애플리케이션 rollout보다 먼저다.
## Progress
### 구현 전 확인 — 2026-10-27
- 변경 전 working tree는 clean이고 현재 branch는 `test`다. 저장소 규칙에 따라 worktree를 자동 생성하지 않고 현재 작업 공간에서 진행한다.
- `./gradlew tasks --all test --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain` — exit 0, 기존 관리자/배너 테스트와 명령 유효성 확인.
- `./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*DefaultAudioRecommendationQueryRepositoryTest' --console=plain` — exit 0, 기존 v2 repository 회귀 baseline 확인.
- `./gradlew ktlintCheck --console=plain` — exit 0, 변경 전 포맷 baseline 확인.
- 사전 대조: P1-T1은 nullable UTC entity와 관리자 필드를 제공하고 P1-T2는 같은 필드를 소비하므로 순차 실행한다. P1-T1의 HTTP 테스트와 DB 왕복, P1-T2의 고정 now/limit 전 필터가 각 요구사항과 일치한다.
- 계획 초안의 `startAt/endAt` 및 `start_at/end_at`은 구현 전에 사용자 명명 `startDate/endDate`, `start_date/end_date`로 보정했다. 제품 동작 변경은 없다.
### P1-T1 실행 증거 — 2026-10-27
**TDD 실행 이력**
| 순서 | 명령/대상 | 실제 결과 및 판정 |
|---|---|---|
| RED 1 | `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --console=plain` | exit 1, 6건 중 6건 실패, errors 0. 정상 multipart 생성과 GET 목록 요청 후 `Admin HTTP response must include startDate ==> expected: <true> but was: <false>` assertion으로 실패. 미구현 entity 참조나 없는 JDBC 컬럼에 의한 실패가 아니다. |
| GREEN 1 | 위와 같은 focused 명령 | exit 0, `BUILD SUCCESSFUL in 1m 59s`, 생성·목록 6건 통과. nullable 저장·KST 응답 projection 구현 후 H2 컬럼을 `TIMESTAMP(0)`으로 맞추고 고정 기대 UTC 값과 실제 재조회 값을 정확히 비교했다. |
| 테스트 작성 중 보정 | `compileTestKotlin` | multipart builder의 `.with(...)` 반환 타입이 `MockHttpServletRequestBuilder`인데 helper에서 더 좁은 타입을 선언해 3곳 컴파일 오류가 발생했다. 테스트 helper의 타입만 바로잡았으며 이 실패는 유효 RED에 포함하지 않는다. Redis/DB 설정 변경이나 production 우회는 없었다. |
| RED 2 | 위와 같은 focused 명령 | exit 1, 60건 중 52건 실패. 수정 null 해제·새 경계 적용의 HTTP 날짜 assertion 실패, 역전/잘못된 수정의 `$.success` expected false/actual true, 잘못된 생성 날짜가 `SodaException`이 아닌 예외로 처리되는 assertion 실패를 확인했다. |
| GREEN 2 | 아래 최종 focused 명령과 동일 | exit 0, `BUILD SUCCESSFUL in 37s`, 통합 60건 + 기존 서비스 1건 + 공개 repository 1건 = 62건 통과. raw `JsonNode.has` 병합과 파싱 오류 변환, 업로드 전 effective 범위 검증을 구현했다. |
| RED 3 | 통합 focused 명령 | exit 1, 64건 중 3건 실패. strict `uuuu` parser도 `+02026-10-27 10:00`을 허용해 잘못된 형식의 create start/end 및 update start에서 `$.success`가 true였다. 계약의 16자리 형식 검증을 추가하기 전에 확인한 별도 RED다. |
| 최종 GREEN | 아래 최종 focused 명령 | exit 0, `BUILD SUCCESSFUL in 37s`, 66건 통과. XML 보고서의 failures/errors/skipped는 모두 0이다. |
```bash
./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain
```
**최종 테스트 집계 및 실제 검증 범위**
| 테스트 클래스 | tests | failures | errors | skipped |
|---|---:|---:|---:|---:|
| `AdminContentBannerControllerIntegrationTest` | 64 | 0 | 0 | 0 |
| `AdminContentBannerServiceTest` | 1 | 0 | 0 | 0 |
| `AudioContentBannerRepositoryTest` | 1 | 0 | 0 | 0 |
| 합계 | 66 | 0 | 0 | 0 |
- 새 `@SpringBootTest`는 실제 controller/service/repository와 MockMvc multipart POST/PUT 및 GET을 사용하고 S3 외부 업로드만 mock한다. 기존 `EmbeddedRedisInitializer`를 해당 클래스에만 opt-in하고, 전용 H2 DB와 매 테스트 배너/탭 정리를 사용한다. 기존 서비스·공개 repository 테스트 파일은 수정하지 않았다.
- 생성 6건은 양쪽 생략/명시 null/시작만/종료만/양쪽/같은 분을 포함한다. 수정 9건은 생략 보존, 각 경계·양쪽 null 해제, 단일 경계 변경, 같은 분 및 다른 경계 해제를 반영한 병합을 검증한다. 미래·만료·무기한 예약은 관리자 목록에서 계속 보이고 기존 active/tab 조건은 보존된다.
- 형식/달력 오류는 11개 입력 × start/end × create/update = 44건이다. 생성 역전 1건 및 기존 값과 병합한 수정 역전 3건도 검증한다. 실패마다 `SodaException.messageKey == common.error.invalid_request`, S3 미호출, 생성 행 0개 또는 수정 전후 전체 DB row 완전 일치를 확인했다.
- persistence는 HTTP 서비스 트랜잭션 완료 후 별도 트랜잭션에서 `flush`/`clear`/재조회했다. H2 `start_date/end_date`는 명시적 `TIMESTAMP(0)`이다. `2026-10-27 00:00` KST의 전날 UTC 변환, `23:59` 입력의 종료 `:59`, 나노초 0, 목록의 KST 문자열/실제 JSON null을 허용오차나 절삭 비교 없이 확인했다.
- production 변경은 계획된 entity/관리자 DTO·service·repository 6파일에 한정했다. QueryDSL 보조 생성자에서 UTC를 KST 문자열로 변환해 기존 data class와 응답 필드를 유지하며 global serializer/timezone/dependency/security와 공개 query/response는 변경하지 않았다.
**DDL 및 검증 경계**
- `alter-content-banner-reservation.sql`은 MySQL `start_date/end_date TIMESTAMP NULL DEFAULT NULL`과 각 컬럼의 한국어 COMMENT만 추가한다. 기존 행은 NULL 유지, backfill 없음이며 파일 검토만 했다. 운영 적용은 별도 승인 절차로 DDL을 먼저 적용한 뒤 앱을 rollout해야 한다.
- 실제 MySQL DDL 실행, 운영 JDBC/session timezone과 MySQL TIMESTAMP 허용 범위에 대한 왕복 검증은 수행하지 않았다. H2 초 정밀도 및 Java KST/UTC 변환 검증을 운영 MySQL 검증으로 간주하지 않는다.
- `P1-T1`만 완료했다. `P1-T2` 공개 예약 필터와 `P1-GATE`는 미실행·미완료이며 해당 체크박스를 변경하지 않았다.
### P1-T1-R1 실행 증거 — 2026-10-27
- RED: 기존 잘못된 날짜의 parameterized HTTP 테스트에 객체 `{}`/배열 `[]` × `startDate/endDate` × create/update 8건만 추가했다. `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --console=plain` — exit 1, `BUILD FAILED in 27s`, 72건 중 신규 8건만 assertion 실패, 기존 64건 통과, errors/skipped 0. HTTP 실패 응답의 resolved exception이 `SodaException`이어야 한다는 assertion에서 실패했으며 실제 원인은 DTO 매핑의 `MismatchedInputException`이었다. 컴파일/설정 오류는 없었다.
- GREEN: create/update 모두 JSON 트리를 한 번 읽고 DTO 매핑 전에 두 예약 필드만 검사한다. 생략·JSON null·문자열을 허용하고 그 외 JSON 타입을 `common.error.invalid_request`로 거부한다. 같은 트리를 `treeToValue`와 update의 `has` 판독에 재사용했다. 다른 DTO 필드나 전역 handler는 변경하지 않았고 mapping 예외를 포괄 catch하지 않는다.
- 회귀 명령: `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain` — exit 0, `BUILD SUCCESSFUL in 38s`. 실제 `:test`가 실행됐으며 통합 72건 + 기존 서비스 1건 + 공개 repository 1건 = 74건 통과했다. `build/test-results/test/TEST-*.xml`에서 failures/errors/skipped 모두 0임을 확인했다.
- 신규 8건은 실제 MockMvc multipart 요청으로 `SodaException.messageKey == common.error.invalid_request`, S3 미호출, 생성 행 0개 또는 수정 전후 전체 DB row 일치를 검증한다. 기존 64건으로 생략 보존·명시 null 해제·날짜 정밀도/시간대·관리자 목록 회귀도 재검증했다. S3 이외 mock은 추가하지 않았다.
- scoped 자체 재검토: production 변경은 `AdminContentBannerService.kt`의 예약 JSON 타입 경계뿐이다. 나머지 변경은 기존 통합 테스트의 공통 입력 source 확장과 이 R1 체크/증거이며 P1-T2·Gate 및 기존 완료 기록은 변경하지 않았다. 전체 test/build와 운영 MySQL은 이 회귀 목표의 검증 범위에서 제외했고 Kotlin main/test 컴파일을 focused 실행으로 확인했다.
- 정적 검사 및 문서 명령 확인: `./gradlew ktlintCheck tasks --all --console=plain` — exit 0, `BUILD SUCCESSFUL in 15s`. main/test ktlint 오류는 없었으며 기존 Gradle deprecation 안내만 남았다. LSP 도구는 제공되지 않아 Kotlin 컴파일/ktlint로 대체했다. R1의 RED/GREEN/REFACTOR만 완료 처리했다.
- parent 독립 검증: `./gradlew test --rerun --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain` — exit 0, `BUILD SUCCESSFUL in 36s`, 실제 test 재실행. XML의 72 + 1 + 1 = 74건, failures/errors/skipped 모두 0을 직접 확인했다.
- scoped 리뷰 및 재검토: 최초 예약 JSON 객체/배열 오류 경계 P2 1건을 R1로 보완했다. 재검토에서 finding ADDRESSED, 명세 준수/코드 품질 모두 APPROVE이며 신규 actionable finding은 없었다. P1-T1/R1 완료 후 P1-T2로 이동한다.
### P1-T2 실행 증거 — 2026-10-27
- 실행 순서: 네 조회의 미구현 필터 RED 확인 → 공유 predicate/기본 UTC nowProvider 및 고정 시각 matrix → 공개 4클래스/관리자 2클래스 회귀와 ktlint. 관리자 파일 및 기존 완료 증거는 변경하지 않는다.
- 최초 RED 명령: `./gradlew test --tests '*AudioContentBannerRepositoryTest.shouldExcludeFutureAndExpiredReservations' --tests '*AudioContentRepositoryBannerTest.shouldExcludeFutureAndExpiredReservations' --tests '*DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --tests '*DefaultAudioRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --console=plain`.
- 최초 RED 결과: exit 1, `BUILD FAILED in 23s`, 4건 실행/4건 assertion 실패, errors/skipped 0. 기존 생성자를 사용하고 실제 UTC 현재 시각의 ±1년으로 저장 후 flush/clear했다. 두 구형 조회는 expected `[visible]`, actual `[future, expired, visible]`; 홈 v2는 expected `[visible-1, visible-2]`, actual `[future, expired]`; 오디오 v2는 같은 이름의 CDN URL expected/actual로 실패했다. 컴파일/없는 seam/설정 오류가 아닌 실제 H2 조회 결과의 실패다.
- 중복 축소 결정: 구현 전 Files에 test-only `AudioContentBannerReservationFixtures.kt`를 추가했다. 고정 UTC now·nullable 시작/종료·기대 노출 여부만 공유하며 production test 분기나 공통 테스트 프레임워크는 추가하지 않는다.
- 종료 초 RED 명령: `./gradlew test --tests '*AudioContentBannerRepositoryTest.should*Reservation*' --tests '*AudioContentRepositoryBannerTest.should*Reservation*' --tests '*DefaultHomeRecommendationQueryRepositoryTest.should*Reservation*' --tests '*DefaultAudioRecommendationQueryRepositoryTest.should*Reservation*' --console=plain`.
- 종료 초 RED 결과: 최초 nullable inclusive predicate와 선택적 UTC seam 추가 후, 초 절삭을 추가하기 전에 실행했다. exit 1, `BUILD FAILED in 1m 22s`, 68건 중 8건 실패, errors/skipped 0. 네 경로 모두 `end-second-123456789`/`end-second-999999999`에서 expected `[candidate]`(오디오 v2는 CDN URL), actual `[]`였다. 나머지 60건은 통과해 최초 필터 RED의 4건도 GREEN을 확인했다. `TIMESTAMP(0)` 저장/flush/clear/재조회 값은 정확히 일치했으며 실패 원인은 미정규화 now 비교였다. 이후 공통 helper 내부에서 now를 한 번만 `truncatedTo(ChronoUnit.SECONDS)` 처리한다.
- GREEN 구현: `AudioContentBannerVisibility.kt`의 `QAudioContentBanner.isVisibleAt(now)`가 `(startDate IS NULL OR startDate <= currentSecond) AND (endDate IS NULL OR endDate >= currentSecond)`를 반환한다. Files의 네 query 구현에서 마지막 optional 생성자 인자 `nowProvider = { LocalDateTime.now(ZoneOffset.UTC) }`를 추가하고 해당 조회의 `.where(...)`에서 helper와 provider를 각각 한 번만 호출한다. 기존 positional 생성자 호출과 Spring 주입은 컴파일/통합 테스트로 확인했다. 기존 join/필터/order/limit/projection 및 port/method/response signature는 그대로다.
- 최종 고정 시각 검증: 공유 16-case matrix × 4경로 = 64건이다. 양쪽 NULL, 시작/종료 단독의 전·동등·후, 양쪽 범위 안·이전·이후·동등, `23:59:59.123456789`/`.999999999` 종료 초 포함, 다음 초 시작 전 제외, UTC 자정 시작 동등·전날 만료를 검증한다. H2 두 컬럼을 각 matrix에서 `TIMESTAMP(0)`로 맞춘 뒤 persist/flush/clear/재조회하여 원래 기대 시각과 정확히 비교한다. 최초 RED의 4건도 같은 기대 결과를 유지한 고정 UTC fixture로 전환했다.
- 기존 조건 회귀: v2의 미래/만료 두 선행 orders 행을 제외하고 `limit=2`를 정상 두 행이 채우는지 확인했다. 메인/구형에 탭·성인·활성·언어 적용 여부와 orders 정렬 보존 테스트 2건을 보강했다. 기존 홈 v2의 활성 대상·양방향 차단·언어·탭·성인·랜덤 tie-breaker/limit 테스트는 유지했고, 오디오 v2의 기존 배너 테스트에 비활성 creator 및 역방향 차단 대상도 추가했다. 실행 SQL에서도 네 경로의 괄호 묶인 nullable AND 조건과 v2의 order/limit 이전 where 적용을 확인했다.
- 첫 통합 GREEN: 아래 최종 명령에서 `ktlintCheck tasks --all`만 제외한 동일 6클래스 focused 명령 — exit 0, `BUILD SUCCESSFUL in 38s`, 237건 통과. 별도 `./gradlew ktlintCheck --console=plain`는 새 테스트의 긴 `saveBanner` 한 줄에서만 실패했다. `apply_patch`로 해당 호출의 인자 줄바꿈만 수정하고 전역 formatter는 실행하지 않았다.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerRepositoryTest' --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryBannerTest' --tests 'kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerServiceTest' --tests 'kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerControllerIntegrationTest' ktlintCheck tasks --all --console=plain
```
- 최종 재검증: 위 명령 exit 0, `BUILD SUCCESSFUL in 37s`, 실제 `:test` 재실행 및 main/test/script ktlint 통과, Gradle task 목록 유효성 확인. `build/test-results/test/TEST-*.xml`를 직접 확인한 집계는 아래와 같으며 failures/errors/skipped는 모두 0이다.
| 테스트 클래스 | tests | failures | errors | skipped |
|---|---:|---:|---:|---:|
| `AudioContentBannerRepositoryTest` | 19 | 0 | 0 | 0 |
| `AudioContentRepositoryBannerTest` | 18 | 0 | 0 | 0 |
| `DefaultHomeRecommendationQueryRepositoryTest` | 98 | 0 | 0 | 0 |
| `DefaultAudioRecommendationQueryRepositoryTest` | 29 | 0 | 0 | 0 |
| `AdminContentBannerControllerIntegrationTest` | 72 | 0 | 0 | 0 |
| `AdminContentBannerServiceTest` | 1 | 0 | 0 | 0 |
| 합계 | 237 | 0 | 0 | 0 |
- 범위/검증 경계: production은 helper 1파일과 공개 query 4파일만 변경했다. 테스트는 기존 3클래스, 신규 구형 query 1클래스와 matrix 1파일이며 관리자·DTO·설정·dependency·DDL 파일은 수정하지 않았다. 공개 조회는 실제 JPA/QueryDSL/H2로 호출했고 관리자 73건은 기존 MockMvc/service 회귀 그대로다. LSP 대신 Kotlin main/test 컴파일과 ktlint로 확인했다. 초기 재컴파일에서 기존 다른 테스트의 unnecessary `!!`/unchecked-cast 경고와 기존 Gradle deprecation 안내가 있었고 관련 없는 코드는 변경하지 않았다.
- 사용자 지정 범위대로 전체 `test`/`build`, 운영 MySQL/DDL 실행, 별도 live HTTP 공개 API 검증은 수행하지 않았다. 공개 persistence 동작은 H2 실행 결과이며 운영 JDBC/session timezone 왕복 검증을 대체하지 않는다. P1-T2만 완료하고 활성/다음 Goal은 `P1-GATE`로 이동한다. 최종 Gate의 독립 판정과 rollout 확인은 parent에게 남기며 Phase/Gate를 완료 처리하지 않는다.
## 최종 검증 기록
- 문서 작성 검토: 필수 project guide 및 sample PRD/plan과 대상 코드·테스트 경로를 대조했다.
- 구현 검증은 수행하지 않았다. 이 산출물은 요구사항/실행 계획이며 product/test 파일은 변경하지 않았다.
### P1-T1 구현 후 추가 검증 — 2026-10-27
- 최종 `./gradlew ktlintCheck tasks --all --console=plain` — exit 0, `BUILD SUCCESSFUL in 16s`. main/test/Kotlin script ktlint와 문서 변경 후 Gradle 명령 목록을 확인했다. 앞선 `./gradlew ktlintCheck --console=plain`도 exit 0, `BUILD SUCCESSFUL in 15s`였다.
- 기존 문서 작성 검토와 구현 미실행 항목은 계획 작성 시점의 이력이다. 이후 P1-T1 구현 및 실제 RED/GREEN 결과는 Progress의 실행 증거와 최종 66건 XML 집계에 기록했다. 보고서는 `build/reports/tests/test/index.html`, 원본 집계는 `build/test-results/test/TEST-*.xml`에서 확인했다.
- LSP 도구는 host에서 제공되지 않는다. focused 명령이 실행한 `compileKotlin`/`compileTestKotlin`과 ktlint로 정적 검증을 대체하며 LSP 검증으로 보고하지 않는다.
- 전체 `test`/`build`는 실행하지 않았다. 관리자 예약 저장 경계에 한정된 P1-T1이며 공통 설정·보안·직렬화를 변경하지 않아 계획/프로젝트 가이드에 따라 focused 3클래스로 회귀 범위를 제한했다. 최초 전체 테스트 소스 컴파일 시 기존 다른 테스트의 non-null/unchecked-cast 경고와 Gradle deprecation 경고가 있었으며 관련 없는 파일은 수정하지 않았다.
### P1-GATE parent 독립 실행 — 2026-10-27
```bash
./gradlew test --rerun --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --tests '*AudioContentRepositoryBannerTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*DefaultAudioRecommendationQueryRepositoryTest' ktlintCheck bootJar tasks --all --console=plain
```
- 실제 `:test`를 강제 재실행해 exit 0, `BUILD SUCCESSFUL in 46s`를 확인했다. `bootJar`는 새로 패키징했고 ktlint는 변경 코드 기준 up-to-date 통과했다. Gradle 명령 유효성도 확인했다.
- XML 직접 집계는 관리자 통합 72 + 관리자 서비스 1 + 메인 배너 repository 19 + 구형 배너 repository 18 + 홈 v2 repository 98 + 콘텐츠 추천 v2 repository 29 = 237건이다. 모든 클래스의 failures/errors/skipped는 0이다.
- `git diff --check`는 exit 0이다. 최초 clean 상태 이후 변경은 계획한 관리자·공개 query·테스트 및 작업 문서에 한정되며 기존 사용자 변경을 되돌리거나 commit/push하지 않았다.
- 관리자 surface는 실제 Spring controller/service/repository의 MockMvc multipart POST/PUT와 GET을 재실행했고, 공개 query surface는 실제 QueryDSL/JPA/H2 driver 테스트로 재실행했다. 별도 실서버 curl/운영 HTTP 검증은 수행하지 않았다.
- 전체 `test`/`check`/`build`는 실행하지 않았다. 공통 보안/설정/직렬화 변경이 없고 직접 영향받는 관리자 및 네 공개 query의 focused 회귀로 범위를 한정했다. 실행한 `bootJar`는 전체 회귀 테스트를 의미하지 않는다.
- 운영 MySQL DDL 실행, JDBC/session timezone 및 MySQL TIMESTAMP 허용 범위의 실환경 왕복은 미검증이다. 운영 DB에는 연결·변경하지 않았으며 DDL은 앱 rollout 전에 승인된 배포 절차로 적용해야 한다.
- Oracle의 P1-T2/전체 변경 통합 리뷰를 요청한 상태다. 결과를 수집하고 판정하기 전에는 Gate/Phase를 완료 처리하지 않는다.
### P1-GATE 최종 판정 — 2026-10-27
- 위 parent fresh 실행의 237건/실패 0, ktlint 및 bootJar 성공 결과와 변경 파일·DDL을 다시 대조했다.
- Oracle의 P1-T2/전체 변경 통합 검토 결과는 명세 준수 APPROVE, 코드 품질 APPROVE이며 미해결 actionable finding이 없다. 네 공개 조회의 DB 선필터링, 기본 UTC nowProvider, nullable inclusive 경계, 관리자 예약 무관 조회, QueryProjection 및 기존 응답/필터/정렬의 호환성을 확인했다.
- 상세 리뷰는 `reviews/banner-reservation-final-review.md`에 기록했다. 앞선 관리자 입력 타입 문제는 R1과 `reviews/admin-reservation-review.md`로 수정·검증 완료 상태를 보존했다.
- PRD 수용 기준과 P1-T1/R1/P1-T2/Gate가 모두 충족되어 Phase 1을 완료 처리한다. 개발 범위의 blocked 항목은 없다. 운영 DDL 적용과 실환경 MySQL/timezone 검증은 코드 완료와 구분한 배포 전 조치이며 실행하지 않았다.
- 사용자의 후속 지시로 이후 git 명령은 실행하지 않는다. 그 전에도 commit/staging/branch 변경은 하지 않았고, 후속 지시 이후에는 Gradle 명령·문서 내용만 확인했다.