From af1e9b565a63e94bded37747241e3ad15a615cea Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 18 Jun 2026 17:06:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(user-creator-chat):=20WebSocket=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EB=A0=88=EC=A7=80=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...UserCreatorChatWebSocketSessionRegistry.kt | 55 ++++++++ ...CreatorChatWebSocketSessionRegistryTest.kt | 129 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistry.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistryTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistry.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistry.kt new file mode 100644 index 00000000..a78c8a7f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistry.kt @@ -0,0 +1,55 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket + +import org.springframework.stereotype.Component +import org.springframework.web.socket.WebSocketSession +import java.util.concurrent.ConcurrentHashMap + +@Component +class UserCreatorChatWebSocketSessionRegistry { + private val sessionsByRoomMember = ConcurrentHashMap>() + private val sessionIndexes = ConcurrentHashMap() + private val lockStripes = Array(LOCK_STRIPE_COUNT) { Any() } + + fun register(roomId: Long, memberId: Long, session: WebSocketSession) { + val sessionId = session.id + synchronized(lockFor(sessionId)) { + removeLocked(sessionId) + + val key = RoomMemberKey(roomId, memberId) + sessionsByRoomMember.computeIfAbsent(key) { ConcurrentHashMap() }[sessionId] = session + sessionIndexes[sessionId] = key + } + } + + fun findSessions(roomId: Long, memberId: Long): List { + return sessionsByRoomMember[RoomMemberKey(roomId, memberId)]?.values?.toList() ?: emptyList() + } + + fun remove(sessionId: String) { + synchronized(lockFor(sessionId)) { + removeLocked(sessionId) + } + } + + private fun removeLocked(sessionId: String) { + val key = sessionIndexes.remove(sessionId) ?: return + val sessions = sessionsByRoomMember[key] ?: return + sessions.remove(sessionId) + if (sessions.isEmpty()) { + sessionsByRoomMember.remove(key, sessions) + } + } + + private fun lockFor(sessionId: String): Any { + return lockStripes[Math.floorMod(sessionId.hashCode(), lockStripes.size)] + } + + private data class RoomMemberKey( + val roomId: Long, + val memberId: Long + ) + + companion object { + private const val LOCK_STRIPE_COUNT = 64 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistryTest.kt new file mode 100644 index 00000000..474220e1 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistryTest.kt @@ -0,0 +1,129 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.web.socket.WebSocketSession +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class UserCreatorChatWebSocketSessionRegistryTest { + private val registry = UserCreatorChatWebSocketSessionRegistry() + + @Test + @DisplayName("roomId/memberId/sessionId 기준으로 local WebSocket session을 등록하고 조회한다") + fun shouldRegisterAndFindSessionsByRoomAndMember() { + val session = session("session-1") + + registry.register(roomId = 10L, memberId = 20L, session = session) + + val sessions = registry.findSessions(roomId = 10L, memberId = 20L) + assertEquals(1, sessions.size, "Expected one registered local WebSocket session") + assertSame(session, sessions.single()) + } + + @Test + @DisplayName("sessionId로 등록된 local WebSocket session을 제거한다") + fun shouldRemoveSessionBySessionId() { + val session = session("session-1") + registry.register(roomId = 10L, memberId = 20L, session = session) + + registry.remove("session-1") + + assertFalse( + registry.findSessions(roomId = 10L, memberId = 20L).isNotEmpty(), + "Expected removed WebSocket session not to be returned" + ) + } + + @Test + @DisplayName("같은 session이 다른 room으로 전환되면 기존 room 등록을 제거한다") + fun shouldRemovePreviousRoomWhenSameSessionSwitchesRoom() { + val session = session("session-1") + registry.register(roomId = 10L, memberId = 20L, session = session) + + registry.register(roomId = 11L, memberId = 20L, session = session) + + assertFalse( + registry.findSessions(roomId = 10L, memberId = 20L).isNotEmpty(), + "Expected previous room mapping to be removed when same session switches rooms" + ) + assertEquals(listOf(session), registry.findSessions(roomId = 11L, memberId = 20L)) + } + + @Test + @DisplayName("room/member에 등록된 여러 local session을 모두 조회한다") + fun shouldFindMultipleSessionsForSameRoomMember() { + val first = session("session-1") + val second = session("session-2") + registry.register(roomId = 10L, memberId = 20L, session = first) + registry.register(roomId = 10L, memberId = 20L, session = second) + + val sessions = registry.findSessions(roomId = 10L, memberId = 20L) + + assertEquals(setOf(first, second), sessions.toSet()) + } + + @Test + @DisplayName("같은 session의 동시 room 전환에서도 stale room 등록을 남기지 않는다") + fun shouldNotLeaveStaleRoomMappingWhenSameSessionSwitchesRoomConcurrently() { + val session = sessionWithSynchronizedFirstTwoIdReads("session-1") + val executor = Executors.newFixedThreadPool(2) + + try { + val first = executor.submit { registry.register(roomId = 10L, memberId = 20L, session = session) } + val second = executor.submit { registry.register(roomId = 11L, memberId = 20L, session = session) } + + first.get(3, TimeUnit.SECONDS) + second.get(3, TimeUnit.SECONDS) + } finally { + executor.shutdownNow() + } + + val registeredRooms = listOf(10L, 11L).filter { roomId -> + registry.findSessions(roomId = roomId, memberId = 20L).isNotEmpty() + } + assertEquals( + 1, + registeredRooms.size, + "Expected concurrent same-session room switch to leave exactly one active room mapping" + ) + } + + @Test + @DisplayName("sessionId별 lock map을 유지하지 않는다") + fun shouldNotKeepPerSessionLockMap() { + val hasSessionLockMap = UserCreatorChatWebSocketSessionRegistry::class.java.declaredFields + .any { field -> field.name == "sessionLocks" } + + assertFalse( + hasSessionLockMap, + "Expected registry not to keep a per-session lock map that can grow with WebSocket traffic" + ) + } + + private fun sessionWithSynchronizedFirstTwoIdReads(id: String): WebSocketSession { + val session = Mockito.mock(WebSocketSession::class.java) + val readCount = AtomicInteger() + val firstTwoReads = CountDownLatch(2) + Mockito.`when`(session.id).thenAnswer { + if (readCount.incrementAndGet() <= 2) { + firstTwoReads.countDown() + firstTwoReads.await(1, TimeUnit.SECONDS) + } + id + } + return session + } + + private fun session(id: String): WebSocketSession { + val session = Mockito.mock(WebSocketSession::class.java) + Mockito.`when`(session.id).thenReturn(id) + return session + } +}