Files
sodalive-backend-spring-boot/docs/20261029_가로형서브배너/plan-task.md
T

648 lines
79 KiB
Markdown

# 가로형 서브 배너 백엔드 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | P7 언어 기능·P8 정적 이관 SQL 검증 및 리뷰 완료, 미배포 |
| 작성일 | 2026-10-29 |
| 요구사항 기준 | [prd.md](prd.md) |
| API 기준 | [api-contract.md](api-contract.md), 실제 구현·HTTP 검증 계약 |
| 현재 Phase | Phase 2~8 완료 |
| 현재 활성 Goal | 없음 |
| 다음 Goal | 없음 |
## 목표
두 placement의 서브 배너를 독립 테이블과 백엔드 API로 제공한다. 기존 문서 준비 Goal `DOC-T1`~`DOC-T5`는 완료된 역사적 문서 작업이다. 이후 사용자가 문서대로 백엔드 코드 및 DDL 생성을 승인했으며, 운영 DB 적용·배포는 범위 밖이다.
## 범위
### 포함
- `prd.md`의 요구사항 요약과 결정 상태 기록
- `api-contract.md`의 실제 API 계약
- 독립 `v2.subbanner` 기능 모듈·DDL·관리자 CRUD·소프트 삭제 및 기존 두 추천 GET 응답 합성
- 현재 구현 작업은 신규 subbanner 코드·테스트·DDL 파일 생성과 실제 계약 문서 동기화를 포함한다. 이전 문서 전용 작업은 별도 완료 이력으로 보존한다.
### 제외
- **현재 제외:** 운영 DB DDL 적용·배포. 코드 변경이 포함된 이번 구현에서는 테스트·lint·빌드를 실행했다. 문서 전용 변경에 대한 Gradle 생략 규칙은 유지한다.
- **향후 구현 범위 밖:** 앱/관리자 실제 UI, 기존 대형 배너 변경, scheduler/job, 순서 편집, 활성화 switch, analytics
- 배포, 운영 DB 직접 변경, 기존 콘텐츠 배너 schema migration
- 운영 배포가 완료됐다는 주장
- 신규 dependency, global exception rewrite, 공통 utility wrapper
- 앱/client network 통합은 backend 구현 후 별도 계약 검토와 제공이 끝날 때까지 차단한다. backend를 알 수 없는 외부 block으로 취급하지 않는다.
### 승인된 언어 후속 범위
- [2026-11-02 승인 후속] 재사용 `Lang`의 KO/EN/JA로 서브 배너를 저장한다. 관리자 생성 누락은 KO, 수정 누락은 유지, explicit null/non-text/invalid는 upload/write 전 HTTP 400이다. Public 조회는 member 국가로 JP→JA, 그 외→KO를 선택하며 관리자 목록은 언어 미필터다.
- public `SubBannerResponse`는 기존 필드를 유지하고, 운영 DB/DDL 실행·UI·event link parsing·event detail/adult 변경·새 dependency는 금지한다.
- 신규 설치용 `create-sub-banner.sql`과 `20261102_add_sub_banner_lang.sql`을 함께 갱신한다. KO backfill은 소재의 JA 여부와 연결 이벤트 언어를 자동 추정하지 않으며, 운영 담당자의 수동 분류·확인을 적용 전에 요구한다.
## 문서 변환 Goal
### `DOC-T5` 백엔드 계획 문서 변환
**Goal 실행 `DOC-T5`:** 사용자 승인 범위에서 PRD와 계획 문서만 backend 요구사항 및 향후 구현 계획으로 변환한다.
- **시작 조건:** `DOC-T1`~`DOC-T4`의 기존 문서 결과가 있고 사용자가 backend 문서 보완과 문서 검증 지침 수정을 승인했다.
- **완료 증거:** 지정된 기존 문서 세 파일만 수정, 신규 backend Goal은 전부 미체크, 과거 DOC 체크박스/Gates/Progress와 이전 Gradle 증거 보존.
- **범위 밖:** API 구현 승인, 코드·SQL 신규 파일 작성, Gradle/test/build 실행, 앱·관리자 prompt 변경.
- **TDD 예외 사유:** 문서 전용 변경이다.
- **대체 검증 방법:** PRD 요구 ID↔Goal 추적, 정확한 path/signature 일치, 과거 완료 기록과 미실행 검증 상태를 수동 대조한다.
- [x] PRD와 backend Phase/Goal 초안 작성 완료.
- [x] 부모 작업자가 문서 대조·공백 검사 및 범위를 확인했다. 아래 `DOC-T5` 최종 검증 기록을 따른다.
## 백엔드 구현 공통 제약
- 기술 기준은 저장소의 Kotlin·Java 17·Spring Boot 2.7.14·JPA/QueryDSL·JUnit 5와 Gradle Wrapper를 그대로 사용한다. 버전·dependency 변경은 계획에 포함하지 않는다.
- 서브 배너 도메인·관리자는 `v2/subbanner`에 두고 기존 `v2/api/home` 및 `v2/api/content/recommendation`의 facade/DTO에서 응답을 합성한다. 대응 테스트 경로도 같은 책임을 따른다.
- 데이터 계층은 한 개의 독립 기능 모듈로 유지한다. `SubBannerController`는 P6에서 제거했고, `AdminSubBannerController`와 `SubBannerService`·persistence·6필드 public DTO는 재사용한다. 단일 클래스 파일명은 ktlint 규칙을 따른다.
- 현재 앱 조회는 `GET /api/v2/home/recommendations`, `GET /api/v2/audio/recommendations`의 `data.subBanners`다. 관리자 경로는 `/admin/sub-banners`를 유지한다. 이전 Phase의 standalone GET 기록은 당시 구현 이력이며 현재 계약은 [api-contract.md](api-contract.md)를 따른다.
- 파일/링크/날짜 입력을 경계에서 검사한다. 오류는 기존 `ApiResponse`/`SodaExceptionHandler` 사실을 확인하고 global handler는 고치지 않는다.
- DDL은 `docs/20261029_가로형서브배너/create-sub-banner.sql`이며 P5에서 is_active 기본 true를 추가했다. 테스트용 Flyway/자동 live migration을 만들지 않는다.
- 기존 S3Uploader, 이미지 검증기 및 QueryDSL/JPA를 재사용한다. GIF는 허용하며 S3 object 자동 삭제/정리는 제공하지 않는다. 새 dependency는 추가하지 않았다.
- 시간은 KST 분 입력→UTC 초 단위 DB 값→KST 분 응답이다. `TIMESTAMP` DDL precision을 확인하고 service에서 한번 얻은 `now`를 초 단위로 정규화한다.
- 각 단계의 원래 명령은 계획이며 실제 실행 결과는 해당 Progress를 따른다. 문서 전용 변경은 Gradle을 실행하지 않고, 코드 변경의 Gate는 테스트·lint·빌드 증거로 판정한다.
## Historical Phase 1: 문서 작성 완료 기록
아래 `DOC-T1`~`DOC-T4`, Gate와 Progress는 기존 문서 작업 이력이다. 새 backend Phase의 완료 상태나 구현 착수 근거가 아니다.
**Phase 결과:** 요구사항, 제안 API와 두 독립 구현 프롬프트가 서로 일치하는 문서 묶음.
### Task 1.1 요구사항과 준비 계획
**Goal 실행 `DOC-T1`:** 샘플에 맞춰 PRD와 문서 작성 계획을 작성한다.
- **시작 조건:** 사용자 확정 요구, 프로젝트 문서 가이드 및 샘플 확인.
- **완료 증거:** 정확한 파일 두 개 작성, `USER REQUIREMENTS`와 `AUTHOR PROPOSALS` 구분.
- **범위 밖:** 코드 구현, 추가 계획 문서.
- **TDD 예외 사유:** 문서 전용 변경으로 실행 동작이 없다.
- **대체 검증 방법:** 요구사항 표와 문서 가이드 대조.
**Files:** `docs/20261029_가로형서브배너/prd.md`, `docs/20261029_가로형서브배너/plan-task.md`
- [x] 요구사항, 비목표, 외부 의존, 결정 기록 작성.
- [x] 구현 계획을 문서 준비 범위로 한정.
### Task 1.2 계약 및 독립 프롬프트
**Goal 실행 `DOC-T2`:** API 제안과 독립 실행 가능한 앱·관리자 프롬프트를 작성한다.
- **시작 조건:** `DOC-T1` 문서 완료.
- **완료 증거:** 요청된 세 파일 작성, API 필드/라우트/오류 상태가 PRD와 일치, 각 prompt에 필요한 계약을 자체 포함.
- **범위 밖:** 서버 구현 지시, 실 API 연결, 특정 앱/admin 코드베이스 존재 가정.
- **TDD 예외 사유:** 문서 전용 변경으로 실행 동작이 없다.
- **대체 검증 방법:** PRD/API/prompt 사이 필드, 인증, 주소 처리, 차단 조건 교차 점검.
**Files:** `docs/20261029_가로형서브배너/api-contract.md`, `docs/20261029_가로형서브배너/app-prompt.md`, `docs/20261029_가로형서브배너/admin-prompt.md`
- [x] endpoint와 전송 형식을 하나의 최소 제안으로 고정한다.
- [x] 두 prompt를 별도 사용 가능하게 만들고 백엔드 구현 지시를 제외한다.
- [x] 콘솔에 prompt 본문을 출력하지 않는다.
### Task 1.3 출처 표기 및 독립 사용성 보정
**Goal 실행 `DOC-T3`:** 초안 검토에서 발견한 확정 요구·제안 혼용을 고치고 프롬프트의 독립 사용성을 검증한다.
- **시작 조건:** `DOC-T2` 작성 완료와 부모 검토.
- **완료 증거:** 제안 상태, 응답 예시, 주소 조합표, URL 검증 및 순서 설명을 부모가 직접 대조.
- **범위 밖:** 새로운 제품 결정, 서버 구현 및 실제 앱·관리자 연결.
- **Files:** 같은 디렉터리의 `prd.md`, `api-contract.md`, `app-prompt.md`, `admin-prompt.md`.
- **TDD 예외 사유:** 실행 코드가 없는 문서 보정.
- **대체 검증 방법:** 아래 Progress의 직접 대조·독립 검토·공백 검사.
- [x] 사용자 확정 범위와 작성자 제안을 분리하고 두 프롬프트의 계약·검증 사례를 보완했다.
### DOC-T4 후속: 관리자 노출 기간
**Goal 실행 `DOC-T4`:** 사용자 확인을 받은 콘텐츠 배너 동등 노출 기간 설정을 기존 다섯 문서에 일관되게 반영한다.
- **시작 조건:** 사용자가 관리자 페이지에서 콘텐츠 배너와 동일하게 노출 기간 설정이 필요하다고 확인했다.
- **범위:** 기존 문서만 수정한다. 구현 계획의 TDD 예외는 문서 전용 변경으로 실행 동작이 없기 때문이다.
- **Files:** `docs/20261029_가로형서브배너/prd.md`, `docs/20261029_가로형서브배너/plan-task.md`, `docs/20261029_가로형서브배너/api-contract.md`, `docs/20261029_가로형서브배너/app-prompt.md`, `docs/20261029_가로형서브배너/admin-prompt.md`.
- **완료 기준:** 시작·종료 경계 의미, KST 입력/응답 형식, create/update 생략·null 규칙, 기간 역전 검증, 공개 응답 DTO 불변, 관리자 기간 무관 목록, 앱 기간 필터와 OS 필터가 모든 문서에서 일치한다. 관리자 프롬프트에 KST 입력, 경계 개별 해제, 오류/편집 보존 및 기간 외 기록 편집 기준을 포함한다.
- **TDD 예외 사유:** 문서 전용 변경으로 실행 동작이 없다.
- **대체 검증 방법:** 다섯 문서의 용어·JSON 예시·요청/응답 규칙 상호 대조와 공백 검사.
- **부모 검증:** 아래 후속 `DOC-T4-GATE`와 부모 작업자의 `./gradlew tasks --all` 검증은 별도로 수행한다. 과거 `DOC-GATE` 완료 기록은 유지한다.
- [x] 다섯 기존 문서에 후속 요구와 독립 프롬프트 세부사항을 반영한다.
- [x] 부모가 문서 내용을 검토하고 후속 검증을 수행했다.
#### 후속 Gate `DOC-T4-GATE`
- **시작 조건:** `DOC-T4` 문서 보완 완료.
- **완료 증거:** 기존 콘텐츠 배너 규칙과 다섯 문서 대조, 공백 검사, Gradle 문서 검증 명령 결과 기록.
- **범위 밖:** 실제 API·관리자 화면 구현 및 실연동 완료 판정.
- [x] 부모가 노출 기간 문서의 후속 검증을 완료했다.
### Phase 1 Gate `DOC-GATE`
- **시작 조건:** `DOC-T1`, `DOC-T2`, `DOC-T3` 작성 완료.
- **완료 증거:** 요청된 파일만 관리했으며 문서 내용과 경로를 대조.
- **범위 밖:** 제품 기능 구현 완료, API 제공·배포 완료 판정.
- [x] 부모 작업자가 변경 파일과 요구사항 추적을 검증했다.
`DOC-GATE`의 기존 완료 기록은 `DOC-T3` 당시 검증 기록이다. `DOC-T4` 후속 반영은 별도 `DOC-T4-GATE`와 아래 부모 검증 기록으로 판정하며 과거 완료 기록으로 대체하지 않는다.
검증 명령: 부모 작업자가 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`, 종료 코드 0을 확인했다. 상세 증거는 아래에 누적한다.
## Historical DOC-T1~DOC-T4 변경 금지
아래 지침은 당시 문서-only 작업 범위의 기록이며 새 backend 계획에는 적용하지 않는다.
- 기존 다섯 파일 외의 파일을 변경하지 않는다.
- backend, DB/schema/migration, architecture, 배포 구현 지시를 작성하지 않는다.
- 제안 경로를 실제 배포 경로라고 표현하지 않는다.
- 요청된 다섯 파일 이외의 파일을 만들지 않는다.
## Progress
### `DOC-T1` 1차 실행, 2026-10-29
- 상태: 완료
- 무엇을: PRD와 문서 준비 계획 작성.
- 왜: 앱/관리자 구현 요청 전에 사용자 요구와 문서 범위를 분명히 하기 위해.
- 어떻게: 프로젝트 작업절차·문서유지보수 가이드 및 샘플과 기존 API 근거를 읽고 요구사항을 분리해 기록.
- 남은 항목: `DOC-T2`, 부모 최종 검증.
- 다음 행동: 계약과 프롬프트 작성.
### `DOC-T2` 1차 실행, 2026-10-29
- 상태: 작성 완료, 부모 검증 대기
- 무엇을: API 제안, 독립 앱 프롬프트, 독립 관리자 프롬프트 작성.
- 왜: 별도 담당자가 현재 코드베이스나 prompt 원문 없이도 요구와 제안 계약을 이해하도록 하기 위해.
- 어떻게: PRD의 요구사항 및 차단 경계를 계약 모델·endpoint·수용 기준에 대조.
- 남은 항목: 부모 작업자의 문서 검토와 `./gradlew tasks --all`.
- 다음 행동: 부모가 Gate 검증을 기록한다.
### `DOC-T3` 후속 보정 및 `DOC-GATE`, 2026-10-29
- 상태: 문서 보정·최종 검증 완료. 제품 기능과 신규 API는 미구현이다.
- 무엇을: 사용자 확정 요구와 작성자 제안의 출처를 정정하고, 두 독립 프롬프트의 JSON 예시·8가지 주소 조합·오류 처리·multipart·null 삭제 규칙을 대조했다.
- 왜: 실제로 제공되지 않은 API나 승인되지 않은 링크 우선순위를 확정 계약처럼 전달하지 않기 위해.
- 어떻게: 부모가 다섯 파일 전체와 기존 추천/관리자 Controller·배너 DTO·공통 응답 근거를 직접 읽었다. 앱의 위험 URI scheme 표기를 `javascript`로 정정하고 대소문자 무관 검사를 명시했다.
- 독립 검토: 첫 검토 에이전트는 모델 호환성 오류로 실행되지 않았다. 대체 검토는 완료됐으며 순서 충돌 후보 1건을 보고했다. 부모 대조 결과 서버의 id 오름차순과 클라이언트의 응답 순서 보존은 양립하므로 충돌은 아니다. 오해를 줄이도록 두 프롬프트에 서버 순서와 클라이언트 재정렬 금지를 함께 적었다. 그 외 중대한 누락은 보고되지 않았다.
- 명령 검증: `./gradlew tasks --all` 재실행은 `BUILD SUCCESSFUL in 999ms`, 종료 코드 0이었다. 기존 Gradle deprecation 안내가 있었다.
- 공백 검증: 다섯 파일 각각 `GIT_MASTER=1 /usr/bin/git diff --no-index --check -- /dev/null 파일경로` 실행, 출력 없음·종료 코드 0. 최초 셸 검사에서 zsh의 예약 변수 `path`로 명령 검색이 실패해 `doc_path`와 절대 git 경로로 고친 뒤 재실행했다. 파일 내용 문제는 아니었다.
- 변경 범위: `GIT_MASTER=1 /usr/bin/git -c core.quotePath=false status --short --untracked-files=all`에서 해당 디렉터리의 새 Markdown 다섯 개만 확인했다. 기존 파일 변경, 커밋, 배포는 없다.
- 생략·한계: 문서만 변경하여 전체 테스트·빌드 및 실제 앱/관리자 화면 QA는 실행하지 않았다. Markdown LSP 진단 도구가 제공되지 않아 직접 내용 대조와 공백 검사로 대체했다. 신규 API 실연동은 계약 합의·제공 후 별도 검증이 필요하다.
### `DOC-T4` 후속 반영, 2026-10-29
- 상태: 문서 수정 완료, 부모 검증 대기. 기존 `DOC-GATE`의 과거 완료 상태는 변경하지 않으며 이번 검증은 `DOC-T4-GATE`로 구분한다.
- 무엇을: 콘텐츠 배너와 같은 기간 설정 요구를 기존 다섯 문서에 반영한다.
- 왜: 관리자가 가로형 서브 배너에도 예약 노출 경계를 설정하고 목록에서 기간 외 레코드까지 관리할 수 있도록 계약 제안을 명확히 하기 위해.
- 어떻게: 기존 콘텐츠 배너 예약 PRD와 구현을 근거로 KST 분 정밀도, 종료 분 inclusive 경계, 생성/수정 null 의미를 문서화한다. 공개 `SubBanner`는 유지하고 기간 필터는 앱 조회 보장으로만 정의한다.
- 남은 항목: 부모 작업자의 문서 검토, `./gradlew tasks --all` 및 최종 문서 검증.
- 변경 범위: 지정된 기존 다섯 Markdown만 수정. 코드·신규 파일·API 구현은 없다.
- 생략·한계: 이 후속 문서 작업에서는 Gradle, 런타임 및 배포 검증을 수행하지 않았다. 신규 API는 제안 상태이며 구현·승인은 별도다.
### `DOC-T4-GATE` 부모 검증, 2026-10-29
- 상태: 문서 후속 검증 완료. 신규 API와 실제 관리자·앱 기능의 구현 완료를 뜻하지 않는다.
- 무엇을·왜: 다섯 문서를 직접 읽고 기존 콘텐츠 배너의 PRD·Service·응답 formatter·노출 조건과 대조하여 날짜 계약의 차이를 방지했다.
- 대조 결과: KST 분 입력/응답과 초 단위 포함 경계를 구분했고, null/null·한쪽 경계·동일 분·역전/잘못된 입력·수정 생략 유지와 null 해제 규칙이 일치한다. 관리자 목록은 기간 무관, 공개 목록은 기간 내 항목만 반환하며 공개 DTO와 기존 OS 링크 필터는 유지한다.
- 보정: 관리자 응답의 기존 필드 타입을 보존하고 요청·응답 JSON 예시의 기간을 맞췄다. 기간만 수정할 때 기존 주소와 이미지를 보존하도록 명시했으며, 과거 Gate와 후속 Gate를 분리했다.
- 명령: `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 1s`, 종료 코드 0. 다섯 파일 각각 `GIT_MASTER=1 /usr/bin/git diff --no-index --check -- /dev/null 파일경로`도 출력 없음·종료 코드 0이었다.
- 범위: `GIT_MASTER=1 /usr/bin/git -c core.quotePath=false status --short --untracked-files=all`에서 기존 서브 배너 Markdown 다섯 개만 확인했다. 새 문서·코드 변경·커밋·배포는 없다.
- 생략·한계: 문서 변경이므로 전체 테스트·빌드 및 실제 관리자/앱 UI·API 연동은 검증하지 않았다. Markdown LSP 도구 미제공으로 직접 계약 대조와 공백 검사로 대체했다. 신규 API는 계약 제안 상태를 유지한다.
## Phase 2: 데이터 모델 및 기간 규칙
**Phase 결과:** 새로운 독립 테이블과 도메인/persistence 계층에서 배너를 저장하고 placement, 주소, 기간 규칙을 보장한다.
**선행조건:** 부모가 backend 요구사항과 제안 API를 검토하고 구현 착수를 별도 승인. 과거 Phase 1/DOC Gate 완료는 backend 구현 승인이 아니다.
### Task 2.1 독립 데이터 모델, 기간 처리와 DDL
**Goal 실행 `P2-T1`:** `SubBanner` 저장 모델과 독립 SQL artifact를 정의하고 DB 왕복으로 초 정밀도 및 기간 규칙을 증명한다.
- **요구사항:** `SB-001`, `SB-002`, `SB-006`, `SB-010`.
- **완료 증거:** 도메인 및 repository test, DDL의 테이블/컬럼 comment와 nullable UTC second-precision period, JPA flush/clear/reload 결과 기록.
- **시작 조건:** backend 착수 승인. 첫 구현 checkpoint에서 GIF 허용 여부와 S3 object 삭제/교체 실패 시 정책을 검토하고 사용자 확인이 필요한 선택은 구현 전에 결정 기록에 추가한다. 권고안은 기존 Tika `image/*` 검증을 재사용하고 GIF 허용, 기존 DB 변경 없음, S3 자동 cleanup 미보장이다.
- **범위 밖:** API/controller, multipart upload, 운영 DB 적용, 기존 content_banner migration.
- **TDD 예외 사유:** 없음.
**Files (모두 PROPOSED):**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerPlacement.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt`
- Create: `docs/20261029_가로형서브배너/create-sub-banner.sql`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt`
**Interfaces:**
- Produces: `enum class SubBannerPlacement { HOME_RECOMMENDATION, CONTENT_RECOMMENDATION }`; `data class SubBanner(val id: Long? = null, val placement: SubBannerPlacement, val imagePath: String, val webUrl: String?, val androidDeepLink: String?, val iosDeepLink: String?, val startDate: LocalDateTime?, val endDate: LocalDateTime?)`; `interface SubBannerRepository { fun findById(id: Long): SubBanner?; fun findAllByPlacement(placement: SubBannerPlacement): List<SubBanner>; fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List<SubBanner>; fun save(subBanner: SubBanner): SubBanner; fun deleteById(id: Long) }`. `imagePath`는 S3 object key이며 DTO에서만 기존 `cloud.aws.cloud-front.host`와 조합해 `imageUrl`을 만든다.
- Consumes: 기존 `BaseEntity`, QueryDSL/JPA와 프로젝트 DDL convention. 만든 repository의 `findVisibleByPlacement`에서 null 경계 독립 unbounded, 양끝 포함, id asc 정렬.
제안 테이블은 `sub_banner`이며 아래 매핑을 `SubBannerEntity` 및 향후 SQL에 함께 적용한다. 이는 검토용 저장 설계이지 기존 테이블이나 확정된 운영 스키마가 아니다.
| 컬럼 | 제안 MySQL 타입·규칙 | 매핑 |
|---|---|---|
| `id` | `BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY` | `BaseEntity.id` |
| `placement` | `VARCHAR(32) NOT NULL` | `SubBannerPlacement`, enum 이름 저장 |
| `image_path` | `LONGTEXT NOT NULL` | 서버가 생성한 S3 object key, 절대 URL 아님 |
| `web_url`, `android_deep_link`, `ios_deep_link` | 각각 `LONGTEXT NULL` | 기존 URI를 255자 등으로 잘라 저장하지 않음 |
| `start_date`, `end_date` | 각각 `TIMESTAMP NULL DEFAULT NULL` | UTC 초 단위, H2 검증 컬럼은 `TIMESTAMP(0)` |
| `is_active` | `BOOLEAN NOT NULL DEFAULT TRUE` | `SubBannerEntity.isActive`; P5 후속 소프트 삭제 시 false |
| `created_at` | `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` | `BaseEntity.createdAt` |
| `updated_at` | `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | `BaseEntity.updatedAt` |
모든 컬럼·테이블에 MySQL COMMENT를 붙이고 `(placement, id)` 인덱스를 둔다. `BaseEntity`와 전역 시간 설정은 변경하지 않는다. DDL은 신규 table CREATE 한 개이며 rollback 제안은 데이터 보존 확인 및 별도 승인 이후 해당 신규 table만 DROP, 자동 rollback 실행은 금지다. 삭제는 승인된 구현에서 해당 DB row만 제거하는 제안이며 S3 object는 자동 삭제하지 않는다.
- [x] **RED:** 링크 불변식·기간·목록·수정·삭제의 실패 테스트를 먼저 작성했다.
- [x] **RED 확인:** 위 두 focused 클래스에서 26개 중 19개 assertion failure를 확인했다. 컴파일 실패는 RED 증거로 세지 않았다.
- [x] **GREEN:** domain, Entity, QueryDSL repository와 신규 테이블 DDL을 구현했다.
- [x] **GREEN 확인:** 같은 focused 테스트 26개 통과. 실제 H2 `TIMESTAMP(0)`의 flush/clear/reload, 긴 URI, 기간·placement·25개 초과 목록·수정·삭제를 검증했다.
- [x] **REFACTOR:** 직접 영향 배너·creatorname 회귀를 포함한 50개 테스트와 `ktlintCheck` 통과. 부모의 별도 focused 재실행도 26개 통과했다.
### Phase 2 Gate
**Goal 실행 `P2-GATE`:** 독립 schema와 시간/persistence 경계를 backend feature로 확정한다.
- **시작 조건:** `P2-T1` 완료.
- **완료 증거:** focused 테스트 통과, SQL review에서 신규 table 외 변경 없음, 결정 checkpoint 기록.
- **범위 밖:** 운영 DB 적용, app/admin 구현.
- **검증:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.*'` 및 `./gradlew ktlintCheck`, 둘 다 **구현 시 실행, 이번 작업 미실행**. 전체 test/build는 공통 보안/설정 영향 또는 final Gate 등 project guide 조건에서만 실행한다.
- **Expected:** DB reload 뒤 날짜 정확도와 모든 허용 링크 조합 확인, ktlint 성공, live DDL 실행 0회.
- [x] `P2-T1` 완료 증거 확인. 독립 P2 리뷰는 명세·품질 승인, 차단 사항 없음.
- [x] DDL과 Entity의 테이블·컬럼·정밀도·index·COMMENT 일치 및 기존 데이터 비변경을 검토했다. 운영 적용·rollback은 별도 승인 대상이다.
| Goal | 선행조건 | 종속 산출물 |
|---|---|---|
| `P2-T1` | backend 착수 승인 | domain/repository 인터페이스, 신규 SQL artifact |
| `P2-GATE` | `P2-T1` | Phase 3 착수 조건 |
## Phase 3: 관리자 API 및 multipart 이미지
**Phase 결과:** ADMIN만 multipart 기반 CRUD를 수행하고, 유효성 검사 실패가 image upload/DB 변경 없이 끝난다.
**선행조건:** `P2-GATE` 완료 및 관리자 API contract 검토.
### Task 3.1 관리자 CRUD
**Goal 실행 `P3-T1`:** 관리자 CRUD와 이미지 업로드를 구현하고 요청 검증, 병합 기간, 응답 envelope를 통합 테스트로 확인한다.
- **요구사항:** `SB-003`~`SB-007`, `SB-011`, `SB-012`.
- **완료 증거:** create/list/update/delete 통합 테스트, DB 저장된 URI exact preservation, S3 object key의 기존 CDN URL 변환, 기간 KST exact roundtrip, 검증 실패 전 upload/save 없음.
- **시작 조건:** `P2-GATE`, ADMIN route/API draft review. 이미지 정책은 `P2-T1` decision checkpoint에서 확정.
- **범위 밖:** 공개 조회, ordering, cleanup scheduler, 공통 exception rewrite, 무단 400/404 보장.
**Files (Create는 신규 제안 경로, Reference는 기존 파일):**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt`
- Reference: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`의 관리자 경로는 기존 `anyRequest().authenticated()` 유지, 별도 permitAll 추가나 이 Task의 설정 수정은 없다.
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt`
- 서비스 동작은 위 실제 HTTP→service→JPA 통합 테스트에서 검증한다. 같은 시나리오만 반복하는 별도 service unit test는 추가하지 않는다.
**Interfaces:**
- Consumes: `SubBannerRepository`, `SubBannerPlacement`, 기존 `S3Uploader.upload(inputStream: InputStream, bucket: String, filePath: String, metadata: ObjectMetadata? = null): String`의 반환 object key, 해당 key를 public `imageUrl`로 만드는 기존 CDN 변환 방식, `validateImage(file: MultipartFile, gifAllowed: Boolean)`.
- Produces: `SubBannerService.findAllByPlacement(placement: SubBannerPlacement): List<SubBanner>`, `create(image: MultipartFile, request: String): SubBanner`, `update(id: Long, image: MultipartFile?, request: String): SubBanner`, `delete(id: Long): Unit`. 서비스는 raw JSON의 필드 존재 여부·타입을 검사한 뒤 DTO로 변환하여 생략된 날짜와 명시적 null을 잃지 않는다. 기본 4필드의 누락도 검증한다. Controller at `/admin/sub-banners`: `GET list(placement): ApiResponse<List<AdminSubBannerResponse>>`; `POST create(image: MultipartFile, request: String): ApiResponse<AdminSubBannerResponse>`; `PUT /{id} update(id: Long, image: MultipartFile?, request: String): ApiResponse<AdminSubBannerResponse>`; `DELETE /{id} delete(id: Long): ApiResponse<Any>`. DELETE는 서비스의 Unit을 data로 전달하지 않고 `data:null`인 성공 응답을 만든다. Controller class ADMIN preauthorization. `AdminSubBannerResponse` has `id, placement, imageUrl, webUrl, androidDeepLink, iosDeepLink, startDate, endDate`; create/update request wire fields follow `api-contract.md`. 공개 서비스 메서드는 `P4-T1`에서 추가한다.
- [x] **RED:** 실제 HTTP/JWT/H2/S3 대체 경계에서 CRUD·8개 주소 조합·JSON/URI/날짜 검증·생략/null·이미지 실패·기간 무관 목록 테스트를 먼저 작성했다.
- [x] **RED:** 익명 및 모든 비관리자 역할 거부, 없는 id의 404·무변경 테스트도 작성했다.
- [x] **RED 확인:** 신규 HTTP 테스트 39개에서 assertion failure 39개를 확인했다. 이후 DST gap 회귀에서도 43개 중 2개가 기대 400/실제 200으로 실패했다.
- [x] **GREEN:** 관리자 controller·service·DTO를 구현했다. 검증 후에만 업로드/저장하며 트랜잭션·이미지 보존·row 삭제·실제 오류 계약을 적용했다.
- [x] **GREEN 확인:** 최초 39/39, DST 회귀 수정 후 실제 HTTP 43/43 통과. POST/PUT 시작·종료 gap 거부·upload 0·DB 불변과 정상 과거/overlap 왕복을 확인했다.
- [x] **REFACTOR:** 수정 전 직접 영향 회귀 161/161 및 수정 후 focused 43/43·`ktlintCheck` 통과. 파일명은 클래스명에 맞춰 단수형으로 정리했다.
### Phase 3 Gate
**Goal 실행 `P3-GATE`:** CRUD 및 업로드의 검증 순서와 계약 응답을 판정한다.
- **시작 조건:** `P3-T1` 완료.
- **완료 증거:** multipart integration test, 저장 후 DB 확인, validation-failure 무변경 증거, 공통 응답 envelope 대조.
- **범위 밖:** public GET, browser UI, S3 object cleanup 보장.
- **검증:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.AdminSubBannerControllerIntegrationTest'`와 `./gradlew ktlintCheck`. 실제 security 공통 영향을 수정했을 때는 `./gradlew test`로 검증을 확대한다. 실행 증거는 아래 Progress에 기록한다.
- **Expected:** 검증 실패는 upload/DB mutation 0회, 성공 POST/PUT 객체 및 DELETE null 응답, ADMIN만 변경.
- [x] `P3-T1` 완료 증거 확인. 독립 P3 리뷰의 DST blocker를 RED→GREEN으로 수정하고 scoped 재검토 승인.
- [x] 실제 400/404 Boot 오류 body, 익명 401, 비관리자·multipart 바인딩 오류의 기존 200 `success:false`를 HTTP로 확인했다. 소비자 문서의 최종 계약 동기화는 P4 최종 Gate에서 수행한다.
| Goal | 선행조건 | 종속 산출물 |
|---|---|---|
| `P3-T1` | `P2-GATE`와 API contract review | 관리자 CRUD endpoint 및 request/response DTO |
| `P3-GATE` | `P3-T1` | Phase 4 착수 조건 |
## Phase 4: 공개 기간 조회 및 보안 Gate
**Phase 결과:** 정확한 공개 GET만 익명 허용하고 현재 기간의 전체 목록을 id asc로 반환한다. 관리자/인접 method 보호를 end-to-end 확인한다.
**선행조건:** `P3-GATE` 완료.
### Task 4.1 공개 GET, 기간 filtering과 권한 통합 검증
**Goal 실행 `P4-T1`:** 익명 공개 조회와 접근 정책을 구현하고 운영 경로에 실제 공개 범위 외 노출이 없음을 검증한다.
- **요구사항:** `SB-007`~`SB-009`, `SB-011`.
- **완료 증거:** public/admin DTO 차이, placement/date/ordering, 정확한 GET 공개와 보호된 변형 경로 및 role test.
- **시작 조건:** `P3-GATE` 완료, proposed endpoint contract 확인.
- **범위 밖:** app OS link filtering, UI, pagination, active toggle, scheduler.
**Files (신규 경로 PROPOSED):**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/SubBannerController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt` (공개 조회 및 고정 시각 seam 추가)
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`, 정확한 `GET /api/v2/sub-banners`만 permitAll.
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/SubBannerControllerIntegrationTest.kt`
- 보안 시나리오는 같은 실제 HTTP/JWT/H2 context의 `SubBannerControllerIntegrationTest`에 통합했다. 중복 HTTP fixture와 별도 security test 클래스는 만들지 않았다.
**Interfaces:**
- Consumes: `P2-T1`의 `SubBannerRepository.findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List<SubBanner>`와 `P3-T1`의 CDN URL 조립 규칙.
- Produces: `SubBannerService.findVisibleByPlacement(placement: SubBannerPlacement): List<SubBanner>`와 `SubBannerController.getSubBanners(placement: SubBannerPlacement): ApiResponse<List<SubBannerResponse>>`. 서비스는 기존 `nowProvider: () -> LocalDateTime` 주입 패턴으로 UTC 시각을 한 번 얻어 초 단위 절삭 후 같은 값을 repository에 전달한다. public DTO는 정확히 `id, placement, imageUrl, webUrl, androidDeepLink, iosDeepLink`, 날짜 미포함. `SecurityConfig`에서 해당 GET만 익명 허용.
- [x] **RED:** 고정 시각·실제 DB·HTTP로 23개 공개 조회/권한 테스트를 작성했다. 두 placement, 전체 25개 목록, 빈 결과, nullable·초·소수초·자정 경계, 6필드 DTO 및 OS 비필터를 포함한다.
- [x] **RED 확인:** 공개 경로 미구현 상태에서 23개 중 22개 assertion failure(기대 200/400, 실제 401)를 확인했다.
- [x] **GREEN:** public controller/DTO·서비스 조회 및 정확한 GET matcher 한 줄을 추가했다. 기존 권한·추천 응답은 유지했다.
- [x] **GREEN 확인:** 실제 HTTP 23/23 통과. GET 외 method·HEAD·인접 경로·관리자 보호와 요청 시각 1회 조회를 검증했다.
- [x] **REFACTOR:** 기존 홈/오디오 추천 회귀 포함 96/96 및 ktlint 통과. 부모의 최종 전체 suite에서도 신규 92개를 포함한 2,676개가 통과했다.
### Phase 4 Gate
**Goal 실행 `P4-GATE`:** 신규 public/admin endpoint의 데이터·security·API 계약을 최종 검증한다.
- **시작 조건:** `P4-T1` 완료.
- **완료 증거:** Phase 2~4 focused tests 통과, 신규 SQL review, security behavior와 실제 공통 오류 응답 기록.
- **범위 밖:** API 배포/운영 DB 적용, 앱·관리자 client 연동 완료 주장.
- **검증:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.*'` 및 관련 추천 회귀로 확인한 뒤 보안 설정 변경과 사용자 ulw 요구에 따라 부모가 `./gradlew test ktlintCheck bootJar --continue --console=plain`을 실행했다. 결과는 아래 최종 검증 기록을 따른다.
- **직접 영향 회귀:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest'`는 **구현 시 실행, 이번 작업 미실행**이다. 기존 추천 응답이 바뀌지 않았는지 확인한다.
- **Expected:** focused test 성공, backend artifact bootJar 성공, exact public GET만 anonymous, 관리자 CRUD ADMIN-only, 전체 8 조합 및 기간/DB 정밀도 규칙 통과. 운영 DDL 실행과 app integration 완료 판정 0건.
- [x] `P4-T1` 및 모든 선행 Gate 증거 확인. 전체 테스트·lint·bootJar와 독립 최종 코드 리뷰 통과.
- [x] API 계약·앱/관리자 프롬프트를 실제 구현에 맞추고 미배포·운영 DB 미적용 상태를 구분했다.
| Goal | 선행조건 | 종속 산출물 |
|---|---|---|
| `P4-T1` | `P3-GATE` | public GET, 기간 query, exact security matcher |
| `P4-GATE` | `P2-GATE`, `P3-GATE`, `P4-T1` | backend 구현 검증, 별도 client integration 재개 조건 |
### 백엔드 실행 의존성
```text
P2-T1 → P2-GATE → P3-T1 → P3-GATE → P4-T1 → P4-GATE
```
backend checkbox는 실제 검증 후 갱신한다. 문서 작성/부모 검토 완료만으로 구현 완료를 표시하지 않는다. 사용자 구현 승인에 따라 순차적으로 한 개의 Goal만 active한다.
## Current Progress
### `DOC-T5` 문서 변환 초안, 2026-10-29
- 상태: 작성 초안 완료, 작성자 대조 및 부모 검증 대기.
- 무엇을: 기존 `prd.md`와 `plan-task.md`에 backend requirements와 `P2`~`P4` 향후 implementation plan을 추가했다. DOC-T1~4 역사적 checkbox/Gate/Progress는 유지했다.
- 왜: 앱·관리자 실제 구현 전에 서버 기능과 persistence를 codebase 근거에 맞게 검토할 수 있도록 하기 위해.
- 어떻게: API draft, project guides 및 기존 security, S3, image validation, reservation, exception, JPA test patterns를 대조했다. API 전면 승인·배포·구현 상태로 표현하지 않았다.
- 검증: PRD ID와 Goal, exact paths/signatures, proposed API shapes, 향후 DDL, historical checkboxes 및 미착수 backend checkboxes를 직접 대조했다. 이번 작업에서 Gradle/test/build를 실행하지 않았다.
- 남은 항목: 작성자 문서 대조 및 부모 검토. backend 구현은 별도 승인 대기.
- 다음 행동: 부모가 이 문서 Gate를 검증하고 반영한다.
### `DOC-T5` 부모 최종 문서 검증, 2026-10-29
- 상태: 백엔드 PRD·계획 문서 보완 및 문서 검증 지침 수정 완료. 백엔드 구현은 미착수이며 `P2-T1`과 모든 후속 구현 Task/Gate는 미완료다.
- 무엇을·왜: 프롬프트 작성 계획을 실제 백엔드 개발에 사용할 수 있는 요구사항·파일·인터페이스·TDD·Gate 계획으로 보완하고, 문서만 바꿀 때 불필요한 Gradle 실행을 제거했다.
- 문서 대조: `SB-001`~`SB-012`를 `P2-T1`·`P3-T1`·`P4-T1`과 연결했다. 사용자 확정과 작성자 제안을 구분하고, 8개 주소 존재 조합 중 모두 없는 경우만 거부·나머지 7개 허용으로 PRD/테스트 이름/계약을 맞췄다. 기간 형식·생략/null·노출 경계·관리자/공개 DTO는 기존 API 초안과 일치한다.
- 파일·책임 대조: 신규 파일은 모두 제안 경로로 표시했다. 저장 object key인 `imagePath`와 응답 `imageUrl`을 구분하고 각 Phase의 서비스 제공 메서드, 변경 파일, 신규 테이블 매핑을 일치시켰다. 기존 구현과 회귀 테스트 경로를 직접 확인했다.
- 이력 대조: `DOC-T1`~`DOC-T4`의 완료 체크박스와 과거 Gradle 실행 결과는 삭제하거나 새 작업 증거로 재사용하지 않았다. 새 백엔드 Task/Gate의 체크박스는 모두 미체크다.
- 공백 검사: `GIT_MASTER=1 /usr/bin/git diff --check -- docs/agent-guides/문서유지보수.md`와 PRD/계획 각각의 `git diff --no-index --check -- /dev/null 파일경로`는 출력 없음·종료 코드 0이었다.
- 범위 대조: 현재 승인 범위에서 수정한 파일은 이 PRD·계획 및 `docs/agent-guides/문서유지보수.md` 세 개다. 이전 작업에서 존재한 앱/관리자 prompt와 API 초안은 변경 대상에 넣지 않았다. 새 코드·SQL·빌드 설정·배포 변경은 없다.
- 미실행·사유: 사용자 승인과 수정한 문서 전용 검증 지침에 따라 이번 작업에서는 `./gradlew tasks --all`·테스트·빌드를 실행하지 않았다. 위 구현 단계의 명령들은 향후 검증 계획이며 이번 실행 결과가 아니다. 실제 API 동작과 배포는 검증하지 않았다.
- 다음 행동: PRD/API 제안을 검토하고 별도로 구현 착수를 승인한 뒤 `P2-T1`부터 재개한다. 이미지/GIF 및 S3 객체 보존 정책의 검토 checkpoint를 건너뛰지 않는다.
### 백엔드 구현 착수, 2026-10-29
- 승인: 사용자가 "문서대로 백엔드 코드, DDL 생성 진행"을 요청했다. 기존 설계의 독립 리소스·API·권한·기간 계약을 구현 기준으로 사용한다. 운영 DB 및 AWS에 검증용 요청을 보내지 않는다.
- 실행 방식: 단일 구현 담당자에게 단계별 작업을 위임하고 부모가 파일·실행 증거를 검증한다. 기존 `test` 브랜치에서 진행하며 새 worktree를 자동 생성하거나 이전 문서 변경을 되돌리지 않는다.
- 결정: GIF는 기존 이미지 검증기의 허용 옵션으로 지원하며, 삭제는 DB row에 한정하고 S3 object는 자동 삭제하지 않는다. 입력 오류/없는 리소스의 400/404는 신규 경계에서 처리하고 기존 공통 예외·권한 거부 정책은 변경하지 않는다.
- 검증 시나리오: 관리자 실제 HTTP CRUD와 7개 유효 주소 조합, 모두 빈 주소·잘못된 형식의 무변경 거부, KST 생략/null 및 DB 왕복, 기간/placement 공개 조회와 20개 초과 무절단 목록, 익명/비관리자 권한 거부 및 기존 추천 회귀를 확인한다.
- 도구 한계: 현재 host는 `lsp_diagnostics`를 제공하지 않는다. Kotlin 컴파일과 ktlint로 정적 오류를 검사하되 LSP 검증 완료로 표현하지 않는다.
- 자원: 실행 임시 root는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/subbanner-ulw.kY4Cw5`다. 생성한 test/QA 자원만 종료·정리하며 기존 공유 Gradle 캐시는 삭제하지 않는다.
### `P2-T1` 및 `P2-GATE`, 2026-10-29
- 완료: `SubBanner`·placement·저장소 port·JPA Entity·QueryDSL adapter와 `create-sub-banner.sql`, domain/persistence 테스트를 생성했다.
- RED/GREEN: 두 신규 클래스 26 tests 중 의도한 assertion failure 19개 확인 후 26/26 통과. 기존 배너 baseline 19/19, 직접 영향 배너·creatorname 포함 Gate 50/50 및 전체 `ktlintCheck` 통과.
- 부모 검증: 신규 두 클래스 focused 명령을 다시 실행해 `BUILD SUCCESSFUL in 16s`, XML domain 2 + persistence 24, failures/errors/skipped 모두 0을 확인했다. 전체 변경 소스·테스트·DDL을 읽었고 독립 리뷰도 P2 승인이다.
- 결정: 기간 저장 경계는 repository에서 한 번 초 절삭한 값으로 저장/반환해 DB 반올림을 방지한다. 현재 UTC 시각도 repository query 경계에서 초 절삭한다. 서비스의 UTC 분 입력 정규화와 함께 사용한다.
- 한계: 로컬 MySQL 실행 파일과 실행 중 Docker daemon이 없어 운영 MySQL 적용을 검증하지 않았다. H2 MySQL mode와 DDL/Entity 대조로 확인했으며 운영 DB에는 적용하지 않았다. 전체 suite/HTTP는 후속 Gate에서 수행한다.
- 별도 DDL 직접 검증: task-owned Java/Hibernate driver가 작성한 SQL을 독립 H2에 적용한 뒤 실제 repository CRUD·초 정밀도·기간·placement·잘못된 주소 조합을 검증했다. `MANUAL QA PASS`, `BUILD SUCCESSFUL in 6s`. 초기 driver naming 설정 및 Java fluent setter 오류를 driver에서만 수정했으며 제품 코드는 변경하지 않았다.
### `P3-T1` 및 `P3-GATE`, 2026-10-29
- 완료: 관리자 CRUD·service·request/admin response DTO·실제 random-port JWT HTTP 테스트를 구현했다. 이미지 스트림은 use로 닫고 검증 전에 업로드/저장하지 않는다. 기본 4필드 필수 교체와 날짜 필드의 생략 유지/null 해제를 구분했다.
- 증거: baseline 기존 HTTP/service 5/5; 신규 scaffold RED 39 assertion failures → GREEN 39/39. 기존 배너/creatorname 포함 직접 영향 회귀 161/161, full ktlint 통과. 별도 service unit test 대신 실제 HTTP→service→JPA 경로의 비중복 검증을 유지했다.
- 리뷰 수정: `1988-05-08 02:30`의 Asia/Seoul DST gap 자동 보정을 JDK17과 실제 HTTP로 재현했다. gap 회귀 2 failures → 유효 offset 부재를 거부하도록 수정 → 43/43 및 ktlint 통과. 독립 scoped 재검토는 ADDRESSED/APPROVE다.
- 실제 응답: DELETE HTTP200 `{"success":true,"message":null,"data":null,"errorProperty":null}`. JSON/URI/기간 검증 400과 없는 id 404는 Spring Boot 오류 body. 익명 401, 모든 비관리자와 필수 multipart part 누락은 기존 정책의 HTTP200 `success:false`다.
- 저장 한계: MySQL TIMESTAMP가 표현 가능한 UTC `1970-01-01 00:00:01`~`2038-01-19 03:14:07` 밖 입력은 업로드 전에 400으로 거부한다. 이는 임의의 과거 날짜 제한이 아니다. S3 업로드 후 DB 실패 시 새 object 잔존 가능성은 보존하며 자동 정리를 추가하지 않았다.
- 부모 확인: 실제 소스·테스트를 읽고 최종 XML 43 tests/failures0/errors0/skipped0을 확인했다. P4 후 부모가 전체 HTTP·회귀·빌드를 다시 실행한다.
### `P4-T1` 및 `P4-GATE` 최종 실행, 2026-10-29
- 완료: 공개 controller/6필드 DTO, UTC nowProvider를 한 번 읽는 read-only 서비스 조회, 정확한 `GET /api/v2/sub-banners` 허용 matcher 한 줄과 실제 HTTP 테스트를 구현했다. 기존 추천 API 응답·테이블·공통 예외 정책·dependency는 변경하지 않았다.
- RED/GREEN: 공개 HTTP 23개 중 22개 assertion failures(기대 200/400, 실제 401) → 23/23 통과. JSON fixture의 LongNode/IntNode 차이는 기대 map의 serialize/readTree로 해소하고 전체 JSON tree 비교는 유지했다. 공개·관리자 보안 테스트는 동일 실제 HTTP context로 통합했다.
- 관련 회귀: 신규 92개(domain 2, persistence 24, 관리자 HTTP 43, 공개 HTTP 23) + 기존 홈/오디오 추천 4개 = 96/96, ktlint 통과.
- 부모 최종 명령: `./gradlew test ktlintCheck bootJar --continue --console=plain` → exit 0, `BUILD SUCCESSFUL in 12m 27s`. `build/reports/tests/test/index.html`의 총 2,676 tests, failures 0, ignored 0, 성공률 100%를 확인했다. `build/libs/sodalive-0.0.1-SNAPSHOT.jar` 생성 확인. 현재 코드로 전체 실제 HTTP 테스트도 재실행됐다.
- 실제 HTTP 증거: 관리자 POST/PUT 200의 관리자 8필드, DELETE 200 `{"success":true,"message":null,"data":null,"errorProperty":null}`. 익명 공개 GET은 200의 6필드 배열/빈 배열, 누락·잘못된 placement는 400. 익명 POST/PUT/DELETE/HEAD/PATCH와 인접 GET은 401이며 비관리자 admin은 기존 200 `success:false`다. 원본 응답은 `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.*.xml`의 `HTTP EVIDENCE`에 기록돼 있다. Authorization/JWT는 출력하지 않았다.
- 기간 증거: 고정 now의 시작 `:00`, 종료 `:59.999999999` 포함, 다음 초/다음 날 제외, 한쪽·양쪽 null, 두 placement 격리, 25개 무절단 id 순서, iOS 요청에도 Android-only 행을 반환하는 서버 OS 비필터를 실제 DB·HTTP로 확인했다.
- 독립 최종 리뷰: production 11개·대응 테스트 4개·DDL·SecurityConfig diff·호출 경계를 검토한 결과 `APPROVE`, Must fix/Should fix 없음. 기존 P3 DST gap blocker의 RED→GREEN 및 재검토 승인도 확인했다. 부모가 리뷰와 별도로 전체 실행 결과를 확인했다.
- 문서: API 계약과 앱/관리자 프롬프트를 실제 경로·shape·오류·날짜 허용 범위·이미지 정책으로 갱신했다. 문서 동기화 중 잘못 적힌 경로 ID 형식의 400 보장은 제거했으며 프레임워크 바인딩 오류까지 같은 상태라고 가정하지 않는다.
- 남은 한계: 운영 DB DDL 적용·배포·실제 AWS 업로드·native MySQL 실행·앱/관리자 UI는 수행하지 않았다. DDL은 독립 H2에 실제 적용해 검증했다. host에 LSP 도구가 없어 Kotlin 컴파일/ktlint로 대체했으며 LSP clean으로 주장하지 않는다. S3 업로드 후 DB 실패 시 orphan 및 교체/삭제 후 기존 object 보존 가능성은 승인된 범위대로 남긴다.
- 종료 확인: 변경 파일의 `git diff --check` 및 신규 파일 공백 검사 통과, PRD/plan 미완료 체크박스 없음. `pgrep`에서 Gradle Test Executor·embedded Redis·일회성 QA driver 잔존 없음. 검증 요약을 본 문서에 보존한 뒤 이번 소유 run root·child roots·QA driver·note·cache를 제거하고 경로 부재를 확인했다. 기존 공유 캐시·worktree·소스·빌드 결과/테스트 보고서는 보존했으며 커밋·푸시는 하지 않았다.
## Phase 5: is_active 소프트 삭제
앞선 하드 삭제 구현·검증은 당시 완료 이력이다. 이번 사용자 결정은 이를 대체하는 후속 변경이며 기존 완료 체크박스를 되돌리지 않는다.
### Task 5.1 저장·조회·삭제 경계 변경
**Goal `P5-T1`:** DELETE가 행을 보존하며 is_active만 false로 바꾸고, 삭제된 배너가 모든 소비자 조회와 수정 경로에서 제외된다.
- 시작 조건: `DEC-010` 사용자 승인 및 기존 P2~P4 완료.
- Files: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt`, `DefaultSubBannerRepository.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt`, `adapter/in/web/AdminSubBannerControllerIntegrationTest.kt`, `adapter/in/web/SubBannerControllerIntegrationTest.kt`; `docs/20261029_가로형서브배너/create-sub-banner.sql` 및 기존 PRD/API/프롬프트/계획.
- 완료 증거: DB 행과 기존 필드 보존, false 갱신, findById/관리자 목록/공개 목록 제외, 수정·재삭제 404, 기본 true와 DTO 불변을 실제 persistence/HTTP 테스트로 확인한다.
- 동시 경계: 수정 요청이 활성 entity를 로드한 뒤 다른 트랜잭션의 DELETE가 커밋하더라도, 먼저 시작한 수정이 삭제 상태를 true로 되돌리지 않아야 한다. 두 독립 EntityManager의 겹치는 트랜잭션과 새 조회 context로 재현·검증한다.
- 범위 밖: 활성 토글·복원 API·isActive 요청/응답 필드·S3 삭제·기존 완료 이력 삭제·운영 DDL 적용. 아직 미배포인 신규 CREATE DDL을 보완하며 추측한 레거시 migration은 추가하지 않는다.
- [x] **RED:** 행 보존/비활성/목록 제외·기간·placement·수정/재삭제 경계를 먼저 테스트했다. 추가 리뷰에서 발견한 stale managed entity도 두 트랜잭션으로 재현했다.
- [x] **RED 확인:** 기존 하드 삭제/컬럼 부재 assertion 8개 실패, 이후 동시 상태 덮어쓰기 회귀 1개 실패를 각각 확인했다.
- [x] **GREEN:** entity/DDL 기본 true, DELETE false 갱신, 모든 소비자 조회의 활성 필터와 entity-local DynamicUpdate를 구현했다.
- [x] **GREEN 확인:** focused 99/99 및 실제 HTTP에서 행·필드·S3 보존/미노출/404·기존 DTO/권한/기간 규칙과 동시 수정의 비복원을 확인했다.
- [x] **REFACTOR:** 직접 영향만 정리하고 ktlint·DDL 실제 H2 실행·독립 scoped 재검토를 통과했다. 전체 Gate는 별도 진행한다.
### Phase 5 Gate `P5-GATE`
- 시작 조건: `P5-T1` 완료.
- 완료 증거: 실제 HTTP 시나리오·전체 테스트·ktlint·bootJar·독립 리뷰·자원 정리 기록.
- [x] P6를 포함한 최종 전체 테스트 2,682개·lint·bootJar 및 독립 리뷰 통과. DDL의 is_active 기본 true와 entity 일치, 행 보존·조회 제외·동시 비복원을 검증했다. 자원 정리 영수증은 아래 최종 기록을 따른다.
- 도구 한계: LSP 및 native MySQL 실행 환경은 미제공이다. 컴파일/ktlint와 H2 검증으로 대체하되 운영 적용 검증으로 표현하지 않는다.
### `P5-T1` 1차 검증 및 리뷰, 2026-10-29
- baseline: 기존 서브 배너 focused 92/92 통과. 회귀 테스트를 먼저 변경해 기존 하드 삭제와 컬럼 부재로 인한 assertion 8개 실패를 확인했다.
- 1차 GREEN: entity 기본 true/nonnull, DELETE false 갱신, id/관리자/공개 활성 필터와 authored CREATE의 default true를 구현했다. focused 98/98 및 ktlint 통과. 실제 HTTP에서 DELETE 200/null, DB 행·payload·이미지·생성 시각 보존, 비활성 PUT/재DELETE404와 upload0을 확인했다.
- 독립 리뷰: 순차 삭제 경계는 통과했으나, 삭제 전 true entity를 보관한 수정 트랜잭션의 Hibernate 전체 컬럼 UPDATE가 뒤늦게 is_active=true를 덮을 수 있다는 P1 blocker를 확인했다. 기존 flush/clear 후 stale-domain 테스트와 다른 경계이므로 두 트랜잭션 회귀 RED→GREEN을 추가한다. 전체 완료 판정은 수정·재검토 후 수행한다.
### `P5-T1` 동시 경계 수정 및 Gate 진행, 2026-10-29
- 수정: `@DynamicUpdate`를 SubBannerEntity에만 추가해 payload 수정이 변경하지 않은 is_active를 SQL에 포함하지 않도록 했다. 새 잠금·버전 컬럼·외부 API는 추가하지 않았다.
- 재현: T1이 활성 managed entity를 유지한 상태에서 T2 DELETE 커밋 후 T1 payload UPDATE를 커밋했다. 제3 context에서 false를 기대했으나 true가 된 assertion RED 1건을 확인했다. 수정 뒤 persistence29/29와 focused99/99·ktlint가 통과했고 원래 stale-domain 회귀도 유지했다. 독립 재검토는 ADDRESSED/APPROVE다.
- 실제 SQL: 삭제는 `set updated_at=?, is_active=? where id=?`, 일반 payload 변경은 `set updated_at=?, web_url=? where id=?`로 관찰돼 삭제 상태가 덮이지 않았다. 테스트의 독립 EntityManager 3개와 커밋된 fixture는 finally로 정리했다.
- 전체 검증 1차: 부모의 `./gradlew test ktlintCheck bootJar --continue --console=plain`은 `bootJar` 뒤 `:test` 수행 중 실행 도구의 1,200,000ms 제한으로 종료됐다. 테스트 실패나 전체 성공을 판정할 출력은 없었다. 기존 99개 focused HTML을 전체 성공 증거로 재사용하지 않았다.
- 조사·재시도: 종료 후 Gradle Test Executor/embedded Redis 잔존 없음을 확인했다. 코드·테스트·빌드 설정은 바꾸지 않고, 테스트별 진행만 기록하는 임시 init script를 사용해 동일 전체 명령을 2,400,000ms 제한으로 한 번 재실행 중이다. 이 기록은 최종 통과 주장이 아니다.
## Phase 6: 두 추천 API에 서브 배너 통합
### Task 6.1 비로그인 추천 응답 합성 및 기존 앱 GET 제거
**Goal `P6-T1`:** 홈·콘텐츠 추천 GET에서 한 요청으로 기존 추천 데이터와 `subBanners`를 받고, 별도 서브 배너 조회 endpoint는 제공하지 않는다.
- 시작 조건: 사용자 `DEC-011` 승인, P5 focused 99개 및 독립 재리뷰 승인. P5 전체 Gate는 이번 통합 이후 최종 코드로 함께 판정한다.
- Files: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`, `dto/recommendation/HomeRecommendationResponse.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt`, `dto/AudioRecommendationsResponse.kt`; 기존 `v2/subbanner/adapter/in/web/SubBannerController.kt` 삭제 및 `configs/SecurityConfig.kt`의 해당 단일 permitAll 제거. 두 facade/DTO 직접 생성 테스트 호출자와 기존 실제 HTTP subbanner 테스트를 추천 통합 시나리오로 갱신한다.
- 인터페이스: 두 facade는 기존 `SubBannerService.findVisibleByPlacement` 및 `SubBannerResponse.from`을 재사용한다. HOME/CONTENT placement는 해당 facade가 고정하며 클라이언트 placement query나 OS 조건을 추가하지 않는다. `data.subBanners: List<SubBannerResponse>`는 항상 존재하며 없으면 `[]`다.
- 범위 밖: 관리자 API/DDL·소프트 삭제 변경, 기존 banners 교체, 새 fallback/cache/endpoint, 앱 UI 코드, 운영 배포. 기존 추천의 익명 GET 허용은 유지한다.
- [x] **RED:** 두 추천 GET의 실제 익명 HTTP에서 subBanners·활성/기간·배열·legacy 보존·폐기 handler 부재를 검사했다.
- [x] **RED 확인:** 29개 중 26 assertion failures를 확인했다. 통합 필드 부재와 폐기 대상 경로가 여전히 200인 것을 재현했다.
- [x] **GREEN:** 두 facade/DTO에 필수 배열을 합성하고 모든 직접 생성 호출자를 수정했다. standalone controller와 해당 permitAll 한 줄을 제거했다.
- [x] **GREEN 확인:** 같은 RED 명령 29/29 및 subbanner/home/audio 전체 관련 회귀 161/161 통과. 두 실제 추천 GET에 기존 소프트 삭제/기간/권한/무절단 시나리오를 이식했다.
- [x] **REFACTOR:** API 계약·앱 프롬프트를 `data.subBanners`로 갱신했다. 기존 기능·보안 및 JSON assertion을 완화하지 않았고 ktlint와 독립 P6 리뷰를 통과했다.
### 통합 최종 Gate `P6-GATE`
- 시작 조건: P6-T1 및 P5 focused/리뷰 완료.
- [x] 소프트 삭제·두 추천 통합 focused/전체 테스트·ktlint·bootJar·독립 리뷰를 통과하고 실제 HTTP 증거를 기록했다.
- [x] 앱 프롬프트/API 문서·소프트 삭제 DDL·행 보존·비로그인 조회·폐기 endpoint 부재를 확인했다. 소유 QA 자원 정리는 최종 종료 기록에 남긴다.
### 전체 검증 범위 변경, 2026-10-29
- 사용자 추가 요청으로 변경 전 코드를 대상으로 실행 중이던 전체 검증 runner를 개별 취소했다. 소프트 삭제 구현은 유지하며 P6 통합 코드로 최종 검증을 새로 수행한다. 이는 전체 테스트 통과/실패 판정이 아니다.
- 취소 당시 Gradle wrapper/test-worker PID를 확인한 뒤 후속 ps/lsof에서 해당 프로세스 부재를 확인했다. 공유 daemon·다른 작업 프로세스는 종료하지 않았다.
### `P6-T1` 구현·focused·독립 리뷰, 2026-10-29
- 구현: HomeRecommendationFacade는 HOME_RECOMMENDATION, AudioRecommendationFacade는 CONTENT_RECOMMENDATION을 고정해 기존 SubBannerService/6필드 DTO를 재사용한다. 기존 추천 DTO의 필수 `subBanners` 배열을 추가했고 모든 기존 필드/기존 banners 매핑을 그대로 유지했다. standalone 앱 controller와 정확한 GET 허용 matcher를 제거했다. 관리자 CRUD·DDL·소프트 삭제 저장소·DynamicUpdate는 변경하지 않았다.
- TDD: 컴파일 후 RED 29개 중 26개 assertion failures → GREEN 29/29. 기존 소켓 테스트를 `RecommendationSubBannerIntegrationTest`로 이식했으며, 폐기된 placement query 검증은 두 화면의 고정 placement 및 handler 부재 검증으로 대체했다. 삭제된 테스트를 통과를 위한 우회로 사용하지 않았다.
- focused: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*' ktlintCheck --console=plain` → 161/161, failures/errors/skipped 0, `BUILD SUCCESSFUL in 2m 25s`.
- 실제 HTTP: 두 GET은 Authorization 없이 200이며 `data.subBanners:[]` 또는 정확한 6필드 목록을 반환했다. HOME의 기존 9개+신규 배열=10필드, AUDIO의 기존 8개+신규 배열=9필드 전체와 비어 있지 않은 legacy banners를 정확히 비교했다. 각 placement 25개 전체·기간 소수초/자정·삭제 행 9개 중 비활성 4개 보존·OS 비필터를 확인했다. 폐기된 경로는 인증 GET 404/익명 401이며 handler mapping에도 없다.
- 독립 P6 리뷰: APPROVE, 차단 사항 없음. 직접 생성 테스트 호출자 6파일과 실제 HTTP 이식 전체를 확인했고 이전 P5 동시 삭제 비복원 회귀도 유지됨을 확인했다.
- 부모 확인: facade/DTO 변경, 기존 테스트 diff 및 새 실제 HTTP 테스트 전체와 앱/API 문서를 읽었다. 통합 이후에는 별도 배너 fetch가 없으므로 앱 프롬프트의 오류 처리를 추천 API 오류와 개별 이미지/링크 오류로 구분했다.
- 배포 조건: 추천 API도 신규 테이블/컬럼을 조회하므로 현재 DDL 준비가 서버 배포보다 선행해야 한다. 운영 DB 적용·배포는 수행하지 않는다.
- 최종 실행: 안정된 P5+P6 코드로 테스트별 진행 로그만 추가한 임시 init script와 함께 전체 `test ktlintCheck bootJar --continue`를 실행 중이다. 이전 취소/timeout 결과나 focused 통과를 전체 통과로 대체하지 않는다.
### `P5-GATE`·`P6-GATE` 최종 전체 검증, 2026-10-29
- 결과: 안정된 최종 코드에서 `./gradlew test ktlintCheck bootJar --continue --console=plain`과 동작을 바꾸지 않는 임시 테스트 진행 로그 init script를 사용해 1회 실행했다. exit 0, `BUILD SUCCESSFUL in 15m 46s`. 전체 2,682 tests, failures/errors/skipped/ignored 모두 0이다. 부모가 fresh HTML 및 `QA_SUITE_RESULT tests=2682 failed=0 skipped=0` 원본 로그를 직접 확인했다.
- 빌드/정적 검사: `bootJar`가 이번 실행에서 생성됐고 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`를 확인했다. `ktlintCheck`는 변경 없는 소스의 선행 성공 결과를 사용한 UP-TO-DATE 상태이며 전체 명령은 성공했다. Kotlin 컴파일과 선행 focused lint도 통과했다. LSP 도구 미제공을 LSP 성공으로 표현하지 않는다.
- 실제 HTTP: 최종 suite에도 `RecommendationSubBannerIntegrationTest` 24개가 실행됐다. 두 추천 GET의 Authorization 없는 200과 `data.subBanners`, 정확한 기존 root 필드/legacy banners 보존, 활성·기간·placement·25개 무절단, 폐기 경로의 handler 부재/인증 404·익명 401, admin 보호 및 삭제 행 보존을 확인했다. 이전 standalone 테스트 클래스는 source/build/report에서 사라졌고 새 클래스의 XML/HTML은 이번 실행에서 갱신됐다.
- 소프트 삭제: 실제 DELETE는 200/data:null을 유지하며 row/is_active=false/기존 payload 보존, 삭제 후 수정·재삭제404, 관리자·추천 조회 제외를 확인했다. 두 EntityManager의 겹치는 트랜잭션에서도 삭제 상태가 true로 돌아가지 않는 회귀를 유지했다. 앱·관리자에 isActive 필드나 복구 API를 추가하지 않았다.
- 리뷰: P5의 유일한 동시성 blocker는 재현 RED→DynamicUpdate GREEN 후 ADDRESSED/APPROVE. P6 독립 리뷰는 추가 차단 없이 APPROVE다. 각각의 검토와 별개로 부모가 전체 실행 결과·변경 파일·문서를 확인했다.
- 증거 위치: `build/reports/tests/test/index.html`, `build/test-results/test/TEST-*.xml`. 실제 응답은 RecommendationSubBannerIntegrationTest/AdminSubBannerControllerIntegrationTest XML의 HTTP EVIDENCE/DB EVIDENCE, 동시 경계는 SubBannerPersistenceTest XML의 CONCURRENCY EVIDENCE에 남는다. 테스트 토큰은 출력하지 않았다.
- 이전 중단 구분: P5 첫 전체 실행은 20분 도구 제한, 두 번째 진행 로그 실행은 사용자 P6 범위 변경 때문에 취소했다. 둘 다 통과 증거로 사용하지 않았으며 최종 P5+P6 전체 실행만 이번 완료 판정에 사용했다.
- 운영 한계: DDL 파일만 생성·보완했으며 native MySQL·운영 DB·실제 AWS·앱/관리자 UI·배포는 수행하지 않았다. 서버 배포 전에 현재 sub_banner/is_active DDL 준비가 필요하다. 기존 테이블을 이미 별도로 생성한 환경은 배포 담당자가 스키마 차이를 확인해야 하며 자동 migration/운영 변경은 하지 않는다.
- 종료 영수증: tracked/new 파일 공백 검사 통과, PRD/plan 미완료 체크박스 없음, production source에 폐기 `/api/v2/sub-banners` 경로 없음. 테스트 wrapper/worker·embedded Redis 잔존 없음을 확인한 뒤 이번 소유 `subbanner-soft-delete.wmREV2` root와 child root·note·진행 로그 스크립트·cache를 제거하고 경로 부재를 확인했다. 공유 Gradle daemon/cache·기존 worktree·소스·JAR·build 테스트 보고서는 보존했다. 커밋·푸시는 하지 않았다.
### 커밋 전 영향 범위 재검증, 2026-10-29
- 무엇을·왜: 커밋할 최종 변경의 서브 배너·홈·콘텐츠 추천 경계를 재검증했다. 제품 코드와 테스트는 수정하지 않았다.
- 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*' ktlintCheck bootJar --console=plain`.
- 결과: 종료 코드 0, `BUILD SUCCESSFUL in 5m 5s`. 최신 HTML/XML에서 161 tests, failures/errors/skipped 0을 확인했다. 테스트는 재실행됐으며 lint·컴파일·bootJar는 입력 변경이 없어 UP-TO-DATE였다.
- 전체 회귀 생략: 이전 최종 Gate의 전체 2,682개 통과 이후 제품 코드 변경이 없어 전체 suite를 반복하지 않았다. 이번 영향 범위 실행으로 `build/reports/tests/test/index.html`과 XML은 161개 결과로 갱신됐으며, 과거 전체 결과와 구분한다.
- 운영 한계: 운영 DB·native MySQL·실제 AWS·앱/관리자 UI·배포는 이번에도 검증하지 않았다. 서버 배포 전에 현재 DDL 준비가 필요하다.
## Phase 7: 서브 배너 언어 후속
2026-11-02 사용자 승인으로 새 Phase를 추가한다. Phase 2~6의 완료 상태와 검증 기록은 당시 계약의 이력이며 수정하지 않는다.
### Task 7.1 저장·관리자 계약·공개 국가별 조회
**Goal `P7-T1`:** 서브 배너가 기존 `Lang`을 저장하고 관리자가 안전하게 언어를 관리하며, 두 공개 추천 API가 회원 국가에 맞는 언어만 반환한다.
- 요구사항: PRD `SB-013`, `DEC-012`.
- 시작 조건: `DEC-012` 승인과 기존 Phase 2~6 완료.
- 완료 증거: baseline 기록, language 저장/기본값/수정 보존 persistence 및 실제 관리자/public HTTP 테스트의 RED→GREEN 결과, DDL 두 경로 검토, 독립 코드 리뷰.
- 범위 밖: 앱·관리자 UI, public `SubBannerResponse` 변경, event link parsing, event detail/adult 정책, dependency, 운영 DB/DDL 실행, 기존 언어 자동 추론.
- **Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt`
- Modify: `docs/20261029_가로형서브배너/create-sub-banner.sql`
- Create: `docs/20261029_가로형서브배너/20261102_add_sub_banner_lang.sql`
- Modify: `docs/20261029_가로형서브배너/api-contract.md`, `docs/20261029_가로형서브배너/admin-prompt.md`, `docs/20261029_가로형서브배너/app-prompt.md` (실제 언어 계약과 배포 주의사항 동기화; UI 구현 제외)
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
- **Interfaces:** `SubBanner.lang: Lang = Lang.KO`; admin request optional `lang` preserving JSON presence/type distinctions; `AdminSubBannerResponse.lang: Lang`; repository `findVisibleByPlacement(placement: SubBannerPlacement, lang: Lang, now: LocalDateTime): List<SubBanner>`. Public service accepts `Member?`, resolves through existing `MemberContentPreferenceService.resolveCountryCode(member)`, then passes required `Lang`. Both existing facades pass their `member`. Admin list receives no language filter. `SubBannerResponse` remains unchanged.
- [x] **Baseline:** 부모의 사전 통과 113개 중 서브 배너 기존 4개 클래스 98개를 확인했다. 원본 `/tmp/soda-banner-ulw.HDkpg4/baseline.log`. 두 facade 클래스는 사전 baseline 범위 밖이며 이번 GREEN에서 10개를 검증했다. 운영 DDL은 실행하지 않았다.
- [x] **RED:** domain KO 기본값, 관리자 9필드·생성 언어·invalid 입력 400 및 공개 두 JP 경로의 KO 제외를 실제 HTTP/응답 assertion으로 먼저 추가했다. invalid 입력은 실제 DB 상태·업로드·repository save 호출 0회를 검증한다.
- [x] **RED 확인:** domain/admin/public 81개 중 언어 관련 assertion 15개 실패, 오류 0을 확인했다. 컴파일·설정 RED가 아니다. `/tmp/soda-banner-impl.CfAkhD/subbanner-red.log`와 `subbanner-*-red.xml`.
- [x] **GREEN:** 기존 `Lang`, 생성 KO·수정 생략 보존, 부작용 전 검증, facade의 member 전달, repository 필수 언어 조건 및 두 SQL artifact를 구현했다. typed persistence 언어 왕복·필터·변경과 기존 호출부도 함께 갱신했다.
- [x] **GREEN 확인:** focused 123개 실패·오류·무시 0. 실제 관리자 KO/JA/EN·수정 생략 보존·invalid 입력, 두 추천 JP/KR/US/누락·강제 회원, 공개 정확히 6필드 및 기존 기간·소프트 삭제·정렬·링크 회귀가 통과했다.
- [x] **REFACTOR:** 전체 2,928개 테스트, 공백 수정 후 영향 26개 HTTP 테스트·전체 ktlint·bootJar 및 독립 리뷰를 확인했다. 운영 DDL과 도구가 없는 LSP 진단은 실행하지 않았다.
```bash
export TMPDIR=/tmp/soda-banner-impl.CfAkhD NODE_COMPILE_CACHE=/tmp/soda-banner-impl.CfAkhD/node-compile-cache BUN_RUNTIME_TRANSPILER_CACHE_PATH=/tmp/soda-banner-impl.CfAkhD/bun-transpiler
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerTest' --tests 'kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence.SubBannerPersistenceTest' --tests 'kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.AdminSubBannerControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.RecommendationSubBannerIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest' --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process
./gradlew test ktlintCheck bootJar --continue --console=plain
```
**DDL requirements:** both SQL artifacts define `lang VARCHAR(10) NOT NULL DEFAULT 'KO'` with a column comment. DEC-013 supersedes the original guarded migration with one static ALTER TABLE for an existing table without lang. Existing rows receive KO; do not rerun when lang exists. Before production application, operations must manually identify JA banners and verify language consistency against linked events. No event link parser or automatic classification is allowed. Production DB/DDL execution is not part of this goal.
### Phase 7 Gate `P7-GATE`
- 시작 조건: `P7-T1` 완료.
- 완료 증거: full test suite·ktlint·bootJar 성공, 실제 관리자와 두 추천 HTTP 경로 검증, 두 SQL artifact/수동 분류 경고 대조, 독립 리뷰에서 미해결 차단 사항 없음.
- 범위 밖: 운영 DB 적용·배포·UI.
- [x] 국가 resolver 재사용·관리자 전체 언어·public DTO 불변·invalid 부작용 0을 확인하고 이전 Phase 이력을 보존했다. 전체 검증 및 최종 Oracle 승인으로 Gate를 완료했다.
### Phase 7 구현 Progress, 2026-11-02
- RED는 위 명령 중 domain/admin/public 3개 클래스를 실행해 `81 tests completed, 15 failed`를 확인했다. 실패는 KO 기본값/관리자 lang 필드 누락, invalid lang의 200 수락, JP에서 KO 노출이다. 원본 XML은 `/tmp/soda-banner-impl.CfAkhD/subbanner-domain-red.xml`, `subbanner-admin-red.xml`, `subbanner-public-red.xml`이다. 개별 typed persistence 테스트를 별도 RED 실행한 것으로 주장하지 않는다.
- GREEN은 위 6개 클래스 명령이며 `/tmp/soda-banner-impl.CfAkhD/subbanner-green.log`에 `BUILD SUCCESSFUL in 8m 28s`가 남는다. Domain 3, Persistence 32, Admin HTTP 52, Public HTTP 26, Home facade 8, Audio facade 2로 총 123개가 통과했다.
- 실제 HTTP/DB 증거는 `subbanner-http-db-evidence.log`의 HTTP 121건과 `subbanner-green-TEST-*.xml`에 보관했다. 신규 언어 경로는 두 추천 각각 익명 JP/KR/US/누락·강제 JP/KR를 실제 JWT/회원/resolver로 검증한다. EN 행이 있어도 공개 목록에서 제외되고, admin은 KO/JA/EN을 모두 반환한다. invalid 8종 POST/PUT 16건은 400이며 실제 DB 불변·S3 상호작용 없음·repository save 0회가 통과했다.
- H2 flush/clear/재조회에서 모든 언어의 문자열 저장·변경·언어 조건 및 `VARCHAR(10) NOT NULL`을 확인했다. 기존 CREATE SQL 실행 테스트도 H2에서 KO default/NOT NULL/길이 10을 검증했다. migration은 기존 event DDL의 information_schema/PREPARE 패턴과 대조만 했고 native MySQL·운영 DDL은 실행하지 않았다. 기존 초 정밀도·소프트 삭제/동시성·링크 검증은 유지했다.
- API 계약/관리자·앱 프롬프트에 생성 생략 KO·수정 생략 유지·null/type/blank/unknown 400, JP→JA/그 외→KO, admin 전체 언어, EN 비노출, 공개 6필드 및 migration-before-deploy를 반영했다. KO backfill은 자동 분류가 아니므로 JA 소재와 연결 이벤트 언어를 수동 확인해야 한다. 외부 링크 원문은 유지하며 파서를 추가하지 않았다.
- 사용자 지정 종료 경계에 따라 전체 suite·ktlint·bootJar·독립 리뷰와 `P7-GATE`는 부모가 수행하도록 미완료 유지한다. LSP 진단 도구 없음, 실제 AWS/UI/운영 DB/배포 미검증. Gradle 및 embedded Redis 소유 프로세스가 종료됐음을 확인했고 evidence는 남겼다. 모든 변경 파일·명령·제한은 `/tmp/soda-banner-impl.CfAkhD/implementation-report.md`에 기록한다. 기존 Phase 2~6 기록과 부모 PRD 변경은 보존했다.
- 최종 source 상태에서 위 6개 클래스와 Phase 3의 5개 클래스를 한 focused 명령으로 재실행했다. `/tmp/soda-banner-impl.CfAkhD/final-focused.log`: `BUILD SUCCESSFUL in 8m 8s`, 총 145개 실패·오류·무시 0. 전체 suite는 실행하지 않았다. 최종 11개 XML은 `final-TEST-*.xml`, 실제 HTTP 185건과 DB/동시성 증거는 `final-http-db-evidence.log`에 별도 보관했다.
### Phase 7 최종 검증 Progress, 2026-11-03
- 부모가 `./gradlew test ktlintCheck bootJar --continue --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`를 실행했다. 전체 XML을 집계해 336 suites / 2,928 tests / failures=0 / errors=0 / skipped=0을 확인했고 JAR도 생성됐다. 최초 명령은 `RecommendationSubBannerIntegrationTest.kt`의 생성자 인자 줄바꿈 1곳(5개 lint 진단) 때문에 exit 1, 15m 50s였다.
- 값·순서·동작 변경 없이 인자만 줄별로 배치했다. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.RecommendationSubBannerIntegrationTest' ktlintCheck bootJar --continue --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: exit 0, `BUILD SUCCESSFUL in 4m 32s`, 26 tests / failures=0 / errors=0 / skipped=0, 전체 lint 통과, bootJar UP-TO-DATE.
- 전체 회귀 뒤 테스트 공백만 달라져 영향 HTTP suite만 재실행했다. 현재 `build/test-results/test`/HTML은 26개 결과이며, 전체 2,928개 수치는 갱신 전 부모의 직접 집계다. 실제 HTTP·DB 검증은 테스트 대체가 아니라 RANDOM_PORT 서버를 대상으로 한 TestRestTemplate 요청과 저장소 재조회다.
- 부모가 production diff, 입력·저장·조회 경로, 테스트 assertion, SQL 두 개와 소비 문서를 직접 대조했다. LSP 도구가 없어 미실행이며 Gradle 컴파일·lint로 대체했다. 운영 DB/native MySQL/AWS/UI/배포는 제외한다.
- 원본 `/tmp/soda-banner-ulw.HDkpg4/full-verification.log`, `final-verification.log` 및 구현자 임시 증거는 작업 종료 시 정리한다. 재현 테스트 소스, 최신 build reports, 본 검증 기록과 [공동 리뷰](reviews/banner-language-review.md)를 남긴다.
- 독립 Oracle 리뷰는 명세·코드·시나리오 APPROVE, 후속 공백 delta 및 최종 검증 Gate도 APPROVE했다. 미해결 발견 사항 없음. `P7-T1/P7-GATE` 완료이며 운영 DDL·배포는 별도다.
- 서브 배너 구현·SQL·테스트 커밋 `c4c61e36`, hook/메시지 검사 통과. 소유 임시 자료·QA 프로세스 정리와 build 산출물 보존은 공동 리뷰의 종료 증거에 기록했다. 운영 DDL·배포·push는 하지 않았다.
## Phase 8: 정적 이관 SQL
### Task 8.1 단일 ALTER TABLE 전환 (`P8-T1`)
- Objective: 저장 프로시저·동적 SQL 없이 기존 lang 미존재 테이블에 KO 기본 언어 컬럼을 추가한다.
- 시작 조건: DEC-013 사용자 요청, 기존 SQL 및 MySQL ALTER 문법 확인.
- Files: `docs/20261029_가로형서브배너/20261102_add_sub_banner_lang.sql`, `prd.md`, `api-contract.md`, `plan-task.md`.
- 범위 밖: 앱 코드·신규 CREATE 변경, 이미 존재하는 컬럼 정정, 자동 재실행, 운영 DB 접속·실행.
- TDD 예외: 애플리케이션 로직 변경 없이 배포 SQL 표현을 단순화한다. H2에서 기존 MySQL PREPARE가 실패하는 것을 기능 RED로 가장하지 않는다.
- 대체 검증: 실제 SQL 파일을 독립 H2 MySQL 모드에 적용하는 임시 JDBC assertion으로 기존 행 보존/KO 초기값, 새 행 기본값, JA/EN 보존, NULL 거부, 재실행 실패 후 값 불변을 확인한다. native MySQL 실행 증거와 구분한다.
- [x] 단일 정적 SQL 및 실행 전제/재실행 제한을 관련 계약에 반영했다.
- [x] 독립 DB 확인, 문서 대조와 `git diff --check`를 수행했다.
### Phase 8 Gate (`P8-GATE`)
- 완료 증거: S1 기존 행/컬럼, S2 신규 기본값·명시 언어·NULL 거부, S3 재실행 실패·값 보존이 통과하고 scoped 리뷰가 승인한다.
- [x] 실제 SQL 검증·독립 리뷰 APPROVE를 기록하고 소유 임시 자원을 정리했다. 애플리케이션 변경이 없어 Gradle 전체 suite는 반복하지 않았다.
### Phase 8 검증 기록 — 2026-11-03
- MySQL 8.0 공식 [ALTER TABLE 문법](https://dev.mysql.com/doc/refman/8.0/en/alter-table.html)의 정적 `ADD COLUMN column_definition [AFTER col_name]`를 확인했다. 직접 fetch는 403으로 실패해 검색 결과의 공식 문법을 확인했다. MySQL에 MariaDB의 `ADD COLUMN IF NOT EXISTS` 문법을 가정하지 않았다.
- 실제 변경한 SQL 파일을 캐시된 H2 2.1.214의 MySQL 모드에서 임시 Java/JDBC driver로 실행했다. 명령: `java --class-path /Users/klaus/.gradle/caches/modules-2/files-2.1/com.h2database/h2/2.1.214/d5c2005c9e3279201e12d4776c948578b16bf8b2/h2-2.1.214.jar /tmp/soda-banner-sql.CIhdCg/VerifyMigration.java docs/20261029_가로형서브배너/20261102_add_sub_banner_lang.sql`. exit 0, 모든 assertion PASS.
- S1: 기존 활성·비활성 두 행의 ID/이미지/활성값 보존, 두 행의 새 lang=KO, VARCHAR(10)/NOT NULL 확인.
- S2: 새 행의 언어 생략 KO, 명시적 JA/EN 저장 유지, 명시적 NULL 거부(SQLState 23502) 확인.
- S3: 같은 SQL 재실행은 duplicate column(SQLState 42S21)으로 실패하고 JA/EN/KO 값이 바뀌지 않음을 확인했다. 이는 재실행 허용이 아니라 한 번만 실행해야 한다는 계약 검증이다.
- Native MySQL·운영 DB에는 실행하지 않았으며, H2 결과를 MySQL 실행 증거로 표현하지 않는다. 앱 소스·CREATE SQL 변경이 없어 Gradle 테스트/빌드/린트는 반복하지 않았다. 임시 driver는 결과 기록 후 정리한다.
- 독립 scoped 리뷰 `ses_e630f286affevZ6Rc77rxuL3Dn`: APPROVE, 문서화된 1회 실행 전제 아래 데이터 손실 회귀·차단 사항 없음. 기존 Phase 7의 동적 SQL 검토 기록은 당시 이력이며 현재 계약은 DEC-013/P8이다.
- Teardown: Java 검증 프로세스 잔존 없음 확인, 소유 `/tmp/soda-banner-sql.CIhdCg`와 `/tmp/soda-sql-review.439u9H`의 내용·실경로를 확인한 뒤 삭제하고 부재를 확인했다. 임시 notepad도 삭제했다. 공유 H2/Gradle 캐시는 보존했다.