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

46 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 값만 예약한다.
  • 최근 소식 응답에는 별도 creatorId를 내려주지 않는다. 크리에이터 채널 이동이 필요한 CREATOR_RANKINGtargetId가 크리에이터 회원 id다.
  • 최근 소식 랭킹 값은 rank: Int?만 사용한다. rankChange, isNew, nested ranking object는 사용하지 않는다.
  • 최근 소식 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 creatorProfileImageUrl: String,
    val creatorNickname: String,
    val title: String,
    val body: String,
    val thumbnailImageUrl: String?,
    val targetId: Long,
    val occurredAtUtc: String,
    val visibleFromAtUtc: String,
    val rank: Int?
) {
    companion object {
        fun from(news: HomeFollowingNews): FollowingNewsResponse {
            return FollowingNewsResponse(
                newsId = news.newsId,
                type = news.type,
                creatorProfileImageUrl = news.creatorProfileImageUrl,
                creatorNickname = news.creatorNickname,
                title = news.title,
                body = news.body,
                thumbnailImageUrl = news.thumbnailImageUrl,
                targetId = news.targetId,
                occurredAtUtc = news.occurredAtUtc,
                visibleFromAtUtc = news.visibleFromAtUtc,
                rank = news.rank
            )
        }
    }
}

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 creatorProfileImageUrl: String,
    val creatorNickname: String,
    val title: String,
    val body: String,
    val thumbnailImageUrl: String?,
    val targetId: Long,
    val occurredAtUtc: String,
    val visibleFromAtUtc: String,
    val rank: 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 변환 결과가 creatorId 없이 rank: Int?만 포함하는 테스트를 작성한다.
    • 실패 확인: ./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: creatorId가 응답 domain에 노출되지 않고 rank만 nullable로 내려가는 테스트를 작성한다.
    • 실패 확인: 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: publishCommunityPostCreated(...)가 현재 active follower에게만 inbox record를 생성하는 테스트를 작성한다.
    • 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(...) 성공 후 publishCommunityPostCreated(...)가 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: FollowingNewsResponsecreatorId와 nested ranking이 없고 rank만 있는지 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 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 "FollowingNewsRankingResponse|ranking\\?|rankChange|isNew|creatorId" docs/20260625_메인_홈_팔로잉_탭_API로 삭제된 공개 응답 필드가 남아 있는지 확인한다. 단, 팔로잉 크리에이터/스케줄의 creatorId와 DDL 내부 컬럼 creator_id는 허용한다.
    • 실행 명령: ./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. 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 길이 정규화를 보강했다.
    • 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.