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

112 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


현재 후속 작업 상태

Phase 상태 완료 Task 활성/다음 Goal 차단 또는 남은 조건
1~2 완료 기존 Task 전체 없음 없음
3 완료 기존 6/6, 회귀 1/1 없음 없음
4 완료 기존 5/5, 회귀 2/2 없음 없음
5~5.5 완료 기존 Task 전체 없음 없음
6 완료 기존 2/2, 회귀 2/2 없음 없음
7 완료 기존 1/1, 회귀 2/2 없음 없음
  • 2026-07-30 1차 Phase별 리뷰의 확정 항목은 P3-R1P3-R-GATEP7-R1P7-R2P7-R-GATE 순서로 실행 완료했다.
  • 2026-07-30 2차 정적 리뷰의 후속 순서 P4-R1P4-R-GATEP6-R1P6-R-GATE는 실행 완료했다.
  • 2026-07-30 3차 정적 리뷰의 후속 순서 P4-R2P4-R2-GATEP6-R2P6-R2-GATE는 실행 완료했다.

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을 사용한다.
  • CREATOR_RANKING은 현재 시점에 공개된 최신 WEEKLY, DONE 크리에이터 랭킹 job 기준 배치만 최근 소식에 표시한다. 신규 배치 공개 전에는 직전 공개 배치를 유지하고, 공개 후에는 이전 배치를 표시하지 않는다.
  • 최신 공개 배치에 포함되지 않은 팔로잉 크리에이터의 과거 CREATOR_RANKING은 보충하지 않는다.
  • CONTENT_RANKING은 같은 contentId의 노출 가능한 row 중 visibleFromAtUtc desc, newsId desc 기준 최신 항목 하나만 표시한다.
  • 랭킹 배치 필터와 콘텐츠 중복 제거를 먼저 적용한 뒤 전체 최근 소식 최대 30개를 조회하며, 제외 후 30개 미만이어도 과거·중복 랭킹으로 보충하지 않는다.
  • CONTENT_RANKING inbox 발행과 콘텐츠 랭킹 스냅샷 연동은 이번 보완 범위에 포함하지 않는다.
  • 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

Phase 7 후속 보완

  • 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
  • Verify: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt

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 세션/트랜잭션 유효성을 검증하도록 정리한다.
  • Task 3.7: 라이브 성별·크리에이터 입장 제한 회귀 수정

    Goal 실행 P3-R1: REV-P3-001에 따라 On Air와 라이브 스케줄에서 기존 라이브 입장 제한을 동일하게 적용한다.

    • 시작 조건: reviews/phase-3-review.mdREV-P3-001 확정, 기존 Task 3.1~3.6 완료.

    • 완료 증거: 제한 불일치 재현 테스트의 RED 확인, 최소 구현 후 focused test와 E2E 통과, 검증 기록 누적.

    • 범위 밖: 공개 응답 스키마 변경, 오디오 스케줄 정책 변경, 라이브 입장 정책 자체의 재정의.

    • Files:

      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt
      • 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/application/HomeFollowingQueryServiceTest.kt
      • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt
      • Verify: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt
    • RED: 회원 본인인증 성별을 우선한 effective gender와 크리에이터 회원 여부가 query port에 전달되는 테스트를 추가하고 실패를 확인한다.

    • RED: 성별 제한이 맞지 않는 라이브와 isAvailableJoinCreator=false인 타 크리에이터 라이브가 On Air 및 라이브 스케줄에서 제외되는 repository 테스트를 추가하고 실패를 확인한다.

    • GREEN: 기존 LiveRoomQueryRepositoryImpl/DefaultCreatorChannelHomeQueryRepositorygenderRestriction 및 크리에이터 입장 제한 조건을 재사용 가능한 최소 QueryDSL 조건으로 적용한다.

    • GREEN 확인: 아래 focused test를 실행해 신규 회귀와 기존 섹션 조회가 통과하는지 확인한다.

      ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"
      ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
      
    • REFACTOR: live 조건만 정리하고 공개 DTO와 최근 소식 로직은 변경하지 않는다.

Phase 3 리뷰 회귀 Gate

