36 KiB
오디오 콘텐츠 배너 예약 기간 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 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는 기존
UpdateContentBannerRequestDTO를 유지하면서 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)와GetAdminContentBannerResponseprojection. -
Produces: entity
startDate/endDate: LocalDateTime?; create/update 선택 필드startDate/endDate: String?; update 생략/null 구분; admin responsestartDate/endDate: String?. 포맷yyyy-MM-dd HH:mmKST. -
RED: create 값 저장, update 생략 유지 및 explicit null clear, 포맷/역전 오류와 업로드 미호출을 검증하는 focused test를 작성한다.
-
RED 확인:
./gradlew test --tests '*AdminContentBannerControllerIntegrationTest'로 미구현 동작의 HTTP/assertion 실패를 확인한다. 기존 mock 서비스 테스트는 회귀 확인으로 유지한다. -
GREEN: nullable entity 컬럼, 선택적 create 필드, 최소 raw JSON presence 판독, KST/UTC parsing·formatting, effective 범위 검증, 응답 projection을 구현하고 DDL을 추가한다.
-
GREEN 확인: 같은 focused test를 통과시킨다. persistence 테스트는 고정 시각으로 저장 후
flush/clear/재조회해 두 경계가 초 단위로 왕복하는지 검증한다. -
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 오류 처리 변경.
- RED: 예약 객체·배열 입력의 HTTP 공통 오류 계약 실패를 확인한다.
- GREEN: 해당 예약 필드의 타입 검증만 최소 보강해 같은 테스트를 통과시킨다.
- 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/endDateUTC 저장 값; 구형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와 반환 타입 유지. 예약 기간 필터는 공개 조회 결과에만 적용.
-
RED: 네 경로에서 양쪽 NULL, 시작만/종료만, inclusive 경계, 기간 밖 배너 및 limit 전에 걸러짐을 테스트한다. 기존 성인/언어/탭/active/target 조건과 정렬 유지 사례를 포함한다.
-
RED 확인: 네 repository 클래스의 신규 예약 테스트를 combined
./gradlew test --tests '*AudioContentBannerRepositoryTest.shouldExcludeFutureAndExpiredReservations' --tests '*AudioContentRepositoryBannerTest.shouldExcludeFutureAndExpiredReservations' --tests '*DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --tests '*DefaultAudioRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --console=plain로 실행해 예약 조건 assertion 실패를 확인한다. -
GREEN: 각 기존 QueryDSL query의
.where(...)에 현재 시각 기준 nullable inclusive 조건을 추가한다. 별도 조회 후 메모리 필터링은 하지 않는다. -
GREEN 확인: 각 focused test를 통과시킨다. 시각은 고정된 UTC now를 사용하고 시작·종료 동등 경계를 포함한다.
-
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 변경, 구현 범위 외 수정.
./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 자동 실행은 없다.
- 자동 검증: focused 테스트 재실행, Kotlin 컴파일, ktlint 및
bootJar패키징이 통과한다. - 계약 대조: NULL 경계 네 조합, 관리자 예약 무관 조회, 네 공개 query의 limit 전 조건과 기존 필터 보존을 확인한다.
- 리뷰: P1-T2와 전체 변경 통합 검토에서 미해결 blocking finding이 없다.
- 배포/문서: DDL 선행 적용과 미검증 운영 환경 경계를 명시하고 PRD·계획·리뷰 상태를 갱신한다.
실행 순서
| 순서 | Goal | 선행조건 | 차단 시 행동 |
|---|---|---|---|
| 1 | P1-T1 |
없음 | PRD/기존 convention의 불일치만 문서화하고 임의 계약 금지 |
| 2 | P1-T2 |
P1-T1 |
기존 query 조건을 유지하며 공개 경로 단위로 계속 검증 |
| 3 | P1-GATE |
Task 1.1, 1.2 전체 | 실패 소유 Task에 회귀 검증 추가 |
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이다. |
./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/재조회했다. H2start_date/end_date는 명시적TIMESTAMP(0)이다.2026-10-27 00:00KST의 전날 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은 MySQLstart_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는 실행하지 않았다.
./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
./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 명령·문서 내용만 확인했다.