docs(subtitle): 자막 연동 검증과 배포 조건을 기록한다

This commit is contained in:
2026-11-11 08:35:29 +09:00
parent 9c1c544e45
commit 4c1d876d5e
7 changed files with 623 additions and 0 deletions
@@ -0,0 +1,141 @@
# 자막 Phase 1 배포 게이트
**현재 판정: 배포 BLOCKED.** 이 문서는 로컬 저장 계약과 배포 전 확인 항목이며 DB 적용·AWS 배포 증거가 아니다.
기준일은 2026-11-10이며 [PRD](prd.md), [구현 계획](plan-task.md), [DDL](subtitle-schema.sql)을 함께 본다.
## 1. 검토 기준과 파일 경계
- persistence 기준 경로: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/`.
- 실제 읽은 파일: `SubtitleJob.kt`, `Subtitle.kt`, `SubtitleCallbackReceipt.kt` 및 세 repository.
- 콘텐츠 기준: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt`, `common/BaseEntity.kt`.
- `src/main/resources/application.yml`의 `MySQL8Dialect`·`ddl-auto: validate`는 로컬 설정 근거일 뿐 운영 DB 증거가 아니다.
- 문서 담당은 이 문서와 DDL만 작성한다. 병렬 Kotlin 구현의 최종 엔티티 대조는 상위 구현 게이트에서 다시 수행한다.
## 2. 저장 계약 대조
물리명은 기존 Spring snake_case 매핑 기준이다. 길이 미지정 `String`은 JPA 기본 `VARCHAR(255)`를 따른다.
`jobId`는 UUID **문자열**이며 `BINARY(16)`으로 바꾸지 않는다. `N`은 NOT NULL, `Y`는 NULL 허용이다.
| `SubtitleJob.kt` 속성 | DDL 컬럼·타입 | NULL |
|---|---|---|
| `jobId` | `job_id VARCHAR(36)` PK | N |
| `contentId` | `content_id BIGINT` signed | N |
| `sourceBucket`, `sourceKey` | `source_bucket`, `source_key VARCHAR(255)`; 전체 두 컬럼 unique | N |
| `outputKey` | `output_key VARCHAR(255)` | N |
| `status` | `status VARCHAR(32)`; enum STRING | N |
| `errorCode` | `error_code VARCHAR(255)` | Y |
| `errorMessage` | `error_message TEXT` | Y |
| `sourceUploadErrorCode` | `source_upload_error_code VARCHAR(255)` | Y |
| `sourceUploadErrorAt` | `source_upload_error_at TIMESTAMP`; 명시적 JPA TIMESTAMP | Y |
| `createdAt`, `updatedAt` | `created_at`, `updated_at TIMESTAMP` | N |
| `Subtitle.kt` 속성 | DDL 컬럼·타입 | NULL |
|---|---|---|
| `id` | `id BIGINT` signed PK AUTO_INCREMENT | N |
| `jobId` | `job_id VARCHAR(36)` FK → `subtitle_job.job_id` | N |
| `language` | `language VARCHAR(35)`; `(job_id, language)` unique | N |
| `format` | `format VARCHAR(16)` | N |
| `s3Bucket`, `s3Key` | `s3_bucket`, `s3_key VARCHAR(255)` | N |
| 기존 엔티티 속성 | DDL 컬럼·타입 | NULL |
|---|---|---|
| `SubtitleCallbackReceipt.eventId` | `event_id VARCHAR(255)` PK; UUID 제한 없음 | N |
| `SubtitleCallbackReceipt.jobId` | `job_id VARCHAR(36)` | N |
| `SubtitleCallbackReceipt.bodyHash` | `body_hash VARCHAR(64)` | N |
| `SubtitleCallbackReceipt.createdAt` | `created_at TIMESTAMP` | N |
| `AudioContent.latestSubtitleJobId` | `content.latest_subtitle_job_id VARCHAR(36)` | Y |
### 미확정 차이와 적용 전 대조
1. 마지막 정적 대조에서 `Subtitle.kt`의 테이블명·6개 컬럼·길이·nullable·IDENTITY PK·unique가 합의 계약과 일치했다.
`SubtitleJob.kt`에서도 `koKey`/`jaKey` 제거를 확인했다. `Subtitle.jobId`는 scalar이고 JPA 관계/FK 선언은 없다.
DDL의 `subtitle.job_id` FK는 합의 계약에 따른 DB 제약이며 ORM 관계 선언 확인으로 보고하지 않는다.
`subtitle_job` CREATE에 `ko_key`/`ja_key`는 넣지 않는다. 미출시 초안 교체이며 기존 데이터 이관·컬럼 삭제 SQL은 없다.
2. 최초 대조에서는 `sourceUploadErrorAt`에 `columnDefinition`이 없어 차이로 기록했다.
최종 구현은 `@Column(columnDefinition = "TIMESTAMP")`와 초 절삭을 명시했고 부모가 DDL과 다시 대조했다.
이 로컬 mapping 차이는 해소됐지만 실제 MySQL schema 적용·왕복 검증은 여전히 운영 게이트다.
3. 나머지 시각은 엔티티의 명시적 `TIMESTAMP`와 초 절삭을 따른다. DDL 소수초 정밀도는 모두 0이다.
`created_at`은 `DEFAULT CURRENT_TIMESTAMP`, job의 `updated_at`은 같은 기본값과 `ON UPDATE CURRENT_TIMESTAMP`다.
`Subtitle.kt`에는 시각이나 `BaseEntity` 상속이 없으므로 결과 행에 임의 시각 컬럼을 추가하지 않는다.
UTC 생성값과 JDBC/session timezone 및 실제 MySQL 왕복은 미검증이다.
4. `AudioContent`는 `content` 테이블이며 `BaseEntity.id: Long?`의 IDENTITY를 상속한다.
신규 `content_id`는 signed BIGINT로 맞췄지만 운영 `content.id`의 실제 타입·부호·엔진은 확인하지 않았다.
합의된 `subtitle.job_id` FK만 둔다. receipt의 jobId 및 콘텐츠 연결은 현재 scalar 매핑이므로 FK를 임의 추가하지 않는다.
FK cascade는 없으며 기본 RESTRICT다. `(job_id, language)` unique가 결과 FK와 작업별 조회 인덱스를 겸한다.
5. 현재 callback 활성화는 `ko-KR`/`ja-JP`, 형식은 `SRT`, 결과 2/1/0개의 상태 판정은 그대로다.
저장 구조만 언어별 행으로 바꾼다. 새 언어 활성화·재생성·새 관리 API·recovery 상태/테이블은 추가하지 않는다.
### 식별자와 인덱스 전제
- 세 신규 테이블과 최신 job 컬럼은 `utf8mb4_0900_bin`을 쓴다. 대소문자·후행 공백을 구별하는 NO PAD 비교다.
eventId 및 bucket/key의 정확한 식별에 case-insensitive collation이나 PAD SPACE인 `utf8mb4_bin`을 대신 쓰지 않는다.
- MySQL 8.0.17 이상 및 해당 collation의 binary/NO PAD 속성, InnoDB DYNAMIC·3072-byte 키 지원을 운영자가 확인한다.
16 KiB 이상 InnoDB page를 전제로 하며 4/8 KiB page 환경의 적합성을 주장하지 않는다.
- source unique 최대 키 데이터는 `(255 + 255) × 4 = 2040` bytes다. eventId PK는 `255 × 4 = 1020` bytes다.
결과 unique는 `(36 + 35) × 4 = 284` bytes다. 보조 인덱스의 PK 포함분도 3072 bytes 미만이다.
prefix unique나 해시 식별자로 원래 bucket/key를 대체하지 않는다.
- 경로 컬럼은 현재 UUID 기반 계약에 맞는 `VARCHAR(255)`이며 일반 S3 최대 길이 전체 지원을 뜻하지 않는다.
job의 `(content_id, created_at)` 인덱스는 현재 `findFirstByContentIdOrderByCreatedAtAsc` 조회에 대응한다.
## 3. Lambda handoff와 실제 환경 구분
아래 SHA-256은 **PRD §2의 기존 조사 기록 인용**이다. 문서 담당이 외부 저장소·ZIP·AWS를 다시 조회한 결과가 아니다.
원본 보고서 참조는 PRD에 적힌 `aws-subtitle-service/docs/FINAL_REPORT.md`다.
| 정확한 artifact | SHA-256 |
|---|---|
| `start-subtitle-transcription.zip` | `a0d0b19b2ecb24c0f89caf528bf09fd039558563a99063f082ac64418704abfd` |
| `generate-subtitle.zip` | `bfa8742ca436bace853ec2540fc5f70daa24cbab49e75315565d980189fbf209` |
로컬 artifact 일치 기록은 운영 함수 버전/alias·event routing·consumer·in-flight·Scheduler·DB 적용 증거가 아니다.
자격 증명/JWT/raw body 값은 이 문서나 증거 보고서에 기록하지 않는다.
## 4. 운영 차단 항목
| 게이트 | 현재 | 해제에 필요한 증거 |
|---|---|---|
| 최종 엔티티·DB | BLOCKED | 위 차이 해소, 운영 기존 스키마·버전·collation·인덱스 확인, 별도 DDL 승인·적용 기록 |
| consumer·조정 배포 | BLOCKED | 운영자 inventory, 실행 중일 때만 pause/drain, backend·정확한 두 Lambda의 조정 배포 기록 |
| MediaConvert·CloudFront | BLOCKED | UUID 입력의 ID 추출/출력 확장자 및 private origin·서명 URL 실환경 검증 |
| controlled live | BLOCKED | ko/ja/mixed의 실제 S3 → callback → 콘텐츠/앱 결과와 word/item 언어 증거 |
| 대표 2시간 입력 | BLOCKED·미측정 | 신뢰 가능한 duration 근거 및 대표 입력의 전체 처리 예산 실측 |
### 운영자 실행 순서: 별도 승인 후에만
1. 운영 consumer·event routing·함수 버전/alias·in-flight·기존 DB 상태를 inventory로 확정한다.
실행 중인 consumer가 있을 때만 신규 유입을 pause하고 기존 작업·callback을 drain하는 전환 구간을 조율한다.
실행 중인 대상이 없으면 그 증거와 함께 pause/drain을 생략한다. 미확인 상태를 비가동으로 간주하지 않는다.
2. 최종 JPA와 실제 `SHOW CREATE TABLE`·collation·인덱스 전제를 대조하고 DB 변경을 별도 승인받는다.
기존 동명 테이블/컬럼이 있으면 중단하고 차이를 검토한다. `IF NOT EXISTS`로 불일치를 숨기지 않는다.
additive DDL 승인·적용 확인과 Phase 1 backend/두 Lambda의 배포를 같은 전환 계획으로 조율한다.
구·신 consumer의 중복 실행이나 호환성 미확인 in-flight를 남긴 채 재개하지 않는다.
3. UUID 원본 `input/{contentId}/{UUID filename}`으로 외부 MediaConvert의 contentId 추출 규칙을 확인한다.
등록은 안전한 실제 파일 확장자를 UUID 뒤에 보존한다. 실제 START ZIP은 지원 확장자를 검사하므로
누락/미지원 확장자의 자막 성공을 주장하지 않는다. 이를 위해 기존 업로드를 거부하거나 파일을 허위 변환하지 않는다.
출력 경로·실제 확장자·완료 callback·기존 preview/알림 흐름의 호환 증거가 필요하다.
로컬 `matchesOutput`의 허용 확장자는 `aac`/`m4a`/`mp3`이나 실제 외부 출력 계약이 확인된 것은 아니다.
자막 bucket/key가 기존 private CloudFront origin/path에 정확히 매핑되고 권한 있는 사용자만 서명 URL로
열람하는지 확인한다. 두 항목 모두 현재 미검증이며 임의 prefix 허용이나 공개 S3 URL로 우회하지 않는다.
현재 Spring의 자막 bucket allowlist는 기존 `cloud.aws.s3.content-bucket` 한 개다.
Lambda `MEDIA_BUCKET`과 `SUBTITLE_BUCKET`도 이 값에 맞춰야 하며, 별도 자막 bucket을 쓰려면
allowlist와 CloudFront origin 계약을 먼저 변경·검증해야 한다. 현재 구현이 다중 bucket을 지원한다고 보지 않는다.
4. 일반 유입을 재개하기 전에 controlled ko/ja/mixed live를 수행한다.
S3 source binding, Transcribe 실제 word/item 언어, 결과 SRT, BOT callback, 언어별 콘텐츠/앱 표시를 연결해 기록한다.
mixed는 실제 word/item 언어 증거가 필수이며 synthetic fixture나 HTTP metadata 검증으로 대체할 수 없다.
유료 preview-only 사용자의 전체 자막 차단과 private 접근도 확인한다.
5. 신뢰 가능한 기존 duration의 가용성과 입력 검증 경계를 확인하고 대표 2시간 입력의 처리 예산을 실측한다.
구형/검증 불가능 원본은 수동 게이트로 남기며 임의 client duration을 신뢰하지 않는다.
2시간은 입력 목표이지 SLA/지원 보장이 아니다. 미측정 상태에서 지원 또는 재개 승인을 하지 않는다.
모든 차단 항목의 증거와 승인자를 기록한 뒤에만 일반 유입을 재개한다.
purge·데이터 정리·적용 migration 수정·Scheduler fallback·자동 복구·Translate 50-call 정책은 이 절차에 없다.
재생성 전체와 새 언어 활성화 역시 제외한다. 미해결 작업을 삭제하거나 성공 처리해 drain을 가장하지 않는다.
## 5. 문서 담당 검증 경계
문서 간 계약·실제 엔티티 속성·컬럼별 COMMENT·테이블 COMMENT·SQL 종류·공백을 read/rg로 정적으로 대조했다.
DDL은 CREATE TABLE 3개와 ALTER TABLE ADD COLUMN 1개뿐이며 23개 신규 컬럼·3개 신규 테이블에 COMMENT가 있다.
후행 공백·탭·CR·130자 초과 행은 발견하지 않았다. SQL 파싱/실행이나 물리 스키마 호환성을 검증한 결과가 아니다.
이 담당은 테스트·빌드·DB 실행·AWS 접근·외부 저장소 조회를 하지 않으며 성공 결과를 주장하지 않는다.
상위 구현 검증도 이번 작업에서 새로 작성한 테스트만 실행하도록 승인되어 있다. 기존 테스트 실행은 승인 범위가 아니다.
로컬 HTTP·새 테스트·빌드 결과는 구현 담당의 별도 증거이며, 그 성공도 위 운영 게이트를 해제하지 않는다.
@@ -0,0 +1,124 @@
# 자막 Phase 1 Spring 구현 검증 기록
> 상태: **재생성 제외 로컬 backend 구현·신규 테스트 15개·독립 리뷰 완료**. 운영 배포 완료가 아니다.
## 범위와 결정
- 이번 범위: 기존 업로드의 PENDING/source 선등록, 네 BOT 내부 endpoint, raw-body receipt, 기존 상세의 언어·권한별 자막 제공.
- 재생성은 사용자 결정으로 제외했다. 새 공개 재생성 API, 직접 START 호출, Scheduler/outbox를 만들지 않는다.
- 후속 요구로 언어별 결과는 `Subtitle` 행에 저장한다. 이번 callback 허용 언어/완료 판정은 ko/ja만 유지하며 다른 언어를 활성화하지 않는다.
- 테스트 실행은 사용자 후속 지시 이후 **이번 작업에서 새로 작성한 테스트만** 대상으로 한다. 기존 클래스에 추가한 사례도 메서드 selector로 한정한다.
## 실제 조사와 보존
- 현재 Spring에 subtitle/recovery/자막 HMAC/재생성 action이 발견되지 않았다. 제거할 해당 코드가 없으므로 실제 삭제 없음.
- 공유 member login/JWT/MemberRole.BOT, Agora HMAC, 텍스트 TranslationJob/worker, S3Uploader와 기존 MediaConvert 관련 외부 코드/설정은 보존 대상이다.
- 기존 MediaConvert 완료 API의 ADMIN/BOT 권한은 유지한다. 새 원본 UUID에 대한 source-bound 경로 검증과 기존 파일 검증을 구분한다. 실제 consumer ID 추출·출력 규칙 호환성은 운영 확인 전 배포 BLOCKED다.
- Flyway/Liquibase 의존성 및 자막 migration은 현재 프로젝트에서 발견되지 않았다. 운영/공유 환경의 적용 schema/data 상태는 조회하지 않았다. additive SQL 문서만 준비하며 DB에는 적용하지 않는다.
## Lambda와 운영 전환
명시된 `aws-subtitle-service/docs/FINAL_REPORT.md`와 실제 로컬 ZIP을 대조했다.
| 산출물 | SHA-256 |
|---|---|
| `start-subtitle-transcription.zip` | `a0d0b19b2ecb24c0f89caf528bf09fd039558563a99063f082ac64418704abfd` |
| `generate-subtitle.zip` | `bfa8742ca436bace853ec2540fc5f70daa24cbab49e75315565d980189fbf209` |
파일 hash 일치는 AWS 배포 증거가 아니다. 운영 legacy consumer/caller/in-flight/Scheduler/배포 버전은 미확인이다. AWS 호출·설정·배포·중지·삭제·재개를 수행하지 않았다. 운영자는 inventory 후 실제 가동 중인 경우에만 pause/drain하고 Phase 1 backend/Lambda를 조정 배포해야 한다. S3/DB purge나 legacy fake work API는 금지한다.
## 실행 기록
| 시점 | 명령/검증 | 관찰 결과 |
|---|---|---|
| 테스트 범위 변경 지시 전 | 기존 `AudioContentServiceTest`, `MemberLoginResponseContractTest`, `TokenProviderTest` focused baseline | 부모 실행은 kapt 단계 200초 제한 종료, 테스트 판정 없음 |
| 테스트 범위 변경 지시 전 | 구현 담당자의 격리 focused 시도 | `red-focused.log`: 41 cases 중 새 사례의 JWT key setup 1건 실패. 기능 RED로 인정하지 않음. 기존 테스트 실행은 이후 중단 |
| RED | 새 `SubtitleHttpIntegrationTest.shouldRejectActualNonBotLoginJwt` | `red-http.log`: 실제 로그인한 non-BOT의 자막 접근 차단 실패를 재현. 최종 GREEN 아님 |
| 1차 새 테스트/HTTP | 부모가 신규 `SubtitleHttpIntegrationTest` 직접 재실행 | **13 passed, failures/errors/skipped 0**. XML timestamp `2026-11-10T14:33:59`, `BUILD SUCCESSFUL in 2m 21s` |
| bootJar/변경 범위 lint | 구현 담당 실제 실행 및 부모 같은 코드 재확인 | `build-lint.log`: `BUILD SUCCESSFUL in 1m 28s`; 부모 재확인은 UP-TO-DATE |
| 리뷰 수정 RED | 신규 HTTP 2개 | 빈 body 422 기대/200 실제, DB 실패 500 기대/200 실제 확인 |
| 최종 새 테스트/빌드/lint | `P1-R1` 수정 후 신규 자막 클래스 전체 | **15 passed, failures/errors/skipped 0**, bootJar/변경 lint 실제 실행 성공, 7m44s |
| 부모 최종 HTTP 재검증 | 추가된 새 2개 메서드만 직접 실행 | **2 passed**, 422/500 및 전체 rollback 확인. XML timestamp `2026-11-10T15:34:43`, 4m55s |
| 독립 리뷰 | 1차 REQUEST_CHANGES 1건 수정 후 delta 재검토 | **REV-P1-001 ADDRESSED / 로컬 backend APPROVE**, 추가 blocker 없음 |
테스트 fixture는 로컬 합성 데이터와 H2, 실제 localhost HTTP, 외부 S3/서명 경계 대체를 사용한다. 실제 음성 또는 live S3 품질 검증이 아니다. LSP diagnostics 도구가 제공되지 않아 실제 Kotlin compile/lint 결과를 대체 증거로 기록했으며 LSP 성공을 주장하지 않는다.
실제 검증한 HTTP 결과: BOT 허용 및 non-BOT 403/익명 401, unknown 404, callback 수락/replay 200,
같은 eventId의 변경 body 422, terminal 다른 결과 409, 동시 receipt 경합 200/422 및 loser rollback.
기존 콘텐츠 상세에서 preview-only의 자막 null, 구매/소유자/무료 및 공개 시점별 ko/ja 선택을 검증했다.
동일 job 결과는 언어별 행 ID를 보존하며, 이전 job 완료가 최신 선택값을 바꾸지 않는 것을 확인했다.
실제 HTTP 증거는 테스트 JVM PID `23390`, Tomcat `127.0.0.1:59074`, XML timestamp
`2026-11-10T14:33:59`다. 실행 후 `ps -p 23390,20950,22207`, `lsof -iTCP:59074 -sTCP:LISTEN`,
작업 root를 대상으로 한 `pgrep`에서 남은 프로세스/listener가 없음을 확인했다. 임시 산출물은 리뷰 후 정리했다.
후속 HTTP 검증은 PID `44707`, Tomcat port `59977`에서 실행했다. 빈 callback/started는 422이고,
receipt 저장 실패는 `{"error":"Subtitle request failed"}`와 500을 반환하며 상태/오류/결과/receipt가 rollback된다.
구현 담당의 첫 GREEN은 컴파일 600초 제한으로 종료됐으나 캐시 재사용 후 성공했다. 실패 이력은 삭제하지 않았다.
후속 PID `44707`/`28638`/`37727`/`39291`, port `59977`, 작업 root 참조 프로세스의 부재도 확인했다.
## 내부 API와 저장 구조
| Method | Path |
|---|---|
| GET | `/internal/api/subtitle-jobs/resolve` |
| GET | `/internal/api/subtitle-jobs/{jobId}` |
| POST | `/internal/api/subtitle-jobs/{jobId}/started` |
| POST | `/internal/api/subtitle-jobs/callback` |
네 API는 기존 로그인 JWT와 활성 DB BOT role을 요구한다. 조회는 최소 DTO이며 callback은 원문 bytes hash를 사용한다.
`subtitle_job`, 언어별 `subtitle`, `subtitle_callback_receipt`와 `content.latest_subtitle_job_id`의
additive SQL만 작성했다. 실제 DB 적용은 하지 않았다. 미래 언어는 결과 행으로 저장할 수 있지만
현재 허용 언어는 ko/ja뿐이고 향후 Lambda·API·조회 정책 변경 없이 새 언어가 자동 활성화되지는 않는다.
공유 로그인/JWT/업로드/MediaConvert는 보존했다. 현재 저장소에는 삭제할 자막 recovery code가 없어서 삭제 파일은 0개다.
외부 프로젝트, AWS 리소스, 적용 migration 및 운영 데이터는 변경하지 않았다. commit/push도 수행하지 않았다.
전체 변경 경로·신규 case 이름·재현 명령·실패/수정 이력은 [implementation-report.md](implementation-report.md)에 있다.
로컬 mapping의 업로드 오류 시각 TIMESTAMP 불일치는 수정·대조했으나 운영 MySQL 실행 증거는 아니다.
테스트 전체 Spring context에서 기존 worker의 H2/MySQL 구문 차이 로그가 발생했다. 해당 worker 성공을 주장하지 않는다.
## 외부 차단 항목
| 항목 | 상태와 재개 조건 |
|---|---|
| 운영 DB | BLOCKED: 기존 schema 확인 및 additive DDL 승인·적용 필요 |
| old consumer coordination | BLOCKED: 원격 배포/사용/in-flight 조사와 운영자 조정 필요 |
| MediaConvert/CloudFront | BLOCKED: UUID ID 추출·출력 key 규칙 및 자막 private origin/서명 접근 확인 필요 |
| live ko/ja/mixed | BLOCKED: 승인된 실제 S3 입력부터 callback/콘텐츠/앱 표시 검증 필요. mixed는 실제 word/item 언어 evidence 필수 |
| 2시간 입력 목표 | BLOCKED: 실제 처리 시간/예산 미측정. 상한을 내리거나 SLA/지원 성공을 주장하지 않음 |
전체 테스트는 실행하지 않는다. 사용자가 별도로 전체 검증을 원하면 `./gradlew test`를 실행할 수 있다. 기존 회귀 미실행에 따른 잔여 위험은 새 기능 테스트 성공과 구분한다.
## 종료 정리와 재현
2026-11-11 리뷰 승인 후 이번 작업 소유 임시 root `subtitle-phase1.wiRcAJ`와 그 안의 build/cache/log/JAR를 제거했다.
`test ! -e` exit0으로 부재를 확인했고 공유 캐시·기존 worker는 변경하지 않았다. 위 임시 경로는 과거 검증 위치이며,
검증 결과와 실패 이력은 이 문서 및 구현/리뷰 보고서에 보존했다.
정상 개발 환경에서 이번에 작성한 테스트만 다시 실행하는 명령:
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest'
```
## 후속 완료: generate-url 자막 응답
2026-11-11 요청에 따라 `GET /audio-content/{id}/generate-url`에도 상세와 동일한 nullable `subtitle`을 추가했다.
기존 `contentUrl`과 활성 구매 검사/음원 URL 동작은 유지하며 자막만 요청 언어·완료 상태·공개 시점 조건을 따른다.
이번 후속에서 새로 작성한 HTTP3개만 실행했고 부모 재실행도 3/0/0/0으로 통과했다. 독립 리뷰 APPROVE다.
컴파일/bootJar/테스트 lint는 성공했으나 production lint는 시작 전부터 있던 서비스 EOF 공백1건을 지적해 보존했다.
이 결과를 기존15개를 포함한 클래스 전체 재실행 성공으로 표현하지 않는다.
상세 명령/선택자/응답 예시/잔여 게이트는 [generate-url-report.md](generate-url-report.md)에 있다.
## 커밋 검증 후속 기록
2026-11-11 별도 사용자 `/commit-with-validation` 요청으로 변경을 커밋했다.
기능 연동 커밋은 pre-commit `ktlintCheck`에서 위에 기록된 서비스 EOF 빈 줄 1건으로 처음 거부됐다.
해당 빈 줄만 제거한 뒤 재시도하여 staged Kotlin 대상 lint, `git diff --staged --check` 및
`work/scripts/check-commit-message-rules.sh`의 커밋 직전·직후 검증이 통과했다.
기존 EOF 미해결 기록은 당시 상태이며 이 커밋 준비 과정에서 해소됐다.
이번 커밋 작업에서는 기능 테스트·전체 테스트·bootJar를 재실행하지 않았다.
기존 테스트 결과는 위 실행 시점의 기록이며 운영 배포 BLOCKED는 유지한다.
사용자가 전체 테스트를 실행할 때의 명령은 `./gradlew test`다.
@@ -0,0 +1,101 @@
# generate-url 자막 후속 구현 검증
## 범위와 결과
- 기준: `prd.md` §7, `plan-task.md` `P2-T1`. Phase 1은 재구현하지 않았다.
- 이번 production 변경은 `content/GenerateUrlResponse.kt`의 기존 DTO import/nullable 필드 추가와 `content/AudioContentService.kt`의 `generateUrl` 본문뿐이다. DTO는 `GenerateUrlResponse(contentUrl: String, subtitle: ContentSubtitleResponse? = null)`이다.
- 기존 `orderRepository.isExistOrdered`를 그대로 사용한다. 구매 없는 소유자/무료, 비활성 KEEP, 만료 RENTAL은 빈 `contentUrl`과 명시적 null `subtitle`이다. 활성 구매의 기존 음원 경로/만료 기간/envelope는 유지했다.
- 구매자는 하나의 `expirationTime`을 음원 서명과 `SubtitleJobService.subtitle(audioContent, langContext.lang, expirationTime)`에 전달한다. 자막만 미지정/도래한 공개일 또는 소유자 조건을 적용한다. 예약 구매자의 기존 음원 URL은 유지한다.
- 기존 `SubtitleHttpIntegrationTest`의 15개 메서드/도우미를 보존하고 HTTP 테스트 3개와 세 테스트가 공용으로 사용하는 요청 도우미 1개만 추가했다. 다른 dirty 파일, PRD/plan/final-report, controller/DB/Lambda는 수정하지 않았다.
## 새 HTTP 테스트
클래스: `kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest`. 실행은 아래 세 메서드 selector만 사용했다.
1. `shouldIncludeRequestedSubtitleInGenerateUrl`: 활성 KEEP 구매/공개 완료 ko·ja, 정확한 `{contentUrl,subtitle}` 및 자막 3필드, 상세와 동일한 자막, 음원/SRT 경로와 같은 14,400,000ms 서명 인자, 기존 success envelope.
2. `shouldReturnNullSubtitleInGenerateUrlWhenUnavailable`: PENDING/최신 job 미등록/부분 완료 ko 누락/미지원 en의 명시적 null과 음원 URL 보존, 부분 완료 ja 정상 제공.
3. `shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions`: 미구매자·소유자·무료 및 비활성/만료 주문 차단, 활성 RENTAL 구매자의 예약 음원 URL 보존/자막 차단, 활성 구매 소유자의 예약 예외, 공개일 null/과거 허용, 익명 401.
실제 EMAIL 로그인/JWT, RANDOM_PORT Tomcat HTTP, H2와 기존 fixture/callback 도우미를 사용했다. 외부 S3/CloudFront 경계만 기존 mock을 재사용했다. 서명 URL은 전달 경로/만료 인자 검증이며 실제 CloudFront 암호학적 서명이나 다운로드 검증은 아니다.
## 명령과 증거
소유 root는 아래 `VERIFY_ROOT`다. build/project-cache/TMPDIR/Node/Bun 캐시를 격리했으며 init script는 production 소스를 필터링하지 않는다. 테스트 compile만 이 클래스와 필요한 support 3파일로 한정한다. `test.outputs.upToDateWhen { false }`로 실제 실행을 강제한다.
```bash
export VERIFY_ROOT=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/generate-url-subtitle.fSBkJM/implementer
export TMPDIR="$VERIFY_ROOT/tmp" NODE_COMPILE_CACHE="$VERIFY_ROOT/node-cache"
export npm_config_cache="$VERIFY_ROOT/node-cache" BUN_INSTALL_CACHE_DIR="$VERIFY_ROOT/bun-cache"
export AWS_EC2_METADATA_DISABLED=true
G=(./gradlew --offline --no-daemon --max-workers=2
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g
--project-cache-dir "$VERIFY_ROOT/project-cache"
--init-script "$VERIFY_ROOT/verification.init.gradle")
S=(--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldIncludeRequestedSubtitleInGenerateUrl'
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturnNullSubtitleInGenerateUrlWhenUnavailable'
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions')
# RED: production 변경 전
"${G[@]}" test "${S[@]}" > "$VERIFY_ROOT/red-assertions.log" 2>&1
# 첫 GREEN
"${G[@]}" test "${S[@]}" ktlintCheck bootJar > "$VERIFY_ROOT/green-build-lint.log" 2>&1
# 입력 3파일을 고정/확인한 production+test lint
"${G[@]}" verifyLintScope ktlintCheck bootJar > "$VERIFY_ROOT/final-verified-scope-lint-build.log" 2>&1
# 기존 dirty EOF 보존 후 최종 HTTP/컴파일/테스트 lint/bootJar
"${G[@]}" test "${S[@]}" verifyLintScope ktlintTestSourceSetCheck bootJar > "$VERIFY_ROOT/final-green-build-test-lint.log" 2>&1
```
| 검증 | 결과/증거 |
|---|---|
| RED | exit 1, 50초, 3 tests/3 failures/0 errors/0 skipped. 모두 `has("subtitle")`에서 expected true/actual false. `red-assertions.log`, `red-summary.log` |
| 첫 GREEN | exit 0, `BUILD SUCCESSFUL in 3m 35s`, HTTP 3개 통과 및 compile/bootJar. lint의 정확한 입력 증거는 이후 고정한 실행을 기준으로 한다. |
| 정확한 변경 lint | 입력은 production 2파일+이 테스트 1파일, Kotlin script 0개임을 `verifyLintScope` assertion으로 확인. production lint는 **기존 EOF 빈 줄 1건 실패**: `AudioContentService.kt:1482:1 Needless blank line(s)`. 그 외 지적 없음. `final-verified-scope-lint-build.log` |
| 최종 GREEN | exit 0, `BUILD SUCCESSFUL in 3m 30s`, compileKotlin/compileTestKotlin/bootJar 실제 실행, 테스트 lint 통과. HTTP XML 3 tests/0 failures/0 errors/0 skipped, timestamp `2026-11-10T22:07:38` (UTC). `final-green-build-test-lint.log`, `green-summary.log` |
최초 임시 init의 상대 source 경로로 `test NO-SOURCE`가 된 `red.log`는 RED 증거가 아니다. `project.file`로 수정 후 실제 assertion RED를 확인했다. `final-scope-lint-build.log`도 lint scope introspection assertion 실패 기록이며 HTTP 실패가 아니다. 이후 init에서 lint 입력 파일을 명시적으로 고정하고 검증했다. 이 이력과 로그를 삭제하지 않았다.
## 보존 사항과 남은 게이트
- 시작 전 `git diff`에도 있던 서비스 EOF 빈 줄은 타 dirty 변경 보존 지시에 따라 유지했다. `git diff --check` 역시 같은 EOF 1건을 지적하므로 lint 전체 clean을 주장하지 않는다. 테스트/새 DTO lint 오류는 없다.
- LSP 도구는 제공되지 않았다. Kotlin compile과 실제 3파일 ktlint 결과로 대체했다. 기존 Kotlin 경고와 테스트 종료 중 Redis reconnect 경고가 있으나 새 테스트 실패/오류는 없다.
- 기존 15개 테스트/기타 기존 테스트/전체 test/check/build는 실행하지 않았다. 사용자 전체 테스트 명령은 `./gradlew test`이며 이번 승인된 자동 검증 범위 밖이다.
- RED PID 14462/HTTP 50806, 첫 GREEN PID 15417/HTTP 50833, 최종 GREEN PID 16741/HTTP 50879는 테스트 종료로 정상 종료했다. 세 PID와 HTTP/Redis port 50806/50833/50879/50803/50831/50877에 `ps`/`lsof` 조회 결과가 없었다. 공유 프로세스를 종료하거나 task 취소를 호출하지 않았다. 부모 재검증을 위해 소유 root/init/log/JAR는 남겼다.
- AWS/외부 저장소/운영 DB/배포/live ko·ja·mixed/2h 처리/앱/실제 CloudFront 다운로드는 미검증이다. 부모 재검증과 독립 리뷰도 이 보고서의 완료 주장에 포함하지 않는다. credential/JWT/request body는 로그/보고서에 출력하지 않았으며 최종 XML과 로그의 관련 패턴 검색 결과도 없었다.
## 부모 재검증
- 같은 격리 init 및 정확히 새 메서드 3개 selector로 직접 재실행했다. `parent-verification.log`의 결과는 exit0,
`BUILD SUCCESSFUL in 37s`이며 test는 실제 실행, bootJar/테스트 lint는 동일 코드의 UP-TO-DATE 확인이다.
- XML: 3 tests/0 failures/0 errors/0 skipped, UTC timestamp `2026-11-10T22:12:51`. 실제 Tomcat PID17759, HTTP port50914다.
- 부모가 DTO·generateUrl의 전후 diff 및 추가 테스트 전체를 읽었다. 공유 자막 조회와 기존 구매 판정·음원 URL이 유지된다.
- 독립 리뷰와 임시 자원 정리는 아래 후속 기록으로 남긴다. 기존 production EOF lint1건은 성공으로 덮어쓰지 않는다.
## 독립 리뷰 및 사용 계약
- 제한 독립 리뷰 `ses_e387b1e8fffensjb6dnoRk82ST`: **APPROVE**. 이번 production delta의 명세·품질 blocker나 새 회귀 없음.
- `data.contentUrl`과 기존 envelope는 유지하며 `data.subtitle`만 추가된다. 요청 언어는 기존 `Accept-Language`/`LangContext`를 따른다.
- 자막 응답 예시:
```json
{
"contentUrl": "https://cdn.example.com/audio-signed-url",
"subtitle": {
"language": "ko-KR",
"format": "SRT",
"url": "https://cdn.example.com/subtitle-signed-url"
}
}
```
- 구매/공개 조건을 충족하지 못하거나 미완료·해당 언어 자막이 없으면 `subtitle`은 명시적 null이다. 기존 URL 권한을 확대하지 않는다.
- 부모 HTTP PID17759 및 자식 PID14462/15417/16741, port50914, 소유 root 참조 프로세스는 `ps`/`lsof`/`pgrep`에서 부재를 확인했다.
- 리뷰 승인/결과 문서 보존 후 작업 소유 root `generate-url-subtitle.fSBkJM`과 하위 build/cache/log/JAR를 제거했고 `test ! -e` exit0으로 확인했다. 위 임시 log/init 경로는 과거 검증 위치이며 공유 캐시는 건드리지 않았다.
일반 개발 환경에서 이번 후속 테스트만 실행하는 명령:
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldIncludeRequestedSubtitleInGenerateUrl' \
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturnNullSubtitleInGenerateUrlWhenUnavailable' \
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions'
```
@@ -0,0 +1,192 @@
# 자막 Phase 1 Kotlin 구현 보고
## 결과와 검증 경계
- 재생성을 제외한 기존 업로드 등록, 네 BOT 내부 API, callback 원자 저장, 기존 콘텐츠 상세의 권한별 ko/ja 자막 조회를 구현했다.
- 새로 작성한 `SubtitleHttpIntegrationTest` 13개는 실제 RANDOM_PORT localhost HTTP/H2에서 전부 통과했다. 외부 AWS 클라이언트와 서명 URL 생성만 대체하고, 회원 서비스·로그인·비밀번호 검증·JWT·DB principal/role은 실제 경로를 사용했다.
- `bootJar`와 변경 Kotlin 파일에 한정한 `ktlintCheck`가 성공했다. LSP 도구는 제공되지 않아 실행하지 않았으며 실제 Kotlin/JPA 생성 코드·테스트 컴파일과 lint로 대체했다.
- PRD/계획과 SQL/deployment 문서는 병렬 담당자 소유다. 이 담당자는 해당 문서를 수정하지 않았으며 전체 Phase gate/독립 리뷰 완료를 대신 선언하지 않는다.
## 구현 선택
1. `AudioContentService.createAudioContent` 두 overload와 AI 관리자 create orchestration은 `NOT_SUPPORTED`다. 필수 주입된 transaction manager의 `TransactionTemplate(REQUIRES_NEW)`가 content·tags·cover·source/job·latest pointer를 commit한 뒤 원본 S3 업로드를 시작한다. 업로드 후 content의 input 경로를 다시 대입하지 않으므로 조기 MediaConvert 완료 경로를 되돌리지 않는다. 성공 후 기존 언어 이벤트는 별도의 짧은 실제 쓰기 트랜잭션에서 발행한다.
2. 원본은 `input/{contentId}/{canonical UUID}.{safe actual extension}`이다. multipart filename의 경로는 버리고 영문/숫자 1~16자리 실제 확장자만 소문자로 보존한다. 예를 들어 `C:\client\dir/file.MP3`는 UUID `.mp3`로 등록된다. 확장자가 없거나 안전하게 보존할 수 없으면 UUID만 사용한다. 업로드 자체를 새로 거부하거나 `.mp3`로 허위 변경하지 않는다. 확장자는 형식 hint일 뿐 실제 오디오 검증 증거가 아니다.
3. `SubtitleJob.outputKey`는 확장자를 제외한 `output/{contentId}/{same UUID}` stem이다. 새 등록의 upload-complete는 이 정확한 stem과 `aac`/`m4a`/`mp3` 확장자 조합만 수락한다. 다른 콘텐츠, 다른 UUID, 추가 suffix, 임의 디렉터리, 비오디오 확장자는 거부한다. source가 없는 기존 콘텐츠의 동작은 기존 prefix 검증을 유지한다. 실제 consumer의 basename·ID 추출 호환성은 여전히 배포 전 확인 대상이다.
4. 언어 결과는 `Subtitle` 행에 저장한다. `jobId` scalar와 `unique(job_id, language)`, 실제 `language`·`format`·`s3Bucket`·`s3Key`를 보존한다. callback 허용 언어는 여전히 ko-KR/ja-JP이며 상태 규칙은 2/1/0개 그대로다. 미래 언어 활성화/새 provider framework는 추가하지 않았다.
5. callback은 원래 HTTP `ByteArray` SHA-256을 receipt와 함께 저장한다. `REQUIRES_NEW`/`READ_COMMITTED`에서 receipt를 먼저 조회하고 job lock 뒤 다시 조회한다. 기존 eventId로 unknown job을 보낸 변경 body도 422다. receipt는 JPA `Persistable.isNew=true`로 insert하여 merge가 경합한 다른 receipt를 update하지 못하게 했다. unique 경합은 rollback 종료 후 새 트랜잭션에서 receipt를 읽어 200/422를 판정한다. terminal의 동일 결과는 행 ID를 보존하고, 다른 결과는 409다.
## 변경 경로
### 신규 production
```text
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJobRepository.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleRepository.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceipt.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceiptRepository.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/application/SubtitleJobService.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleJobController.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleRequestParser.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleExceptionHandler.kt
```
### 기존 production 연결
```text
src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt
src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt
src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt
src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt
```
### 테스트와 보고
```text
src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt
docs/20261110_자막Phase1연동/implementation-report.md
```
- 기존 수동 `AudioContentService` 생성자는 필수 자막 서비스/transaction manager 인자를 반영했다.
- 기존 생성 통합 테스트의 성공 fixture는 등록 전에 실제 commit되도록 조정했다. audio 업로드/후속 이벤트 실패 테스트는 이미 commit된 등록을 보존한다는 새 승인 계약에 맞췄다. 실패 테스트를 삭제하거나 성공 assertion을 느슨하게 하지 않았다.
- 이 기존 테스트들은 후속 사용자 지시 이후 **실행하지 않았다**. 관련 기존 5개 클래스는 새 테스트 실행의 컴파일 입력에 포함했지만 실행 selector는 신규 자막 패키지뿐이다.
- production 파일 삭제, 기존 JWT/member/텍스트 TranslationJob worker 변경, 외부 프로젝트 변경, 신규 AWS SDK 의존성, 재생성 API/START 호출은 없다.
## 새 테스트 selector
전체 신규 클래스만 실행하는 selector:
```bash
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest'
```
개별 selector는 아래 클래스명 뒤에 각 메서드를 붙인다. 모두 이번 작업에서 새로 작성한 case다.
```text
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRejectActualNonBotLoginJwt
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldUseRealBotJwtAndStoreCallbackBeforeStartedWithExactByteReplay
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldStartMonotonicallyAndAcceptPartialAndFailedResults
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturn404WithoutDiscoveryAndCheckCurrentDatabaseRole
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRejectMalformedContractsWithoutPersistingAnything
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldSerializeConcurrentCallbacksAndRollbackLosingJob
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRollbackResultsAndStatusWhenReceiptInsertFails
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldKeepLatestPointerWhenOlderJobCompletes
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldCommitRegistrationBeforeUploadAndNeverRevertEarlyMediaConvertOutput
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldKeepAcceptedSourceAndTerminalResultWhenUploadReturnFails
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldPreserveOnlySafeActualSourceExtensionsWithoutClaimingConversion
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldExposeLanguageSubtitleOnlyWithReleasedOriginalAccessInExistingDetail
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReadOnlyPersistedLanguageOfMatchingLatestCompletedJob
```
## 재현 명령과 아티팩트
작업 전용 root는 삭제하지 않고 부모 검증에 인계한다.
```bash
ROOT=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/subtitle-phase1.wiRcAJ/backend.HX0j0B
export TMPDIR="$ROOT"
export NODE_COMPILE_CACHE="$ROOT/node"
export BUN_RUNTIME_TRANSPILER_CACHE_PATH="$ROOT/bun"
./gradlew --no-daemon --max-workers=2 \
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \
--project-cache-dir "$ROOT/gradle-cache" -I "$ROOT/isolation.gradle" \
test --tests 'kr.co.vividnext.sodalive.v2.subtitle.*'
./gradlew --no-daemon --max-workers=2 \
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \
--project-cache-dir "$ROOT/gradle-cache" -I "$ROOT/isolation.gradle" \
bootJar ktlintCheck
```
- 임시 init script는 build 출력과 Gradle project cache를 공유 출력에서 격리한다. 테스트 Kotlin 컴파일 입력은 신규 자막/직접 영향 기존 5개 클래스/support이며, lint filter도 변경 Kotlin 파일만 대상으로 한다. 전역 설정이나 저장소 build 설정은 변경하지 않았다.
- 성공 테스트: `$ROOT/green-new-tests-cached.log`, `BUILD SUCCESSFUL in 1m 32s`. 임시 script의 사용하지 않는 JWT 기본값을 제거한 뒤 다시 실행한 최종 `$ROOT/final-new-tests.log`도 `BUILD SUCCESSFUL in 2m 25s`다. XML: `$ROOT/build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.xml`, `tests=13`, `failures=0`, `errors=0`, `skipped=0`.
- 실제 두 job의 동일 eventId 경합에서 H2 SQLState `23505` unique 위반이 관찰됐고, 최종 HTTP 200/422와 loser job의 PENDING/결과 행 없음 assertion이 통과했다. 별도의 receipt 쓰기 실패 주입에서도 identity 생성으로 flush된 결과 행까지 rollback됨을 확인했다.
- 성공 빌드/lint: `$ROOT/build-lint.log`, `BUILD SUCCESSFUL in 1m 28s`. 실행 가능한 boot JAR은 `$ROOT/build/libs/sodalive-0.0.1-SNAPSHOT.jar`다. `jar tf`에서 새 컨트롤러/서비스/엔티티/repository/DTO가 `BOOT-INF/classes`에 포함된 것을 확인했다.
- 검증 완료 후 root를 참조하는 작업 소유 Gradle/HTTP/Redis 프로세스는 관찰되지 않았다. 기존 공유 worker PID `55393`과 그 Redis PID `55452`는 이 작업 소유가 아니며 종료하지 않았다. 테스트 실행 중 관찰된 작업 소유 executor PID `20950`과 최종 executor PID `22207`은 종료됐다.
## RED와 실패 이력
- 첫 baseline/신규 RED 명령은 테스트 컴파일 중 600초 제한으로 종료됐다(`red.log`). 테스트 결과가 아니며 RED로 인정하지 않았다.
- `red-focused.log`는 이전 지시에 따른 기존 3개 회귀 클래스와 신규 1개 case 실행이다. 총 41개 중 1개가 테스트 JWT key setup에서 실패했다. 이를 기능 RED로 인정하지 않았다. 이 명령은 기존 테스트 실행 금지라는 후속 사용자 지시 전의 기록이며 현재 구현의 회귀 성공 증거로 사용하지 않는다.
- 테스트 전용 `DynamicPropertySource`로 유효 길이의 비운영 JWT key를 지정한 뒤 `red-http.log`에서 실제 로그인 성공 후 `403 기대 / 404 실제`를 확인했다. BOT 전용 경로가 없던 동작의 실제 RED다.
- 초기 `red-binding.log`는 JPA unique constraint의 logical/physical column 이름 오류로 context가 뜨지 못했다. 이를 RED로 인정하지 않고 엔티티에 명시적 column 이름을 붙였다.
- `red-binding-assertions.log`에서 새 두 case가 실제 assertion으로 실패했다: source 확장자 보존 실패, 기존 eventId의 unknown job 변경에 대해 `422 기대 / 404 실제`. 확장자 전달과 receipt 선조회/명시적 isolation 변경 후 같은 두 case를 포함한 13개 전체 신규 case가 GREEN이다.
- `green-first.log`는 사용자 지시 갱신을 위한 중단으로 완료 결과가 없다. `green-new-tests.log`는 compile을 통과한 뒤 test 시작 시점에 600초 도구 제한으로 종료됐다. 공유 프로세스를 종료하지 않고 같은 격리 경로의 캐시를 재사용한 `green-new-tests-cached.log`에서 완료 결과를 얻었다.
- `format.log`의 첫 변경 파일 format 실행은 길이 위반을 보고했다. 해당 새 줄만 정리한 뒤 최종 `ktlintCheck`가 통과했다.
## 외부 게이트와 남은 미검증
1. 실제 MediaConvert consumer가 기존 contentId filename prefix를 읽는지, UUID basename·출력 확장자·디렉터리를 어떻게 구성하는지 운영 증거가 없다. 로컬에서는 정확한 UUID stem과 AAC/M4A/MP3 경로 검증만 확인했다. consumer의 운영 호환성을 확인하기 전 배포 전환은 BLOCKED다.
2. 부모가 승인된 ZIP에서 확인한 Transcribe 지원 확장자는 amr/flac/m4a/mp3/mp4/ogg/webm/wav다. Spring은 AAC/opus 등 기존 업로드를 임의로 거부하지 않는다. 이들 또는 누락/불안전 확장자에 대한 자막 성공은 보장하지 않으며 Lambda의 명시적 unsupported 결과/수동 확인 대상이다. 테스트의 한 바이트 파일은 source binding/transaction fixture일 뿐 실제 미디어 증거가 아니다.
3. S3 putObject 수락 후 getUrl 실패는 성공으로 숨기지 않는다. 원래 예외를 호출자에게 전파하고 `SOURCE_UPLOAD_UNCERTAIN` 및 초 정밀도 TIMESTAMP를 별도 기록한다. 이미 완료된 자막 상태/metadata와 content를 삭제하거나 회귀시키지 않는다. 실제 객체/MediaConvert 상태는 운영자 수동 확인이 필요하다.
4. 운영 MySQL DDL 적용, case-sensitive/NO PAD collation 및 FK/인덱스 검증, private CloudFront origin과 실제 SRT 서명 접근, 실서버 권한/앱 표시, live ko/ja/mixed 및 실제 duration/2시간 처리 예산은 미검증이다. AWS 호출·배포·DB 적용은 수행하지 않았다.
5. 전체 테스트와 후속 지시 이후의 기존 회귀 case 실행은 수행하지 않았다. H2 전체 Spring context의 기존 charge/translation scheduler는 MySQL `FOR UPDATE SKIP LOCKED` 구문 때문에 로그 오류를 남겼다. 이번 신규 case는 모두 통과했지만 그 worker 기능의 성공 증거는 아니다. 사용자가 별도로 전체 테스트를 원할 때만 `./gradlew test`를 실행한다.
## 20261111 P1 후속 수정: body/DB 실패의 HTTP 200 오인 방지
### 수정 범위와 계약
- `SubtitleExceptionHandler.kt`의 기존 `SubtitleJobController` 전용 scope와 우선순위를 유지했다. `HttpMessageNotReadableException`은 HTTP 422와 기존 일반화된 거절 body로, 그 외 예상하지 못한 `Exception`은 HTTP 500과 `{"error":"Subtitle request failed"}`로 응답한다.
- 기존 `ResponseStatusException`의 404/409/422 처리는 변경하지 않았다. 공통 `SodaExceptionHandler`, JWT, 컨트롤러, 서비스 및 DB 저장 로직은 변경하지 않았으며 예외 원문·요청·토큰 로그도 추가하지 않았다.
- 수정 파일은 자막 advice, `SubtitleHttpIntegrationTest.kt` 및 이 보고서의 추가 기록뿐이다. 기존 직접 서비스 롤백 테스트 `shouldRollbackResultsAndStatusWhenReceiptInsertFails`와 기존 assertion은 그대로 보존했다. 부모 소유 PRD/계획/final-report는 수정하지 않았다.
### 새 HTTP 두 사례
1. `shouldRejectEmptyCallbackAndStartedBodiesWithoutPersistingAnything`: 실제 BOT 로그인/JWT로 빈 callback·started body를 모두 전송해 422를 확인한다. job/receipt/결과 행 수, PENDING 상태, 갱신 시각 및 오류 필드가 변경되지 않고 해당 job 결과도 없음을 검증한다.
2. `shouldReturn500AndRollbackWhenReceiptInsertFailsOverHttp`: 실제 HTTP callback에서 기존 receipt repository SpyBean의 `saveAndFlush`에 DB 예외를 주입한다. ko-KR partial callback을 사용하여 상태뿐 아니라 오류 필드도 변경 대상에 포함하고, 일반화된 500 body 및 receipt·결과 부재, PENDING/갱신 시각/오류 필드 롤백을 DB 재조회로 확인한다.
### RED/GREEN 명령과 증거
위 격리 `ROOT`와 `TMPDIR`/Node/Bun cache 환경을 그대로 사용했다. 외부 의존성 다운로드를 막는 `--offline`과 부모의 `verification.gradle`을 추가하여 테스트만 강제로 다시 실행했다. `clean` 및 무필터 test/check/build는 실행하지 않았다.
advice 수정 전 RED와 수정 후 두 사례 GREEN에 사용한 동일 selector/명령:
```bash
./gradlew --offline --no-daemon --max-workers=2 \
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \
--project-cache-dir "$ROOT/gradle-cache" \
-I "$ROOT/isolation.gradle" -I "$ROOT/../verification.gradle" \
test \
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRejectEmptyCallbackAndStartedBodiesWithoutPersistingAnything' \
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturn500AndRollbackWhenReceiptInsertFailsOverHttp'
```
- RED: `$ROOT/p1-red-two-http.log`, `BUILD FAILED in 4m 16s`, 정확히 2개 실행/2개 assertion 실패다. `$ROOT/p1-red-assertions.log`에 XML의 실패 증거를 보존했다. 빈 body는 `expected [422, 422] / actual [200, 200]`, DB 실패는 `expected 500 / actual 200`이었다.
- 첫 GREEN 시도 `$ROOT/p1-green-two-http.log`는 `compileKotlin` 도중 600초 도구 제한으로 종료됐다. 테스트가 실행되지 않아 GREEN으로 계산하지 않았다. 작업 소유 프로세스가 남지 않은 것을 확인한 후 같은 캐시와 1200초 제한으로 재실행했다.
- 두 사례 GREEN: `$ROOT/p1-green-two-http-retry.log`, `BUILD SUCCESSFUL in 13m 29s`. `$ROOT/p1-green-two-assertions.log`는 `tests=2`, `failures=0`, `errors=0`, `skipped=0`이다.
이후 자막 신규 클래스 15개 전체와 기존 격리 script의 변경 Kotlin 파일 lint 및 boot JAR를 함께 검증했다:
```bash
./gradlew --offline --no-daemon --max-workers=2 \
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \
--project-cache-dir "$ROOT/gradle-cache" \
-I "$ROOT/isolation.gradle" -I "$ROOT/../verification.gradle" \
test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest' \
bootJar ktlintCheck
```
- 최종 `$ROOT/p1-green-15-build-lint.log`: `BUILD SUCCESSFUL in 7m 44s`. production/test 컴파일은 `UP-TO-DATE`였으며 test, bootJar, main/test 변경 파일 lint는 실제 실행됐다.
- 최종 XML `$ROOT/build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.xml`: `tests=15`, `failures=0`, `errors=0`, `skipped=0`. 실제 RANDOM_PORT HTTP에서 새 422/500과 기존 404/409/422 및 직접 서비스 롤백 case가 모두 통과했다. 기존 회귀 클래스는 컴파일 입력일 뿐 실행하지 않았다.
- `$ROOT/build/libs/sodalive-0.0.1-SNAPSHOT.jar`에 수정된 `SubtitleExceptionHandler.class`가 포함됨을 `jar tf`로 확인했다. LSP 도구는 제공되지 않아 사용하지 못했으며 Kotlin 컴파일과 변경 파일 lint를 확인했다.
- RED/GREEN/최종 테스트 executor PID `28638`/`37727`/`39291`은 모두 종료됐다(`$ROOT/p1-test-process-status.log`는 header만 존재). 완료 후 작업 root를 사용하는 Gradle/HTTP/Redis 프로세스는 관찰되지 않았다. 기존 공유 PID `55393`/`55452`는 그대로이며 어떤 task도 취소하거나 프로세스를 수동 종료하지 않았다. root와 로그는 부모 재검증용으로 보존했다.
### 남은 검증 경계
- 기존 Kotlin 경고, Gradle deprecation, H2의 기존 charge/translation scheduler SQL 오류와 Redis 종료 시 재접속 경고는 관찰됐으나 수정 범위 밖이다. 신규 자막 15개 assertion은 전부 통과했다.
- 기존 회귀/전체 테스트, 운영 MySQL, AWS·MediaConvert·Lambda 실연동 및 배포 검증은 수행하지 않았다. 앞 절의 외부 게이트는 그대로 남으며, 전체 테스트 사용자 실행 명령은 `./gradlew test`다.
## 부모 최종 검증·정리 추가 기록
- 부모가 처음 신규13개 및 P1의 두 HTTP 사례를 각각 직접 재실행해 모두 통과했고, 독립 리뷰의 유일 finding은 ADDRESSED 및 로컬 backend APPROVE 판정을 받았다.
- 부모의 P1 재실행은 `BUILD SUCCESSFUL in 4m 55s`, 실제 XML2/0/0/0, timestamp `2026-11-10T15:34:43`이다.
- 2026-11-11 작업 소유 PID/listener 부재를 확인한 후 위 임시 ROOT 전체를 제거했다. 따라서 ROOT 기반 로그·init script·JAR 경로는 과거 검증 위치이며 현재 파일로 남아 있지 않다. 명령/결과/실패 이력은 본 보고서와 final-report에 보존했다.
- 일반 개발 환경의 재현 명령은 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest'`다. 기존 테스트는 실행하지 않는 selector다.
@@ -152,6 +152,8 @@
## Progress
- 2026-11-11 별도 사용자 `/commit-with-validation` 요청으로 커밋 진행. 기능 연동 커밋의 pre-commit `ktlintCheck`가 기존 `AudioContentService.kt` EOF 빈 줄 1건으로 실패하여 해당 빈 줄만 제거했다. 재시도에서 staged Kotlin 대상 lint와 `git diff --staged --check`, 커밋 직전·직후 메시지 검증이 통과했다. 이번 커밋 작업에서는 기능/전체 테스트 및 bootJar를 재실행하지 않았으며 기존 실행 이력과 외부 배포 BLOCKED는 유지한다. 전체 테스트 사용자 명령은 `./gradlew test`다.
- 2026-11-11 `P2-GATE` 종료: 부모 직접 HTTP3개 재실행 성공과 독립 승인 후 소유 root `generate-url-subtitle.fSBkJM`의 디렉터리/symlink 여부를 확인해 제거했다. `test ! -e` exit0이며 공유 캐시/다른 dirty 파일은 건드리지 않았다. 원래 EOF lint1건은 별도 미해결 관찰로 남긴다. 검증 로그 경로는 과거 위치이며 결과/명령/실패 이력은 보고서에 보존했다.
- 2026-11-11 `P2-T1` 독립 리뷰 APPROVE: 기존 contentUrl/envelope/활성 구매 정책과 서명 만료 인자 유지, 자막만 공개 시점·언어·최신 결과 조건 적용, 새 HTTP3개 증거를 확인했다. 이번 delta blocker/새 회귀 없음. production EOF 공백은 작업 시작 전 dirty 상태의 잔여사항이며 전체 lint clean을 주장하지 않는다.
@@ -0,0 +1,47 @@
# 자막 Phase 1 Backend Gate 리뷰
## 1. 리뷰 정보
- 일자: 2026-11-10
- 대상: `test` working tree의 신규 `v2.subtitle` 및 기존 업로드/상세/보안 연결
- 기준: [PRD](../prd.md), [계획](../plan-task.md), [구현 보고](../implementation-report.md)
- 리뷰어: 독립 Oracle (`ses_e3a1e039bffeXbBb7Nx0V7Hvk8`)
- 상태: 회귀 수정 검증 완료, 로컬 backend APPROVE
- 검토 제외: AWS/운영 DB/실음성/2시간 성능, 기존 전체 회귀 실행
## 2. 근거와 판정
부모 직접 실행의 실제 localhost HTTP 신규 테스트 13개가 0 failure/error/skip으로 통과했다.
reviewer는 코드·신규 테스트·실행 로그·DDL을 읽었고 테스트를 재실행하지 않았다.
저장/권한/언어별 결과/업로드/스키마는 부합 판정이나 HTTP 오류 경계 한 건으로 REQUEST_CHANGES다.
| ID | 심각도 | 상태 | 내용 | 수정 Goal |
|---|---|---|---|---|
| REV-P1-001 | High | 수정 완료 / ADDRESSED | 자막 빈 body/저장 실패가 공통 advice에서 HTTP200으로 변환 | P1-R1 |
## 3. REV-P1-001
- `SubtitleExceptionHandler.kt`는 ResponseStatusException만 처리한다.
- 기존 `common/SodaExceptionHandler.kt:73`과 `:94`는 DB 예외/일반 예외에 HTTP 상태 없이 ApiResponse.error를 반환한다.
- 필수 body가 비면 strict parser 이전에 HttpMessageNotReadableException이 발생한다.
- receipt 실패의 기존 새 테스트는 서비스 직접 호출 rollback만 검증하므로 HTTP200 문제를 포착하지 못했다.
- 영향: 결과가 저장되지 않았어도 Lambda가 HTTP 성공으로 해석할 수 있다.
최소 수정은 자막 controller에 한정된 advice에서 body 읽기 오류를 422, 예상하지 못한 서버 오류를
민감정보 없는 500으로 반환하는 것이다. 기존 404/409/422 및 공유 API 동작을 보존한다.
## 4. 수정 검증 조건
1. 빈 callback/started 요청이 200이 되는 RED를 실제 HTTP로 확인한다.
2. receipt 실패 callback이 200이 되는 RED와 전체 DB rollback을 확인한다.
3. 수정 후 422/500 및 무부분반영을 검증하고 이번 작성 테스트만 재실행한다.
4. 같은 reviewer가 수정 delta와 새 증거를 재검토한다.
## 5. 판정 기록
- 1차: REQUEST_CHANGES 1건. 부모가 공통 handler 코드를 직접 확인하고 `P1-R1`에 반영했다.
- 2026-11-11 수정 검증: 전용 advice만 수정하고 신규 HTTP 두 사례의 RED/GREEN을 확인했다. 이후 신규 자막15개·bootJar·변경 lint가 통과했고, 부모 직접 두 HTTP 사례 재실행도 2/0/0/0으로 통과했다. 기존 공통 handler와 직접 서비스 rollback 테스트는 보존했다. reviewer session continuation 유실로 기존 판정과 delta만 전달한 단일 재검토를 진행 중이다.
- 운영 배포는 코드 리뷰와 별개이며 [배포 게이트](../deployment-gates.md)의 차단 상태를 유지한다.
- 최종 제한 delta 리뷰 (`ses_e39e64c42ffeCOGUZjgeM4P1jf`): **REV-P1-001 ADDRESSED / 로컬 backend APPROVE**.
전용 advice의 422/500, 기존 404/409/422 유지, 실제 HTTP 및 rollback RED/GREEN 증거를 확인했다.
수정 delta에서 신규 버그/blocker는 발견하지 못했다. 추가 코드 수정 불필요 판정이며 전체 리뷰 반복이나 기존 테스트 실행은 하지 않았다.
@@ -0,0 +1,16 @@
# generate-url 자막 응답 제한 리뷰
- 일자: 2026-11-11
- 범위: `P2-T1`, `GenerateUrlResponse`와 `AudioContentService.generateUrl`, 새 HTTP 테스트3개.
- 기준: PRD §7, plan-task P2, 실제 메서드/DTO 전후 diff 및 `generate-url-report.md`의 RED/GREEN 증거.
- 리뷰어: `ses_e387b1e8fffensjb6dnoRk82ST`, read-only. 기존 Phase1 전체는 다시 리뷰하지 않았다.
- 판정: **APPROVE**, 이번 범위의 확정 finding/blocker/새 회귀 없음.
확인한 기준:
1. contentUrl과 ApiResponse envelope 유지, 공유 nullable subtitle만 additive 추가.
2. 활성 구매(KEEP/유효 RENTAL) 정책 유지, 소유자·무료만으로 우회하지 않음.
3. 음원 URL은 기존대로이며 자막에만 상세와 동일한 공개일/소유자 조건 적용.
4. 동일 expirationTime 전달 및 공유 latest/언어/상태별 자막 조회 재사용.
5. 새 HTTP3개 RED → GREEN 및 부모 재실행3/0/0/0, 실제 localhost HTTP/JWT/H2 증거 확인.
잔여사항: production lint의 기존 서비스 EOF 빈 줄1건은 보존했다. 기존 테스트/전체 테스트/LSP 및 실제 AWS 서명·다운로드/운영 배포는 이번 검증이 아니다. 수정 Task로 전환할 새로운 발견 사항은 없다.