fix(live-room): 최근 종료 라이브 조회와 캐시 무효화를 최적화한다

This commit is contained in:
2026-02-27 14:42:29 +09:00
parent a85bc67f7a
commit 3e4e23eb73
5 changed files with 71 additions and 38 deletions

View File

@@ -0,0 +1,17 @@
# 최근 종료 라이브(getLatestFinishedLive) 최적화
- [x] `getLatestFinishedLive` 조회를 DB 단계에서 차단 관계(`left join`)로 필터링하도록 변경
- [x] 조회 결과를 `GetLatestFinishedLiveResponse`로 QueryProjection 하여 서비스 단 추가 `map` 제거
- [x] 회원 차단(`memberBlock`) / 차단해제(`memberUnBlock`) 시 최근 종료 라이브 캐시 무효화 적용
- [x] 정적 진단 및 테스트/빌드 검증 수행
- [x] 검증 기록 작성
## 검증 기록
### 1차 구현
- 무엇을: `getLatestFinishedLive`를 서비스 후처리(`filter`/`map`)에서 제거하고, `LiveRoomRepository`에서 `leftJoin(blockMember)` + `blockMember.id.isNull` 조건으로 차단 관계를 DB 단계에서 제외하도록 변경했다. 또한 `GetLatestFinishedLiveResponse``@QueryProjection` 생성자를 추가해 쿼리 결과를 응답 DTO로 바로 생성했다. 마지막으로 `memberBlock`/`memberUnBlock`에서 `getLatestFinishedLive:{memberId}` 캐시를 즉시 evict 하도록 반영했다.
- 왜: 기존 로직은 조회 후 애플리케이션 레벨에서 차단 여부를 반복 조회하고 별도 `map`을 수행해 비용이 컸고, 차단/차단해제 직후 최근 종료 라이브 캐시가 TTL 만료 전까지 stale 상태가 될 수 있어 DB 레벨 필터링 및 이벤트성 캐시 무효화가 필요했다.
- 어떻게:
- `lsp_diagnostics` (대상: `GetLatestFinishedLiveResponse.kt`, `LiveRoomRepository.kt`, `LiveRoomService.kt`, `MemberService.kt`) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가**
- `./gradlew test && ./gradlew build` 실행 결과: **성공 (BUILD SUCCESSFUL)**
- `./gradlew tasks --all` 실행 결과: **성공 (BUILD SUCCESSFUL)**

View File

