fix(recommend-live): 차단 관계를 추천 조회에 반영하고 캐시를 무효화한다

This commit is contained in:
2026-02-26 03:33:09 +09:00
parent e7252574d2
commit dd9cd788ca
8 changed files with 368 additions and 13 deletions

View File

@@ -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 포함)**

View File

@@ -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 통과)**

View File

@@ -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.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member 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 kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -20,7 +21,7 @@ class LiveRecommendRepository(
private val cloudFrontHost: String private val cloudFrontHost: String
) { ) {
fun getRecommendLive( fun getRecommendLive(
isBlocked: (Long) -> Boolean, memberId: Long?,
isAdult: Boolean isAdult: Boolean
): List<GetRecommendLiveResponse> { ): List<GetRecommendLiveResponse> {
val dateNow = LocalDateTime.now() val dateNow = LocalDateTime.now()
@@ -32,7 +33,7 @@ class LiveRecommendRepository(
where = where.and(recommendLiveCreatorBanner.isAdult.isFalse) where = where.and(recommendLiveCreatorBanner.isAdult.isFalse)
} }
return queryFactory var select = queryFactory
.select( .select(
Projections.constructor( Projections.constructor(
GetRecommendLiveResponse::class.java, GetRecommendLiveResponse::class.java,
@@ -41,12 +42,26 @@ class LiveRecommendRepository(
) )
) )
.from(recommendLiveCreatorBanner) .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) .where(where)
.orderBy(recommendLiveCreatorBanner.orders.asc()) .orderBy(recommendLiveCreatorBanner.orders.asc())
.fetch() .fetch()
.asSequence()
.filter { !isBlocked(it.creatorId) }
.toList()
} }
fun getOnAirRecommendChannelList( fun getOnAirRecommendChannelList(

View File

@@ -16,17 +16,11 @@ class LiveRecommendService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
@Cacheable( @Cacheable(
cacheNames = ["cache_ttl_3_hours"], cacheNames = ["cache_ttl_3_hours"],
key = "'getRecommendLive:' + (#member ?: 'guest')" key = "'getRecommendLive:' + (#member?.id ?: 'guest')"
) )
fun getRecommendLive(member: Member?): List<GetRecommendLiveResponse> { fun getRecommendLive(member: Member?): List<GetRecommendLiveResponse> {
return repository.getRecommendLive( return repository.getRecommendLive(
isBlocked = { memberId = member?.id,
if (member != null) {
isBlockedBetweenMembers(memberId = member.id!!, creatorId = it)
} else {
false
}
},
isAdult = member?.auth != null isAdult = member?.auth != null
) )
} }

View File

@@ -54,6 +54,7 @@ import kr.co.vividnext.sodalive.point.MemberPointRepository
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.utils.generatePassword import kr.co.vividnext.sodalive.utils.generatePassword
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.CacheManager
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
@@ -107,6 +108,7 @@ class MemberService(
private val countryContext: CountryContext, private val countryContext: CountryContext,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val cacheManager: CacheManager,
@Value("\${cloud.aws.s3.bucket}") @Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String, private val s3Bucket: String,
@@ -117,6 +119,8 @@ class MemberService(
private val tokenLocks: MutableMap<Long, ReentrantReadWriteLock> = mutableMapOf() private val tokenLocks: MutableMap<Long, ReentrantReadWriteLock> = mutableMapOf()
private val recommendLiveCacheKeyPrefix = "getRecommendLive:"
@Transactional @Transactional
fun signUpV2(request: SignUpRequestV2): SignUpResponse { fun signUpV2(request: SignUpRequestV2): SignUpResponse {
val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID) val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID)
@@ -558,6 +562,9 @@ class MemberService(
blockMember.isActive = true blockMember.isActive = true
} }
} }
evictRecommendLiveCache(memberId)
blockTargetMemberIds.forEach { evictRecommendLiveCache(it) }
} }
@Transactional @Transactional
@@ -570,6 +577,9 @@ class MemberService(
if (blockMember != null) { if (blockMember != null) {
blockMember.isActive = false blockMember.isActive = false
} }
evictRecommendLiveCache(memberId)
evictRecommendLiveCache(request.blockMemberId)
} }
fun isBlocked(blockedMemberId: Long, memberId: Long) = blockMemberRepository.isBlocked(blockedMemberId, memberId) fun isBlocked(blockedMemberId: Long, memberId: Long) = blockMemberRepository.isBlocked(blockedMemberId, memberId)
@@ -829,6 +839,10 @@ class MemberService(
return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() } return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() }
} }
private fun evictRecommendLiveCache(memberId: Long) {
cacheManager.getCache("cache_ttl_3_hours")?.evict(recommendLiveCacheKeyPrefix + memberId)
}
@Transactional @Transactional
fun updateMarketingInfo(memberId: Long, adid: String, pid: String): String? { fun updateMarketingInfo(memberId: Long, adid: String, pid: String): String? {
val member = repository.findByIdOrNull(id = memberId) val member = repository.findByIdOrNull(id = memberId)

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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 <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}