From cd3fc00809773b5b43f116d4b4798f34211ef2be Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 12 Jul 2026 21:16:34 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix(coupon):=20=EC=BA=94=20=EC=BF=A0?= =?UTF-8?q?=ED=8F=B0=20=EC=82=AC=EC=9A=A9=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=9D=84=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/can/coupon/CanCouponService.kt | 1 + .../coupon/CanCouponServiceIntegrationTest.kt | 111 ++++++++++++++++++ .../can/coupon/CanCouponServiceTest.kt | 15 +++ 3 files changed, 127 insertions(+) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceIntegrationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponService.kt index b457778d..496d8387 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponService.kt @@ -131,6 +131,7 @@ class CanCouponService( } } + @Transactional fun useCanCoupon(couponNumber: String, memberId: Long): String { val member = memberRepository.findByIdOrNull(id = memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceIntegrationTest.kt new file mode 100644 index 00000000..814afbb9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceIntegrationTest.kt @@ -0,0 +1,111 @@ +package kr.co.vividnext.sodalive.can.coupon + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.auth.Auth +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:can-coupon-service-integration;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class CanCouponServiceIntegrationTest @Autowired constructor( + private val service: CanCouponService, + private val memberRepository: MemberRepository, + private val couponNumberRepository: CanCouponNumberRepository, + private val transactionTemplate: TransactionTemplate, + private val entityManager: EntityManager +) { + @Test + @DisplayName("쿠폰 사용은 OSIV off 환경의 실제 서비스 호출 경로에서 lazy auth 접근까지 완료된다") + fun shouldUseCouponThroughTransactionalServiceProxyWhenOpenInViewIsDisabled() { + val fixture = createAuthenticatedMemberAndCouponFixture() + + service.useCanCoupon(couponNumber = fixture.couponNumber, memberId = fixture.memberId) + + val result = transactionTemplate.execute { + val member = memberRepository.findById(fixture.memberId).orElseThrow() + val couponNumber = couponNumberRepository.findByCouponNumber(fixture.couponNumber)!! + + CouponUseResult( + rewardCan = member.pgRewardCan, + usedMemberId = couponNumber.member!!.id!! + ) + }!! + + assertEquals(300, result.rewardCan) + assertEquals(fixture.memberId, result.usedMemberId) + } + + private fun createAuthenticatedMemberAndCouponFixture(): Fixture { + return transactionTemplate.execute { + val member = Member( + email = "can-coupon-osiv@test.com", + password = "password", + nickname = "can-coupon-osiv" + ) + entityManager.persist(member) + + val auth = Auth( + name = "테스트", + birth = "19900101", + uniqueCi = "can-coupon-osiv-ci", + di = "can-coupon-osiv-di", + gender = 1 + ) + auth.member = member + entityManager.persist(auth) + + val coupon = CanCoupon( + couponName = "OSIV 회귀 테스트 쿠폰", + couponType = CouponType.CAN, + can = 300, + couponCount = 1, + validity = LocalDateTime.now().plusDays(1), + isActive = true, + isMultipleUse = false + ) + entityManager.persist(coupon) + + val couponNumber = CanCouponNumber("OSIVCOUPON1234") + couponNumber.canCoupon = coupon + entityManager.persist(couponNumber) + + entityManager.flush() + val fixture = Fixture( + memberId = member.id!!, + couponNumber = couponNumber.couponNumber + ) + entityManager.clear() + fixture + }!! + } + + private data class Fixture( + val memberId: Long, + val couponNumber: String + ) + + private data class CouponUseResult( + val rewardCan: Int, + val usedMemberId: Long + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceTest.kt index 651ec3fb..392a7aeb 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceTest.kt @@ -19,6 +19,7 @@ import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.springframework.context.ApplicationEventPublisher +import org.springframework.transaction.annotation.Transactional import java.util.Optional class CanCouponServiceTest { @@ -110,6 +111,20 @@ class CanCouponServiceTest { verify(chargeService, never()).chargeByCoupon("COUPON5678", member) } + @Test + @DisplayName("쿠폰 사용은 OSIV off 환경에서도 lazy 연관을 읽을 수 있도록 트랜잭션 안에서 실행된다") + fun shouldRunUseCanCouponInTransaction() { + val method = CanCouponService::class.java.getDeclaredMethod( + "useCanCoupon", + String::class.java, + java.lang.Long.TYPE + ) + + val transactional = method.getAnnotation(Transactional::class.java) + + assertEquals(false, transactional?.readOnly) + } + private fun createMember(memberId: Long): Member { return Member( email = "member$memberId@test.com", From 729e3e8c45cbb61c6a9847be02a17c08c9f46db6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 12 Jul 2026 21:16:58 +0900 Subject: [PATCH 2/7] =?UTF-8?q?docs(coupon):=20=EC=BA=94=20=EC=BF=A0?= =?UTF-8?q?=ED=8F=B0=20OSIV=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B3=84=ED=9A=8D=EC=9D=84=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 30 +++++++++++++++++++ .../20260712_캔쿠폰_OSIV회귀테스트보강/prd.md | 24 +++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 docs/20260712_캔쿠폰_OSIV회귀테스트보강/plan-task.md create mode 100644 docs/20260712_캔쿠폰_OSIV회귀테스트보강/prd.md diff --git a/docs/20260712_캔쿠폰_OSIV회귀테스트보강/plan-task.md b/docs/20260712_캔쿠폰_OSIV회귀테스트보강/plan-task.md new file mode 100644 index 00000000..518cd3ba --- /dev/null +++ b/docs/20260712_캔쿠폰_OSIV회귀테스트보강/plan-task.md @@ -0,0 +1,30 @@ +# 캔 쿠폰 OSIV 회귀 테스트 보강 Plan/TASK + +## Goal +`CanCouponService.useCanCoupon()`가 OSIV off 환경에서 Spring 트랜잭션 프록시를 통해 호출될 때 `Member.auth` lazy 연관 접근을 포함한 쿠폰 사용 흐름이 예외 없이 완료됨을 검증한다. + +### Phase 1: 실제 호출 경로 통합 테스트 추가 + +- [x] **Task 1.1: `CanCouponServiceIntegrationTest` 추가** + - 파일: `src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceIntegrationTest.kt` + - RED: 트랜잭션 없는 테스트 메서드에서 인증 회원, lazy `Member.auth`, CAN 쿠폰 fixture를 만든 뒤 Spring 빈 `CanCouponService.useCanCoupon()`를 호출하는 테스트를 작성한다. + - GREEN: 기존 `CanCouponService.useCanCoupon()`의 `@Transactional` 경계로 테스트가 통과하는지 확인한다. + - REFACTOR: 테스트 fixture를 최소화하고, 쿠폰 사용 후 저장 상태를 확인한다. + - 검증 기준: + - `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.can.coupon.CanCouponServiceIntegrationTest` + - `./gradlew --no-daemon ktlintCheck` + - 검증 기록: + - 무엇: `CanCouponServiceIntegrationTest`에서 인증 회원, lazy `Member.auth`, CAN 쿠폰 fixture를 DB에 저장한 뒤 트랜잭션 없는 테스트 메서드에서 Spring 빈 `CanCouponService.useCanCoupon()`를 호출했다. + - 왜: 단순 애노테이션 검사가 아니라 OSIV off 환경의 실제 서비스 호출 경로에서 lazy 초기화 예외가 재발하지 않는지 확인하기 위해서다. + - 어떻게: 첫 실행 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.can.coupon.CanCouponServiceIntegrationTest`는 컨텍스트 부팅 중 Redis 연결 실패로 실패했다. 테스트에 기존 관례인 `EmbeddedRedisInitializer` opt-in과 `@DirtiesContext`를 추가했다. + - 결과: 재실행 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.can.coupon.CanCouponServiceIntegrationTest`가 `BUILD SUCCESSFUL in 2m 1s`로 통과했다. + +## 검증 기록 +- Run: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.can.coupon.CanCouponServiceTest --tests kr.co.vividnext.sodalive.can.coupon.CanCouponServiceIntegrationTest` +- Result: `BUILD SUCCESSFUL in 2m 24s` +- Run: `./gradlew --no-daemon ktlintCheck` +- Result: `BUILD SUCCESSFUL in 1m 9s` +- Run: `git diff --check` +- Result: 공백 오류 없이 통과 +- Run: `./gradlew tasks --all` +- Result: sandbox 환경에서는 `~/.gradle` wrapper lock 파일 접근 제한으로 실패했고, 승인 실행 후 `BUILD SUCCESSFUL in 11s`로 통과했다. diff --git a/docs/20260712_캔쿠폰_OSIV회귀테스트보강/prd.md b/docs/20260712_캔쿠폰_OSIV회귀테스트보강/prd.md new file mode 100644 index 00000000..4fa0cfcd --- /dev/null +++ b/docs/20260712_캔쿠폰_OSIV회귀테스트보강/prd.md @@ -0,0 +1,24 @@ +# PRD: 캔 쿠폰 OSIV 회귀 테스트 보강 + +## 1. Overview +`CanCouponService.useCanCoupon()`가 `spring.jpa.open-in-view=false` 환경에서도 실제 Spring 호출 경로에서 lazy 초기화 예외 없이 동작하는지 검증하는 통합 테스트를 추가한다. + +## 2. Problem +- 기존 단위 테스트는 `@Transactional` 애노테이션 존재만 확인한다. +- 실제 DB 엔티티, Spring 트랜잭션 프록시, lazy 연관 접근이 함께 동작하는지는 검증하지 못한다. +- `Member.auth` lazy 연관 접근이 다시 트랜잭션 밖에서 실행되면 `LazyInitializationException` 회귀가 발생할 수 있다. + +## 3. Goals +- 트랜잭션 없는 테스트 메서드에서 Spring 빈 `CanCouponService.useCanCoupon()`를 호출한다. +- 인증된 회원의 `member.auth` lazy 연관을 포함한 실제 쿠폰 사용 경로가 예외 없이 완료됨을 검증한다. +- 쿠폰 사용 후 회원 보상 캔과 쿠폰 사용 회원이 저장됐는지 확인한다. + +## 4. Non-Goals +- 공개 API 스키마 변경은 하지 않는다. +- 쿠폰 사용 정책이나 충전 정책 로직은 변경하지 않는다. +- 전체 OSIV 회귀 테스트를 재구성하지 않는다. + +## 5. Technical Constraints +- 기존 Kotlin/Spring Boot 테스트 스타일을 따른다. +- `spring.jpa.open-in-view=false` 테스트 설정을 유지한다. +- 검증 범위는 캔 쿠폰 사용 실제 호출 경로로 제한한다. From 90b1ff22fbb3eaba32a4d58d143fca34952c0342 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 12 Jul 2026 22:42:36 +0900 Subject: [PATCH 3/7] =?UTF-8?q?docs(content):=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EC=8A=A4=EB=83=85=EC=83=B7=20=ED=8F=B4=EB=B0=B1=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EC=9D=84=20=EB=AC=B8=EC=84=9C=ED=99=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 230 ++++++++++++++++++ .../prd.md | 168 +++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/plan-task.md create mode 100644 docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/prd.md diff --git a/docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/plan-task.md b/docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/plan-task.md new file mode 100644 index 00000000..19f7ca4e --- /dev/null +++ b/docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/plan-task.md @@ -0,0 +1,230 @@ +# 메인 콘텐츠 추천 오디오 스냅샷 폴백 Plan/Task + +## 시나리오 계약 +- Happy path: `GET /api/v2/audio/recommendations`가 사용자 visibility에 맞는 `NEW_AND_HOT`, `MOST_COMMENTED`, `RECOMMENDED_AUDIO` 스냅샷을 각각 조회하고, 비어 있는 섹션만 fallback refresh 후 재조회한다. Real surface: `AudioRecommendationQueryServiceTest`, `AudioRecommendationEndToEndTest`. +- Independent fallback: `NEW_AND_HOT` 스냅샷이 존재해도 `MOST_COMMENTED` 또는 `RECOMMENDED_AUDIO`가 비어 있으면 해당 섹션 fallback을 독립적으로 실행한다. Real surface: `AudioRecommendationQueryServiceTest`, 신규 fallback service test. +- Visibility: 비회원/19금 노출 불가 회원은 `*_SAFE`, 19금 노출 가능 회원은 `*_ALL` section type으로 fallback을 수행한다. Real surface: `AudioRecommendationQueryServiceTest`. +- Lock and wait: fallback은 `lock:audio-recommendation-snapshot-refresh:{SECTION_TYPE}` lock, 300ms lock wait, 1,500ms API wait, JVM single-flight, lock 안 double-check를 사용한다. Real surface: 신규 fallback service test. +- Empty marker: 오디오 snapshot-backed 6개 section type은 refresh 결과 0건이면 `targetId = 0` marker를 저장하고, snapshot 조회 응답에서는 marker를 제외한다. Real surface: `RecommendationSnapshotPersistenceAdapterTest`. +- Refresh reuse: fallback refresh는 `AudioRecommendationSnapshotRefreshService`의 스케줄러와 동일한 snapshot 생성 로직을 사용한다. Real surface: `AudioRecommendationSnapshotRefreshServiceTest`, 신규 fallback service test. +- Adjacent regression: `GET /api/v2/audio/recommendations` URL, response field, 오디오 점수 산식, 배너/오리지널/최신/무료/포인트 섹션 조회 정책은 변경하지 않는다. Real surface: `AudioRecommendationFacadeTest`, `AudioRecommendationEndToEndTest`. + +## 범위와 전제 +- 이번 문서는 `docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/prd.md`의 구현 계획이다. +- 신규 공개 API, 신규 응답 필드, 운영 DDL 추가는 범위에 포함하지 않는다. +- 기존 `recommendation_snapshot` 테이블과 오디오 관련 `RecommendedSectionType` 6개를 재사용한다. +- `AudioRecommendationQueryService.getRecommendations`의 snapshot-backed 3개 섹션만 이번 fallback 보강의 대상이다. +- `findNewAndHotAudios` 전체보기 fallback은 기존 lazy refresh 동작을 유지하되, 공통 helper 변경 시 회귀 테스트로 보호한다. +- 오디오 스냅샷은 기존 `findLatestSnapshots(...)` 정책을 유지한다. 홈 추천처럼 대상일 `snapshotAt` exact 조회로 바꾸는 것은 이번 범위에 포함하지 않는다. +- 기존 `AudioRecommendationSnapshotRefreshService`는 일괄 refresh만 제공하므로, 단일 섹션 refresh 추가와 일괄 refresh 재사용 중 더 작은 변경을 구현 단계에서 선택한다. +- 우선 권장안은 오디오 전용 fallback service를 두고, 홈 추천 fallback service의 lock/timeout/double-check/single-flight 패턴을 복제보다 작은 형태로 재사용 가능한 helper로 분리할지 검토하는 것이다. + +## 기존 오디오 추천 로직 유지/변경 경계 +- 유지: `GET /api/v2/audio/recommendations` endpoint와 응답 JSON 필드. +- 유지: `NEW_AND_HOT`, `MOST_COMMENTED`, `RECOMMENDED_AUDIO` 점수 산식과 limit. +- 유지: `SAFE`/`ALL` visibility variant 선택 기준. +- 유지: `AudioRecommendationSnapshotScheduler`의 매일 00:00 KST refresh. +- 유지: `banners`, `originalSeries`, `latestAudios`, `freeAudios`, `pointAudios` 조회 경로. +- 변경: `MOST_COMMENTED`와 `RECOMMENDED_AUDIO`만 비어 있어도 fallback refresh를 시도한다. +- 변경: `NEW_AND_HOT` fallback도 다른 두 섹션과 같은 공통 fallback 경로를 사용하도록 정리한다. +- 추가: 오디오 snapshot-backed 6개 section type에 empty marker를 적용한다. +- 추가: 오디오 fallback refresh lock/timeout/single-flight 로그를 남긴다. + +## 실행 명령 +- 문서 명령 확인: `./gradlew tasks --all` +- 오디오 조회 service 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` +- 오디오 refresh service 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` +- snapshot marker 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest` +- 오디오 API 회귀 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest` +- 포맷 검증: `./gradlew ktlintCheck` +- 전체 회귀: `./gradlew test` + +--- + +### Phase 1: 문서와 현재 동작 고정 + +- [x] **Task 1.1: PRD와 구현 계획 문서 작성** + - 파일 경로: + - Create: `docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/prd.md` + - Create: `docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/plan-task.md` + - RED: 문서 작업은 TDD 예외. TDD 예외 사유: 코드 동작 변경 전 요구사항과 구현 순서를 고정하는 작업이다. + - GREEN: 오디오 snapshot-backed 3개 섹션의 독립 fallback, lock, timeout, empty marker, refresh 재사용 요구사항을 문서화한다. + - REFACTOR: 기존 메인 콘텐츠 추천 탭 PRD와 홈 추천 snapshot fallback 문서의 정책 차이를 반영해 범위/비범위를 정리한다. + - 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다. + +- [x] **Task 1.2: 현재 오디오 snapshot fallback 부재를 회귀 테스트로 고정** + - 파일 경로: + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt` + - RED: `NEW_AND_HOT_AUDIO_SAFE`는 기존 스냅샷이 있지만 `MOST_COMMENTED_AUDIO_SAFE`만 비어 있을 때, `MOST_COMMENTED` fallback port가 호출되어야 한다는 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` + - GREEN: 아직 구현하지 않는다. 이 task는 구현 단계에서 실패를 확인한 뒤 Phase 3에서 통과시킨다. + - REFACTOR: `RECOMMENDED_AUDIO_SAFE`만 비어 있는 케이스도 별도 테스트로 추가해 두 섹션이 `NEW_AND_HOT` 존재 여부에 묶이지 않음을 고정한다. + - 기대 결과: 현재 결함이 테스트로 재현된다. + +--- + +### Phase 2: empty marker 저장 정책 + +- [x] **Task 2.1: 오디오 스냅샷 section type empty marker 지원 추가** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt` + - RED: `replaceSnapshots(NEW_AND_HOT_AUDIO_SAFE, snapshotAt, emptyList())`, `replaceSnapshots(MOST_COMMENTED_AUDIO_SAFE, snapshotAt, emptyList())`, `replaceSnapshots(RECOMMENDED_AUDIO_SAFE, snapshotAt, emptyList())` 호출 시 marker가 저장되고 `findSnapshots(...)`는 빈 배열, `existsSnapshot(...)`는 true인 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest` + - GREEN: `supportsEmptySnapshotMarker(...)`에 오디오 snapshot-backed 6개 section type을 추가한다. + - REFACTOR: 홈 추천 `AI_CHARACTER`, `CHEER_CREATOR`, `POPULAR_COMMUNITY` marker 동작 회귀 assertion을 유지한다. + - 기대 결과: 데이터가 없는 오디오 섹션도 정상 refresh 완료 상태를 저장한다. + +- [x] **Task 2.2: marker 대체와 latest 조회 제외 검증** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt` + - RED: 오디오 marker가 있는 같은 `sectionType`, `snapshotAt`에 실제 row를 저장하면 marker가 제거되는 실패 테스트를 작성한다. `findLatestSnapshots(...)`에서도 marker가 반환되지 않음을 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest` + - GREEN: 기존 delete 후 save 흐름이 marker 대체를 보장하는지 확인하고, 조회 query의 `target_id <> 0` 조건이 latest 조회에도 적용되게 유지한다. + - REFACTOR: marker 관련 상수와 지원 section 판정 함수 이름이 오디오/홈 모두에 어색하지 않은지 정리한다. + - 기대 결과: marker가 사용자 응답이나 이후 실제 스냅샷을 오염시키지 않는다. + +--- + +### Phase 3: 오디오 fallback orchestration + +- [x] **Task 3.1: 오디오 fallback port/service 추가** + - 파일 경로: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackServiceTest.kt` + - RED: 특정 오디오 `sectionType`의 latest snapshot이 없고 marker도 없으면 lock을 획득하고 refresh service를 호출한 뒤 같은 section latest snapshot을 재조회하는 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackServiceTest` + - GREEN: `RecommendationSnapshotPort`, `AudioRecommendationSnapshotRefreshService`, `RedissonClient`를 사용하는 오디오 전용 fallback service를 추가한다. + - REFACTOR: 홈 추천 fallback의 lock wait 300ms, API wait 1,500ms, single-flight, double-check 패턴을 맞추되, 공통화가 과하면 오디오 전용 최소 구현으로 유지한다. + - 기대 결과: 오디오 섹션 하나를 입력받아 fallback refresh와 재조회를 수행할 수 있다. + +- [x] **Task 3.2: lock miss, timeout, 실패, marker 케이스 검증** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackServiceTest.kt` + - RED: lock 획득 실패, 1,500ms timeout, refresh 예외, marker 존재, 동시 요청 single-flight 케이스를 실패 테스트로 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackServiceTest` + - GREEN: 각 실패/대기 상황에서 빈 배열을 반환하고 warn/info log를 남기며 전체 API 예외로 전파하지 않게 구현한다. + - REFACTOR: fallback service가 상세 DTO 조립이나 점수 계산을 직접 하지 않도록 유지한다. + - 기대 결과: fallback이 요청 지연과 중복 refresh를 제한한다. + +- [x] **Task 3.3: refresh service에 섹션 단위 refresh 경로 추가 또는 일괄 refresh 재사용 확정** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt` + - RED: `refreshSection(sectionType, now)` 또는 동등한 경로가 입력 section type에 맞는 queryPort 함수와 limit을 사용해 `replaceSnapshots(...)`를 호출하는 실패 테스트를 작성한다. 일괄 refresh 재사용을 선택하면 fallback service test에서 `refreshDailySnapshots()` 호출을 명시적으로 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` + - GREEN: 가장 작은 변경으로 스케줄러와 fallback이 같은 snapshot 생성 로직을 공유하게 한다. + - REFACTOR: `SAFE`/`ALL` section type 매핑 중복이 커지면 기존 `AudioRecommendationVisibility` extension 또는 작은 helper로 정리한다. + - 기대 결과: fallback refresh와 scheduler refresh 사이에 산식 drift가 생기지 않는다. + +--- + +### Phase 4: `AudioRecommendationQueryService` 연결 + +- [x] **Task 4.1: `getRecommendations`의 3개 snapshot 조회를 fallback 경로로 변경** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt` + - RED: `MOST_COMMENTED_AUDIO_SAFE`만 비어 있을 때 `mostCommentedAudios`가 fallback 재조회 결과로 조립되는 실패 테스트를 작성한다. `RECOMMENDED_AUDIO_SAFE`만 비어 있는 케이스도 추가한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` + - GREEN: `NEW_AND_HOT`, `MOST_COMMENTED`, `RECOMMENDED_AUDIO` 각각에 대해 `findSnapshotsWithFallback(sectionType, offset, limit)` 형태의 경로를 사용한다. + - REFACTOR: 기존 `refreshMissingNewAndHotSnapshots(...)`와 Redis 날짜 marker는 새 fallback 경로로 대체하거나 전체보기 전용으로만 남긴다. 사용하지 않게 되면 관련 의존성/상수를 제거한다. + - 기대 결과: 3개 오디오 스냅샷 섹션이 서로 독립적으로 fallback을 실행한다. + +- [x] **Task 4.2: visibility별 fallback section type 검증** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt` + - RED: 비회원은 `*_SAFE`, 성인 콘텐츠 노출 가능 회원은 `*_ALL` section type으로 fallback service가 호출되는 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` + - GREEN: 기존 `newAndHotSectionType`, `mostCommentedSectionType`, `recommendedAudioSectionType` 매핑을 fallback 호출에도 그대로 사용한다. + - REFACTOR: 기존 성인 preference 조회 정책과 `initializeDefaultPreference` 미호출 회귀 테스트를 유지한다. + - 기대 결과: fallback이 현재 사용자 visibility와 다른 variant를 잘못 갱신하거나 조회하지 않는다. + +- [x] **Task 4.3: `findNewAndHotAudios` 전체보기 회귀 정리** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt` + - RED: 전체보기 `findNewAndHotAudios(member, offset, limit)`가 기존처럼 offset/limit snapshot 순서를 유지하고, snapshot이 없을 때 새 fallback 경로 또는 기존 lazy refresh 정책 중 결정된 경로를 사용하는 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` + - GREEN: 전체보기 동작을 구현 결정에 맞춰 최소 수정한다. + - REFACTOR: 홈 첫 화면 limit 12와 전체보기 paging limit이 섞이지 않도록 helper 인자를 명확히 유지한다. + - 기대 결과: 첫 화면 fallback 보강이 전체보기 paging을 깨지 않는다. + +--- + +### Phase 5: API 회귀와 최종 검증 + +- [x] **Task 5.1: 오디오 추천 API 응답 스키마 회귀 검증** + - 파일 경로: + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt` + - RED: `newAndHotAudios`, `mostCommentedAudios`, `recommendedAudios` 필드명이 유지되고 신규 필드가 추가되지 않는 회귀 테스트를 확인/보강한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest` + - GREEN: controller/facade/DTO 변경 없이 application service 결과가 기존 response로 매핑되게 한다. + - REFACTOR: 공개 API URL과 JSON field name 변경이 없음을 assertion으로 유지한다. + - 기대 결과: 클라이언트 공개 스키마는 변경되지 않는다. + +- [x] **Task 5.2: focused regression 실행** + - 파일 경로: + - Modify: `docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/plan-task.md` + - RED: 구현 task 완료 후 계획 문서에 기록할 focused command 목록을 확정한다. + - 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다. + - GREEN: 아래 명령을 실행하고 결과를 이 문서 하단 검증 기록에 누적한다. + - REFACTOR: 실패한 명령이 있으면 원인과 재실행 결과를 같은 task 아래에 기록한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackServiceTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest` + - 기대 결과: 오디오 fallback, marker, refresh, API 회귀가 최소 명령으로 검증된다. + +- [ ] **Task 5.3: 전체 회귀와 문서 검증** + - 파일 경로: + - Modify: `docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/plan-task.md` + - RED: 구현 완료 후 전체 회귀 명령 실행 전에는 검증 기록이 구현 전 상태여야 한다. + - 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다. + - GREEN: `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 실행하고 결과를 문서 하단 검증 기록에 누적한다. + - REFACTOR: PRD와 plan-task가 구현 결과와 어긋나면 먼저 문서를 갱신하고 필요한 focused test를 재실행한다. + - 기대 결과: 포맷, 전체 테스트, 문서 명령 유효성을 모두 확인한다. + +--- + +## 검증 기록 +- 2026-07-12: PRD와 구현 계획 문서만 작성했다. 코드 변경은 수행하지 않았다. +- 2026-07-12: 문서 변경 검증을 수행했다. + - `git diff --check` 성공. + - `./gradlew tasks --all`은 최초 샌드박스 실행에서 `~/.gradle` lock 파일 권한 오류로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL`로 통과했다. +- 2026-07-12: 오디오 snapshot-backed 3개 섹션의 독립 fallback 구현을 수행했다. + - RED 확인: + - `RecommendationSnapshotPersistenceAdapterTest.shouldSaveAudioEmptySnapshotMarkerWhenReplacingWithEmptySnapshots`는 오디오 section marker 미지원으로 실패했다. + - `AudioRecommendationSnapshotRefreshServiceTest.shouldRefreshRequestedAudioSnapshotSectionOnly`는 `refreshSection` 미정의 컴파일 오류로 실패했다. + - `AudioRecommendationSnapshotFallbackServiceTest`는 `AudioRecommendationSnapshotFallbackService` 미정의 컴파일 오류로 실패했다. + - `AudioRecommendationQueryServiceTest`는 기존 생성자/조회 경로가 fallback service를 사용하지 않아 컴파일 오류로 실패했다. + - GREEN 확인: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` 성공. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` 성공. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackServiceTest` 성공. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest` 성공. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest` 성공. + - 포맷/문서 명령: + - `./gradlew ktlintCheck` 성공. + - `./gradlew tasks --all` 성공. + - `git diff --check` 성공. + - 전체 회귀: + - `./gradlew test`는 최초 300초 제한에 걸렸고, 재실행은 사용자 요청으로 중단했다. 전체 테스트는 사용자가 별도로 재실행하기로 했다. +- 2026-07-12: post-implementation review에서 blocking issue 2건을 확인하고 수정했다. + - `AudioRecommendationQueryService`의 fallback 기준 시간을 `Asia/Seoul` 기준으로 분리해 JVM 기본 timezone 의존을 제거했다. + - `AudioRecommendationSnapshotScheduler`가 fallback과 동일한 6개 section lock을 획득한 뒤 일괄 refresh를 실행하도록 수정했다. + - 추가 RED 확인: + - `AudioRecommendationSnapshotSchedulerTest.shouldSkipWhenSectionLockNotAcquired`는 section lock miss에도 일 배치를 실행해 실패했다. + - `AudioRecommendationSnapshotSchedulerTest.shouldRefreshOnlyWhenLockAcquired`는 section lock unlock 검증 실패로 실패했다. + - 수정 후 검증: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.scheduler.AudioRecommendationSnapshotSchedulerTest` 성공. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` 성공. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackServiceTest` 성공. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` 성공. + - `./gradlew ktlintCheck` 성공. diff --git a/docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/prd.md b/docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/prd.md new file mode 100644 index 00000000..3d295187 --- /dev/null +++ b/docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/prd.md @@ -0,0 +1,168 @@ +# PRD: 메인 콘텐츠 추천 오디오 스냅샷 폴백 + +## 1. Overview +메인 콘텐츠 추천 탭의 오디오 스냅샷 기반 3개 섹션이 각각 독립적으로 스냅샷 없음 fallback refresh를 수행하도록 보강한다. + +--- + +## 2. Problem +- `AudioRecommendationQueryService.getRecommendations`는 `NEW_AND_HOT_AUDIO_*`, `MOST_COMMENTED_AUDIO_*`, `RECOMMENDED_AUDIO_*` 3개 스냅샷을 조회한다. +- 현재 조회 흐름은 `NEW_AND_HOT_AUDIO_*`가 비어 있을 때만 lazy refresh를 시도한다. +- `MOST_COMMENTED_AUDIO_*` 또는 `RECOMMENDED_AUDIO_*`만 비어 있는 경우에는 refresh가 실행되지 않아 해당 섹션이 빈 배열로 내려간다. +- `NEW_AND_HOT_AUDIO_*` fallback이 `refreshDailySnapshots()`를 호출하면 6개 오디오 스냅샷 variant를 모두 갱신하지만, 같은 요청에서 이미 읽어둔 `MOST_COMMENTED_AUDIO_*`, `RECOMMENDED_AUDIO_*`는 재조회하지 않는다. +- 홈 추천의 `AI_CHARACTER`, `CHEER_CREATOR`, `POPULAR_COMMUNITY`는 각 섹션별 fallback, lock, double-check, timeout, empty marker 정책을 갖고 있어 오디오 추천과 동작 일관성이 다르다. + +--- + +## 3. Goals +- 오디오 추천의 스냅샷 기반 3개 섹션 모두 독립 fallback을 갖는다. +- fallback 대상은 visibility variant를 포함한 실제 조회 섹션 타입 기준으로 분리한다. + - `NEW_AND_HOT_AUDIO_SAFE` + - `NEW_AND_HOT_AUDIO_ALL` + - `MOST_COMMENTED_AUDIO_SAFE` + - `MOST_COMMENTED_AUDIO_ALL` + - `RECOMMENDED_AUDIO_SAFE` + - `RECOMMENDED_AUDIO_ALL` +- 각 섹션 스냅샷이 없으면 스케줄러와 동일한 오디오 스냅샷 refresh 로직으로 저장한 뒤, 해당 섹션을 다시 조회한다. +- fallback은 중복 refresh를 막기 위해 섹션 단위 lock, double-check, JVM 내 single-flight를 사용한다. +- fallback refresh 실패, timeout, lock miss는 전체 API 실패로 전파하지 않고 해당 섹션 빈 배열로 처리한다. +- refresh 결과 0건인 섹션은 정상 refresh 완료 상태를 저장해 매 요청마다 fallback을 반복하지 않게 한다. +- 기존 공개 API URL, 응답 JSON 필드, 오디오 추천 산식, 스냅샷 스케줄 시각은 변경하지 않는다. + +--- + +## 4. Non-Goals +- `GET /api/v2/audio/recommendations` 응답 스키마를 변경하지 않는다. +- `NEW_AND_HOT`, `MOST_COMMENTED`, `RECOMMENDED_AUDIO` 점수 산식과 집계 window를 변경하지 않는다. +- 오디오 배너, 오리지널 시리즈, 최신 오디오, 무료 오디오, 포인트 오디오 조회 정책은 변경하지 않는다. +- 신규 추천 스냅샷 테이블 또는 DDL을 만들지 않는다. +- 홈 추천 `RecommendationSnapshotFallbackService`를 무리하게 공통화하지 않는다. 오디오 추천에 필요한 최소 재사용/확장만 검토한다. +- 전체보기 `findNewAndHotAudios`의 공개 API 스키마와 paging 계약은 변경하지 않는다. + +--- + +## 5. Target Users +- 회원/비회원: 메인 콘텐츠 추천 탭에서 스냅샷 누락으로 특정 오디오 추천 섹션이 비는 상황을 덜 겪어야 하는 사용자 +- 앱 클라이언트: 기존 응답 계약을 유지한 채 가능한 추천 섹션을 안정적으로 받는 클라이언트 +- 운영자: 스케줄러 실패 또는 일부 스냅샷 누락 후 첫 조회에서 자동 복구 흐름을 기대하는 운영 담당자 + +--- + +## 6. User Stories +- 사용자는 추천 탭 진입 시 `New & Hot`, `최근 댓글 많은 오디오`, `추천 오디오`가 각각 가능한 데이터로 채워지기를 기대한다. +- 사용자는 한 섹션의 스냅샷이 없더라도 추천 탭 전체가 실패하지 않기를 기대한다. +- 앱 클라이언트는 특정 스냅샷 섹션이 없는 날에도 기존 응답 구조 그대로 빈 배열 또는 복구된 결과를 받기를 원한다. +- 운영자는 오디오 추천 스케줄러가 실패한 뒤 첫 사용자 조회가 스케줄러와 같은 refresh 로직으로 스냅샷을 복구하기를 원한다. + +--- + +## 7. Core Features + +### Feature A. 오디오 스냅샷 섹션별 독립 fallback + +#### Requirements +- `AudioRecommendationQueryService.getRecommendations`는 `NEW_AND_HOT`, `MOST_COMMENTED`, `RECOMMENDED_AUDIO` 각각에 대해 fallback 조회 경로를 사용한다. +- fallback 판단은 현재 사용자 visibility에 맞는 `RecommendedSectionType` 기준으로 수행한다. +- 한 섹션의 스냅샷이 비어 있더라도 다른 섹션의 기존 스냅샷 조회 결과를 버리거나 재정렬하지 않는다. +- fallback refresh 후에는 refresh를 요청한 섹션을 다시 조회한다. +- `NEW_AND_HOT`이 비어 fallback을 실행한 경우에도 `MOST_COMMENTED`, `RECOMMENDED_AUDIO`가 비어 있으면 각 섹션의 fallback 판단이 독립적으로 수행되어야 한다. +- 각 섹션 fallback은 refresh 결과를 직접 응답으로 조립하지 않고 `recommendation_snapshot`에 저장된 row를 재조회해 사용한다. + +#### Edge Cases +- 특정 섹션 refresh가 실패해도 다른 섹션 응답은 가능한 범위에서 유지한다. +- fallback 후 상세 조회 필터에서 모두 제외되면 해당 섹션은 빈 배열로 반환한다. +- `SAFE`와 `ALL` variant 중 현재 요청에서 사용하지 않는 variant의 누락 여부는 해당 요청의 fallback 조건이 아니다. + +### Feature B. 섹션 단위 lock, double-check, single-flight + +#### Requirements +- fallback refresh는 섹션 타입 단위로 lock key를 분리한다. +- 권장 lock key 형식은 `lock:audio-recommendation-snapshot-refresh:{SECTION_TYPE}`이다. +- lock 대기 시간은 홈 추천 fallback과 동일하게 최대 300ms를 우선 적용한다. +- API 요청이 fallback refresh 완료를 기다리는 시간은 홈 추천 fallback과 동일하게 최대 1,500ms를 우선 적용한다. +- timeout은 요청 대기 timeout이며, 이미 시작된 background refresh를 반드시 중단한다는 의미가 아니다. +- 동일 JVM에서는 같은 `SECTION_TYPE`에 대해 single-flight를 적용해 동시 요청이 중복 refresh를 시작하지 않게 한다. +- lock 획득 후에는 대상 스냅샷 존재 여부를 다시 확인하고, 이미 존재하면 refresh를 실행하지 않는다. + +#### Edge Cases +- lock 획득 직전에 다른 요청 또는 스케줄러가 스냅샷을 저장할 수 있으므로 double-check가 필요하다. +- lock 획득 실패 시 refresh를 시작하지 않고 짧게 재조회한 뒤 없으면 빈 배열을 반환한다. +- timeout 이후 background refresh가 완료되면 다음 요청은 저장된 스냅샷을 사용한다. + +### Feature C. 오디오 스냅샷 empty marker + +#### Requirements +- 오디오 스냅샷 섹션도 refresh 결과 0건이면 정상 refresh 완료 상태를 저장해야 한다. +- 기존 `recommendation_snapshot` 구조를 재사용하고, 홈 추천과 같은 `targetId = 0` empty snapshot marker 방식을 우선 적용한다. +- marker 적용 대상은 오디오 스냅샷 기반 6개 section type이다. +- 조회 쿼리는 marker가 사용자 응답에 노출되지 않도록 `target_id <> 0` 정책을 유지한다. +- 존재 여부 확인은 marker를 포함해 판단하여 집계 결과가 없는 섹션이 매 요청마다 fallback refresh를 반복하지 않게 한다. + +#### Edge Cases +- marker만 있으면 snapshot 조회 결과는 빈 배열이어야 한다. +- 같은 `sectionType`, `snapshotAt`에 실제 row가 생기는 재실행이 있으면 marker는 실제 row로 대체되어야 한다. +- 오디오 marker 추가가 기존 홈 추천 marker 동작을 바꾸면 안 된다. + +### Feature D. 오디오 refresh 경로 재사용 + +#### Requirements +- fallback refresh는 스케줄러와 같은 `AudioRecommendationSnapshotRefreshService`의 refresh 로직을 사용한다. +- 현재 `refreshDailySnapshots()`가 6개 오디오 스냅샷을 일괄 갱신하는 구조는 유지할 수 있다. +- 가능하면 단일 섹션 refresh 함수를 추가해 fallback 요청 섹션만 갱신하는 방식을 우선 검토한다. +- 단일 섹션 refresh를 추가하더라도 기존 일괄 스케줄러는 6개 섹션을 계속 갱신해야 한다. +- `snapshotAt`과 집계 window는 기존 오디오 추천 정책을 유지한다. + - KST 기준 전날 23:59:59 + - `NEW_AND_HOT`: 최근 3일 + - `MOST_COMMENTED`: 최근 7일 + - `RECOMMENDED_AUDIO`: 최근 7일 + +#### Edge Cases +- 단일 섹션 refresh가 과도한 중복을 만들면 기존 일괄 refresh를 호출하고 해당 섹션만 재조회하는 최소 구현을 허용한다. +- 단, 일괄 refresh를 호출하는 경우에도 fallback trigger와 재조회는 섹션별로 독립이어야 한다. + +--- + +## 8. Technical Constraints +- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다. +- 기존 `recommendation_snapshot` 테이블과 `RecommendedSectionType` enum 값을 재사용한다. +- 기존 `AudioRecommendationQueryService`, `AudioRecommendationSnapshotRefreshService`, `AudioRecommendationSnapshotScheduler` 경계를 우선 유지한다. +- fallback orchestration은 홈 추천 `RecommendationSnapshotFallbackService`의 lock, timeout, double-check, single-flight 패턴을 기준으로 설계한다. +- 공개 API 응답 DTO와 controller endpoint는 변경하지 않는다. +- 성인 콘텐츠 visibility는 기존 `MemberContentPreferenceService.canViewAdultContent(member)` 결과에 따른 `SAFE`/`ALL` section type 선택을 유지한다. +- `findLatestSnapshots(...)` 기반의 현재 오디오 snapshot 조회 정책은 유지한다. 대상일 `snapshotAt` exact 조회 방식으로 바꾸는 것은 이번 요구사항의 필수 범위가 아니다. +- 신규 DDL은 만들지 않는다. + +--- + +## 9. Metrics +- 오디오 fallback refresh 실행/성공/실패/timeout 로그 +- 오디오 fallback lock 획득 성공/실패 로그 +- section type별 fallback refresh 대기 시간 +- section type별 empty marker 저장 횟수 +- `newAndHotAudios`, `mostCommentedAudios`, `recommendedAudios` 빈 응답 비율 +- `audio_recommendation_snapshot_refresh_success` 저장 수 또는 section별 저장 수 + +--- + +## 10. Open Questions +- 없음. + +--- + +## 11. Decisions +- 이번 작업은 문서 작성만 수행한다. +- 오디오 추천 snapshot-backed 3개 섹션 모두 홈 추천 snapshot-backed 3개 섹션과 같은 수준의 fallback 정책을 갖는 것을 목표로 한다. +- fallback 실패는 전체 API 실패가 아니라 해당 섹션 빈 배열로 처리한다. +- 기존 `MOST_COMMENTED`가 비면 빈 배열로 내려주던 초기 PRD 정책은 이번 요구사항으로 변경한다. +- 스냅샷 산식, visibility 정책, 공개 응답 스키마는 변경하지 않는다. + +--- + +## 12. Related Documents +- `docs/prd/sample-prd.md` +- `docs/agent-guides/작업절차.md` +- `docs/agent-guides/문서유지보수.md` +- `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md` +- `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md` +- `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` +- `docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/prd.md` From 23df3da784575fbb4fb541cb0e622b1bc9d84d8a Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 12 Jul 2026 22:42:44 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix(content):=20=EC=98=A4=EB=94=94=EC=98=A4?= =?UTF-8?q?=20=EC=8A=A4=EB=83=85=EC=83=B7=20empty=20marker=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ecommendationSnapshotPersistenceAdapter.kt | 8 ++- ...mendationSnapshotPersistenceAdapterTest.kt | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt index f4a030e1..5d1e236e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt @@ -86,7 +86,13 @@ class RecommendationSnapshotPersistenceAdapter( private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean { return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR || - sectionType == RecommendedSectionType.POPULAR_COMMUNITY + sectionType == RecommendedSectionType.POPULAR_COMMUNITY || + sectionType == RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE || + sectionType == RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL || + sectionType == RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE || + sectionType == RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL || + sectionType == RecommendedSectionType.RECOMMENDED_AUDIO_SAFE || + sectionType == RecommendedSectionType.RECOMMENDED_AUDIO_ALL } companion object { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt index 5704f384..041f5ec2 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt @@ -236,6 +236,59 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor( ) } + @Test + fun shouldSaveAudioEmptySnapshotMarkerWhenReplacingWithEmptySnapshots() { + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + + adapter.replaceSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, snapshotAt, emptyList()) + adapter.replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, snapshotAt, emptyList()) + adapter.replaceSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, snapshotAt, emptyList()) + + assertEquals( + emptyList(), + adapter.findSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, snapshotAt) + ) + assertEquals( + emptyList(), + adapter.findSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, snapshotAt) + ) + assertEquals( + emptyList(), + adapter.findSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, snapshotAt) + ) + assertEquals(true, adapter.existsSnapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, snapshotAt)) + assertEquals(true, adapter.existsSnapshot(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, snapshotAt)) + assertEquals(true, adapter.existsSnapshot(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, snapshotAt)) + } + + @Test + fun shouldReplaceAudioEmptyMarkerWithRealSnapshotsAndExcludeMarkerFromLatestSnapshots() { + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + adapter.replaceSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_ALL, snapshotAt, emptyList()) + + adapter.replaceSnapshots( + RecommendedSectionType.RECOMMENDED_AUDIO_ALL, + snapshotAt, + listOf( + RecommendationSnapshotRecord( + sectionType = RecommendedSectionType.RECOMMENDED_AUDIO_ALL, + targetId = 99L, + score = 99.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.9 + ) + ) + ) + + assertEquals(listOf(99L), adapter.findLatestSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_ALL).map { it.targetId }) + assertEquals( + listOf(99L), + repository.findAll() + .filter { it.sectionType == RecommendedSectionType.RECOMMENDED_AUDIO_ALL } + .map { it.targetId } + ) + } + @Test fun shouldReplaceCheerCreatorEmptyMarkerWithRealSnapshots() { val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) From d8fbf449c4581e8fabf3592bdcf31e0c23537307 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 12 Jul 2026 22:42:59 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat(content):=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?fallback=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ioRecommendationSnapshotFallbackService.kt | 155 +++++++++ ...dioRecommendationSnapshotRefreshService.kt | 42 +++ ...commendationSnapshotFallbackServiceTest.kt | 295 ++++++++++++++++++ ...ecommendationSnapshotRefreshServiceTest.kt | 19 ++ 4 files changed, 511 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackService.kt new file mode 100644 index 00000000..8580d1c1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackService.kt @@ -0,0 +1,155 @@ +package kr.co.vividnext.sodalive.v2.content.recommendation.application + +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord +import org.redisson.api.RedissonClient +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicReference +import javax.annotation.PreDestroy + +@Service +class AudioRecommendationSnapshotFallbackService( + private val snapshotPort: RecommendationSnapshotPort, + private val refreshService: AudioRecommendationSnapshotRefreshService, + private val redissonClient: RedissonClient, + executor: Executor? = null, + private val homeWaitMillis: Long = HOME_WAIT_MILLIS +) { + private val log = LoggerFactory.getLogger(javaClass) + private val ownedExecutor: ExecutorService? = if (executor == null) { + Executors.newFixedThreadPool(DEFAULT_WORKER_THREADS) + } else { + null + } + private val workerExecutor: Executor = executor ?: ownedExecutor!! + private val refreshFutures = ConcurrentHashMap?>>() + + fun refreshIfMissing( + sectionType: RecommendedSectionType, + offset: Long, + limit: Int, + now: LocalDateTime = LocalDateTime.now(KST_ZONE) + ): List { + val existing = snapshotPort.findLatestSnapshots(sectionType, offset, limit) + if (existing.isNotEmpty()) return existing + val snapshotAt = snapshotAt(now) + if (snapshotPort.existsSnapshot(sectionType, snapshotAt)) { + return snapshotPort.findLatestSnapshots(sectionType, offset, limit) + } + + val future = getOrStartRefresh(sectionType, now) + return try { + future.get(homeWaitMillis, TimeUnit.MILLISECONDS) + snapshotPort.findLatestSnapshots(sectionType, offset, limit) + } catch (ex: TimeoutException) { + log.warn( + "event=audio_recommendation_snapshot_fallback_timeout sectionType={} homeWaitMs={}", + sectionType, + homeWaitMillis + ) + emptyList() + } catch (ex: InterruptedException) { + Thread.currentThread().interrupt() + log.warn( + "event=audio_recommendation_snapshot_fallback_failure sectionType={} error={}", + sectionType, + ex.message, + ex + ) + emptyList() + } catch (ex: Exception) { + log.warn( + "event=audio_recommendation_snapshot_fallback_failure sectionType={} error={}", + sectionType, + ex.message, + ex + ) + emptyList() + } + } + + @Synchronized + private fun getOrStartRefresh(sectionType: RecommendedSectionType, now: LocalDateTime): CompletableFuture { + val reference = refreshFutures.computeIfAbsent(sectionType) { AtomicReference() } + reference.get()?.let { return it } + + val newFuture = CompletableFuture.runAsync({ refreshInWorker(sectionType, now) }, workerExecutor) + reference.set(newFuture) + newFuture.whenComplete { _, _ -> reference.compareAndSet(newFuture, null) } + return newFuture + } + + private fun refreshInWorker(sectionType: RecommendedSectionType, now: LocalDateTime) { + val lock = redissonClient.getLock(lockKey(sectionType)) + try { + if (!lock.tryLock(LOCK_WAIT_MILLIS, -1, TimeUnit.MILLISECONDS)) { + log.info( + "event=audio_recommendation_snapshot_fallback_lock_missed sectionType={} lockKey={} lockWaitMs={}", + sectionType, + lockKey(sectionType), + LOCK_WAIT_MILLIS + ) + return + } + + log.info( + "event=audio_recommendation_snapshot_fallback_lock_acquired sectionType={} lockKey={}", + sectionType, + lockKey(sectionType) + ) + if (snapshotPort.existsSnapshot(sectionType, snapshotAt(now))) return + + log.info("event=audio_recommendation_snapshot_fallback_refresh_start sectionType={}", sectionType) + val refreshedCount = refreshService.refreshSection(sectionType, now) + log.info( + "event=audio_recommendation_snapshot_fallback_refresh_success sectionType={} refreshedCount={}", + sectionType, + refreshedCount + ) + } catch (ex: Exception) { + log.warn( + "event=audio_recommendation_snapshot_fallback_refresh_failure sectionType={} error={}", + sectionType, + ex.message, + ex + ) + } finally { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } + + @PreDestroy + fun shutdown() { + ownedExecutor?.shutdown() + } + + companion object { + private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul") + private const val LOCK_WAIT_MILLIS = 300L + private const val HOME_WAIT_MILLIS = 1_500L + private const val DEFAULT_WORKER_THREADS = 2 + + fun lockKey(sectionType: RecommendedSectionType): String { + return "lock:audio-recommendation-snapshot-refresh:$sectionType" + } + + private fun snapshotAt(now: LocalDateTime): LocalDateTime { + return now.toLocalDate() + .minusDays(1) + .atTime(23, 59, 59) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt index f2e76301..e56fb14e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt @@ -62,6 +62,48 @@ class AudioRecommendationSnapshotRefreshService( } } + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun refreshSection(sectionType: RecommendedSectionType, now: LocalDateTime = LocalDateTime.now(KST_ZONE)): Int { + val snapshotAt = snapshotAt(now.atZone(KST_ZONE)) + val newAndHotWindowStart = windowStart(snapshotAt, days = 3) + val mostCommentedWindowStart = windowStart(snapshotAt, days = 7) + + when (sectionType) { + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE -> replaceNewAndHotSnapshots( + newAndHotWindowStart, + snapshotAt, + AudioRecommendationVisibility.SAFE + ) + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL -> replaceNewAndHotSnapshots( + newAndHotWindowStart, + snapshotAt, + AudioRecommendationVisibility.ALL + ) + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE -> replaceMostCommentedSnapshots( + mostCommentedWindowStart, + snapshotAt, + AudioRecommendationVisibility.SAFE + ) + RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL -> replaceMostCommentedSnapshots( + mostCommentedWindowStart, + snapshotAt, + AudioRecommendationVisibility.ALL + ) + RecommendedSectionType.RECOMMENDED_AUDIO_SAFE -> replaceRecommendedAudioSnapshots( + mostCommentedWindowStart, + snapshotAt, + AudioRecommendationVisibility.SAFE + ) + RecommendedSectionType.RECOMMENDED_AUDIO_ALL -> replaceRecommendedAudioSnapshots( + mostCommentedWindowStart, + snapshotAt, + AudioRecommendationVisibility.ALL + ) + else -> error("Unsupported audio recommendation sectionType: $sectionType") + } + return 1 + } + private fun replaceNewAndHotSnapshots( windowStart: LocalDateTime, snapshotAt: LocalDateTime, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackServiceTest.kt new file mode 100644 index 00000000..7ec06cb1 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackServiceTest.kt @@ -0,0 +1,295 @@ +package kr.co.vividnext.sodalive.v2.content.recommendation.application + +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.redisson.api.RLock +import org.redisson.api.RedissonClient +import java.time.LocalDateTime +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class AudioRecommendationSnapshotFallbackServiceTest { + @Test + @DisplayName("오디오 fallback은 최신 스냅샷이 이미 있으면 lock과 refresh를 실행하지 않는다") + fun shouldReturnExistingAudioSnapshotsWithoutLockOrRefresh() { + val snapshotPort = FakeAudioFallbackSnapshotPort() + val snapshotAt = LocalDateTime.of(2026, 7, 9, 23, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + snapshotAt, + listOf(snapshot(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, 10L, snapshotAt)) + ) + val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val service = AudioRecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing( + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + offset = 0, + limit = 5, + now = LocalDateTime.of(2026, 7, 10, 0, 0) + ) + + assertEquals(listOf(10L), snapshots.map { it.targetId }) + Mockito.verifyNoInteractions(redissonClient) + Mockito.verifyNoInteractions(refreshService) + } + + @Test + @DisplayName("오디오 fallback은 section lock을 잡고 section refresh 후 최신 스냅샷을 다시 조회한다") + fun shouldRefreshMissingAudioSnapshotsWithSectionLock() { + val snapshotPort = FakeAudioFallbackSnapshotPort() + val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val now = LocalDateTime.of(2026, 7, 10, 0, 0) + val sectionType = RecommendedSectionType.RECOMMENDED_AUDIO_ALL + Mockito.`when`( + redissonClient.getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType)) + ).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + Mockito.doAnswer { + snapshotPort.replaceSnapshots( + sectionType, + LocalDateTime.of(2026, 7, 9, 23, 59, 59), + listOf(snapshot(sectionType, 20L)) + ) + 1 + }.`when`(refreshService).refreshSection(sectionType, now) + val service = AudioRecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing( + sectionType, + offset = 0, + limit = 20, + now = now + ) + + assertEquals(listOf(20L), snapshots.map { it.targetId }) + Mockito.verify(redissonClient) + .getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType)) + Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS) + Mockito.verify(refreshService).refreshSection(sectionType, now) + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("오디오 fallback은 빈 스냅샷 marker가 있으면 refresh를 반복하지 않는다") + fun shouldSkipAudioRefreshWhenEmptySnapshotMarkerExists() { + val snapshotPort = FakeAudioFallbackSnapshotPort() + val now = LocalDateTime.of(2026, 7, 10, 0, 0) + snapshotPort.replaceSnapshots( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + LocalDateTime.of(2026, 7, 9, 23, 59, 59), + emptyList() + ) + val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val service = AudioRecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + offset = 0, + limit = 12, + now = now + ) + + assertEquals(emptyList(), snapshots) + Mockito.verifyNoInteractions(redissonClient) + Mockito.verifyNoInteractions(refreshService) + } + + @Test + @DisplayName("오디오 fallback은 lock 획득 실패 시 refresh를 실행하지 않고 빈 배열을 반환한다") + fun shouldReturnEmptyWhenAudioSectionLockIsNotAcquired() { + val snapshotPort = FakeAudioFallbackSnapshotPort() + val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val sectionType = RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL + Mockito.`when`( + redissonClient.getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType)) + ).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(false) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) + val service = AudioRecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing( + sectionType, + offset = 0, + limit = 5, + now = LocalDateTime.of(2026, 7, 10, 0, 0) + ) + + assertEquals(emptyList(), snapshots) + Mockito.verifyNoInteractions(refreshService) + Mockito.verify(lock, Mockito.never()).unlock() + } + + @Test + @DisplayName("오디오 fallback은 refresh 실패 시 예외를 던지지 않고 빈 배열을 반환한다") + fun shouldReturnEmptyWhenAudioRefreshFails() { + val snapshotPort = FakeAudioFallbackSnapshotPort() + val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val now = LocalDateTime.of(2026, 7, 10, 0, 0) + val sectionType = RecommendedSectionType.RECOMMENDED_AUDIO_SAFE + Mockito.`when`( + redissonClient.getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType)) + ).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + Mockito.doThrow(IllegalStateException("boom")).`when`(refreshService) + .refreshSection(sectionType, now) + val service = AudioRecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing( + sectionType, + offset = 0, + limit = 20, + now = now + ) + + assertEquals(emptyList(), snapshots) + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("오디오 fallback은 timeout 시 빈 배열을 반환하고 worker를 취소하지 않는다") + fun shouldReturnEmptyOnAudioTimeoutAndKeepWorkerRunning() { + val snapshotPort = FakeAudioFallbackSnapshotPort() + val refreshStarted = CountDownLatch(1) + val allowRefreshComplete = CountDownLatch(1) + val refreshService = BlockingAudioRefreshService(snapshotPort, refreshStarted, allowRefreshComplete) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val sectionType = RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE + Mockito.`when`( + redissonClient.getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType)) + ).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val executor = Executors.newFixedThreadPool(2) + val service = AudioRecommendationSnapshotFallbackService( + snapshotPort, + refreshService, + redissonClient, + executor, + homeWaitMillis = 50 + ) + + val first = service.refreshIfMissing( + sectionType, + offset = 0, + limit = 5, + now = LocalDateTime.of(2026, 7, 10, 0, 0) + ) + assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS)) + allowRefreshComplete.countDown() + executor.shutdown() + assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS)) + val second = service.refreshIfMissing( + sectionType, + offset = 0, + limit = 5, + now = LocalDateTime.of(2026, 7, 10, 0, 0) + ) + + assertEquals(emptyList(), first) + assertEquals(listOf(99L), second.map { it.targetId }) + } + + private fun directExecutor(): Executor = Executor { command -> command.run() } +} + +private class BlockingAudioRefreshService( + private val snapshotPort: RecommendationSnapshotPort, + private val refreshStarted: CountDownLatch, + private val allowRefreshComplete: CountDownLatch +) : AudioRecommendationSnapshotRefreshService( + snapshotPort, + Mockito.mock(kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort::class.java) +) { + override fun refreshSection(sectionType: RecommendedSectionType, now: LocalDateTime): Int { + refreshStarted.countDown() + allowRefreshComplete.await(1, TimeUnit.SECONDS) + snapshotPort.replaceSnapshots( + sectionType, + LocalDateTime.of(2026, 7, 9, 23, 59, 59), + listOf(snapshot(sectionType, 99L)) + ) + return 1 + } +} + +private class FakeAudioFallbackSnapshotPort : RecommendationSnapshotPort { + private val snapshots = mutableListOf() + + override fun findLatestSnapshots( + sectionType: RecommendedSectionType, + offset: Long, + limit: Int + ): List { + val latestSnapshotAt = snapshots.filter { it.sectionType == sectionType }.maxOfOrNull { it.snapshotAt } + return snapshots + .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L } + .drop(offset.toInt()) + .take(limit) + } + + override fun findSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + offset: Long, + limit: Int + ): List { + return snapshots + .filter { it.sectionType == sectionType && it.snapshotAt == snapshotAt && it.targetId != 0L } + .drop(offset.toInt()) + .take(limit) + } + + override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean { + return snapshots.any { it.sectionType == sectionType } + } + + override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean { + return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + } + + override fun replaceSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + newSnapshots: List + ) { + snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + if (newSnapshots.isEmpty()) { + snapshots.add(snapshot(sectionType, 0L, snapshotAt)) + return + } + snapshots.addAll(newSnapshots) + } +} + +private fun snapshot( + sectionType: RecommendedSectionType, + targetId: Long, + snapshotAt: LocalDateTime = LocalDateTime.of(2026, 7, 9, 23, 59, 59) +): RecommendationSnapshotRecord { + return RecommendationSnapshotRecord( + sectionType = sectionType, + targetId = targetId, + score = 1.0, + snapshotAt = snapshotAt, + randomTieBreaker = 1.0 + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt index ddee8e35..168bacc3 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt @@ -104,4 +104,23 @@ class AudioRecommendationSnapshotRefreshServiceTest { 100 ) } + + @Test + @DisplayName("section refresh는 요청한 오디오 스냅샷 section만 교체한다") + fun shouldRefreshRequestedAudioSnapshotSectionOnly() { + val now = LocalDateTime.of(2026, 6, 24, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 6, 23, 23, 59, 59) + val windowStart = LocalDateTime.of(2026, 6, 17, 0, 0) + + service.refreshSection(RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, now) + + Mockito.verify(queryPort).findMostCommentedSnapshots( + windowStart, + snapshotAt, + AudioRecommendationVisibility.ALL, + AudioRecommendationSnapshotRefreshService.MOST_COMMENTED_LIMIT + ) + Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, snapshotAt, emptyList()) + Mockito.verifyNoMoreInteractions(queryPort) + } } From 91f0d20a795fc78c5efe94a64d433ccb75879b0f Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 12 Jul 2026 22:44:14 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(content):=20=EC=98=A4=EB=94=94=EC=98=A4?= =?UTF-8?q?=20=EC=B6=94=EC=B2=9C=20=EC=A1=B0=ED=9A=8C=20fallback=EC=9D=84?= =?UTF-8?q?=20=EC=84=B9=EC=85=98=EB=B3=84=EB=A1=9C=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudioRecommendationQueryService.kt | 74 +++---- .../AudioRecommendationQueryServiceTest.kt | 187 +++++++++--------- 2 files changed, 122 insertions(+), 139 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt index b01a875f..5f72de82 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt @@ -7,12 +7,8 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommenda import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType -import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord -import org.redisson.api.RedissonClient import org.springframework.stereotype.Service -import java.time.Duration -import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId @@ -20,30 +16,31 @@ import java.time.ZoneId class AudioRecommendationQueryService( private val queryPort: AudioRecommendationQueryPort, private val memberContentPreferenceService: MemberContentPreferenceService, - private val snapshotPort: RecommendationSnapshotPort, - private val snapshotRefreshService: AudioRecommendationSnapshotRefreshService, - private val redissonClient: RedissonClient + private val snapshotFallbackService: AudioRecommendationSnapshotFallbackService ) { fun getRecommendations(member: Member?): AudioRecommendations { val now = LocalDateTime.now() + val fallbackNow = LocalDateTime.now(KST_ZONE) val canViewAdultContent = canViewAdultContent(member) val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE val memberId = member?.id val newAndHotSectionType = newAndHotSectionType(visibility) - val newAndHotSnapshots = snapshotPort.findLatestSnapshots(newAndHotSectionType, limit = NEW_AND_HOT_HOME_LIMIT) - val mostCommentedSnapshots = snapshotPort.findLatestSnapshots( - mostCommentedSectionType(visibility), - limit = MOST_COMMENTED_AUDIO_LIMIT - ) - val recommendedSnapshots = snapshotPort.findLatestSnapshots( - recommendedAudioSectionType(visibility), - limit = RECOMMENDED_AUDIO_LIMIT - ) - val refreshedNewAndHotSnapshots = refreshMissingNewAndHotSnapshots( + val mostCommentedSectionType = mostCommentedSectionType(visibility) + val recommendedAudioSectionType = recommendedAudioSectionType(visibility) + val newAndHotSnapshots = findSnapshotsWithFallback( newAndHotSectionType, - newAndHotSnapshots, - offset = 0, - limit = NEW_AND_HOT_HOME_LIMIT + limit = NEW_AND_HOT_HOME_LIMIT, + now = fallbackNow + ) + val mostCommentedSnapshots = findSnapshotsWithFallback( + mostCommentedSectionType, + limit = MOST_COMMENTED_AUDIO_LIMIT, + now = fallbackNow + ) + val recommendedSnapshots = findSnapshotsWithFallback( + recommendedAudioSectionType, + limit = RECOMMENDED_AUDIO_LIMIT, + now = fallbackNow ) return AudioRecommendations( @@ -51,7 +48,7 @@ class AudioRecommendationQueryService( originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now), latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now), newAndHotAudios = queryPort.findAudioCardsByIds( - refreshedNewAndHotSnapshots.map { it.targetId }, + newAndHotSnapshots.map { it.targetId }, memberId, canViewAdultContent, now @@ -74,14 +71,14 @@ class AudioRecommendationQueryService( fun findNewAndHotAudios(member: Member, offset: Long, limit: Int): List { val now = LocalDateTime.now() + val fallbackNow = LocalDateTime.now(KST_ZONE) val canViewAdultContent = canViewAdultContent(member) val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE val sectionType = newAndHotSectionType(visibility) - val snapshots = snapshotPort.findLatestSnapshots(sectionType, offset, limit) - val refreshedSnapshots = refreshMissingNewAndHotSnapshots(sectionType, snapshots, offset, limit) + val snapshots = findSnapshotsWithFallback(sectionType, offset, limit, fallbackNow) return queryPort.findAudioCardsByIds( - refreshedSnapshots.map { it.targetId }, + snapshots.map { it.targetId }, member.id, canViewAdultContent, now @@ -113,29 +110,13 @@ class AudioRecommendationQueryService( } } - private fun refreshMissingNewAndHotSnapshots( + private fun findSnapshotsWithFallback( sectionType: RecommendedSectionType, - snapshots: List, - offset: Long, - limit: Int + offset: Long = 0, + limit: Int, + now: LocalDateTime ): List { - if (snapshots.isNotEmpty()) return snapshots - val today = LocalDate.now(KST_ZONE) - val marker = redissonClient.getBucket(newAndHotLazyRefreshMarkerKey(today)) - if (!marker.setIfAbsent(LAZY_REFRESH_ATTEMPTED_VALUE, LAZY_REFRESH_MARKER_TTL)) { - return snapshots - } - runCatching { - snapshotRefreshService.refreshDailySnapshots() - }.onFailure { ex -> - marker.delete() - throw ex - } - return snapshotPort.findLatestSnapshots(sectionType, offset, limit) - } - - private fun newAndHotLazyRefreshMarkerKey(date: LocalDate): String { - return "$LAZY_REFRESH_MARKER_KEY_PREFIX:$date" + return snapshotFallbackService.refreshIfMissing(sectionType, offset, limit, now) } private fun canViewAdultContent(member: Member?): Boolean { @@ -152,9 +133,6 @@ class AudioRecommendationQueryService( const val NEW_AND_HOT_HOME_LIMIT = 12 const val MOST_COMMENTED_AUDIO_LIMIT = 5 const val RECOMMENDED_AUDIO_LIMIT = 20 - private const val LAZY_REFRESH_MARKER_KEY_PREFIX = "audio-recommendation:new-and-hot:lazy-refresh-attempted" - private const val LAZY_REFRESH_ATTEMPTED_VALUE = "1" - private val LAZY_REFRESH_MARKER_TTL: Duration = Duration.ofDays(2) private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul") } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt index ce8f0180..1f258021 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt @@ -7,30 +7,21 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType -import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito -import org.redisson.api.RBucket -import org.redisson.api.RedissonClient -import java.time.Duration import java.time.LocalDateTime class AudioRecommendationQueryServiceTest { private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) - private val snapshotPort = Mockito.mock(RecommendationSnapshotPort::class.java) - private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java) - private val redissonClient = Mockito.mock(RedissonClient::class.java) - private val lazyRefreshMarker = Mockito.mock(RBucket::class.java) as RBucket + private val fallbackService = Mockito.mock(AudioRecommendationSnapshotFallbackService::class.java) private val service = AudioRecommendationQueryService( queryPort, preferenceService, - snapshotPort, - refreshService, - redissonClient + fallbackService ) @Test @@ -40,8 +31,8 @@ class AudioRecommendationQueryServiceTest { } @Test - @DisplayName("조회 서비스는 SAFE 스냅샷을 lazy refresh 후 상세 섹션으로 조립한다") - fun shouldBuildRecommendationsFromSafeSnapshotsWithLazyRefresh() { + @DisplayName("조회 서비스는 SAFE 스냅샷 기반 3개 섹션을 fallback 경로로 조립한다") + fun shouldBuildRecommendationsFromSafeSnapshotsWithFallback() { val snapshot = RecommendationSnapshotRecord( sectionType = RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, targetId = 1L, @@ -49,39 +40,39 @@ class AudioRecommendationQueryServiceTest { snapshotAt = LocalDateTime.now(), randomTieBreaker = 1.0 ) - Mockito.doReturn(emptyList(), listOf(snapshot)) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, - 0, - AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT - ) - Mockito.doReturn(emptyList()) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, - 0, - AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT - ) - Mockito.doReturn(emptyList()) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, - 0, - AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT - ) - allowLazyRefreshOnce() + stubFallback( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT, + listOf(snapshot) + ) + stubFallback( + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT, + emptyList() + ) + stubFallback( + RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT, + emptyList() + ) val recommendations = service.getRecommendations(null) assertEquals(0, recommendations.mostCommentedAudios.size) - Mockito.verify(refreshService).refreshDailySnapshots() - Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots( + verifyFallback( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT + ) + verifyFallback( RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, 0, AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT ) - Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots( + verifyFallback( RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, 0, AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT @@ -114,35 +105,34 @@ class AudioRecommendationQueryServiceTest { } @Test - @DisplayName("New & Hot lazy refresh는 보강 후에도 비어 있으면 같은 KST 날짜에 다시 실행하지 않는다") - fun shouldAttemptEmptyNewAndHotLazyRefreshOncePerKstDate() { - Mockito.doReturn(emptyList()) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, - 0, - AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT - ) - Mockito.doReturn(emptyList()) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, - 0, - AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT - ) - Mockito.doReturn(emptyList()) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, - 0, - AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT - ) - allowLazyRefreshOnce() + @DisplayName("Most Commented만 비어 있어도 해당 섹션 fallback을 독립적으로 호출한다") + fun shouldFallbackMostCommentedIndependentlyWhenNewAndHotExists() { + stubFallback( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT, + listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, 1L)) + ) + stubFallback( + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT, + emptyList() + ) + stubFallback( + RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT, + listOf(snapshot(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, 2L)) + ) - service.getRecommendations(null) service.getRecommendations(null) - Mockito.verify(refreshService, Mockito.times(1)).refreshDailySnapshots() + verifyFallback( + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT + ) } @Test @@ -150,19 +140,18 @@ class AudioRecommendationQueryServiceTest { fun shouldUseStoredPreferenceForMemberAdultVisibility() { val member = member(id = 10L) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) - Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, - 0, - AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT - ) + stubFallback( + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT, + listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L)) + ) service.getRecommendations(member) Mockito.verify(preferenceService).canViewAdultContent(member) Mockito.verify(preferenceService, Mockito.never()).initializeDefaultPreference(member) - Mockito.verify(snapshotPort).findLatestSnapshots( + verifyFallback( RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 0, AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT @@ -174,16 +163,16 @@ class AudioRecommendationQueryServiceTest { fun shouldKeepNewAndHotHomeLimitAtTwelve() { val member = member(id = 10L) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) - Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))).`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, - 0, - AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT - ) + stubFallback( + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT, + listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L)) + ) service.getRecommendations(member) - Mockito.verify(snapshotPort).findLatestSnapshots( + verifyFallback( RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 0, AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT @@ -200,15 +189,19 @@ class AudioRecommendationQueryServiceTest { snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 5L) ) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) - Mockito.doReturn(snapshots).`when`(snapshotPort) - .findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21) + stubFallback(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21, snapshots) Mockito.doReturn(listOf(audioCard(3L), audioCard(4L), audioCard(5L))).`when`(queryPort) - .findAudioCardsByIds(eqValue(listOf(3L, 4L, 5L)), eqValue(member.id), eqValue(true), anyLocalDateTime()) + .findAudioCardsByIds( + eqValue(listOf(3L, 4L, 5L)), + eqValue(member.id), + eqValue(true), + anyLocalDateTime() + ) val result = service.findNewAndHotAudios(member, offset = 20L, limit = 21) assertEquals(listOf(3L, 4L, 5L), result.map { it.audioContentId }) - Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21) + verifyFallback(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21) } @Test @@ -280,15 +273,27 @@ class AudioRecommendationQueryServiceTest { ) } + private fun stubFallback( + sectionType: RecommendedSectionType, + offset: Long, + limit: Int, + snapshots: List + ) { + Mockito.doReturn(snapshots) + .`when`(fallbackService) + .refreshIfMissing(eqValue(sectionType), eqValue(offset), eqValue(limit), anyLocalDateTime()) + } + + private fun verifyFallback(sectionType: RecommendedSectionType, offset: Long, limit: Int) { + Mockito.verify(fallbackService).refreshIfMissing( + eqValue(sectionType), + eqValue(offset), + eqValue(limit), + anyLocalDateTime() + ) + } + private fun eqValue(value: T): T { return Mockito.eq(value) ?: value } - - private fun allowLazyRefreshOnce() { - Mockito.doReturn(lazyRefreshMarker).`when`(redissonClient).getBucket(Mockito.anyString()) - Mockito.doReturn(true, false).`when`(lazyRefreshMarker).setIfAbsent( - eqValue("1"), - eqValue(Duration.ofDays(2)) - ) - } } From 18abdee2492483f38a118e2194adc78de040e2b1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 12 Jul 2026 22:45:20 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix(content):=20=EC=98=A4=EB=94=94=EC=98=A4?= =?UTF-8?q?=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=9D=BC=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=20lock=EC=9D=84=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudioRecommendationSnapshotScheduler.kt | 27 ++++++++++++ ...udioRecommendationSnapshotSchedulerTest.kt | 41 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt index 3d0bcaf2..43c126f5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt @@ -1,6 +1,9 @@ package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.scheduler +import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackService import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshService +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import org.redisson.api.RLock import org.redisson.api.RedissonClient import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component @@ -14,19 +17,43 @@ class AudioRecommendationSnapshotScheduler( @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") fun refreshDailySnapshots() { val lock = redissonClient.getLock(LOCK_KEY) + val sectionLocks = mutableListOf() try { if (lock.tryLock(0, -1, TimeUnit.SECONDS)) { + if (!tryLockSections(sectionLocks)) return refreshService.refreshDailySnapshots() } } finally { + sectionLocks.asReversed().forEach { sectionLock -> + if (sectionLock.isHeldByCurrentThread) { + sectionLock.unlock() + } + } if (lock.isHeldByCurrentThread) { lock.unlock() } } } + private fun tryLockSections(acquiredLocks: MutableList): Boolean { + return AUDIO_SECTION_TYPES.all { sectionType -> + val lock = redissonClient.getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType)) + if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) return false + acquiredLocks.add(lock) + true + } + } + companion object { const val LOCK_KEY = "lock:audio-recommendation-snapshot-refresh" + private val AUDIO_SECTION_TYPES = listOf( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, + RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, + RecommendedSectionType.RECOMMENDED_AUDIO_ALL + ) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt index 957b49e5..8f8fb038 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.scheduler +import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackService import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshService +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -14,6 +16,15 @@ class AudioRecommendationSnapshotSchedulerTest { private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java) private val redissonClient = Mockito.mock(RedissonClient::class.java) private val lock = Mockito.mock(RLock::class.java) + private val sectionLocks = listOf( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, + RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, + RecommendedSectionType.RECOMMENDED_AUDIO_ALL + ) + .associateWith { Mockito.mock(RLock::class.java) } private val scheduler = AudioRecommendationSnapshotScheduler(refreshService, redissonClient) @Test @@ -33,10 +44,40 @@ class AudioRecommendationSnapshotSchedulerTest { Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY) Mockito.doReturn(true).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS) Mockito.doReturn(true).`when`(lock).isHeldByCurrentThread + sectionLocks.forEach { (sectionType, sectionLock) -> + Mockito.doReturn(sectionLock).`when`(redissonClient) + .getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType)) + Mockito.doReturn(true).`when`(sectionLock).tryLock(0, -1, TimeUnit.MILLISECONDS) + Mockito.doReturn(true).`when`(sectionLock).isHeldByCurrentThread + } scheduler.refreshDailySnapshots() Mockito.verify(refreshService).refreshDailySnapshots() + sectionLocks.forEach { (_, sectionLock) -> + Mockito.verify(sectionLock).unlock() + } + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("section lock 획득 실패 시 일 배치를 실행하지 않는다") + fun shouldSkipWhenSectionLockNotAcquired() { + Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY) + Mockito.doReturn(true).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.doReturn(true).`when`(lock).isHeldByCurrentThread + val missedSectionType = RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE + sectionLocks.forEach { (sectionType, sectionLock) -> + Mockito.doReturn(sectionLock).`when`(redissonClient) + .getLock(AudioRecommendationSnapshotFallbackService.lockKey(sectionType)) + Mockito.doReturn(sectionType != missedSectionType).`when`(sectionLock).tryLock(0, -1, TimeUnit.MILLISECONDS) + Mockito.doReturn(sectionType != missedSectionType).`when`(sectionLock).isHeldByCurrentThread + } + + scheduler.refreshDailySnapshots() + + Mockito.verify(refreshService, Mockito.never()).refreshDailySnapshots() + Mockito.verify(sectionLocks.getValue(missedSectionType), Mockito.never()).unlock() Mockito.verify(lock).unlock() }