Files

36 KiB
Raw Permalink Blame History

오디오 콘텐츠 배너 예약 기간 구현 계획

문서 항목 내용
상태 구현 완료
작성일 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.

  • 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/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와 반환 타입 유지. 예약 기간 필터는 공개 조회 결과에만 적용.

  • 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/재조회했다. 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는 실행하지 않았다.
./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 명령·문서 내용만 확인했다.