[Websocket] STOMP 에서 세션 관리하기

eugene·2025년 1월 13일
0

Trouble Shooting

목록 보기
4/4

읽지 않은 메시지 수를 관리하며 실시간 데이터 처리 개선하기

API 호출 학습을 진행하던 중, 사용자가 채팅 페이지를 벗어나는 순간 마지막으로 읽은 메시지 정보를 업데이트하는 API를 호출하는 방식으로 구현하려 했습니다. 하지만 페이지를 벗어날 때마다 API를 호출하면 서버에 부담을 줄 수 있다는 점을 인지하게 되었습니다.

이를 개선하기 위해 STOMP 세션 관리를 도입하여 WebSocket 연결이 끊어질 때 서버에서 lastReadMsgId 필드를 자동으로 업데이트하도록 리팩토링을 진행했습니다.


🧐 고려 사항

  1. 인증된 사용자만 채팅을 전송할 수 있어야 한다.
    • 해결 방법: HandshakeInterceptor를 구현하여 세션 속성에 인증된 사용자 정보를 저장
  2. WebSocket 연결 시작과 종료 시, 세션 정보를 기반으로 마지막 읽은 메시지 ID를 업데이트해야 한다.
    • 해결 방법: STOMP 세션 이벤트를 활용하여 비즈니스 로직을 처리

1️⃣ Interceptor vs Filter

DispatcherServlet을 기준으로 Filter는 클라이언트의 최초 요청을 처리하며, Interceptor는 비즈니스 로직과 관련된 작업을 담당합니다. 이를 시각적으로 이해하기 쉽게 표현한 이미지는 아래와 같습니다:

Filter와 Interceptor 역할

Filter

  • DispatcherServlet에 도달하기 전에 요청 처리
  • 주요 역할:
    • 인증
    • 로깅 및 감사(Auditing)
    • 데이터 압축
    • Spring Context 외부 작업

Interceptor

  • Controller에 접근하기 직전 또는 응답 후 추가 작업 처리
  • 주요 역할:
    • 세부 권한 검사(Authorization)
    • 비즈니스 로직 관련 작업
    • Spring Context 조작

2️⃣ STOMP @EventListener와 StompHeaderAccessor 활용

StompHeaderAccessor를 사용하면 HandshakeInterceptor에서 저장한 세션 속성에 접근할 수 있습니다.

세션 이벤트 종류

  • SessionConnectEvent: 클라이언트의 STOMP CONNECT 프레임 수신 시 발생. 새로운 세션 시작을 알림.
  • SessionConnectedEvent: 브로커가 CONNECT에 대한 응답으로 STOMP CONNECTED 프레임을 전송했을 때 발생.
  • SessionDisconnectEvent: STOMP DISCONNECT 프레임 수신 또는 연결 종료 시 발생. 세션 종료를 알림.

이벤트 리스너를 활용해 연결이 종료될 때 마지막으로 읽은 메시지 ID를 업데이트합니다.


💻 코드 구현

HandshakeInterceptor

public class CustomHandshakeInterceptor implements HandshakeInterceptor {

    private final TokenService tokenService;

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;

        String accessToken = httpServletRequest.getHeader("Authorization");
        String provider = httpServletRequest.getHeader("provider");

        boolean isValidToken = tokenService.isValidToken(accessToken, provider);
        if (isValidToken) {
            attributes.put("user", tokenService.getUserFromToken(accessToken.substring(7), provider));
            return true;
        } else {
            response.setStatusCode(HttpStatus.UNAUTHORIZED); // 인증 실패
            return false;
        }
    }
}

EventListener

@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
    StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
    User user = (User) headerAccessor.getSessionAttributes().get("user");
    String sessionId = headerAccessor.getSessionId();
    Long chatRoomId = Long.parseLong(headerAccessor.getDestination().substring(DESTINATION_ROUTE.length()));

    if (user != null) {
        log.info("Client disconnected: " + sessionId + " (User: " + user.getNickname() + ")");
        
        ChatMessageDto chatMessageDto = chatMessagePolicy.getLastChatMessage(chatRoomId);
        chatActivityService.updateLastReadMessage(chatRoomId, new ChatActivityRequestDto(user.getId(), chatMessageDto.chatMessageId()));
    } else {
        log.error("Client disconnected: " + sessionId + " (User: Unknown)");
    }
}

✍️ 마무리

기존에는 API 호출 방식만 고집했지만, 세션 관리 방식을 도입하며 더 효율적인 설계를 구현할 수 있었습니다. 앞으로는 도전해야 할 부분을 더 과감히 시도해봐야겠다고 느꼈습니다.

profile
뽀글뽀글 개발공부

0개의 댓글