Goal 실행 P3-R-GATE: Phase 3의 라이브 입장 정책 수정과 팔로잉 탭 조립 회귀를 최종 판정한다.

  • 시작 조건: P3-R1 완료.
  • 완료 증거: 아래 명령이 모두 BUILD SUCCESSFUL이고 결과가 ## 6. 검증 기록reviews/phase-3-review.md에 누적됨.
  • 범위 밖: 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화.
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"
./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.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
./gradlew --no-daemon ktlintCheck

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와 호출 지점을 함께 점검한다.
  • Task 4.6: 언팔로우·재팔로우와 최근 소식 발행 동시성 보장

    Goal 실행 P4-R1: REV-P4-001에 따라 follower 판정부터 inbox insert까지의 경계를 팔로우 상태 변경과 직렬화해, 언팔로우 이전 이벤트가 재팔로우 후 새 소식으로 노출되지 않게 한다.

    • 시작 조건: reviews/phase-4-review.mdREV-P4-001 확정, 기존 Task 4.1~4.5 완료.

    • 완료 증거: 두 트랜잭션을 제어한 회귀 테스트의 RED 확인, 최소 구현 후 focused test 통과, 실제 결과를 ## 6. 검증 기록reviews/phase-4-review.md에 누적.

    • 범위 밖: 외부 MQ/outbox/worker 도입, inbox 공개 API·DDL 변경, 팔로우 알림 정책 변경, 관련 없는 publish 경로 리팩터링.

    • Files:

      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingNewsInboxPort.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt
      • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt
      • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt
      • Test: src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt
      • Verify: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt
    • RED: publish가 active follower를 읽은 뒤 insert하기 전에 같은 회원이 언팔로우하는 순서를 두 트랜잭션과 latch로 고정한다. 언팔로우 완료 후 publish insert가 늦게 완료되고 다시 팔로우하면 언팔로우 이전 이벤트가 조회되는 현재 실패를 재현한다.

    • GREEN: follower 판정과 inbox insert를 하나의 트랜잭션 경계로 묶고 해당 creator_following row를 팔로우/언팔로우 상태 변경과 동일한 잠금 순서로 직렬화한다. publish가 먼저 완료되면 뒤이은 언팔로우가 새 row까지 비활성화하고, 언팔로우가 먼저 완료되면 publish가 해당 회원 row를 생성하지 않아야 한다.

    • GREEN: 재팔로우는 기존 비활성 inbox를 복구하지 않으며, 재팔로우 이후 발생한 새 이벤트만 active row로 생성되는 기존 정책을 유지한다.

    • GREEN 확인: 아래 focused test를 실행해 동시성 회귀와 기존 중복 방지·발행·언팔로우 테스트가 모두 통과하는지 확인한다.

      ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
      
    • REFACTOR: H2/MySQL에서 검증 가능한 기존 JPA 경로와 공개 port 범위를 유지하고, 동시성 보장에 필요하지 않은 계층·설정·DDL을 추가하지 않는다.

Phase 4 Review Gate

Goal 실행 P4-R-GATE: P4-R1의 동시성 보장과 기존 최근 소식 발행·조회 계약을 최종 판정한다.

  • 시작 조건: P4-R1 완료.
  • 완료 증거: 아래 명령이 모두 BUILD SUCCESSFUL이고 결과가 ## 6. 검증 기록reviews/phase-4-review.md에 누적됨.
  • 범위 밖: 테스트 삭제·완화, 공개 API·DDL 확장, 이번 회귀와 무관한 코드 수정.
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
./gradlew --no-daemon ktlintCheck

Expected: publish와 언팔로우의 완료 순서와 무관하게 최종 언팔로우 상태에서는 해당 creator의 active inbox가 없고, 재팔로우 후에는 재팔로우 이후 이벤트만 노출된다.

  • 전체 ./gradlew test는 follower fan-out과 팔로우 상태 변경 경계만 보완하는 국소 수정이므로 기본 Gate에서 생략한다. focused test 또는 E2E가 공유 트랜잭션 경계의 회귀를 충분히 판정하지 못하면 전체 회귀로 확장하고 근거와 결과를 기록한다.

  • Task 4.7: 통합 팔로우 API의 isActive=false 경로에서 inbox 비활성화

    Goal 실행 P4-R2: REV-P4-002에 따라 creatorFollow(..., isActive=false)가 새 active 팔로우를 만들지 않고, 기존 관계의 active inbox를 비활성화해 재팔로우 후 과거 소식이 다시 노출되지 않게 한다.

    • 시작 조건: reviews/phase-4-review.mdREV-P4-002 확정, P4-R1P4-R-GATE 완료.

    • 완료 증거: 통합 팔로우 API 경로의 회귀 테스트 RED 확인, 최소 구현 후 MemberServiceTest 통과, Phase 4 회귀 Gate와 검증 기록 누적.

    • 범위 밖: CreatorFollowRequest·controller 공개 스키마 변경, 알림 설정 정책 변경, 외부 MQ/outbox 도입, 전용 creatorUnFollow(...) 경로 리팩터링.

    • Files:

      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt
      • Test: src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt
      • Verify: src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt
      • Verify: src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowRequest.kt
    • Interfaces:

      • Consumes: MemberService.creatorFollow(creatorId: Long, isNotify: Boolean, isActive: Boolean, memberId: Long), HomeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long).
      • Produces: 기존 public method·request 계약을 바꾸지 않고 isActive=false일 때 전용 언팔로우 경로와 같은 inbox 최종 상태.
    • RED: 관계가 없는 회원이 creatorFollow(..., isActive=false)를 호출해도 새 active 팔로우가 생성되지 않는지 검증한다.

    • RED: active 팔로우와 active inbox를 준비한 뒤 creatorFollow(..., isActive=false)를 호출하고 관계와 inbox가 모두 inactive인지 검증한다. 이어 creatorFollow(..., isActive=true)를 호출해 기존 inbox가 inactive로 유지되는지 검증한다.

    • RED 확인: 아래 단일 테스트를 실행해 creatorFollow(..., isActive=false) 직후 inbox가 여전히 active인 assertion 실패를 확인한다.

      ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
      
    • GREEN: 관계가 없고 isActive=false면 전용 creatorUnFollow(...)와 동일하게 새 active 관계를 만들지 않는다. 기존 creator_following row에 isActive=false를 반영하는 같은 트랜잭션에서는 homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)를 호출한다. isActive=true인 알림 변경·재팔로우 경로에서는 기존 비활성 inbox를 복구하지 않는다.

    • GREEN 확인: 같은 MemberServiceTest 명령을 재실행해 전용 언팔로우와 통합 팔로우 API 경로의 회귀가 모두 통과하는지 확인한다.

    • REFACTOR: 두 공개 method의 계약과 비관적 잠금 순서를 유지하고, 공통화가 한 번만 쓰이는 추상화나 신규 계층은 추가하지 않는다.