@@ -6,13 +6,6 @@ import kr.co.vividnext.sodalive.extensions.getTimeAgoString
import java.time.LocalDateTime
import java.time.ZoneId
data class GetLatestFinishedLiveQueryResponse @QueryProjection constructor(
val memberId: Long,
val nickname: String,
val profileImageUrl: String,
val updatedAt: LocalDateTime
)
data class GetLatestFinishedLiveResponse(
@JsonProperty("memberId") val memberId: Long,
@JsonProperty("nickname") val nickname: String,
@@ -20,12 +13,18 @@ data class GetLatestFinishedLiveResponse(
@JsonProperty("timeAgo") val timeAgo: String,
@JsonProperty("dateUtc") val dateUtc: String
) {
constructor(response: GetLatestFinishedLiveQueryResponse) : this(
response.memberId,
response.nickname,
response.profileImageUrl,
response.updatedAt.getTimeAgoString(),
response.updatedAt
@QueryProjection
constructor(
memberId: Long,
nickname: String,
profileImageUrl: String,
updatedAt: LocalDateTime
) : this(
memberId,
nickname,
profileImageUrl,
updatedAt.getTimeAgoString(),
updatedAt
.atZone(ZoneId.of("UTC"))
.toInstant()
.toString()

View File

@@ -66,7 +66,7 @@ interface LiveRoomQueryRepository {
fun getTotalHeartCount(roomId: Long): Int?
fun getLiveRoomCreatorId(roomId: Long): Long?
fun getHeartList(roomId: Long): List<GetLiveRoomHeartListItem>
fun getLatestFinishedLive(): List<GetLatestFinishedLiveQueryResponse>
fun getLatestFinishedLive(memberId: Long?): List<GetLatestFinishedLiveResponse>
}
class LiveRoomQueryRepositoryImpl(
@@ -486,10 +486,16 @@ class LiveRoomQueryRepositoryImpl(
.fetch()
}
override fun getLatestFinishedLive(): List<GetLatestFinishedLiveQueryResponse> {
return queryFactory
override fun getLatestFinishedLive(memberId: Long?): List<GetLatestFinishedLiveResponse> {
var where = liveRoom.isActive.isFalse
.and(liveRoom.channelName.isNotNull)
.and(liveRoom.updatedAt.goe(LocalDateTime.now().minusWeeks(1)))
.and(member.isActive.isTrue)
.and(member.role.eq(MemberRole.CREATOR))
var select = queryFactory
.select(
QGetLatestFinishedLiveQueryResponse(
QGetLatestFinishedLiveResponse(
member.id,
member.nickname,
member.profileImage.prepend("/").prepend(cloudFrontHost),
@@ -498,13 +504,24 @@ class LiveRoomQueryRepositoryImpl(
)
.from(liveRoom)
.innerJoin(liveRoom.member, member)
.where(
liveRoom.isActive.isFalse
.and(liveRoom.channelName.isNotNull)
.and(liveRoom.updatedAt.goe(LocalDateTime.now().minusWeeks(1)))
.and(member.isActive.isTrue)
.and(member.role.eq(MemberRole.CREATOR))
)
if (memberId != null) {
val blockMemberCondition = blockMember.isActive.isTrue
.and(
blockMember.member.id.eq(member.id)
.and(blockMember.blockedMember.id.eq(memberId))
.or(
blockMember.member.id.eq(memberId)
.and(blockMember.blockedMember.id.eq(member.id))
)
)
select = select.leftJoin(blockMember).on(blockMemberCondition)
where = where.and(blockMember.id.isNull)
}
return select
.where(where)
.groupBy(member.id)
.orderBy(liveRoom.updatedAt.max().desc())
.limit(20)

View File

@@ -1444,20 +1444,9 @@ class LiveRoomService(
@Transactional(readOnly = true)
@Cacheable(
cacheNames = ["cache_ttl_10_minutes"],
key = "'getLatestFinishedLive:' + (#member ?: 'guest')"
key = "'getLatestFinishedLive:' + (#member?.id ?: 'guest')"
)
fun getLatestFinishedLive(member: Member?): List<GetLatestFinishedLiveResponse> {
return repository.getLatestFinishedLive()
.filter {
if (member?.id != null) {
!blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.memberId) &&
!blockMemberRepository.isBlocked(blockedMemberId = it.memberId, memberId = member.id!!)
} else {
true
}
}
.map {
GetLatestFinishedLiveResponse(response = it)
}
return repository.getLatestFinishedLive(memberId = member?.id)
}
}

View File

@@ -120,6 +120,7 @@ class MemberService(
private val tokenLocks: MutableMap<Long, ReentrantReadWriteLock> = mutableMapOf()
private val recommendLiveCacheKeyPrefix = "getRecommendLive:"
private val latestFinishedLiveCacheKeyPrefix = "getLatestFinishedLive:"
@Transactional
fun signUpV2(request: SignUpRequestV2): SignUpResponse {
@@ -564,7 +565,11 @@ class MemberService(
}
evictRecommendLiveCache(memberId)
blockTargetMemberIds.forEach { evictRecommendLiveCache(it) }
evictLatestFinishedLiveCache(memberId)
blockTargetMemberIds.forEach {
evictRecommendLiveCache(it)
evictLatestFinishedLiveCache(it)
}
}
@Transactional
@@ -580,6 +585,8 @@ class MemberService(
evictRecommendLiveCache(memberId)
evictRecommendLiveCache(request.blockMemberId)
evictLatestFinishedLiveCache(memberId)
evictLatestFinishedLiveCache(request.blockMemberId)
}
fun isBlocked(blockedMemberId: Long, memberId: Long) = blockMemberRepository.isBlocked(blockedMemberId, memberId)
@@ -843,6 +850,10 @@ class MemberService(
cacheManager.getCache("cache_ttl_3_hours")?.evict(recommendLiveCacheKeyPrefix + memberId)
}
private fun evictLatestFinishedLiveCache(memberId: Long) {
cacheManager.getCache("cache_ttl_10_minutes")?.evict(latestFinishedLiveCacheKeyPrefix + memberId)
}
@Transactional
fun updateMarketingInfo(memberId: Long, adid: String, pid: String): String? {
val member = repository.findByIdOrNull(id = memberId)