Files

371 lines
66 KiB
Markdown

# P2 v2 HTTP 이름 적용 지도 및 GREEN 인계
> 2026-10-15 COR013의 현재 구현 상태는 문서 마지막의 **COR013 JOIN 전환 manifest**를 따른다. 위아래의 COR012 batch 설계/검증 이력은 당시 기록으로 보존한다. COR013 runtime GREEN은 부모의 직렬 검증 전까지 미확인이다.
## 상태와 경계
- 기준: `prd.md` COR012 및 `plan-task.md` P2-T1, NAME002/003/005/006. 첫 단계의 통합 테스트 4개에 이어 아래 앱 표면의 운영 연결과 필수 생성자/fixture 적응을 구현했다. PRD/plan, 설정, DDL, core/legacy 전달 코드는 수정하지 않았다.
- **구현됨, 부모 직렬 GREEN 검증 대기**다. 부모 RED 로그 `creatorname-consumer-red.log` 100~110행에서 네 테스트의 실제 이름 assertion 실패를 확인했다. 아래 모든 HTTP 행과 blocked-message 행은 구현했으며 테스트/빌드/린트 통과나 수동 HTTP QA 완료를 주장하지 않는다.
- 경로 약어: `M/` = `src/main/kotlin/kr/co/vividnext/sodalive/`, `T/` = `src/test/kotlin/kr/co/vividnext/sodalive/`. 아래 경로는 이 prefix를 붙인 정확한 기존 파일이다.
- `v2.usercreatorchat`, `v2.creatorname`은 다른 소유자의 수정 영역이다. 추가 승인된 `v2.chat` DTO/service/controller 및 `chat/room/repository/ChatRoomRepository.kt`의 AI projection만 이 세션에서 수정했다. 검색/legacy/비동기 전달은 부모 작업이다.
## 공통 적용 결정
1. 기존 `M/i18n/LangInterceptor.kt#preHandle`이 `Accept-Language`를 해석한다. 각 HTTP controller에서 `LangContext.lang`을 읽어 facade의 명시 `lang: Lang` 인자로 전달한다. 이름 기능 때문에 내부 서비스에서 request scope를 새로 읽거나 언어를 다시 추정하지 않는다. 기존 콘텐츠 번역 로직의 언어 선택은 별도 기능으로 보존한다.
2. facade가 조회 결과의 **실제 이름 주인 member ID**를 합쳐 `CreatorLocalizedNameService.findLocalizedNames(memberIds, lang)`을 batch 호출한다. 응답 전 domain/record `copy`로 `map[id] ?: originalName`을 선택한다. 하나의 목록에 DB 행별 호출을 하지 않는다. 공유 DTO, JPA `Member.nickname`, principal, 저장 snapshot을 변이하지 않고 공개 DTO field 이름/타입도 유지한다.
3. CREATOR가 댓글 작성자/답글 작성자/후원자로 등장해도 적용 대상이다. `writerId`, donor `memberId`로 찾으며 채널 owner ID를 대신 쓰지 않는다. USER는 같은 이름 필드여도 그대로다. foundation의 활성 JA + 현재 `Member.role == CREATOR` 필터를 사용하고 `memberKind`만으로 판정하지 않는다.
4. 중립 ranking snapshot 이름은 현재 원본이 아니다. 이 두 ranking consumer는 owner IDs로 `MemberRepository.findNicknamesByIds`도 한 번 호출해 엔티티 로드 없이 현재 원본을 확보하고 JA map을 우선한다. KO/EN 또는 JA 미등록은 **현재** nickname으로 fallback한다. 삭제된 owner의 기존 표시 정책은 유지하며 저장 row를 갱신하지 않는다. 차단 마스킹된 creator ID `0`/빈 이름을 복구하지 않는다.
## 작성한 실제 회귀 테스트
| 정확한 기존 파일 / 새 심볼 | 실제 경로와 기대 동작 |
|---|---|
| `T/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt#shouldSelectLocalizedCreatorNamesWithoutChangingAudioTitles` | `GET /api/v2/audio/contents?type=AUDIO`: 저장 후 원본을 변경해도 JA 별칭 우선, KO는 변경된 원본, JA 미등록 작성자는 원본, audio title 불변. 별도 USER를 먼저 저장해 content ID와 owner member ID를 분리하며 내부 owner ID는 JSON에 노출하지 않는다. |
| `T/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt#shouldLocalizeCreatorAuthorsAndLatestRepliesWithoutChangingUserNames` | `GET /api/v2/creator-channels/community-posts/{postId}`: post creator, CREATOR root comment, USER root comment, CREATOR latestReply를 함께 저장한다. JA는 CREATOR만 별칭이며 USER는 잔존 별칭 row가 있어도 원본이다. 본문은 이름 치환 대상이 아니다. |
| `T/v2/api/home/CreatorRankingControllerTest.kt#shouldRenderCurrentLocalizedNamesWithoutRewritingRankingSnapshots` | `GET /api/v2/home/rankings/creators`: 원본 이름으로 중립 snapshot 저장 후 회원 원본 변경. JA 별칭/JA 미등록 현재 원본/KO 현재 원본을 검증하고 DB snapshot 이름과 회원 원본 보존을 단언한다. |
| `T/v2/api/home/HomeRecommendationControllerTest.kt#shouldLocalizeNativeDebutCreatorResultsByRequestLanguage` | `GET /api/v2/home/recommendations/debut-creators`: 실제 live 활동으로 native CTE 후보를 만든다. 원본 변경 후 KO 현재 원본과 JA 별칭을 검증한다. repository/query service를 mock하지 않는다. |
픽스처는 기존 `saveMember`, `saveAudioContent`, `saveCommunity`, `saveCommunityComment`, `saveSnapshot`, `saveLiveRoom`과 실제 JPA 저장을 재사용한다. 별칭만 허용된 H2 `JdbcTemplate` INSERT로 저장했다. `member_id`, enum NAME `'JA'`, `display_name`, `is_active`, BaseEntity 시각 컬럼을 명시하며 production DDL 실행/새 schema 생성/반사 호출은 없다. USER의 alias row는 관리 API가 USER 저장을 허용한다는 뜻이 아니라 역할 변경 후 잔존 행과 같은 읽기 필터 회귀를 재현한다. 기존 번역/외부 미디어 mock은 유지했으며 이름 서비스는 mock하지 않았다.
### 부모 RED 명령
foundation 준비 후 한 번에 하나씩 실행한다. 첫 단계는 새 메서드만 좁혀서 실제 이름 assertion 실패인지 확인한다.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest.shouldSelectLocalizedCreatorNamesWithoutChangingAudioTitles'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest.shouldLocalizeCreatorAuthorsAndLatestRepliesWithoutChangingUserNames'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest.shouldRenderCurrentLocalizedNamesWithoutRewritingRankingSnapshots'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldLocalizeNativeDebutCreatorResultsByRequestLanguage'
```
예상 RED 지점은 각각 `creatorNickname` JA 기대값, post/comment/reply JA 이름, ranking `nickname` JA 기대값, native debut `creatorNickname` JA 기대값이다. 테이블 미생성, 컴파일/컨텍스트 실패, SQL fixture 실패는 RED 증거로 취급하지 않는다. GREEN 뒤에는 같은 명령과 아래 HTTP owner의 전체 class 회귀를 수행한다.
### foundation 선행 인터페이스 확인
- 조사 시작 때 파일이 없었지만 인계 전 실제 소스를 다시 읽었다. `M/v2/creatorname/application/CreatorLocalizedNameService.kt#findLocalizedNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String>`와 `#saveLocalizedName(creatorId: Long, lang: Lang, displayName: String)`가 현재 존재한다.
- `M/v2/creatorname/adapter/out/persistence/CreatorLocalizedNameEntity.kt`는 `creator_localized_name` 및 위 INSERT 컬럼, enum-string `Lang`, BaseEntity 상속과 일치한다. `M/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameController.kt`의 ADMIN PUT도 확인했다. 시그니처 추측이나 임시 타입은 추가하지 않았다.
- 후속 상태: 부모가 foundation 18 tests PASS 및 검토 완료를 전달했고 consumer RED도 실행했다. 초기 조사 당시의 repository `emptyMap()` 미구현 관찰은 더 이상 현재 blocker가 아니다. 이번 소비자는 실제 foundation 필수 생성자 의존성을 사용하며 nullable/optional service 우회가 없다.
## HTTP 표면별 정확한 적용 지점
아래 모든 행은 **구현됨, GREEN 대기**다. 각 controller는 `LangContext.lang`을 명시 전달하고 facade는 batch와 immutable copy를 수행한다. 아래 초기 설계와 달리 following news는 각 subtype 대신 상위 `HomeFollowingNews.creatorMemberId` 하나에 joined owner를 보존했다. live는 `HomeFollowingLive.creatorMemberId`를 사용한다. 공개 DTO에는 두 필드를 추가하지 않았다.
| 표면 / 응답 이름 | 기존 파일과 심볼 / 필요한 최소 변경 | HTTP test owner |
|---|---|---|
| 홈 추천 `lives`, `recentlyActiveCreators`, `recentDebutCreators`, `firstAudioContents`, `genreCreators[].creators`, `cheerCreators`, `popularCommunityPosts`의 `creatorNickname` | `M/v2/api/home/adapter/in/web/HomeRecommendationController.kt#getHomeRecommendations/getLives/getDebutCreators` → `M/v2/api/home/application/HomeRecommendationFacade.kt#getHomeRecommendations/getLives/getRecentDebutCreators`. 섹션 결과를 모은 후 ID 합집합 batch. banner의 국가 기반 `bannerLang`은 이름 언어로 재사용 금지. `HomeFirstAudioContentRecord.creatorId`는 이미 존재한다. | `T/v2/api/home/HomeRecommendationControllerTest.kt` |
| 홈 live `creatorNickname` | 위 `getLives` 및 `M/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt#getOnAirLives` → `M/v2/api/home/live/application/HomeOnAirLiveFacade.kt#getOnAirLives`. 공유 `M/v2/recommendation/port/out/HomeRecommendationQueryPort.kt#HomeLiveRecommendationRecord`에 내부 `creatorMemberId` 추가, `M/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt#findLiveRecommendations`의 QueryDSL constructor projection에 `member.id` 추가. `liveRoomId/roomId`로 alias 조회 금지. | `T/v2/api/home/live/adapter/in/web/HomeOnAirLiveEndToEndTest.kt` |
| 홈 creator ranking `items[].nickname` | `M/v2/api/home/adapter/in/web/CreatorRankingController.kt#getCreatorRankings` → `M/v2/api/home/application/HomeCreatorRankingFacade.kt#getCreatorRankings`. `M/v2/ranking/application/CreatorRankingQueryService.kt#getCreatorRankings` 결과의 `items[].creatorId`를 batch key로 사용. snapshot 원본이 아닌 현재 Member fallback도 batch 조회. 기존 block mask와 rank/rankChange/isNew 유지. | `T/v2/api/home/CreatorRankingControllerTest.kt` |
| 팔로잉 creator/live/schedule/news 이름 및 recentChats | `M/v2/api/home/following/adapter/in/web/HomeFollowingController.kt#getFollowingTab` → `M/v2/api/home/following/application/HomeFollowingFacade.kt#getFollowingTab`. `M/v2/home/following/domain/HomeFollowing.kt`: `HomeFollowingCreator.creatorId`, `HomeFollowingSchedule.creatorId`, `HomeFollowingCreatorRankingNews.creatorId` 재사용. `HomeFollowingLive`, `HomeFollowingContentNews`, `HomeFollowingCommunityPostNews`에 내부 owner 추가. `M/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt#findOnAirLives/findRecentNews/toHomeFollowingNews`에 현재 join한 `creator.id` selection/전달 추가. `audioContent`, `photoContent`, `communityPost`의 이름과 creatorRanking `nickname`을 분기별 copy. recentChats는 아래 v2.chat 소유 경계 사용. | `T/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` |
| 전체 콘텐츠 audio/series `creatorNickname` | `M/v2/api/content/all/adapter/in/web/MainContentAllController.kt#getContents` → `M/v2/api/content/all/application/MainContentAllFacade.kt#getContents`. `M/v2/content/all/domain/MainContentAll.kt#MainContentAllAudio/MainContentAllSeries`에 내부 `creatorMemberId` 추가. `M/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt#findAudios/findSeries`에서 전달. audio row는 owner를 이미 select하지만 domain에서 버린다. series는 `member.id` selection도 추가. title 번역/정렬/pagination 불변. | `T/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt` |
| 콘텐츠 추천 5종 audio card `creatorNickname` | `M/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt#getRecommendations` → `M/v2/api/content/recommendation/application/AudioRecommendationFacade.kt#getRecommendations`. `M/v2/content/recommendation/domain/AudioRecommendation.kt#AudioCard`에 내부 owner 추가. `M/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt#audioRows/toAudioCards`의 이미 select한 `audioContent.member.id`를 card까지 전달. `latestAudios/newAndHotAudios/freeAudios/pointAudios/recommendedAudios` 합집합 batch. `OriginalSeries`, `CommentedAudio`는 이름 필드 없음. | `T/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt` |
| 콘텐츠 overview `items[].creatorNickname` | `M/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt#getContents` → `M/v2/api/content/overview/application/ContentOverviewFacade.kt#getContents/getNewAndHotContents/getFirstAudioContents`. NEW_AND_HOT은 위 `AudioCard` owner 추가 사용. FIRST_AUDIO는 `M/v2/recommendation/port/out/HomeRecommendationQueryPort.kt#HomeFirstAudioContentRecord.creatorId`가 이미 있으므로 새 owner 필드 불필요. native first-audio CTE 결과 이름도 batch copy. | `T/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt` |
| 오디오 ranking `items[].creatorNickname` | `M/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt#getRankings` → `M/v2/api/content/ranking/application/AudioRankingFacade.kt#getRankings`. `M/v2/content/ranking/domain/AudioRanking.kt#AudioRankingItem`에 내부 `creatorMemberId` 추가. `M/v2/content/ranking/application/AudioRankingQueryService.kt#toItem`에서 기존 snapshot record owner를 버리지 않고 전달. facade가 현재 Member + alias batch로 선택. locale별 content title은 손대지 않음. | `T/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt` |
| 채널 home `creator.nickname`, `notices/communities[].creatorNickname`, `fanTalk.latestFanTalk.nickname`, `channelDonations[].nickname` | `M/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeController.kt#getHome` → `M/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt#getHome`. `M/v2/creator/channel/home/domain/CreatorChannelHome.kt`의 creator/post/member IDs 재사용. donation만 내부 donor memberId 추가: 같은 파일 `CreatorChannelDonation`, `M/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt#CreatorChannelDonationRecord`, `M/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt#findChannelDonations`, `M/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt#toDomain`. 모든 이름 주인 합집합 batch, fan/donor가 CREATOR인 경우도 적용. | `T/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` |
| 채널 community tab/detail 및 댓글/답글 `creatorNickname/writerNickname` | `M/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt#getCommunityTab/getCommunityPostDetail/getCommunityComments/getCommunityReplies` → `M/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`의 같은 네 심볼. `M/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt#CreatorChannelCommunityPost.creatorId`, `M/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt#CreatorChannelCommunityComment/CreatorChannelCommunityReply.writerId`가 존재. detail은 post/comment/latestReply ID를 합쳐 한 번 lookup. 별도 댓글/답글 endpoint도 각각 처리. translator의 postId와 이름의 memberId를 혼용하지 않는다. | `T/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` |
| 채널 fanTalk와 creatorReplies `writerNickname` | `M/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt#getFanTalkTab` → `M/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt#getFanTalkTab`. `M/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt#CreatorChannelFanTalk/CreatorChannelFanTalkReply.writerId` 합집합으로 batch. USER 팬 이름 보존, CREATOR 작성자/답글은 적용. | `T/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt` |
| 채널 donation `rankings[].nickname`, `donations[].nickname` | `M/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt#getDonationTab` → `M/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt#getDonationTab`. `M/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt#CreatorChannelDonationRanking.userId`는 실제 donor member ID다. donation list는 donor ID를 추가: 같은 파일 `CreatorChannelDonation`, `M/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt#CreatorChannelDonationRecord`, `M/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt#findChannelDonations`, `M/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt#toDomain`. USER 불변, CREATOR donor만 적용. | `T/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt` |
| 기프트 목록/상세 `counterpartNickname`, `giftInfo.recipientCreatorNickname/senderNickname` | `M/v2/api/gift/adapter/in/web/GiftController.kt#getGifts/getGiftDetail`가 명시 Lang 전달. facade 없는 기존 구조이므로 `M/v2/gift/application/GiftQueryService.kt#getGifts/getGiftDetail/toGiftListItem`의 결과 조립에 resolver 주입. 목록은 이미 batch 조회한 `membersById`의 counterpart IDs, 상세는 이미 읽은 sender/recipient IDs를 묶어 lookup. `applicationNo/giftId`는 key가 아님. `senderInfo.name`, 수령인 실명/주소, categoryNameSnapshot, status/sizeName은 치환 금지. command 응답에는 회원 이름이 없어 수정하지 않음. | `T/v2/api/gift/adapter/in/web/GiftControllerTest.kt` |
## 정상 응답 밖의 표시 경계
- 채널 audio/series/live 정상 응답에는 creator 이름이 없다. `M/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt`, `M/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt`, `M/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`, `M/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt`의 title/seriesName을 creator 이름으로 치환하거나 필드를 신설하지 않는다.
- 단, 아래 일곱 query service는 blocked-access 메시지에 `String.format(messageTemplate, creator.nickname)`을 넣는다. 이 이름도 사용자에게 표시된다. 각각 controller → facade → 해당 함수로 `lang: Lang`을 전달하고 **오류 분기에서만** 대상 creator 한 명을 resolver batch API로 조회해 format한다. 정상 응답 facade lookup과 중복 조회하지 않는다. 기존 번역/권한/차단 정책과 message key는 유지한다.
| 정확한 query service / 심볼 | 명시 Lang 전달 facade | 기존 HTTP test owner |
|---|---|---|
| `M/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt#getHome` | `M/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt#getHome` | `T/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` |
| `M/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt#getCommunityTab` | `M/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt#getCommunityTab` | `T/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` |
| `M/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt#getFanTalkTab` | `M/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt#getFanTalkTab` | `T/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt` |
| `M/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt#getDonationTab` | `M/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt#getDonationTab` | `T/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt` |
| `M/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt#getAudioTab` | `M/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt#getAudioTab` | `T/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt` |
| `M/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt#getSeriesTab` | `M/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt#getSeriesTab` | `T/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt` |
| `M/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt#getLiveTab` | `M/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt#getLiveTab` | `T/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveEndToEndTest.kt` |
audio/series/live의 controller 파일은 각각 `M/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt`, `M/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt`, `M/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveController.kt`다. 성공 DTO는 그대로 두고 위 오류 경계의 명시 Lang 인자만 전달한다.
## neutral snapshot / native producer-consumer 분류
| 기존 파일 / 심볼 | 분류와 구현 결정 |
|---|---|
| `M/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt#aggregateAllCandidates/toCandidate` → `M/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt` → `M/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt` → `M/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt` → `CreatorRankingQueryService#getCreatorRankings` | native SQL이 당시 원본 `Member.nickname`을 저장하며 Lang 컬럼/수신자별 렌더링이 없는 **중립 materialization**이다. 역사 면제 아님. 조회 현재 이름 선택만 추가하고 저장 snapshot/rank/score는 불변. 새 ranking 회귀가 이 구분을 고정한다. |
| `M/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt#AGGREGATION_SQL` → `M/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt` → `M/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt` → `M/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt#toRecords/toRecord` → `AudioRankingQueryService#toItem` | `c.member_id`, `m.nickname`을 저장하는 중립 creator 이름. consumer가 locale별 `ContentTranslation` title을 고르지만 creator 이름이 언어별 역사 데이터가 되는 것은 아니다. owner 전달 + 현재 이름 batch 필요. title/renderedPayload/기존 snapshot row는 불변. |
| `M/v2/recommendation/adapter/out/persistence/RecommendationSnapshot.kt` 및 `M/v2/recommendation/application/RecommendationSnapshotRefreshService.kt` → `M/v2/recommendation/application/HomeRecommendationQueryService.kt#findCheerCreatorRecommendations/findPopularCommunityRecommendations` | section/targetId/score 후보 snapshot이고 표시 이름의 언어 snapshot이 아니다. detail 조회(`findCheerCreatorRecommendationDetails`, `findPopularCommunityRecommendationDetails`)에서 현재 member ID와 원본을 얻어 facade에서 batch. targetId는 section에 따라 creator 또는 community resource이므로 alias key로 그대로 사용하지 않음. |
| `M/v2/home/following/application/HomeFollowingNewsPublishService.kt#publishToFollowers` → `M/v2/home/following/adapter/out/persistence/HomeFollowingNewsInbox.kt` → `DefaultHomeFollowingQueryRepository#findRecentNews/toHomeFollowingNews` | Lang 없는 중립 inbox. producer는 raw creatorNickname/title/body를 저장하지만 **HTTP consumer는 creator를 join해 현재 nickname을 다시 읽는다**. creator-ranking title에 저장된 과거 nickname은 현재 응답의 이름 원천이 아니다. consumer의 joined owner ID를 전달해 언어 선택. inbox 저장값/producer는 수정하지 않음. |
| `DefaultHomeRecommendationQueryRepository#findRecentlyActiveCreators/findRecentDebutCreators/findFirstAudioContents/findCreatorsByGenre` | native SQL에서 현재 creator 이름을 읽는 current lookup. 결과 record의 creatorId로 batch한다. 새 native debut HTTP 회귀는 CTE/query/service를 실제 실행하도록 작성했다. |
| `M/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` | card의 이름은 현재 QueryDSL member join. native most-commented 조회에는 creator 이름 응답이 없어 comment 본문/title을 치환하지 않음. recommendation candidate snapshot도 현재 card를 다시 조회하므로 이름 저장 변경 불필요. |
이 v2 조사에서 **이미 수신 언어로 렌더된 역사 이름 snapshot으로 면제할 HTTP 이름 필드는 확인되지 않았다**. `snapshot`이라는 클래스명, content title 번역 또는 과거 생성 시점만으로 면제하지 않는다.
## 다른 소유자와의 연결 및 원본 유지 영역
- `M/v2/chat/controller/ChatRoomListController.kt#getRooms` → `M/v2/chat/service/ChatRoomListService.kt#getRooms/toResponse`: DM의 `targetName`은 creator 또는 USER 상대 회원 이름이므로 대상이다. AI `targetName`은 `M/chat/room/repository/ChatRoomRepository.kt#findAiChatListRooms`의 `pc.character.name`이며 **캐릭터 제목이므로 제외**한다. 최근 메시지 본문도 이름 문자열 치환 대상이 아니다.
- 초기 조사에서 DM owner ID는 `M/v2/chat/dto/ChatRoomListDtos.kt#ChatRoomListQueryDto`에서 누락되어 있었다. 후속 승인에 따라 DTO/AI projection은 이 세션이, `M/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt#findDmChatListRooms`의 `opponent.member.id` projection은 delivery 소유자가 각각 구현했다. 이 세션은 DM repository를 수정하지 않았다. roomId/character creatorMemberId로 이름을 치환하지 않는다.
- 후속 구현: `ChatRoomListService`가 최종 `pageRows`의 DM member IDs를 batch 처리한다. `HomeFollowingFacade`는 같은 명시 Lang으로 이미 선택된 recentChats를 받아 다시 치환하지 않는다. 실제 계약은 `ChatRoomListQueryDto(roomId, chatType, targetName, targetImagePath, lastMessage, messageType, lastMessageAt, opponentMemberId: Long?)`다. trailing 필드에 default는 없다. AI는 `cast(null as long)`, delivery 소유자가 변경한 DM JPQL은 마지막 `opponent.member.id`를 전달함을 read/rg로 확인했다. 공개 `ChatRoomListItemResponse`는 변경하지 않았다. 해당 소유자의 변경은 이 세션의 수정 파일에 포함하지 않는다.
- 앱이 아닌 운영 식별/편집 응답은 원본 유지: `M/v2/gift/application/GiftAdminQueryService.kt#getGifts/getGiftDetail` 및 `M/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt`, `M/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt#toResponse`, `M/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt#toResponse`, `M/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt#list/createReply/updateReply`. 앱 DTO를 재사용하더라도 DTO 전역 변환을 적용하지 않는다. admin nickname 검색/원본 편집/배송 실명은 별칭 표시 대상과 분리한다.
- `M/v2/api/home/application/HomeRecommendationFacade.kt#HomeAiCharacterRecommendationRecord.toItem`의 `name`, content/series/live title, event banner, `GiftStatus.displayName`, `GiftSize.displayName`은 creator 이름이 아니며 수정하지 않는다. ADMIN charge/ranking job API는 작업 상태 응답이며 creator display-name 필드가 없다.
## GREEN 생성자·fixture 적응 지도
현재 테스트 생성자와 의존성을 읽고 `rg`로 직접 생성 지점을 대조해 아래 적응을 수행했다. 기존 facade 단위 테스트는 필수 resolver mock과 명시 Lang을 전달한다. controller slice의 `@MockBean LangContext` getter는 `Lang.KO`로 stub했다. 새 기능의 실제 DB/HTTP 검증은 위 통합 테스트가 담당하며 기존 assertion은 완화하지 않았다. `GiftQueryServiceTest`에는 foundation service/repository를 실제 import했다.
| 정확한 기존 test 파일 | 최소 적응 |
|---|---|
| `T/v2/api/home/application/HomeRecommendationFacadeTest.kt`; `T/v2/api/home/HomeRecommendationControllerTest.kt` | 현재 `HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test")`. 새 resolver 및 `lang` 호출 인자 추가. 후자는 장애 로그 test에서도 facade를 3곳 직접 생성하므로 Spring 주입만 보고 끝내지 않음. |
| `T/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt`; `T/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` | facade resolver/lang 추가와 `HomeLiveRecommendationRecord` owner fixture 추가. QueryDSL positional constructor와 모든 fake port live record도 동기화. |
| `T/v2/api/home/following/application/HomeFollowingFacadeTest.kt`; `T/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt`; `T/v2/home/following/application/HomeFollowingQueryServiceTest.kt` | facade resolver/lang 및 chat service 명시 Lang stub. `HomeFollowingLive/ContentNews/CommunityPostNews` 내부 owner fixture 추가. 공개 response DTO와 JSON 필드는 추가하지 않음. |
| `T/v2/api/content/all/application/MainContentAllFacadeTest.kt`; `T/v2/content/all/application/MainContentAllQueryServiceTest.kt`; `T/v2/api/content/all/dto/MainContentAllTabResponseTest.kt` | 현재 `MainContentAllFacade(queryService)`에 resolver 추가, facade 호출 lang 추가. `MainContentAllAudio/Series`의 필수 내부 owner fixture를 모두 채움. |
| `T/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`; `T/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`; `T/v2/api/content/overview/application/ContentOverviewFacadeTest.kt`; `T/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt` | `AudioRecommendationFacade(queryService)` 및 overview의 기존 두 query service/preference/host 의존성은 유지하고 resolver/lang 추가. 모든 `AudioCard` fixture owner 추가. FIRST_AUDIO record는 기존 creatorId 사용. |
| `T/v2/api/content/ranking/application/AudioRankingFacadeTest.kt`; `T/v2/api/content/ranking/dto/AudioRankingResponseTest.kt` | `AudioRankingFacade(queryService)`에 resolver 및 현재 Member batch 조회 의존성/lang 추가. `AudioRankingItem` 내부 owner fixture 추가. contentId를 owner로 쓰지 않는 assertion. |
| `T/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`; `T/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`; `T/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt` | home facade resolver/lang, donor record/domain owner fixture, query service의 오류 분기 resolver/lang 의존성 및 controller facade stubs 조정. `CreatorChannelHomeQueryServiceTest`의 donation copy 기대값에도 필드 전파. |
| `T/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt`; `T/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`; `T/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` | 현재 `CreatorChannelCommunityFacade(service)`를 여러 메서드에서 직접 생성. resolver/lang 추가, detail/comment/latestReply 합집합 검증. query service 오류 분기에도 명시 Lang/resolver 전달. |
| `T/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`; `T/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`; `T/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt`; `T/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt`; `T/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt` | facade/query service resolver와 명시 Lang, donor record/domain owner fixture 추가. writer/donor가 USER면 원본, CREATOR면 alias. ranking은 `LegacyCreatorChannelDonationRankingAdapter`가 넘긴 donor `userId` 재사용. |
| `T/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt`; `T/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt`; `T/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt`; `T/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt`; `T/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt`; `T/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt` | 성공 title DTO는 그대로. blocked-access 이름을 위한 명시 Lang forwarding 및 query service resolver 생성자 추가. 기존 controller MockMvc forwarding stubs의 추가 Lang도 일치시킴. |
| `T/v2/gift/application/GiftQueryServiceTest.kt#queryService`; `T/v2/api/gift/adapter/in/web/GiftControllerTest.kt` | 기존 helper는 여섯 repository + host로 `GiftQueryService`를 직접 생성한다. resolver 추가, `getGifts/getGiftDetail`에 Lang 지정. `@DataJpaTest` helper에는 실제 foundation service/repository를 구성하거나 import하며 조회 서비스를 mock해 버리지 않음. HTTP는 실제 queryService를 사용하고 기존 commandService mock은 유지. |
| `T/v2/chat/ChatRoomListServiceTest.kt`; `T/v2/chat/ChatRoomListControllerTest.kt` | service/controller 직접 생성 인자와 query DTO의 DM owner/AI null fixture, Lang 호출 인자를 조정. AI character name 불변과 DM USER 보존 확인. |
| `T/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`; `T/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`; `T/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`; `T/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` | 기존 실제 persistence test에서 새 내부 owner ID가 resource ID와 다른 fixture로 올바르게 전달되는지 단언. projection constructor 순서와 current joined ID 전달 검증. |
위 표 외에도 owner 필드를 추가하는 각 data class의 생성 위치는 GREEN 직전에 전체 `M/`, `T/`에서 다시 `rg`한다. repository의 `Projections.constructor`, JPQL `select new`, Kotlin positional fixture는 공개 JSON 변화 없이 모두 맞춰야 한다. optional owner default `0`으로 컴파일만 통과시키지 않는다.
## GREEN 구현 범위와 검증 소유
- 운영 수정 후보는 위 HTTP 표의 controller/facade, owner를 잃는 domain/port/projection adapter, 두 ranking의 현재 이름 batch consumer, 기프트 query result 조립, 채널 오류 분기 query service로 제한한다. response DTO `from`은 기존 공개 필드를 그대로 복사하므로 원칙적으로 수정할 필요 없다.
- 읽기 근거로만 등장한 snapshot entity/producer/native aggregation, following inbox producer, admin 원본 편집기는 수정 후보가 아니다. 두 타 소유 영역은 조정 전 수정 금지다. `v2.chat` DM projection은 소유자 인계 항목이다.
- 첫 4개 테스트는 대표 RED이지 전체 surface 완료 증거가 아니다. GREEN에서는 HTTP 표의 각 owner class에 JA/KO/EN/미등록/비활성/혼합 USER 및 필요한 owner-ID 회귀를 추가하고 해당 exact class만 우선 실행한다. fixture/생성자 적응은 기존 단위/adapter 회귀로 확인한다.
- 검증 실행 예시는 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest'`다. 다른 행도 `T/` 아래 package와 class를 그대로 FQCN으로 사용한다. 단계 마지막에 부모가 `./gradlew ktlintCheck`, `./gradlew assemble`을 직렬 실행한다.
- 이 단계는 부모 RED 증거와 소스·fixture·필드·시그니처의 read/rg 대조만 수행했다. Gradle/서버 실행/DB 연결/commit/push는 하지 않았다. 사용 가능한 도구에 `lsp_diagnostics`가 없어 IDE 진단도 수행하지 못했다. 부모의 실제 GREEN 결과를 기다린다.
### 추가 확인한 중립 후원 랭킹 캐시
`M/explorer/profile/CreatorDonationRankingService.kt#getMemberDonationRanking`의 Redis key는 Lang을 포함하지 않고 raw nickname이 담긴 `MemberDonationRankingListResponse`를 캐시한다. 이 파일과 캐시 저장은 수정하지 않았다. `CreatorChannelDonationFacade`는 legacy adapter가 넘긴 donor `userId`를 batch Member 조회해 현재 원본으로 fallback하고 같은 aggregate의 JA map을 우선한다. 목록 donor와 ranking donor의 alias lookup은 합집합 한 번이다. 따라서 중립 donor cache도 역사 면제로 남기지 않았다.
### 정적 점검 결과와 남은 검증
- content/live/ranking/following/donor 내부 owner 생성 위치를 `src/main`, `src/test` 전체에서 `rg`해 모든 직접 생성 fixture를 적응했다. JPQL `select new`의 AI/DM trailing ID와 QueryDSL constructor의 donor ID 첫 인자/live owner 마지막 인자를 대조했다.
- `findLocalizedNames`는 정상 목록/aggregate별 한 번이며 blocked-access 경로는 오류 분기에서만 한 명을 batch API로 조회한다. snapshot current fallback은 batch Member 조회이고 `0L` 마스킹은 ID 집합에서 제외한다. `LangContext`는 새 이름 기능에서 controller 경계만 읽는다.
- public response DTO, snapshot entity/producer, inbox producer, core/legacy, `FcmEvent`는 수정하지 않았다. AI character name/content title/본문 및 배송 실명·주소는 치환하지 않는다.
- 부모의 `creatorname-foundation-final-lint.log`를 읽었으며 첫 단계 v2 테스트 4개에 해당하는 lint 지적은 없었다. 다른 소유자 테스트의 lint는 변경하지 않았다. 변경 파일의 길이/공백을 `rg`로 점검했으며 전체 lint 실행을 대신한 것으로 주장하지 않는다.
- 길이 검색에서 `DefaultHomeRecommendationQueryRepository.kt`의 기존 raw SQL 두 줄은 130자를 넘는다. 이번 변경은 live owner projection 한 필드이며 그 SQL 본문은 변경하지 않았다. 이 검색 결과를 새 Kotlin lint 실패로 단정하거나 무관한 SQL 포맷 변경으로 확장하지 않는다.
- 내부 연결의 알려진 누락은 없다. 남은 불확실성은 실제 compile/test/lint, H2/Hibernate의 typed-null JPQL 실행 및 전체 HTTP 응답 회귀다. 특히 `v2.chat`의 AI/DM 생성자 계약은 부모 통합 GREEN에서 검증해야 한다.
### 부모 직렬 GREEN 명령
먼저 위 네 메서드의 동일 명령으로 GREEN을 확인한 뒤 다음 정확한 기존 class 회귀를 실행한다. 명령은 이 세션에서 실행하지 않았다.
```bash
./gradlew compileTestKotlin
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.gift.application.GiftQueryServiceTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest'
./gradlew ktlintCheck
./gradlew assemble
```
### 이번 GREEN 단계 실제 운영 수정 파일
아래는 `M/` 기준이다. 기존 표의 나머지 producer/DTO/admin 파일은 읽기 근거이지 수정 파일이 아니다.
```text
v2/api/content/all/adapter/in/web/MainContentAllController.kt
v2/api/content/all/application/MainContentAllFacade.kt
v2/api/content/overview/adapter/in/web/ContentOverviewController.kt
v2/api/content/overview/application/ContentOverviewFacade.kt
v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt
v2/api/content/recommendation/application/AudioRecommendationFacade.kt
v2/api/content/ranking/adapter/in/web/AudioRankingController.kt
v2/api/content/ranking/application/AudioRankingFacade.kt
v2/content/all/domain/MainContentAll.kt
v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt
v2/content/recommendation/domain/AudioRecommendation.kt
v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt
v2/content/ranking/domain/AudioRanking.kt
v2/content/ranking/application/AudioRankingQueryService.kt
v2/api/home/adapter/in/web/HomeRecommendationController.kt
v2/api/home/application/HomeRecommendationFacade.kt
v2/api/home/adapter/in/web/CreatorRankingController.kt
v2/api/home/application/HomeCreatorRankingFacade.kt
v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt
v2/api/home/live/application/HomeOnAirLiveFacade.kt
v2/api/home/following/adapter/in/web/HomeFollowingController.kt
v2/api/home/following/application/HomeFollowingFacade.kt
v2/recommendation/port/out/HomeRecommendationQueryPort.kt
v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt
v2/home/following/domain/HomeFollowing.kt
v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt
v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeController.kt
v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt
v2/creator/channel/home/domain/CreatorChannelHome.kt
v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt
v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt
v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt
v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt
v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt
v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt
v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt
v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt
v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt
v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt
v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt
v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt
v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt
v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt
v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt
v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt
v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt
v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt
v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt
v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt
v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt
v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveController.kt
v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt
v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt
v2/api/gift/adapter/in/web/GiftController.kt
v2/gift/application/GiftQueryService.kt
v2/chat/dto/ChatRoomListDtos.kt
v2/chat/service/ChatRoomListService.kt
v2/chat/controller/ChatRoomListController.kt
chat/room/repository/ChatRoomRepository.kt
```
### 이번 GREEN 단계 실제 테스트 적응 파일
아래는 `T/` 기준이다. 첫 단계 네 assertion의 기대값은 유지했다. 이 단계에서는 그중 `HomeRecommendationControllerTest`의 기존 직접 facade 생성/호출도 적응했으며 다른 세 RED 파일은 변경할 필요가 없었다.
```text
v2/api/content/all/application/MainContentAllFacadeTest.kt
v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt
v2/api/content/all/dto/MainContentAllTabResponseTest.kt
v2/content/all/application/MainContentAllQueryServiceTest.kt
v2/api/content/overview/application/ContentOverviewFacadeTest.kt
v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt
v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt
v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt
v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt
v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt
v2/api/content/ranking/application/AudioRankingFacadeTest.kt
v2/api/content/ranking/dto/AudioRankingResponseTest.kt
v2/api/home/application/HomeRecommendationFacadeTest.kt
v2/api/home/HomeRecommendationControllerTest.kt
v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt
v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt
v2/api/home/following/application/HomeFollowingFacadeTest.kt
v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt
v2/api/home/following/dto/HomeFollowingTabResponseTest.kt
v2/home/following/application/HomeFollowingQueryServiceTest.kt
v2/recommendation/application/HomeRecommendationQueryServiceTest.kt
v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt
v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt
v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt
v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt
v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt
v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt
v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt
v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt
v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt
v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt
v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt
v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt
v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt
v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt
v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt
v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt
v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt
v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt
v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt
v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt
v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt
v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt
v2/gift/application/GiftQueryServiceTest.kt
v2/chat/ChatRoomListServiceTest.kt
v2/chat/ChatRoomListControllerTest.kt
```
## COR013 JOIN 전환 manifest, 2026-10-15
### 범위와 인계 상태
- 승인 근거는 `prd.md` 10절과 `plan-task.md` JOIN-T1~T3다. 이 단계는 `v2.creatorname`, `v2.usercreatorchat`, `v2.chat` 및 core/legacy를 수정하지 않았다. `v2.chat` 전체는 delivery 소유자에게 넘어갔으며 아래 팔로잉 recentChats의 기존 명시 Lang 계약만 소비한다.
- 부모 RED 로그는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/creatorname-join-consumer-red.log`다. `MainContentAllEndToEndTest.shouldSelectLocalizedCreatorNamesWithoutChangingAudioTitles`는 이름 출력 assertion 뒤 standalone alias SELECT 기대 0/실제 1에서 실패했다. 기존 2249 전체 PASS와 JOIN 기반 43 PASS는 변경 전/공통 모듈 기준선이며 이번 v2 결과로 재사용하지 않는다.
- 모든 소유 current DB 이름 projection은 기존 owner에 `CreatorNameJoin(owner, lang, uniqueAlias)`의 `name/condition/displayName`을 명시 LEFT JOIN/ON/select한다. 조건은 공통 helper의 owner ID + 요청 Lang + active + CREATOR ON이다. JA-only 읽기 guard를 만들지 않았으며 관리자 /ja 쓰기와 JA 검색은 그대로다.
- 아래 DB 읽기에서 facade 별칭 service 의존성, 후속 이름 SELECT와 이름 전용 copy/map을 제거했다. 제목 번역, 페이지 구성, CDN 변환 및 차단 마스킹 등 이름과 다른 기존 변환은 유지한다. DTO는 `val` 기반이며 회원·snapshot·inbox entity를 변이하지 않는다.
### DB 읽기 전환 지점
각 행의 경로는 앞서 정의한 `M/` prefix를 적용한다. query adapter 외에 같은 행의 service/port/facade도 함께 수정했다.
| 경로/심볼 | 현재 구현과 호출 계약 |
|---|---|
| `v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt#findAudios/findAudioRows/findSeries` | 기존 `locale`을 `Lang.fromCode(locale)`로 재사용한다. audio/series의 실제 owner 이름을 JOIN으로 읽고 인기순 audio GROUP BY에는 raw nickname과 별칭 displayName을 함께 명시했다. `countAudios/countSeries/findSeriesIds`에는 이름 조인을 넣지 않았다. `v2/content/all/application/MainContentAllQueryService.kt#getContents(..., lang: Lang)`와 `v2/api/content/all/application/MainContentAllFacade.kt`가 언어를 전달한다. `v2/content/all/port/out/MainContentAllQueryPort.kt`의 기존 locale 계약은 유지한다. |
| `v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt#audioRows/toAudioCards` | 최신/무료/포인트/ID 기반 New&Hot·추천 card 모두 같은 JOIN을 사용한다. 기존 locale을 재사용하며 most-commented native query는 이름 출력이 없어 변경하지 않았다. `v2/content/recommendation/application/AudioRecommendationQueryService.kt#getRecommendations(member, lang)/findNewAndHotAudios(member, offset, limit, lang)`와 `v2/api/content/recommendation/application/AudioRecommendationFacade.kt`, `v2/api/content/overview/application/ContentOverviewFacade.kt`에서 전달한다. |
| `v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt#findLiveRecommendations/findCheerCreatorRecommendationDetails/findPopularCommunityRecommendationDetails` | live creator, 응원 creator, community owner 각각 고유 alias의 QueryDSL JOIN이다. community 집계 GROUP BY에 별칭 displayName을 추가하고 기존 countDistinct·필터·순서를 유지한다. `v2/recommendation/port/out/HomeRecommendationQueryPort.kt`와 `v2/recommendation/application/HomeRecommendationQueryService.kt`에 필수 Lang을 전달한다. |
| `v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt#findRecentlyActiveCreators/findRecentDebutCreators/findFirstAudioContents/findCreatorsByGenre` | native 현재 SELECT 네 곳에 LEFT JOIN + COALESCE를 넣었다. 최근 활동/장르는 후보 ranking/grouping 뒤 outer display owner에 조인하고 debut/first-audio는 현재 member에 조인한다. native `:lang`은 `lang.name` 또는 `Lang.fromCode(locale).name`으로 bind한다. CTE/window/rank/order/limit, 후보 ID/count query와 snapshot 생성 SQL은 보존한다. `findGenreCreatorRecommendations`의 private helper까지 Lang을 전달하며 first-audio port는 기존 locale을 사용한다. |
| `v2/api/home/application/HomeRecommendationFacade.kt`; `v2/api/home/live/application/HomeOnAirLiveFacade.kt` | 요청 Lang을 위 query service에 명시 전달하고 JOIN된 record를 그대로 응답 조립에 사용한다. country 기반 `bannerLang`은 배너 전용이며 creator 이름 언어로 쓰지 않는다. |
| `v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt#findFollowingCreators/findOnAirLives/findMonthlySchedules/findRecentNews` | 팔로잉 creator/on-air/live schedule/audio schedule/news의 현재 owner 이름을 JOIN으로 읽는다. `v2/home/following/port/out/HomeFollowingQueryPort.kt`의 네 메서드와 `v2/home/following/application/HomeFollowingQueryService.kt#findHomeFollowing(member, lang)`에 필수 Lang을 추가했다. news는 중립 inbox의 이름이 아니라 이미 조인한 현재 creator 이름을 선택한다. |
| `v2/api/home/following/application/HomeFollowingFacade.kt#getFollowingTab` | 위 JOIN 결과를 사용하며 `ChatRoomListService.getRooms(member, lang=lang, followedCreatorsOnly=true, ...)` 계약은 그대로다. recentChats를 다시 이름 치환하지 않는다. |
| `v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt#findCreator/findChannelDonations/findFanTalkSummary` | creator/donor/latest-fanTalk writer에 각각 JOIN한다. `v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`의 세 읽기 메서드에 필수 Lang을 추가했고 `v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`에서 전달한다. notices/communities도 아래 community service에 같은 Lang을 전달한다. `v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt`의 batch와 copy들을 제거했다. |
| `v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt#findCreator/findCommunityPosts/findHomeCommunityPosts/findCommunityPost/findCommunityComments/findLatestRepliesByCommentIds/findCommunityReplies` | post creator와 각 comment/reply writer를 JOIN한다. tuple alias는 `postCreatorDisplayName/commentWriterDisplayName`으로 지정하고 raw 회원 nickname field를 alias로 쓰지 않는다. `v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`의 해당 메서드와 `v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt#findHomeCommunityPosts/getCommunityPostDetail/getCommunityComments/getCommunityReplies`에 필수 Lang을 추가했다. `v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`는 JOIN 결과를 바로 매핑한다. |
| `v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt#findCreator/findFanTalks/findCreatorReplies` | creator와 root/reply writer 이름을 JOIN한다. `v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt` 세 메서드에 필수 Lang, `v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`의 private reply helper까지 전달한다. `v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt`의 alias dependency/copy를 제거했다. |
| `v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt#findCreator/findChannelDonations` | creator/donor의 직접 DB 읽기를 JOIN으로 바꿨다. `v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt` 두 메서드에 필수 Lang, `v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`가 전달한다. `v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt`는 DB donation 목록에 후속 치환하지 않고 중립 cache ranking만 current batch한다. |
| `v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt#findCreator`; `v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt#findCreator`; `v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt#findCreator` | 각 `v2/creator/channel/{audio,series,live}/port/out/CreatorChannel{Audio,Series,Live}QueryPort.kt#findCreator`는 필수 Lang을 받는다. 각 `v2/creator/channel/{audio,series,live}/application/CreatorChannel{Audio,Series,Live}QueryService.kt`의 blocked-access 이름은 이 record.nickname을 그대로 사용한다. 후속 resolver 주입/오류 분기 alias SELECT를 제거했다. 정상 DTO의 제목/콘텐츠 필드는 그대로다. |
| `v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt#findLatestVisibleSnapshots` | DB의 중립 snapshot을 조회하면서 현재 Member/별칭을 LEFT JOIN한다. `COALESCE(alias, current nickname, snapshot nickname)`로 삭제된 owner의 기존 fallback도 유지하며 snapshot entity를 수정하지 않는다. 기존 최신 visible 조건/rankNo 순서를 QueryDSL로 동일하게 적용했다. `v2/ranking/port/out/CreatorRankingSnapshotPort.kt#findLatestVisibleSnapshots(type, nowUtc, lang)`와 `v2/ranking/application/CreatorRankingQueryService.kt#getCreatorRankings(viewerMemberId, lang)`는 필수 Lang이다. `v2/api/home/application/HomeCreatorRankingFacade.kt`의 Member scalar batch와 alias batch/copy를 모두 제거했다. |
| `v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt#findLatestVisibleSnapshots` | 위와 같이 현재 owner/name을 snapshot 읽기에서 JOIN하며 기존 locale을 재사용한다. 제목 번역은 별개로 유지한다. `v2/content/ranking/application/AudioRankingQueryService.kt#getRankings(type, member, lang)` → 기존 snapshot port locale을 전달한다. `v2/api/content/ranking/application/AudioRankingFacade.kt`의 Member scalar batch와 alias batch/copy를 제거했다. |
`v2/creator/channel/{audio,series,live}` 행의 중괄호는 세 기존 파일의 일대일 대응이며 신규 추정 경로가 아니다. 관리 AI community/fanTalk은 별도 raw repository를 사용함을 확인했으므로 앱 query에 admin용 KO 기본값/null-language fallback을 추가하지 않았다. 원래 언어 중립 snapshot writer가 쓰는 집계기간/raw 조회 계약도 바꾸지 않았다.
`MainContentAllQueryService`, `AudioRecommendationQueryService`, `AudioRankingQueryService`, `HomeRecommendationQueryService`, `HomeFollowingQueryService`는 요청 Lang을 명시 전달받으며 더 이상 필요 없는 `LangContext` 생성자 의존성을 제거했다. 기존 locale port를 그대로 쓰는 경우 코드 ↔ enum 매핑에는 `Lang.fromCode`만 사용한다. 나머지 채널 서비스의 기존 본문/제목 번역 context는 이번 이름 조회와 무관한 동작으로 유지한다.
혼합 writer/donor record에는 조회 결과의 `isLocalizedName`을 보존해 원본 `deleted_` prefix 정리만 유지하고 별칭 원문은 정리하지 않는다. 이 flag는 내부 `CreatorChannelCommunityCommentRecord`, `CreatorChannelFanTalkRecord/ReplyRecord`, 두 `CreatorChannelDonationRecord`, 홈 `CreatorChannelFanTalkRecord`에만 있으며 공개 DTO에는 추가하지 않는다. raw fixture의 기본 false와 QueryDSL의 실제 alias-row 존재 projection을 구분한다.
### 남은 batch와 미전환 읽기의 분류
| 정확한 경계 | 유지 이유와 현재 처리 |
|---|---|
| `M/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt#findCurrentNames` | `M/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt` → `M/explorer/profile/CreatorDonationRankingService.kt#getMemberDonationRanking`은 Lang 없는 Redis `creator_donation_ranking_v2:...`에 raw nickname을 저장한다. cache hit는 `cachedData.rankings`를 그대로 반환하므로 DB JOIN으로 치환할 수 없는 중립 재사용 경계다. rankings donor ID만 `findCurrentNames` 한 번으로 현재 별칭/원본을 함께 조회한다. 별도 `Member.findNicknamesByIds`는 제거했다. cache entry/producer는 변이하지 않는다. |
| `M/v2/gift/application/GiftQueryService.kt#getGifts/getGiftDetail` | 기프트·배송 업무 엔티티와 sender/recipient 회원을 이미 읽어 방향/상태/존재 검증, profile, 주소 공개 범위를 조립하는 기존 경계다. 이름만을 위해 기프트 query/permission DTO를 재설계하지 않았다. 각 use case에서 `findLocalizedNames` 한 번의 sparse batch와 이미 로드한 nickname fallback을 유지하며 추가 Member 이름 query는 없다. 실명/주소/category snapshot/status/size는 치환하지 않는다. |
| 두 ranking adapter의 previous/raw 조회와 snapshot refresh/aggregation | 이전 데이터는 rank 변화 계산 또는 중립 writer 입력이며 그 이름을 직접 HTTP 이름으로 노출하지 않는다. latest 표시 read만 현재 이름 JOIN으로 바꿨다. 실제 `v2/ranking`, `v2/content/ranking` 읽기에는 Redis/Cacheable 및 `.getFirst()` 기반 공유 raw ranking cache가 없고 DB snapshot을 직접 소비함을 read/rg로 확인했다. test fake의 ArrayDeque `firstOrNull`은 cache producer가 아니다. |
| `M/v2/home/following/adapter/out/persistence/HomeFollowingNewsInbox.kt`와 publish service | 언어 중립 저장값은 그대로다. 이미 현재 creator를 조인하는 HTTP consumer는 위에서 JOIN 전환했으므로 역사 snapshot 면제가 아니다. |
| admin/원본 역할 확인, AI 캐릭터 제목, content/series/live title | 운영 식별/원본 편집과 이름 아닌 필드는 기존 raw 정책을 유지한다. 언어별로 렌더되어 저장된 historical 이름으로 면제한 소유 v2 HTTP 필드는 없다. |
| `v2.creatorname`, `v2.usercreatorchat`, `v2.chat`, core/legacy/delivery | 다른 소유자 범위다. 공통 helper/batch 계약만 소비했으며 공유 파일을 수정하지 않았다. chat recentChats의 기존 Lang 호출은 보존했다. |
소유 범위 `findLocalizedNames/findCurrentNames/findNicknamesByIds` 재검색 결과는 위 기프트 두 호출과 donation cache의 current batch 한 호출뿐이다. 단순 current DB projection으로 남긴 alias batch는 없으며 entity/snapshot/inbox 원본도 변경하지 않았다.
### 테스트/생성자 적응과 정적 증거
- 기존 facade tests의 resolver mock/MemberRepository 생성자 인자를 제거했고 query service tests의 `LangContext` 인자는 필요한 곳만 제거했다. HomeRecommendation의 모든 직접 facade 생성(장애 로그 tests 포함), 채널 seven fake ports, home/following fake ports, 두 ranking fake ports 및 snapshot adapter 직접 생성자를 현재 계약으로 맞췄다. `DefaultAudioRankingSnapshotPersistenceAdapterTest`는 기존 EntityManager로 `JPAQueryFactory`를 전달한다. nullable dependency로 테스트를 우회하지 않는다.
- `T/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt#shouldSelectLocalizedCreatorNamesWithoutChangingAudioTitles`의 실제 standalone alias SQL counter 기대값 0을 그대로 유지했다. 같은 파일의 `#shouldReadActiveEnglishCreatorNameAndFallbackWhenInactive`는 활성 EN 읽기, 비활성 EN 원본 fallback, POPULAR 집계/page 개수와 제목 불변을 검증하도록 추가했다.
- `T/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt#shouldLocalizeCreatorAuthorsAndLatestRepliesWithoutChangingUserNames`의 JA/USER/본문 assertion을 유지하고 `deleted_`로 시작하는 별칭도 post/root comment/latest reply에서 원문 그대로인지 추가했다. 기존 삭제 원본 이름 정리 tests의 기대값은 완화하지 않았다.
- 추가로 적응한 ranking tests는 `T/v2/ranking/application/CreatorRankingQueryServiceTest.kt`, `T/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`, `T/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`, `T/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`, `T/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt`다. reflection 기반 service method 검사에도 Lang type을 추가했다. 일본어/영어 번역 기대 tests는 기존 언어를 명시 인자로 옮겼다.
- `read/rg` 및 임시 정적 점검으로 34개 관련 계약의 언어/locale 참조 468곳을 대조했고 알려진 누락은 0이었다. `src/main`, `src/test`의 직접 생성자/override, v2 외부 import/caller, 기존 생성 QMember/QSnapshot field와 native `:lang` 네 곳의 enum NAME binding을 대조했다. 이는 컴파일/실제 SQL 실행을 대신하는 성공 주장이 아니다.
- 이 writer는 요청대로 Gradle/서버/DB/agent/commit/push를 실행하지 않았다. `lsp_diagnostics` 도구도 제공되지 않아 미실행이다. 남은 runtime 확인은 H2/MySQL 대응 JOIN/COALESCE와 boolean/enum binding, 전체 consumer 회귀 및 parent GREEN이다.
### 부모 직렬 GREEN 명령, 이 단계 미실행
먼저 새 SQL counter RED의 같은 메서드와 EN/grouping 회귀를 실행하고, 아래 module/HTTP consumer 회귀 및 빌드/린트를 한 번에 하나씩 실행한다.
```bash
./gradlew compileTestKotlin --offline --console=plain
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest' --offline --console=plain
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest' --offline --console=plain
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --offline --console=plain
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.home.following.*' --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --offline --console=plain
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.gift.application.GiftQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest' --offline --console=plain
./gradlew ktlintCheck assemble --offline --console=plain
```
정적 인계 상태는 **parent GREEN 준비**이며 위 명령의 PASS/실제 HTTP QA 완료를 기록하지 않는다. 공통 및 다른 writer 변경이 합쳐진 전체 회귀와 실제 자원 종료 검증은 부모 JOIN-GATE 소유다.