refactor(dm): 채팅 저장소를 WebSocket 기준으로 전환한다

This commit is contained in:
2026-06-18 18:25:29 +09:00
parent 3d71def880
commit deba733522
5 changed files with 101 additions and 106 deletions

View File

@@ -186,9 +186,8 @@ import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomApi
import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomRepository import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomRepository
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModel import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModel
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatApi import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatApi
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatEventClient
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRealtimeClient
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketClient
import kr.co.vividnext.sodalive.v2.main.home.HomeCreatorRankingViewModel import kr.co.vividnext.sodalive.v2.main.home.HomeCreatorRankingViewModel
import kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModel import kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModel
import kr.co.vividnext.sodalive.v2.main.home.data.HomeCreatorRankingApi import kr.co.vividnext.sodalive.v2.main.home.data.HomeCreatorRankingApi
@@ -313,7 +312,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
single { ApiBuilder().build(get(), CharacterCommentApi::class.java) } single { ApiBuilder().build(get(), CharacterCommentApi::class.java) }
single { ApiBuilder().build(get(), OriginalWorkApi::class.java) } single { ApiBuilder().build(get(), OriginalWorkApi::class.java) }
single { ApiBuilder().build(get<Retrofit>(named("agoraRetrofit")), V2vApi::class.java) } single { ApiBuilder().build(get<Retrofit>(named("agoraRetrofit")), V2vApi::class.java) }
single<DmChatRealtimeClient> { DmChatEventClient(okHttpClient = get(), gson = get(), baseUrl = baseUrl) } single { DmChatSocketClient(okHttpClient = get(), gson = get(), baseUrl = baseUrl) }
} }
private val viewModelModule = module { private val viewModelModule = module {
@@ -462,7 +461,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { PointStatusRepository(get()) } factory { PointStatusRepository(get()) }
factory { HomeRepository(get()) } factory { HomeRepository(get()) }
factory { ChatRoomRepository(get()) } factory { ChatRoomRepository(get()) }
factory { DmChatRepository(api = get(), realtimeClient = get()) } factory { DmChatRepository(api = get(), socketClient = get()) }
factory { HomeCreatorRankingRepository(get()) } factory { HomeCreatorRankingRepository(get()) }
factory { HomeRecommendationRepository(get()) } factory { HomeRecommendationRepository(get()) }
factory { factory {

View File

@@ -30,17 +30,4 @@ interface DmChatApi {
@Query("cursor") cursor: Long?, @Query("cursor") cursor: Long?,
@Query("limit") limit: Int = 20 @Query("limit") limit: Int = 20
): Single<ApiResponse<DmChatMessagesPageResponse>> ): Single<ApiResponse<DmChatMessagesPageResponse>>
@POST("/api/v2/user-creator-chat/rooms/{roomId}/messages/text")
fun sendDmTextMessage(
@Header("Authorization") authHeader: String,
@Path("roomId") roomId: Long,
@Body request: SendDmTextMessageRequest
): Single<ApiResponse<SendDmChatMessageResponse>>
@POST("/api/v2/user-creator-chat/rooms/{roomId}/events/disconnect")
fun disconnectRealtime(
@Header("Authorization") authHeader: String,
@Path("roomId") roomId: Long
): Single<ApiResponse<Boolean>>
} }

View File

@@ -42,15 +42,3 @@ data class DmChatMessageResponse(
@SerializedName("senderNickname") val senderNickname: String, @SerializedName("senderNickname") val senderNickname: String,
@SerializedName("senderProfileImageUrl") val senderProfileImageUrl: String @SerializedName("senderProfileImageUrl") val senderProfileImageUrl: String
) )
@Keep
data class SendDmTextMessageRequest(
@SerializedName("textMessage") val textMessage: String
)
@Keep
data class SendDmChatMessageResponse(
@SerializedName("message") val message: DmChatMessageResponse,
@SerializedName("deliveredRealtime") val deliveredRealtime: Boolean,
@SerializedName("pushSent") val pushSent: Boolean
)

