feat(home): 팔로잉 탭 최근 소식 정책을 보강한다

This commit is contained in:
2026-07-30 19:51:30 +09:00
parent e6f56f24fe
commit f1c2e6c50b
21 changed files with 2145 additions and 19 deletions

View File

@@ -10,6 +10,23 @@
---
## 현재 후속 작업 상태
| 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-R1``P3-R-GATE``P7-R1``P7-R2``P7-R-GATE` 순서로 실행 완료했다.
- 2026-07-30 2차 정적 리뷰의 후속 순서 `P4-R1``P4-R-GATE``P6-R1``P6-R-GATE`는 실행 완료했다.
- 2026-07-30 3차 정적 리뷰의 후속 순서 `P4-R2``P4-R2-GATE``P6-R2``P6-R2-GATE`는 실행 완료했다.
---
## 0. 확정 사항
- API endpoint: `GET /api/v2/home/following`
@@ -30,6 +47,11 @@
- 최근 소식 상세 값은 타입별 nullable nested DTO로 내려준다. `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다.
- `CREATOR_RANKING``creatorRanking.rank`, `creatorRanking.creatorId`, `creatorRanking.nickname`, `creatorRanking.profileImageUrl`을 사용한다. `rankChange`, `isNew`는 사용하지 않는다.
- `CONTENT_RANKING``contentRanking.rank`, `contentRanking.contentId`, `contentRanking.contentImageUrl`, `contentRanking.title`을 사용한다.
- `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`/`photoContent``contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 담고, 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다.
- `COMMUNITY_POST``communityPost``postId`, `creatorProfileImage`, `creatorNickname`, nullable `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 담는다.
- `COMMUNITY_POST` 최근 소식은 무료 커뮤니티 게시글만 발행한다. 유료 커뮤니티 게시글은 inbox row를 생성하지 않는다.
@@ -90,6 +112,11 @@
- 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 초안
@@ -565,6 +592,48 @@ data class HomeFollowingNewsInboxRecord(
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: mock 기반 race 테스트와 통합 테스트의 책임을 분리해, mock은 분기 검증만 하고 통합 테스트는 실제 Hibernate 세션/트랜잭션 유효성을 검증하도록 정리한다.
- [x] **Task 3.7: 라이브 성별·크리에이터 입장 제한 회귀 수정**
**Goal 실행 `P3-R1`:** `REV-P3-001`에 따라 On Air와 라이브 스케줄에서 기존 라이브 입장 제한을 동일하게 적용한다.
- **시작 조건:** `reviews/phase-3-review.md``REV-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`
- [x] **RED:** 회원 본인인증 성별을 우선한 effective gender와 크리에이터 회원 여부가 query port에 전달되는 테스트를 추가하고 실패를 확인한다.
- [x] **RED:** 성별 제한이 맞지 않는 라이브와 `isAvailableJoinCreator=false`인 타 크리에이터 라이브가 On Air 및 라이브 스케줄에서 제외되는 repository 테스트를 추가하고 실패를 확인한다.
- [x] **GREEN:** 기존 `LiveRoomQueryRepositoryImpl`/`DefaultCreatorChannelHomeQueryRepository`
`genderRestriction` 및 크리에이터 입장 제한 조건을 재사용 가능한 최소 QueryDSL 조건으로 적용한다.
- [x] **GREEN 확인:** 아래 focused test를 실행해 신규 회귀와 기존 섹션 조회가 통과하는지 확인한다.
```bash
./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"
```
- [x] **REFACTOR:** live 조건만 정리하고 공개 DTO와 최근 소식 로직은 변경하지 않는다.
#### Phase 3 리뷰 회귀 Gate
**Goal 실행 `P3-R-GATE`:** Phase 3의 라이브 입장 정책 수정과 팔로잉 탭 조립 회귀를 최종 판정한다.
- **시작 조건:** `P3-R1` 완료.
- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-3-review.md`에 누적됨.
- **범위 밖:** 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화.
```bash
./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와 기존 이벤트 연결
- [x] **Task 4.1: sourceKey 생성 정책 구현**
@@ -631,6 +700,96 @@ data class HomeFollowingNewsInboxRecord(
- 통과 확인: 위 두 단일 테스트 명령 재실행, PASS 확인.
- REFACTOR: 결제/수정/관리자 저장 중 실제 공개 이벤트가 아닌 경로에서 중복 발행하지 않도록 sourceKey unique와 호출 지점을 함께 점검한다.
- [x] **Task 4.6: 언팔로우·재팔로우와 최근 소식 발행 동시성 보장**
**Goal 실행 `P4-R1`:** `REV-P4-001`에 따라 follower 판정부터 inbox insert까지의 경계를 팔로우 상태 변경과 직렬화해, 언팔로우 이전 이벤트가 재팔로우 후 새 소식으로 노출되지 않게 한다.
- **시작 조건:** `reviews/phase-4-review.md`의 `REV-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`
- [x] **RED:** publish가 active follower를 읽은 뒤 insert하기 전에 같은 회원이 언팔로우하는 순서를 두 트랜잭션과 latch로 고정한다. 언팔로우 완료 후 publish insert가 늦게 완료되고 다시 팔로우하면 언팔로우 이전 이벤트가 조회되는 현재 실패를 재현한다.
- [x] **GREEN:** follower 판정과 inbox insert를 하나의 트랜잭션 경계로 묶고 해당 `creator_following` row를 팔로우/언팔로우 상태 변경과 동일한 잠금 순서로 직렬화한다. publish가 먼저 완료되면 뒤이은 언팔로우가 새 row까지 비활성화하고, 언팔로우가 먼저 완료되면 publish가 해당 회원 row를 생성하지 않아야 한다.
- [x] **GREEN:** 재팔로우는 기존 비활성 inbox를 복구하지 않으며, 재팔로우 이후 발생한 새 이벤트만 active row로 생성되는 기존 정책을 유지한다.
- [x] **GREEN 확인:** 아래 focused test를 실행해 동시성 회귀와 기존 중복 방지·발행·언팔로우 테스트가 모두 통과하는지 확인한다.
```bash
./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"
```
- [x] **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 확장, 이번 회귀와 무관한 코드 수정.
```bash
./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가 공유 트랜잭션 경계의 회귀를 충분히 판정하지 못하면 전체 회귀로 확장하고 근거와 결과를 기록한다.
- [x] **Task 4.7: 통합 팔로우 API의 `isActive=false` 경로에서 inbox 비활성화**
**Goal 실행 `P4-R2`:** `REV-P4-002`에 따라 `creatorFollow(..., isActive=false)`가 새 active 팔로우를 만들지 않고, 기존 관계의 active inbox를 비활성화해 재팔로우 후 과거 소식이 다시 노출되지 않게 한다.
- **시작 조건:** `reviews/phase-4-review.md`의 `REV-P4-002` 확정, `P4-R1`과 `P4-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 최종 상태.
- [x] **RED:** 관계가 없는 회원이 `creatorFollow(..., isActive=false)`를 호출해도 새 active 팔로우가 생성되지 않는지 검증한다.
- [x] **RED:** active 팔로우와 active inbox를 준비한 뒤 `creatorFollow(..., isActive=false)`를 호출하고 관계와 inbox가 모두 inactive인지 검증한다. 이어 `creatorFollow(..., isActive=true)`를 호출해 기존 inbox가 inactive로 유지되는지 검증한다.
- [x] **RED 확인:** 아래 단일 테스트를 실행해 `creatorFollow(..., isActive=false)` 직후 inbox가 여전히 active인 assertion 실패를 확인한다.
```bash
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
```
- [x] **GREEN:** 관계가 없고 `isActive=false`면 전용 `creatorUnFollow(...)`와 동일하게 새 active 관계를 만들지 않는다. 기존 `creator_following` row에 `isActive=false`를 반영하는 같은 트랜잭션에서는 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다. `isActive=true`인 알림 변경·재팔로우 경로에서는 기존 비활성 inbox를 복구하지 않는다.
- [x] **GREEN 확인:** 같은 `MemberServiceTest` 명령을 재실행해 전용 언팔로우와 통합 팔로우 API 경로의 회귀가 모두 통과하는지 확인한다.
- [x] **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 변경, 이번 회귀와 무관한 코드 수정.
```bash
./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
- [x] **Task 5.1: HomeFollowingFacade 통합**
@@ -772,6 +931,178 @@ data class HomeFollowingNewsInboxRecord(
- 기대 결과: 두 명령 모두 `BUILD SUCCESSFUL`
- 검증 결과 기록: 각 task 완료 시 실행 명령, 결과, 실패 시 원인과 후속 조치를 이 문서의 해당 task 아래에 한국어로 누적 기록한다.
- [x] **Task 6.3: `newsId` 공개 계약을 현재 구현과 동기화**
**Goal 실행 `P6-R1`:** `REV-P6-001`에 따라 `newsId`가 `home_following_news_inbox.id`의 10진 문자열임을 PRD에 명시하고, `scheduleId`의 `{TYPE}:{targetId}` 계약과 분리한다.
- **시작 조건:** `reviews/phase-6-review.md`의 `REV-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가 없다.
- [x] `prd.md`의 공통 식별자 설명에서 `scheduleId`만 `{TYPE}:{targetId}` 형식으로 유지하고, `newsId`는 inbox PK의 10진 문자열이며 같은 노출 시각의 정렬·`CONTENT_RANKING` 동률 해소에 사용한다고 명시한다.
- [x] 아래 검색으로 PRD, repository, repository test, E2E의 `newsId` 형식을 정적으로 대조한다.
```bash
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
```
- [x] 문서 명령 유효성만 확인하는 `./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 응답 동작은 변경되지 않는다.
- [x] **Task 6.4: 크리에이터 랭킹 최신 공개 배치 기준 문서 동기화**
**Goal 실행 `P6-R2`:** `REV-P6-002`에 따라 PRD의 최신 `CREATOR_RANKING` 배치 판정 기준을 현재 구현의 `WEEKLY`, `DONE` job 우선·legacy snapshot 제한 fallback 정책과 일치시킨다.
- **시작 조건:** `reviews/phase-6-review.md`의 `REV-P6-002` 확정, `P7-R1`과 `P7-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가 없다.
- [x] `prd.md`의 두 최신 공개 배치 설명을 적용 가능한 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 시각 우선으로 수정한다.
- [x] 적용 가능한 `DONE` job이 전혀 없는 legacy/backfill 데이터에서만 최신 공개 snapshot 시각을 fallback으로 사용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시한다.
- [x] 아래 검색으로 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트를 정적으로 대조한다.
```bash
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
```
- [x] 문서 명령 유효성만 확인하는 `./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-T1`과 `P7-GATE` 완료, focused test·직접 영향 회귀·문서 검증 기록 누적.
- [x] **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)` 계약을 변경하지 않은 필터링 결과.
- [x] **RED:** `shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews` 테스트에 직전·최신 공개 크리에이터 랭킹 스냅샷과 inbox를 저장한다. 신규 배치 공개 전에는 직전 배치가 조회되고, 공개 후에는 최신 배치만 조회되며, 최신 배치에 없는 크리에이터의 과거 순위는 제외되는지 검증한다.
- [x] **RED:** `shouldFindLatestContentRankingNewsPerContentBeforeLimit` 테스트에 동일 `contentId`의 서로 다른 `visibleFromAtUtc` row, 같은 시각의 서로 다른 `newsId` row, 다른 콘텐츠 row를 저장한다. `visibleFromAtUtc desc`, `newsId desc` 기준 최신 row 하나만 남고 중복 제거 후 `limit`까지 다른 고유 소식이 채워지는지 검증한다.
- [x] **RED 확인:** 아래 focused test를 실행해 과거 크리에이터 배치 노출, `CONTENT_RANKING` 미조립 또는 동일 콘텐츠 중복 노출 때문에 assertion이 실패하는지 확인한다.
```bash
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
```
- [x] **GREEN:** `DefaultHomeFollowingQueryRepository.findRecentNews(...)`에 `creator_ranking_snapshot`의 `rankingType = WEEKLY`, `visibleFromAtUtc <= nowUtc` 중 최신 공개 시각과 일치하는 `CREATOR_RANKING`만 허용하는 조건을 추가한다. 공개 스냅샷이 없으면 `CREATOR_RANKING`을 반환하지 않는다.
- [x] **GREEN:** `CONTENT_RANKING`을 활성 오디오 콘텐츠 target과 조인·조립하고, 동일 회원·동일 `targetId`의 노출 가능한 더 최신 row가 존재하지 않는 항목만 남기는 조건을 추가한다. 최신 비교는 `visibleFromAtUtc`, 동률이면 inbox `id`를 사용하며 이 조건을 전체 `limit`보다 먼저 적용한다.
- [x] **GREEN 확인:** 같은 focused test 명령을 다시 실행해 두 회귀 테스트와 기존 repository 테스트가 모두 통과하는지 확인한다.
- [x] **REFACTOR:** 이번 Task가 추가한 QueryDSL alias와 조건 함수만 정리하고, 공개 port/DTO·DDL은 변경하지 않는다. 아래 직접 영향 회귀와 lint를 실행해 결과를 기록한다.
```bash
./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` 발행 기능 추가.
```bash
./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에서 공유 경계 회귀를 판단할 수 없는 실패가 발생하면 전체 회귀로 확장하고 근거와 결과를 기록한다.
- [x] **Task 7.2: 빈 최신 크리에이터 랭킹 배치에서 과거 소식 제외**
**Goal 실행 `P7-R1`:** `REV-P7-001`에 따라 최신 완료 배치의 결과가 0건이어도 이전 배치의 크리에이터 랭킹 소식을 노출하지 않는다.
- **시작 조건:** `reviews/phase-7-review.md`의 `REV-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`
- [x] **RED:** 직전 공개 스냅샷/inbox와 더 최신 `WEEKLY`, `DONE` snapshot job을 저장하되 최신 배치 스냅샷은 0건으로 두고, 과거 `CREATOR_RANKING`이 제외되어야 하는 테스트를 추가해 실패를 확인한다.
- [x] **GREEN:** 최신 공개 배치 식별은 결과 row가 없어도 남는 `creator_ranking_snapshot_job`의 `WEEKLY`, `DONE`,
`visibleFromAtUtc <= nowUtc` 최신 시각을 기준으로 한다. 기존 snapshot만 있고 적용 가능한 job 이력이 전혀 없는 데이터의 호환 fallback이 필요하면 그 경우로만 제한하며, 빈 `DONE` 배치에서는 과거 snapshot으로 fallback하지 않는다.
- [x] **GREEN 확인:** repository focused test를 재실행해 최신 빈 배치와 기존 신규 공개 전/후 정책이 모두 통과하는지 확인한다.
- [x] **REFACTOR:** 최신 배치 조건 helper와 필요한 QueryDSL alias만 정리하고 port/DTO/DDL은 변경하지 않는다.
- [x] **Task 7.3: 노출 가능한 콘텐츠 랭킹 row만 최신 중복 제거 기준으로 사용**
**Goal 실행 `P7-R2`:** `REV-P7-002`에 따라 더 최신이지만 노출 불가능한 `CONTENT_RANKING` row가 이전의 노출 가능한 row를 가리지 않도록 한다.
- **시작 조건:** `P7-R1` 완료와 `reviews/phase-7-review.md`의 `REV-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`
- [x] **RED:** 같은 `memberId/targetId`에서 이전 row는 노출 가능하고 더 최신 row는 `rank=null`인 fixture를 추가해 이전 row가 유지되어야 하는 테스트를 작성하고 실패를 확인한다.
- [x] **RED:** 비성인 회원에게 더 최신 inbox row만 `isAdult=true`인 경우에도 이전의 노출 가능한 row가 유지되는 테스트를 작성하고 실패를 확인한다.
- [x] **GREEN:** `latestContentRankingNewsCondition(...)`의 newer-row 판정에 `rank is not null`, 회원별 inbox 성인 조건 등 row마다 달라질 수 있는 외부 조회와 동일한 노출 조건을 적용한다.
- [x] **GREEN 확인:** repository focused test를 재실행해 노출 불가 newer row와 기존 최신 시각/id tie-break 회귀가 모두 통과하는지 확인한다.
- [x] **REFACTOR:** 중복 제거 조건만 최소 수정하고 다른 최근 소식 타입의 조인·조립은 변경하지 않는다.
#### Phase 7 리뷰 회귀 Gate
**Goal 실행 `P7-R-GATE`:** Phase 7 리뷰에서 확정된 빈 배치와 노출 가능 row 기준을 최종 판정한다.
- **시작 조건:** `P7-R1`, `P7-R2` 완료.
- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-7-review.md`에 누적됨.
- **범위 밖:** 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화, 신규 발행 기능.
```bash
./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. 구현 순서 요약
@@ -783,6 +1114,11 @@ data class HomeFollowingNewsInboxRecord(
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` 식별자 계약을 현재 구현과 동기화한다.
---
@@ -865,3 +1201,96 @@ data class HomeFollowingNewsInboxRecord(
- 직접 검색 결과 정렬 계약을 명시한 위치는 `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}`, `newsId`는 `home_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/follow`가 `isActive=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`; 포맷 정리 후 `MemberServiceTest`와 `ktlintCheck` 재실행 결과 모두 `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`로만 점검한다.

View File

@@ -12,6 +12,8 @@
- 최근 소식은 랭킹, 커뮤니티 게시글 업로드, 콘텐츠 업로드가 섞인 피드라 매 요청마다 팔로잉한 모든 크리에이터의 모든 원천 데이터를 크게 조인하면 응답 지연과 DB 부하가 커질 수 있다.
- 최근 소식은 전체 후보를 매번 조회하는 모델보다, 팔로우 중인 크리에이터의 이벤트가 발생할 때 각 follower의 우체통에 소식 row를 넣는 사용자별 Inbox Feed 모델이 요구사항에 더 맞다.
- 따라서 공개 API 조립 계층과 도메인 조회 계층을 분리하고, 최근 소식은 사용자별 inbox row를 최신순으로 읽는 구조가 필요하다.
- 현재 최근 소식 조회는 노출 가능한 과거 `CREATOR_RANKING` inbox도 함께 조회하므로, 전체 소식이 30개 미만이면 직전 공개 배치보다 오래된 크리에이터 순위가 표시될 수 있다.
- 향후 `CONTENT_RANKING` inbox가 적재되면 같은 콘텐츠가 여러 랭킹 소식에 포함될 수 있으므로, 조회 시 동일 콘텐츠의 중복 노출을 방지하는 정책이 필요하다.
---
@@ -24,6 +26,8 @@
- DM/AI 채팅방 중 최신 대화순 10개를 응답한다.
- 사용자가 팔로우한 크리에이터들의 이번 달 오늘 이후 스케줄을 오늘과 가까운 순으로 최대 3개 응답한다.
- 사용자가 팔로우한 크리에이터들의 최근 소식을 최신 노출 가능 시각순 최대 30개 응답한다.
- `CREATOR_RANKING` 최근 소식은 현재 시점에 공개된 최신 크리에이터 랭킹 배치만 응답한다.
- `CONTENT_RANKING` 최근 소식은 같은 콘텐츠의 노출 가능한 inbox가 여러 개여도 가장 최신 항목 하나만 응답한다.
- 최근 소식은 팔로우 중인 크리에이터의 이벤트 발생 시점에 사용자별 inbox row를 생성하고, 조회 시 열람 가능 시각/활성 여부/차단/성인 노출 조건을 적용한다.
- 새로 팔로우한 사용자는 과거 소식을 받지 않는다.
- 언팔로우하면 해당 크리에이터가 보낸 기존 inbox row를 비활성화한다.
@@ -42,6 +46,7 @@
- 최근 소식의 운영자 수동 고정/숨김 기능은 포함하지 않는다.
- 최근 소식 발송용 외부 MQ, outbox table, 별도 worker, cursor/retry dashboard는 이번 범위에 포함하지 않는다.
- 화보 업로드 기능 자체 구현은 포함하지 않는다. 단, 향후 콘텐츠 타입 확장을 고려한 응답 타입은 정의한다.
- `CONTENT_RANKING` inbox 발행과 콘텐츠 랭킹 스냅샷 연동은 이번 보완 범위에 포함하지 않는다.
- 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
---
@@ -59,7 +64,8 @@
- 사용자는 팔로우한 크리에이터가 지금 진행 중인 라이브를 바로 확인하고 싶다.
- 사용자는 최근 DM/AI 채팅방으로 빠르게 이동하고 싶다.
- 사용자는 팔로우한 크리에이터의 이번 달 예정 라이브/콘텐츠 일정을 가까운 일정부터 보고 싶다.
- 사용자는 팔로우한 크리에이터의 이번 주 랭킹 순위, 커뮤니티 게시글, 콘텐츠 업로드 소식을 최신순으로 보고 싶다.
- 사용자는 팔로우한 크리에이터의 최신 공개 랭킹 순위, 커뮤니티 게시글, 콘텐츠 업로드 소식을 최신순으로 보고 싶다.
- 사용자는 과거 크리에이터 랭킹 배치나 같은 콘텐츠의 중복 랭킹 소식 없이 최근 소식을 보고 싶다.
- 앱 클라이언트는 소식 item의 타입별 터치 액션을 명확한 target id로 처리하고 싶다.
---
@@ -174,6 +180,13 @@
- inbox row에는 소식 타입, 발생 시각, 열람 가능 시각, 수신 회원 id, 크리에이터 id, target id, 표시용 제목/본문/이미지 path, 랭킹 순위 값 등 응답 생성에 필요한 최소 정보를 저장한다.
- API 조회는 `memberId = 요청 회원 id`, `isActive = true`, `visibleFromAtUtc <= nowUtc`인 inbox row를 최신순으로 조회한다.
- 조회 정렬은 `visibleFromAtUtc desc`, `newsId desc`를 기본으로 한다.
- `CREATOR_RANKING`은 적용 가능한 `creator_ranking_snapshot_job``rankingType = WEEKLY`, `status = DONE`, `visibleFromAtUtc <= nowUtc`를 만족하는 최신 완료 job의 `visibleFromAtUtc`와 같은 inbox row만 조회한다.
- 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot``WEEKLY`, `visibleFromAtUtc <= nowUtc` 최신 공개 시각을 fallback으로 사용한다.
- 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다.
- 다음 랭킹 배치가 공개되기 전에는 직전 공개 배치를 최신 배치로 유지하고, 새 배치가 공개된 뒤에는 이전 배치의 `CREATOR_RANKING` inbox를 조회하지 않는다.
- 사용자가 팔로우한 크리에이터가 최신 공개 배치에 없으면 해당 크리에이터의 과거 `CREATOR_RANKING` inbox로 대체하지 않는다.
- `CONTENT_RANKING`은 같은 회원과 같은 `targetId`를 콘텐츠 동일성 기준으로 사용하고, 노출 가능한 row 중 `visibleFromAtUtc desc`, `newsId desc` 기준 첫 항목 하나만 조회한다.
- 랭킹 배치 필터와 콘텐츠 중복 제거는 전체 최대 30개 제한보다 먼저 적용한다.
- 조회 시 원천 target의 비활성/삭제 여부, 차단 관계, 성인 노출 가능 여부를 최종 확인한다.
- `FollowingNewsResponse` 최상위 응답 필드는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 포함한다.
- 타입별 세부 값은 nullable nested DTO로 내려주며, `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다.
@@ -190,12 +203,16 @@
- 즉시 공개 콘텐츠는 `visibleFromAtUtc = releaseDate`로 저장할 수 있다.
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시 inbox row를 생성할 수 있으나, `visibleFromAtUtc`는 랭킹 스냅샷의 `visibleFromAtUtc`를 그대로 사용한다.
- 크리에이터 랭킹 스냅샷이 월요일 01:00 KST에 생성되고 월요일 09:00 KST에 화면 반영되는 경우, `CREATOR_RANKING` inbox row도 월요일 09:00 KST 전에는 API에 노출되지 않아야 한다.
- 월요일 신규 랭킹 공개 전에는 직전 공개 배치의 `CREATOR_RANKING` 소식을 표시하고, 신규 배치 공개 시점부터는 신규 배치 소식만 표시한다.
- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다. 랭킹 타입은 nested DTO의 `rank`만 내려준다.
#### Edge Cases
- inbox row가 없거나 필터링 후 결과가 없으면 빈 배열을 내려준다.
- inbox 적재 실패 시 API 조회에서 실시간 fallback 집계를 무조건 수행하지 않는다.
- 랭킹 소식의 순위 값이 없거나 오래된 경우 해당 item은 생성하지 않는다.
- 최신 공개 크리에이터 랭킹 배치에 해당하는 inbox가 없으면 과거 배치로 보충하지 않고 `CREATOR_RANKING` 소식을 가능한 개수만 응답한다.
- 같은 콘텐츠의 `CONTENT_RANKING` row가 여러 개이고 `visibleFromAtUtc`가 같으면 `newsId`가 큰 row 하나만 응답한다.
- 과거 크리에이터 랭킹과 중복 콘텐츠 랭킹을 제외한 뒤 최근 소식이 30개 미만이어도 과거·중복 랭킹으로 보충하지 않는다.
- 같은 회원, 같은 소식 타입, 같은 `sourceKey`에 대해 중복 inbox row를 생성하지 않는다.
- 언팔로우와 inbox 적재가 동시에 발생하면, 최종적으로 언팔로우 상태인 크리에이터의 새 소식은 노출하지 않는다.
- 타입별 이미지가 없으면 해당 nested DTO의 이미지 URL 필드는 `null`로 내려준다.
@@ -328,7 +345,8 @@ enum class FollowingNewsType {
```
- `ChatRoomListItemResponse`는 기존 `v2.chat.dto` 응답 DTO를 직접 재사용한다.
- `scheduleId``newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다. 최근 소식의 이동 대상 id는 타입별 nested DTO 안의 id 필드를 사용한다.
- `scheduleId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 사용한다.
- `newsId``home_following_news_inbox.id`의 10진 문자열이며, 최근 소식의 `visibleFromAtUtc desc`, `newsId desc` 정렬과 `CONTENT_RANKING` 동률 해소에 사용한다. 최근 소식의 이동 대상 id는 타입별 nested DTO 안의 id 필드를 사용한다.
---
@@ -370,7 +388,9 @@ enum class FollowingNewsType {
- publish service는 `publishContentUploaded(...)`, `publishFreeCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다. 유료 커뮤니티 게시글은 publish service 호출 대상이 아니다.
- 운영 규모가 커지면 publish service 내부에서 outbox row 저장 또는 비동기 worker 위임으로 전환할 수 있도록 호출부 계약을 작게 유지한다.
- `CREATOR_RANKING` 타입은 크리에이터 랭킹 소식만 포함한다.
- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값만 예약하고, 이번 범위에서는 생성하지 않는다.
- `CREATOR_RANKING` 조회의 최신 공개 배치는 적용 가능한 `creator_ranking_snapshot_job``WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 조건으로 우선 판정한다. 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot`의 최신 공개 시각을 fallback으로 사용하며, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다.
- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값을 유지하되, 이번 보완에서는 발행 기능을 추가하지 않고 기존 또는 향후 적재된 inbox의 조회 정책만 정의한다.
- `CONTENT_RANKING``targetId`별 최신 노출 가능 row 하나만 남기고, `visibleFromAtUtc`가 같으면 `newsId` 내림차순으로 하나를 선택한다.
- 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다.
- 재팔로우 시 비활성화된 기존 inbox row는 복구하지 않는다.
- 현재 `creator_following`에는 재팔로우 시점이 명확히 남지 않으므로, 조회 조건으로 재팔로우 시점을 추론하지 않는다.
@@ -395,3 +415,10 @@ enum class FollowingNewsType {
## 12. Open Questions
- 현재 PRD 기준의 미결정 요구사항은 없다.
- 구현 계획 단계에서는 기존 라이브 조회 코드의 진행 중 판단 조건과 스케줄 `isOnAir` 판단 조건을 같은 조건으로 추출할지 검토한다.
---
## 13. Decision Log
- 2026-07-30: `CREATOR_RANKING` 최근 소식은 KST 달력 주간이 아니라 현재 시점의 최신 공개 `WEEKLY`, `DONE` 크리에이터 랭킹 job을 기준으로 한다. 신규 배치 공개 전에는 직전 공개 배치를 유지하고, 공개 후에는 이전 배치를 노출하지 않으며, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다.
- 2026-07-30: `CONTENT_RANKING` 발행 기능은 이번 보완 범위에서 제외한다. 조회 시 동일 `contentId`의 노출 가능한 row 중 `visibleFromAtUtc desc`, `newsId desc` 기준 최신 항목 하나만 응답한다.

View File

@@ -0,0 +1,59 @@
# Phase 1 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / Task 1.1~1.2 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/*.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- DTO/domain 모델, 비로그인 응답, controller, Security `permitAll`이 공개 계약과 일치하는지 정적으로 대조했다.
- `HomeFollowingTabResponse.kt`, `HomeFollowingController.kt`, `HomeFollowingFacade.kt`, `SecurityConfig.kt`와 대응 테스트를 포함했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. `plan-task.md`의 기존 성공 기록은 참고 증거로만 사용했다.
## 3. 검토 근거
- `HomeFollowingTabResponse.loginRequired()`는 로그인 필요 상태와 다섯 개 빈 배열을 생성한다.
- controller는 nullable 인증 회원을 facade에 전달하고 `ApiResponse.ok(...)`로 감싼다.
- `SecurityConfig``GET /api/v2/home/following``permitAll`로 허용한다.
- DTO 테스트와 controller 테스트는 비회원/인증 회원 계약 및 nested 최근 소식 변환을 다룬다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 1 코드·테스트·문서 정적 대조 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 기존 기록 분리 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 3차 정적 리뷰 — 2026-07-30
- controller, facade, DTO, Security permitAll과 비로그인 빈 응답 계약을 현재 working tree 기준으로 다시 대조했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 1 완료 판정을 유지한다.
## 8. 4차 정적 리뷰 — 2026-07-30
- 공개 endpoint, nullable 인증 principal, `ApiResponse.ok(...)`, 로그인 필요 빈 응답과 nested DTO 변환을 현재 working tree에서 다시 대조했다.
- controller·facade·DTO 테스트가 비회원/인증 회원 분기와 공개 응답 계약을 고정하는지 정적으로 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 1 완료 판정을 유지한다.

View File

@@ -0,0 +1,59 @@
# Phase 2 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 2 / Task 2.1~2.2 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, `create-home-following-news-inbox-table.sql` |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- inbox entity/JPA repository/adapter와 MySQL DDL의 컬럼·유니크 키·인덱스·비활성화 정책을 정적으로 대조했다.
- 중복 충돌 retry, 활성 follower 조회, 테스트 격리와 기존 통합 테스트 범위를 확인했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
## 3. 검토 근거
- entity와 DDL은 `member_id/news_type/source_key` 유니크 정책, timestamp, 길이, 활성 상태 컬럼이 일치한다.
- adapter는 입력 중복 제거 후 기존 수신 회원을 일괄 조회하고 `saveAll`/`flush`하며, unique 충돌 시 새 트랜잭션으로 한 번 재시도한다.
- 언팔로우 비활성화 쿼리와 활성 follower 조회 쿼리는 계획의 키 조건을 사용한다.
- 통합 테스트는 실제 unique 충돌 후 트랜잭션 사용 가능 여부, 비활성화, 활성 follower 조회를 포함한다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | entity/repository/adapter/DDL/test 정적 대조 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 3차 정적 리뷰 — 2026-07-30
- inbox DDL, Entity, JPA adapter의 중복 방지·retry·비활성화 계약을 현재 working tree 기준으로 다시 대조했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 2 완료 판정을 유지한다.
## 8. 4차 정적 리뷰 — 2026-07-30
- DDL·Entity의 컬럼 길이, timestamp, unique/index 정책과 adapter의 중복 제거·충돌 재시도·비활성화 동작을 다시 대조했다.
- 활성 follower 조회의 잠금이 Phase 4 팔로우 상태 변경 경계와 연결되고 관련 persistence 테스트가 이를 고정하는지 정적으로 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 2 완료 판정을 유지한다.

View File

@@ -0,0 +1,120 @@
# Phase 3 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 3 / Task 3.1~3.6 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md` Feature B~F, `plan-task.md` Phase 3 |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- 팔로잉 크리에이터, On Air, 월간 스케줄, 최근 소식 repository와 query service를 기존 접근 정책까지 포함해 정적으로 대조했다.
- `DefaultHomeFollowingQueryRepository.kt`, `HomeFollowingQueryPort.kt`, `HomeFollowingQueryService.kt`와 대응 테스트를 검토했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
## 3. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P3-001` | High | 확정 | On Air와 라이브 스케줄이 기존 라이브 입장 제한을 적용하지 않는다 | Task 3.7 | `P3-R1` |
## 4. 발견 사항 상세
### REV-P3-001 — On Air와 라이브 스케줄이 기존 라이브 입장 제한을 적용하지 않는다
- **심각도:** High
- **상태:** 확정
- **관련 요구사항:** PRD Feature C의 성별·크리에이터 입장 제한, Feature E의 기존 채널 스케줄 정책 재사용
- **소유 Task:** 신규 Task 3.7 / `P3-R1`
**관찰 내용**
`HomeFollowingQueryService`는 회원 id와 성인 콘텐츠 허용 여부만 port에 전달한다.
`DefaultHomeFollowingQueryRepository.findOnAirLives(...)`와 live schedule 조회는 활성/채널/성인/차단 조건만 적용하며
`live_room.gender_restriction`과 크리에이터 회원의 `is_available_join_creator` 조건을 적용하지 않는다.
반면 기존 `LiveRoomQueryRepositoryImpl.getLiveRoomListNow(...)`
`DefaultCreatorChannelHomeQueryRepository.findCurrentLive/findSchedules(...)`는 effective gender와 크리에이터 입장 제한을 적용한다.
**근거**
- 코드: `HomeFollowingQueryService.kt:19-30`
- 코드: `DefaultHomeFollowingQueryRepository.kt:70-99`, `242-273`
- 비교 코드: `LiveRoomRepository.kt:97-115`
- 비교 코드: `DefaultCreatorChannelHomeQueryRepository.kt:136-145`, `225-233`
- 테스트 공백: `DefaultHomeFollowingQueryRepositoryTest`의 On Air 테스트는 활성/성인/정렬만 검증한다.
**정적 재현 절차**
1. 남성 회원이 여성 전용 라이브를 연 팔로잉 크리에이터를 조회한다고 가정한다.
2. 라이브를 `isActive=true`, non-empty `channelName`, 비성인으로 두면 현재 팔로잉 조회 조건을 모두 통과한다.
3. 실제 라이브 입장 정책은 성별 불일치로 입장을 거부하지만 팔로잉 탭 On Air에는 노출된다.
4. 크리에이터 회원이 `isAvailableJoinCreator=false`인 타 크리에이터 라이브를 조회하는 경우도 동일하게 노출된다.
**영향**
팔로잉 탭에 터치해도 입장할 수 없는 라이브 또는 스케줄이 노출되어 기존 라이브 접근 정책과 API 결과가 불일치한다.
**권장 조치**
회원의 본인인증 성별을 우선한 effective gender와 크리에이터 회원 여부를 query service에서 전달하고, 기존 QueryDSL 조건을
On Air와 live schedule에 최소 적용한다. 성별 불일치와 크리에이터 입장 불가 회귀 테스트를 먼저 추가한다.
**판정 기록**
- 2026-07-30 — PRD와 기존 라이브 조회 구현을 대조해 확정했다. 런타임 테스트는 사용자 지시에 따라 실행하지 않았다.
## 5. plan·goal 전환
- `plan-task.md` Phase 3에 Task 3.7, `P3-R1`, `P3-R-GATE`를 추가했다.
- 기존 Task 3.1~3.6의 완료 체크와 검증 기록은 유지했다.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 3 repository/service/test 정적 대조 |
| 후보 항목 판정 완료 | 충족 | `REV-P3-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 3.7 / `P3-R1` |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 수정 goal 필요.
**남은 항목:** `P3-R1` 구현 후 `P3-R-GATE`와 이 문서의 수정 후 검증 기록을 수행한다.
## 7. 수정 후 검증 기록
- 2026-07-30 — 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"` 실행 결과 effective gender·creator 여부 port 인자 미반영 컴파일 오류로 `BUILD FAILED`.
- 2026-07-30 — `HomeFollowingQueryService`에서 본인인증 성별 우선 effective gender와 크리에이터 회원 여부를 `HomeFollowingQueryPort`에 전달하고, On Air와 라이브 스케줄 QueryDSL에 `genderRestriction``isAvailableJoinCreator` 조건을 적용했다.
- 2026-07-30 — GREEN 확인: 같은 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`.
- 2026-07-30 — Gate 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"``./gradlew --no-daemon ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
- 2026-07-30 — 리뷰 게이트: Oracle reviewer가 Critical/Important blocker 없음으로 승인했다.
## 8. 후속 종료 판정 — 2026-07-30
- `HomeFollowingQueryService`가 본인인증 성별 우선 effective gender와 크리에이터 여부를 On Air·스케줄 조회에 전달하는 현재 코드를 정적으로 재확인했다.
- `DefaultHomeFollowingQueryRepository`가 On Air와 라이브 스케줄에 `genderRestriction``isAvailableJoinCreator` 조건을 적용하는 현재 코드를 정적으로 재확인했다.
- 이번 후속 판정에서는 컴파일과 테스트를 재실행하지 않았고, 위 `## 7. 수정 후 검증 기록`의 기존 성공 결과를 근거로 삼았다.
**최종 결론:** `P3-R1``P3-R-GATE` 수정 검증 완료.
**남은 항목:** 없음.
## 9. 3차 정적 리뷰 — 2026-07-30
- 팔로잉 크리에이터, On Air, 월간 스케줄, 최근 소식 조회 조건과 `P3-R1`의 성별·크리에이터 입장 제한 반영을 현재 working tree에서 다시 추적했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 3 완료 판정을 유지한다.
## 10. 4차 정적 리뷰 — 2026-07-30
- 팔로잉 크리에이터·On Air·월간 스케줄·최근 소식의 활성/role/차단/성인/시간 범위/정렬/limit 조건을 PRD와 다시 대조했다.
- `P3-R1`의 effective gender와 크리에이터 입장 제한이 service→port→On Air·라이브 스케줄 query에 동일하게 전달되는지 확인했다.
- repository/service 테스트가 KST 월간 경계, 동률 정렬, 원천 target 활성 상태와 라이브 접근 제한을 고정하는지 정적으로 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 3 완료 판정을 유지한다.

View File

@@ -0,0 +1,203 @@
# Phase 4 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 4 / Task 4.1~4.5 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md` Feature F, `plan-task.md` Phase 4 |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- source key, follower fan-out, 언팔로우 비활성화, 랭킹/오디오/무료 커뮤니티 발행 연결을 정적으로 추적했다.
- 원 트랜잭션 commit 이후 발행, 발행 실패 격리, 공개 시각과 중복 방지 키를 확인했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
## 3. 검토 근거
- source key는 타입과 원천 id/기간으로 안정적으로 생성된다.
- publish service는 활성 follower에게만 record를 만들고 DB 컬럼 길이에 맞춰 title/body를 제한한다.
- 언팔로우는 기존 inbox를 비활성화하며 재팔로우가 이를 복구하지 않는다.
- 랭킹, 즉시/예약 오디오, 무료 커뮤니티 생성 경로는 commit 이후 publish service를 호출하고 실패를 원 처리와 격리한다.
- 관련 단위·서비스 테스트는 발행 성공, 예약 공개, 유료 미발행, 발행 실패 격리를 포함한다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 발행 경로와 호출부 정적 추적 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 2차 리뷰 — 2026-07-30
### 리뷰 범위
- active follower 조회, inbox insert의 트랜잭션 경계와 언팔로우·재팔로우 상태 변경을 함께 정적으로 추적했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
### REV-P4-001 — follower 판정 후 언팔로우가 완료되면 stale active inbox가 생성될 수 있음
**심각도:** Medium
**상태:** 확정
**근거**
- `HomeFollowingNewsPublishService.publishToFollowers(...)`는 active follower id 목록을 먼저 읽고 이후 별도 호출로 inbox row를 insert한다.
- `HomeFollowingNewsInboxJpaRepository.findActiveFollowerIds(...)``creator_following`을 잠그지 않는 조회다.
- `HomeFollowingNewsInboxPersistenceAdapter.insertIgnoreAll(...)`의 실제 insert는 `REQUIRES_NEW` 경로에서 실행될 수 있어 follower 조회와 하나의 직렬화 경계를 공유하지 않는다.
- `MemberService.creatorUnFollow(...)`는 언팔로우 시점에 존재하는 active inbox만 비활성화하고, `creatorFollow(...)`의 재팔로우는 기존 관계 row를 다시 active로 바꾼다.
**정적 재현 절차**
1. publish 트랜잭션 A가 active follower인 회원을 읽고 insert 전에 멈춘다.
2. 트랜잭션 B가 같은 회원의 언팔로우와 현재 inbox 비활성화를 완료한다.
3. A가 기존 follower snapshot으로 active inbox를 뒤늦게 insert한다.
4. 언팔로우 중에는 조회의 active-following 조건으로 숨겨지지만, 회원이 다시 팔로우하면 해당 언팔로우 이전 이벤트가 노출 가능해진다.
**영향**
재팔로우 시 기존 비활성 row를 복구하지 않고 재팔로우 이후의 새 이벤트만 제공한다는 정책을 우회해, 언팔로우 이전 이벤트가 최근 소식으로 나타날 수 있다.
**권장 조치**
follower 판정부터 inbox insert까지를 하나의 트랜잭션 경계로 묶고, 해당 `creator_following` row를 팔로우·언팔로우 상태 변경과 같은 순서로 잠근다. publish가 먼저 끝나면 뒤이은 언팔로우가 새 row까지 비활성화하고, 언팔로우가 먼저 끝나면 publish가 row를 생성하지 않는 두 순서를 동시성 테스트로 고정한다.
**판정 기록**
- 2026-07-30 — publish/follow/unfollow 호출 흐름과 트랜잭션 경계를 대조해 확정했다. 런타임 테스트는 실행하지 않았다.
### plan·goal 전환
- `plan-task.md` Phase 4에 Task 4.6, `P4-R1`, `P4-R-GATE`를 추가했다.
- 기존 Task 4.1~4.5의 완료 체크와 검증 기록은 유지했다.
### 2차 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | publish/follow/unfollow 트랜잭션 경계 정적 추적 |
| 후보 항목 판정 완료 | 충족 | `REV-P4-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 4.6 / `P4-R1` |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 수정 goal 필요.
**남은 항목:** 없음. `P4-R1``P4-R-GATE` 수정 후 검증은 아래 기록에 누적했다.
## 8. 수정 후 검증 — 2026-07-30
- RED 확인: `HomeFollowingNewsInboxPersistenceAdapterTest.shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed` 실행 결과 stale active inbox assertion 실패로 `BUILD FAILED`.
- follower 조회와 inbox insert 경계에서 `creator_following` row를 잠그도록 active follower 조회에 `for update`를 적용했고, `creatorFollow(...)`/`creatorUnFollow(...)`도 같은 row를 `PESSIMISTIC_WRITE`로 조회하도록 변경했다.
- GREEN 확인: 동시성 단일 테스트 재실행 결과 `BUILD SUCCESSFUL`.
- P4-R1 focused test `HomeFollowingNewsInboxPersistenceAdapterTest`, `HomeFollowingNewsPublishServiceTest`, `MemberServiceTest` 실행 결과 `BUILD SUCCESSFUL`.
- P4-R-GATE E2E `HomeFollowingEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`.
- P4-R-GATE lint `ktlintCheck` 최초 실행은 테스트 import 정렬 위반으로 `BUILD FAILED`; import 정렬 수정 후 재실행한다.
- P4-R-GATE lint `ktlintCheck` 재실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
**수정 후 결론:** `REV-P4-001` 보완 완료.
## 9. 3차 정적 리뷰 — 2026-07-30
### 리뷰 범위
- 공개 팔로우·언팔로우 controller 경로, `MemberService` 상태 전이, inbox 비활성화와 재팔로우 조회 결과를 함께 정적으로 추적했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
### REV-P4-002 — 통합 팔로우 API의 `isActive=false` 경로가 기존 inbox를 비활성화하지 않음
**심각도:** High
**상태:** 확정
**관련 요구사항:** PRD Feature F의 언팔로우 시 기존 inbox 비활성화, 재팔로우 시 기존 inbox 미복구
**소유 Task:** Task 4.7 / `P4-R2`
**관찰 내용**
`POST /member/creator/follow`는 request의 `isActive=false``MemberService.creatorFollow(...)`에 전달해 팔로우·언팔로우를 함께 처리한다. 관계 row가 없으면 전달된 `isActive`를 반영하지 않은 기본 active 관계를 만들고, 기존 관계 row가 있으면 `creatorFollowing.isActive=false`만 반영한 채 inbox 비활성화를 호출하지 않는다. 이후 같은 API로 다시 활성화하면 기존 inbox row가 계속 active이므로 active-following 조회 조건을 다시 만족해 언팔로우 이전 소식이 노출될 수 있다.
**근거**
- `MemberController.creatorFollow(...)`는 nullable `request.isActive`를 기본값과 함께 service에 전달한다.
- `CreatorFollowRequest``isActive`를 공개 request 필드로 정의한다.
- `MemberService.creatorFollow(...)`의 신규 관계 분기는 기본값이 `isActive=true``CreatorFollowing()`을 저장하고, 기존 관계 분기는 `isNotify``isActive`만 변경한다.
- `MemberService.creatorUnFollow(...)``homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다.
- `MemberServiceTest`는 전용 `creatorUnFollow(...)` 경로만 검증하며 `creatorFollow(..., isActive=false)` 경로는 검증하지 않는다.
**정적 재현 절차**
1. 관계가 없는 회원이 `POST /member/creator/follow``isActive=false`를 보내면 기본 active 관계가 새로 생성된다.
2. 별도로 active 팔로우와 active inbox row가 있는 회원이 같은 요청을 보내면 관계 row만 inactive가 되고 inbox는 active로 남는다.
3. 같은 API에 `isActive=true`를 보내 재팔로우한다.
4. 기존 active inbox가 다시 active-following 조건을 만족해 최근 소식 조회 후보가 된다.
**영향**
관계가 없는 회원은 언팔로우 요청으로 오히려 active follower가 될 수 있다. 기존 follower는 언팔로우 경로에 따라 과거 소식 보존 상태가 달라지고, 통합 언팔로우 경로에서 재팔로우 이후 과거 소식 미복구 계약을 위반한다.
**권장 조치**
관계가 없고 `isActive=false`면 전용 언팔로우 경로와 동일하게 새 active 관계를 만들지 않는다. 기존 관계를 `isActive=false`로 반영하는 같은 트랜잭션에서는 inbox를 비활성화한다. `isActive=true`인 알림 변경·재팔로우는 기존 비활성 inbox를 복구하지 않도록 유지하고, 두 경우를 `MemberServiceTest`로 고정한다.
**판정 기록**
- 2026-07-30 — request/controller/service의 공개 `isActive=false` 흐름과 PRD 재팔로우 정책을 정적으로 대조해 확정했다. 런타임 테스트는 실행하지 않았다.
### plan·goal 전환
- `plan-task.md` Phase 4에 Task 4.7, `P4-R2`, `P4-R2-GATE`를 추가했다.
- 기존 Task 4.1~4.6의 완료 체크와 검증 기록은 유지했다.
### 3차 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 두 공개 상태 변경 경로와 조회 조건 정적 추적 |
| 후보 항목 판정 완료 | 충족 | `REV-P4-002` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 4.7 / `P4-R2` |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 정적 근거 기록 |
**최종 결론:** 수정 goal 필요.
**남은 항목:** 없음. `P4-R2``P4-R2-GATE` 수정 후 검증은 아래 기록에 누적했다.
## 10. 3차 수정 후 검증 — 2026-07-30
- 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`; 포맷 정리 후 `MemberServiceTest``ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
- 추가 전체 회귀 확인: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
**수정 후 결론:** `REV-P4-002` 보완 완료.
## 11. 4차 정적 리뷰 — 2026-07-30
- source key, after-commit 발행, follower fan-out, 랭킹·오디오·무료 커뮤니티 호출부와 발행 실패 격리를 다시 추적했다.
- `P4-R1``creator_following` 잠금 순서와 `P4-R2`의 통합 `isActive=false` 경로가 전용 언팔로우와 같은 inbox 최종 상태를 만드는지 확인했다.
- persistence·publish·`MemberService` 테스트가 중복 방지, 동시 publish/unfollow, 신규 inactive 요청과 재팔로우 미복구를 고정하는지 정적으로 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. `REV-P4-001`, `REV-P4-002` 수정 완료와 기존 Phase 4 완료 판정을 유지한다.

View File

@@ -0,0 +1,58 @@
# Phase 5 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 5 / Task 5.1~5.2 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md` Feature A·D, `plan-task.md` Phase 5 |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- facade의 비회원 단락, 도메인 조회와 기존 최근 대화 10개 조립, E2E API 표면을 정적으로 대조했다.
- `HomeFollowingFacade.kt`, facade/controller/E2E 테스트를 검토했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
## 3. 검토 근거
- 비회원은 query/chat service 호출 없이 로그인 필요 응답을 받는다.
- 로그인 회원은 `ChatRoomListService.getRooms(member, "ALL", null, 10)` 결과를 도메인 조회 결과에 조립한다.
- E2E 테스트는 비회원 빈 섹션과 로그인 회원의 다섯 섹션, 최근 소식 JSON surface를 검증한다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | facade/controller/E2E 정적 대조 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 3차 정적 리뷰 — 2026-07-30
- facade의 로그인 분기, 최근 대화 재사용, E2E 조립 범위를 현재 working tree 기준으로 다시 대조했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 5 완료 판정을 유지한다.
## 8. 4차 정적 리뷰 — 2026-07-30
- 비회원 조기 반환, 로그인 회원의 도메인 조회와 `ChatRoomListService.getRooms(..., limit = 10)` 조립을 다시 대조했다.
- controller/facade/E2E 테스트가 비회원의 조회 생략과 로그인 회원의 다섯 섹션 공개 응답을 고정하는지 정적으로 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 5 완료 판정을 유지한다.

View File

@@ -0,0 +1,60 @@
# Phase 5.5 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 5.5 / Task 5.5.1~5.5.5 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md` Feature F·G, `plan-task.md` Phase 5.5 |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- 타입별 nullable nested DTO, 원천 target enrichment, 무료 커뮤니티 전용 발행과 E2E JSON 계약을 정적으로 대조했다.
- DTO/domain/repository/커뮤니티 호출부와 대응 테스트를 포함했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
## 3. 검토 근거
- 최근 소식 최상위는 공통 필드와 다섯 nested 필드만 노출하고 flat 이동/표시 필드를 제거했다.
- repository는 랭킹, 활성 오디오, 활성 무료 커뮤니티 원천을 타입별 payload로 조립한다.
- 커뮤니티 좋아요와 최상위 댓글은 active row만 집계한다.
- 유료 커뮤니티는 발행과 조회 양쪽에서 제외된다.
- DTO 및 E2E 테스트는 nested payload의 상호 배타적 null 계약과 제거된 flat 필드를 검증한다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | DTO/repository/call site/test 정적 대조 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 3차 정적 리뷰 — 2026-07-30
- 타입별 nullable nested DTO, 원천 target enrichment, 무료 커뮤니티 발행 제한과 E2E JSON 계약을 현재 working tree 기준으로 다시 대조했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 5.5 완료 판정을 유지한다.
## 8. 4차 정적 리뷰 — 2026-07-30
- 최근 소식 공통 필드와 타입별 상호 배타적 nested DTO, CDN/UTC 변환, 원천 오디오·무료 커뮤니티 enrichment를 다시 대조했다.
- 유료 커뮤니티와 비활성·성인 원천 target 제외, active like·top-level comment 집계가 repository와 E2E 테스트에 연결되는지 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 5.5 완료 판정을 유지한다.

View File

@@ -0,0 +1,185 @@
# Phase 6 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 6 / Task 6.1~6.2 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, DDL, 기존 검증 기록 |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- PRD, 구현 계획, DDL, 실제 공개 DTO의 현재 계약과 Phase별 기존 검증 기록을 정적으로 대조했다.
- 현재 compile/test 통과 상태는 사용자 설명과 `plan-task.md` 기존 기록을 근거로 삼았으며 직접 재실행하지 않았다.
## 3. 검토 근거
- PRD와 계획의 endpoint, 섹션 limit, nested 최근 소식 필드, 무료 커뮤니티 정책, DDL 유니크 키가 현재 코드와 일치한다.
- `plan-task.md`에는 Phase 1~7의 focused/회귀/lint 성공 기록과 실패 후 보완 이력이 누적되어 있다.
- 이번 리뷰에서 확정된 후속 결함은 기존 완료 상태를 되돌리지 않고 소유 Phase의 신규 Task로 추가했다.
- `./gradlew tasks --all` 최초 실행은 sandbox의 사용자 Gradle cache 접근 제한으로 실패했고, 승인된 권한으로 재실행한 결과
`BUILD SUCCESSFUL`이었다. task 목록만 확인했으며 컴파일과 테스트는 실행하지 않았다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음. Phase 3과 Phase 7의 코드 발견 사항은 각 Phase 보고서와 Task에 귀속했다.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | PRD/plan/DDL/code/기존 기록 정적 대조 |
| 후보 항목 판정 완료 | 충족 | Phase 6 자체 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 다른 Phase 소유 항목은 해당 Phase에 반영 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 기존 기록 분리 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음. Phase 3·7 회귀 Goal과 Phase 6 후속 문서 검증 기록은 이후 섹션에 누적했다.
## 7. 2차 리뷰 — 2026-07-30
### 기존 후속 항목 확인
- Phase 3의 `P3-R1`과 Phase 7의 `P7-R1`·`P7-R2` 구현 및 기존 Gate 성공 기록이 누적된 것을 확인했다.
- 이번 확인에서는 컴파일과 테스트를 재실행하지 않았다.
### REV-P6-001 — PRD의 `newsId` 형식이 현재 공개 동작과 불일치
**심각도:** Low
**상태:** 확정
**근거**
- PRD 공통 식별자 설명은 `scheduleId``newsId` 모두 `{TYPE}:{targetId}` 형식을 기본안으로 적고 있다.
- `DefaultHomeFollowingQueryRepository``home_following_news_inbox.id`를 문자열로 변환해 `newsId`로 반환한다.
- repository test와 E2E도 inbox PK의 10진 문자열을 계약으로 검증한다.
- 최근 소식 정렬과 `CONTENT_RANKING` 동률 해소도 inbox `id`를 사용하므로, 현재 동작을 유지한 문서 수정이 가장 작은 정합성 보완이다.
**영향**
클라이언트 또는 후속 구현자가 PRD만 보면 `newsId`를 타입·target 기반 식별자로 해석할 수 있어 실제 응답 파싱, 정렬 의미, 동일 target의 여러 소식 식별을 잘못 구현할 수 있다.
**권장 조치**
runtime 코드와 공개 응답 값은 변경하지 않는다. PRD에서 `scheduleId``{TYPE}:{targetId}`, `newsId``home_following_news_inbox.id`의 10진 문자열이며 같은 노출 시각의 정렬·동률 해소에 쓰인다고 분리해 명시한다.
**판정 기록**
- 2026-07-30 — PRD, repository, repository test, E2E를 정적으로 대조해 확정했다. Gradle 명령은 실행하지 않았다.
### plan·goal 전환
- `plan-task.md` Phase 6에 문서 전용 Task 6.3, `P6-R1`, `P6-R-GATE`를 추가했다.
- 기존 Task 6.1~6.2의 완료 체크와 검증 기록은 유지했다.
### 2차 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | PRD/repository/test/E2E 식별자 계약 정적 대조 |
| 후보 항목 판정 완료 | 충족 | `REV-P6-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 6.3 / `P6-R1` |
| 검증 명령과 결과 기록 | 충족 | Gradle 미실행 사유 기록 |
**최종 결론:** 문서 수정 goal 필요.
**남은 항목:** 없음. `P6-R1``P6-R-GATE` 수정 후 검증은 아래 기록에 누적했다.
## 8. 수정 후 검증 — 2026-07-30
- PRD에서 `scheduleId``newsId` 식별자 설명을 분리했다.
- `scheduleId``{TYPE}:{targetId}` 형식, `newsId``home_following_news_inbox.id`의 10진 문자열이며 `visibleFromAtUtc desc`, `newsId desc` 정렬과 `CONTENT_RANKING` 동률 해소에 사용한다고 명시했다.
- runtime 코드, 테스트, DDL, 공개 응답 필드는 변경하지 않았다.
- 정적 검색 `rg -n "newsId|scheduleId|home_following_news_inbox\.id" ...` 실행 결과 PRD와 repository/test/E2E의 식별자 계약이 일치함을 확인했다.
- `git diff --check` 실행 결과 오류 없음.
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
**수정 후 결론:** `REV-P6-001` 문서 보완 완료.
## 9. 3차 정적 리뷰 — 2026-07-30
### 리뷰 범위
- PRD의 최근 소식 최신 배치 정책, Phase 7 회귀 Task, repository 조건과 회귀 테스트를 정적으로 대조했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
### REV-P6-002 — PRD의 최신 크리에이터 랭킹 배치 판정 기준이 현재 구현과 불일치
**심각도:** Low
**상태:** 확정
**관련 요구사항:** 최신 공개 `WEEKLY` 배치만 조회하고 빈 최신 배치에서 과거 순위를 보충하지 않는 정책
**소유 Task:** Task 6.4 / `P6-R2`
**관찰 내용**
PRD Feature F와 기술 제약은 최신 `CREATOR_RANKING` 배치를 `creator_ranking_snapshot`의 최대 공개 시각으로 판정한다고 기술한다. 그러나 `REV-P7-001` 보완 이후 현재 repository는 적용 가능한 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 시각을 우선 사용하고, 해당 job이 전혀 없을 때만 snapshot으로 fallback한다.
**근거**
- PRD의 최신 배치 설명 두 곳은 `creator_ranking_snapshot`만 판정 원천으로 명시한다.
- `plan-task.md` Task 7.2는 빈 최신 완료 배치를 식별하기 위해 job 우선·legacy snapshot 제한 fallback을 확정했다.
- `DefaultHomeFollowingQueryRepository.latestVisibleCreatorRankingBatchCondition(...)`은 최신 DONE job 시각을 우선하고 `hasRankingJob.not()`일 때만 snapshot 시각을 사용한다.
- `DefaultHomeFollowingQueryRepositoryTest.shouldExcludeCreatorRankingNewsWhenLatestDoneBatchIsEmpty`는 최신 DONE job의 snapshot row가 0건이어도 과거 소식을 반환하지 않는 현재 계약을 검증한다.
**영향**
후속 구현자가 PRD만 따르면 최신 빈 완료 배치에서 과거 snapshot을 다시 최신으로 판정해 이미 수정한 회귀를 재도입할 수 있다.
**권장 조치**
runtime은 변경하지 않는다. PRD 두 곳을 최신 공개 DONE job 우선으로 동기화하고, 적용 가능한 DONE job이 전혀 없는 legacy/backfill 데이터에서만 snapshot fallback을 허용하며 빈 DONE 배치는 과거 snapshot으로 보충하지 않는다고 명시한다.
**판정 기록**
- 2026-07-30 — PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트를 정적으로 대조해 확정했다. Gradle 명령은 실행하지 않았다.
### plan·goal 전환
- `plan-task.md` Phase 6에 문서 전용 Task 6.4, `P6-R2`, `P6-R2-GATE`를 추가했다.
- 기존 Task 6.1~6.3의 완료 체크와 검증 기록은 유지했다.
### 3차 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | PRD/Task/repository/test 최신 배치 기준 정적 대조 |
| 후보 항목 판정 완료 | 충족 | `REV-P6-002` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 6.4 / `P6-R2` |
| 검증 명령과 결과 기록 | 충족 | Gradle 미실행 사유와 정적 근거 기록 |
**최종 결론:** 문서 수정 goal 필요.
**남은 항목:** 없음. `P6-R2``P6-R2-GATE` 수정 후 검증은 아래 기록에 누적했다.
## 10. 3차 수정 후 검증 — 2026-07-30
- 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`.
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
**수정 후 결론:** `REV-P6-002` 문서 보완 완료.
## 11. 4차 정적 리뷰 — 2026-07-30
- PRD·plan·DDL·공개 DTO와 현재 repository의 endpoint, limit, 식별자, latest ranking batch, nested 최근 소식 계약을 다시 대조했다.
- `newsId`의 inbox PK 문자열 계약과 최신 `WEEKLY`, `DONE` job 우선·legacy snapshot 제한 fallback 설명이 현재 코드·테스트와 일치함을 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. `REV-P6-001`, `REV-P6-002` 문서 보완 완료와 기존 Phase 6 완료 판정을 유지한다.

View File

@@ -0,0 +1,162 @@
# Phase 7 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 7 / Task 7.1 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md` Feature F·Decision Log, `plan-task.md` Phase 7 |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- 최신 공개 크리에이터 랭킹 배치 필터와 콘텐츠별 최신 랭킹 중복 제거가 모든 확정 edge case에서 limit 전에 적용되는지 검토했다.
- 현재 working tree의 repository, repository test, E2E fixture 변경을 포함했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. 기존 Phase 7 성공 기록은 참고 증거로만 사용했다.
## 3. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P7-001` | High | 확정 | 최신 완료 배치가 0건이면 과거 크리에이터 랭킹이 다시 최신으로 판정된다 | Task 7.2 | `P7-R1` |
| `REV-P7-002` | High | 확정 | 노출 불가능한 최신 콘텐츠 랭킹 row가 이전의 노출 가능한 row를 가린다 | Task 7.3 | `P7-R2` |
## 4. 발견 사항 상세
### REV-P7-001 — 최신 완료 배치가 0건이면 과거 크리에이터 랭킹이 다시 최신으로 판정된다
- **심각도:** High
- **상태:** 확정
- **관련 요구사항:** 최신 공개 배치만 노출, 최신 배치에 없는 크리에이터의 과거 순위 미보충
- **소유 Task:** 신규 Task 7.2 / `P7-R1`
**관찰 내용**
최신 배치 조건은 `creator_ranking_snapshot.visibleFromAtUtc.max()`만 조회한다. 최신 집계가 성공했지만 점수 조건을 통과한
snapshot이 0건이면 해당 배치를 나타내는 snapshot row가 없으므로 max 값은 직전 배치 시각으로 남는다.
랭킹 job은 결과가 0건이어도 `creator_ranking_snapshot_job``WEEKLY`, `DONE`, `visibleFromAtUtc`를 남기므로 완료 배치를
식별할 근거가 이미 있지만 현재 팔로잉 조회는 이를 사용하지 않는다.
**근거**
- 코드: `DefaultHomeFollowingQueryRepository.kt:428-451`
- 코드: `DefaultCreatorRankingSnapshotRepository.kt:51-65`
- 코드: `CreatorRankingSnapshotJobService.kt:52-69`
- 테스트 공백: `shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews`는 최신 배치에 snapshot이 1건 이상인 경우만 검증한다.
**정적 재현 절차**
1. 직전 주간 snapshot과 같은 시각의 `CREATOR_RANKING` inbox를 둔다.
2. 더 최신 주간 job을 `DONE`으로 완료하지만 집계 결과 snapshot은 0건으로 둔다.
3. 최신 job 공개 시각 이후 API를 조회한다.
4. 요구 결과는 크리에이터 랭킹 0건이지만, 현재 subquery max는 직전 snapshot 시각이므로 과거 inbox가 노출된다.
**영향**
활동 부족 등으로 최신 배치 결과가 비어 있는 주에 명시적으로 금지된 과거 랭킹 보충이 발생한다.
**권장 조치**
결과 row와 독립적으로 남는 최신 공개 `DONE` job을 배치 식별 기준으로 사용하고, 최신 빈 배치에서는 과거 snapshot으로
fallback하지 않는 회귀 테스트를 추가한다.
**판정 기록**
- 2026-07-30 — snapshot 교체와 job 완료 흐름을 정적으로 추적해 확정했다.
### REV-P7-002 — 노출 불가능한 최신 콘텐츠 랭킹 row가 이전의 노출 가능한 row를 가린다
- **심각도:** High
- **상태:** 확정
- **관련 요구사항:** 같은 콘텐츠의 노출 가능한 row 중 최신 1건 조회
- **소유 Task:** 신규 Task 7.3 / `P7-R2`
**관찰 내용**
외부 row는 `CONTENT_RANKING``rank is not null`, inbox 성인 조건 등으로 노출 가능 여부를 확인한다. 그러나
`latestContentRankingNewsCondition(...)`의 newer-row subquery는 member/type/target/active/visible/order만 확인하고
`rank is not null`과 회원별 성인 조건을 확인하지 않는다.
따라서 더 최신 row의 `rank`가 null이면 최신 row는 외부 조건에서 제외되면서도 이전 정상 row를 subquery에서 가려 결과가 0건이 된다.
**근거**
- 코드: `DefaultHomeFollowingQueryRepository.kt:154-166`
- 코드: `DefaultHomeFollowingQueryRepository.kt:428-438`
- 코드: `DefaultHomeFollowingQueryRepository.kt:454-471`
- 테스트 공백: 현재 콘텐츠 중복 테스트는 모든 newer row가 노출 가능한 경우만 검증한다.
**정적 재현 절차**
1. 같은 회원·콘텐츠에 `rank=2`, 노출 가능 시각 09:00인 정상 row를 둔다.
2. 같은 키에 `rank=null`, 노출 가능 시각 10:00인 active row를 둔다.
3. 10:00 이후 조회하면 이전 row는 newer row 존재로 제외되고, newer row는 `rank is not null` 조건에서 제외된다.
4. 요구 결과는 노출 가능한 row 중 최신인 09:00 row 1건이지만 실제 결과는 0건이다.
**영향**
부분 적재나 정책 변경으로 최신 inbox row가 노출 불가능해지면 유효한 이전 콘텐츠 랭킹까지 사라져 “노출 가능한 row 중 최신” 계약을 위반한다.
**권장 조치**
newer-row 판정에 외부 조회와 동일한 row별 노출 조건을 적용한다. 최소한 `rank is not null`과 회원별 inbox 성인 조건의 회귀
테스트를 추가하고 기존 시각/id tie-break 테스트를 유지한다.
**판정 기록**
- 2026-07-30 — 외부 where와 correlated subquery 조건을 대조해 확정했다.
## 5. plan·goal 전환
- `plan-task.md` Phase 7에 Task 7.2/7.3, `P7-R1`, `P7-R2`, `P7-R-GATE`를 추가했다.
- 기존 Task 7.1과 `P7-GATE`의 완료 기록은 유지했다.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | working tree repository/test/E2E 정적 대조 |
| 후보 항목 판정 완료 | 충족 | `REV-P7-001`, `REV-P7-002` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 7.2/7.3 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 수정 goal 필요.
**남은 항목:** `P7-R1``P7-R2``P7-R-GATE` 실행 후 이 문서에 수정 검증을 누적한다.
## 7. 수정 후 검증 기록
- 2026-07-30 — 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"` 실행 결과 최신 빈 배치와 노출 불가 newer row 보완 전 컴파일/회귀 실패를 확인했다.
- 2026-07-30 — `CREATOR_RANKING` 최신 배치 기준을 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc`로 보강해 최신 완료 배치가 비어 있어도 과거 배치를 보충하지 않도록 했다.
- 2026-07-30 — `CONTENT_RANKING` newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건을 적용해 노출 불가 최신 row가 이전 노출 가능 row를 가리지 않도록 했다.
- 2026-07-30 — GREEN 확인: 같은 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`.
- 2026-07-30 — Gate 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"``./gradlew --no-daemon ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
- 2026-07-30 — 리뷰 게이트: Oracle reviewer가 Critical/Important blocker 없음으로 승인했다.
## 8. 후속 종료 판정 — 2026-07-30
- 최신 공개 크리에이터 랭킹 배치가 빈 결과여도 과거 배치로 fallback하지 않는 현재 job 기준 조건을 정적으로 재확인했다.
- `CONTENT_RANKING` newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건이 적용된 현재 코드를 정적으로 재확인했다.
- 이번 후속 판정에서는 컴파일과 테스트를 재실행하지 않았고, 위 `## 7. 수정 후 검증 기록`의 기존 성공 결과를 근거로 삼았다.
**최종 결론:** `P7-R1`, `P7-R2``P7-R-GATE` 수정 검증 완료.
**남은 항목:** 없음.
## 9. 3차 정적 리뷰 — 2026-07-30
- 최신 DONE job 우선의 크리에이터 랭킹 배치 판정, legacy snapshot fallback, 노출 가능한 콘텐츠 랭킹 중복 제거를 현재 working tree에서 다시 추적했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- runtime 신규 확정 발견 사항 없음. PRD의 stale 최신 배치 설명은 문서 소유 Phase의 `REV-P6-002`와 Task 6.4로 전환했으며, 기존 Phase 7 완료 판정을 유지한다.
## 10. 4차 정적 리뷰 — 2026-07-30
- 최신 공개 `WEEKLY`, `DONE` job 기준과 job 부재 시 snapshot fallback, 최신 빈 배치의 과거 랭킹 미보충을 다시 추적했다.
- `CONTENT_RANKING`의 노출 가능한 newer row 판정에 active/rank/성인/visible/id 조건이 적용되고 전체 limit 전에 콘텐츠별 한 건만 남는지 확인했다.
- repository 테스트가 신규 배치 공개 전후, 빈 최신 배치, rank null·성인 newer row와 동률 id 해소를 고정하는지 정적으로 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. `REV-P7-001`, `REV-P7-002` 수정 완료와 기존 Phase 7 완료 판정을 유지한다.

View File

@@ -497,12 +497,14 @@ class MemberService(
fun creatorFollow(creatorId: Long, isNotify: Boolean, isActive: Boolean, memberId: Long) {
if (creatorId == memberId) return
val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(
val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberIdForUpdate(
creatorId = creatorId,
memberId = memberId
)
if (creatorFollowing == null) {
if (!isActive) return
val creator = repository.findByIdOrNull(creatorId)
?: throw SodaException(messageKey = "member.validation.creator_not_found")
val member = repository.findByIdOrNull(memberId)
@@ -515,12 +517,15 @@ class MemberService(
} else {
creatorFollowing.isNotify = isNotify && isActive
creatorFollowing.isActive = isActive
if (!isActive) {
homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId)
}
}
}
@Transactional
fun creatorUnFollow(creatorId: Long, memberId: Long) {
val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(
val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberIdForUpdate(
creatorId = creatorId,
memberId = memberId
)

View File

@@ -4,12 +4,14 @@ import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import javax.persistence.LockModeType
@Repository
interface CreatorFollowingRepository : JpaRepository<CreatorFollowing, Long>, CreatorFollowingQueryRepository
interface CreatorFollowingQueryRepository {
fun findByCreatorIdAndMemberId(creatorId: Long, memberId: Long): CreatorFollowing?
fun findByCreatorIdAndMemberIdForUpdate(creatorId: Long, memberId: Long): CreatorFollowing?
}
@Repository
@@ -25,4 +27,15 @@ class CreatorFollowingQueryRepositoryImpl(
)
.fetchFirst()
}
override fun findByCreatorIdAndMemberIdForUpdate(creatorId: Long, memberId: Long): CreatorFollowing? {
return queryFactory
.selectFrom(creatorFollowing)
.where(
creatorFollowing.creator.id.eq(creatorId)
.and(creatorFollowing.member.id.eq(memberId))
)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.fetchFirst()
}
}

View File

@@ -11,7 +11,9 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommun
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.block.QBlockMember
@@ -22,11 +24,16 @@ import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeF
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentRankingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreatorRankingNews
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 kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.QCreatorRankingSnapshot
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.QCreatorRankingSnapshotJob
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@@ -64,7 +71,13 @@ class DefaultHomeFollowingQueryRepository(
}
}
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
override fun findOnAirLives(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
limit: Int
): List<HomeFollowingLive> {
val creator = QMember("onAirCreator")
return queryFactory
.select(liveRoom.id, creator.profileImage, creator.nickname, liveRoom.title, liveRoom.beginDateTime)
@@ -80,6 +93,8 @@ class DefaultHomeFollowingQueryRepository(
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultLiveCondition(canViewAdultContent),
genderLiveCondition(memberId, effectiveViewerGender),
creatorJoinLiveCondition(memberId, isViewerCreator),
notBlockedCreatorCondition(memberId, creator.id)
)
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
@@ -99,11 +114,19 @@ class DefaultHomeFollowingQueryRepository(
override fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule> {
val window = monthlyScheduleWindow(now)
val liveSchedules = findLiveSchedules(memberId, canViewAdultContent, window)
val liveSchedules = findLiveSchedules(
memberId,
canViewAdultContent,
effectiveViewerGender,
isViewerCreator,
window
)
val audioSchedules = findAudioSchedules(memberId, canViewAdultContent, window)
return (liveSchedules + audioSchedules)
.sortedWith(
@@ -141,7 +164,7 @@ class DefaultHomeFollowingQueryRepository(
.from(homeFollowingNewsInbox)
.join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId))
.leftJoin(newsAudioContent).on(
homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT),
homeFollowingNewsInbox.newsType.`in`(FollowingNewsType.AUDIO_CONTENT, FollowingNewsType.CONTENT_RANKING),
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId)
)
.leftJoin(newsCommunity).on(
@@ -157,7 +180,7 @@ class DefaultHomeFollowingQueryRepository(
activeFollowingCondition(memberId, homeFollowingNewsInbox.creatorId),
adultNewsCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId),
activeNewsTargetCondition(canViewAdultContent)
activeNewsTargetCondition(memberId, canViewAdultContent, nowUtc)
)
.orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc())
.limit(limit.toLong())
@@ -209,6 +232,16 @@ class DefaultHomeFollowingQueryRepository(
} else {
null
},
contentRanking = if (type == FollowingNewsType.CONTENT_RANKING && rank != null) {
HomeFollowingContentRankingNews(
rank = rank,
contentId = targetId,
contentImageUrl = get(newsAudioContent.coverImage).toCdnUrl(cloudFrontHost),
title = get(newsAudioContent.title)!!
)
} else {
null
},
communityPost = if (type == FollowingNewsType.COMMUNITY_POST) {
HomeFollowingCommunityPostNews(
postId = targetId,
@@ -229,6 +262,8 @@ class DefaultHomeFollowingQueryRepository(
private fun findLiveSchedules(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
window: ScheduleWindow
): List<HomeFollowingSchedule> {
val creator = QMember("scheduleLiveCreator")
@@ -254,6 +289,8 @@ class DefaultHomeFollowingQueryRepository(
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultLiveCondition(canViewAdultContent),
genderLiveCondition(memberId, effectiveViewerGender),
creatorJoinLiveCondition(memberId, isViewerCreator),
notBlockedCreatorCondition(memberId, creator.id)
)
.fetch()
@@ -340,6 +377,20 @@ class DefaultHomeFollowingQueryRepository(
return if (canViewAdultContent) null else liveRoom.isAdult.isFalse
}
private fun genderLiveCondition(viewerId: Long, effectiveViewerGender: Gender): BooleanExpression? {
val genderCondition = when (effectiveViewerGender) {
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
Gender.NONE -> return null
}
return genderCondition.or(liveRoom.member.id.eq(viewerId))
}
private fun creatorJoinLiveCondition(viewerId: Long, isViewerCreator: Boolean): BooleanExpression? {
if (!isViewerCreator) return null
return liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(viewerId))
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
@@ -385,7 +436,11 @@ class DefaultHomeFollowingQueryRepository(
}
}
private fun activeNewsTargetCondition(canViewAdultContent: Boolean): BooleanExpression {
private fun activeNewsTargetCondition(
memberId: Long,
canViewAdultContent: Boolean,
nowUtc: LocalDateTime
): BooleanExpression {
val newsAudioContent = QAudioContent("newsAudioContent")
val newsCommunity = QCreatorCommunity("newsCommunity")
val activeAudioExists = JPAExpressions
@@ -410,10 +465,84 @@ class DefaultHomeFollowingQueryRepository(
return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING)
.and(homeFollowingNewsInbox.rank.isNotNull)
.and(latestVisibleCreatorRankingBatchCondition(nowUtc))
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists))
.or(
homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CONTENT_RANKING)
.and(homeFollowingNewsInbox.rank.isNotNull)
.and(activeAudioExists)
.and(latestContentRankingNewsCondition(memberId, canViewAdultContent, nowUtc))
)
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists))
}
private fun latestVisibleCreatorRankingBatchCondition(nowUtc: LocalDateTime): BooleanExpression {
val latestCreatorRankingSnapshot = QCreatorRankingSnapshot("latestCreatorRankingSnapshot")
val latestCreatorRankingJob = QCreatorRankingSnapshotJob("latestCreatorRankingJob")
val anyCreatorRankingJob = QCreatorRankingSnapshotJob("anyCreatorRankingJob")
val latestJobVisibleFromAt = JPAExpressions
.select(latestCreatorRankingJob.visibleFromAtUtc.max())
.from(latestCreatorRankingJob)
.where(
latestCreatorRankingJob.rankingType.eq(CreatorRankingType.WEEKLY),
latestCreatorRankingJob.status.eq(CreatorRankingSnapshotJobStatus.DONE),
latestCreatorRankingJob.visibleFromAtUtc.loe(nowUtc)
)
val hasRankingJob = JPAExpressions
.selectOne()
.from(anyCreatorRankingJob)
.where(
anyCreatorRankingJob.rankingType.eq(CreatorRankingType.WEEKLY),
anyCreatorRankingJob.status.eq(CreatorRankingSnapshotJobStatus.DONE),
anyCreatorRankingJob.visibleFromAtUtc.loe(nowUtc)
)
.exists()
val latestSnapshotVisibleFromAt = JPAExpressions
.select(latestCreatorRankingSnapshot.visibleFromAtUtc.max())
.from(latestCreatorRankingSnapshot)
.where(
latestCreatorRankingSnapshot.rankingType.eq(CreatorRankingType.WEEKLY),
latestCreatorRankingSnapshot.visibleFromAtUtc.loe(nowUtc)
)
return homeFollowingNewsInbox.visibleFromAtUtc.eq(latestJobVisibleFromAt).or(
hasRankingJob.not().and(homeFollowingNewsInbox.visibleFromAtUtc.eq(latestSnapshotVisibleFromAt))
)
}
private fun latestContentRankingNewsCondition(
memberId: Long,
canViewAdultContent: Boolean,
nowUtc: LocalDateTime
): BooleanExpression {
val newerContentRanking = QHomeFollowingNewsInbox("newerContentRanking")
return JPAExpressions
.selectOne()
.from(newerContentRanking)
.where(
newerContentRanking.memberId.eq(memberId),
newerContentRanking.newsType.eq(FollowingNewsType.CONTENT_RANKING),
newerContentRanking.targetId.eq(homeFollowingNewsInbox.targetId),
newerContentRanking.isActive.isTrue,
newerContentRanking.rank.isNotNull,
adultNewerContentRankingCondition(canViewAdultContent, newerContentRanking),
newerContentRanking.visibleFromAtUtc.loe(nowUtc),
newerContentRanking.visibleFromAtUtc.gt(homeFollowingNewsInbox.visibleFromAtUtc)
.or(
newerContentRanking.visibleFromAtUtc.eq(homeFollowingNewsInbox.visibleFromAtUtc)
.and(newerContentRanking.id.gt(homeFollowingNewsInbox.id))
)
)
.notExists()
}
private fun adultNewerContentRankingCondition(
canViewAdultContent: Boolean,
newerContentRanking: QHomeFollowingNewsInbox
): BooleanExpression? {
return if (canViewAdultContent) null else newerContentRanking.isAdult.isFalse
}
private fun adultAudioNewsTargetCondition(
canViewAdultContent: Boolean,
newsAudioContent: QAudioContent

View File

@@ -52,6 +52,7 @@ interface HomeFollowingNewsInboxJpaRepository : JpaRepository<HomeFollowingNewsI
where cf.creator_id = :creatorId
and cf.is_active = true
order by cf.member_id asc
for update
""",
nativeQuery = true
)

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
@@ -19,17 +21,36 @@ class HomeFollowingQueryService(
fun findHomeFollowing(member: Member): HomeFollowing {
val memberId = requireNotNull(member.id)
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(member)
val effectiveViewerGender = member.effectiveGender()
val isViewerCreator = member.role == MemberRole.CREATOR
val now = nowProvider()
return HomeFollowing(
followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT),
onAirLives = queryPort.findOnAirLives(memberId, canViewAdultContent, ON_AIR_LIVES_LIMIT),
onAirLives = queryPort.findOnAirLives(
memberId,
canViewAdultContent,
effectiveViewerGender,
isViewerCreator,
ON_AIR_LIVES_LIMIT
),
recentChats = emptyList(),
monthlySchedules = queryPort.findMonthlySchedules(memberId, canViewAdultContent, now, MONTHLY_SCHEDULES_LIMIT),
monthlySchedules = queryPort.findMonthlySchedules(
memberId,
canViewAdultContent,
effectiveViewerGender,
isViewerCreator,
now,
MONTHLY_SCHEDULES_LIMIT
),
recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT)
)
}
private fun Member.effectiveGender(): Gender {
return auth?.let { if (it.gender == 1) Gender.MALE else Gender.FEMALE } ?: gender
}
companion object {
private const val FOLLOWING_CREATORS_LIMIT = 20
private const val ON_AIR_LIVES_LIMIT = 10

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.home.following.port.out
import kr.co.vividnext.sodalive.member.Gender
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
@@ -9,11 +10,19 @@ 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 findOnAirLives(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
limit: Int
): List<HomeFollowingLive>
fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule>

View File

@@ -1,12 +1,14 @@
package kr.co.vividnext.sodalive.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.member.login.LoginRequest
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxJpaRepository
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@@ -23,6 +25,7 @@ import javax.persistence.EntityManager
class MemberServiceTest @Autowired constructor(
private val service: MemberService,
private val memberRepository: MemberRepository,
private val creatorFollowingRepository: CreatorFollowingRepository,
private val homeFollowingNewsInboxJpaRepository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager
) {
@@ -94,4 +97,88 @@ class MemberServiceTest @Autowired constructor(
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
}
@Test
@DisplayName("통합 팔로우 API에서 isActive=false이고 기존 관계가 없으면 active 팔로우를 생성하지 않는다")
fun shouldNotCreateActiveFollowingWhenCreatorFollowIsInactiveWithoutExistingRelation() {
val member = memberRepository.save(
Member(
email = "inactive-follower@test.com",
password = "password",
nickname = "inactive-follower"
)
)
val creator = memberRepository.save(
Member(
email = "inactive-creator@test.com",
password = "password",
nickname = "inactive-creator",
role = MemberRole.CREATOR
)
)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = false, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
assertNull(creatorFollowingRepository.findByCreatorIdAndMemberId(creatorId = creator.id!!, memberId = member.id!!))
}
@Test
@DisplayName("통합 팔로우 API에서 isActive=false이면 기존 inbox를 비활성화하고 재팔로우해도 복구하지 않는다")
fun shouldDeactivateFollowingNewsInboxWhenCreatorFollowIsInactiveAndKeepInactiveAfterRefollow() {
val member = memberRepository.save(
Member(
email = "integrated-follower@test.com",
password = "password",
nickname = "integrated-follower"
)
)
val creator = memberRepository.save(
Member(
email = "integrated-creator@test.com",
password = "password",
nickname = "integrated-creator",
role = MemberRole.CREATOR
)
)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!)
val inbox = homeFollowingNewsInboxJpaRepository.save(
HomeFollowingNewsInbox(
memberId = member.id!!,
creatorId = creator.id!!,
newsType = FollowingNewsType.COMMUNITY_POST,
sourceKey = "COMMUNITY_POST:2",
targetId = 2L,
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0),
creatorNickname = "integrated-creator",
creatorProfileImagePath = null,
title = "title",
body = "body",
thumbnailImagePath = null,
rank = null,
isAdult = false
)
)
entityManager.flush()
entityManager.clear()
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = false, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
val inactiveFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(
creatorId = creator.id!!,
memberId = member.id!!
)
assertEquals(false, inactiveFollowing?.isActive)
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
}
}

View File

@@ -14,6 +14,8 @@ import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant
@@ -167,7 +169,9 @@ class HomeFollowingEndToEndTest @Autowired constructor(
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = paidPost.id!!
)
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", now.minusHours(1), rank = 7)
val rankedVisibleFromAtUtc = now.minusHours(1)
saveCreatorRankingSnapshot(creator, rankNo = 7, visibleFromAtUtc = rankedVisibleFromAtUtc)
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", rankedVisibleFromAtUtc, rank = 7)
val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
entityManager.flush()
entityManager.clear()
@@ -275,6 +279,28 @@ class HomeFollowingEndToEndTest @Autowired constructor(
return news
}
private fun saveCreatorRankingSnapshot(
creator: Member,
rankNo: Int,
visibleFromAtUtc: LocalDateTime
): CreatorRankingSnapshot {
val snapshot = CreatorRankingSnapshot(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1),
aggregationEndAtUtc = visibleFromAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = creator.id!!,
nickname = creator.nickname,
profileImageUrl = creator.profileImage,
rankNo = rankNo,
finalScore = 100.0 - rankNo,
scorePolicyVersion = "v1",
scoreDetailJson = "{}"
)
entityManager.persist(snapshot)
return snapshot
}
private fun saveCommunityPost(creator: Member, content: String, price: Int, imagePath: String): CreatorCommunity {
val post = CreatorCommunity(
content = content,

View File

@@ -7,13 +7,20 @@ import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
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.ranking.adapter.out.persistence.CreatorRankingSnapshot
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshotJob
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
@@ -98,12 +105,50 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
saveLiveRoom(nonCreator, LocalDateTime.of(2026, 6, 25, 15, 0), channelName = "non-creator")
flushAndClear()
val lives = repository.findOnAirLives(memberId = viewer.id!!, canViewAdultContent = false, limit = 10)
val lives = repository.findOnAirLives(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
limit = 10
)
assertEquals(listOf(latest.id!!, older.id!!), lives.map { it.liveId })
assertEquals("https://cdn.test/live-profile.png", lives.first().creatorProfileImageUrl)
}
@Test
@DisplayName("On Air는 성별 제한과 크리에이터 입장 제한 정책을 반영한다")
fun shouldFindFollowingOnAirLivesWithViewerGenderAndCreatorJoinPolicy() {
val viewer = saveMember("live-policy-viewer", MemberRole.CREATOR)
val creator = saveMember("live-policy-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
saveLiveRoom(
creator,
LocalDateTime.of(2026, 6, 25, 10, 0),
channelName = "male-only",
genderRestriction = GenderRestriction.MALE_ONLY
)
saveLiveRoom(
creator,
LocalDateTime.of(2026, 6, 25, 11, 0),
channelName = "creator-hidden",
isAvailableJoinCreator = false
)
val visible = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 12, 0), channelName = "visible")
flushAndClear()
val lives = repository.findOnAirLives(
memberId = viewer.id!!,
canViewAdultContent = true,
effectiveViewerGender = Gender.FEMALE,
isViewerCreator = true,
limit = 10
)
assertEquals(listOf(visible.id!!), lives.map { it.liveId })
}
@Test
@DisplayName("이달의 스케줄은 KST 오늘 00시부터 다음 달 00시 전까지 라이브와 오디오를 가까운 순으로 조회한다")
fun shouldFindMonthlySchedulesInKstWindow() {
@@ -127,6 +172,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
now = LocalDateTime.of(2026, 6, 25, 12, 0),
limit = 3
)
@@ -160,6 +207,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 3
)
@@ -181,6 +230,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
now = LocalDateTime.of(2026, 6, 25, 14, 30),
limit = 3
)
@@ -205,6 +256,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 10
)
@@ -220,6 +273,41 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
)
}
@Test
@DisplayName("라이브 스케줄은 성별 제한과 크리에이터 입장 제한 정책을 반영한다")
fun shouldFindLiveMonthlySchedulesWithViewerGenderAndCreatorJoinPolicy() {
val viewer = saveMember("schedule-policy-viewer", MemberRole.CREATOR)
val creator = saveMember("schedule-policy-creator", MemberRole.CREATOR)
val theme = saveTheme("schedule-policy-theme")
saveFollowing(viewer, creator)
saveLiveRoom(
creator,
LocalDateTime.of(2026, 6, 25, 1, 0),
channelName = null,
genderRestriction = GenderRestriction.MALE_ONLY
)
saveLiveRoom(
creator,
LocalDateTime.of(2026, 6, 25, 2, 0),
channelName = null,
isAvailableJoinCreator = false
)
val visibleLive = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 3, 0), channelName = null)
val audio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 4, 0))
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = true,
effectiveViewerGender = Gender.FEMALE,
isViewerCreator = true,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 10
)
assertEquals(listOf("LIVE:${visibleLive.id!!}", "AUDIO:${audio.id!!}"), schedules.map { it.scheduleId })
}
@Test
@DisplayName("최근 소식은 노출 가능한 랭킹 inbox 중 rank가 있는 row만 최신순으로 조회한다")
fun shouldFindRecentNewsWithRankedCreatorRankingPayloadOnly() {
@@ -230,6 +318,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
saveFollowing(viewer, creator)
saveFollowing(viewer, blockedCreator)
saveFollowing(viewer, nonCreator)
saveCreatorRankingSnapshot(creator, rankNo = 3, visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0))
saveNews(viewer.id!!, creator.id!!, "old-without-rank", LocalDateTime.of(2026, 6, 25, 8, 0), rank = null)
val latestVisible = saveNews(viewer.id!!, creator.id!!, "latest", LocalDateTime.of(2026, 6, 25, 9, 0), rank = 3)
saveNews(viewer.id!!, creator.id!!, "future", LocalDateTime.of(2026, 6, 25, 10, 0), rank = 1)
@@ -250,12 +339,236 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(3), news.map { it.creatorRanking?.rank })
}
@Test
@DisplayName("최근 소식의 크리에이터 랭킹은 최신 공개 배치만 조회한다")
fun shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews() {
val viewer = saveMember("news-ranking-viewer", MemberRole.USER)
val firstCreator = saveMember("news-ranking-first", MemberRole.CREATOR)
val secondCreator = saveMember("news-ranking-second", MemberRole.CREATOR)
saveFollowing(viewer, firstCreator)
saveFollowing(viewer, secondCreator)
saveCreatorRankingSnapshot(firstCreator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0))
saveCreatorRankingSnapshot(secondCreator, rankNo = 2, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0))
saveCreatorRankingSnapshot(firstCreator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 15, 0, 0))
val oldFirstNews = saveNews(
viewer.id!!,
firstCreator.id!!,
"old-ranking-first",
LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 1
)
val oldSecondNews = saveNews(
viewer.id!!,
secondCreator.id!!,
"old-ranking-second",
LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 2
)
val latestFirstNews = saveNews(
viewer.id!!,
firstCreator.id!!,
"latest-ranking-first",
LocalDateTime.of(2026, 6, 15, 0, 0),
rank = 1
)
flushAndClear()
val beforeLatestVisible = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 14, 23, 59),
limit = 30
)
val afterLatestVisible = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 15, 0, 0),
limit = 30
)
assertEquals(listOf(oldSecondNews.id!!.toString(), oldFirstNews.id!!.toString()), beforeLatestVisible.map { it.newsId })
assertEquals(listOf(latestFirstNews.id!!.toString()), afterLatestVisible.map { it.newsId })
}
@Test
@DisplayName("최근 소식의 크리에이터 랭킹은 최신 완료 배치가 비어 있어도 과거 배치로 보충하지 않는다")
fun shouldExcludeCreatorRankingNewsWhenLatestDoneBatchIsEmpty() {
val viewer = saveMember("news-empty-ranking-viewer", MemberRole.USER)
val creator = saveMember("news-empty-ranking-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
saveCreatorRankingSnapshot(creator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0))
saveCreatorRankingSnapshotJob(LocalDateTime.of(2026, 6, 8, 0, 0))
saveCreatorRankingSnapshotJob(LocalDateTime.of(2026, 6, 15, 0, 0))
saveNews(
viewer.id!!,
creator.id!!,
"old-ranking-empty-latest",
LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 1
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 15, 0, 0),
limit = 30
)
assertTrue(news.none { it.type == FollowingNewsType.CREATOR_RANKING })
}
@Test
@DisplayName("최근 소식의 콘텐츠 랭킹은 콘텐츠별 최신 row만 limit 전에 남긴다")
fun shouldFindLatestContentRankingNewsPerContentBeforeLimit() {
val viewer = saveMember("news-content-ranking-viewer", MemberRole.USER)
val creator = saveMember("news-content-ranking-creator", MemberRole.CREATOR)
val theme = saveTheme("news-content-ranking-theme")
saveFollowing(viewer, creator)
val firstContent = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0)).apply {
title = "first ranked content"
coverImage = "content-ranking/first.png"
}
val secondContent = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 10)).apply {
title = "second ranked content"
coverImage = "content-ranking/second.png"
}
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-first-older",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
rank = 3,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = firstContent.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-first-same-time-older-id",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
rank = 2,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = firstContent.id!!
)
val latestFirst = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-first-same-time-latest-id",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
rank = 1,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = firstContent.id!!
)
val latestSecond = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-second",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 8, 59),
rank = 4,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = secondContent.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
limit = 2
)
assertEquals(listOf(latestFirst.id!!.toString(), latestSecond.id!!.toString()), news.map { it.newsId })
assertEquals(listOf(firstContent.id!!, secondContent.id!!), news.map { it.contentRanking?.contentId })
assertEquals(listOf(1, 4), news.map { it.contentRanking?.rank })
assertTrue(news.all { it.creatorRanking == null && it.audioContent == null && it.communityPost == null })
}
@Test
@DisplayName("최근 소식의 콘텐츠 랭킹은 rank가 없는 최신 row가 이전 노출 가능 row를 가리지 않는다")
fun shouldKeepOlderContentRankingNewsWhenNewerRowHasNoRank() {
val viewer = saveMember("news-content-null-rank-viewer", MemberRole.USER)
val creator = saveMember("news-content-null-rank-creator", MemberRole.CREATOR)
val theme = saveTheme("news-content-null-rank-theme")
saveFollowing(viewer, creator)
val content = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0))
val visible = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-visible-before-null-rank",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
rank = 2,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = content.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-null-rank-newer",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
rank = null,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = content.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 11, 0),
limit = 30
)
assertEquals(listOf(visible.id!!.toString()), news.map { it.newsId })
}
@Test
@DisplayName("최근 소식의 콘텐츠 랭킹은 성인 최신 row가 비성인 회원의 이전 노출 가능 row를 가리지 않는다")
fun shouldKeepOlderContentRankingNewsWhenNewerRowIsAdultForNonAdultViewer() {
val viewer = saveMember("news-content-adult-row-viewer", MemberRole.USER)
val creator = saveMember("news-content-adult-row-creator", MemberRole.CREATOR)
val theme = saveTheme("news-content-adult-row-theme")
saveFollowing(viewer, creator)
val content = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0), isAdult = false)
val visible = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-visible-before-adult-row",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
rank = 2,
isAdult = false,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = content.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-adult-newer",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
rank = 1,
isAdult = true,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = content.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = false,
nowUtc = LocalDateTime.of(2026, 6, 25, 11, 0),
limit = 30
)
assertEquals(listOf(visible.id!!.toString()), news.map { it.newsId })
}
@Test
@DisplayName("최근 소식은 UTC now 이후 visibleFromAtUtc row를 조기 노출하지 않는다")
fun shouldNotExposeNewsVisibleAfterUtcNow() {
val viewer = saveMember("news-utc-viewer", MemberRole.USER)
val creator = saveMember("news-utc-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
saveCreatorRankingSnapshot(creator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 14, 30))
val visibleNow = saveNews(viewer.id!!, creator.id!!, "visible-now", LocalDateTime.of(2026, 6, 25, 14, 30), rank = 1)
saveNews(viewer.id!!, creator.id!!, "future-utc", LocalDateTime.of(2026, 6, 25, 14, 31), rank = 2)
flushAndClear()
@@ -555,14 +868,18 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
creator: Member,
beginDateTime: LocalDateTime,
channelName: String?,
isAdult: Boolean = false
isAdult: Boolean = false,
genderRestriction: GenderRestriction = GenderRestriction.ALL,
isAvailableJoinCreator: Boolean = true
): LiveRoom {
val liveRoom = LiveRoom(
title = "live-${creator.nickname}-$beginDateTime",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
isAdult = isAdult
isAdult = isAdult,
isAvailableJoinCreator = isAvailableJoinCreator,
genderRestriction = genderRestriction
).apply {
member = creator
this.channelName = channelName
@@ -667,6 +984,43 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
return news
}
private fun saveCreatorRankingSnapshot(
creator: Member,
rankNo: Int,
visibleFromAtUtc: LocalDateTime,
rankingType: CreatorRankingType = CreatorRankingType.WEEKLY
): CreatorRankingSnapshot {
val snapshot = CreatorRankingSnapshot(
rankingType = rankingType,
aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1),
aggregationEndAtUtc = visibleFromAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = creator.id!!,
nickname = creator.nickname,
profileImageUrl = creator.profileImage,
rankNo = rankNo,
finalScore = 100.0 - rankNo,
scorePolicyVersion = "v1",
scoreDetailJson = "{}"
)
entityManager.persist(snapshot)
return snapshot
}
private fun saveCreatorRankingSnapshotJob(visibleFromAtUtc: LocalDateTime): CreatorRankingSnapshotJob {
val job = CreatorRankingSnapshotJob(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1),
aggregationEndAtUtc = visibleFromAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
trigger = CreatorRankingSnapshotJobTrigger.SCHEDULED,
status = CreatorRankingSnapshotJobStatus.DONE,
processedAt = visibleFromAtUtc.minusHours(1)
)
entityManager.persist(job)
return job
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
@@ -17,7 +18,15 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.transaction.TestTransaction
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import javax.persistence.EntityManager
@DataJpaTest(
@@ -29,9 +38,13 @@ import javax.persistence.EntityManager
@Import(QueryDslConfig::class)
class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
private val repository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager
private val creatorFollowingRepository: CreatorFollowingRepository,
private val entityManager: EntityManager,
transactionManager: PlatformTransactionManager
) {
private val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager)
private val transactionalAdapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager, transactionManager)
private val transactionTemplate = TransactionTemplate(transactionManager)
@Test
@DisplayName("insertIgnoreAll은 memberId newsType sourceKey 중복을 예외 없이 무시하고 신규 row만 저장한다")
@@ -115,6 +128,75 @@ class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
assertEquals(listOf(activeFollowerWithoutInbox.id!!), followerIds)
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
@DisplayName("publish가 follower 조회 후 insert 전 멈춘 사이 언팔로우가 완료돼도 재팔로우 후 과거 소식이 활성화되지 않는다")
fun shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed() {
val (memberId, creatorId) = transactionTemplate.execute {
val creator = saveMember("concurrent-creator", MemberRole.CREATOR)
val follower = saveMember("concurrent-follower", MemberRole.USER)
saveFollowing(follower, creator, isActive = true)
entityManager.flush()
follower.id!! to creator.id!!
}!!
val followerRead = CountDownLatch(1)
val allowPublishInsert = CountDownLatch(1)
val executor = Executors.newFixedThreadPool(2)
try {
val publish = executor.submit<Int> {
transactionTemplate.execute {
val followerIds = transactionalAdapter.findActiveFollowerIds(creatorId)
followerRead.countDown()
assertTrue(allowPublishInsert.await(5, TimeUnit.SECONDS))
transactionalAdapter.insertIgnoreAll(
followerIds.map { followerId ->
record(memberId = followerId, creatorId = creatorId, sourceKey = "COMMUNITY_POST:delayed")
}
)
}!!
}
assertTrue(followerRead.await(5, TimeUnit.SECONDS))
val unfollow = executor.submit {
transactionTemplate.executeWithoutResult {
val following = creatorFollowingRepository.findByCreatorIdAndMemberId(
creatorId = creatorId,
memberId = memberId
)!!
following.isActive = false
transactionalAdapter.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId)
}
}
Thread.sleep(200)
val unfollowCompletedBeforeInsert = try {
unfollow.get(1, TimeUnit.SECONDS)
true
} catch (_: TimeoutException) {
false
}
allowPublishInsert.countDown()
assertEquals(1, publish.get(5, TimeUnit.SECONDS))
if (!unfollowCompletedBeforeInsert) {
unfollow.get(5, TimeUnit.SECONDS)
}
transactionTemplate.executeWithoutResult {
creatorFollowingRepository.findByCreatorIdAndMemberId(
creatorId = creatorId,
memberId = memberId
)!!.isActive = true
}
val activeRows = transactionTemplate.execute {
repository.findAll().filter { it.memberId == memberId && it.creatorId == creatorId && it.isActive }
}!!
assertEquals(emptyList<HomeFollowingNewsInbox>(), activeRows)
} finally {
executor.shutdownNow()
}
}
private fun saveMember(seed: String, role: MemberRole): Member {
val member = Member(
email = "$seed@test.com",

View File

@@ -1,6 +1,9 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
@@ -58,6 +61,24 @@ class HomeFollowingQueryServiceTest {
assertEquals(11L, queryPort.memberId)
}
@Test
@DisplayName("본인인증 성별 우선 effective gender와 크리에이터 여부는 라이브 조회 port에 전달된다")
fun shouldPassEffectiveGenderAndCreatorRoleToLiveQueryPort() {
val member = member(13L).apply {
role = MemberRole.CREATOR
gender = Gender.MALE
auth = Auth(name = "auth", birth = "20000101", uniqueCi = "ci", di = "di", gender = 0)
}
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true)
service.findHomeFollowing(member)
assertEquals(Gender.FEMALE, queryPort.onAirEffectiveViewerGender)
assertEquals(Gender.FEMALE, queryPort.monthlySchedulesEffectiveViewerGender)
assertTrue(queryPort.onAirIsViewerCreator!!)
assertTrue(queryPort.monthlySchedulesIsViewerCreator!!)
}
@Test
@DisplayName("기본 now는 JVM 기본 timezone과 무관하게 UTC 기준으로 port에 전달된다")
fun shouldUseUtcNowRegardlessOfJvmDefaultTimezone() {
@@ -91,9 +112,13 @@ class HomeFollowingQueryServiceTest {
var followingCreatorsLimit: Int? = null
var onAirLivesLimit: Int? = null
var onAirCanViewAdultContent: Boolean? = null
var onAirEffectiveViewerGender: Gender? = null
var onAirIsViewerCreator: Boolean? = null
var monthlySchedulesLimit: Int? = null
var monthlySchedulesNow: LocalDateTime? = null
var monthlySchedulesCanViewAdultContent: Boolean? = null
var monthlySchedulesEffectiveViewerGender: Gender? = null
var monthlySchedulesIsViewerCreator: Boolean? = null
var recentNewsLimit: Int? = null
var recentNewsNow: LocalDateTime? = null
var recentNewsCanViewAdultContent: Boolean? = null
@@ -104,8 +129,16 @@ class HomeFollowingQueryServiceTest {
return listOf(HomeFollowingCreator(1L, "creator", "profile"))
}
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
override fun findOnAirLives(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
limit: Int
): List<HomeFollowingLive> {
onAirCanViewAdultContent = canViewAdultContent
onAirEffectiveViewerGender = effectiveViewerGender
onAirIsViewerCreator = isViewerCreator
onAirLivesLimit = limit
return emptyList()
}
@@ -113,10 +146,14 @@ class HomeFollowingQueryServiceTest {
override fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule> {
monthlySchedulesCanViewAdultContent = canViewAdultContent
monthlySchedulesEffectiveViewerGender = effectiveViewerGender
monthlySchedulesIsViewerCreator = isViewerCreator
monthlySchedulesNow = now
monthlySchedulesLimit = limit
return emptyList()