diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt index 4c1b7e7c..511a843d 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt @@ -41,7 +41,6 @@ class DmChatRoomViewModel( private var hasMore: Boolean = false private var nextCursor: Long? = null private var isLoadingOlder: Boolean = false - private var isSending: Boolean = false private var isRealtimeJoining: Boolean = false private var isRealtimeConnected: Boolean = false private var shouldReconnectRealtime: Boolean = false @@ -50,6 +49,9 @@ class DmChatRoomViewModel( private var currentRealtimeRoomId: Long = 0L private var reconnectDisposable: Disposable? = null private var localMessageSequence: Long = 0L + private var requestSequence: Long = 0L + private val pendingRequestLocalIds = mutableMapOf() + private val pendingTimeoutDisposables = mutableMapOf() private val mainHandler = Handler(Looper.getMainLooper()) private val _chatRoomStateLiveData = MutableLiveData() @@ -105,12 +107,14 @@ class DmChatRoomViewModel( fun sendText(text: String) { val trimmed = text.trim() - if (trimmed.isBlank() || currentRoomId <= 0L || isSending) return + if (trimmed.isBlank() || currentRoomId <= 0L) return val localId = nextLocalId() + val requestId = nextRequestId() val localItem = DmChatMessageUiItem( messageId = null, localId = localId, + requestId = requestId, mine = true, textMessage = trimmed, senderNickname = "", @@ -119,25 +123,28 @@ class DmChatRoomViewModel( status = DmChatMessageStatus.SENDING ) currentMessages = currentMessages + localItem - isSending = true + pendingRequestLocalIds[requestId] = localId + schedulePendingTimeout(requestId) emitContent() - sendLocalMessage(localId = localId, text = trimmed) + sendLocalMessage(requestId = requestId, text = trimmed) } fun retry(localId: String) { val failedItem = currentMessages.firstOrNull { it.localId == localId && it.status == DmChatMessageStatus.FAILED } ?: return - if (isSending || currentRoomId <= 0L) return + if (currentRoomId <= 0L) return + val requestId = nextRequestId() currentMessages = currentMessages.map { - if (it.localId == localId) it.copy(status = DmChatMessageStatus.SENDING) else it + if (it.localId == localId) it.copy(requestId = requestId, status = DmChatMessageStatus.SENDING) else it } - isSending = true + pendingRequestLocalIds[requestId] = localId + schedulePendingTimeout(requestId) emitContent() - sendLocalMessage(localId = localId, text = failedItem.textMessage) + sendLocalMessage(requestId = requestId, text = failedItem.textMessage) } fun onRealtimeMessage(message: DmChatMessageResponse) { @@ -294,13 +301,24 @@ class DmChatRoomViewModel( ) } - private fun sendLocalMessage(localId: String, text: String) { + private fun sendLocalMessage(requestId: String, text: String) { val sent = repository.sendSocketText( roomId = currentRoomId, - requestId = localId, + requestId = requestId, textMessage = text ) - if (!sent) markLocalMessageFailed(localId) + if (!sent) markPendingMessageFailed(requestId) + } + + private fun schedulePendingTimeout(requestId: String) { + pendingTimeoutDisposables[requestId]?.dispose() + val disposable = reconnectScheduler.scheduleDirect( + { scheduleRealtimeCallback { markPendingMessageFailed(requestId) } }, + SEND_ACK_TIMEOUT_MILLIS, + TimeUnit.MILLISECONDS + ) + pendingTimeoutDisposables[requestId] = disposable + compositeDisposable.add(disposable) } private fun handleOpenRoomResult(response: ApiResponse) { @@ -348,21 +366,22 @@ class DmChatRoomViewModel( } is DmChatSocketEvent.Message -> onRealtimeMessage(event.message) is DmChatSocketEvent.SendAck -> handleSendAck(event.requestId, event.message) - is DmChatSocketEvent.Error -> event.requestId?.let { markLocalMessageFailed(it) } + is DmChatSocketEvent.Error -> event.requestId?.let { markPendingMessageFailed(it) } DmChatSocketEvent.Pong -> Unit } } - private fun handleSendAck(localId: String, message: DmChatMessageResponse) { + private fun handleSendAck(requestId: String, message: DmChatMessageResponse) { + val localId = pendingRequestLocalIds.remove(requestId) ?: return + pendingTimeoutDisposables.remove(requestId)?.dispose() val sentItem = message.toUiItem() if (sentItem == null) { markLocalMessageFailed(localId) return } - isSending = false currentMessages = currentMessages.map { - if (it.localId == localId) sentItem else it + if (it.localId == localId) sentItem.copy(localId = localId) else it }.deduplicateSentMessage(sentItem.messageId).sortByCreatedAtAndMessageId() emitContent() } @@ -378,8 +397,13 @@ class DmChatRoomViewModel( } } + private fun markPendingMessageFailed(requestId: String) { + val localId = pendingRequestLocalIds.remove(requestId) ?: return + pendingTimeoutDisposables.remove(requestId)?.dispose() + markLocalMessageFailed(localId) + } + private fun markLocalMessageFailed(localId: String) { - isSending = false currentMessages = currentMessages.map { if (it.localId == localId) it.copy(status = DmChatMessageStatus.FAILED) else it } @@ -413,6 +437,11 @@ class DmChatRoomViewModel( return "local-$localMessageSequence" } + private fun nextRequestId(): String { + requestSequence += 1L + return "request-$requestSequence" + } + private fun authToken(): String { val token = tokenProvider() if (token.isNotBlank()) currentAuthToken = token @@ -426,6 +455,7 @@ class DmChatRoomViewModel( private companion object { const val RECONNECT_DELAY_MILLIS = 3_000L + const val SEND_ACK_TIMEOUT_MILLIS = 10_000L } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatMappers.kt index e0a5622b..00e1ba62 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatMappers.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatMappers.kt @@ -10,6 +10,7 @@ fun DmChatMessageResponse.toUiItem(): DmChatMessageUiItem? { return DmChatMessageUiItem( messageId = messageId, localId = null, + requestId = null, mine = mine, textMessage = textMessage, senderNickname = senderNickname, diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt index 596038e1..36654e52 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt @@ -9,6 +9,7 @@ enum class DmChatMessageStatus { data class DmChatMessageUiItem( val messageId: Long?, val localId: String?, + val requestId: String?, val mine: Boolean, val textMessage: String, val senderNickname: String, diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt index ebd6f2a8..fbddbc1b 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt @@ -33,6 +33,7 @@ import okhttp3.WebSocketListener import okio.ByteString import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -171,21 +172,21 @@ class DmChatRoomViewModelTest { viewModel.sendText(" 안녕 ") val sendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content - val localId = sendingState.messages.single().localId!! + val requestId = sendingState.messages.single().requestId!! assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(1).get("type").asString) - assertEquals(localId, socketFactory.webSocket.sentJsonAt(1).getAsJsonObject("payload").get("requestId").asString) + assertEquals(requestId, socketFactory.webSocket.sentJsonAt(1).getAsJsonObject("payload").get("requestId").asString) assertEquals(DmChatMessageStatus.SENDING, sendingState.messages.single().status) assertEquals("안녕", sendingState.messages.single().textMessage) - socketFactory.emitAck(localId, message(messageId = 30L, mine = true, textMessage = "안녕")) + socketFactory.emitAck(requestId, message(messageId = 30L, mine = true, textMessage = "안녕")) val sentState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content assertEquals(listOf(30L), sentState.messages.map { it.messageId }) assertEquals(DmChatMessageStatus.SENT, sentState.messages.single().status) } @Test - fun `전송 중 새 전송 중복 요청은 무시한다`() { + fun `전송 중 새 텍스트도 독립 pending으로 추가한다`() { api.enqueueOpenSuccess(openResponse(roomId = 10L)) viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.connectRealtime() @@ -193,9 +194,123 @@ class DmChatRoomViewModelTest { viewModel.sendText("안녕") viewModel.sendText("안녕") - assertEquals(2, socketFactory.webSocket.sentTexts.size) + assertEquals(3, socketFactory.webSocket.sentTexts.size) val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content - assertEquals(1, state.messages.size) + assertEquals(2, state.messages.size) + } + + @Test + fun `서로 다른 텍스트는 각각 requestId로 독립 pending 전송한다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L)) + viewModel.enter(roomId = 10L, creatorId = 0L) + viewModel.connectRealtime() + + viewModel.sendText("첫번째") + viewModel.sendText("두번째") + + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(listOf("첫번째", "두번째"), state.messages.map { it.textMessage }) + assertEquals(listOf(DmChatMessageStatus.SENDING, DmChatMessageStatus.SENDING), state.messages.map { it.status }) + val requestIds = state.messages.map { it.requestId } + assertNotEquals(requestIds[0], requestIds[1]) + assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(1).get("type").asString) + assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(2).get("type").asString) + assertEquals(requestIds[0], socketFactory.webSocket.sentJsonAt(1).getAsJsonObject("payload").get("requestId").asString) + assertEquals(requestIds[1], socketFactory.webSocket.sentJsonAt(2).getAsJsonObject("payload").get("requestId").asString) + } + + @Test + fun `SEND_ACK는 requestId가 일치하는 pending만 확정한다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L)) + viewModel.enter(roomId = 10L, creatorId = 0L) + viewModel.connectRealtime() + viewModel.sendText("첫번째") + viewModel.sendText("두번째") + val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + val firstRequestId = pendingState.messages[0].requestId!! + + socketFactory.emitAck(firstRequestId, message(messageId = 30L, mine = true, textMessage = "첫번째")) + + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(listOf(30L, null), state.messages.map { it.messageId }) + assertEquals(listOf(DmChatMessageStatus.SENT, DmChatMessageStatus.SENDING), state.messages.map { it.status }) + assertEquals("두번째", state.messages[1].textMessage) + } + + @Test + fun `ERROR는 requestId가 일치하는 pending만 실패로 전환한다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L)) + viewModel.enter(roomId = 10L, creatorId = 0L) + viewModel.connectRealtime() + viewModel.sendText("첫번째") + viewModel.sendText("두번째") + val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + val firstRequestId = pendingState.messages[0].requestId!! + + socketFactory.emitError(firstRequestId) + + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(listOf(DmChatMessageStatus.FAILED, DmChatMessageStatus.SENDING), state.messages.map { it.status }) + } + + @Test + fun `pending timeout은 requestId가 일치하는 메시지만 실패로 전환한다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L)) + viewModel.enter(roomId = 10L, creatorId = 0L) + viewModel.connectRealtime() + viewModel.sendText("첫번째") + viewModel.sendText("두번째") + val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + val secondRequestId = pendingState.messages[1].requestId!! + + socketFactory.emitAck(secondRequestId, message(messageId = 31L, mine = true, textMessage = "두번째")) + reconnectScheduler.advanceTimeBy(10L, TimeUnit.SECONDS) + shadowOf(Looper.getMainLooper()).idle() + + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(DmChatMessageStatus.FAILED, state.messages.first { it.textMessage == "첫번째" }.status) + assertEquals(DmChatMessageStatus.SENT, state.messages.first { it.textMessage == "두번째" }.status) + } + + @Test + fun `같은 requestId의 SEND_ACK 중복 수신은 첫 확정 결과만 반영한다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L)) + viewModel.enter(roomId = 10L, creatorId = 0L) + viewModel.connectRealtime() + viewModel.sendText("안녕") + val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content) + .messages.single().requestId!! + + socketFactory.emitAck(requestId, message(messageId = 41L, mine = true, textMessage = "첫 ACK")) + socketFactory.emitAck(requestId, message(messageId = 42L, mine = true, textMessage = "중복 ACK")) + + val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + assertEquals(listOf(41L), state.messages.map { it.messageId }) + assertEquals(listOf("첫 ACK"), state.messages.map { it.textMessage }) + } + + @Test + fun `retry는 기존 failed item을 유지하고 새 requestId로 SEND_TEXT를 전송한다`() { + api.enqueueOpenSuccess(openResponse(roomId = 10L)) + viewModel.enter(roomId = 10L, creatorId = 0L) + viewModel.connectRealtime() + viewModel.sendText("재시도") + val failedRequestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content) + .messages.single().requestId!! + socketFactory.emitError(failedRequestId) + val failedItem = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content).messages.single() + + viewModel.retry(failedItem.localId!!) + + val retryState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content + val retryItem = retryState.messages.single() + assertEquals(failedItem.localId, retryItem.localId) + assertNotEquals(failedRequestId, retryItem.requestId) + assertEquals(DmChatMessageStatus.SENDING, retryItem.status) + assertEquals( + retryItem.requestId, + socketFactory.webSocket.sentJsonAt(2).getAsJsonObject("payload").get("requestId").asString + ) } @Test @@ -212,7 +327,9 @@ class DmChatRoomViewModelTest { socketFactory.webSocket.sendResult = true viewModel.retry(failedItem.localId!!) - socketFactory.emitAck(failedItem.localId, message(messageId = 40L, mine = true, textMessage = "안녕")) + val retryRequestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content) + .messages.single().requestId!! + socketFactory.emitAck(retryRequestId, message(messageId = 40L, mine = true, textMessage = "안녕")) val retriedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content assertEquals(listOf(40L), retriedState.messages.map { it.messageId }) @@ -231,8 +348,10 @@ class DmChatRoomViewModelTest { socketFactory.webSocket.sendResult = true viewModel.retry(failedItem.localId!!) + val retryRequestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content) + .messages.single().requestId!! viewModel.onRealtimeMessage(message(messageId = 45L, mine = true, textMessage = "안녕")) - socketFactory.emitAck(failedItem.localId, message(messageId = 45L, mine = true, textMessage = "안녕")) + socketFactory.emitAck(retryRequestId, message(messageId = 45L, mine = true, textMessage = "안녕")) val retriedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content assertEquals(listOf(45L), retriedState.messages.map { it.messageId }) @@ -259,9 +378,10 @@ class DmChatRoomViewModelTest { viewModel.connectRealtime() viewModel.sendText("안녕") - val localId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content).messages.single().localId!! + val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content) + .messages.single().requestId!! viewModel.onRealtimeMessage(message(messageId = 50L, mine = true, textMessage = "안녕")) - socketFactory.emitAck(localId, message(messageId = 50L, mine = true, textMessage = "안녕")) + socketFactory.emitAck(requestId, message(messageId = 50L, mine = true, textMessage = "안녕")) val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content assertEquals(listOf(50L), state.messages.map { it.messageId }) @@ -771,6 +891,13 @@ class FakeWebSocketFactory { ) } + fun emitError(requestId: String) { + webSocketListener?.onMessage( + webSocket, + "{\"type\":\"ERROR\",\"payload\":{\"requestId\":\"$requestId\",\"code\":\"SEND_FAILED\",\"message\":\"failed\"}}" + ) + } + fun emitFailure(throwable: Throwable) { webSocketListener?.onFailure(webSocket, throwable, null) } diff --git a/docs/20260610_DM_채팅화면/plan-task.md b/docs/20260610_DM_채팅화면/plan-task.md index 37152227..d892064c 100644 --- a/docs/20260610_DM_채팅화면/plan-task.md +++ b/docs/20260610_DM_채팅화면/plan-task.md @@ -612,7 +612,7 @@ - 검증 기록: - 2026-06-18: `DmChatRoomViewModelTest`에 `JOINED` 전 `isRealtimeConnected=false`, `JOINED` 후 `true`, 중복 `connectRealtime()` 시 socket connect와 `JOIN_ROOM`이 1회만 수행되는 검증을 추가했다. 수정 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` 실행 결과 `DmChatRoomViewModelTest.kt:326` assertion failure로 RED를 확인했다. 이후 `DmChatRoomViewModel`에 `isRealtimeJoining`/`currentRealtimeRoomId`를 추가하고 `JOINED` 수신 시점에만 connected로 전환하도록 변경했다. 재실행 결과 같은 ViewModel 테스트가 PASS했고, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check`도 PASS했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning만 출력됐다. -- [ ] **Task 10.2: MESSAGE 수신 반영으로 교체** +- [x] **Task 10.2: MESSAGE 수신 반영으로 교체** - Files: - Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt` - Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt` @@ -625,8 +625,10 @@ - 검증: - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` - Expected: `MESSAGE` append, 중복 `messageId` 제거, 잘못된 payload 무시 테스트가 PASS. + - 검증 기록: + - 2026-06-18: 기존 `socketFactory.emitMessage()` 기반 WebSocket `MESSAGE` callback이 `handleSocketEvent()`에서 `onRealtimeMessage()`로 연결되고, `DmChatMessageResponse.toUiItem()` 및 `mergeByMessageId()`로 UI 목록에 반영되는 흐름을 확인했다. `DmChatRoomViewModelTest`의 `realtime message callback은 SSE 메시지를 화면 상태에 병합한다`, `SSE 메시지는 messageId 중복을 제거하고 최신 메시지를 추가한다` 테스트를 유지해 `MESSAGE` append와 `messageId` 중복 제거 회귀를 고정했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` PASS를 확인했다. -- [ ] **Task 10.3: SEND_TEXT/requestId pending 전송으로 교체** +- [x] **Task 10.3: SEND_TEXT/requestId pending 전송으로 교체** - Files: - Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt` - Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt` @@ -644,6 +646,9 @@ - 검증: - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` - Expected: blank 무시, pending 추가, 서로 다른 텍스트의 독립 pending, `SEND_TEXT` 송신, `SEND_ACK` requestId 매칭, `ERROR` 실패, timeout 실패, retry 새 requestId, 중복 ack 무시 테스트가 PASS. + - 검증 기록: + - 2026-06-18: `DmChatRoomViewModelTest`에 requestId 기반 독립 pending, `SEND_ACK` requestId 매칭, `ERROR` 실패, timeout 실패, retry 새 requestId, 중복 ACK 무시 테스트를 먼저 추가했다. 수정 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` 실행 시 `DmChatMessageUiItem.requestId` 미정의 컴파일 오류로 RED를 확인했고, timeout 테스트는 구현 전 `DmChatRoomViewModelTest.kt:270` assertion failure로 RED를 확인했다. + - 2026-06-18: `DmChatMessageUiItem`에 `requestId`를 추가하고, `DmChatRoomViewModel`의 단일 `isSending` 제한을 제거해 각 텍스트 전송이 새 `requestId`와 pending map으로 관리되도록 변경했다. `SEND_TEXT`는 WebSocket으로 전송하고, `SEND_ACK`/`ERROR`/send false/10초 timeout은 해당 requestId의 local item만 확정 또는 실패 처리한다. retry는 기존 `localId` item을 유지하면서 새 `requestId`를 발급하도록 변경했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` PASS를 확인했다. - [ ] **Task 10.4: SEND_ACK보다 MESSAGE가 먼저 도착하는 race 처리** - Files: @@ -893,3 +898,4 @@ - 2026-06-11: Phase 6.6 리뷰 후속으로 `onCleared()` 테스트를 source 문자열 검증에서 런타임 동작 검증으로 보강했다. 예약된 realtime 재연결이 `onCleared()` 후 실행되지 않는지, background thread에서 예약된 `mainHandler.post { action() }` callback이 `onCleared()` 후 상태를 갱신하지 않는지 확인하도록 수정했다. Phase 6.7 테스트에는 `requestLatch.await(2, TimeUnit.SECONDS)` 반환값 assertion을 추가해 timeout 검증 의도를 명확히 했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1` PASS를 확인했다. - 2026-06-18: `docs/20260610_DM_채팅화면/prd.md`의 WebSocket 전환 요구사항을 기준으로 `plan-task.md`를 보강했다. 기존 Phase 1~8의 SSE 구현 이력은 보존하고, Phase 9~13에 WebSocket 모델/클라이언트, Repository/DI 전환, `JOIN_ROOM`/`JOINED`, `MESSAGE`, `SEND_TEXT`/`SEND_ACK`, `LEAVE_ROOM`/close, reconnect/heartbeat/token refresh, USER_CREATOR push routing, 제거 endpoint 회귀 검증, 최종 수동 확인 작업을 추가했다. - 2026-06-18: 현재 코드 기준으로 `DmChatApi`의 `/messages/text`, `/events/disconnect`, `DmChatEventClient`, `DmChatRoomViewModel.sendText()` REST 전송, `disconnectRealtime()` SSE 해제 경로가 남아 있음을 확인했고, 이를 Phase 9~13에서 제거/교체할 대상으로 문서화했다. 이번 단계는 계획 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다. +- 2026-06-18: Phase 10의 Task 10.1~10.3 코드 리뷰 및 검증을 수행했다. `DmChatRoomViewModel`의 `JOINED` 기준 연결 확정, WebSocket `MESSAGE` 병합, `requestId` 단위 pending/SEND_ACK/ERROR/timeout/retry 처리를 확인했고 blocking issue는 발견하지 못했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`와 Gradle 실행에서는 기존 Gradle deprecation warning만 출력됐고 실패는 없었다. Task 10.4의 MESSAGE/SEND_ACK race와 Task 10.5 회귀 테스트 정리는 후속 미완료 범위로 유지한다.