웹소켓 요청을 인터셉터에서 처리하기(feat filter)
개요
웹소켓을 처음 도입했을 때, 어차피 그냥 요청이니까 filter에서 인증 처리하면 되지 않을까? 생각했다.
처음엔 잘 되네 싶었는데, 시간이 지나면서 왜 이 구조가 잘못됐는지 깨달은 점을 기록하려 한다
WebSocket
웹소켓은 흔히 말하는 HTTP 요청/응답 기반 구조랑은 다르다.
한번 연결되면 계속 연결 상태가 유지되는 연결 지향형(Connection-Oriented) 프로토콜이다.
웹소켓이 처음 연결될 때는 HTTP 프로토콜로 요청이 보내지지만,
Upgrade: websocket이라는 헤더를 통해 프로토콜을 바꾸는 핸드쉐이크 과정을 거친다.
이 핸드쉐이크 과정에서 서버가 승인하면 비로소 TCP연결이 완성된다.
따라서 정리하자면
1. HTTP 요청 (/ws?token=xxx) -> 핸드쉐이크 시작
2. 서버가 Upgrade 수락 -> WebSocket 연결 성립
3. 그 이후부터 WebSocket 메시지 주고받기
여기서 핵심은 3번인데, 핸드쉐이크 이후부터는 더이상 HTTP가 아니고, 필터나 Security Context도 관여하지 않는다.
TCP 관점에서의 웹소켓 연결 흐름
웹소켓은 TCP 위에서 동작하는 프로토콜이다.
웹소켓을 사용하려면 TCP 연결부터 확립되어야 하고, 연결을 끊을 때도 TCP의 4-way termination을 따라야 한다.
//웹소켓 전체 흐름
1. TCP 3-Way Handshake
2. HTTP Request with Upgrade: websocket
3. HTTP 101 Switching Protocols
4. WebSocket connected
5. 메시지 송수신
6. WebSocket close
7. TCP 4-Way Handshake
TCP 3-Way Handshake는, WebSocket 연결 전 TCP 연결 과정이다.
클라이언트가 -> 서버로 "연결하고 싶다"고 "SYN" (Synchronize Sequence Number) 을 보내고,
서버가 -> 클라이언트로 "알겠다" 고 "SYN-ACK" 를 보낸다. 그럼 다시
클라이언트가 -> 서버로 "그럼 연결하자" 고 "ACK" (Acknowledgment)를 보낸다.
단순하게 서버가 클라이언트의 synchronized한 일련번호를 승인하기 위해 왕복하는 과정이라고 보면 된다.
HTTP Upgrade 과정은, HTTP를 WebSocket 통신 프로토콜로 업그레이드 하는거다.
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: XXXXXX==
Sec-WebSocket-Version: 13
클라이언트 요청에 이렇게 Upgrade: websocket을 넣어보내고,
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: XXXXX==
서버가 101 Switching Protocols로 응답을 주어서, WebSocket 프로토콜로 통신이 시작된다.
(이 때 내부적으로는 여전히 TCP 연결은 유지된 채로 프로토콜만 전환된다.)
이후 클라이언트와 서버는 WebSocket 프레임을 HTTP가 아니라 TCP위에서 주고받는다.
연결 종료 시, TCP 4-Way Handshake 절차를 거친다.
이 과정을 통해 연결이 양방향으로 안전하게 종료된다.
아무튼 결론은, 웹소켓 연결 흐름은 이러하고, 웹소켓은 HTTP와 반쯤 무관하다는거다.
Filter
그래서 처음에 Filter로 구현하고 느낀 위화감을 설명하자면,, 우선 초반에 구현했던 로직은
//JwtFilter
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (request.getRequestURI().startsWith("/ws")) {
processWebSocketAuthentication(request, response, chain);
return;
}
private void processWebSocketAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws IOException, ServletException {
String token = request.getParameter("token");
if (token == null || token.isEmpty()) {
filterExceptionHandler.sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "웹소켓 토큰을 찾을 수 없습니다.");
return;
}
Claims claims = jwtUtil.extractClaims(jwtUtil.substringToken(token));
String[] data = claims.getSubject().split(":");
request.setAttribute("memberId", Long.parseLong(String.valueOf(data[0])));
chain.doFilter(request, response);
}
//Handler
@Override
public void afterConnectionEstablished(WebSocketSession session) {
String token = Objects.requireNonNull(session.getUri()).getQuery().split("=")[1];
Claims claims = jwtUtil.extractClaims(jwtUtil.substringToken(token));
String[] data = claims.getSubject().split(":");
Long memberId = Long.valueOf(data[0]);
sessionManager.addSession(memberId, session);
}
1. /ws 요청에 대해 filter를 적용함
2. token 파라미터가 없으면 401에러 반환
3. 있으면 토큰 파싱
4. 파싱 후 세션 핸들러에서, 토큰 한번 더 파싱 << ??
필터에서 이미 토큰 검증을 다 했는데 여기서 왜 또 같은 걸 하고있지? 여기서부터 잘못된걸 느꼈다..
filter에서 설정(setAttribute)한 값은 WebSocketSession으로 전달되지 않는거다..
WebSocketSession은 HandshakeHandler가 핸드쉐이크를 성공적으로 마친 다음에
Spring이 내부적으로 새로만들고, 초기화하는 객체다.
그리고 필터에서의 설정값은 HTTP요청 생명주기 안에서만 유효하고,
Spring이 만든 WebSocketSession에는 아무런 영향을 주지 못한다.
Interceptor
//Interceptor
public class WebSocketHandshakeInterceptor implements HandshakeInterceptor {
private final JwtUtil jwtUtil;
@Override
public boolean beforeHandshake(
@NonNull ServerHttpRequest request,
@NonNull ServerHttpResponse response,
@NonNull WebSocketHandler wsHandler,
@NonNull Map<String, Object> attributes
) {
try {
String query = ((ServletServerHttpRequest)request).getServletRequest().getQueryString();
String token = Arrays.stream(query.split("&"))
.filter(p -> p.startsWith("token="))
.map(p -> p.substring(6))
.findFirst()
.orElse(null);
Claims claims = jwtUtil.extractClaims(token);
Long memberId = Long.valueOf(claims.getSubject().split(":")[0]);
attributes.put("memberId", memberId);
return true;
} catch (JwtException e) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return false;
}
}
반면 HandshakeInterceptor는, Spring이 WebSocketSession을 만들기 바로 직전에 호출한다.
그래서 WebSocketSession을 세팅하고싶으면, 인터셉터를 사용해야 한다.
beforeHandshake메서드에서 attributes.put(memberId)를 하면,
여기서의 attributes Map은 내부적으로 WebSocketSession 생성 시 그대로 복사된다.
Map<String, Object> attributes = new ConcurrentHashMap<>(); 에 주목하자
WebSocket 세션이 생성될 때, 인터셉터에서 만든 attributes(put(memberId, 1L)했던 거)를 파라미터로 넘겨주고, 널체킹 하고 난 뒤에
this.attributes.put으로 내부 attributes 필드에 복사하게된다.
//Handler
@Override
public void afterConnectionEstablished(WebSocketSession session) {
Long memberId = (Long) session.getAttributes().get("memberId");
sessionManager.addSession(memberId, session);
}
그래서 핸들러에서 이렇게 바로 꺼내 쓸 수 있다.
이 구조를 허접한 그림으로 표현하면 이렇게 ....
회고
인터셉터로 바꾸고나서 핸드쉐이크 시점에 토큰을 검증하고, 그 결과를 WebSocketSession에 attributes로 심으니
코드도 깔끔해지고, 역할도 명확하게 분리되고, 무엇보다 스프링 설계 원칙 (?) 을 지키게 되었다
웹소켓은 REST API와는 완전 다른 구조다. 요청-응답이 끝이 아니라 연결 지향형이기 때문에.. 필터로 무작정 구현했던게, 역시 필터라는 게 뭔지도 제대로 모르고 사용했던 거 같다 ㅎㅎ;
알고 나니 재밌다..