Phase 4 3차 리뷰 회귀 Gate

Goal 실행 P4-R2-GATE: P4-R2의 통합 언팔로우 경로와 기존 publish·언팔로우 동시성 계약을 최종 판정한다.

  • 시작 조건: P4-R2 완료.
  • 완료 증거: 아래 명령이 모두 BUILD SUCCESSFUL이고 결과가 ## 6. 검증 기록reviews/phase-4-review.md에 누적됨.
  • 범위 밖: 테스트 삭제·완화, 공개 API·DDL 변경, 이번 회귀와 무관한 코드 수정.
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"
./gradlew --no-daemon ktlintCheck

Expected: 전용 언팔로우와 creatorFollow(..., isActive=false) 어느 경로를 사용해도 새 active 팔로우가 생기지 않고 기존 active inbox가 비활성화되며, 재팔로우는 과거 inbox를 복구하지 않는다.

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 아래에 한국어로 누적 기록한다.
  • Task 6.3: newsId 공개 계약을 현재 구현과 동기화

    Goal 실행 P6-R1: REV-P6-001에 따라 newsIdhome_following_news_inbox.id의 10진 문자열임을 PRD에 명시하고, scheduleId{TYPE}:{targetId} 계약과 분리한다.

    • 시작 조건: reviews/phase-6-review.mdREV-P6-001 확정, 현재 repository/test/E2E의 newsId 동작 확인.

    • 완료 증거: PRD 식별자 정책과 최근 소식 정렬·동률 해소 설명이 현재 구현과 일치하고, 대체 검증 결과를 ## 6. 검증 기록reviews/phase-6-review.md에 누적.

    • 범위 밖: 공개 응답 필드 추가·삭제, runtime 코드·테스트·DDL 변경, 기존 newsId 값 형식 변경.

    • Files:

      • Modify: docs/20260625_메인_홈_팔로잉_탭_API/prd.md
      • Modify: docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md
      • Verify: src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt
      • Verify: src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt
      • Verify: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt
    • TDD 예외 사유: 현재 공개 동작을 변경하지 않는 문서 계약 정합성 보완이며 신규 production behavior가 없다.

    • prd.md의 공통 식별자 설명에서 scheduleId{TYPE}:{targetId} 형식으로 유지하고, newsId는 inbox PK의 10진 문자열이며 같은 노출 시각의 정렬·CONTENT_RANKING 동률 해소에 사용한다고 명시한다.

    • 아래 검색으로 PRD, repository, repository test, E2E의 newsId 형식을 정적으로 대조한다.

      rg -n "newsId|scheduleId|home_following_news_inbox\\.id" docs/20260625_메인_홈_팔로잉_탭_API/prd.md src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt
      git diff --check
      
    • 문서 명령 유효성만 확인하는 ./gradlew tasks --all을 실행해 BUILD SUCCESSFUL을 확인한다. 컴파일과 테스트는 실행하지 않는다.

Phase 6 Review Gate

Goal 실행 P6-R-GATE: P6-R1의 식별자 계약 정합성과 공개 동작 무변경을 최종 판정한다.

  • 시작 조건: P6-R1 완료.
  • 완료 증거: 정적 검색과 git diff --check, ./gradlew tasks --all 결과가 ## 6. 검증 기록reviews/phase-6-review.md에 누적됨.
  • 범위 밖: 컴파일·테스트 재실행, runtime 코드·테스트·DDL 변경.

Expected: scheduleId{TYPE}:{targetId}, newsId는 inbox PK 10진 문자열로 문서와 현재 구현이 일치하며 API 응답 동작은 변경되지 않는다.

  • Task 6.4: 크리에이터 랭킹 최신 공개 배치 기준 문서 동기화

    Goal 실행 P6-R2: REV-P6-002에 따라 PRD의 최신 CREATOR_RANKING 배치 판정 기준을 현재 구현의 WEEKLY, DONE job 우선·legacy snapshot 제한 fallback 정책과 일치시킨다.

    • 시작 조건: reviews/phase-6-review.mdREV-P6-002 확정, P7-R1P7-R-GATE 완료.

    • 완료 증거: PRD Feature F와 기술 제약의 최신 배치 설명이 Task 7.2 및 repository 조건과 일치하고, 정적 검색 결과를 ## 6. 검증 기록reviews/phase-6-review.md에 누적.

    • 범위 밖: runtime 조회 조건·테스트·DDL·공개 응답 변경, 랭킹 집계·점수·공개 시각 정책 변경.

    • Files:

      • Modify: docs/20260625_메인_홈_팔로잉_탭_API/prd.md
      • Modify: docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md
      • Verify: src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt
      • Verify: src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt
    • TDD 예외 사유: 현재 공개 동작을 바꾸지 않는 문서 정합성 보완이며 신규 production behavior가 없다.

    • prd.md의 두 최신 공개 배치 설명을 적용 가능한 creator_ranking_snapshot_job의 최신 WEEKLY, DONE, visibleFromAtUtc <= nowUtc 시각 우선으로 수정한다.

    • 적용 가능한 DONE job이 전혀 없는 legacy/backfill 데이터에서만 최신 공개 snapshot 시각을 fallback으로 사용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시한다.

    • 아래 검색으로 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트를 정적으로 대조한다.

      rg -n "creator_ranking_snapshot_job|creator_ranking_snapshot|최신 공개 배치|WEEKLY|DONE|legacy|fallback" docs/20260625_메인_홈_팔로잉_탭_API/prd.md docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt
      git diff --check
      
    • 문서 명령 유효성만 확인하는 ./gradlew tasks --all을 실행해 BUILD SUCCESSFUL을 확인한다. 컴파일과 테스트는 실행하지 않는다.