View File

@@ -5,7 +5,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
class DmChatRepository( class DmChatRepository(
private val api: DmChatApi, private val api: DmChatApi,
private val realtimeClient: DmChatRealtimeClient? = null private val socketClient: DmChatSocketClient? = null
) { ) {
fun createOrGetRoom( fun createOrGetRoom(
token: String, token: String,
@@ -37,34 +37,31 @@ class DmChatRepository(
limit = limit limit = limit
) )
fun sendTextMessage( fun connectSocket(
token: String, token: String,
roomId: Long, listener: DmChatSocketClient.Listener
textMessage: String
): Single<ApiResponse<SendDmChatMessageResponse>> = api.sendDmTextMessage(
authHeader = bearer(token),
roomId = roomId,
request = SendDmTextMessageRequest(textMessage = textMessage)
)
fun disconnectRealtime(
token: String,
roomId: Long
): Single<ApiResponse<Boolean>> = api.disconnectRealtime(
authHeader = bearer(token),
roomId = roomId
)
fun connectRealtime(
token: String,
roomId: Long,
listener: DmChatEventClient.Listener
) { ) {
realtimeClient?.connect(token = token, roomId = roomId, listener = listener) socketClient?.connect(token = token, listener = listener)
} }
fun cancelRealtime() { fun sendJoinRoom(roomId: Long): Boolean = socketClient?.sendJoinRoom(roomId = roomId) ?: false
realtimeClient?.cancel()
fun sendLeaveRoom(roomId: Long): Boolean = socketClient?.sendLeaveRoom(roomId = roomId) ?: false
fun sendSocketText(
roomId: Long,
requestId: String,
textMessage: String
): Boolean = socketClient?.sendText(
roomId = roomId,
requestId = requestId,
textMessage = textMessage
) ?: false
fun sendPing(): Boolean = socketClient?.sendPing() ?: false
fun closeSocket() {
socketClient?.close()
} }
private fun bearer(token: String) = "Bearer $token" private fun bearer(token: String) = "Bearer $token"

View File

@@ -5,20 +5,32 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomRequest import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomRequest
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomResponse import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomResponse
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatApi import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatApi
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessagesPageResponse import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessagesPageResponse
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmChatMessageResponse import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketClient
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmTextMessageRequest import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketEvent
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class DmChatRepositoryTest { class DmChatRepositoryTest {
private val api = FakeDmChatApi() private val api = FakeDmChatApi()
private val repository = DmChatRepository(api) private val socketFactory = FakeWebSocketFactory()
private val socketClient = DmChatSocketClient(
okHttpClient = OkHttpClient(),
gson = com.google.gson.Gson(),
baseUrl = "https://api.example.com",
webSocketFactory = socketFactory::newWebSocket
)
private val repository = DmChatRepository(api = api, socketClient = socketClient)
@Test @Test
fun `createOrGetRoom은 bearer header와 creatorId를 API에 위임한다`() { fun `createOrGetRoom은 bearer header와 creatorId를 API에 위임한다`() {
@@ -48,27 +60,38 @@ class DmChatRepositoryTest {
} }
@Test @Test
fun `sendTextMessage는 textMessage request를 API에 위임한다`() { fun `connectSocket은 bearer header와 listener를 socket client에 위임한다`() {
repository.sendTextMessage(token = "test-token", roomId = 12L, textMessage = "안녕").blockingGet() repository.connectSocket(token = "test-token", listener = TestListener())
assertEquals("Bearer test-token", api.lastAuthHeader) assertEquals("Bearer test-token", socketFactory.request?.header("Authorization"))
assertEquals(12L, api.lastRoomId) assertEquals("wss://api.example.com/ws/v2/user-creator-chat", socketFactory.request?.tag(String::class.java))
assertEquals(SendDmTextMessageRequest(textMessage = "안녕"), api.lastSendRequest)
} }
@Test @Test
fun `disconnectRealtime은 bearer header와 roomId를 API에 위임한다`() { fun `socket send 메서드는 socket client envelope 전송에 위임한다`() {
repository.disconnectRealtime(token = "test-token", roomId = 12L).blockingGet() repository.connectSocket(token = "test-token", listener = TestListener())
assertEquals("Bearer test-token", api.lastAuthHeader) assertTrue(repository.sendJoinRoom(roomId = 12L))
assertEquals(12L, api.lastRoomId) assertTrue(repository.sendLeaveRoom(roomId = 12L))
assertNull(api.lastSendRequest) assertTrue(repository.sendSocketText(roomId = 12L, requestId = "request-1", textMessage = "안녕"))
assertTrue(repository.sendPing())
assertEquals(listOf("JOIN_ROOM", "LEAVE_ROOM", "SEND_TEXT", "PING"), socketFactory.webSocket.sentTypes())
}
@Test
fun `closeSocket은 socket close에 위임하고 미연결 전송은 false를 반환한다`() {
repository.connectSocket(token = "test-token", listener = TestListener())
repository.closeSocket()
assertEquals(1, socketFactory.webSocket.closeCount)
assertFalse(repository.sendPing())
} }
private class FakeDmChatApi : DmChatApi { private class FakeDmChatApi : DmChatApi {
var lastAuthHeader: String? = null var lastAuthHeader: String? = null
var lastCreateRequest: CreateDmChatRoomRequest? = null var lastCreateRequest: CreateDmChatRoomRequest? = null
var lastSendRequest: SendDmTextMessageRequest? = null
var lastRoomId: Long? = null var lastRoomId: Long? = null
var lastCursor: Long? = null var lastCursor: Long? = null
var lastLimit: Int? = null var lastLimit: Int? = null
@@ -126,46 +149,47 @@ class DmChatRepositoryTest {
) )
) )
} }
}
override fun sendDmTextMessage( private open class TestListener : DmChatSocketClient.Listener {
authHeader: String, override fun onEvent(event: DmChatSocketEvent) = Unit
roomId: Long, override fun onFailure(throwable: Throwable) = Unit
request: SendDmTextMessageRequest }
): Single<ApiResponse<SendDmChatMessageResponse>> {
lastAuthHeader = authHeader private class FakeWebSocketFactory {
lastRoomId = roomId val webSocket = FakeWebSocket()
lastSendRequest = request var request: Request? = null
return Single.just(
ApiResponse( fun newWebSocket(request: Request, listener: WebSocketListener): WebSocket {
success = true, this.request = request
data = SendDmChatMessageResponse( return webSocket
message = message(), }
deliveredRealtime = true, }
pushSent = false
) private class FakeWebSocket : WebSocket {
) val sentTexts = mutableListOf<String>()
) var closeCount = 0
override fun request(): Request = Request.Builder().url("wss://example.com").build()
override fun queueSize(): Long = 0L
override fun send(text: String): Boolean {
sentTexts += text
return true
} }
override fun disconnectRealtime( override fun send(bytes: ByteString): Boolean = true
authHeader: String,
roomId: Long override fun close(code: Int, reason: String?): Boolean {
): Single<ApiResponse<Boolean>> { closeCount += 1
lastAuthHeader = authHeader return true
lastRoomId = roomId
return Single.just(ApiResponse(success = true, data = true))
} }
private fun message() = DmChatMessageResponse( override fun cancel() = Unit
messageId = 1L,
messageType = "TEXT", fun sentTypes(): List<String> = sentTexts.map { text ->
mine = true, com.google.gson.JsonParser.parseString(text).asJsonObject.get("type").asString
createdAt = 1000L, }
textMessage = "안녕",
voiceMessageUrl = null,
senderId = 2L,
senderNickname = "",
senderProfileImageUrl = "https://example.com/profile.png"
)
} }
} }