Spring 숙련 (WebSocket 도입, HandshakeInterceptor)

KimGwangmin·2026년 9월 16일

WebSocket

  • HTTP 요청으로 연결을 시작, 이후 WebSocket 프로토콜(ws/wss)로 전환하여 연결을 유지하는 실시간 통신 방식
    • HTTP(HTTPS) 포트는 보통 열려있기 때문에 이쪽으로 먼저 통신을 시작한다.
  • 서버와 클라이언트가 자유롭게 메시지를 주고받을 수 있는 양방향 통신
  • 채팅, 실시간 게임, 협업 기능 등에 사용됨
  • WebSocket도 핸드셰이크를 통해 연결이 열림
    • 요청 -> 응답으로 구성된 2-way Handshake이다.
  • 웹소켓이 연결되면 HTTP쪽 규약은 무시된다. (주고받는 메시지가 HTTP 형식을 따르지 않는다)
    • TLS를 사용하는 경우 TLS 계층은 유지된다.

SSE vs WebSocket
SSE도 WebSocket처럼 실시간으로 데이터를 전달할 수 있지만, WebSocket 프로토콜이 아닌 HTTP 연결 위에서 이루어진다. 또한 서버 -> 클라이언트 단방향 통신이다.
SSE는 알림, 실시간 로그, 상태 전달 등에 주로 사용한다.

Polling
클라이언트가 일정 간격으로 HTTP 요청을 보내 새 데이터가 있는지 확인하는 방식
실시간성이 떨어진다.

예제

Config

  • @EnableWebSocket 필요
  • addHandler()에서 /ws로 연결한 클라이언트의 메시지를 이 핸들러(WebSocketHandler)가 처리하도록 등록
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
    private final WebSocketHandler webSocketHandler;

    @Override
    public void registerWebSocketHandlers(
            WebSocketHandlerRegistry registry
    ) {
        registry.addHandler(webSocketHandler, "/ws");
    }
}

WebSocketHandler

TextWebSocketHandler: 문자열 WebSocket 메시지 처리용 기본 클래스
이를 상속하면 Spring이 연결, 수신, 종료 시점에 해당하는 메서드를 호출

@Slf4j
@Component
public class WebSocketHandler extends TextWebSocketHandler {
	// 연결
    @Override
    public void afterConnectionEstablished(
            WebSocketSession session
    ) {
        log.info("연결됨: id={}", session.getId());
    }

	// 수신
    @Override
    protected void handleTextMessage(
            WebSocketSession session,
            TextMessage message
    ) throws Exception {
        log.info("받음: {}", message.getPayload());
        // 이 예제에서는 받은 메시지를 그대로 다시 보내줌
        // 상대방이 없기 때문에 이런 방식으로 양방향 통신을 확인
        session.sendMessage(message);
    }

	// 종료
    @Override
    public void afterConnectionClosed(
            WebSocketSession session,
            CloseStatus status
    ) {
        log.info("끊김: id={} code={}", session.getId(), status.getCode());
    }
}

헷갈린 포인트: throw vs throws

  • throw
    • 메서드 내부에서 동작
    • 강제로 예외 객체를 발생시킴
public void checkAge(int age) {
    if (age < 0) {
        // 나이가 음수일 경우 강제로 예외 발생
        throw new IllegalArgumentException("나이는 음수가 될 수 없습니다.");
    }
}
  • throws
    • 메서드 선언부에 위치
    • 동작에는 영향 없음
    • 해당 메서드 내부에서 발생하는 예외를 호출한 곳으로 전가한다는 선언
// 이 메서드를 호출하는 곳에서 예외를 처리해야 함을 명시
public void readFile() throws IOException {
    FileReader file = new FileReader("not_found.txt");
}

handleTextMessage에서는 소켓을 통해 데이터를 전송한다. (session.sendMessage(message))
이때 물리적인 통신 에러(IOException 등)가 발생할 수 있고, 이런 네트워크 에러를 호출자(스프링)가 처리하도록 위임하기 위해 throws Exception을 붙여준다.