Phase 6 3차 리뷰 회귀 Gate

Goal 실행 P6-R2-GATE: P6-R2의 최신 공개 배치 문서 정합성과 runtime 무변경을 최종 판정한다.

  • 시작 조건: P6-R2 완료.
  • 완료 증거: 정적 검색과 git diff --check, ./gradlew tasks --all 결과가 ## 6. 검증 기록reviews/phase-6-review.md에 누적됨.
  • 범위 밖: 컴파일·테스트 실행, runtime 코드·테스트·DDL 변경.

Expected: PRD가 최신 완료 job의 빈 배치를 포함한 현재 판정과 일치하고, 적용 가능한 DONE job이 없는 경우에만 snapshot fallback을 허용한다.

Phase 7: 최근 소식 랭킹 조회 정책 보완

Phase 결과: 최근 소식에서 크리에이터 랭킹은 최신 공개 배치만, 콘텐츠 랭킹은 동일 콘텐츠의 최신 소식 하나만 표시된다.

선행조건: Phase 1~6 완료와 2026-07-30 확정 요구사항 반영.

Phase 완료 조건: P7-T1P7-GATE 완료, focused test·직접 영향 회귀·문서 검증 기록 누적.

  • Task 7.1: 최신 크리에이터 랭킹 배치 필터와 콘텐츠 랭킹 중복 제거

    Goal 실행 P7-T1: findRecentNews(...)가 랭킹 정책을 최대 30개 제한 전에 적용해 최신 크리에이터 배치와 콘텐츠별 최신 랭킹 소식만 반환한다.

    • 시작 조건: PRD Feature F와 Decision Log의 2026-07-30 결정 확인.

    • 완료 증거: 아래 TDD 체크박스 전체 완료, focused test 통과, 실제 결과를 ## 6. 검증 기록에 누적.

    • 범위 밖: CONTENT_RANKING inbox 발행, 콘텐츠 랭킹 스냅샷 연동, 공개 API 스키마·DDL 변경, 다른 최근 소식 타입 리팩터링.

    • 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
    • Interfaces:

      • Consumes: creator_ranking_snapshot.ranking_type, visible_from_at, home_following_news_inbox.news_type, target_id, visible_from_at_utc, id.
      • Produces: 기존 HomeFollowingQueryPort.findRecentNews(memberId, canViewAdultContent, nowUtc, limit) 계약을 변경하지 않은 필터링 결과.
    • RED: shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews 테스트에 직전·최신 공개 크리에이터 랭킹 스냅샷과 inbox를 저장한다. 신규 배치 공개 전에는 직전 배치가 조회되고, 공개 후에는 최신 배치만 조회되며, 최신 배치에 없는 크리에이터의 과거 순위는 제외되는지 검증한다.

    • RED: shouldFindLatestContentRankingNewsPerContentBeforeLimit 테스트에 동일 contentId의 서로 다른 visibleFromAtUtc row, 같은 시각의 서로 다른 newsId row, 다른 콘텐츠 row를 저장한다. visibleFromAtUtc desc, newsId desc 기준 최신 row 하나만 남고 중복 제거 후 limit까지 다른 고유 소식이 채워지는지 검증한다.

    • RED 확인: 아래 focused test를 실행해 과거 크리에이터 배치 노출, CONTENT_RANKING 미조립 또는 동일 콘텐츠 중복 노출 때문에 assertion이 실패하는지 확인한다.

      ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
      
    • GREEN: DefaultHomeFollowingQueryRepository.findRecentNews(...)creator_ranking_snapshotrankingType = WEEKLY, visibleFromAtUtc <= nowUtc 중 최신 공개 시각과 일치하는 CREATOR_RANKING만 허용하는 조건을 추가한다. 공개 스냅샷이 없으면 CREATOR_RANKING을 반환하지 않는다.

    • GREEN: CONTENT_RANKING을 활성 오디오 콘텐츠 target과 조인·조립하고, 동일 회원·동일 targetId의 노출 가능한 더 최신 row가 존재하지 않는 항목만 남기는 조건을 추가한다. 최신 비교는 visibleFromAtUtc, 동률이면 inbox id를 사용하며 이 조건을 전체 limit보다 먼저 적용한다.

    • GREEN 확인: 같은 focused test 명령을 다시 실행해 두 회귀 테스트와 기존 repository 테스트가 모두 통과하는지 확인한다.

    • REFACTOR: 이번 Task가 추가한 QueryDSL alias와 조건 함수만 정리하고, 공개 port/DTO·DDL은 변경하지 않는다. 아래 직접 영향 회귀와 lint를 실행해 결과를 기록한다.

      ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
      ./gradlew --no-daemon ktlintCheck
      

