# 메인 홈 팔로잉 탭 API Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다. **Goal:** `GET /api/v2/home/following`으로 메인 홈 팔로잉 탭의 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식을 조회한다. **Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.home.following` 조립 계층에 둔다. 팔로잉 탭 조회 service, 최근 소식 publish service, domain model, port, QueryDSL/JPA repository는 `kr.co.vividnext.sodalive.v2.home.following` 하위에 두고 `v2.api.*`에 의존하지 않는다. 최근 소식은 별도 inbox table에 사용자별 row를 저장하고, 이번 범위에서는 외부 MQ/outbox/worker 없이 내부 publish service에서 follower 조회와 bulk insert를 수행한다. **Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, MySQL, JUnit 5, MockMvc, Gradle Wrapper --- ## 0. 확정 사항 - API endpoint: `GET /api/v2/home/following` - 인증 정책: 비로그인 조회 허용. 비로그인 응답은 `isLoginRequired = true`와 빈 섹션 배열을 내려준다. - 로그인 회원 응답은 `isLoginRequired = false`와 팔로잉 탭 데이터를 내려준다. - 응답 wrapper: `ApiResponse.ok(...)` - `SecurityConfig`에 `GET /api/v2/home/following` permitAll 설정을 추가한다. - 섹션별 기본 노출 수: - `followingCreators`: 최신 팔로우순 20개 - `onAirLives`: 팔로잉 크리에이터의 현재 진행 중인 라이브 최신순 10개 - `recentChats`: DM/AI 채팅 최신순 10개 - `monthlySchedules`: 이번 달 오늘 이후 일정 중 오늘과 가까운 순 3개 - `recentNews`: `visibleFromAtUtc desc`, `newsId desc` 기준 30개 - 최근 대화는 기존 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)`와 `ChatRoomListItemResponse`를 재사용한다. - 최근 소식 타입은 `CREATOR_RANKING`, `CONTENT_RANKING`, `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`를 정의한다. - 이번 범위에서 생성하는 랭킹 소식은 `CREATOR_RANKING`만이다. `CONTENT_RANKING`은 향후 확장용으로 enum/table 값만 예약한다. - 최근 소식 응답 최상위에는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 내려준다. - 최근 소식 상세 값은 타입별 nullable nested DTO로 내려준다. `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다. - `CREATOR_RANKING`은 `creatorRanking.rank`, `creatorRanking.creatorId`, `creatorRanking.nickname`, `creatorRanking.profileImageUrl`을 사용한다. `rankChange`, `isNew`는 사용하지 않는다. - `CONTENT_RANKING`은 `contentRanking.rank`, `contentRanking.contentId`, `contentRanking.contentImageUrl`, `contentRanking.title`을 사용한다. - `AUDIO_CONTENT`, `PHOTO_CONTENT`는 각각 `audioContent`/`photoContent`에 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 담고, 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다. - `COMMUNITY_POST`는 `communityPost`에 `postId`, `creatorProfileImage`, `creatorNickname`, nullable `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 담는다. - `COMMUNITY_POST` 최근 소식은 무료 커뮤니티 게시글만 발행한다. 유료 커뮤니티 게시글은 inbox row를 생성하지 않는다. - 최근 소식 inbox table DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`을 기준으로 한다. - inbox 중복 방지는 `memberId`, `newsType`, `sourceKey` 기준 unique 정책으로 보장한다. - 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다. 재팔로우 시 기존 비활성 row는 복구하지 않는다. - 이미지 URL은 기존 `v2.common.domain.CdnUrlExtensions.toCdnUrl` 패턴을 따른다. - UTC 문자열 변환은 기존 `toUtcIso` 패턴을 따른다. - 성인 콘텐츠 노출 가능 여부는 `MemberContentPreferenceService.canViewAdultContent(member)`를 사용한다. - 차단 관계가 있는 크리에이터의 팔로잉 크리에이터, On Air, 스케줄, 최근 소식은 노출하지 않는다. --- ## 1. 파일 구조 계획 ### 신규 API 조립 계층 - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt` ### 신규 도메인 조회 계층 - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKey.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingNewsInboxPort.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInbox.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingQueryRepository.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKeyTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt` ### 기존 파일 수정 - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTask.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTaskTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` ### 문서/DDL - Keep: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md` - Keep: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql` - Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md` --- ## 2. Response data class 초안 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt`에는 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 필드 계약을 바꾸는 작업은 먼저 PRD와 이 문서를 갱신한 뒤 별도 변경으로 처리한다. ```kotlin package kr.co.vividnext.sodalive.v2.api.home.following.dto import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule data class HomeFollowingTabResponse( @JsonProperty("isLoginRequired") val isLoginRequired: Boolean, val followingCreators: List, val onAirLives: List, val recentChats: List, val monthlySchedules: List, val recentNews: List ) { companion object { fun loginRequired(): HomeFollowingTabResponse { return HomeFollowingTabResponse( isLoginRequired = true, followingCreators = emptyList(), onAirLives = emptyList(), recentChats = emptyList(), monthlySchedules = emptyList(), recentNews = emptyList() ) } fun from(home: HomeFollowing): HomeFollowingTabResponse { return HomeFollowingTabResponse( isLoginRequired = false, followingCreators = home.followingCreators.map(FollowingCreatorResponse::from), onAirLives = home.onAirLives.map(FollowingLiveResponse::from), recentChats = home.recentChats, monthlySchedules = home.monthlySchedules.map(FollowingScheduleResponse::from), recentNews = home.recentNews.map(FollowingNewsResponse::from) ) } } } data class FollowingCreatorResponse( val creatorId: Long, val creatorNickname: String, val creatorProfileImageUrl: String ) { companion object { fun from(creator: HomeFollowingCreator): FollowingCreatorResponse { return FollowingCreatorResponse( creatorId = creator.creatorId, creatorNickname = creator.creatorNickname, creatorProfileImageUrl = creator.creatorProfileImageUrl ) } } } data class FollowingLiveResponse( val liveId: Long, val creatorProfileImageUrl: String, val creatorNickname: String, val title: String, val startedAtUtc: String ) { companion object { fun from(live: HomeFollowingLive): FollowingLiveResponse { return FollowingLiveResponse( liveId = live.liveId, creatorProfileImageUrl = live.creatorProfileImageUrl, creatorNickname = live.creatorNickname, title = live.title, startedAtUtc = live.startedAtUtc ) } } } data class FollowingScheduleResponse( val scheduleId: String, val creatorId: Long, val creatorProfileImageUrl: String, val creatorNickname: String, val title: String, val type: CreatorActivityType, val targetId: Long, val scheduledAtUtc: String, @JsonProperty("isOnAir") val isOnAir: Boolean ) { companion object { fun from(schedule: HomeFollowingSchedule): FollowingScheduleResponse { return FollowingScheduleResponse( scheduleId = schedule.scheduleId, creatorId = schedule.creatorId, creatorProfileImageUrl = schedule.creatorProfileImageUrl, creatorNickname = schedule.creatorNickname, title = schedule.title, type = schedule.type, targetId = schedule.targetId, scheduledAtUtc = schedule.scheduledAtUtc, isOnAir = schedule.isOnAir ) } } } data class FollowingNewsResponse( val newsId: String, val type: FollowingNewsType, val visibleFromAtUtc: String, val creatorRanking: FollowingCreatorRankingNewsResponse?, val audioContent: FollowingContentNewsResponse?, val photoContent: FollowingContentNewsResponse?, val contentRanking: FollowingContentRankingNewsResponse?, val communityPost: FollowingCommunityPostNewsResponse? ) { companion object { fun from(news: HomeFollowingNews): FollowingNewsResponse { return FollowingNewsResponse( newsId = news.newsId, type = news.type, visibleFromAtUtc = news.visibleFromAtUtc, creatorRanking = news.creatorRanking?.toResponse(), audioContent = news.audioContent?.toContentResponse(), photoContent = news.photoContent?.toContentResponse(), contentRanking = news.contentRanking?.toResponse(), communityPost = news.communityPost?.toResponse() ) } } } data class FollowingCreatorRankingNewsResponse( val rank: Int, val creatorId: Long, val nickname: String, val profileImageUrl: String ) data class FollowingContentNewsResponse( val contentId: Long, val contentImageUrl: String?, val title: String, val creatorProfileImageUrl: String, val creatorNickname: String ) data class FollowingContentRankingNewsResponse( val rank: Int, val contentId: Long, val contentImageUrl: String?, val title: String ) data class FollowingCommunityPostNewsResponse( val postId: Long, val creatorProfileImage: String, val creatorNickname: String, val imageUrl: String?, val content: String, val createdAt: String, val likeCount: Int, val commentCount: Int ) private fun HomeFollowingCreatorRankingNews.toResponse() = FollowingCreatorRankingNewsResponse( rank = rank, creatorId = creatorId, nickname = nickname, profileImageUrl = profileImageUrl ) private fun HomeFollowingContentNews.toContentResponse() = FollowingContentNewsResponse( contentId = contentId, contentImageUrl = contentImageUrl, title = title, creatorProfileImageUrl = creatorProfileImageUrl, creatorNickname = creatorNickname ) private fun HomeFollowingContentRankingNews.toResponse() = FollowingContentRankingNewsResponse( rank = rank, contentId = contentId, contentImageUrl = contentImageUrl, title = title ) private fun HomeFollowingCommunityPostNews.toResponse() = FollowingCommunityPostNewsResponse( postId = postId, creatorProfileImage = creatorProfileImage, creatorNickname = creatorNickname, imageUrl = imageUrl, content = content, createdAt = createdAt, likeCount = likeCount, commentCount = commentCount ) ``` --- ## 3. Domain / Port 초안 ```kotlin package kr.co.vividnext.sodalive.v2.home.following.domain import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType data class HomeFollowing( val followingCreators: List, val onAirLives: List, val recentChats: List, val monthlySchedules: List, val recentNews: List ) data class HomeFollowingCreator( val creatorId: Long, val creatorNickname: String, val creatorProfileImageUrl: String ) data class HomeFollowingLive( val liveId: Long, val creatorProfileImageUrl: String, val creatorNickname: String, val title: String, val startedAtUtc: String ) data class HomeFollowingSchedule( val scheduleId: String, val creatorId: Long, val creatorProfileImageUrl: String, val creatorNickname: String, val title: String, val type: CreatorActivityType, val targetId: Long, val scheduledAtUtc: String, val isOnAir: Boolean ) data class HomeFollowingNews( val newsId: String, val type: FollowingNewsType, val visibleFromAtUtc: String, val creatorRanking: HomeFollowingCreatorRankingNews? = null, val audioContent: HomeFollowingContentNews? = null, val photoContent: HomeFollowingContentNews? = null, val contentRanking: HomeFollowingContentRankingNews? = null, val communityPost: HomeFollowingCommunityPostNews? = null ) data class HomeFollowingCreatorRankingNews( val rank: Int, val creatorId: Long, val nickname: String, val profileImageUrl: String ) data class HomeFollowingContentNews( val contentId: Long, val contentImageUrl: String?, val title: String, val creatorProfileImageUrl: String, val creatorNickname: String ) data class HomeFollowingContentRankingNews( val rank: Int, val contentId: Long, val contentImageUrl: String?, val title: String ) data class HomeFollowingCommunityPostNews( val postId: Long, val creatorProfileImage: String, val creatorNickname: String, val imageUrl: String?, val content: String, val createdAt: String, val likeCount: Int, val commentCount: Int ) enum class FollowingNewsType { CREATOR_RANKING, CONTENT_RANKING, COMMUNITY_POST, AUDIO_CONTENT, PHOTO_CONTENT } ``` ```kotlin package kr.co.vividnext.sodalive.v2.home.following.port.out import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule import java.time.LocalDateTime interface HomeFollowingQueryPort { fun findFollowingCreators(memberId: Long, limit: Int): List fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List fun findMonthlySchedules(memberId: Long, canViewAdultContent: Boolean, now: LocalDateTime, limit: Int): List fun findRecentNews(memberId: Long, canViewAdultContent: Boolean, nowUtc: LocalDateTime, limit: Int): List } interface HomeFollowingNewsInboxPort { fun insertIgnoreAll(records: List): Int fun deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long): Long fun findActiveFollowerIds(creatorId: Long): List } data class HomeFollowingNewsInboxRecord( val memberId: Long, val creatorId: Long, val newsType: String, val sourceKey: String, val targetId: Long, val occurredAtUtc: LocalDateTime, val visibleFromAtUtc: LocalDateTime, val creatorNickname: String, val creatorProfileImagePath: String?, val title: String, val body: String, val thumbnailImagePath: String?, val rank: Int?, val isAdult: Boolean ) ``` --- ## 4. Phase / Task 계획 ### Phase 1: 응답 DTO, 도메인 모델, Security 기본 골격 - [x] **Task 1.1: 팔로잉 탭 응답 DTO와 domain model 추가** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt` - RED: `HomeFollowingTabResponse.loginRequired()`가 `isLoginRequired=true`와 빈 배열을 반환하는 테스트를 작성한다. - RED: `FollowingNewsResponse` 변환 결과의 최상위 필드가 `newsId`, `type`, `visibleFromAtUtc`와 타입별 nullable nested DTO만 포함하는지 테스트를 작성한다. `CREATOR_RANKING`은 `creatorRanking`만 non-null이고 다른 nested DTO는 null이어야 한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행, DTO 미구현으로 실패 확인. - GREEN: DTO/domain enum/model을 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: import, `JsonProperty`, nullable 필드 정리 후 `./gradlew --no-daemon ktlintCheck` 실행. - [x] **Task 1.2: Controller와 Security permitAll 추가** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt` - RED: 비로그인 `GET /api/v2/home/following`이 200과 `isLoginRequired=true`를 반환하는 MockMvc 테스트를 작성한다. - RED: 로그인 회원 요청이 facade를 호출하고 `isLoginRequired=false` 응답을 반환하는 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingControllerTest"` 실행, endpoint 미구현 또는 security 미설정 실패 확인. - GREEN: controller, facade 빈 골격, `SecurityConfig` permitAll을 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 기존 `HomeRecommendationController`의 `@AuthenticationPrincipal` 패턴과 응답 wrapper 스타일에 맞춘다. ### Phase 2: 최근 소식 Inbox 저장소 - [x] **Task 2.1: Inbox Entity/JPA repository/DDL 정합성 구현** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInbox.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt` - Verify: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt` - RED: 같은 `memberId/newsType/sourceKey` 중복 저장이 1건만 유지되어야 하는 테스트를 작성한다. - RED: `memberId/creatorId` 기준 활성 row 비활성화가 동작하는 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행, entity/repository 미구현 실패 확인. - GREEN: Entity와 JPA repository를 DDL 컬럼명에 맞춰 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 컬럼명, enum 저장 방식, timestamp nullable 정책이 DDL과 맞는지 비교한다. - [x] **Task 2.2: Inbox persistence adapter 구현** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingNewsInboxPort.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt` - RED: `insertIgnoreAll(records)`가 중복 source key를 예외 없이 무시하고 신규 row만 저장하는 테스트를 작성한다. - RED: `findActiveFollowerIds(creatorId)`가 활성 팔로워만 반환하는 테스트를 작성한다. - 실패 확인: Task 2.1과 같은 단일 테스트 명령 실행, port/adapter 미구현 실패 확인. - GREEN: JPA `saveAndFlush`와 unique 제약 기반 `DataIntegrityViolationException` 처리로 중복 source key를 예외 없이 무시하는 idempotent 저장을 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: H2/MySQL dialect 분기 없이 단일 JPA 경로를 유지하고, 동시 적재 시 inserted count는 best-effort임을 검증 기록에 남긴다. ### Phase 3: 팔로잉 탭 조회 Repository/Service - [x] **Task 3.1: 팔로잉 크리에이터 조회** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingQueryRepository.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` - RED: 활성 팔로우/활성 크리에이터만 최신 팔로우순 20개 조회하는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 테스트를 작성한다. - RED: 차단 관계 크리에이터가 제외되는 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행, repository 미구현 실패 확인. - GREEN: `creator_following`, `member`, `block_member` 조건을 QueryDSL로 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 기본 프로필 이미지와 CDN 변환 책임은 service/facade 중 기존 패턴과 맞는 위치로 정리한다. - [x] **Task 3.2: On Air 조회** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` - RED: 팔로우한 크리에이터의 `live_room.is_active=true`, `channel_name` 존재 라이브만 `beginDateTime desc, id desc`로 10개 조회하는 테스트를 작성한다. - RED: 성인 콘텐츠 노출 불가이면 19금 라이브가 제외되는 테스트를 작성한다. - 실패 확인: Task 3.1의 repository 단일 테스트 명령 실행, On Air 미구현 실패 확인. - GREEN: 기존 `DefaultHomeRecommendationQueryRepository.findLiveRecommendations(...)` 조건을 팔로잉 필터로 확장해 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 라이브 진행 중 판단 조건이 스케줄 `isOnAir`와 중복되면 private helper로 추출한다. - [x] **Task 3.3: 이달의 스케줄 조회** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` - RED: KST 오늘 00:00 이상 다음 달 00:00 미만의 라이브/오디오 일정을 `scheduledAt asc`로 3개 조회하는 테스트를 작성한다. - RED: 오늘 이전 일정과 차단 크리에이터 일정이 제외되는 테스트를 작성한다. - 실패 확인: repository 단일 테스트 명령 실행, schedule 미구현 실패 확인. - GREEN: 기존 `CreatorChannelHomeQueryRepository.findSchedules(...)`의 live/audio 조건을 팔로잉 전체 조회로 확장한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: `scheduleId`는 `{TYPE}:{targetId}` 형식으로 안정적으로 생성한다. - [x] **Task 3.4: 최근 소식 조회** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` - RED: `memberId`, `isActive=true`, `visibleFromAtUtc <= nowUtc` 조건으로 `visibleFromAtUtc desc, id desc` 30개를 조회하는 테스트를 작성한다. - RED: 최근 소식 domain이 타입별 nested DTO를 채우고, `COMMUNITY_POST`/`AUDIO_CONTENT` target 비활성 필터를 유지하는 테스트를 작성한다. - 실패 확인: repository 단일 테스트 명령 실행, recent news 미구현 실패 확인. - GREEN: inbox table 조회와 `HomeFollowingNews` 변환을 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 조회 시 차단/성인/target 활성 조건을 과도하게 조인하지 않도록 필요한 조건만 유지한다. - [x] **Task 3.5: HomeFollowingQueryService 조립** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt` - RED: query service가 팔로잉 크리에이터 20, On Air 10, 스케줄 3, 최근 소식 30 limit로 port를 호출하는 테스트를 작성한다. - RED: `MemberContentPreferenceService.canViewAdultContent(member)` 결과가 조회 port에 전달되는 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"` 실행, service 미구현 실패 확인. - GREEN: service에서 now/limit/성인 노출 정책을 조립한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: `nowProvider: () -> LocalDateTime`을 주입해 테스트 시간을 고정한다. - [x] **Task 3.6: Inbox 중복 insert 충돌 통합 테스트 보강** - Files: - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt` - RED: 실제 `HomeFollowingNewsInboxJpaRepository`로 동일 `memberId/newsType/sourceKey` unique 충돌을 발생시킨 뒤, 같은 테스트 흐름에서 `insertIgnoreAll(records)` 또는 repository 조회가 예외 없이 동작하는 통합 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행, 실제 DB 충돌 후 persistence context/transaction 상태 검증 실패를 확인한다. - GREEN: 필요 시 adapter의 중복 충돌 처리에서 persistence context 정리 또는 트랜잭션 경계를 최소 보강한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: mock 기반 race 테스트와 통합 테스트의 책임을 분리해, mock은 분기 검증만 하고 통합 테스트는 실제 Hibernate 세션/트랜잭션 유효성을 검증하도록 정리한다. ### Phase 4: 최근 소식 Publish Service와 기존 이벤트 연결 - [x] **Task 4.1: sourceKey 생성 정책 구현** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKey.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKeyTest.kt` - RED: `CREATOR_RANKING:{creatorId}:{aggregationStartAtUtc}` 형식 source key 생성 테스트를 작성한다. - RED: `AUDIO_CONTENT:{contentId}`와 `COMMUNITY_POST:{postId}` source key 생성 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKeyTest"` 실행, source key 미구현 실패 확인. - GREEN: source key 생성 object를 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 문자열 상수는 `FollowingNewsType` enum 이름과 불일치하지 않게 정리한다. - [x] **Task 4.2: HomeFollowingNewsPublishService 구현** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt` - RED: `publishFreeCommunityPostCreated(...)`가 무료 커뮤니티 게시글에 대해서만 현재 active follower에게 inbox record를 생성하고, 유료 커뮤니티 게시글 생성 흐름은 publish service를 호출하지 않는 테스트를 작성한다. - RED: `publishContentUploaded(...)`가 `visibleFromAtUtc`를 콘텐츠 공개 시각으로 저장하는 테스트를 작성한다. - RED: `publishCreatorRankingVisible(...)`이 `rank`와 랭킹 스냅샷 `visibleFromAtUtc`를 저장하는 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행, service 미구현 실패 확인. - GREEN: publish service에서 follower 조회, record 변환, `insertIgnoreAll` 호출을 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 외부 MQ/outbox 없이 동작하되 호출부가 service 메서드에만 의존하도록 public API를 작게 유지한다. - [x] **Task 4.3: 언팔로우 시 inbox 비활성화 연동** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt` - RED: 언팔로우 시 해당 `memberId/creatorId`의 active inbox row가 `isActive=false`가 되는 테스트를 작성한다. - RED: 재팔로우 시 기존 비활성 row가 복구되지 않는 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행, inbox 비활성화 미연동 실패 확인. - GREEN: 기존 언팔로우 처리 성공 후 `HomeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 팔로잉 공개 API 스키마는 변경하지 않는다. - [x] **Task 4.4: 크리에이터 랭킹 소식 발행 연결** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt` - RED: `refreshLastCompletedWeek(...)`가 스냅샷 저장 성공 후 `publishCreatorRankingVisible(...)`을 `visibleFromAtUtc`, `rank`, `creatorId`로 호출하는 테스트를 작성한다. - RED: `snapshotPort.replaceSnapshots(...)` 실패 시 `publishCreatorRankingVisible(...)`이 호출되지 않는 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest"` 실행, publish 미연동 실패 확인. - GREEN: `snapshotPort.replaceSnapshots(...)` 성공 직후 `snapshots.mapIndexed { index, snapshot -> rank = index + 1 }`로 publish service를 호출한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 월요일 01:00 생성, 09:00 노출 정책은 inbox `visibleFromAtUtc`로만 처리한다. - [x] **Task 4.5: 콘텐츠/커뮤니티 업로드 소식 발행 연결** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` - RED: `CreatorCommunityService.createCommunityPost(...)` 성공 후 무료 게시글이면 `publishFreeCommunityPostCreated(...)`가 post id, creator id, 본문, 생성 시각으로 호출되고, 유료 게시글이면 호출되지 않는 테스트를 작성한다. - RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate <= now`인 즉시 공개 콘텐츠 저장 성공 후 `publishContentUploaded(...)`가 호출되는 테스트를 작성한다. - RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate > now`인 예약 공개 콘텐츠 생성 시점에는 `publishContentUploaded(...)`가 호출되지 않는 테스트를 작성한다. - RED: `AudioContentService.releaseContent()`가 예약 콘텐츠를 active로 바꾸는 시점에 `publishContentUploaded(...)`를 호출하는 테스트를 작성한다. - 실패 확인: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"` - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"` - 기대 결과: publish 미연동으로 FAIL - GREEN: `AudioContentService.createAudioContent(...)`, `AudioContentService.releaseContent()`, `CreatorCommunityService.createCommunityPost(...)`의 트랜잭션 성공 경로에서 publish service를 호출한다. - 통과 확인: 위 두 단일 테스트 명령 재실행, PASS 확인. - REFACTOR: 결제/수정/관리자 저장 중 실제 공개 이벤트가 아닌 경로에서 중복 발행하지 않도록 sourceKey unique와 호출 지점을 함께 점검한다. ### Phase 5: Facade 통합, 최근 대화 재사용, API End-to-End - [x] **Task 5.1: HomeFollowingFacade 통합** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt` - RED: `member == null`이면 query/chat 서비스를 호출하지 않고 `HomeFollowingTabResponse.loginRequired()`를 반환하는 테스트를 작성한다. - RED: 로그인 회원이면 query service와 `ChatRoomListService.getRooms(member, "ALL", null, 10)`를 호출해 응답을 조립하는 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행, facade 미구현 실패 확인. - GREEN: facade 조립 로직을 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 한 섹션 데이터 부족은 빈 배열/가능한 개수로 성공 처리한다. - [x] **Task 5.2: End-to-End API 통합 테스트** - Files: - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` - RED: 비로그인 호출이 200, `isLoginRequired=true`, 모든 배열 빈 값인지 검증하는 통합 테스트를 작성한다. - RED: 로그인 회원 호출이 팔로잉 크리에이터/On Air/최근 대화/스케줄/최근 소식을 모두 조립하는 통합 테스트를 작성한다. - RED: `FollowingNewsResponse` 최상위에 `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `rank`가 없고, 타입과 일치하는 nested DTO만 non-null인지 JSON path 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행, 통합 미구현 실패 확인. - GREEN: 누락된 wiring, bean 등록, security 설정을 최소 수정한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 테스트 데이터 builder가 과하게 커지면 테스트 내부 private helper로만 분리한다. ### Phase 5.5: 최근 소식 응답 계약 nested DTO 변경 후속 구현 > 이 Phase는 Phase 1-5 구현 완료 후 변경된 공개 응답 계약을 실제 코드에 반영하기 위한 후속 작업이다. 기존 flat `FollowingNewsResponse`를 제거하고, 타입별 nullable nested DTO 계약과 무료 커뮤니티 게시글 전용 발행 계약을 구현한다. #### 작업 의존성 1. `Task 5.5.1` DTO/domain 계약 변경과 `Task 5.5.3` 무료 커뮤니티 발행 제한은 서로 독립적으로 시작할 수 있다. 2. `Task 5.5.2` repository enrichment는 `Task 5.5.1`의 domain model 변경 이후 진행한다. 3. `Task 5.5.4` E2E 검증은 `Task 5.5.1`, `Task 5.5.2`, `Task 5.5.3` 완료 후 진행한다. 4. `Task 5.5.5` 회귀 검증은 모든 구현 task 완료 후 진행한다. - [x] **Task 5.5.1: FollowingNewsResponse / HomeFollowingNews nested DTO 모델 전환** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt` - RED: `FollowingNewsResponse` 최상위 JSON에 `newsId`, `type`, `visibleFromAtUtc`, `creatorRanking`, `contentRanking`, `audioContent`, `photoContent`, `communityPost`만 존재하는 테스트를 작성한다. - RED: 최상위 JSON에 기존 flat 필드인 `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `rank`가 존재하지 않는 테스트를 작성한다. - RED: `CREATOR_RANKING`이면 `creatorRanking`만 non-null이고 `rank`, `creatorId`, `nickname`, `profileImageUrl`을 포함하는 테스트를 작성한다. - RED: `CONTENT_RANKING`이면 `contentRanking`만 non-null이고 `rank`, `contentId`, `contentImageUrl`, `title`을 포함하는 테스트를 작성한다. - RED: `AUDIO_CONTENT`이면 `audioContent`만 non-null이고 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함하며 `releaseDate`가 없음을 검증하는 테스트를 작성한다. - RED: `PHOTO_CONTENT`이면 `photoContent`만 non-null이고 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함하며 `releaseDate`가 없음을 검증하는 테스트를 작성한다. - RED: `COMMUNITY_POST`이면 `communityPost`만 non-null이고 `postId`, `creatorProfileImage`, `creatorNickname`, nullable `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 포함하는 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행, 기존 flat DTO/domain 구조로 실패 확인. - GREEN: `HomeFollowingNews`에 공통 필드 `newsId`, `type`, `visibleFromAtUtc`와 타입별 nullable domain payload를 추가한다. - GREEN: `FollowingNewsResponse`에 공통 필드와 타입별 nullable response payload를 추가하고, domain payload를 response payload로 1:1 변환한다. - REFACTOR: 타입별 DTO 변환 helper는 `HomeFollowingTabResponse.kt` 내부 private function 또는 companion object로만 유지하고, sealed class/상속 구조는 도입하지 않는다. - 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인. - [x] **Task 5.5.2: 최근 소식 조회 repository 타입별 nested payload enrichment 구현** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` - RED: `CREATOR_RANKING` row가 `creatorRanking.rank`, `creatorRanking.creatorId`, `creatorRanking.nickname`, `creatorRanking.profileImageUrl`을 채우고 다른 nested payload는 null인 테스트를 작성한다. - RED: `AUDIO_CONTENT` row가 `AudioContent` 원천에서 `contentId`, `contentImageUrl`, `title`, creator profile/nickname을 채우고 공개 시각은 최상위 `visibleFromAtUtc`를 사용하는 테스트를 작성한다. - RED: `COMMUNITY_POST` row가 무료 `CreatorCommunity` 원천에서 `postId`, `creatorProfileImage`, `creatorNickname`, CDN `imageUrl`, 전체 `content`, UTC `createdAt`, active `likeCount`, active top-level `commentCount`를 채우는 테스트를 작성한다. - RED: `COMMUNITY_POST` 원천 게시글이 `price > 0`이면 inbox row가 있더라도 최근 소식에서 제외되는 테스트를 작성한다. - RED: 기존 `AUDIO_CONTENT`, `COMMUNITY_POST` 원천 target 비활성 제외 테스트가 nested domain 구조에서도 유지되도록 수정한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행, flat `HomeFollowingNews` 매핑과 enrichment 미구현으로 실패 확인. - GREEN: 기존 inbox 후보 조회는 유지하되, 조회된 `targetId`를 타입별로 모아 batch 조회한다. - GREEN: `AUDIO_CONTENT`는 `audio_content` 원천에서 active row만 조회하고 이미지 URL을 변환해 `audioContent` payload를 만든다. - GREEN: `COMMUNITY_POST`는 active이고 `price <= 0`인 원천 게시글만 조회하며, active like count와 active top-level comment count를 postIds 기준으로 batch 집계해 `communityPost` payload를 만든다. - GREEN: `CREATOR_RANKING`은 inbox/creator 정보와 `rank_no`로 `creatorRanking` payload를 만든다. - GREEN: 아직 생성하지 않는 `CONTENT_RANKING`, `PHOTO_CONTENT`는 domain/DTO 타입만 유지하고, 원천 조회 경로가 없으면 API에 노출하지 않는다. - REFACTOR: enrichment helper는 `DefaultHomeFollowingQueryRepository.kt` private method로 둔다. 새 port/repository interface는 이 task에서 만들지 않는다. - 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인. - [x] **Task 5.5.3: 무료 커뮤니티 게시글만 COMMUNITY_POST 최근 소식 발행** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt` - RED: 무료 커뮤니티 게시글 생성 성공 후 `HomeFollowingNewsPublishService`가 post id, creator id, creator nickname/profile, 본문, 이미지 path, 생성 시각으로 호출되는 테스트를 작성한다. - RED: 유료 커뮤니티 게시글 생성 성공 후 `HomeFollowingNewsPublishService`가 호출되지 않는 테스트를 작성한다. - RED: 무료 커뮤니티 게시글 최근 소식 발행 실패가 원 게시글 생성 성공을 실패로 전파하지 않는 기존 after-commit 격리 테스트를 유지한다. - RED: `HomeFollowingNewsPublishServiceTest`의 커뮤니티 발행 테스트명/설명을 무료 게시글 발행 계약에 맞게 갱신한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행, 유료 게시글도 발행하는 기존 구현으로 실패 확인. - GREEN: `CreatorCommunityService`에서 `post.price <= 0`인 경우에만 최근 소식 publish를 예약한다. - GREEN: publish service 메서드명은 구현 시 선택한다. 이름을 `publishFreeCommunityPostCreated(...)`로 바꾸면 모든 호출부/테스트를 함께 갱신하고, 기존 이름을 유지하면 호출 조건으로 무료 게시글 전용 계약을 보장한다. - REFACTOR: 유료 게시글 미리보기 마스킹 로직이 최근 소식 발행에서 더 이상 쓰이지 않으면 제거하되, 커뮤니티 탭/상세의 유료 콘텐츠 정책은 건드리지 않는다. - 통과 확인: 위 테스트 명령 재실행, PASS 확인. - [x] **Task 5.5.4: 팔로잉 탭 API E2E nested response 계약 검증** - Files: - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` - RED: 로그인 회원 API 응답에 `CREATOR_RANKING`, `AUDIO_CONTENT`, 무료 `COMMUNITY_POST` 최근 소식 fixture를 포함한다. - RED: 각 recentNews item의 최상위 flat 필드 `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `rank`가 존재하지 않는 JSON path 테스트를 작성한다. - RED: `CREATOR_RANKING` item은 `creatorRanking`만 non-null이고 `rank`, `creatorId`, `nickname`, `profileImageUrl`을 포함하는지 검증한다. - RED: `AUDIO_CONTENT` item은 `audioContent`만 non-null이고 `releaseDate`가 없으며 최상위 `visibleFromAtUtc`가 존재하는지 검증한다. - RED: `COMMUNITY_POST` item은 `communityPost`만 non-null이고 `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 포함하는지 검증한다. - RED: 유료 커뮤니티 게시글은 최근 소식 응답에 노출되지 않는지 검증한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행, 기존 flat API 응답으로 실패 확인. - GREEN: Task 5.5.1-5.5.3 구현으로 대부분 통과해야 한다. wiring 누락이 있으면 관련 production file만 최소 수정한다. - REFACTOR: E2E fixture helper는 테스트 파일 내부 private helper로만 분리한다. - 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인. - [x] **Task 5.5.5: Phase 5.5 문서/회귀 검증 기록** - Files: - Verify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md` - Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md` - Verify: `docs/20260629_커뮤니티_게시글_좋아요_상태/prd.md` - Verify: Phase 5.5에서 변경한 Kotlin source/test - TDD 예외 사유: 문서/회귀 검증 작업이며 신규 production behavior를 만들지 않는다. - 대체 검증 방법: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` - `./gradlew --no-daemon test` - `./gradlew --no-daemon ktlintCheck` - 문서 검증 grep: - `rg -n "creatorProfileImageUrl|creatorNickname|thumbnailImageUrl|targetId|occurredAtUtc|rank: Int\?|publishCommunityPostCreated|유료 커뮤니티 최근 소식 미리보기" docs/20260625_메인_홈_팔로잉_탭_API` - 검색 결과가 팔로잉 크리에이터/스케줄/타입별 nested DTO/내부 inbox 컬럼/과거 검증 기록 맥락인지 확인하고, stale flat `FollowingNewsResponse` 공개 계약이면 수정한다. - 검증 결과 기록: Phase 5.5 완료 시 실행 명령, 결과, 실패 시 원인과 후속 조치를 이 문서의 `## 6. 검증 기록`에 한국어로 누적 기록한다. ### Phase 6: 문서/회귀 검증 - [x] **Task 6.1: 문서 동기화 확인** - Files: - Verify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md` - Verify: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql` - Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md` - TDD 예외 사유: 문서 검증 작업이며 실행 코드가 없다. - 대체 검증 방법: `rg -n "creatorProfileImageUrl|creatorNickname|thumbnailImageUrl|targetId|occurredAtUtc|rank: Int\\?|publishCommunityPostCreated|유료 커뮤니티 최근 소식 미리보기" docs/20260625_메인_홈_팔로잉_탭_API`로 최근 소식 flat 공개 응답 필드와 유료 커뮤니티 발행 계약이 남아 있는지 확인한다. 단, 팔로잉 크리에이터/스케줄/타입별 nested DTO/내부 inbox 컬럼 맥락은 허용한다. - 실행 명령: `./gradlew tasks --all` - 기대 결과: `BUILD SUCCESSFUL` - [x] **Task 6.2: 전체 회귀 검증** - Files: - Verify: 전체 Kotlin source/test - TDD 예외 사유: 전체 회귀 검증 task이며 신규 테스트 작성 대상이 아니다. - 대체 검증 방법: - `./gradlew --no-daemon test` - `./gradlew --no-daemon ktlintCheck` - 기대 결과: 두 명령 모두 `BUILD SUCCESSFUL` - 검증 결과 기록: 각 task 완료 시 실행 명령, 결과, 실패 시 원인과 후속 조치를 이 문서의 해당 task 아래에 한국어로 누적 기록한다. --- ## 5. 구현 순서 요약 1. DTO/domain/controller/security 기본 응답을 먼저 만든다. 2. inbox entity/repository/adapter와 unique 정책을 만든다. 3. 팔로잉 크리에이터, On Air, 스케줄, 최근 소식 조회 repository를 만든다. 4. query service와 facade에서 섹션을 조립한다. 5. publish service를 만들고 언팔로우/랭킹/콘텐츠/커뮤니티 이벤트에 연결한다. 6. `FollowingNewsResponse`를 타입별 nested DTO 계약으로 전환하고 무료 커뮤니티 게시글만 `COMMUNITY_POST` 최근 소식을 발행하도록 보강한다. 7. End-to-End 테스트와 전체 회귀 검증을 수행한다. --- ## 6. 검증 기록 - 2026-06-25 Phase 1-2 구현 검증: - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingControllerTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행 결과 `BUILD SUCCESSFUL`. - 병렬 Gradle 실행 중 `build/snapshot/kotlin/kaptGenerateStubsKotlin` 삭제 충돌이 1회 발생해 동일 명령을 순차 재실행했다. - 2026-06-25 Phase 3 구현 검증: - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 repository/service 미구현 컴파일 오류로 `BUILD FAILED`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행 결과 `BUILD SUCCESSFUL`. - 2026-06-25 Phase 4 구현 검증: - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKeyTest"` 실행 결과 `HomeFollowingNewsSourceKey`, `HomeFollowingNewsPublishService` 미구현 및 생성자 의존성 미연동 컴파일 오류로 `BUILD FAILED`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKeyTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"` 실행 결과 `BUILD SUCCESSFUL`. - `insertIgnoreAll`은 H2/MySQL dialect 분기 없이 JPA `saveAndFlush`와 unique 제약 기반 중복 예외 재확인 단일 경로로 검증했다. - 2026-06-25 Phase 5 구현 검증: - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행 결과 facade 생성자 미구현으로 `BUILD FAILED`. - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 facade 생성자 미구현으로 `BUILD FAILED`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. - 2026-06-26 Phase 3-5 리뷰 보완 검증: - 리뷰 지적 사항에 따라 팔로잉 탭 조회의 크리에이터 role 필터, 오디오 공개 시각 판정, 커뮤니티 최근 소식 미리보기 마스킹, 최근 소식 발행 `REQUIRES_NEW` 트랜잭션, inbox `title/body` 길이 정규화를 보강했다. 이후 계약 변경으로 `COMMUNITY_POST` 최근 소식은 무료 게시글만 발행하도록 재정의했다. - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"` 실행 결과 reviewer 보완 전 7개 regression 테스트 실패를 확인했다. - 같은 regression 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`. - Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. - 2026-06-26 Phase 3-5 2차 리뷰 보완 검증: - 2차 리뷰 지적 사항에 따라 inbox insert 정상 경로를 row별 `saveAndFlush`에서 기존 memberId 일괄 조회 + `saveAll` + 단일 `flush`로 완화하고, 중복 충돌 fallback은 유지했다. - 유료 오디오 콘텐츠의 `isFullDetailVisible=false` 상세 설명은 기존 상세 API 정책과 동일하게 미리보기만 최근 소식에 저장하도록 보강했다. - 오디오/커뮤니티/랭킹 최근 소식 발행 실패가 원 업로드/게시글 생성/랭킹 스냅샷 갱신 성공을 실패로 전파하지 않도록 after-commit 발행 예외를 로그로 격리했다. - 보완 직후 regression 테스트에서 adapter race 테스트와 Mockito matcher stubbing 불일치 실패를 확인한 뒤 테스트를 새 구현 경로에 맞게 정리했다. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest"` 실행 결과 `BUILD SUCCESSFUL`. - Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. - 2026-06-26 Phase 3-5 3차 리뷰 보완 검증: - 최근 소식 조회가 `AUDIO_CONTENT`, `COMMUNITY_POST` 원천 target의 `isActive=false` 상태를 최종 제외하도록 보강했다. `CREATOR_RANKING`은 creator 활성/role 필터를 유지하고, 아직 원천 테이블이 없는 예약 타입은 조회에서 노출하지 않는다. - 이달의 스케줄 정렬을 `scheduledAtUtc asc`, `type.sortOrder asc`, `targetId asc`로 안정화했다. - inbox insert를 H2/MySQL 공통 JPA portable path로 변경했다. 구현은 `newsType/sourceKey`별 기존 수신 member id를 일괄 조회한 뒤 신규 row만 `saveAll` + `flush`하고, unique 충돌 시 persistence context를 정리한 뒤 한 번 재조회/재시도한다. - 추후 운영에서 follower 수가 큰 크리에이터 이벤트로 `member_id in (...)` 또는 `saveAll` 배치 크기가 병목이 되면, follower id chunking, outbox table, 비동기 worker, 재시도/모니터링 대시보드 도입을 별도 후속 작업으로 진행한다. - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterRetryTest"` 실행 결과 target 비활성 필터와 insert retry 미구현으로 `BUILD FAILED`. - 같은 regression 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`. - Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test` 전체 테스트 실행 결과 `BUILD SUCCESSFUL`. - 2026-06-26 Phase 6 문서/회귀 검증: - 문서 동기화 확인을 위해 `rg -n "FollowingNewsRankingResponse|ranking\\?|rankChange|isNew|creatorId" docs/20260625_메인_홈_팔로잉_탭_API`를 실행했다. 검색 결과의 `creatorId`는 팔로잉 크리에이터/스케줄 공개 필드, 최근 소식의 `creatorId` 부재 검증 설명, 내부 `creator_id`/port 인자/테스트 설명 맥락으로 확인했으며 삭제된 공개 응답 필드 잔존은 확인되지 않았다. - `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. - 2026-06-30 Phase 5.5 구현 검증: - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행 결과 nested domain/DTO 미구현 컴파일 오류로 `BUILD FAILED`. - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldPopulateRecentNewsNestedPayloadsFromSourceTargets" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldExcludePaidCommunityPostRecentNews"` 실행 결과 원천 payload enrichment와 유료 커뮤니티 제외 미구현으로 `BUILD FAILED`. - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldNotPublishNewsAfterPaidCommunityPostCreated"` 실행 결과 유료 커뮤니티 게시글도 최근 소식을 발행해 `BUILD FAILED`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldPopulateRecentNewsNestedPayloadsFromSourceTargets" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldExcludePaidCommunityPostRecentNews"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldPublishNewsAfterCommunityPostCreated" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldNotPublishNewsAfterPaidCommunityPostCreated"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember"` 실행 결과 `BUILD SUCCESSFUL`. - Phase 5.5 집중 회귀 명령 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon ktlintCheck` 실행 결과 import 정렬 위반 2건을 수정한 뒤 재실행 결과 `BUILD SUCCESSFUL`. - 리뷰 보완: `CREATOR_RANKING`은 `rank`가 있는 row만 노출하도록 수정했고, `AUDIO_CONTENT`/`COMMUNITY_POST` 최근 소식은 현재 원천 target의 성인 상태를 함께 반영하도록 보강했다. - 리뷰 보완: `HomeFollowingEndToEndTest`가 `CREATOR_RANKING`, `AUDIO_CONTENT`, 무료 `COMMUNITY_POST`, 유료 커뮤니티 미노출 JSON surface를 모두 검증하도록 fixture/assertion을 확장했다. - 리뷰 보완 후 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`. - 리뷰 보완 후 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. - 리뷰 보완 후 Phase 5.5 집중 회귀 명령 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. - 리뷰 보완 후 `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. - 응답 단순화: `HomeFollowingContentNews`/`FollowingContentNewsResponse`의 `releaseDate`를 제거하고 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용하도록 계약과 테스트를 갱신했다.