diff --git a/docs/20260226_라이브추천차단조인및캐시무효화.md b/docs/20260226_라이브추천차단조인및캐시무효화.md new file mode 100644 index 00000000..d40f586c --- /dev/null +++ b/docs/20260226_라이브추천차단조인및캐시무효화.md @@ -0,0 +1,17 @@ +# 라이브 추천 차단 JOIN 및 캐시 무효화 + +- [x] `LiveRecommendService.getRecommendLive`의 차단 필터 처리 구조 점검 +- [x] `LiveRecommendRepository.getRecommendLive`를 DB 조회 시 차단 관계를 JOIN/조건으로 제외하도록 변경 +- [x] 차단(`memberBlock`) 및 차단 해제(`memberUnBlock`) 시 추천 라이브 캐시가 즉시 반영되도록 무효화 처리 +- [x] 변경 코드 정적 진단 및 테스트/빌드 검증 +- [x] 검증 기록 작성 + +## 검증 기록 + +### 1차 구현 +- 무엇을: `getRecommendLive`의 차단 제외 로직을 서비스 단 필터링에서 QueryDSL `leftJoin(blockMember)` + `blockMember.id.isNull` 조건으로 이동했고, 차단/차단해제 시 `CacheManager`로 `getRecommendLive:{memberId}` 키를 직접 evict 하도록 적용했다. +- 왜: 기존 방식은 추천 결과 조회 후 creator마다 `isBlocked`를 반복 호출해 후처리하고, 캐시 만료 전까지 차단/해제 결과가 반영되지 않는 문제가 있어 DB 레벨 필터링과 이벤트성 캐시 무효화가 필요했다. +- 어떻게: + - `lsp_diagnostics` (대상: `LiveRecommendRepository.kt`, `LiveRecommendService.kt`, `MemberService.kt`) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가** + - `./gradlew test` 실행 결과: **성공 (BUILD SUCCESSFUL)** + - `./gradlew build` 실행 결과: **성공 (BUILD SUCCESSFUL, ktlint/check 포함)** diff --git a/docs/20260226_라이브추천차단조인캐시무효화검증테스트.md b/docs/20260226_라이브추천차단조인캐시무효화검증테스트.md new file mode 100644 index 00000000..8a1ec80b --- /dev/null +++ b/docs/20260226_라이브추천차단조인캐시무효화검증테스트.md @@ -0,0 +1,21 @@ +# 라이브 추천 차단 JOIN/캐시 무효화 검증 테스트 + +- [x] `LiveRecommendRepository.getRecommendLive`가 차단 관계(`member -> creator`, `creator -> member`)를 DB 조회 단계에서 제외하는지 테스트 추가 +- [x] `LiveRecommendService.getRecommendLive`가 서비스 단 후처리 없이 저장소 결과를 그대로 위임하는지 테스트 추가 +- [x] `MemberService.memberBlock`/`memberUnBlock` 호출 시 추천 라이브 캐시 키(`getRecommendLive:{memberId}`)가 즉시 무효화되는지 테스트 추가 +- [x] 테스트 및 빌드 검증 수행 +- [x] 검증 기록 작성 + +## 검증 기록 + +### 1차 검증 테스트 구현 +- 무엇을: 문서 요구사항(추천 라이브 차단 JOIN, 서비스 위임 구조, 차단/해제 시 캐시 무효화)을 검증하는 테스트 3종을 추가했다. + - `src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt` + - `src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt` + - `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt` +- 왜: `docs/20260226_라이브추천차단조인및캐시무효화.md`에 기재된 구현이 실제 코드에서 회귀 없이 유지되는지 자동 검증이 필요하다. +- 어떻게: + - `lsp_diagnostics` (대상: 위 3개 Kotlin 테스트 파일) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가** + - `./gradlew test --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendRepositoryTest" --tests "kr.co.vividnext.sodalive.live.recommend.LiveRecommendServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceCacheEvictionTest"` 실행 결과: **성공 (BUILD SUCCESSFUL)** + - `./gradlew build` 1차 실행 결과: **실패 (`MemberServiceCacheEvictionTest.kt` 라인 길이/인자 줄바꿈 ktlint 위반)** + - `MemberServiceCacheEvictionTest.kt` 포맷 수정 후 `./gradlew build` 재실행 결과: **성공 (BUILD SUCCESSFUL, test/check/ktlint 통과)** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt index 88ed2146..e7592698 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepository.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.live.recommend.QRecommendLiveCreatorBanner.recom import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository @@ -20,7 +21,7 @@ class LiveRecommendRepository( private val cloudFrontHost: String ) { fun getRecommendLive( - isBlocked: (Long) -> Boolean, + memberId: Long?, isAdult: Boolean ): List { val dateNow = LocalDateTime.now() @@ -32,7 +33,7 @@ class LiveRecommendRepository( where = where.and(recommendLiveCreatorBanner.isAdult.isFalse) } - return queryFactory + var select = queryFactory .select( Projections.constructor( GetRecommendLiveResponse::class.java, @@ -41,12 +42,26 @@ class LiveRecommendRepository( ) ) .from(recommendLiveCreatorBanner) + + if (memberId != null) { + val blockMemberCondition = blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(recommendLiveCreatorBanner.creator.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(recommendLiveCreatorBanner.creator.id)) + ) + ) + + where = where.and(blockMember.id.isNull) + select = select.leftJoin(blockMember).on(blockMemberCondition) + } + + return select .where(where) .orderBy(recommendLiveCreatorBanner.orders.asc()) .fetch() - .asSequence() - .filter { !isBlocked(it.creatorId) } - .toList() } fun getOnAirRecommendChannelList( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt index a68d896e..66616436 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendService.kt @@ -16,17 +16,11 @@ class LiveRecommendService( @Transactional(readOnly = true) @Cacheable( cacheNames = ["cache_ttl_3_hours"], - key = "'getRecommendLive:' + (#member ?: 'guest')" + key = "'getRecommendLive:' + (#member?.id ?: 'guest')" ) fun getRecommendLive(member: Member?): List { return repository.getRecommendLive( - isBlocked = { - if (member != null) { - isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) - } else { - false - } - }, + memberId = member?.id, isAdult = member?.auth != null ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index feaf92d5..0fb4228b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -54,6 +54,7 @@ import kr.co.vividnext.sodalive.point.MemberPointRepository import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generatePassword import org.springframework.beans.factory.annotation.Value +import org.springframework.cache.CacheManager import org.springframework.data.repository.findByIdOrNull import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder @@ -107,6 +108,7 @@ class MemberService( private val countryContext: CountryContext, private val objectMapper: ObjectMapper, + private val cacheManager: CacheManager, @Value("\${cloud.aws.s3.bucket}") private val s3Bucket: String, @@ -117,6 +119,8 @@ class MemberService( private val tokenLocks: MutableMap = mutableMapOf() + private val recommendLiveCacheKeyPrefix = "getRecommendLive:" + @Transactional fun signUpV2(request: SignUpRequestV2): SignUpResponse { val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID) @@ -558,6 +562,9 @@ class MemberService( blockMember.isActive = true } } + + evictRecommendLiveCache(memberId) + blockTargetMemberIds.forEach { evictRecommendLiveCache(it) } } @Transactional @@ -570,6 +577,9 @@ class MemberService( if (blockMember != null) { blockMember.isActive = false } + + evictRecommendLiveCache(memberId) + evictRecommendLiveCache(request.blockMemberId) } fun isBlocked(blockedMemberId: Long, memberId: Long) = blockMemberRepository.isBlocked(blockedMemberId, memberId) @@ -829,6 +839,10 @@ class MemberService( return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() } } + private fun evictRecommendLiveCache(memberId: Long) { + cacheManager.getCache("cache_ttl_3_hours")?.evict(recommendLiveCacheKeyPrefix + memberId) + } + @Transactional fun updateMarketingInfo(memberId: Long, adid: String, pid: String): String? { val member = repository.findByIdOrNull(id = memberId) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt new file mode 100644 index 00000000..bdd50ee4 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendRepositoryTest.kt @@ -0,0 +1,104 @@ +package kr.co.vividnext.sodalive.live.recommend + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@Import(QueryDslConfig::class) +class LiveRecommendRepositoryTest @Autowired constructor( + private val queryFactory: JPAQueryFactory, + private val memberRepository: MemberRepository, + private val blockMemberRepository: BlockMemberRepository, + private val recommendLiveCreatorBannerRepository: RecommendLiveCreatorBannerRepository, + private val entityManager: EntityManager +) { + private lateinit var liveRecommendRepository: LiveRecommendRepository + + @BeforeEach + fun setup() { + liveRecommendRepository = LiveRecommendRepository(queryFactory, "https://cdn.test") + } + + @Test + fun shouldExcludeBlockedCreatorsInBothDirections() { + val viewer = saveMember(nickname = "viewer", role = MemberRole.USER) + val creatorBlockedByViewer = saveMember(nickname = "creator-blocked-by-viewer", role = MemberRole.CREATOR) + val creatorBlockingViewer = saveMember(nickname = "creator-blocking-viewer", role = MemberRole.CREATOR) + val creatorAllowed = saveMember(nickname = "creator-allowed", role = MemberRole.CREATOR) + + saveBanner(creator = creatorBlockedByViewer, order = 1) + saveBanner(creator = creatorBlockingViewer, order = 2) + saveBanner(creator = creatorAllowed, order = 3) + + saveBlock(member = viewer, blockedMember = creatorBlockedByViewer, isActive = true) + saveBlock(member = creatorBlockingViewer, blockedMember = viewer, isActive = true) + + entityManager.flush() + entityManager.clear() + + val result = liveRecommendRepository.getRecommendLive(memberId = viewer.id, isAdult = true) + + assertEquals(1, result.size) + assertEquals(creatorAllowed.id, result[0].creatorId) + } + + @Test + fun shouldKeepCreatorWhenBlockRelationIsInactive() { + val viewer = saveMember(nickname = "viewer-inactive", role = MemberRole.USER) + val creator = saveMember(nickname = "creator-inactive", role = MemberRole.CREATOR) + + saveBanner(creator = creator, order = 1) + saveBlock(member = viewer, blockedMember = creator, isActive = false) + + entityManager.flush() + entityManager.clear() + + val result = liveRecommendRepository.getRecommendLive(memberId = viewer.id, isAdult = true) + + assertEquals(1, result.size) + assertEquals(creator.id, result[0].creatorId) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + ) + } + + private fun saveBanner(creator: Member, order: Int) { + val banner = RecommendLiveCreatorBanner( + startDate = LocalDateTime.now().minusDays(1), + endDate = LocalDateTime.now().plusDays(1), + isAdult = false, + orders = order, + image = "recommend/$order.png" + ) + banner.creator = creator + recommendLiveCreatorBannerRepository.saveAndFlush(banner) + } + + private fun saveBlock(member: Member, blockedMember: Member, isActive: Boolean) { + val block = BlockMember(isActive = isActive) + block.member = member + block.blockedMember = blockedMember + blockMemberRepository.saveAndFlush(block) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt new file mode 100644 index 00000000..126bd3e9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendServiceTest.kt @@ -0,0 +1,62 @@ +package kr.co.vividnext.sodalive.live.recommend + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.auth.Auth +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito + +class LiveRecommendServiceTest { + private lateinit var repository: LiveRecommendRepository + private lateinit var blockMemberRepository: BlockMemberRepository + private lateinit var service: LiveRecommendService + + @BeforeEach + fun setup() { + repository = Mockito.mock(LiveRecommendRepository::class.java) + blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java) + service = LiveRecommendService(repository, blockMemberRepository) + } + + @Test + fun shouldDelegateToRepositoryWithAdultFlagWhenMemberIsAuthenticated() { + val member = Member( + email = "member@test.com", + password = "password", + nickname = "member" + ) + member.id = 10L + + val auth = Auth( + name = "name", + birth = "19900101", + uniqueCi = "ci", + di = "di", + gender = 1 + ) + auth.member = member + + val expected = listOf(GetRecommendLiveResponse(imageUrl = "https://cdn.test/recommend.png", creatorId = 77L)) + Mockito.`when`(repository.getRecommendLive(memberId = member.id, isAdult = true)).thenReturn(expected) + + val result = service.getRecommendLive(member) + + assertEquals(expected, result) + Mockito.verify(repository).getRecommendLive(memberId = member.id, isAdult = true) + Mockito.verifyNoInteractions(blockMemberRepository) + } + + @Test + fun shouldDelegateToRepositoryAsGuestWhenMemberIsNull() { + val expected = listOf(GetRecommendLiveResponse(imageUrl = "https://cdn.test/recommend-guest.png", creatorId = 88L)) + Mockito.`when`(repository.getRecommendLive(memberId = null, isAdult = false)).thenReturn(expected) + + val result = service.getRecommendLive(null) + + assertEquals(expected, result) + Mockito.verify(repository).getRecommendLive(memberId = null, isAdult = false) + Mockito.verifyNoInteractions(blockMemberRepository) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt new file mode 100644 index 00000000..134ca3d3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceCacheEvictionTest.kt @@ -0,0 +1,128 @@ +package kr.co.vividnext.sodalive.member + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.auth.AuthRepository +import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.member.block.MemberBlockRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.cache.Cache +import org.springframework.cache.CacheManager +import java.util.Optional + +class MemberServiceCacheEvictionTest { + private lateinit var memberRepository: MemberRepository + private lateinit var blockMemberRepository: BlockMemberRepository + private lateinit var authRepository: AuthRepository + private lateinit var cacheManager: CacheManager + private lateinit var cache: Cache + private lateinit var service: MemberService + + @BeforeEach + fun setup() { + memberRepository = mock() + blockMemberRepository = mock() + authRepository = mock() + cacheManager = mock() + cache = mock() + + Mockito.`when`(cacheManager.getCache("cache_ttl_3_hours")).thenReturn(cache) + + service = MemberService( + repository = memberRepository, + tokenRepository = mock(), + stipulationRepository = mock(), + stipulationAgreeRepository = mock(), + creatorFollowingRepository = mock(), + blockMemberRepository = blockMemberRepository, + authRepository = authRepository, + signOutRepository = mock(), + nicknameChangeLogRepository = mock(), + memberTagRepository = mock(), + liveReservationRepository = mock(), + chargeRepository = mock(), + memberPointRepository = mock(), + orderService = mock(), + emailService = mock(), + pushTokenService = mock(), + canPaymentService = mock(), + nicknameGenerateService = mock(), + memberNotificationService = mock(), + s3Uploader = mock(), + validator = mock(), + tokenProvider = mock(), + passwordEncoder = mock(), + authenticationManagerBuilder = mock(), + messageSource = SodaMessageSource(), + langContext = LangContext(), + countryContext = CountryContext(), + objectMapper = ObjectMapper(), + cacheManager = cacheManager, + s3Bucket = "test-bucket", + cloudFrontHost = "https://cdn.test" + ) + } + + @Test + fun shouldEvictRecommendLiveCacheForRequesterAndTargetOnBlock() { + val memberId = 100L + val blockedMemberId = 200L + val member = createMember(id = memberId, nickname = "requester") + val blockedMember = createMember(id = blockedMemberId, nickname = "target") + + Mockito.`when`(memberRepository.findById(memberId)).thenReturn(Optional.of(member)) + Mockito.`when`(memberRepository.findById(blockedMemberId)).thenReturn(Optional.of(blockedMember)) + Mockito.`when`( + blockMemberRepository.getBlockAccount( + blockedMemberId = blockedMemberId, + memberId = memberId + ) + ).thenReturn(null) + + service.memberBlock(MemberBlockRequest(blockMemberId = blockedMemberId), memberId) + + Mockito.verify(cache).evict("getRecommendLive:$memberId") + Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId") + Mockito.verifyNoInteractions(authRepository) + } + + @Test + fun shouldEvictRecommendLiveCacheForRequesterAndTargetOnUnblock() { + val memberId = 300L + val blockedMemberId = 400L + val blockMember = BlockMember(isActive = true) + + Mockito.`when`( + blockMemberRepository.getBlockAccount( + blockedMemberId = blockedMemberId, + memberId = memberId + ) + ).thenReturn(blockMember) + + service.memberUnBlock(MemberBlockRequest(blockMemberId = blockedMemberId), memberId) + + assertEquals(false, blockMember.isActive) + Mockito.verify(cache).evict("getRecommendLive:$memberId") + Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId") + } + + private fun createMember(id: Long, nickname: String): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname + ) + member.id = id + return member + } + + private inline fun mock(): T { + return Mockito.mock(T::class.java) + } +}