Phase 7 Gate

Goal 실행 P7-GATE: Phase 7의 최신 배치·콘텐츠 중복 제거 정책과 기존 팔로잉 탭 API 회귀를 최종 판정한다.

  • 시작 조건: P7-T1 완료.
  • 완료 증거: 아래 명령이 모두 BUILD SUCCESSFUL이고 결과가 ## 6. 검증 기록에 누적됨.
  • 범위 밖: 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화, CONTENT_RANKING 발행 기능 추가.
./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.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
./gradlew --no-daemon ktlintCheck

Expected: 과거 크리에이터 랭킹 배치와 동일 콘텐츠의 중복 랭킹이 최근 소식에 포함되지 않고, 기존 팔로잉 탭 응답 계약과 다른 소식 타입 회귀가 없다.

  • 전체 ./gradlew test는 조회 repository 한 파일과 해당 테스트만 변경하는 국소 보완이므로 기본 Gate에서 생략한다. focused test 또는 E2E에서 공유 경계 회귀를 판단할 수 없는 실패가 발생하면 전체 회귀로 확장하고 근거와 결과를 기록한다.

  • Task 7.2: 빈 최신 크리에이터 랭킹 배치에서 과거 소식 제외

    Goal 실행 P7-R1: REV-P7-001에 따라 최신 완료 배치의 결과가 0건이어도 이전 배치의 크리에이터 랭킹 소식을 노출하지 않는다.

    • 시작 조건: reviews/phase-7-review.mdREV-P7-001 확정, 기존 P7-GATE 완료.
    • 완료 증거: 빈 최신 배치 재현 테스트의 RED 확인, 최소 구현 후 repository/E2E 회귀 통과, 검증 기록 누적.
    • 범위 밖: 랭킹 집계·점수 정책 변경, CONTENT_RANKING 발행, 신규 테이블/공개 API 변경.
    • 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
      • Verify: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt
    • RED: 직전 공개 스냅샷/inbox와 더 최신 WEEKLY, DONE snapshot job을 저장하되 최신 배치 스냅샷은 0건으로 두고, 과거 CREATOR_RANKING이 제외되어야 하는 테스트를 추가해 실패를 확인한다.
    • GREEN: 최신 공개 배치 식별은 결과 row가 없어도 남는 creator_ranking_snapshot_jobWEEKLY, DONE, visibleFromAtUtc <= nowUtc 최신 시각을 기준으로 한다. 기존 snapshot만 있고 적용 가능한 job 이력이 전혀 없는 데이터의 호환 fallback이 필요하면 그 경우로만 제한하며, 빈 DONE 배치에서는 과거 snapshot으로 fallback하지 않는다.
    • GREEN 확인: repository focused test를 재실행해 최신 빈 배치와 기존 신규 공개 전/후 정책이 모두 통과하는지 확인한다.
    • REFACTOR: 최신 배치 조건 helper와 필요한 QueryDSL alias만 정리하고 port/DTO/DDL은 변경하지 않는다.
  • Task 7.3: 노출 가능한 콘텐츠 랭킹 row만 최신 중복 제거 기준으로 사용

    Goal 실행 P7-R2: REV-P7-002에 따라 더 최신이지만 노출 불가능한 CONTENT_RANKING row가 이전의 노출 가능한 row를 가리지 않도록 한다.

    • 시작 조건: P7-R1 완료와 reviews/phase-7-review.mdREV-P7-002 확정.
    • 완료 증거: rank=null 또는 회원에게 노출 불가한 최신 row 재현 테스트의 RED 확인, 최소 구현 후 focused test 통과, 검증 기록 누적.
    • 범위 밖: 콘텐츠 랭킹 발행/스냅샷 연동, 콘텐츠 동일성 기준 변경, 공개 응답 스키마 변경.
    • 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/targetId에서 이전 row는 노출 가능하고 더 최신 row는 rank=null인 fixture를 추가해 이전 row가 유지되어야 하는 테스트를 작성하고 실패를 확인한다.
    • RED: 비성인 회원에게 더 최신 inbox row만 isAdult=true인 경우에도 이전의 노출 가능한 row가 유지되는 테스트를 작성하고 실패를 확인한다.
    • GREEN: latestContentRankingNewsCondition(...)의 newer-row 판정에 rank is not null, 회원별 inbox 성인 조건 등 row마다 달라질 수 있는 외부 조회와 동일한 노출 조건을 적용한다.
    • GREEN 확인: repository focused test를 재실행해 노출 불가 newer row와 기존 최신 시각/id tie-break 회귀가 모두 통과하는지 확인한다.
    • REFACTOR: 중복 제거 조건만 최소 수정하고 다른 최근 소식 타입의 조인·조립은 변경하지 않는다.

Phase 7 리뷰 회귀 Gate

Goal 실행 P7-R-GATE: Phase 7 리뷰에서 확정된 빈 배치와 노출 가능 row 기준을 최종 판정한다.

  • 시작 조건: P7-R1, P7-R2 완료.
  • 완료 증거: 아래 명령이 모두 BUILD SUCCESSFUL이고 결과가 ## 6. 검증 기록reviews/phase-7-review.md에 누적됨.
  • 범위 밖: 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화, 신규 발행 기능.
