일반적인 HTTP 요청:
WebSocket:
상황 | 발생 가능 문제 |
---|---|
WebSocket 연결 시 인증 없음 | 아무나 WebSocket 연결 가능 |
악성 사용자 WebSocket 연결 후 메시지 전송 | 채팅방에서 권한 없는 사용자도 메시지 송신/수신 가능 |
서버가 사용자 정보를 모름 | 누가 어떤 메시지를 보내는지 서버가 파악 불가 → 전체 서비스 위험 |
/ws
엔드포인트로 JWT 포함해서 연결 시도서버 쪽 JwtHandshakeInterceptor
에서 토큰 검증 후 사용자 인증 정보 저장
인증된 사용자만 채팅방 입장 허용
이후 메시지 처리 시에도 인증된 사용자 정보 기반으로 송신/수신 처리
ChannelInterceptor
추가 구현 추천 // WebSocket 연결(핸드셰이크) 요청이 들어올 때 JWT 토큰을 검증해서 인증된 사용자만 WebSocket 연결 허용
// 인증된 사용자의 정보를 WebSocket 세션에 저장 → 이후 채팅 메시지 처리 시 활용 가능
public class JwtHandshakeInterceptor implements HandshakeInterceptor {
private final JwtUtil jwtUtil;
public JwtHandshakeInterceptor(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
// WebSocket 연결 요청이 Servlet 기반 HTTP 요청인지 확인
// 이유: HttpServletRequest 에서 헤더를 읽으려고 하기 때문 + Spring WebSocket 지원 구조 때문
if (request instanceof ServletServerHttpRequest servletRequest) {
// 원본 HttpServletRequest 꺼내오기
HttpServletRequest httpServletRequest = servletRequest.getServletRequest();
// JWT 토큰 추출 (Authorization 헤더에서 Bearer 토큰 가져오기)
String token = jwtUtil.extractToken(httpServletRequest);
// 토큰이 존재하고, 유효한지, 그리고 ACCESS 토큰인지 확인
if (token != null && jwtUtil.validateToken(token) && jwtUtil.isAccessToken(token)) {
// 인증 성공 → 사용자 정보(UserAuth)를 WebSocket 세션에 저장
attributes.put("userAuth", jwtUtil.extractUserAuth(token));
return true; // → WebSocket 연결 허용
} else {
// 인증 실패 → WebSocket 연결 거부
return false;
}
}
// Servlet 기반 요청이 아니면 거부
return false;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {
// 아무것도 안 해도 됨
// afterHandshake 는 지금은 별도 로직 필요 없음 (빈 메서드)
}
}
이것은 WebSocket 인증 흐름을 알면 깊게 이해할 수 있다.
1️⃣ WebSocket은 처음부터 WebSocket 프로토콜이 아니다
Handshake
요청을 보냄GET /ws
2️⃣ 서버는 이 요청을 받고 → "업그레이드" 결정
1️⃣ HttpServletRequest에서 헤더를 읽으려고 하기 때문
HTTP 헤더 Authorization
에 담아서 전송Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
HttpServletRequest
객체가 필요2️⃣ Spring WebSocket 지원 구조 때문
Spring에서 HandshakeInterceptor의 beforeHandshake
메서드는 파라미터로 ServerHttpRequest
를 줌
그런데 ServerHttpRequest
는 여러 구현체가 있음:
ServletServerHttpRequest
→ 서블릿 기반 요청 (Tomcat, Jetty 등 서블릿 컨테이너 사용)
→ 우리가 원하는 경우!
다른 구현체 → WebFlux 기반 등
(Reactive Netty 기반 등)
→ 그래서 우리가 Servlet 기반일 때만 HttpServletRequest로 다운캐스팅 해서 헤더를 읽는 것
if (request instanceof ServletServerHttpRequest servletRequest) {
HttpServletRequest httpServletRequest = servletRequest.getServletRequest();
String token = jwtUtil.extractToken(httpServletRequest);
}
단계 | 설명 |
---|---|
클라이언트가 /ws 연결 시도 | HTTP 요청으로 Handshake 요청 보냄 |
HandshakeInterceptor의 beforeHandshake 실행 | ServerHttpRequest 객체 전달 |
우리가 헤더에서 JWT 읽으려면 → HttpServletRequest 필요 | → Servlet 기반인지 확인 (ServletServerHttpRequest 인지 확인) |
맞으면 HttpServletRequest로 변환 → 헤더에서 JWT 추출 후 검증 | 인증 성공 여부에 따라 연결 허용/거부 결정 |
✅ 서블릿 기반이 아닐 때는 HttpServletRequest가 없으므로 헤더를 못 읽음
→ 방어 코드 필요
✅ 예를 들어 WebFlux 기반으로 서버가 구성되어 있을 경우 ServerHttpRequest
구현체가 다름
→ NullPointerException 발생 가능
✅ 그래서 안전하게 instanceof 체크 후 변환
질문 | 답변 |
---|---|
왜 Servlet 기반 요청이어야 하나요? | JWT 토큰을 HTTP 헤더에서 읽어야 하고, 헤더는 HttpServletRequest를 통해서만 읽을 수 있기 때문 |
왜 instanceof 체크를 하나요? | HandshakeInterceptor는 다양한 ServerHttpRequest 구현체를 받을 수 있고, 우리가 원하는 HttpServletRequest는 Servlet 기반에서만 사용 가능하므로 안전하게 확인 후 사용 |
1️⃣
나중에 WebFlux (Reactive)로 서버 구성하게 되면 HandshakeInterceptor 구현 방식이 달라짐
→ 주의!
2️⃣
클라이언트가 JWT 토큰을 쿼리 파라미터로 보내게 하면 헤더 없이도 쓸 수 있지만
→ 보안상 권장되지 않음 (URL 노출 가능)
3️⃣
→ 지금 구조는 Spring Boot + Spring MVC (서블릿 기반) 이므로 현재 로직이 적절!
토큰 종류 | 용도 | 특징 |
---|---|---|
Access Token | 실제 API 호출, 리소스 접근 시 사용 | 만료 시간 짧음 (예: 30분) |
Refresh Token | Access Token 재발급 받을 때 사용 | 만료 시간 김 (예: 7일), 보안상 더 엄격하게 관리 |
1️⃣ WebSocket 연결 목적은? → 실시간 서비스 사용 (ex. 채팅방)
WebSocket 연결 = 실시간 기능에 직접 접근하는 것
→ 실제 서비스 이용 중
즉, WebSocket 연결은 "인가된 사용자(로그인된 사용자)"가 이용해야 함
2️⃣ Refresh Token의 목적은?
메서드 | 언제 호출? | 주로 하는 일 |
---|---|---|
beforeHandshake | WebSocket 연결 시도 직전 | 인증 검사, 속성 설정, 연결 허용 여부 결정 |
afterHandshake | WebSocket 연결 성공 직후 | 주로 로깅, 감사 기록, 부가 처리 |
지금 내 코드의 흐름은:
1️⃣ beforeHandshake에서 JWT 검증
→ 연결 허용 여부 결정 (return true/false
)
2️⃣ JWT 검증 후 UserAuth를 attributes에 저장
3️⃣ 연결 성공 시 → WebSocket 연결이 정상 성립됨
그럼 afterHandshake
에서는 뭘 더 할 필요가 있을까?
→ 지금 단계에서는 딱히 추가할 로직이 없음
beforeHandshake
에서 끝났음쓰는 경우 | 예시 |
---|---|
감사 로깅(Audit Logging) | "누가 언제 WebSocket 연결했는지" 로그 기록 |
메트릭 수집 | WebSocket 연결 수 모니터링 (Prometheus 연동 등) |
연결 성공 후 알림 발송 | 관리자 알림, 슬랙 메시지 등 |
연결 성공 후 트랜잭션 처리 | 예: DB에 연결 로그 저장 등 |
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final JwtUtil jwtUtil;
/* 클라이언트가 최초로 WebSocket 연결을 시도할 때 접속할 엔드포인트 설정 */
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 서버 내부 엔드포인트 경로 설정 + CORS 설정 + SockJS 프로토콜 지원 추가 (fallback)
registry.addEndpoint("/ws")
.addInterceptors(new JwtHandshakeInterceptor(jwtUtil)) // 핸드셰이크 단계에서 JWT 토큰을 검증해서 인증된 사용자만 WebSocket 연결을 허용
.setAllowedOrigins("*")
.withSockJS();
}
}
→ JwtHandshakeInterceptor
에서 Authorization
헤더 또는 쿼리파라미터로 토큰 받아서 검증
[ 클라이언트 (브라우저, 앱) ]
|
| 1️⃣ WebSocket 연결 시도
| HTTP GET /ws + Upgrade: websocket
v
[ 서버 (Spring Boot) ]
┌────────────────────────────┐
│ registerStompEndpoints() │ → /ws 엔드포인트 등록
└────────────────────────────┘
|
| 2️⃣ Handshake 단계 시작
v
┌──────────────────────────────────────────┐
│ JwtHandshakeInterceptor.beforeHandshake() │ → JWT 인증 검증
└──────────────────────────────────────────┘
|
| 3️⃣ Handshake 성공 시 (HTTP 101 Switching Protocols)
v
[ WebSocket 연결 성립 (TCP 업그레이드 완료) ]
|
| 4️⃣ 이후 실시간 통신 (WebSocket 프레임)
v
┌────────────────────────────┐
│ @MessageMapping │ → 서버가 메시지 핸들링
└────────────────────────────┘
|
| (선택) 추가 보안:
v
┌────────────────────────────────────────┐
│ ChannelInterceptor.preSend(), postSend()│ → 메시지 레벨 인증/인가 가능
└────────────────────────────────────────┘