From 6c252ee008a60b46ae4abb81409ef8fe46ed01db Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 19 Jun 2026 05:35:53 +0900 Subject: [PATCH] =?UTF-8?q?fix(user-creator-chat):=20Redis=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20=EC=98=88=EC=99=B8=20fallback=20=EB=B2=94=EC=9C=84?= =?UTF-8?q?=EB=A5=BC=20=EC=A2=81=ED=9E=8C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 9 ++ .../service/UserCreatorChatService.kt | 55 +++++++----- .../UserCreatorChatServiceTest.kt | 86 +++++++++++++++++++ 3 files changed, 130 insertions(+), 20 deletions(-) diff --git a/docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md b/docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md index 03efcd51..ab354354 100644 --- a/docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md +++ b/docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md @@ -803,6 +803,15 @@ spring: - Fresh lint Result: `BUILD SUCCESSFUL in 7s`. - Fresh 전체 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process` - Fresh 전체 검증 Result: `BUILD SUCCESSFUL in 1m 52s`. +- 잔여 리스크 개선: + - 대상: `UserCreatorChatService.deliverRealtime` + - 무엇: Redis/WebSocket 전달 경계의 fail-open 처리 범위를 전체 `Exception`에서 Redis 접근 예외인 `DataAccessException`으로 좁히고, Redis 오류는 warn 로그를 남긴 뒤 푸시 발송으로 fail-open 하도록 정리했다. Redis 계층이 아닌 broker 예외는 숨기지 않고 전파한다. + - 왜: Redis 장애 시 메시지 저장 후 푸시 발송 요구사항은 유지하되, 프로그래밍 오류나 예상하지 못한 런타임 오류까지 푸시 fallback으로 숨기지 않기 위해서다. + - RED: `UserCreatorChatServiceTest.shouldPropagateNonRedisBrokerExceptionDuringVoiceMessage`를 추가했다. 기존 `runCatching` 구현에서는 `IllegalStateException`이 전파되지 않아 `AssertionFailedError`로 실패했다. + - GREEN: `DataAccessException`만 catch하도록 수정한 뒤 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`가 `BUILD SUCCESSFUL in 3m 33s`로 통과했다. + - 인접 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.*'`가 `BUILD SUCCESSFUL in 38s`로 통과했다. + - Lint: import 정렬 수정 후 `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 21s`로 통과했다. + - 전체 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false`가 `BUILD SUCCESSFUL in 4m 39s`로 통과했다. - Phase 3: - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest` - RED Result: 테스트 파일 부재 상태에서 `No tests found for given includes`로 실패했다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt index c1d6206c..dcc3d77c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt @@ -29,8 +29,10 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoo import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceService import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageType +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher +import org.springframework.dao.DataAccessException import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -119,14 +121,8 @@ class UserCreatorChatService( val message = saveTextMessage(context, textMessage) val senderMessage = toMessageItemDto(message, sender) val opponent = context.opponentParticipant.member - if (presenceService.hasPresence(roomId, opponent.id!!)) { - val opponentMessage = toMessageItemDto(message, opponent) - roomMessageBroker.publish( - roomId = roomId, - memberId = opponent.id!!, - payload = websocketMessagePayload(UserCreatorChatWebSocketMessageType.MESSAGE, roomId, opponentMessage) - ) - } else { + val deliveredRealtime = deliverRealtime(message, opponent) + if (!deliveredRealtime) { publishMessagePush(message, sender, opponent) } return senderMessage @@ -190,18 +186,8 @@ class UserCreatorChatService( ): SendUserCreatorChatMessageResponse { val opponent = opponentParticipant.member val item = toMessageItemDto(message, member) - val opponentPresent = presenceService.hasPresence(message.chatRoom.id!!, opponent.id!!) - if (opponentPresent) { - val opponentMessage = toMessageItemDto(message, opponent) - roomMessageBroker.publish( - roomId = message.chatRoom.id!!, - memberId = opponent.id!!, - payload = websocketMessagePayload( - UserCreatorChatWebSocketMessageType.MESSAGE, - message.chatRoom.id!!, - opponentMessage - ) - ) + val deliveredRealtime = deliverRealtime(message, opponent) + if (deliveredRealtime) { return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = true, pushSent = false) } @@ -209,6 +195,31 @@ class UserCreatorChatService( return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = false, pushSent = true) } + private fun deliverRealtime(message: UserCreatorChatMessage, opponent: Member): Boolean { + val roomId = message.chatRoom.id!! + val opponentId = opponent.id!! + return try { + if (!presenceService.hasPresence(roomId, opponentId)) { + return false + } + val opponentMessage = toMessageItemDto(message, opponent) + roomMessageBroker.publish( + roomId = roomId, + memberId = opponentId, + payload = websocketMessagePayload(UserCreatorChatWebSocketMessageType.MESSAGE, roomId, opponentMessage) + ) + true + } catch (e: DataAccessException) { + logger.warn( + "유저-크리에이터 채팅 실시간 전달 Redis 오류로 푸시 fail-open 처리: roomId={}, opponentId={}, cause={}", + roomId, + opponentId, + e.message + ) + false + } + } + private fun publishMessagePush(message: UserCreatorChatMessage, sender: Member, opponent: Member) { val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) { "message.fcm.voice_received" @@ -287,4 +298,8 @@ class UserCreatorChatService( val senderParticipant: UserCreatorChatParticipant, val opponentParticipant: UserCreatorChatParticipant ) + + companion object { + private val logger = LoggerFactory.getLogger(UserCreatorChatService::class.java) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt index 0464b044..41434029 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt @@ -18,6 +18,7 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPres import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -25,6 +26,7 @@ import org.junit.jupiter.api.Test import org.mockito.ArgumentCaptor import org.mockito.Mockito import org.springframework.context.ApplicationEventPublisher +import org.springframework.dao.DataAccessResourceFailureException import org.springframework.data.domain.PageRequest import org.springframework.mock.web.MockMultipartFile import java.io.ByteArrayInputStream @@ -204,6 +206,33 @@ class UserCreatorChatServiceTest { Mockito.verifyNoInteractions(roomMessageBroker) } + @Test + @DisplayName("WebSocket 텍스트 전송은 Redis presence 확인 실패 시 메시지를 저장하고 푸시 이벤트를 발행한다") + fun shouldPublishPushEventWhenPresenceCheckFailsDuringWebSocketTextMessage() { + val user = member(1L, "user") + val creator = member(2L, "creator") + val room = room(10L) + val senderParticipant = participant(100L, room, user) + val recipientParticipant = participant(101L, room, creator) + Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) + Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant) + Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant) + Mockito.`when`(presenceService.hasPresence(10L, 2L)) + .thenThrow(DataAccessResourceFailureException("redis down")) + Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 207L } + } + + val response = service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello") + + assertEquals(207L, response.messageId) + val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java) + Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) + assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type) + assertEquals(listOf(2L), eventCaptor.value.recipients) + Mockito.verifyNoInteractions(roomMessageBroker) + } + @Test @DisplayName("음성 메시지 REST 전송은 상대방 presence가 있으면 WebSocket broker로 MESSAGE를 발행하고 푸시를 보내지 않는다") fun shouldPublishVoiceMessageToWebSocketWhenOpponentPresenceExists() { @@ -266,6 +295,63 @@ class UserCreatorChatServiceTest { Mockito.verifyNoInteractions(roomMessageBroker) } + @Test + @DisplayName("음성 메시지 REST 전송은 Redis broker 발행 실패 시 푸시 이벤트를 발행한다") + fun shouldPublishPushEventWhenBrokerPublishFailsDuringVoiceMessage() { + val user = member(1L, "user") + val creator = member(2L, "creator") + val room = room(10L) + val senderParticipant = participant(100L, room, user) + val recipientParticipant = participant(101L, room, creator) + Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) + Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant) + Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant) + Mockito.`when`(presenceService.hasPresence(10L, 2L)).thenReturn(true) + Mockito.doThrow(DataAccessResourceFailureException("redis publish down")) + .`when`(roomMessageBroker) + .publish(Mockito.eq(10L), Mockito.eq(2L), Mockito.anyString()) + Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 208L } + } + givenVoiceUploadReturns("voice/208.m4a") + + val response = service.sendVoiceMessage(user, 10L, voiceFile(), "{}") + + assertEquals(208L, response.message.messageId) + assertFalse(response.deliveredRealtime) + assertTrue(response.pushSent) + val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java) + Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) + assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type) + assertEquals(listOf(2L), eventCaptor.value.recipients) + } + + @Test + @DisplayName("음성 메시지 REST 전송은 Redis 계층이 아닌 broker 예외를 푸시로 숨기지 않는다") + fun shouldPropagateNonRedisBrokerExceptionDuringVoiceMessage() { + val user = member(1L, "user") + val creator = member(2L, "creator") + val room = room(10L) + val senderParticipant = participant(100L, room, user) + val recipientParticipant = participant(101L, room, creator) + Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) + Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant) + Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant) + Mockito.`when`(presenceService.hasPresence(10L, 2L)).thenReturn(true) + Mockito.doThrow(IllegalStateException("programming error")) + .`when`(roomMessageBroker) + .publish(Mockito.eq(10L), Mockito.eq(2L), Mockito.anyString()) + Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 209L } + } + givenVoiceUploadReturns("voice/209.m4a") + + assertThrows(IllegalStateException::class.java) { + service.sendVoiceMessage(user, 10L, voiceFile(), "{}") + } + Mockito.verifyNoInteractions(eventPublisher) + } + @Test @DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다") fun shouldGetPreviousMessagesWithDefaultLimitWhenCursorExists() {