WebSocketConfig 구현2

PUROMANGA·2025년 5월 29일

기술스택

목록 보기
14/22

출처

https://velog.io/@ktf1686/Spring-WebSocket%EC%9C%BC%EB%A1%9C-%EC%B1%84%ED%8C%85-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-%EC%9D%B8%ED%84%B0%EC%85%89%ED%84%B0-%EC%84%A4%EC%A0%95-%EB%B0%8F-%EC%9D%B8%EC%A6%9D-%EC%B2%98%EB%A6%AC

개요

WebSocket 연결 요청은 HTTP로 시작되지만, 일반적인 Spring Security 필터 체인은 거의 적용되지 않는다.
따라서 WebSocket 연결 시점에서 토큰을 검증하려면, Spring Security 필터가 아닌 HandshakeInterceptor를 통해 직접 인증 처리를 해줘야 한다.
이때 토큰을 꺼내 사용자 정보를 세션 또는 메시지에 주입하면, 이후의 STOMP 메시지 처리에도 사용자 정보를 활용할 수 있다.

HttpHandshakeInterceptor

소개념

✅ 1. ServerHttpRequest

  • ServerHttpRequest
    이것은 Spring이 제공하는 HTTP 추상화 인터페이스야.
    HttpServletRequest보다 더 범용적이고 스프링스럽게 추상화된 형태.
    WebSocket 핸드셰이크 같은 특수한 요청 처리에 자주 쓰임.

✅ 2. ServletServerHttpRequest가 뭐야?

  • ServletServerHttpRequest
    ServerHttpRequest를 HttpServletRequest로 감싼 구현체야.
    즉, 내부적으로 실제 HttpServletRequest에 접근할 수 있음.

코드

@Slf4j
public class HttpHandshakeInterceptor implements HandshakeInterceptor {

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

		if(request instanceof ServletServerHttpRequest servletServerHttpRequest) {
			HttpServletRequest httpServletRequest = (HttpServletRequest) servletServerHttpRequest;

			String authHeader = httpServletRequest.getHeader("Authorization");

			if(authHeader != null && authHeader.startsWith("Bearer ")) {
				String token = authHeader.substring(7);

                if (!jwtProvider.validateToken(token)) {
                    throw new RuntimeException("유효하지 않은 JWT");
                }

                String username = jwtProvider.getUsernameFromToken(token);

                // 🔥 인증된 사용자 정보를 WebSocket 세션에 저장
                attributes.put("username", username);

			} else {
				throw new RuntimeException("헤더 정보가 없습니다");
			}
		}
		return false;
	}

	@Override
	public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
		Exception exception) {

		if(request instanceof ServletServerHttpRequest servletServerHttpRequest) {
			String ip = servletServerHttpRequest.getServletRequest().getRemoteAddr();
			String userAgent = servletServerHttpRequest.getServletRequest().getHeader("User-Agent");

			log.info("WebSocket 연결됨 - IP: {}, UA: {}", ip, userAgent);
		}
	}
}

StompHandler

package com.websocket.chat.config.handler;

// import ... 생략

@Slf4j
@RequiredArgsConstructor
@Component
public class StompHandler implements ChannelInterceptor {

    private final JwtTokenProvider jwtTokenProvider;