참고로, 표준적인 스프링 웹소켓 개발 관점에서는 afterConnectionEstablished, handleTextMessage, afterConnectionClosed에 모두 throws Exception을 열어두는 것이 일반적이다.

실제로 기본 인터페이스 WebSocketHandler를 확인해보면, 세 메서드 모두 throws Exception이 선언되어 있다.

스프링 컨테이너가 웹소켓 호출부 주변을 데코레이터 패턴(ExceptionWebSocketHandlerDecorator)으로 감싸고 있기 때문에, 메서드 밖으로 예외가 터져나왔을 때 적절한 핸들러를 호출하고 세션을 정리하도록 설계되어 있다.

메서드 내부에서는 비즈니스 로직과 연관된 에러(JSON 파싱 실패 등)만 처리하고, 일반적이고 예측 불가능한 에러(I/O 에러 등)는 컨테이너로 던지는 것이 책임 분리 관점에서 깔끔하다.

실습

마치 ws://localhost:8080/ws으로 바로 웹소켓 통신을 하는 것처럼 보이지만, 내부적으로는 HTTP 요청을 먼저 주고받고, 웹소켓 프로토콜로의 전환이 이루어진다.

HandshakeInterceptor

WebSocket 연결을 허용하기 전 필요한 처리를 수행하는 곳

  • 요청에 필요한 정보가 있는지, 그 값이 유효한지 등을 확인
  • 조건에 맞지 않는 요청(인터셉터의 beforeHandshake() 에서 false를 반환)은 거절
  • 확인한 정보는 이후에도 메시지 처리에 사용

이전에 공부한 Interceptor의 WebSocket 버전!

예제

인터셉터(연결 요청 검증)

@Component
public class NicknameHandshakeInterceptor implements HandshakeInterceptor {
    public static final String ATTR_NICKNAME = "nickname";
    private static final Pattern NICKNAME = Pattern.compile("^[A-Za-z]{2,12}$"); // 영문만 허용, 2~12 글자

    @Override
    public boolean beforeHandshake(
            ServerHttpRequest request,
            ServerHttpResponse response,
            WebSocketHandler handler,
            Map<String, Object> attributes
    ) {
        String query = request.getURI().getQuery(); // URL에서 쿼리 추출
        String nickname = (query != null && query.startsWith("nickname="))
                ? query.substring("nickname=".length()) : null; // 쿼리에서 필요한 정보 추출

		// 값의 유효성 검사
        // 정규식 패턴 매칭
        // 잘못된 입력은 400 BAD REQUEST
        if (nickname == null || !NICKNAME.matcher(nickname).matches()) {
            response.setStatusCode(HttpStatus.BAD_REQUEST);
            return false;
        }

		// 검사한 정보를 이후 사용할 수 있도록 보관
        attributes.put(ATTR_NICKNAME, nickname);
        return true;
    }

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

세션 속성 전달

위에서 attributes.put(ATTR_NICKNAME, nickname);으로 넣어준 정보를 꺼내 활용한다.

@Component
public class WebSocketHandler extends TextWebSocketHandler {
    @Override
    public void afterConnectionEstablished(
            WebSocketSession session
    ) throws Exception {
        String nickname = (String) session.getAttributes()
                .get(NicknameHandshakeInterceptor.ATTR_NICKNAME);
        TextMessage response = new TextMessage(nickname);
        session.sendMessage(response);
    }
}

인터셉터 등록(Config)

핸들러와 마찬가지로 인터셉터도 등록해주어야 함

@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
    private final WebSocketHandler webSocketHandler;
    private final NicknameHandshakeInterceptor nicknameHandshakeInterceptor;

    @Override
    public void registerWebSocketHandlers(
            WebSocketHandlerRegistry registry
    ) {
        registry.addHandler(webSocketHandler, "/ws")
                .addInterceptors(nicknameHandshakeInterceptor);
    }
}

실습

닉네임이 없을 때닉네임 형식이 틀릴 때닉네임이 올바를 때

0개의 댓글