Skip to content
메모장
Go back

인메모리로 STOMP 세션 상태를 추적하다 겪은 두 가지 버그

개요

이 프로젝트는 두 군데에서 STOMP 세션 상태를 인메모리로 추적한다.

둘 다 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을 안 보낸다

해결 — 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)
}

문제는 이 메서드가 실제로 뭔가를 지웠는지 여부를 알려주지 않는다는 것

해결 — “실제로 지워졌는가”를 반환값으로 명시

// 수정 후
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가 조용히 현실이 될 수 있다.


Share this post:

Previous Post
네이버 API - JSON 데이터의 Content-Type: text/plain 응답
Next Post
DB 3 value logic - 가격 협의 게시글이 필터에서 사라지던 이유