개요
이 프로젝트는 두 군데에서 STOMP 세션 상태를 인메모리로 추적한다.
communication모듈의UserConnectionRegistry- 유저가 지금 온라인인지(=WebSocket에 붙어있는지)notification모듈의ActiveChatRoomSessionRegistry- 유저가 지금 특정 채팅방을 “보고 있는 중”인지(=새 메시지가 와도 푸시를 안 보내도 되는지)
둘 다
ConcurrentHashMap기반의 단순한 구조인데, 실제로 붙여보니 STOMP 프로토콜 자체의 특성 때문에 생기는 버그를 각각 하나씩 만났다.
1. notification — UNSUBSCRIBE 프레임엔 destination이 없다
ActiveChatRoomSessionRegistry는 유저가 /topic/rooms/{roomId}를 구독 중이면 그 방의 새 메시지에 대해 FCM 푸시를 억제한다. 처음 구현은 이랬다.
// 초기 구현 — SUBSCRIBE/UNSUBSCRIBE 둘 다 destination에서 roomId를 뽑음
@EventListener
fun onSubscribe(event: SessionSubscribeEvent) {
val accessor = StompHeaderAccessor.wrap(event.message)
val roomId = extractRoomId(accessor.destination) ?: return
val userId = extractUserId(accessor) ?: return
registry.enter(userId, roomId)
}
@EventListener
fun onUnsubscribe(event: SessionUnsubscribeEvent) {
val accessor = StompHeaderAccessor.wrap(event.message)
val roomId = extractRoomId(accessor.destination) ?: return // 항상 null
val userId = extractUserId(accessor) ?: return
registry.leave(userId, roomId)
}
SUBSCRIBE 프레임엔 destination 헤더(/topic/rooms/{roomId})가 있어서 여기서 방 ID를 뽑을 수 있다.
그런데 STOMP UNSUBSCRIBE 프레임은 스펙상 destination을 안 보낸다
- 클라이언트가 구독을 취소할 때 넘기는 건
subscriptionId(구독 시작할 때 클라이언트가 부여한 식별자)뿐이다. extractRoomId(accessor.destination)가 항상null을 반환하니leave()자체가 호출이 안 되고, “방을 나갔는데도 계속 보고 있는 걸로” 상태가 남아있는 버그가 생겼다.
해결 — subscribe 시점에 역추적용 매핑을 따로 저장
@Component
class ActiveChatRoomSessionRegistry {
private val sessions = ConcurrentHashMap<UUID, MutableSet<UUID>>()
// STOMP UNSUBSCRIBE 프레임에는 destination이 없고 subscriptionId만 있으므로,
// subscribe 시점에 (sessionId:subscriptionId) → (userId, roomId) 매핑을 저장해 역추적한다.
private val subscriptions = ConcurrentHashMap<String, Pair<UUID, UUID>>()
private val sessionSubscriptionKeys = ConcurrentHashMap<String, MutableSet<String>>()
fun enter(userId: UUID, roomId: UUID, sessionId: String, subscriptionId: String) {
sessions.getOrPut(userId) { ConcurrentHashMap.newKeySet() }.add(roomId)
val key = subKey(sessionId, subscriptionId)
subscriptions[key] = userId to roomId
sessionSubscriptionKeys.getOrPut(sessionId) { ConcurrentHashMap.newKeySet() }.add(key)
}
fun leaveBySubscription(sessionId: String, subscriptionId: String) {
val key = subKey(sessionId, subscriptionId)
val (userId, roomId) = subscriptions.remove(key) ?: return
sessionSubscriptionKeys[sessionId]?.remove(key)
sessions[userId]?.remove(roomId)
}
fun leaveAll(userId: UUID, sessionId: String) {
sessions.remove(userId)
sessionSubscriptionKeys.remove(sessionId)?.forEach { subscriptions.remove(it) }
}
...
}
구독 시점(SUBSCRIBE, destination이 있어서 roomId를 알 수 있는 유일한 시점)에 sessionId:subscriptionId → (userId, roomId) 매핑을 저장해두고, UNSUBSCRIBE가 오면 destination 대신 이 매핑에서 역으로 찾는다.
연결이 아예 끊기는 DISCONNECT 이벤트에서는 그 세션이 구독했던 모든 항목을 한 번에 정리한다(leaveAll).
2. communication — 같은 세션의 disconnect 이벤트가 두 번 올 수 있다
UserConnectionRegistry는 유저가 온라인/오프라인으로 전환될 때만(=처음 연결됐을 때, 마지막 연결이 끊겼을 때만) 다른 참여자들에게 브로드캐스트한다.
유저 하나가 탭을 여러 개 열어둘 수 있으니, 세션을 Set으로 관리해서 “마지막 세션이 빠질 때만” 오프라인으로 처리한다.
// 수정 전
fun leaveBySession(userId: UUID, sessionId: String) {
sessions.getOrDefault(userId, null)?.remove(sessionId)
}
문제는 이 메서드가 실제로 뭔가를 지웠는지 여부를 알려주지 않는다는 것
- 이미 없는 세션에 대해 다시
remove를 호출해도, 있는 세션을 처음 지운 것도 똑같이 아무것도 리턴 안 한다. - 그런데 동일 세션에 대해
SessionDisconnectEvent가 두 번 발행되는 경우에 “이미 정리된 세션인데도” 다시 오프라인 상태로 판단해서 오프라인 브로드캐스트를 중복으로 보내는 문제가 생겼다.
해결 — “실제로 지워졌는가”를 반환값으로 명시
// 수정 후
fun leaveBySession(userId: UUID, sessionId: String) =
sessions.getOrDefault(userId, null)?.remove(sessionId) == true
// UserConnectSessionEventListener
val wasRemoved = registry.leaveBySession(userId, sessionId)
if (!wasRemoved) return // 이미 정리된 세션이면 여기서 끝 — 브로드캐스트 로직까지 안 감
if (!registry.isActive(userId)) {
// 오프라인 브로드캐스트
}
MutableSet.remove()가 원래 Boolean(실제로 제거됐는지)을 반환하는데, 처음 구현은 이 값을 그냥 버리고 있었다.
반환값을 그대로 살려서 호출하는 쪽에 넘기고, “실제로 뭔가 지워진 경우에만” 오프라인 판단 로직을 타도록 얼리 리턴을 추가했다.
3. 공통점 — 둘 다 “인메모리, 단일 인스턴스 전제”라는 한계를 스스로 남겨뒀다
두 레지스트리 다 코드에 이런 TODO가 그대로 남아 있다.
// TODO: 스케일아웃 시 인메모리 방식은 무력화됨 — Redis 기반으로 이전 필요
ConcurrentHashMap은 프로세스 하나 안에서만 유효하다.
서버를 여러 대로 늘리면(수평 확장) 유저 A의 WebSocket이 인스턴스 1에 붙어있는데 유저 B가 보낸 메시지 처리가 인스턴스 2에서 일어나면, 인스턴스 2는 유저 A가 그 방을 보고 있는지 전혀 알 수 없다.
지금은 단일 인스턴스 전제로 충분히 동작하지만, 이 두 버그와 마찬가지로 “프로토콜/운영 환경이 실제로 어떻게 동작하는가”를 하나씩 확인하지 않으면 다음 번엔 이 TODO가 조용히 현실이 될 수 있다.