./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.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
./gradlew --no-daemon ktlintCheck

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 테스트와 전체 회귀 검증을 수행한다.
  8. 후속 회귀 수정으로 CREATOR_RANKING 최신 공개 배치 필터와 CONTENT_RANKING 콘텐츠별 최신 소식 중복 제거를 적용한다.
  9. Phase 3 리뷰 회귀 수정으로 On Air와 라이브 스케줄에 기존 라이브 입장 제한을 적용한다.
  10. Phase 7 리뷰 회귀 수정으로 빈 최신 랭킹 배치와 노출 불가 콘텐츠 랭킹 newer row를 처리한다.
  11. Phase 4 리뷰 회귀 수정으로 publish와 팔로우 상태 변경의 동시성 경계를 보장한다.
  12. Phase 6 리뷰 후속 문서 수정으로 newsId 식별자 계약을 현재 구현과 동기화한다.

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를 사용하도록 계약과 테스트를 갱신했다.
  • 2026-07-12 팔로잉 크리에이터 정렬 변경 영향 확인:

    • DefaultHomeFollowingQueryRepository.findFollowingCreators의 정렬을 creatorFollowing.createdAt.desc()에서 creatorFollowing.createdAt.asc()로 변경한 계약에 맞춰 PRD/계획 문서의 followingCreators 정렬 설명을 오래된 팔로우순으로 갱신했다.
    • 직접 검색 결과 정렬 계약을 명시한 위치는 docs/20260625_메인_홈_팔로잉_탭_API/prd.md, docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md, DefaultHomeFollowingQueryRepositoryTest.shouldFindActiveFollowingCreatorsByOldestFollowOrder였고, 호출부는 HomeFollowingQueryService가 반환 순서를 그대로 조립하는 구조로 확인했다.
    • ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" 실행 결과 BUILD SUCCESSFUL.
    • ./gradlew --no-daemon tasks --all 실행 결과 BUILD SUCCESSFUL.
  • 2026-07-30 Phase 7 요구사항·계획 문서 반영:

    • PRD에 CREATOR_RANKING 최신 공개 WEEKLY 배치 한정, 신규 배치 공개 전 직전 배치 유지, 최신 배치에 없는 크리에이터의 과거 순위 미보충 정책을 추가했다.
    • PRD에 CONTENT_RANKING의 동일 contentId별 최신 항목 한 건 조회와 발행 기능 제외 정책을 추가했다.
    • plan-task.md에 미완료 회귀 수정 Goal P7-T1과 Phase Gate P7-GATE를 추가하고 RED → GREEN → REFACTOR, focused test, 직접 영향 E2E, lint 검증 명령을 연결했다.
    • rg -n -S "최신 공개|과거.*랭킹|동일.*contentId|CONTENT_RANKING.*발행|P7-T1|P7-GATE|Task 7\\.1|Phase 7" docs/20260625_메인_홈_팔로잉_탭_API/prd.md docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md로 두 정책과 Goal 연결을 확인했다.
    • git diff --check 실행 결과 오류가 없었다.
    • ./gradlew tasks --all 최초 실행은 sandbox의 사용자 Gradle 캐시 접근 제한으로 실패했으며, 동일 명령을 승인된 권한으로 재실행해 BUILD SUCCESSFUL을 확인했다.
    • 이번 요청은 문서 반영만 수행했으므로 P7-T1 구현 체크박스와 Phase 7 상태는 대기로 유지했다.
  • 2026-07-30 Phase 7 구현 검증:

    • RED 확인: ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" 실행 결과 shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews, shouldFindLatestContentRankingNewsPerContentBeforeLimit assertion 실패로 BUILD FAILED.
    • DefaultHomeFollowingQueryRepository.findRecentNews(...)에 최신 공개 WEEKLY 크리에이터 랭킹 배치 필터, CONTENT_RANKING 활성 오디오 target 조립, 동일 contentId 최신 row 조건을 추가했다. 공개 port/DTO/DDL은 변경하지 않았다.
    • GREEN 확인: ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" 실행 결과 BUILD SUCCESSFUL.
    • 직접 영향 E2E 확인: ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest" 최초 실행은 E2E fixture에 최신 공개 creator_ranking_snapshot이 없어 BUILD FAILED; fixture 보강 후 재실행 결과 BUILD SUCCESSFUL.
    • Gate lint 확인: ./gradlew --no-daemon ktlintCheck 실행 결과 BUILD SUCCESSFUL.
  • 2026-07-30 Phase 1~7 정적 코드 리뷰:

    • 사용자 요청에 따라 컴파일과 테스트는 재실행하지 않고 PRD, 구현 계획, production/test 코드, 기존 검증 기록을 정적으로 대조했다.
    • 문서 명령 유효성 확인을 위한 ./gradlew tasks --all 최초 실행은 sandbox의 사용자 Gradle cache 접근 제한으로 실패했고, 승인된 권한으로 같은 명령을 재실행해 BUILD SUCCESSFUL을 확인했다. 이 명령은 컴파일과 테스트를 실행하지 않는다.
    • Phase별 판정은 docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md부터 phase-7-review.md까지 기록했으며, Phase 5.5는 phase-5.5-review.md로 분리했다.
    • Phase 3의 라이브 입장 제한 누락 REV-P3-001을 확정하고 P3-R1, P3-R-GATE를 추가했다.
    • Phase 7의 빈 최신 크리에이터 랭킹 배치 판정 REV-P7-001과 노출 불가 콘텐츠 랭킹 newer row 판정 REV-P7-002를 확정하고 P7-R1, P7-R2, P7-R-GATE를 추가했다.
    • Phase 1, 2, 4, 5, 5.5, 6은 이번 정적 리뷰 범위에서 확정 발견 사항이 없다.
  • 2026-07-30 Phase 3·7 리뷰 보완 구현 검증:

    • RED 확인: ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" 실행 결과 findOnAirLives/findMonthlySchedules의 effective gender·creator 여부 인자 미반영 컴파일 오류로 BUILD FAILED.
    • HomeFollowingQueryService에서 본인인증 성별 우선 effective gender와 크리에이터 여부를 port로 전달하고, DefaultHomeFollowingQueryRepository의 On Air·라이브 스케줄에 genderRestriction/isAvailableJoinCreator 조건을 적용했다.
    • CREATOR_RANKING 최신 배치 식별을 creator_ranking_snapshot_job의 최신 WEEKLY, DONE 공개 시각 기준으로 보강해 최신 빈 배치에서 과거 랭킹을 보충하지 않도록 했다.
    • CONTENT_RANKING 중복 제거의 newer row 판정에 rank is not null과 회원별 inbox 성인 조건을 추가해 노출 불가 row가 이전 노출 가능 row를 가리지 않도록 했다.
    • GREEN 확인: 위 focused test 명령 재실행 결과 BUILD SUCCESSFUL.
    • E2E 확인: ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest" 실행 결과 BUILD SUCCESSFUL.
    • Gate lint 확인: ./gradlew --no-daemon ktlintCheck 최초 실행은 import 정렬 위반으로 BUILD FAILED; import 정렬 수정 후 재실행 결과 BUILD SUCCESSFUL.
    • 리뷰 게이트: Oracle reviewer가 P3-R1, P7-R1, P7-R2, 공개 API/DDL 비확장, 테스트/문서 기록을 검토했고 Critical/Important blocker 없음으로 승인했다.
  • 2026-07-30 Phase 1~7 2차 정적 코드 리뷰:

    • 사용자 요청에 따라 컴파일과 테스트를 실행하지 않고 PRD, 구현 계획, production/test 코드, 기존 검증 기록을 다시 대조했다.
    • Phase 3의 P3-R1과 Phase 7의 P7-R1·P7-R2 반영 코드 및 기존 Gate 성공 기록을 정적으로 재확인했다.
    • Phase 4에서 active follower 조회와 inbox insert 사이에 언팔로우가 완료되면 뒤늦게 active inbox가 생성되고, 이후 재팔로우 시 언팔로우 이전 이벤트가 노출될 수 있는 REV-P4-001을 확정했다.
    • Phase 6에서 PRD는 newsId{TYPE}:{targetId}로 설명하지만 repository와 테스트는 inbox PK의 10진 문자열을 사용하는 REV-P6-001을 확정했다.
    • 후속 작업으로 Task 4.6 / P4-R1 / P4-R-GATE와 Task 6.3 / P6-R1 / P6-R-GATE를 추가했다.
    • Phase 1, 2, 3, 5, 5.5, 7에서는 이번 2차 정적 리뷰의 신규 확정 발견 사항이 없다.
    • 이번 리뷰에서는 Gradle 명령을 실행하지 않았으며, 문서 변경은 정적 검색과 git diff --check로만 점검한다.
  • 2026-07-30 Phase 4·6 리뷰 보완 구현 검증:

    • RED 확인: ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest.shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed" 실행 결과 stale active inbox assertion 실패로 BUILD FAILED.
    • creator_following active follower 조회에 for update를 적용하고, MemberService.creatorFollow(...)/creatorUnFollow(...)가 같은 row를 PESSIMISTIC_WRITE로 조회하도록 보강했다. 공개 API·port·DDL은 변경하지 않았다.
    • GREEN 확인: 위 동시성 단일 테스트 재실행 결과 BUILD SUCCESSFUL.
    • P4-R1 focused test ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceTest" 실행 결과 BUILD SUCCESSFUL.
    • P4-R-GATE E2E ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest" 실행 결과 BUILD SUCCESSFUL.
    • P4-R-GATE lint ./gradlew --no-daemon ktlintCheck 최초 실행은 테스트 import 정렬 위반으로 BUILD FAILED; import 정렬 수정 후 재실행한다.
    • P4-R-GATE lint 재실행 결과 BUILD SUCCESSFUL.
    • P6-R1 문서 보완으로 PRD에서 scheduleId{TYPE}:{targetId}, newsIdhome_following_news_inbox.id의 10진 문자열이며 정렬·동률 해소에 사용하는 계약으로 분리했다.
    • P6-R-GATE 정적 검색 rg -n "newsId|scheduleId|home_following_news_inbox\.id" ... 실행 결과 PRD, repository, repository test, E2E가 scheduleId{TYPE}:{targetId}, newsId는 inbox PK 문자열 계약으로 일치함을 확인했다.
    • P6-R-GATE git diff --check 실행 결과 오류 없음.
    • P6-R-GATE ./gradlew tasks --all 실행 결과 BUILD SUCCESSFUL.
    • 리뷰 게이트: Oracle reviewer가 P4 동시성 보완과 P6 문서 계약 동기화를 검토했고 Critical/Important/Minor blocker 없음으로 승인했다.
  • 2026-07-30 Phase 1~7 3차 정적 코드 리뷰:

    • 사용자 요청에 따라 컴파일과 테스트를 실행하지 않고 PRD, 구현 계획, production/test 코드, 기존 리뷰·검증 기록을 Phase별로 다시 대조했다.
    • Phase 4에서 공개 POST /member/creator/followisActive=false를 전달해도 관계가 없으면 새 active 팔로우를 만들고, 기존 관계에서는 inbox 비활성화를 호출하지 않아 재팔로우 시 과거 소식이 다시 노출될 수 있는 REV-P4-002를 확정했다.
    • Phase 6에서 PRD의 최신 CREATOR_RANKING 배치 설명이 snapshot만 기준으로 적혀 있어, P7-R1로 반영된 최신 WEEKLY, DONE job 우선·legacy snapshot 제한 fallback 동작과 불일치하는 REV-P6-002를 확정했다.
    • 후속 작업으로 Task 4.7 / P4-R2 / P4-R2-GATE와 Task 6.4 / P6-R2 / P6-R2-GATE를 추가했다.
    • Phase 1, 2, 3, 5, 5.5, 7에서는 이번 3차 정적 리뷰의 신규 확정 발견 사항이 없다.
    • 이번 리뷰에서는 Gradle 명령을 실행하지 않았으며, 문서 변경은 정적 검색과 git diff --check로만 점검한다.
  • 2026-07-30 Phase 4 3차 리뷰 보완 구현 검증:

    • RED 확인: ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest" 실행 결과 creatorFollow(..., isActive=false) 신규 회귀 2건이 assertion 실패로 BUILD FAILED.
    • 관계가 없고 isActive=false인 통합 팔로우 요청은 새 active 관계를 만들지 않고, 기존 관계를 inactive로 바꾸는 경로에서는 homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)를 호출하도록 MemberService.creatorFollow(...)만 최소 수정했다.
    • GREEN 확인: 같은 MemberServiceTest 명령 재실행 결과 BUILD SUCCESSFUL.
    • P4-R2-GATE 확인: ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" 실행 결과 BUILD SUCCESSFUL.
    • P4-R2-GATE lint ./gradlew --no-daemon ktlintCheck 최초 실행은 신규 테스트 줄 길이 위반으로 BUILD FAILED; 포맷 정리 후 MemberServiceTestktlintCheck 재실행 결과 모두 BUILD SUCCESSFUL.
  • 2026-07-30 Phase 6 3차 리뷰 보완 문서 검증:

    • PRD Feature F와 최근 소식 Inbox 기술 제약의 CREATOR_RANKING 최신 공개 배치 설명을 현재 구현의 creator_ranking_snapshot_job 최신 WEEKLY, DONE, visibleFromAtUtc <= nowUtc 우선 기준과 일치하도록 갱신했다.
    • 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 creator_ranking_snapshot fallback을 허용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시했다.
    • P6-R2-GATE 정적 검색 rg -n "creator_ranking_snapshot_job|creator_ranking_snapshot|최신 공개 배치|WEEKLY|DONE|legacy|fallback" ... 실행 결과 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트의 기준을 대조했다. 검색 결과의 과거 Task 7.1 snapshot 설명은 Task 7.2에서 superseded된 완료 기록으로 확인했다.
    • P6-R2-GATE git diff --check 실행 결과 오류 없음.
    • P6-R2-GATE ./gradlew tasks --all 실행 결과 BUILD SUCCESSFUL.
    • 추가 전체 회귀 확인: ./gradlew --no-daemon test 실행 결과 BUILD SUCCESSFUL.
    • 리뷰 게이트: Oracle reviewer가 P4-R2 통합 언팔로우 보완과 P6-R2 문서 동기화를 검토했고 Critical/Important/Minor blocker 없음으로 승인했다.
  • 2026-07-30 Phase 1~7 4차 정적 코드 리뷰:

    • 사용자 요청에 따라 컴파일과 테스트를 실행하지 않고 PRD, 구현 계획, DDL, production/test 코드와 기존 리뷰 후속 구현을 Phase별로 다시 대조했다.
    • Phase 1은 공개 endpoint·비회원 응답·DTO, Phase 2는 inbox 저장·중복·잠금, Phase 3은 섹션별 조회·라이브 제한을 확인했다.
    • Phase 4는 after-commit 발행·팔로우 상태 동시성·통합 inactive 경로, Phase 5와 5.5는 facade/E2E·nested payload·무료 커뮤니티 정책을 확인했다.
    • Phase 6은 문서·식별자·latest batch 계약, Phase 7은 최신 빈 배치와 노출 가능한 콘텐츠 랭킹 중복 제거를 확인했다.
    • 각 Phase의 4차 판정은 reviews/phase-1-review.md부터 phase-7-review.md까지 누적했으며 Phase 5.5는 별도 보고서에 기록했다.
    • 이번 차수의 신규 확정 발견 사항이 없어 신규 회귀 수정 Task/Goal은 추가하지 않았고 기존 Phase 완료 판정을 유지한다.
    • 이번 리뷰에서는 Gradle 명령을 실행하지 않았으며, 문서 변경은 정적 검색과 git diff --check로만 점검한다.