2025. 1. 24. 19:10ㆍTIL
웹소켓에 연결할때 토큰을 검증하고 토큰이 만료되었다면 웹소켓을 연결을 끊고 토큰을 재발급 받은다음 다시 웹소켓에 연결하는 로직은 구현이 되었다.
하지만 이미 웹소켓에 연결이 된 상태에서는 accessToken의 만료기간이 끝났음에도 불구하고 메세지를 보낼수가 있는 상황이다.
토큰의 만료시간이 지난것을 볼수 있지만
메세지는 여전히 보낼수 있는 상황이다.
이는 웹소켓을 연결하는 순간에는 토큰 검증이 이루어져 연결에 성공하지만 HTTP 통신과 다르게 지속적으로 연결이 되는 상황이기때문에 연결에 성공한 다음부터는 토큰이 만료가 되더라도 서버 입장에서는 알 수 없는것이다.
물론 사용자가 메세지를 보낼때 토큰도 같이 보내게 하는 식으로도 구현이 가능하지만, 매세지를 보낼때마다 토큰을 검증하는 것은 속도 저하 문제가 발생할수 있어서 다른 방법을 찾아보았다.
Ping Pong 방식
WebSocket 통신에서 연결상태를 유지하거나 확인하기 위해서 사용하는 방식
Ping Pong방식을 사용하여 사용자의 accessToken을 주기적으로 검증하고 refreshToken을 통해서 accessToken을 재발급 받는 방식으로 구현할 것이다. 이를 통해서 새로고침을 하지않더라도 실시간으로 서버 입장에서는 사용자의 인증정보를 확인할 수 있다.
Controller
@MessageMapping("/ping")
fun handlePing(
@Payload message: Map<String, String>, headerAccessor: SimpMessageHeaderAccessor) {
chatService.handlePing(message, headerAccessor)
}
Service
fun handlePing(
message: Map<String, String>,
headerAccessor: SimpMessageHeaderAccessor) {
val token = message["token"] ?: throw IllegalArgumentException("Token is missing")
jwtTokenManager.validateToken(token).onFailure { exception ->
if (exception is ExpiredJwtException) {
val sessionId = headerAccessor.sessionId
val session = headerAccessor.sessionAttributes?.get(sessionId) as? WebSocketSession
?: throw IllegalStateException("WebSocketSession not found for sessionId: $sessionId")
session.close(CloseStatus(4001, "Expired JWT Token"))
return
} else {
throw IllegalArgumentException("Invalid Token")
}
}
}
클라이언트가 AccessToken이 담긴 메세지를 ping으로 보내면 해당 토큰을 검증하고 만약 검증에 실패한다면 그 원인이 토큰의 만료기간에 의한것이라면 클라이언트 웹소켓 세션으로 4001 상태코드를 보내 웹소켓 세션을 닫는다.
웹소켓 핸들러 (세션 id 저장 로직 추가)
package hjp.hjchat.infra.security.jwt.websocket
import org.springframework.web.socket.WebSocketSession
import org.springframework.web.socket.handler.WebSocketHandlerDecorator
import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory
import org.springframework.stereotype.Component
import org.springframework.web.socket.CloseStatus
import org.springframework.web.socket.WebSocketHandler
@Component
class CustomWebSocketHandlerDecoratorFactory : WebSocketHandlerDecoratorFactory {
override fun decorate(handler: WebSocketHandler): WebSocketHandler {
return object : WebSocketHandlerDecorator(handler) {
override fun afterConnectionEstablished(session: WebSocketSession) {
val tokenValid = session.attributes["tokenValid"] as? Boolean ?: false
println("✅ WebSocket 연결 세션 저장 - sessionId: ${session.id}")
session.attributes[session.id] = session // 세션 저장
println(" ------------------tokenValid값:$tokenValid ----------------------")
if (!tokenValid) {
println("🔒 유효하지 않은 토큰 - WebSocket 연결 종료")
session.close(CloseStatus(4001, "Invalid JWT Token"))
// 🔒 연결 종료
return
}
println("✅ WebSocket 연결 성공")
super.afterConnectionEstablished(session)
}
override fun afterConnectionClosed(session: WebSocketSession, closeStatus: CloseStatus) {
println("❌ WebSocket 세션 종료 - sessionId: ${session.id}")
session.attributes.remove(session.id) // 세션 제거
super.afterConnectionClosed(session, closeStatus)
}
}
}
}
웹소켓이 연결되거나 종료할때 sessionId를 저장하고 삭제한다. (해당 handler의 경우 웹소켓에 처음 연결거나 연결이 종료될때 작동하는 함수이다. afterConnectionClosed와 afterConnectionEstablished의 주기)
Client
// WebSocket 초기화
function initializeWebSocket() {
const socket = new SockJS(`https://localhost:443/ws?token=${accessToken}`);
ws = Stomp.over(socket);
ws.connect({}, function (frame) {
console.log("WebSocket 연결 성공:", frame);
if (currentChatRoomId) {
ws.subscribe(`/topic/chatroom/${currentChatRoomId}`, function (message) {
displayMessage(JSON.parse(message.body));
});
}
startPing()
}, function (error) {
console.error("WebSocket 연결 실패:", error);
});
socket.onclose = function (event) {
console.warn(`WebSocket 연결 종료. 코드: ${event.code}, 이유: ${event.reason}`);
// ✅ Ping Interval 정리
clearInterval(pingInterval);
pingInterval = null;
// ✅ 토큰 만료(4001) 시 Access Token 재발급 시도
if (event.code === 4001) {
reissueAccessToken().then(() => {
console.log("🔑 토큰 재발급 성공, 웹소켓 재연결 시도");
initializeWebSocket(); // 🔄 웹소켓 재연결
}).catch(() => {
alert("세션이 만료되었습니다. 다시 로그인 해주세요.");
window.location.href = "./sign-in.html"; // ❌ 실패 시 로그인 페이지로 이동
});
} else {
console.error("예상치 못한 웹소켓 종료입니다.");
}
};
// ❗ 에러 발생 시 단순 로그 출력
socket.onerror = function (error) {
console.error("WebSocket 오류 발생:", error);
};
}
function startPing() {
if (pingInterval) {
clearInterval(pingInterval); // 기존 인터벌 제거
}
// 새로운 Ping 인터벌 시작
pingInterval = setInterval(() => {
sendPing();
}, 1 * 60 * 1000); // 1분마다 Ping
}
function sendPing() {
if (ws && ws.connected) {
ws.send("/app/ping", {}, JSON.stringify({ token: accessToken }));
console.log("서버로 ping 요청을 보냈습니다.");
} else {
console.warn("WebSocket이 연결되지 않아 ping 요청을 보낼 수 없습니다.");
}
}
클라이언트는 1분주기로 서버로 ping 요청을 보낸다. ping에는 accessToken의 정보가 담겨져있다.
클라이언트 로그
서버 로그
동작 과정
1. 최초 접속시 클라이언트가 웹소켓 연결을 시도함
2. accessToken이 만료됨으로 인하여 연결에 실패했다면 refreshToken을 이용하여 서버로부터 재발급요청을 하여 새로운 AccessToken을 발급 받고 재 연결을 시도함.
3. 웹소켓 연결이 성공한 후 주기적으로 1분 간격마다 WebSocket의 app/ping 으로 토큰 정보를 담고있는 ping 메세지를 보냄
4. 서버는 @MessageMapping("/ping") 으로 핸들링하여 메세지에 담겨있는 token을 검증함
5. 해당 토큰의 만료기간이 지났다면 클라이언트에 4001상태코드를 보내며 웹소켓 연결을 종료함
6. 클라이언트는 4001코드로 인하여 웹소켓 연결이 종료되었다면 refreshToken을 이용하여 서버로부터 재발급 요청을 하여 새로운 AccessToken을 발급 받고 재 연결을 시도함
영상 테스트: https://youtu.be/TQdA1aK5aZE
'TIL' 카테고리의 다른 글
kafka 실행중 EC2 메모리 부족으로 인한 실행 종료 (0) | 2025.01.26 |
---|---|
HJ CHAT SERVER EC2에 배포하기 (0) | 2025.01.25 |
𝐖𝐞𝐛𝐒𝐨𝐜𝐤𝐞𝐭 통신에서 𝐀𝐜𝐜𝐞𝐬𝐬𝐓𝐨𝐤𝐞𝐧 재발급 받기 (최초 실행시) (0) | 2025.01.23 |
𝑟𝑒𝑓𝑟𝑒𝑠ℎ𝑇𝑜𝑘𝑒𝑛을 통해 𝑎𝑐𝑐𝑒𝑠𝑠𝑇𝑜𝑘𝑒𝑛 재발급 (1) | 2025.01.22 |
테스트환경에서 SSL/HTTPS 설정하기 (0) | 2025.01.15 |