# 크리에이터 채널 커뮤니티 탭 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}/community`로 크리에이터 채널 커뮤니티 탭의 조회 가능한 전체 게시글 개수와 페이징된 게시글 목록을 조회할 수 있게 한다. **Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 조립 계층에 둔다. 커뮤니티 게시글 조회 service, page/content masking 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 두고 `v2.api.*`에 의존하지 않는다. 홈 API는 홈 repository에 커뮤니티 조회 쿼리를 직접 두지 않고, 분리된 커뮤니티 조회 도메인의 홈 요약 조회 메서드를 호출해 기존 `notices`, `communities` 응답 계약을 유지한다. **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}/community` - 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 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 - response: - `communityPostCount`: 조회자가 조회 가능한 커뮤니티 게시글 전체 개수 - `communityPosts`: 커뮤니티 게시글 목록 - `page`: fallback 보정 후 실제 적용된 page index - `size`: fallback 보정 후 실제 적용된 page size - `hasNext`: 다음 page 존재 여부 - community post item: - `postId`, `creatorId`, `creatorNickname`, `creatorProfileUrl`, `createdAtUtc`, `content`, `imageUrl`, `audioUrl`, `price`, `isCommentAvailable`, `existOrdered`, `likeCount`, `commentCount`, `isPinned` - 공개 게시글 기준: `CreatorCommunity.isActive == true`, `CreatorCommunity.member.id == creatorId`, `CreatorCommunity.member.isActive == true`. - 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다. - 성인 콘텐츠 필터는 구매 여부보다 우선한다. 조회자가 19금 게시글을 구매했거나 작성자여도 성인 콘텐츠 노출 정책이 false이면 제외한다. - 목록 정렬: - 고정 게시글을 먼저 노출한다. - 고정 게시글 사이의 정렬은 `fixedAt desc`, `id desc`다. - 일반 게시글 사이의 정렬은 `createdAt desc`, `id desc`다. - 고정 게시글과 일반 게시글은 하나의 목록으로 페이징한다. - `communityPostCount`는 고정 게시글과 일반 게시글을 모두 포함한 전체 개수다. - `createdAtUtc`는 게시글 생성 시각을 UTC 기준 ISO-8601 문자열로 내려준다. 구현 전 재사용 가능한 `toUtcIso` 확장함수를 검색하고, public 확장함수가 있으면 신규 생성 없이 import해서 사용한다. - 문서 작성 시점 확인 결과 `toUtcIso`는 일부 DTO의 private/internal 확장함수로만 존재하고, 공용 확장 파일인 `kr.co.vividnext.sodalive.extensions.LocalDateTimeExtensions.kt`에는 없다. 구현 시점에도 public 확장함수가 없으면 이 공용 확장 파일에 `fun LocalDateTime.toUtcIso(): String`을 추가하고 커뮤니티 DTO에서 import한다. - `creatorProfileUrl`은 `CreatorCommunity.member.profileImage`를 `String?.toCdnUrl(cloudFrontHost)`로 변환하고, 없으면 기존 홈 API의 기본 프로필 이미지 URL을 내려준다. - `existOrdered`는 조회자가 게시글 작성자이면 `true`, 조회자가 유효 구매 내역을 가지고 있으면 `true`, 그 외에는 `false`로 내려준다. - `imageUrl`은 `CreatorCommunity.imagePath`가 있고 이미지 접근 권한이 있을 때만 `String?.toCdnUrl(cloudFrontHost)`로 변환한다. 경로가 없거나 blank이면 `null`이다. - legacy 커뮤니티 목록은 유료 미구매 게시글의 이미지를 노출했지만, 커뮤니티 탭 API는 유료 미구매 게시글의 `imageUrl`도 `audioUrl`과 동일하게 `null`로 내려준다. - 이미지는 signed URL을 생성하지 않고 기존 CDN URL 조합 정책만 사용한다. - `audioUrl`은 `CreatorCommunity.audioPath`가 있고 접근 권한이 있을 때만 `AudioContentCloudFront.generateSignedURL(resourcePath, 1000 * 60 * 30)` 결과를 내려준다. - 이미지/오디오 접근 권한: - 무료 게시글이면 접근 가능 - 유료 게시글이고 조회자가 게시글 작성자이면 접근 가능 - 유료 게시글이고 조회자가 `CanUsage.PAID_COMMUNITY_POST`, `isRefund == false` 구매 내역을 가지면 접근 가능 - 그 외에는 `imageUrl == null`, `audioUrl == null` - 유료 게시글 본문은 기존 홈 API/legacy 커뮤니티 정책과 같은 마스킹을 적용한다. - 접근 가능하면 원문 - 접근 불가이고 길이가 15 code point 초과이면 앞 15 code point + `...` - 접근 불가이고 길이가 15 code point 이하이면 앞 절반 code point + `...` - `commentCount`는 `isCommentAvailable == false`이면 `0`이다. - `commentCount`는 활성 최상위 댓글만 세고, 기존 커뮤니티 댓글의 차단 관계와 비밀 댓글 노출 정책을 적용한다. - `likeCount`는 활성 좋아요 수만 센다. - legacy `/creator-community` 공개 endpoint는 변경하지 않는다. - 홈 API 공개 응답 스키마는 변경하지 않는다. --- ## 1. 파일 구조 계획 ### 커뮤니티 탭 신규 API 조립 계층 - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt` ### 커뮤니티 도메인 조회 계층 - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt` ### 홈 API 커뮤니티 조회 분리 대상 - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt` ### 기존 파일 확인/재사용 - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt` - Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/like/CreatorCommunityLikeRepository.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt` ### 문서 산출물 - Create: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md` - Verify: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/prd.md` --- ## 2. Response data class 초안 구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다. ```kotlin package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.extensions.toUtcIso import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab data class CreatorChannelCommunityTabResponse( val communityPostCount: Int, val communityPosts: List, val page: Int, val size: Int, @JsonProperty("hasNext") val hasNext: Boolean ) { companion object { fun from(tab: CreatorChannelCommunityTab): CreatorChannelCommunityTabResponse { return CreatorChannelCommunityTabResponse( communityPostCount = tab.communityPostCount, communityPosts = tab.communityPosts.map(CreatorChannelCommunityPostResponse::from), page = tab.page.page, size = tab.page.size, hasNext = tab.hasNext ) } } } data class CreatorChannelCommunityPostResponse( val postId: Long, val creatorId: Long, val creatorNickname: String, val creatorProfileUrl: String, val createdAtUtc: String, val content: String, val imageUrl: String?, val audioUrl: String?, val price: Int, @JsonProperty("isCommentAvailable") val isCommentAvailable: Boolean, val existOrdered: Boolean, val likeCount: Int, val commentCount: Int, @JsonProperty("isPinned") val isPinned: Boolean ) { companion object { fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse { return CreatorChannelCommunityPostResponse( postId = post.postId, creatorId = post.creatorId, creatorNickname = post.creatorNickname, creatorProfileUrl = post.creatorProfileUrl, createdAtUtc = post.createdAt.toUtcIso(), content = post.content, imageUrl = post.imageUrl, audioUrl = post.audioUrl, price = post.price, isCommentAvailable = post.isCommentAvailable, existOrdered = post.existOrdered, likeCount = post.likeCount, commentCount = post.commentCount, isPinned = post.isPinned ) } } } ``` --- ## 3. Domain / Port 초안 구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다. ```kotlin package kr.co.vividnext.sodalive.v2.creator.channel.community.domain import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage import java.time.LocalDateTime data class CreatorChannelCommunityTab( val communityPostCount: Int, val communityPosts: List, val page: CreatorChannelPage, val hasNext: Boolean ) data class CreatorChannelCommunityPost( val postId: Long, val creatorId: Long, val creatorNickname: String, val creatorProfileUrl: String, val imageUrl: String?, val audioUrl: String?, val content: String, val price: Int, val createdAt: LocalDateTime, val existOrdered: Boolean, val isCommentAvailable: Boolean, val likeCount: Int, val commentCount: Int, val isPinned: Boolean ) ``` ```kotlin package kr.co.vividnext.sodalive.v2.creator.channel.community.port.out import kr.co.vividnext.sodalive.member.MemberRole import java.time.LocalDateTime interface CreatorChannelCommunityQueryPort { fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean fun countCommunityPosts( creatorId: Long, viewerId: Long, canViewAdultContent: Boolean ): Int fun findCommunityPosts( creatorId: Long, viewerId: Long, canViewAdultContent: Boolean, offset: Long, limit: Int ): List fun findHomeCommunityPosts( creatorId: Long, viewerId: Long, isPinned: Boolean, canViewAdultContent: Boolean, limit: Int ): List } data class CreatorChannelCommunityCreatorRecord( val creatorId: Long, val role: MemberRole, val nickname: String ) data class CreatorChannelCommunityPostRecord( val postId: Long, val creatorId: Long, val creatorNickname: String, val creatorProfilePath: String?, val imagePath: String?, val audioPath: String?, val content: String, val price: Int, val createdAt: LocalDateTime, val existOrdered: Boolean, val isCommentAvailable: Boolean, val likeCount: Int, val commentCount: Int, val isPinned: Boolean ) ``` --- ## 4. 작업 계획 ### Phase 1: 커뮤니티 도메인 계약과 순수 정책 추가 - [x] **Task 1.1: `CreatorChannelCommunityQueryPolicy`와 domain/port 계약 테스트 작성** - Files: - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt` - RED: 아래 케이스를 테스트로 먼저 작성한다. - `page = null`, `size = null`이면 `page=0`, `size=20`, `offset=0`, `fetchLimit=21`이다. - `page = -1`, `size = 10`이면 `page=0`, `size=20`, `fetchLimit=21`이다. - `page = 2`, `size = 100`이면 `page=2`, `size=50`, `offset=100`, `fetchLimit=51`이다. - `limitItems`는 `size`만큼만 남기고 `hasNext`는 `fetched.size > size`로 계산한다. - 유료 본문 마스킹은 15 code point 초과면 앞 15자 + `...`, 15자 이하면 앞 절반 + `...`로 계산한다. - 무료 게시글, 작성자 본인, 구매자는 유료 본문 원문을 볼 수 있다. - domain model과 port record가 Phase 1 계약 필드를 유지한다. - RED 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"` - 기대 결과: `CreatorChannelCommunityQueryPolicy`, domain, port 미구현으로 컴파일 실패 또는 테스트 실패 - GREEN: `CreatorChannelPage`를 재사용해 page 정책을 만들고, `maskPaidContent(content, price, isCreatorSelf, existOrdered)` 순수 함수를 추가한다. - GREEN 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"` - 기대 결과: `BUILD SUCCESSFUL` - REFACTOR: 정책 클래스에는 DB, Spring MVC, API DTO 의존성을 넣지 않는다. - 검증 기록: - RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"` 실행 결과 `CreatorChannelCommunityQueryPolicy`, domain, port 미구현 심볼로 `compileTestKotlin` 실패를 확인했다. - GREEN: 같은 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 범위: Phase 1의 순수 정책, domain model, port 계약, 계약 테스트만 추가했고 DB/Spring MVC/API DTO 의존성은 넣지 않았다. ### Phase 2: QueryDSL repository 분리와 조회 정책 구현 - [x] **Task 2.1: 커뮤니티 repository의 creator/차단/count/list 조회 테스트 작성** - Files: - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt` - RED: `@DataJpaTest(properties = ["spring.cache.type=none", "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"])`, `@Import(QueryDslConfig::class)` 패턴으로 아래 케이스를 작성한다. - 활성 creator는 `findCreator`로 조회되고 비활성 creator는 `null`이다. - viewer와 creator 사이 양방향 활성 차단 관계는 `existsBlockedBetween`에서 `true`다. - `countCommunityPosts`는 creator의 활성 게시글만 세고 다른 creator, 비활성 게시글은 제외한다. - `canViewAdultContent=false`이면 19금 게시글은 count와 list에서 제외된다. - `canViewAdultContent=false`이고 viewer가 19금 게시글을 구매했어도 count와 list에서 제외된다. - list는 고정 게시글을 먼저 반환하고, 고정 게시글은 `fixedAt desc`, 일반 게시글은 `createdAt desc` 순서를 따른다. - `offset`, `limit`으로 하나의 통합 목록을 페이징한다. - `likeCount`는 활성 좋아요만 센다. - `isCommentAvailable=false`인 게시글의 `commentCount`는 `0`이다. - `commentCount`는 활성 최상위 댓글만 세고, 비밀 댓글은 작성자 본인 또는 콘텐츠 creator에게만 포함한다. - 차단 관계에 걸린 댓글 작성자의 댓글은 `commentCount`에서 제외된다. - 유효 구매 내역은 `CanUsage.PAID_COMMUNITY_POST`, `UseCan.member.id == viewerId`, `UseCan.communityPost.id == postId`, `UseCan.isRefund == false`다. - 같은 게시글에 구매 내역이 중복되어도 list item은 중복되지 않는다. - 조회자가 게시글 작성자이면 구매 내역이 없어도 `existOrdered == true`다. - RED 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest"` - 기대 결과: repository 미구현으로 컴파일 실패 또는 테스트 실패 - GREEN: 기존 `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`, `orderedCommunityPostIds`, `communityLikeCounts`, `communityCommentCounts`, 차단 sub query, adult condition을 커뮤니티 repository로 옮기되 탭용 통합 정렬과 count를 추가한다. - GREEN 구현 기준: - tab list where는 `isActive == true`, `member.id == creatorId`, `member.isActive == true`, adult condition을 먼저 적용한다. - 구매 내역 exists/join은 접근 권한 계산에만 사용하고 adult condition을 우회하지 않는다. - 정렬은 `isFixed desc`, `fixedAt desc nullsLast`, `createdAt desc`, `id desc`를 사용한다. - home summary 조회는 `findHomeCommunityPosts(creatorId, viewerId, isPinned, canViewAdultContent, limit)`로 제공하고, 기존 홈과 동일하게 고정글/일반글을 분리 조회한다. - GREEN 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest"` - 기대 결과: `BUILD SUCCESSFUL` - REFACTOR: `v2.creator.channel.community.adapter.out.persistence`는 `v2.api.*`를 import하지 않는다. - 검증 기록: - RED: focused test 실행 결과 `DefaultCreatorChannelCommunityQueryRepository` 미구현으로 `compileTestKotlin` 실패를 확인했다. - GREEN: repository 구현 추가 후 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 계약 보정: block fixture와 구현을 양방향 활성 차단 정책에 맞춘 뒤 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - Review follow-up RED: raw `createdAt`, 같은 `fixedAt` 고정글 `id desc`, 홈 구매자 비활성 게시글 비노출 테스트 추가 후 focused test 2건 실패를 확인했다. - Review follow-up GREEN: repository 보정 후 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 전체 테스트: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - ktlint: `./gradlew --no-daemon ktlintCheck`는 `DefaultCreatorChannelCommunityQueryRepository.kt` 1개 줄에서 처음 실패했고, formatting 후 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 범위: Phase 2 repository/test 파일만 추가했고 service/API/home refactor는 건드리지 않았다. ### Phase 3: 커뮤니티 조회 service 구현 - [x] **Task 3.1: `CreatorChannelCommunityQueryService` 단위 테스트 작성** - Files: - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` - RED: fake `CreatorChannelCommunityQueryPort`, mock `MemberContentPreferenceService`, mock `AudioContentCloudFront`, `LangContext`, `SodaMessageSource`를 사용해 아래 케이스를 작성한다. - 요청 page/size fallback 결과를 port의 `offset`, `limit`에 전달하고 `hasNext`와 응답 목록 size를 조립한다. - creator가 없으면 `member.validation.user_not_found` 예외를 던진다. - creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던진다. - 차단 관계가 있으면 기존 `explorer.creator.blocked_access` 메시지 예외를 던진다. - `MemberContentPreferenceService`와 `isAdultVisibleByPolicy` 결과를 port의 `canViewAdultContent`로 전달한다. - 이미지 path는 `toCdnUrl(cloudFrontHost)`로 변환하고 blank path는 `null`이다. - 작성자 프로필 path가 없으면 기존 홈 API의 기본 프로필 URL 정책을 적용한다. - 조회자가 게시글 작성자이면 구매 내역이 없어도 `existOrdered == true`로 조립한다. - 무료 이미지, 구매한 유료 이미지, 작성자 본인 유료 이미지는 CDN URL을 사용하고 signed URL을 생성하지 않는다. - 미구매 유료 이미지는 `imageUrl == null`이다. - 무료 오디오, 구매한 유료 오디오, 작성자 본인 유료 오디오는 `AudioContentCloudFront.generateSignedURL(audioPath, 1000 * 60 * 30)` 결과를 사용한다. - 미구매 유료 오디오는 signed URL을 생성하지 않고 `audioUrl == null`이다. - 유료 미구매 본문은 policy의 마스킹 결과를 사용한다. - `findHomeCommunityPosts`는 탭 전체 검증 없이 받은 `viewerId`, `canViewAdultContent`, `isPinned`, `limit`로 홈 요약 목록을 조립한다. - RED 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"` - 기대 결과: service 미구현으로 컴파일 실패 또는 테스트 실패 - GREEN: `getCommunityTab(creatorId, viewer, page, size, now)`와 `findHomeCommunityPosts(creatorId, viewerId, isPinned, canViewAdultContent, limit)`를 구현한다. - GREEN 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"` - 기대 결과: `BUILD SUCCESSFUL` - REFACTOR: signed URL 생성은 접근 가능한 오디오 path가 있을 때만 호출하고, 이미지는 signed URL 생성 대상에서 제외한다. service는 API DTO를 반환하지 않는다. - 검증 기록: - RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"` 실행 결과 `CreatorChannelCommunityQueryService` 미구현 심볼로 `compileTestKotlin` 실패를 확인했다. - GREEN: service 구현 추가 후 같은 focused test 실행 중 Phase 1 마스킹 정책 기대값(`15 code point 이하이면 앞 절반 + ...`)과 테스트 기대값 불일치 1건을 확인했고, 테스트 기대값을 정책에 맞춘 뒤 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 범위: Phase 3 service/test 파일만 추가했고 API DTO/controller/facade와 홈 API 연결은 건드리지 않았다. ### Phase 4: 홈 API 커뮤니티 조회 로직을 새 도메인으로 연결 - [x] **Task 4.1: 홈 service 회귀 테스트를 새 커뮤니티 service 의존으로 갱신** - Files: - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt` - RED: 기존 홈 service 테스트에서 home query port의 `findCommunityPosts` stub 대신 `CreatorChannelCommunityQueryService.findHomeCommunityPosts` 결과를 사용하도록 테스트를 먼저 바꾼다. - `notices`는 `isPinned=true`, `limit=3`으로 조회한다. - `communities`는 `isPinned=false`, `limit=3`으로 조회한다. - 홈 응답의 커뮤니티 필드명은 유지하되, 커뮤니티 도메인 정책에 맞춰 유료 미구매 게시글의 `imageUrl`/`audioUrl`은 `null`이고 `dateUtc`는 게시글 작성 시각(`createdAt`) 기준이다. - RED 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"` - 기대 결과: service 생성자/호출부 미구현으로 컴파일 실패 또는 테스트 실패 - GREEN: `CreatorChannelHomeQueryService`에 `CreatorChannelCommunityQueryService`를 주입하고, 기존 `queryPort.findCommunityPosts` 호출 2곳을 새 community service 호출로 교체한다. - GREEN 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"` - 기대 결과: `BUILD SUCCESSFUL` - REFACTOR: 홈 domain의 기존 `CreatorChannelCommunityPost` data class를 제거하고, 홈의 `notices`, `communities` 타입은 `kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost`를 사용한다. 홈 API response DTO 변환 결과가 바뀌지 않는지 테스트로 확인한다. - 검증 기록: - RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"` 실행 결과 `communityQueryService` 생성자 파라미터 미구현, 홈/커뮤니티 post domain 타입 불일치, 기존 home port `findCommunityPosts` 잔존으로 `compileTestKotlin` 실패를 확인했다. - GREEN: 홈 service가 `CreatorChannelCommunityQueryService.findHomeCommunityPosts`를 `isPinned=true/false`, `limit=3`으로 호출하도록 교체하고, 홈 domain/DTO/test fixture를 커뮤니티 domain post 기준으로 보정한 뒤 같은 focused test `BUILD SUCCESSFUL`을 확인했다. - 홈 API 회귀: 유료 미구매 홈 커뮤니티 게시글의 `imageUrl`/`audioUrl == null`, 고정글 `dateUtc == createdAt` 응답을 `CreatorChannelHomeControllerTest`, `CreatorChannelHomeFacadeTest`에 고정했고, 포함 회귀 focused test 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - [x] **Task 4.2: 홈 port/repository에서 커뮤니티 조회 책임 제거** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` - RED: 홈 repository 테스트에서 커뮤니티 게시글 조회 전용 테스트가 있으면 동일한 케이스가 `DefaultCreatorChannelCommunityQueryRepositoryTest`로 이동되어야 함을 먼저 확인한다. - RED 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"` - 기대 결과: 기존 home port method 제거 전에는 테스트/컴파일이 아직 기존 구조를 기대해 실패할 수 있다. - GREEN: - `CreatorChannelHomeQueryPort.findCommunityPosts`와 `CreatorChannelCommunityPostRecord`를 제거한다. - `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`, `orderedCommunityPostIds`, `communityLikeCounts`, `communityCommentCounts`, 커뮤니티 전용 차단 sub query, `canAccessPaidCommunityContent`, `maskPaidCommunityContent`, `adultCommunityCondition`, `fixedNoticeCondition`, `visibleCommunityPostCondition` 중 홈 repository에서 더 이상 쓰지 않는 커뮤니티 전용 helper를 제거한다. - 같은 로직은 `DefaultCreatorChannelCommunityQueryRepository`에만 남긴다. - GREEN 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"` - 기대 결과: `BUILD SUCCESSFUL` - REFACTOR: 홈 repository에서 `creatorCommunity`, `creatorCommunityLike`, `creatorCommunityComment`, `useCan` import가 더 이상 필요 없으면 제거한다. 다른 홈 조회에서 쓰는 import는 유지한다. - 검증 기록: - GREEN: `CreatorChannelHomeQueryPort.findCommunityPosts`, home 전용 `CreatorChannelCommunityPostRecord`, `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`와 커뮤니티 전용 helper/import 및 home repository의 직접 커뮤니티 조회 테스트를 제거했다. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - `rg -n "CreatorChannelHomeQueryPort\.findCommunityPosts|CreatorChannelCommunityPostRecord|findCommunityPosts\(" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence` 결과 home port/repository의 커뮤니티 조회 책임 잔존 0건을 확인했다. ### Phase 5: 커뮤니티 탭 API 조립 계층 추가 - [x] **Task 5.1: response DTO와 facade 테스트 작성** - Files: - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt` - Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt` - RED: 아래 케이스를 테스트로 먼저 작성한다. - facade는 `CreatorChannelCommunityQueryService.getCommunityTab` 결과를 `CreatorChannelCommunityTabResponse`로 변환한다. - `createdAtUtc`는 UTC ISO-8601 문자열이다. - `createdAtUtc` 변환은 재사용 가능한 `toUtcIso` 확장함수가 있으면 기존 확장함수를 import해서 사용하고, 없으면 `LocalDateTimeExtensions.kt`에 공용 확장함수를 추가해 사용한다. - `creatorProfileUrl`, `existOrdered`가 응답에 포함된다. - `imageUrl == null`, `audioUrl == null`이 그대로 응답된다. - `@JsonProperty`로 `isCommentAvailable`, `isPinned`, `hasNext` 필드명이 유지된다. - RED 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"` - 기대 결과: DTO/facade 미구현으로 컴파일 실패 또는 테스트 실패 - GREEN: PRD와 이 문서의 response data class 초안을 기준으로 DTO와 facade를 구현한다. - GREEN 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"` - 기대 결과: `BUILD SUCCESSFUL` - REFACTOR: DTO는 공개 API 필드 변환만 담당하고, 구매/성인/정렬 정책을 포함하지 않는다. - 검증 기록: - RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"` 실행 결과 `CreatorChannelCommunityTabResponse`, `CreatorChannelCommunityFacade` 미구현 심볼로 `compileTestKotlin` 실패를 확인했다. - GREEN: DTO/facade와 공용 `LocalDateTime.toUtcIso()` 확장함수를 추가한 뒤 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 범위: 공개 API 응답 DTO 변환과 facade 위임만 추가했고 구매/성인/정렬 정책은 DTO에 넣지 않았다. - [x] **Task 5.2: controller 테스트와 endpoint 구현** - Files: - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt` - RED: `@WebMvcTest(CreatorChannelCommunityController::class)`와 기존 시리즈/오디오 controller test의 `TestSecurityConfig` 패턴으로 아래 케이스를 작성한다. - 비회원 요청은 `401 Unauthorized`다. - 인증 회원 요청은 `GET /api/v2/creator-channels/{creatorId}/community`를 호출하고 `creatorId`, `page`, `size`, `viewer`를 facade에 전달한다. - `page=-1`, `size=100` 같은 값은 controller에서 거부하지 않고 facade로 전달한다. - 성공 응답은 `success=true`, `data.communityPostCount`, `data.communityPosts[0].postId`, `data.communityPosts[0].creatorProfileUrl`, `data.communityPosts[0].existOrdered`, `data.communityPosts[0].isCommentAvailable`, `data.communityPosts[0].isPinned`, `data.page`, `data.size`, `data.hasNext`를 포함한다. - RED 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"` - 기대 결과: controller 미구현으로 컴파일 실패 또는 테스트 실패 - GREEN: `@RestController`, `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/community")`, `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 패턴으로 구현한다. - GREEN 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"` - 기대 결과: `BUILD SUCCESSFUL` - REFACTOR: 인증 null guard는 기존 탭 controller와 같은 `requireMember` private 함수로 둔다. - 검증 기록: - RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"` 실행 결과 `CreatorChannelCommunityController` 미구현 심볼로 `compileTestKotlin` 실패를 확인했다. - GREEN: `GET /api/v2/creator-channels/{creatorId}/community` controller 구현 후 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - Phase 5 focused 회귀: facade/controller focused tests 동시 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - ktlint: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 의존 방향: `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과 출력 없음으로 domain/query 계층의 API 의존 0건을 확인했다. - 코드 리뷰 및 fresh 검증: controller는 기존 v2 탭 API와 같은 인증/`requireMember` 패턴으로 facade에 `creatorId`, `viewer`, raw `page`, raw `size`만 전달하고, facade/DTO는 query service 결과를 공개 응답 DTO로 변환만 하는 것을 확인했다. `LocalDateTime.toUtcIso()` 공용 확장함수는 기존 v2 DTO private 확장함수와 동일한 UTC offset 직렬화 방식임을 확인했다. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.*"`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`, `git diff --check` 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했고, 커뮤니티 domain/query 계층의 `v2.api.*` import 검색도 출력 없음으로 확인했다. ### Phase 6: E2E와 회귀 검증 - [x] **Task 6.1: 커뮤니티 탭 end-to-end 테스트 작성** - Files: - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` - RED: `@SpringBootTest`, `@AutoConfigureMockMvc`, `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`, `TransactionTemplate` 패턴으로 아래 케이스를 작성한다. - controller-service-repository를 거쳐 전체 응답 필드를 반환한다. - 고정 게시글이 일반 게시글보다 먼저 반환된다. - `page=-1`, `size=10` 요청은 `page=0`, `size=20`으로 fallback된다. - 성인 콘텐츠 노출 정책이 false인 조회자는 19금 게시글을 count와 list에서 받지 않는다. - 성인 콘텐츠 노출 정책이 false인 조회자가 19금 게시글을 구매했더라도 count와 list에서 받지 않는다. - 구매한 유료 게시글의 `imageUrl`은 CDN URL이고 signed URL이 아니며, 미구매 유료 게시글의 `imageUrl`은 `null`이다. - 구매한 유료 게시글의 `audioUrl`은 signed URL 형태이고, 미구매 유료 게시글의 `audioUrl`은 `null`이다. - 이미지가 없는 게시글의 `imageUrl`은 `null`이다. - RED 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"` - 기대 결과: API 미구현 또는 fixture 미연결로 실패 - GREEN: 필요한 fixture helper를 테스트 내부에 추가하고, `@MockBean AudioContentCloudFront`로 signed URL 결과를 `https://signed.test/community-audio`처럼 고정한다. E2E 테스트에서 실제 CloudFront private key 파일을 요구하지 않게 한다. - GREEN 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"` - 기대 결과: `BUILD SUCCESSFUL` - REFACTOR: E2E fixture는 테스트 내부 helper로 유지하고 운영 코드에 테스트 전용 분기를 넣지 않는다. - 검증 기록: - RED/GREEN: `CreatorChannelCommunityEndToEndTest`를 추가한 뒤 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. 별도 production 수정 없이 즉시 GREEN이었으며, Phase 1-5 구현이 이미 endpoint 동작을 충족했기 때문으로 확인했다. - 범위: `@SpringBootTest`, `@AutoConfigureMockMvc`, `EmbeddedRedisInitializer`, `TransactionTemplate`, `@MockBean AudioContentCloudFront` 패턴으로 controller-service-repository 실제 경로를 검증했다. 고정글 우선 정렬, `page=-1`/`size=10` fallback, 성인 콘텐츠 비노출, 구매/미구매 유료 이미지·오디오 접근, 이미지 없는 게시글 `imageUrl == null`을 E2E 응답으로 고정했고 운영 코드는 변경하지 않았다. 리뷰 후 기존 v2 E2E와 같은 shared H2 datasource를 사용하도록 보정하고, 미구매 오디오 signed URL 생성이 발생하면 실패하도록 `AudioContentCloudFront` interaction 검증을 추가한 뒤 focused E2E와 ktlint를 재실행해 `BUILD SUCCESSFUL`을 확인했다. - [x] **Task 6.2: 홈 API 회귀와 의존 방향 검증** - Files: - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt` - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt` - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` - RED: 홈 API 응답 스키마가 변경되지 않아야 하므로 기존 테스트가 실패하면 변경 원인을 확인한다. - 검증 실행: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"` - 기대 결과: `BUILD SUCCESSFUL` - 의존 방향 검색: - `rg -n "kr\\.co\\.vividnext\\.sodalive\\.v2\\.api\\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` - 기대 결과: 검색 결과 0건 - REFACTOR: 홈 API response DTO의 필드명, `dateUtc`, `existOrdered`, `likeCount`, `commentCount` 의미가 바뀌지 않도록 API DTO 변경을 피한다. - 검증 기록: - 홈 회귀: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 의존 방향: `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과 출력 없음으로 community domain/query 계층의 API 의존 0건을 확인했다. - 코드 리뷰 및 fresh 검증: 신규 E2E가 Phase 6 범위인 controller-service-repository 실제 경로, page/size fallback, 고정글 우선 정렬, 성인 콘텐츠 비노출, 구매/미구매 유료 미디어 접근, 홈 API 회귀, 의존 방향을 검증하는지 확인했고 추가 코드 이슈는 발견하지 않았다. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"`, `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL`; `git diff --check`는 출력 없음, 커뮤니티 domain/query 계층의 `v2.api.*` import 검색도 출력 없음으로 확인했다. ### Phase 7: 전체 검증과 문서 갱신 - [x] **Task 7.1: 전체 테스트와 ktlint 검증** - Files: - Verify: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md` - 검증 실행: - `./gradlew --no-daemon test` - 기대 결과: `BUILD SUCCESSFUL` - `./gradlew --no-daemon ktlintCheck` - 기대 결과: `BUILD SUCCESSFUL` - 문서 검증: - 각 완료 task의 체크박스를 `- [x]`로 갱신한다. - 각 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다. - 전체 검증 결과는 아래 `전체 검증 기록` 섹션에 누적한다. - REFACTOR: 검증 실패가 구현 범위 변경을 요구하면 먼저 이 문서의 task를 갱신한 뒤 코드를 수정한다. - 검증 기록: - 전체 테스트: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - ktlint: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 범위: Phase 7은 검증과 문서 갱신만 수행했고 production/test code는 변경하지 않았다. --- ## 5. 구현 순서 요약 1. Phase 1에서 순수 정책과 domain/port 계약을 먼저 고정한다. 2. Phase 2에서 QueryDSL repository를 새 커뮤니티 도메인으로 분리한다. 3. Phase 3에서 service가 인증/성인/차단/CDN URL/오디오 signed URL/마스킹 정책을 조립하게 한다. 4. Phase 4에서 홈 API의 커뮤니티 조회를 새 도메인으로 연결하고 홈 repository의 커뮤니티 쿼리를 제거한다. 5. Phase 5에서 공개 API DTO/facade/controller를 추가한다. 6. Phase 6에서 커뮤니티 탭 E2E와 홈 API 회귀를 확인한다. 7. Phase 7에서 전체 테스트, ktlint, 의존 방향 검색 결과를 누적 기록한다. --- ## 6. 전체 검증 기록 - 구현 전 문서 작성 단계에서는 코드 검증을 수행하지 않는다. 구현 단계에서 각 task 완료 즉시 실행 명령과 결과를 이 섹션에 누적한다. - 2026-06-21: 문서 작성 검증 - placeholder와 모호한 문구 검색 결과 0건. - 2026-06-21: 문서 변경 whitespace 검증 - `git diff --check` 실행 결과 출력 없음, exit code 0. - 2026-06-21: 문서 유지보수 규칙 검증 - sandbox 내부 `./gradlew tasks --all`은 `~/.gradle` lock 파일 접근 제한으로 실패했고, 승인 실행으로 재시도한 `./gradlew tasks --all`은 `BUILD SUCCESSFUL` 확인. - 2026-06-21: Phase 1 Task 1.1 검증 - RED focused test는 미구현 심볼로 `compileTestKotlin` 실패, GREEN focused test는 `BUILD SUCCESSFUL` 확인. - 2026-06-21: Phase 2 Task 2.1 검증 - focused repository test는 RED에서 미구현 repository로 `compileTestKotlin` 실패, GREEN과 양방향 활성 차단 계약 보정 후 `BUILD SUCCESSFUL` 확인. Review follow-up에서 raw `createdAt`, 같은 `fixedAt` 고정글 `id desc`, 홈 구매자 비활성 게시글 비노출 테스트 추가 후 2건 실패를 확인했고 repository 보정 후 focused test `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon test`는 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon ktlintCheck`는 formatting 후 `BUILD SUCCESSFUL`, `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`는 출력 없음 확인. - 2026-06-21: Phase 3 Task 3.1 검증 - RED focused service test는 `CreatorChannelCommunityQueryService` 미구현 심볼로 `compileTestKotlin` 실패, GREEN focused service test는 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew --no-daemon test`, `./gradlew --no-daemon ktlintCheck` 모두 `BUILD SUCCESSFUL` 확인. - 2026-06-21: Phase 4 Task 4.1 검증 - RED focused home service test는 `communityQueryService` 생성자 파라미터 미구현, 홈/커뮤니티 post domain 타입 불일치, 기존 home port `findCommunityPosts` 잔존으로 `compileTestKotlin` 실패 확인. GREEN focused home service test는 `BUILD SUCCESSFUL` 확인. 리뷰 후 홈 커뮤니티 정책을 유료 미구매 `imageUrl`/`audioUrl == null`, `dateUtc == createdAt`으로 명시하고 테스트에 고정했다. - 2026-06-21: Phase 4 Task 4.2 검증 - focused home repository test는 `BUILD SUCCESSFUL` 확인. 홈/커뮤니티 회귀 focused test(`CreatorChannelHomeControllerTest`, `CreatorChannelHomeFacadeTest`, `CreatorChannelCommunityQueryServiceTest`, `DefaultCreatorChannelCommunityQueryRepositoryTest`)는 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon ktlintCheck`는 import 정렬로 1회 실패 후 `./gradlew --no-daemon ktlintFormat` 적용 및 재실행 결과 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon test` 전체 테스트 `BUILD SUCCESSFUL` 확인. - 2026-06-21: Phase 5 Task 5.1 검증 - RED focused facade test는 `CreatorChannelCommunityTabResponse`, `CreatorChannelCommunityFacade` 미구현 심볼로 `compileTestKotlin` 실패 확인. GREEN focused facade test는 `BUILD SUCCESSFUL` 확인. - 2026-06-21: Phase 5 Task 5.2 검증 - RED focused controller test는 `CreatorChannelCommunityController` 미구현 심볼로 `compileTestKotlin` 실패 확인. GREEN focused controller test는 `BUILD SUCCESSFUL` 확인. Phase 5 facade/controller focused tests 동시 실행, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL` 확인. 커뮤니티 domain/query 계층의 `v2.api.*` import 검색은 출력 없음 확인. - 2026-06-21: Phase 5 코드 리뷰 및 fresh 검증 - controller/facade/DTO 구현이 Phase 5 범위인 인증 사용자 전달, raw page/size 전달, query service 위임, 공개 응답 변환에 머무는지 확인했고 추가 코드 이슈는 발견하지 않았다. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.*"`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL`; `git diff --check`와 `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`는 출력 없음 확인. - 2026-06-22: Phase 6 Task 6.1/6.2 검증 - 커뮤니티 탭 E2E focused test, 홈 API 회귀 focused test, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL` 확인. `git diff --check`와 커뮤니티 domain/query 계층의 `v2.api.*` import 검색은 출력 없음 확인. 리뷰 후 shared H2 datasource와 오디오 signed URL interaction 검증을 보정했고, focused E2E와 ktlint 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 2026-06-22: Phase 6 코드 리뷰 및 fresh 검증 - 신규 E2E와 Phase 6 문서 기록을 재검토했고 추가 코드 이슈는 발견하지 않았다. focused 커뮤니티 E2E, 홈 API 회귀, `ktlintCheck`, 전체 테스트는 모두 `BUILD SUCCESSFUL`; `git diff --check`와 community domain/query 계층의 API 의존 검색은 출력 없음으로 확인했다. - 2026-06-22: Phase 7 Task 7.1 검증 - `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`, `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. Phase 7은 전체 검증과 문서 갱신만 수행했고 production/test code는 변경하지 않았다.