From 3e4e23eb7319a54265f700a0c766de683f3ebcae Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 27 Feb 2026 14:42:29 +0900 Subject: [PATCH] =?UTF-8?q?fix(live-room):=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=99=80=20=EC=BA=90=EC=8B=9C=20=EB=AC=B4=ED=9A=A8?= =?UTF-8?q?=ED=99=94=EB=A5=BC=20=EC=B5=9C=EC=A0=81=ED=99=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260227_최근종료라이브최적화.md | 17 ++++++++ .../room/GetLatestFinishedLiveResponse.kt | 25 ++++++------ .../sodalive/live/room/LiveRoomRepository.kt | 39 +++++++++++++------ .../sodalive/live/room/LiveRoomService.kt | 15 +------ .../sodalive/member/MemberService.kt | 13 ++++++- 5 files changed, 71 insertions(+), 38 deletions(-) create mode 100644 docs/20260227_최근종료라이브최적화.md diff --git a/docs/20260227_최근종료라이브최적화.md b/docs/20260227_최근종료라이브최적화.md new file mode 100644 index 00000000..89ff4bf2 --- /dev/null +++ b/docs/20260227_최근종료라이브최적화.md @@ -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)** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLatestFinishedLiveResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLatestFinishedLiveResponse.kt index 70251422..f65e7d0a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLatestFinishedLiveResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLatestFinishedLiveResponse.kt @@ -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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt index 552a2360..f7fa5f88 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt @@ -66,7 +66,7 @@ interface LiveRoomQueryRepository { fun getTotalHeartCount(roomId: Long): Int? fun getLiveRoomCreatorId(roomId: Long): Long? fun getHeartList(roomId: Long): List - fun getLatestFinishedLive(): List + fun getLatestFinishedLive(memberId: Long?): List } class LiveRoomQueryRepositoryImpl( @@ -486,10 +486,16 @@ class LiveRoomQueryRepositoryImpl( .fetch() } - override fun getLatestFinishedLive(): List { - return queryFactory + override fun getLatestFinishedLive(memberId: Long?): List { + 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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 04a230f7..d5d0e574 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -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 { - 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) } } 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 0fb4228b..4b34411e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -120,6 +120,7 @@ class MemberService( private val tokenLocks: MutableMap = 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)