docs(event): 일반 이벤트 상세 구현 검증과 리뷰를 기록한다

This commit is contained in:
2026-10-29 11:03:29 +09:00
parent 3a4fff5bac
commit 948c032e30
2 changed files with 214 additions and 0 deletions
@@ -0,0 +1,152 @@
# 일반용 이벤트 상세 조회 구현 계획
**Goal:** 일반 사용자가 기존 노출 정책을 통과한 이벤트 한 건을 v2 API로 조회한다.
**Architecture:** 기존 EventRepository의 ID 조회와 EventItem을 재사용한다. 신규 controller와 read-only service는 v2 아래 배치하고 기존 controller/service는 변경하지 않는다.
**Tech Stack:** Kotlin 1.6.21, Java 17, Spring Boot 2.7.14, JPA, JUnit 5, H2.
**Spec:** [prd.md](prd.md)
**실행:** subagent-driven-development; 커밋/푸시는 별도 승인 없이 하지 않는다.
## 현재 상태
- 상태: 구현 및 검증 완료 (운영 배포 제외)
- 현재 Phase: 1
- 활성/다음 Goal: 없음 (P1-T1, P1-GATE 완료)
- 순서: P1-T1 → P1-GATE. 동시에 하나만 실행한다.
## Global Constraints
- 승인된 GET `/api/v2/events/{id}`만 추가. 관리자 상세/legacy 변경/DB·의존성 변경 제외.
- 기존 성인/언어 정책 및 이미지 변환을 그대로 유지한다.
- 모든 신규 API/하위 구현은 v2 패키지에 둔다. 불필요한 추상화/port는 만들지 않는다.
- 기존 테스트 삭제·완화, 비밀값 기록 금지. 소유 임시 자원만 정리한다.
## Review Focus
- 인증된 성인에게 `isAdult=false` 이벤트를 추가로 노출하지 않는지 확인한다.
- 관리자도 언어·기간·활성 조건을 지키는지 확인한다.
- 익명 허용이 쓰기/collection/추가 하위 경로로 확장되지 않는지 확인한다.
- null 이미지, 외부 HTTPS 이미지, 외부 link를 변경하거나 관리 엔티티를 수정하지 않는지 확인한다.
- 미존재와 비노출 오류가 동일하고 상세 정보를 누출하지 않는지 확인한다.
### Phase 1: v2 단건 조회
- [x] **Task 1.1: 일반 이벤트 상세 조회와 보안 회귀 검증**
**Goal P1-T1:** 기존 목록과 같은 노출 정책을 적용한 단일 EventItem을 반환한다.
- 시작 조건: PRD 확정, 기존 event/admin focused test 성공.
- 완료 증거: RED/GREEN 기록, S1~S3 테스트 성공, 실제 HTTP 응답 증거.
- 범위 밖: 전체 빌드/독립 리뷰는 P1-GATE; legacy 리팩터링과 추가 기능.
**Files:**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/adapter/in/web/EventDetailController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/event/EventDetailControllerIntegrationTest.kt`
- 필요한 서비스 경계 테스트는 같은 v2/api/event 패키지에 통합 테스트로 추가한다.
**Interfaces:**
- Consumes: `EventRepository`, `AuthRepository.getAuthIdByMemberId`, `MemberContentPreferenceService.resolveCountryCode`, `EventItem`.
- Produces: `EventDetailService.getEvent(id: Long, member: Member?): EventItem`.
- Controller: `/api/v2/events`, `@GetMapping("/{id}")`, nullable `@AuthenticationPrincipal`.
- Security: GET `/api/v2/events/*`만 permitAll; `/**` 금지.
- [x] **RED:** 먼저 실제 HTTP 정상 요청 테스트를 작성하고, 조회 불가·성인 3상태·언어·관리자·응답 변환·인접 보안 시나리오를 추가한다.
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --console=plain`에서 미구현 route/security로 정상 요청이 실패함을 기록한다.
- [x] **GREEN:** ID 단건 로드, 양수 검사, 활성/현재 시각/언어/성인 필터, EventItem 변환, 좁은 보안 허용을 구현한다.
- [x] **GREEN 확인:** 동일 focused test로 모든 시나리오 통과를 확인한다.
- [x] **REFACTOR:** 새 중복만 필요 시 정리하고 기존 event/admin 회귀를 다시 실행한다.
**검증 세부:**
- 실제 DB에 저장한 KO/JA 및 nullable adult 필드를 비교한다. 인증 일반 회원의 auth 기록은 실제 DB로 준비한다.
- `isAdult` null/false/true 각각을 익명·미인증·인증·관리자에 대해 기존 `/event`와 비교한다.
- 미래/종료 테스트는 현재 시각에서 충분히 떨어진 값을 사용해 flake를 피한다. 기간 조건의 양끝 포함은 코드 및 가능한 기존 seam으로 검증한다.
- 이미지/링크 null, 상대 경로, HTTPS, 기존 http 문자열 보정 및 모든 응답 필드를 확인한다.
- missing/zero/negative/nonnumeric/overflow ID, non-GET, 하위 추가 경로, legacy 목록/팝업/admin 접근을 검증한다.
- RANDOM_PORT/TestRestTemplate의 상태/본문을 test output으로 남긴다. 인증은 동일 컨텍스트 MockMvc로도 검증 가능하다.
### Phase 1 Gate
- [x] **Goal P1-GATE: 동작·회귀·빌드·리뷰·정리 완료**
- 시작 조건: P1-T1 완료.
- 완료 증거: 아래 명령과 HTTP 증거, 독립 reviewer 승인, 자원 정리 확인.
- 범위 밖: 기존 전체 테스트 실패/린트 위반의 무관한 수정.
- TDD 예외 사유: 읽기 전용 완료 검증이며 테스트 추가가 아닌 실행·리뷰 단계다.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' --console=plain
./gradlew test --console=plain
./gradlew bootJar ktlintCheck --console=plain
./gradlew tasks --all --console=plain
```
- LSP 도구가 노출되어 있으면 변경 파일 진단, 없으면 Kotlin compile/ktlint 결과와 미실행 사실을 기록한다.
- 사용자 ulw 지시 및 보안 matcher 추가로 전체 테스트를 실행한다. 실패가 기존 상태에서도 재현되면 별도 제한으로 기록한다.
- 고위험 reviewer 1명이 목표·S1~S3·diff·실행 증거를 확인한다. 범위 내 blocker만 수정한다.
- 소유 임시 폴더/QA 프로세스가 정리되었음을 확인한다. 공유 Gradle 캐시는 삭제하지 않는다.
- 후속 사용자 요청의 전달물: `api-client-prompt.md`에 구현된 요청/응답/오류/노출 정책을 기록하고 최종 응답에도 프롬프트로 제공한다. 문서 작업의 TDD는 해당 없으며 실제 HTTP 응답 및 코드와 대조한다.
## Progress
### 사전 검증 — 2026-10-28
- worktree: clean.
- `./gradlew test --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' --console=plain`: BUILD SUCCESSFUL, 1m 1s. 구현 전 기존 목록/팝업/관리자 테스트 기준선 확보.
- 부모 세션에는 lsp_diagnostics 도구가 노출되지 않았다. 진단 성공으로 보고하지 않는다.
### P1-T1 RED — 2026-10-28
- 새 production 타입을 참조하지 않는 RANDOM_PORT/TestRestTemplate + 같은 컨텍스트 MockMvc 통합 테스트를 먼저 작성했다. S3 외 노출 정책·repository·인증 기록은 mock 없이 실제 H2로 검증한다.
- 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`.
- 첫 실행은 도구의 120초 제한으로 test compile 중 중단되어 RED 근거로 사용하지 않았다. 같은 명령을 600초 제한으로 재실행: BUILD FAILED, 8m 59s, 28 tests completed / 15 failed / 0 errors. compile 성공 후 실제 HTTP assertion에서 expected 200 OK / actual 401 UNAUTHORIZED로 실패했다. 기존 보호 경로 13건은 통과했다.
- 정상 요청 RED 원문: `HTTP GET /api/v2/events/19 country=null -> 401 {"timestamp":"2026-10-28T09:03:37.443+00:00","status":401,"error":"Unauthorized","path":"/api/v2/events/19"}`. `shouldReturnAnonymousEventDetailWithLegacyFieldsWithoutMutatingTheEntity`의 HTTP 상태 assertion 실패이며 missing-class compile 실패가 아니다.
- RED 원본은 소유 임시 폴더의 `red-retry.log` 및 당시 `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.xml`에서 확인했다. XML은 다음 실행으로 갱신되므로 핵심 결과와 응답을 이 기록에 보존한다.
- 실행 자원: `--no-daemon`과 in-process Kotlin compile을 사용했다. 소유 임시 root는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/event-detail-p1-t1.xH3EUF`이며 작업 종료 시 삭제하고 공유 캐시/다른 세션 프로세스는 건드리지 않는다.
### P1-T1 GREEN 및 인접 회귀 — 2026-10-28
- 변경 파일은 신규 `EventDetailController.kt`, `EventDetailService.kt`, 신규 `EventDetailControllerIntegrationTest.kt`, `SecurityConfig.kt`의 GET matcher 한 줄, 이 계획 문서뿐이다. 기존 엔티티/서비스/컨트롤러와 PRD는 변경하지 않았다.
- `EventRepository.findByIdOrNull`로 단건을 로드하고 read-only 서비스에서 기존 목록과 동일한 정책을 확인한다. 전체 목록 조회, 새 port/DTO/의존성, 별도 parser/exception handler는 추가하지 않았다. 관리자 우회는 성인 조건에만 적용한다.
- focused GREEN 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process` → BUILD SUCCESSFUL, 8m 26s, 28 tests / 0 failures / 0 errors / 0 skipped.
- 인접 회귀 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process` → BUILD SUCCESSFUL, 3m 45s. XML 합계 46 tests / 0 failures / 0 errors / 0 skipped: 신규 28, 기존 EventController 5 / EventRepository 5 / EventService 4 / AdminEventBannerController 4.
- S1: RANDOM_PORT 실제 HTTP로 익명 KO/JP/기타 국가/헤더 누락, 5개 필드 전체, nullable 이미지·link, HTTPS 보존, 상대/http 이미지 CDN 접두사, 외부 link 보존을 확인했다. `/event` 항목과 상세를 완전 비교하고 DB 재조회로 이미지 필드가 변하지 않음을 확인했다.
- S2: 실제 H2 auth 기록이 있는/없는 일반 회원과 관리자는 같은 컨텍스트 MockMvc로 검증했다. KO/JA × adult null/false/true × 익명/미인증/인증/관리자 24조합의 상세와 기존 목록을 비교했다. 인증 성인은 false를 제외하고 관리자는 false/true/null을 허용한다. 비활성/미래/종료/언어 불일치는 관리자에게도 미존재와 동일한 실패 응답이다. 양수 검사·미존재는 HTTP 200 invalid_request 및 실제 SodaException messageKey, malformed/overflow는 기존 MethodArgumentTypeMismatchException과 공통 unknown 응답을 확인했다.
- 기간은 현재 시각에서 하루 이상 떨어진 값으로 테스트했다. 정확히 같은 시각의 포함 경계는 기존 주입 seam이 없어 시계를 새로 추상화하지 않고 코드 비교로 확인했다: legacy `startDate.loe(now)` / `endDate.goe(now)`, 신규는 `startDate > now` / `endDate < now`만 거부하여 양끝을 포함한다.
- S3: 실제 HTTP 보안 13건에서 익명 POST/PUT/DELETE, collection, 추가 하위 경로, legacy 팝업/쓰기, 관리자 목록/단건 경로 모두 401을 확인했다. 익명 기존 `/event` 목록은 계속 200이며 기존 목록/팝업/관리자 회귀도 통과했다.
- 검증 중 제한: 첫 GREEN은 테스트 소스 compile 중 600초 도구 제한, 첫 인접 회귀는 180초 제한으로 종료되어 통과 근거로 사용하지 않았다. 제한을 늘려 재실행했다. 중간 GREEN의 2건 실패는 MockMvc `contentAsString`의 기본 문자셋으로 한글을 잘못 해석한 테스트 문제였다. JSON은 원본 bytes로 파싱하고 로그만 명시적 UTF-8로 읽도록 수정했으며 기대값/정책 assertion은 완화하지 않았다.
- 진단: 이 세션에도 `lsp_diagnostics` 도구가 없어 미실행. Gradle의 production/test Kotlin compile은 성공했다. 변경 파일에서 compiler 경고는 없었고 기존 다른 파일의 불필요한 `!!`/unchecked cast/미사용 인자 및 Gradle deprecated 경고는 수정하지 않았다. 전체 suite, bootJar, ktlintCheck, tasks, 독립 리뷰는 부모 담당 P1-GATE로 남겨 실행하지 않았다.
- HTTP 원본 artifact: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.xml`의 `system-out`에 상태/본문을 기록했다. HTML은 `build/reports/tests/test/classes/kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.html`. 다음 Gradle test가 덮어쓸 수 있어 핵심 응답을 아래 보존한다.
```text
HTTP GET /api/v2/events/22 country=null -> 200 {"success":true,"message":null,"data":{"id":22,"thumbnailImageUrl":"https://cdn.test/thumbnail.png","detailImageUrl":null,"popupImageUrl":null,"link":"https://external.test/promotion?from=soda&target=event#detail"},"errorProperty":null}
HTTP GET /api/v2/events/17 country=JP -> 200 {"success":true,"message":null,"data":{"id":17,"thumbnailImageUrl":"https://cdn.test/thumbnail.png","detailImageUrl":"https://cdn.test/detail.png","popupImageUrl":"https://cdn.test/popup.png","link":"https://external.test/event"},"errorProperty":null}
HTTP GET /api/v2/events/0 country=null -> 200 {"success":false,"message":"잘못된 요청입니다.","data":null,"errorProperty":null}
HTTP GET /api/v2/events/1/extra -> 401 {"timestamp":"2026-10-28T09:37:20.055+00:00","status":401,"error":"Unauthorized","path":"/api/v2/events/1/extra"}
HTTP GET /event/popup -> 401 {"timestamp":"2026-10-28T09:37:20.121+00:00","status":401,"error":"Unauthorized","path":"/event/popup"}
```
### P1-T1 Teardown Receipt — 2026-10-28
- 마지막 테스트 executor PID 18510 종료와 소유 root에 열린 파일 없음(`lsof -nP +D`)을 확인했다. mktemp root 소유 표식을 읽고 허용 parent의 canonical path·직접 하위 이름·비심볼릭링크 조건을 확인한 뒤 이 세션의 `event-detail-p1-t1.xH3EUF`만 삭제했다. macOS `/var`의 canonical path가 `/private/var`여서 첫 경로 가드는 삭제를 거부했고, 양쪽 parent를 동일하게 정규화한 후 삭제했다.
- 실제 반환: `TEARDOWN OK: owned event-detail-p1-t1.xH3EUF removed; test executor exited; no open files; shared caches untouched`. RED/GREEN의 핵심 결과와 HTTP 응답은 위에 보존했으며 Gradle XML/HTML은 worktree build 폴더에 남아 있다.
- P1-T1만 완료했다. P1-GATE 체크박스·기준·상단 실행 상태는 부모 세션 소유로 유지하며 커밋/푸시/worktree/다른 에이전트/전체 suite·build·review는 실행하지 않았다.
### P1-GATE 부모 검증 — 2026-10-28
- 부모가 새 controller/service와 전체 통합 테스트, SecurityConfig의 GET matcher 한 줄을 직접 검토했다. 기존 API 변경은 없다.
- `./gradlew bootJar --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: BUILD SUCCESSFUL, 1m 9s.
- 최초 ktlintCheck는 새 테스트 CsvSource의 줄바꿈 3건을 지적했다. 데이터/기대값은 유지하고 각 인자를 별도 줄로 배치했다.
- `./gradlew ktlintCheck --console=plain --no-daemon`: BUILD SUCCESSFUL, 1m 33s. 전체 Kotlin main/test/script 검사 통과.
- 첫 전체 테스트는 사용자 중단으로 끝났으며 성공 근거에서 제외했다. 재개 후 `./gradlew test --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: BUILD SUCCESSFUL, 19m 29s.
- 최종 XML 집계: 326 suites, 2,584 tests, failures=0, errors=0, skipped=0. 새 EventDetailControllerIntegrationTest 28건과 기존 event/admin 테스트도 포함한다.
- 실제 HTTP 표면은 RANDOM_PORT의 TestRestTemplate 호출이다. 부모 전체 테스트 실행에서도 성공/비노출/인접 보안 응답을 확인했다. 인증 사용자 행렬은 실제 DB fixture와 MockMvc로 검증했다.
- 최종 HTTP artifact: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.xml`, timestamp `2026-10-28T10:19:56`.
- 대표 응답: `/api/v2/events/22` → HTTP 200, success=true, id=22, thumbnailImageUrl=https://cdn.test/thumbnail.png, detailImageUrl=null, popupImageUrl=null, 외부 link 보존. `/api/v2/events/0` → HTTP 200, success=false, data=null. `/event/popup` 익명 → HTTP 401.
- `./gradlew tasks --all --console=plain`: BUILD SUCCESSFUL. 문서의 Gradle 명령 존재 확인.
- 이전 리뷰 작업은 완료 알림이 있었으나 중단 후 background_output/session_read 및 정확한 로컬 session ID 조회로 결과를 회수하지 못했다. 이를 승인으로 간주하지 않고 동일 범위의 대체 리뷰 하나만 요청했다.
- `lsp_diagnostics`는 부모/구현 세션 모두 도구 미제공으로 미실행. Kotlin 컴파일·ktlint 성공을 대체 증거로 기록한다. 운영 배포와 운영 DB 호출은 수행하지 않았다.
### P1-GATE 최종 판정 및 전달 — 2026-10-28
- `./gradlew bootJar ktlintCheck tasks --all --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: 최종 재확인 BUILD SUCCESSFUL, 17s. `git diff --check`도 통과했다.
- 독립 대체 리뷰: [reviews/event-detail-review.md](reviews/event-detail-review.md), APPROVE, 확정 발견 사항 없음. 부모가 리뷰 파일을 읽고 실제 코드·HTTP·전체 테스트 근거와 대조했다. 리뷰 환경은 임시 자원/프로세스 생성 0건이다.
- 클라이언트용 [api-client-prompt.md](api-client-prompt.md)를 실제 endpoint, 5개 응답 필드, nullable 값, HTTP 200 실패 envelope, JWT/언어·국가 처리와 대조했다. 기존 API와 관리자 범위는 그대로다.
- 부모 최종 `pgrep`에서 GradleWrapperMain/Gradle Test Executor/소유 root 관련 프로세스가 없었다. 부모 root는 빈 opencode 폴더만 남아 `rmdir`로 제거했다. 소유 notepad와 구현 root도 부재를 확인했다.
- 정리 read-back: `TEARDOWN OK: parent root, implementation root and notepad absent; shared caches untouched`. 공유 캐시나 다른 세션의 파일/프로세스는 삭제하지 않았다.
- 완료 범위: 승인 API·문서·시나리오 S1~S3·전체 테스트·빌드·린트·리뷰·소유 자원 정리. 제한: LSP 미제공으로 미실행, 기간 동일 시각 경계는 코드 대조, 인증 viewer는 MockMvc 검증. 운영 배포·운영 DB 검증·커밋·푸시는 하지 않았다.
### 커밋 전 재검증 — 2026-10-29
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' bootJar ktlintCheck --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: BUILD SUCCESSFUL, 3m 55s. 테스트 5 suites / 46 tests / failures=0 / errors=0 / skipped=0. bootJar·ktlintCheck는 변경 없음으로 UP-TO-DATE다.
- `git diff --check` 및 구현·테스트의 `git diff --staged --check` 통과. 커밋 대상의 비밀값·credential artifact 포함 여부를 확인했다.
- 전체 회귀는 P1-GATE의 2,584건 성공 이후 구현 변경이 없어 재실행하지 않았다. 대신 신규 상세·기존 이벤트·관리자 배너 회귀를 실행했다.
## 종료 기준
승인 endpoint의 정상/경계/회귀가 실제 요청으로 검증되고, 테스트·빌드·사용 가능한 진단 및 독립 리뷰를 마치고 소유 QA 자원이 정리되면 종료한다. 실행 불가능하거나 기존 실패인 항목은 근거와 함께 명시하고 완료로 가장하지 않는다.