    // websocket을 통해 들어온 요청이 처리 되기전 실행된다.
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        if (StompCommand.CONNECT == accessor.getCommand()) { // websocket 연결요청
            String hi = accessor.getFirstNativeHeader("hi");
            String bye = accessor.getFirstNativeHeader("bye");
            log.info("CONNECT {}", hi);
            log.info("CONNECT {}", bye);
        } else if (StompCommand.SUBSCRIBE == accessor.getCommand()) { // 채팅룸 구독요청
            System.out.println("message: ");
            System.out.println(message);
            // header정보에서 구독 destination정보를 얻고, roomId를 추출한다.
            String roomId = chatService.getRoomId(Optional.ofNullable((String) message.getHeaders().get("simpDestination")).orElse("InvalidRoomId"));
            // 채팅방에 들어온 클라이언트 sessionId를 roomId와 맵핑해 놓는다.(나중에 특정 세션이 어떤 채팅방에 들어가 있는지 알기 위함)
            String sessionId = (String) message.getHeaders().get("simpSessionId");
            chatRoomRepository.setUserEnterInfo(sessionId, roomId);
            // 채팅방의 인원수를 +1한다.
            chatRoomRepository.plusUserCount(roomId);
            // 클라이언트 입장 메시지를 채팅방에 발송한다.(redis publish)
            String name = Optional.ofNullable((Principal) message.getHeaders().get("simpUser")).map(Principal::getName).orElse("UnknownUser");
            chatService.sendChatMessage(ChatMessage.builder().type(ChatMessage.MessageType.ENTER).roomId(roomId).sender(name).build());
            log.info("SUBSCRIBED {}, {}", name, roomId);
        } else if (StompCommand.DISCONNECT == accessor.getCommand()) { // Websocket 연결 종료
            // 연결이 종료된 클라이언트 sesssionId로 채팅방 id를 얻는다.
            String sessionId = (String) message.getHeaders().get("simpSessionId");
            String roomId = chatRoomRepository.getUserEnterRoomId(sessionId);
            // 채팅방의 인원수를 -1한다.
            chatRoomRepository.minusUserCount(roomId);
            // 클라이언트 퇴장 메시지를 채팅방에 발송한다.(redis publish)
            String name = Optional.ofNullable((Principal) message.getHeaders().get("simpUser")).map(Principal::getName).orElse("UnknownUser");
            chatService.sendChatMessage(ChatMessage.builder().type(ChatMessage.MessageType.QUIT).roomId(roomId).sender(name).build());
            // 퇴장한 클라이언트의 roomId 맵핑 정보를 삭제한다.
            chatRoomRepository.removeUserEnterInfo(sessionId);
            log.info("DISCONNECTED {}, {}", sessionId, roomId);
        }
        return message;
    }
}

✅ 주요 로직 정리

1. CONNECT (웹소켓 연결 요청)

if (StompCommand.CONNECT == accessor.getCommand()) {
    String hi = accessor.getFirstNativeHeader("hi");
    String bye = accessor.getFirstNativeHeader("bye");
    log.info("CONNECT {}", hi);
    log.info("CONNECT {}", bye);
}
  • 클라이언트가 WebSocket 연결을 시도할 때 발생

  • 여기선 일단 "hi", "bye" 같은 STOMP 헤더값을 로그로 출력

  • 실무에서는 여기서 Authorization 헤더 받아서 JWT 인증 처리하는 게 일반적임

2. SUBSCRIBE (채팅방 구독 요청)

else if (StompCommand.SUBSCRIBE == accessor.getCommand()) {

이 안에서 하는 일:

작업설명
① destination 추출simpDestination에서 채팅방 ID 뽑음
② sessionId → roomId 맵핑나중에 유저 퇴장 시 어떤 방에 있었는지 알기 위해 저장
③ 인원수 증가채팅방 참여자 수 카운팅
④ 입장 메시지 생성 및 전송Redis나 서버 내부로 "누가 입장했어요" 메시지 발송
⑤ 유저 이름 추출simpUser → Principal 객체에서 이름 가져옴

3. DISCONNECT (웹소켓 연결 종료)

else if (StompCommand.DISCONNECT == accessor.getCommand()) {

이 안에서 하는 일:

작업설명
① sessionId → roomId 조회이 유저가 어떤 방에 있었는지 파악
② 인원수 감소참여자 -1
③ 퇴장 메시지 발송채팅방에 "누가 퇴장했어요" 메시지 보냄
④ 유저-방 맵핑 정보 제거메모리/캐시 정리 작업

Message<?> message, MessageChannel channel 이거 두 개 밖에 없는데 어디서 받아오지?

StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

✅ STOMP 헤더는 accessor.getFirstNativeHeader("Authorization") 이렇게 꺼내는 거야.

String token = accessor.getFirstNativeHeader("Authorization");

🔍 왜 Message<?> message 밖에 없는데 가능한가?
Spring WebSocket에서는 Message<?> 내부에 있는 STOMP 헤더들(Authorization, destination, sessionId 등)을
StompHeaderAccessor라는 도구를 통해 꺼낼 수 있게 설계돼 있어.

📌 예시: JWT 인증 처리하는 패턴

if (StompCommand.CONNECT.equals(accessor.getCommand())) {
    String token = accessor.getFirstNativeHeader("Authorization");

    if (token == null || !token.startsWith("Bearer ")) {
        throw new IllegalArgumentException("JWT 토큰이 없습니다.");
    }

    token = token.substring(7); // "Bearer " 제거

    if (!jwtTokenProvider.validateToken(token)) {
        throw new IllegalArgumentException("유효하지 않은 토큰입니다.");
    }

    String username = jwtTokenProvider.getUsernameFromToken(token);

    // 유저 정보를 인증된 Principal로 설정
    accessor.setUser(new StompPrincipal(username)); // Principal 인터페이스 구현체
}

0개의 댓글