# 크리에이터 채널 FanTalk 탭 API Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다. **Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 크리에이터 채널 FanTalk 탭의 전체 FanTalk 개수와 페이징된 FanTalk 글 목록, 크리에이터 답글을 조회할 수 있게 한다. **Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 조립 계층에 둔다. FanTalk 조회 service, page 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 하위에 두고 `v2.api.*`에 의존하지 않는다. 저장 엔티티는 legacy `CreatorCheers`를 그대로 사용하되, legacy timezone 기반 cheers 응답은 재사용하지 않고 V2 탭 전용 UTC 응답을 만든다. **Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper --- ## 0. 구현 전 확정 사항 - API endpoint: `GET /api/v2/creator-channels/{creatorId}/fan-talks` - 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다. - request: - path variable: `creatorId` - query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback - query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback - page 기준: 기존 크리에이터 채널 V2 탭 API와 동일한 0 기반 page index - response: - `fanTalkCount`: 조회자가 조회 가능한 최상위 FanTalk 전체 개수 - `fanTalks`: FanTalk 글 목록 - `page`: fallback 보정 후 실제 적용된 page index - `size`: fallback 보정 후 실제 적용된 page size - `hasNext`: 다음 page 존재 여부 - FanTalk item: - `fanTalkId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`, `creatorReplies` - creator reply item: - `fanTalkId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc` - 저장 엔티티: `kr.co.vividnext.sodalive.explorer.profile.CreatorCheers` - 최상위 FanTalk 기준: `creatorCheers.creator.id == creatorId`, `creatorCheers.isActive == true`, `creatorCheers.parent is null` - 크리에이터 답글 기준: `creatorCheers.parent.id in parentFanTalkIds`, `creatorCheers.creator.id == creatorId`, `creatorCheers.member.id == creatorId`, `creatorCheers.isActive == true` - 팬끼리 답글 작성은 현재 불가능하므로 응답 대상에 포함하지 않는다. 과거 데이터나 비정상 데이터로 크리에이터가 아닌 회원의 답글이 있어도 제외한다. - 목록 정렬: - 최상위 FanTalk: `createdAt desc`, `id desc` - 크리에이터 답글: `createdAt asc`, `id asc` - `fanTalkCount`는 최상위 FanTalk만 계산한다. 답글은 count에 포함하지 않는다. - `hasNext`는 `size + 1`개 조회 또는 동등한 방식으로 판단하고, 응답 목록에는 최대 `size`개만 내려준다. - 차단 필터: - 조회자와 FanTalk 작성자가 서로 차단 관계이면 해당 최상위 FanTalk는 목록과 count에서 제외한다. - 차단으로 제외된 최상위 FanTalk의 답글도 응답에 포함하지 않는다. - 조회자와 조회 대상 크리에이터 사이 차단 관계는 기존 크리에이터 채널 접근 정책과 동일하게 API 접근 자체를 거부한다. - creator 검증: - 조회 대상 회원이 없으면 `member.validation.user_not_found` - 조회 대상 회원이 크리에이터가 아니면 `member.validation.creator_not_found` - 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 차단 오류 - `createdAtUtc`는 `CreatorCheers.createdAt`을 `kr.co.vividnext.sodalive.extensions.toUtcIso`로 변환한다. - 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 없으면 기존 홈 API와 같은 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다. - 탈퇴 회원 닉네임 prefix 제거는 기존 legacy FanTalk 조회와 홈 FanTalk 요약 응답처럼 `removeDeletedNicknamePrefix()`를 적용한다. - `languageCode`는 FanTalk 탭 응답에 포함하지 않는다. - legacy `/profile/{id}/cheers` 공개 endpoint와 응답 스키마는 변경하지 않는다. - 크리에이터 채널 홈 API의 `fanTalk.totalCount`, `fanTalk.latestFanTalk` 공개 응답 의미는 변경하지 않는다. - DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다. --- ## 1. 파일 구조 계획 ### FanTalk 탭 신규 API 조립 계층 - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt` ### FanTalk 도메인 조회 계층 - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt` ### 기존 파일 확인/재사용 - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRole.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt` ### 문서 산출물 - Create: `docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md` - Verify: `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md` --- ## 2. Response data class 초안 구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다. ```kotlin package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.extensions.toUtcIso import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab data class CreatorChannelFanTalkTabResponse( val fanTalkCount: Int, val fanTalks: List, val page: Int, val size: Int, @JsonProperty("hasNext") val hasNext: Boolean ) { companion object { fun from(tab: CreatorChannelFanTalkTab): CreatorChannelFanTalkTabResponse { return CreatorChannelFanTalkTabResponse( fanTalkCount = tab.fanTalkCount, fanTalks = tab.fanTalks.map(CreatorChannelFanTalkResponse::from), page = tab.page.page, size = tab.page.size, hasNext = tab.hasNext ) } } } data class CreatorChannelFanTalkResponse( val fanTalkId: Long, val writerId: Long, val writerNickname: String, val writerProfileImageUrl: String, val content: String, val createdAtUtc: String, val creatorReplies: List ) { companion object { fun from(fanTalk: CreatorChannelFanTalk): CreatorChannelFanTalkResponse { return CreatorChannelFanTalkResponse( fanTalkId = fanTalk.fanTalkId, writerId = fanTalk.writerId, writerNickname = fanTalk.writerNickname, writerProfileImageUrl = fanTalk.writerProfileImageUrl, content = fanTalk.content, createdAtUtc = fanTalk.createdAt.toUtcIso(), creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from) ) } } } data class CreatorChannelFanTalkReplyResponse( val fanTalkId: Long, val writerId: Long, val writerNickname: String, val writerProfileImageUrl: String, val content: String, val createdAtUtc: String ) { companion object { fun from(reply: CreatorChannelFanTalkReply): CreatorChannelFanTalkReplyResponse { return CreatorChannelFanTalkReplyResponse( fanTalkId = reply.fanTalkId, writerId = reply.writerId, writerNickname = reply.writerNickname, writerProfileImageUrl = reply.writerProfileImageUrl, content = reply.content, createdAtUtc = reply.createdAt.toUtcIso() ) } } } ``` --- ## 3. Domain / Port 초안 구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다. ```kotlin package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage import java.time.LocalDateTime data class CreatorChannelFanTalkTab( val fanTalkCount: Int, val fanTalks: List, val page: CreatorChannelPage, val hasNext: Boolean ) data class CreatorChannelFanTalk( val fanTalkId: Long, val writerId: Long, val writerNickname: String, val writerProfileImageUrl: String, val content: String, val createdAt: LocalDateTime, val creatorReplies: List ) data class CreatorChannelFanTalkReply( val fanTalkId: Long, val writerId: Long, val writerNickname: String, val writerProfileImageUrl: String, val content: String, val createdAt: LocalDateTime ) ``` ```kotlin package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out import kr.co.vividnext.sodalive.member.MemberRole import java.time.LocalDateTime interface CreatorChannelFanTalkQueryPort { fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord? fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean fun countFanTalks(creatorId: Long, viewerId: Long): Int fun findFanTalks( creatorId: Long, viewerId: Long, offset: Long, limit: Int ): List fun findCreatorReplies( creatorId: Long, parentFanTalkIds: List ): List } data class CreatorChannelFanTalkCreatorRecord( val creatorId: Long, val role: MemberRole, val nickname: String ) data class CreatorChannelFanTalkRecord( val fanTalkId: Long, val writerId: Long, val writerNickname: String, val writerProfileImagePath: String?, val content: String, val createdAt: LocalDateTime ) data class CreatorChannelFanTalkReplyRecord( val fanTalkId: Long, val parentFanTalkId: Long, val writerId: Long, val writerNickname: String, val writerProfileImagePath: String?, val content: String, val createdAt: LocalDateTime ) ``` --- ## 4. Query policy 초안 구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`에 아래 정책을 둔다. ```kotlin package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage import org.springframework.stereotype.Component @Component class CreatorChannelFanTalkQueryPolicy { fun createPage(page: Int?, size: Int?): CreatorChannelPage { return CreatorChannelPage( page = page?.coerceAtLeast(MIN_PAGE) ?: DEFAULT_PAGE, size = size?.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE) ?: DEFAULT_PAGE_SIZE ) } fun limitItems(fetched: List, page: CreatorChannelPage): List { return fetched.take(page.size) } fun hasNext(fetched: List<*>, page: CreatorChannelPage): Boolean { return fetched.size > page.size } companion object { private const val DEFAULT_PAGE = 0 private const val DEFAULT_PAGE_SIZE = 20 private const val MIN_PAGE = 0 private const val MIN_PAGE_SIZE = 20 private const val MAX_PAGE_SIZE = 50 } } ``` --- ## 5. 구현 TASK ### Phase 1: FanTalk 도메인 모델과 페이징 정책 - [x] **Task 1.1: FanTalk 페이징 정책 테스트와 구현** - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt` - RED: `page`, `size` 보정과 `hasNext`, `limitItems` 동작 테스트를 먼저 작성한다. - 테스트 케이스: - `page == null`, `size == null`이면 `page=0`, `size=20` - `page < 0`이면 `0` - `size < 20`이면 `20` - `size > 50`이면 `50` - fetched size가 `size + 1`이면 `hasNext == true` - fetched size가 `size` 이하이면 `hasNext == false` - `limitItems`는 최대 `size`개만 반환 - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest` - GREEN: `CreatorChannelFanTalkQueryPolicy`를 `CreatorChannelCommunityQueryPolicy`와 같은 보정 규칙으로 최소 구현한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest` - REFACTOR: 상수와 메서드명이 커뮤니티/시리즈 탭 정책과 일관되는지 확인한다. - [x] **Task 1.2: FanTalk domain model과 port 계약 추가** - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt` - RED: Task 1.1 테스트에 domain/port 타입 import를 추가해 타입 부재 컴파일 실패를 확인한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest` - GREEN: `CreatorChannelFanTalkTab`, `CreatorChannelFanTalk`, `CreatorChannelFanTalkReply`, `CreatorChannelFanTalkQueryPort`, record data class를 추가한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest` - REFACTOR: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain`과 `port/out`에서 `v2.api` import가 없는지 확인한다. - 확인 명령: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` ### Phase 2: API 응답 DTO와 조립 계층 - [x] **Task 2.1: FanTalk 응답 DTO와 UTC 변환 테스트** - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt` - RED: facade 테스트에서 domain tab을 response로 변환했을 때 필드명과 UTC 문자열이 PRD와 일치하는지 검증한다. - 검증 값: - `fanTalkCount` - `fanTalks[0].writerId` - `fanTalks[0].writerNickname` - `fanTalks[0].writerProfileImageUrl` - `fanTalks[0].content` - `fanTalks[0].createdAtUtc` - `fanTalks[0].creatorReplies[0].writerId` - `page` - `size` - JSON 직렬화 필드명 `hasNext` - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest` - GREEN: DTO를 추가하고 `createdAt.toUtcIso()`를 사용해 UTC ISO 문자열을 내려준다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest` - REFACTOR: `languageCode`가 응답 DTO에 포함되지 않았는지 확인한다. - 확인 명령: `rg -n "languageCode" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk` - [x] **Task 2.2: FanTalk facade 추가** - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt` - RED: facade가 query service의 `getFanTalkTab(creatorId, viewer, page, size, now)` 결과를 `CreatorChannelFanTalkTabResponse`로 변환하는 테스트를 작성한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest` - GREEN: `CreatorChannelFanTalkFacade`를 `@Service`, `@Transactional(readOnly = true)`로 추가한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest` - REFACTOR: facade가 API DTO와 domain query service 조립 외 책임을 갖지 않는지 확인한다. - [x] **Task 2.3: FanTalk controller 추가** - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt` - RED: MockMvc 테스트를 작성한다. - `GET /api/v2/creator-channels/{creatorId}/fan-talks?page=1&size=20` 요청이 facade에 `creatorId`, `page=1`, `size=20`을 전달한다. - 비회원 요청은 `common.error.bad_credentials` 계열 오류를 반환한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` - GREEN: `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/fan-talks")`, `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 구조로 controller를 추가한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` - REFACTOR: controller가 `ApiResponse.ok(...)`와 `requireMember` 외 응답 가공 책임을 갖지 않는지 확인한다. ### Phase 3: FanTalk 조회 서비스 - [x] **Task 3.1: query service의 creator 검증과 접근 차단 처리** - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` - RED: query service 테스트를 작성한다. - creator가 없으면 `SodaException(messageKey = "member.validation.user_not_found")` - creator role이 `MemberRole.CREATOR`가 아니면 `SodaException(messageKey = "member.validation.creator_not_found")` - 조회자와 크리에이터 사이 차단 관계가 있으면 기존 채널 접근 차단 오류 - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest` - GREEN: `CreatorChannelFanTalkQueryService`를 추가하고 `findCreator`, `existsBlockedBetween`, role 검증을 구현한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest` - REFACTOR: 에러 키와 차단 메시지 흐름이 커뮤니티/홈 query service와 같은지 확인한다. - [x] **Task 3.2: query service의 page/count/list/reply 조립** - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt` - RED: query service 테스트를 추가한다. - `page=-1`, `size=10` 요청 시 port에는 `offset=0`, `limit=21`이 전달되고 응답 `page=0`, `size=20` - fetched FanTalk가 `size + 1`개이면 응답 목록은 `size`개이고 `hasNext=true` - fetched FanTalk가 비어 있으면 `fanTalks=[]`, `hasNext=false` - `countFanTalks` 결과가 `fanTalkCount`로 내려간다. - `findCreatorReplies` 결과는 parent id 기준으로 각 FanTalk의 `creatorReplies`에 묶인다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest` - GREEN: `CreatorChannelFanTalkQueryPolicy`로 page를 만들고, `countFanTalks`, `findFanTalks`, `findCreatorReplies`를 호출해 `CreatorChannelFanTalkTab`을 조립한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest` - REFACTOR: reply 조회는 page에 포함된 parent FanTalk id만 대상으로 호출하는지 확인한다. - [x] **Task 3.3: query service의 URL/닉네임 변환** - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt` - RED: query service 테스트를 추가한다. - writer profile path가 있으면 CDN URL로 변환한다. - writer profile path가 없으면 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다. - writer nickname은 `removeDeletedNicknamePrefix()` 결과를 내려준다. - reply writer도 같은 URL/닉네임 변환을 적용한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest` - GREEN: `String?.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl()`와 `removeDeletedNicknamePrefix()`를 적용한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest` - REFACTOR: default profile URL 생성 방식이 홈/커뮤니티 query service와 일관되는지 확인한다. ### Phase 4: QueryDSL repository - [x] **Task 4.1: FanTalk repository 기본 creator/차단 조회** - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` - RED: repository 테스트를 작성한다. - `findCreator`가 creator id, role, nickname을 조회한다. - `existsBlockedBetween`가 양방향 활성 차단 관계를 감지한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` - GREEN: `JPAQueryFactory` 기반 repository를 추가하고 홈/커뮤니티 repository와 같은 creator/차단 조건을 구현한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` - REFACTOR: repository class 이름은 `Default...Repository` 접두사 규칙을 따른다. - [x] **Task 4.2: 최상위 FanTalk count/list 조회** - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt` - RED: repository 테스트를 추가한다. - `countFanTalks`는 `creator.id`, `isActive=true`, `parent is null` 조건만 count한다. - 비활성 FanTalk는 count/list에서 제외한다. - 답글 FanTalk는 count/list에서 제외한다. - 조회자와 작성자 사이 차단 관계가 있으면 count/list에서 제외한다. - 목록 정렬은 `createdAt desc`, `id desc`다. - `offset`, `limit`이 적용된다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` - GREEN: `countFanTalks`, `findFanTalks`를 구현한다. projection은 `CreatorChannelFanTalkRecord`를 사용한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` - REFACTOR: 홈 API의 `fanTalkSummaryCondition`과 조건 의미가 일치하는지 확인한다. - [x] **Task 4.3: 크리에이터 답글 조회** - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt` - RED: repository 테스트를 추가한다. - `findCreatorReplies`는 parent id 목록에 속한 활성 답글만 조회한다. - 답글 작성자가 조회 대상 크리에이터인 데이터만 조회한다. - 크리에이터가 아닌 회원의 답글은 제외한다. - 비활성 답글은 제외한다. - 답글 정렬은 `createdAt asc`, `id asc`다. - `parentFanTalkIds`가 빈 목록이면 빈 목록을 반환하고 DB 조회 결과가 없어야 한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` - GREEN: `findCreatorReplies`를 구현한다. projection은 `CreatorChannelFanTalkReplyRecord`를 사용한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` - REFACTOR: reply 조회가 최상위 FanTalk page 결과 외 parent를 가져오지 않는지 확인한다. ### Phase 5: API 통합과 회귀 검증 - [x] **Task 5.1: FanTalk End-to-End 테스트** - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt` - RED: E2E 테스트를 작성한다. - 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/fan-talks?page=0&size=20` 호출 시 200 OK - 응답 JSON에 `fanTalkCount`, `fanTalks`, `page`, `size`, `hasNext`가 포함된다. - 최상위 FanTalk의 `createdAtUtc`는 UTC ISO 문자열이다. - 크리에이터 답글은 `creatorReplies`에 포함된다. - 팬이 작성한 비정상 답글 데이터는 응답에 포함되지 않는다. - page 범위를 벗어나면 빈 목록과 `hasNext=false`를 반환하되 count는 유지한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` - GREEN: Phase 1~4 구현을 연결해 E2E 테스트를 통과시킨다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` - REFACTOR: 테스트 데이터가 다른 크리에이터 채널 탭 테스트와 충돌하지 않도록 독립 fixture를 사용한다. - [x] **Task 5.2: 패키지 의존 방향과 기존 API 회귀 확인** - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt` - RED: 신규 테스트 추가는 없다. 이 task는 문서화된 구조 검증 task다. - TDD 예외 사유: 패키지 의존 방향과 기존 endpoint 비변경 여부는 정적 검색과 기존 회귀 테스트가 더 직접적인 검증이다. - 대체 검증 방법: - `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` - `rg -n "fan-talks|/profile/\\{id\\}/cheers|latestFanTalk" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/explorer` - GREEN: `v2.creator.channel.fantalk` 하위에서 `v2.api.*` import 검색 결과가 0건인지 확인한다. - 통과 확인: - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest` - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` - REFACTOR: 홈 API와 legacy cheers endpoint의 공개 응답 스키마를 변경한 파일 diff가 없는지 확인한다. - [x] **Task 5.3: 전체 FanTalk 관련 테스트와 ktlint 검증** - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk` - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` - RED: 신규 테스트 추가는 없다. 이 task는 구현 완료 후 회귀 검증 task다. - TDD 예외 사유: 전체 회귀와 ktlint는 구현 완료 상태를 검증하는 명령 실행 task다. - 대체 검증 방법: - `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` - `./gradlew ktlintCheck` - GREEN: 실패하는 FanTalk 관련 테스트나 ktlint 오류가 있으면 해당 task의 구현 단계로 돌아가 수정한다. - 통과 확인: - `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` - `./gradlew ktlintCheck` - REFACTOR: 필요한 경우 `./gradlew test`를 추가 실행하고 결과를 이 문서 하단 검증 기록에 누적한다. --- ## 6. 구현 시 주의사항 - 구현 전에 이 문서와 `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md`가 같은 endpoint, page 기준, response field를 말하는지 다시 확인한다. - 신규 공개 API 스키마 변경이 필요하면 구현 전에 PRD와 이 문서를 먼저 수정한다. - `CreatorCheers` 엔티티 자체 구조는 변경하지 않는다. - legacy `ExplorerQueryRepository.getCheersList`는 timezone 표시 문자열을 만들기 때문에 신규 V2 응답 DTO에 재사용하지 않는다. - FanTalk 탭 query service는 홈 API query service에 의존하지 않는다. - 홈 API의 `findFanTalkSummary`는 이번 작업에서 수정하지 않는 것을 기본으로 한다. 수정이 필요해지면 PRD와 이 문서를 먼저 갱신한다. - controller/facade/DTO 조립 계층은 `v2.api.creator.channel.fantalk`에만 둔다. - domain/application/port/repository 조회 계층은 `v2.creator.channel.fantalk`에만 둔다. - 테스트 작성 시 Redis가 필요 없는 JPA/QueryDSL slice 테스트는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 관례를 따른다. - 테스트 완료 후 각 task 아래에 실행 명령과 결과를 한국어로 누적 기록한다. --- ## 7. 검증 기록 - 문서 생성 시점에는 구현 코드를 작성하지 않았으므로 신규 테스트는 실행하지 않았다. - 문서 변경 검증으로 `./gradlew tasks --all`을 실행했다. - sandbox 일반 실행은 Gradle wrapper가 `/Users/klaus/.gradle/wrapper/dists/gradle-8.1.1-bin/9wiye5v2saajue4irfo8ybqfp/gradle-8.1.1-bin.zip.lck`에 접근하지 못해 `Operation not permitted`로 실패했다. - 권한 승인 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다. - Phase 1 Task 1.1/1.2 구현 검증을 진행했다. - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest` 실행 시 `CreatorChannelFanTalkQueryPolicy`, FanTalk domain model, FanTalk port record 미존재로 `compileTestKotlin` 실패를 확인했다. - GREEN: FanTalk 페이징 정책, domain model, port 계약 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다. - 의존 방향 확인: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다. - `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다. - Phase 2 Task 2.1/2.2 구현 검증을 진행했다. - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest` 실행 시 FanTalk 응답 DTO, FanTalk facade, FanTalk query service 타입 미존재로 `compileTestKotlin` 실패를 확인했다. - GREEN: FanTalk 응답 DTO, FanTalk facade, Phase 3 구현 전 facade 컴파일을 위한 `CreatorChannelFanTalkQueryService` 최소 shell 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다. - Phase 2 범위 준수: `CreatorChannelFanTalkQueryService`는 최종 public method signature만 두고 조회/검증/DB/port 구현은 추가하지 않았다. - Phase 2 Task 2.3 구현 검증을 진행했다. - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 실행 시 `CreatorChannelFanTalkController` 미존재로 `compileTestKotlin` 실패를 확인했다. - GREEN: FanTalk controller 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다. - `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다. - Phase 3 Task 3.1/3.2/3.3 구현 검증을 진행했다. - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest` 실행 시 `CreatorChannelFanTalkQueryService` 생성자 의존성 미구현으로 `compileTestKotlin` 실패를 확인했다. - GREEN: FanTalk query service의 creator 검증, 접근 차단, page/count/list/reply 조립, CDN URL/default profile URL, 탈퇴 닉네임 prefix 제거 구현 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다. - 회귀 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`와 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다. - FanTalk 관련 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다. - ktlint 확인: `./gradlew ktlintCheck` 최초 실행 시 신규 테스트의 긴 assertion 줄로 실패했고, 테스트 포맷만 수정한 뒤 재실행해 `BUILD SUCCESSFUL`을 확인했다. - `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다. - Phase 4 Task 4.1/4.2/4.3 구현 검증을 진행했다. - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` 실행 시 `DefaultCreatorChannelFanTalkQueryRepository` 미존재로 `compileTestKotlin` 실패를 확인했다. - GREEN: FanTalk QueryDSL repository의 creator 조회, 양방향 차단 조회, 최상위 FanTalk count/list, 크리에이터 답글 조회 구현 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다. - `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다. - 회귀 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - FanTalk 관련 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다. - ktlint 확인: `./gradlew ktlintCheck` 최초 실행 시 신규 테스트의 긴 fixture 호출 줄로 실패했고, 테스트 포맷만 수정한 뒤 재실행해 `BUILD SUCCESSFUL`을 확인했다. - Phase 4 코드 리뷰 및 재검증을 진행했다. - 리뷰 범위: `DefaultCreatorChannelFanTalkQueryRepository`, `CreatorChannelFanTalkQueryRepository`, `CreatorChannelFanTalkQueryPort`, `DefaultCreatorChannelFanTalkQueryRepositoryTest`, `CreatorChannelFanTalkQueryService` 연동부를 PRD/plan의 Phase 4 요구사항과 대조했다. - 리뷰 결과: creator/차단 조회, 최상위 FanTalk count/list 조건, 정렬, offset/limit, 크리에이터 답글 필터와 빈 parent 목록 처리에서 수정이 필요한 결함을 발견하지 않았다. - 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 관련 회귀 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - FanTalk 전체 재검증: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 의존 방향 재확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다. - ktlint 재확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - Phase 5 Task 5.1 구현 검증을 진행했다. - GREEN: `CreatorChannelFanTalkEndToEndTest`를 추가해 인증 회원의 FanTalk 탭 200 OK, 응답 필드, UTC ISO 문자열, 크리에이터 답글 포함, 팬 작성 답글 제외, 범위 밖 page의 빈 목록/count 유지 동작을 검증했다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다. - Phase 5 Task 5.2 회귀 검증을 진행했다. - 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다. - API 참조 확인: `rg -n "fan-talks|/profile/\{id\}/cheers|latestFanTalk" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/explorer` 실행 결과 신규 `fan-talks` controller 매핑과 기존 legacy cheers/home latestFanTalk 참조만 확인했다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - Phase 5 Task 5.3 전체 FanTalk 관련 테스트와 ktlint 검증을 진행했다. - FanTalk 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - ktlint 확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.