Files
sodalive-backend-spring-boot/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md

67 KiB

메인 홈 팔로잉 탭 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(...)
  • SecurityConfigGET /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_RANKINGcreatorRanking.rank, creatorRanking.creatorId, creatorRanking.nickname, creatorRanking.profileImageUrl을 사용한다. rankChange, isNew는 사용하지 않는다.
  • CONTENT_RANKINGcontentRanking.rank, contentRanking.contentId, contentRanking.contentImageUrl, contentRanking.title을 사용한다.
  • AUDIO_CONTENT, PHOTO_CONTENT는 각각 audioContent/photoContentcontentId, contentImageUrl, title, creatorProfileImageUrl, creatorNickname을 담고, 공개 시각은 최상위 visibleFromAtUtc를 사용한다.
  • COMMUNITY_POSTcommunityPostpostId, 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와 이 문서를 갱신한 뒤 별도 변경으로 처리한다.

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<FollowingCreatorResponse>,
    val onAirLives: List<FollowingLiveResponse>,
    val recentChats: List<ChatRoomListItemResponse>,
    val monthlySchedules: List<FollowingScheduleResponse>,
    val recentNews: List<FollowingNewsResponse>
) {
    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 초안

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<HomeFollowingCreator>,
    val onAirLives: List<HomeFollowingLive>,
    val recentChats: List<ChatRoomListItemResponse>,
    val monthlySchedules: List<HomeFollowingSchedule>,
    val recentNews: List<HomeFollowingNews>
)

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
}
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<HomeFollowingCreator>
    fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive>
    fun findMonthlySchedules(memberId: Long, canViewAdultContent: Boolean, now: LocalDateTime, limit: Int): List<HomeFollowingSchedule>
    fun findRecentNews(memberId: Long, canViewAdultContent: Boolean, nowUtc: LocalDateTime, limit: Int): List<HomeFollowingNews>
}

interface HomeFollowingNewsInboxPort {
    fun insertIgnoreAll(records: List<HomeFollowingNewsInboxRecord>): Int
    fun deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long): Long
    fun findActiveFollowerIds(creatorId: Long): List<Long>
}

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 기본 골격

  • 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_RANKINGcreatorRanking만 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 실행.
  • 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 저장소

  • 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과 맞는지 비교한다.
  • 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

  • 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 중 기존 패턴과 맞는 위치로 정리한다.
  • 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로 추출한다.
  • 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} 형식으로 안정적으로 생성한다.
  • 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 활성 조건을 과도하게 조인하지 않도록 필요한 조건만 유지한다.
  • 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을 주입해 테스트 시간을 고정한다.
  • 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와 기존 이벤트 연결

  • 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 이름과 불일치하지 않게 정리한다.
  • 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를 작게 유지한다.
  • 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 스키마는 변경하지 않는다.
  • 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로만 처리한다.
  • 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

  • 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: 한 섹션 데이터 부족은 빈 배열/가능한 개수로 성공 처리한다.
  • 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 완료 후 진행한다.
  • 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 확인.
  • 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_CONTENTaudio_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_nocreatorRanking payload를 만든다.
    • GREEN: 아직 생성하지 않는 CONTENT_RANKING, PHOTO_CONTENT는 domain/DTO 타입만 유지하고, 원천 조회 경로가 없으면 API에 노출하지 않는다.
    • REFACTOR: enrichment helper는 DefaultHomeFollowingQueryRepository.kt private method로 둔다. 새 port/repository interface는 이 task에서 만들지 않는다.
    • 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인.
  • 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 확인.
  • 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 확인.
  • 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: 문서/회귀 검증

  • 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
  • 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_RANKINGrank가 있는 row만 노출하도록 수정했고, AUDIO_CONTENT/COMMUNITY_POST 최근 소식은 현재 원천 target의 성인 상태를 함께 반영하도록 보강했다.
    • 리뷰 보완: HomeFollowingEndToEndTestCREATOR_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/FollowingContentNewsResponsereleaseDate를 제거하고 콘텐츠 공개 시각은 최상위 visibleFromAtUtc를 사용하도록 계약과 테스트를 갱신했다.