HTTP, 특히 HTTP/1.1 이하에서는 클라이언트가 서버에게 요청을 보내고 서버가 그에 응하여 응답을 보내는 방식으로 단방향 소통이 이루어진다. 서버가 클라이언트에게 메세지를 보내는 건 클라이언트의 요청에 응답을 할 때 뿐이다.
일반적인 기능들은 이러한 단방향 방식으로 구현이 가능하지만 다른 방식이 필요할 때가 있다.
채팅앱을 생각해보면, 내가 상대방에게 메세지를 보내는 것은 클라이언트에서 서버로 통보하면 되는 것이니 문제없지만 상대방이 메세지를 보냈을 때 이를 내 앱에서 감지하는 것은 불가능하다. HTTP/1.1 이하에서는 클라이언트의 요청 없이는 서버에서 메세지를 못하기 때문이다. HTTP를 사용해서 이 문제를 해결하자고 한다면 Polling 이라는 방법이 있다.
클라이언트가 주기적으로 서버에 요청을 보내서 상대가 새 챗을 보내는지 확인하는 것이다.
서버는 업데이트가 있으면 있다고, 없으면 없다고 바로 응답을 보낸다.
이 방법에는 크게 두 가지 문제가 있다.
- 첫째로, 요청을 보내는 주기만큼의 지연이 발생할 수 있다. 서버가 갖고 있는 상태의 변화에 즉각적으로 반응하지 못한다.
- 두번째는 계속해서 불필요한 요청들이 보내진다는 것이다. 요청의 주기가 짧다면 반응속도는 빨라지겠지만 트래픽의 낭비는 더 심해질 것이다.
이를 개선한 시도로는 Long Polling이 있다.
Long Polling에서는 서버가 클라이언트의 요청에 바로 응답하지 않고 업데이트가 발생할 때까지 기다린다.
그러다 상대방이 챗을 보내거나 타임아웃으로 설정된 시간이 지나면 응답을 보내고 클라이언트는 다시 요청을 보내서 다음 응답을 기다리는 것이다.
이 방식을 사용하면 데이터의 업데이트에 반응하는 속도는 빨라지지만 서버의 부담이 커진다. 서버가 클라이언트로부터 요청을 받을 때부터 응답을 보내기까지 클라이언트와의 연결이 지속되는데 동시에 여러 클라이언트가 서비스를 사용하면 그만큼의 연결을 유지해야 하므로 부하가 발생하게 되는 것이다. 이는 업데이트에 대한 반응이 느려지는 결과로 나타나기도 한다. HTTP에서의 요청과 응답에 포함되는 헤더 정보의 양도 매 번 부담으로 작용한다.때문에 이러한 서비스를 제대로 구현하기 위해서는 클라이언트와 서버가 동등하게 메세지를 주고 받을 수 있는 즉, 양방향 통신이 가능한 방식이 필요하다. -> Websocket
서버와 연결을 유지하면서 양방향 통신을 유지할 수 있다.
HTTP/1.1이 클라이언트가 편지로 요청을 보내고 서버는 답장만 하는 방식이라면, WebSocket은 서로가 자유롭게 대화를 주고받는 전화통화라고 할 수 있다. WebSocket 은 다음과 같은 방식으로 이루어진다.
- 클라이언트에서는 서버에게 WebSocket을 연결하자는 요청을 HTTP를 통해 보낸다.
- 서버는 그것이 가능한 경우 이를 수락하는 응답을 역시 HTTP로 보낸다.이 과정을 handshake라고 한다.
- 그렇게 연결이 이뤄지고 나면 그 때부터 클라이언트와 서버는 HTTP가 아닌 WebSocket 프로토콜을 사용하여 소통한다. 여기서 클라이언트와 서버는 자유롭게 서로에게 메세지를 보낼 수 있다.
WebSocket에서의 통신은 헤더의 크기가 작고 오버헤드가 적기 때문에 HTTP 보다 효율적인 통신이 가능하다.
이 전화통화는 한 쪽이 다른 쪽에게 연결을 종료하자는 메세지를 보낼 때까지 지속된다.
한 쪽이 close 프레임을 보내면 다른 쪽이 이를 확인하고 역시 close 프레임을 응답으로 보냄으로써 연결이 종료되는 것이다.
비정상적인 종료를 감지하는 방법들이 있다.
지정된 시간동안 메세지가 없을 시 확인 패킷을 보내는 방법도 있고, 주기적으로 ping, pong 프레임을 주고받고서 서로의 접속 여부를 확인하는 방법도 있다.
WebSocket은 하나의 연결을 끝까지 유지하고 그 과정에서도 적은 자원만 소모하기 때문에 Long Polling 만큼 서버에 부담을 주지도 않는다. 이 WebSocket을 사용해서 채팅 앱과 같이 실시간으로 서버로부터의 업데이트가 필요한 서비스들을 효율적으로 구축할 수 있는 것이다.
WebSocket에서의 통신은 TCP를 사용하기 때문에 데이터의 순서와 신뢰성이 보장된다.
이와 같은 장점들 덕분에 WebSocket은 채팅앱 뿐 아니라 온라인 게임, 주식 관련 앱, 협업 도구, 위치 추적 등 실시간 양방향 통신이 필요한 수많은 분야에 활용된다.
@EnableWebSocket
@Configuration
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketAuthHandler webSocketAuthHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketAuthHandler, "/chat")
.setAllowedOrigins("*"); // Allow requests from all origins
}
}
public class WebSocketSessionManager {
private final Map<Long, CopyOnWriteArrayList<WebSocketSession>> roomSessions = new ConcurrentHashMap<>();
public void addSessionToRoom(Long roomId, WebSocketSession session) {
roomSessions.computeIfAbsent(roomId, k -> new CopyOnWriteArrayList<>()).add(session);
}
public void removeSessionFromRoom(Long roomId, WebSocketSession session) {
List<WebSocketSession> sessions = roomSessions.get(roomId);
if (sessions != null) {
sessions.remove(session);
if (sessions.isEmpty()) {
roomSessions.remove(roomId);
}
}
}
public List<WebSocketSession> getSessionsForRoom(Long roomId) {
return roomSessions.getOrDefault(roomId, new CopyOnWriteArrayList<>());
}
}
// 들어오는 WebSocket 메시지를 관리하기 위한 핸들러
@Log4j2
@Component
@RequiredArgsConstructor
public class WebSocketAuthHandler extends TextWebSocketHandler {
private final ChatService chatService;
private final MemberService memberService;
private final NotificationService notificationService;
private final TokenProvider tokenProvider;
private final ObjectMapper objectMapper = new ObjectMapper();
private final WebSocketSessionManager sessionManager = new WebSocketSessionManager();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
log.info("WebSocket connection established, session ID: {}", session.getId());
}
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
Map<String, Object> messageMap = parseMessage(message.getPayload());
String type = (String) messageMap.get("type");
if ("AUTH".equals(type)) {
handleAuthMessage(session, messageMap);
} else if ("CHAT".equals(type)) {
handleChatMessage(session, messageMap);
} else if ("HEARTBEAT".equals(type)) {
log.info("Heartbeat received from session ID: {}", session.getId());
} else {
log.warn("Unknown message type received: {}", type);
}
}
private void handleAuthMessage(WebSocketSession session, Map<String, Object> messageMap) throws IOException {
String token = (String) messageMap.get("token");
Long roomId = ((Number) messageMap.get("roomId")).longValue();
// Validate the JWT token
if (token == null || !tokenProvider.validToken(tokenProvider.getAccessToken(token))) {
log.error("Invalid JWT token: {}", token);
session.close(CloseStatus.NOT_ACCEPTABLE.withReason("Invalid JWT token"));
throw new BusinessException(ErrorCode.INVALID_AUTH_TOKEN);
}
sessionManager.addSessionToRoom(roomId, session);
session.getAttributes().put("roomId", roomId); // Store roomId in session attributes
log.info("Session ID: {} authorized for room ID: {}", session.getId(), roomId);
}
private void handleChatMessage(WebSocketSession session, Map<String, Object> messageMap) throws IOException {
try {
ChatMessageDto messageDto = objectMapper.convertValue(messageMap, ChatMessageDto.class);
Long roomId = messageDto.getRoomId();
// Save the chat message to the database
chatService.saveWebSocketMessage(roomId, messageDto);
Member sender = memberService.findByMemberId(messageDto.getSender());
List<Member> receivers = memberService.findChatRoomRecipients(roomId, sender);
// Send notifications to other chat room members
if (receivers != null && !receivers.isEmpty()) {
NotificationArgs notificationArgs = NotificationArgs.of(sender.getMemberNo(), roomId);
for (Member receiver : receivers) {
NotificationType notificationType = getNotificationType(receiver);
notificationService.send(notificationType, notificationArgs, receiver.getMemberNo());
}
}
// Broadcast the chat message to all WebSocket clients in the room
broadcastMessageToRoom(roomId, messageDto);
} catch (Exception e) {
log.error("Error processing chat message: {}", e.getMessage());
}
}
private Map<String, Object> parseMessage(String payload) throws JsonProcessingException {
return objectMapper.readValue(payload, Map.class);
}
private void broadcastMessageToRoom(Long roomId, ChatMessageDto messageDto) throws IOException {
// Get the list of WebSocket sessions for the room
List<WebSocketSession> roomSessions = sessionManager.getSessionsForRoom(roomId);
for (WebSocketSession session : roomSessions) {
if (session.isOpen()) {
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(messageDto)));
}
}
}
private static NotificationType getNotificationType(Member receiver) {
NotificationType notificationType;
if (receiver.getRole() == Role.ADMIN) {
notificationType = NotificationType.NEW_CHAT_REQUEST_ON_CHATROOM;
} else if (receiver.getRole() == Role.MEMBER) {
notificationType = NotificationType.NEW_CHAT_ON_CHATROOM;
} else {
throw new BusinessException(ErrorCode.INVALID_ROLE);
}
return notificationType;
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
log.error("Error in WebSocket transport for session ID: {}", session.getId(), exception);
session.close(CloseStatus.SERVER_ERROR);
// Remove session from the associated room
removeSession(session);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
log.info("WebSocket connection closed, session ID: {}, close status: {}", session.getId(), status);
// Remove the session from the appropriate room
removeSession(session);
}
private void removeSession(WebSocketSession session) {
Long roomId = getRoomIdFromSession(session);
if (roomId != null) {
sessionManager.removeSessionFromRoom(roomId, session);
log.info("Removed session ID: {} from room ID: {}", session.getId(), roomId);
}
}
private Long getRoomIdFromSession(WebSocketSession session) {
return (Long) session.getAttributes().get("roomId");
}
}
const connectWebsocket = () => {
// WebSocket connection
try {
// Create a new WebSocket connection
const socket = new WebSocket("ws://localhost:8080/chat");
// When the connection is successfully established
socket.onopen = function () {
console.log("WebSocket connection opened");
// You can send an initial message to join a chat room or authenticate
socket.send(JSON.stringify({
// 낮은 수준의 프로토콜이므로 메시지 페이로드에 메타데이터(예: "type")를 포함하여 메시지 유형을 명시적으로 처리해야 한다.
type: 'AUTH',
token: `${isAuthorization}`, // Send the token for authentication
roomId: charRoom.no // Indicate the chat room you want to join
}));
};
// 서버에서 들어오는 메시지를 처리
socket.onmessage = function (event) {
const messageData = JSON.parse(event.data);
console.log("event.data!?!?!?"+messageData);
if (messageData.type === 'CHAT') {
// Handle incoming chat messages
webSocketCallback(messageData); // Process the chat message with your callback
} else {
console.log('Other message type received:', messageData);
}
};
// On WebSocket error
socket.onerror = function (error) {
console.error('WebSocket error:', error);
};
// 연결이 닫히면
socket.onclose = function (event) {
console.log('WebSocket connection closed:', event);
// WebSocket 연결이 종료된 경우 5초 지연 후 재연결을 시도
setTimeout(() => connectWebsocket(), 5000);
};
// Send a heartbeat message every 4 seconds (if needed)
const heartbeatInterval = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'HEARTBEAT' }));
}
// WebSocket 연결이 닫힐 때 하트비트 메시지를 중지
// 연결이 끊어진 후 불필요한 하트비트 메시지가 전송되는 것을 방지
}, 4000);
// Store the WebSocket client for future use
changeClient(socket);
} catch (err) {
console.error('Error while connecting to WebSocket:', err);
}
};
const sendWebsocketChat = () => {
if (chat === "") {
return;
}
const formattedDate = new Date().toLocaleTimeString(); // UTC 시간으로 포맷
// Create a new chat message object
const newMessage = {
type: 'CHAT', // Indicate the type of message
sender: userId, // The ID of the user sending the message
message: chat, // The chat message content
roomId: charRoom.no, // The chat room number (this replaces the STOMP destination)
createdAt: formattedDate // Timestamp of the message
};
// Send (publish) the message using WebSocket
client.send(JSON.stringify(newMessage)); // Send the message as a string
// Clear the input after sending
setChat("");
};
const webSocketCallback = function (message) {
if (message) {
// Process the message (e.g., update chat list)
setChatList((chats) => [...chats, message]);
}
};
하지만 이 WebSocket에도 한계 및 극복해야 할 부분들이 있다.
먼저, 서버의 설계에 따라 구현이 복잡해질 수 있다.
특히 로드 밸런싱이 적용된 서버에서는 이를 위해 고려하고 설정할 부분이 많아진다. 웹소켓은 특정 서버와의 지속적인 연결 안에서만 이뤄지기 때문에 한 서버와 웹소켓 통신을 시작하면 이후로도 계속 그 서버로만 데이터가 전송되도록 설정해야 한다. NGINX, HAProxy, AWS ELB 등 WebSocket을 처리할 수 있는 로드 밸런서를 선택하여 구성하는 등, 서비스에 적합한 방법을 찾아 해결하면 된다.
메세지의 크기가 제한되어 있다는 점도 고려할 부분이다.
브라우저, 서버, 네트워크 환경마다 WebSocket에서의 메세지 크기에 제약을 둘 수 있다.
대용량의 데이터의 경우, 분할해서 전송하거나 다른 프로토콜을 사용하는 등의 방법을 사용할 수 있다.
그리고 WebSocket의 기본 프로토콜인 WS는 통신이 암호화되어 있지 않다.
때문에 보안이 중요한 서비스(HTTPS)라면 SSL/TSL 인증서를 발급받은 뒤 이를 사용하여 WSS를 설정해야 한다.
마지막으로, Polling 등의 방식보다는 훨씬 덜하지만 WebSocket도 서버에 부담을 주는 건 마찬가지이다.
많은 사용자들이 동시에 접속해 있을수록 유지해야 하는 TCP 연결이 많아지고 메세지들이 오가는 빈도가 높다면 네트워크 대역폭과 CPU 의 사용량도 증가할 것이다. 때문에 구현하고자 하는 서비스에 이러한 문제가 발생할 경우 WebSocket이 가장 적절한 선택인지 고려해 볼 필요가 있다.
STOMP 는 Simple/Streaming Text Oriented Messaging Protocol 의 약자로 메시지 전송을 위한 프로토콜이다.
기본적인 Websocket 과 가장 크게 다른 점은 기존의 Websocket 만을 사용한 통신은 발신자와 수신자를 Spring 단에서 직접 관리를 해야만 했다.
그러나 STOMP 는 다르다. stomp 는 pub/sub 기반으로 동작하기 때문에 메시지의 송신, 수신에 대한 처리를 명확하게 정의 할 수 있다. 즉 추가적으로 코드 작업할 필요 없이 @MessageMapping 같은 어노테이션을 사용해서 메시지 발행 시 엔드포인트만 조정해줌으로써 훨씬 쉽게 메시지 전송/수신이 가능하다.
저수준 통신 프로토콜로, 하나의 TCP 연결을 통해 양방향 통신 채널을 제공한다.
클라이언트와 서버 간의 실시간 양방향 통신을 가능하게 하기 위해 설계되었다. 주로 데이터를 전송하는 전송 계층 역할을 한다.
상위 수준 메시징 프로토콜로, 주로 메시징 서비스에서 사용된다. WebSocket과 같은 전송 계층 프로토콜 위에서 동작하며, 메시지를 프레임으로 처리하는 규칙을 제공한다.
주로 메시지 브로커(예: RabbitMQ, ActiveMQ)와 함께 사용되어 메시징 패턴(예: pub/sub, queue)을 지원한다.
STOMP를 사용하기 위해서는 여러가지 설정이 필요하다.
1. Gradle 추가
여기서 WebSocket과 함께 STOMP 관련 라이브러리도 함께 받아와진다.
2. Config 추가 및 설정
Config에서 Soket 연결, SUBSCRIBE 연결 설정, PUBLISH 설정을 해주어야 한다.
3. Message 컨트롤러 생성
Config에서 설정해준 URI로 요청이 메세지 요청이 오면 해당 컨트롤러로 매핑이 된다.
// websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// STOMP
implementation group: 'org.webjars', name: 'stomp-websocket', version: '2.3.3-1'
@EnableWebSocketMessageBroker
@Configuration
@RequiredArgsConstructor
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/stomp/chat") // 여기로 웹소켓 생성
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 메시지를 발행하는 요청 url -> 메시지를 보낼 때
registry.setApplicationDestinationPrefixes("/pub"); // 구독자 -> 서버(메세지보낼때)
// 메시지를 구독하는 요청 url -> 메시지를 받을 때
registry.enableSimpleBroker("/sub"); // 브로커 -> 구독자들(메세지받을때)
}
}
@EnableWebSocketMessageBroker
: 메시지 브로커가 지원하는 ‘WebSocket 메시지 처리’를 활성화한다.registerStompEndpoints()
: 기존의 WebSocket 설정과 마찬가지로 HandShake와 통신을 담당할 EndPoint를 지정한다./stomp/chat
으로 요청을 보내도록 하였다.setAllowedOriginPatterns()
: cors 설정configureMessageBroker()
: 메모리 기반의 Simple Message Broker를 활성화한다./sub
으로 시작하는 주소의 Subscriber들에게 메시지를 전달하는 역할을 한다./pub
로 지정하였다.setApplicationDestinationPrefixes()
:enableSimpleBroker()
:/sub
가 api에 prefix로 붙은 경우, messageBroker가 해당 경로를 가로채 처리한다./sub
)로 SimpleBroker
를 등록한다. SimpleBroker는 해당하는 경로를 구독하는 client에게 메시지를 전달하는 간단한 작업을 수행한다. @Controller
@RequiredArgsConstructor
@RequestMapping("/api")
public class ChatController {
private final SimpMessagingTemplate template; //특정 Broker 로 메세지를 전달(WebSocket 으로 메시지를 전송)
private final ChatService chatService;
// stompConfig 에서 설정한 applicationDestinationPrefixes 와 @MessageMapping 경로가 병합됨 (/pub + ...)
// /pub/chat/enter 에 메세지가 오면 동작
@MessageMapping(value = "/chat/enter")
public void enter(ChatRequestDto message){ // 채팅방 입장
message.setMessage(message.getWriter() + "님이 채팅방에 참여하였습니다.");
template.convertAndSend("/sub/chat/" + message.getRoomId(), message);
}
// /pub/chat/message 에 메세지가 오면 동작
@MessageMapping(value = "/chat/message")
public void message(ChatRequestDto message){
ChatResponseDto savedMessage = chatService.saveMessage(message);
template.convertAndSend("/sub/chat/" + savedMessage.getRoomId(), savedMessage);
}
@Controller에서는 /pub
desination prefix를 제외한 경로 /chat/message를 @MessageMapping하면 된다.
/chat/message
: Config에서 setApplicationDestinationPrefixes()
를 통해 prefix를 /pub
으로 설정 해주었기 때문에 경로가 한번 더 수정되어 /pub/chat/message
로 바뀐다.
convertAndSend
: /sub
를 Config에서 설정해주었다. 그래서 Message Broker가 해당 send를 캐치하고 해당 토픽을 구독하는 모든 사람에게 메세지를 보내게 된다.
예를 들어 roomId 2번을 구독하고 있다면 /sub/chat/2을 구독하고 있는 유저들에게 모두 보낸다.
@Log4j2
@Component
@RequiredArgsConstructor
public class StompHandler implements ChannelInterceptor {
private final TokenProvider tokenProvider; // jwt 토큰 인증 핸들러
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
// 헤더에 있는 토큰값을 가져오기 위해 StompHeaderAccessor.wrap()
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
// websocket 을 통해 들어온 요청이 처리 되기 전 실행된다.
// websocket 연결시 클라이언트가 보낸 헤더의 jwt token 유효성 검증
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String jwt = accessor.getFirstNativeHeader("Authorization");
log.info("jwt=============="+jwt);
if (!tokenProvider.validToken(Objects.requireNonNull(jwt).substring(7))) {
throw new BusinessException(ErrorCode.INVALID_AUTH_TOKEN);
}
}
return message;
}
...
StompHeaderAccessor.wrap으로 message
를 감싸면 STOMP의 헤더에 직접 접근할 수 있습니다. 위에서 작성한 클라이언트에서 보낸 JWT가 들어있는 헤더 Authorization을StompHeaderAccessor.getNativeHeader("Authorization")
메서드를 통해 받아올 수 있고 받아온 헤더의 값은 JWT가 됩니다.public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
...
@Override
public void configureClientInboundChannel(ChannelRegistration registration) { // 핸들러 등록
// connect / disconnect 인터셉터
registration.interceptors(stompHandler);
}
SockJS란 웹 애플리케이션과 웹 서버 간에 실시간 양방향 통신을 가능하게 해주는 JavaScript라이브러리다.
SockJS는 웹 소켓(WebSocket)을 사용할 수 있는 경우에는 웹 소켓을 사용하여 통신하지만, 웹 소켓을 지원하지 않는 경우에는 다른 대안 수단으로 통신하도록 해주는 유용한 라이브러이다.
STOMP 프로토콜을 사용하기 위해 클라이언트 라이브러리 @stomp/stompjs
를 사용하는데,
이 패키지에서 Client
는 호환성을 가진 STOMP 클라이언트 객체를 나타낸다.
import * as StompJs from "@stomp/stompjs";
const connect = () => {
// WebSocket connection
try {
const stompClient = new StompJs.Client({
brokerURL: "wss://tmarket.store/stomp/chat",
connectHeaders: {
Authorization: getCookie('Authorization')
},
debug: function (str) {
console.log(str);
},
reconnectDelay: 5000, // 자동 재연결
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
// 구독
stompClient.onConnect = function () {
stompClient.subscribe("/sub/chat/" + charRoom.no, callback);
};
stompClient.activate(); // 클라이언트 활성화
changeClient(stompClient); // 클라이언트 갱신
} catch (err) {
console.log(err);
}
};
client.publish({ // destination 과 body 를 publish 를 사용해 서버단에 보냄
destination: "/pub/chat/message/" + charRoom.no,
body: JSON.stringify(newMessage),
});
⭐️ 즉, client 객체가 메시지를 전송하면 서버는 그것을 받아서 처리(Chat DB에 저장) 후 client 객체에게 다시 보내주고 client 객체는 전달 받은 메시지를 대화 내역에 추가하면서
실시간
으로 메시지를 주고 받을 수 있다.react -> client 객체가 메시지를 전송
stompClient.onConnect = function () { // 구독 stompClient.subscribe("/sub/chat/" + charRoom.no, callback); }; ... client.publish({ // 전송 (destination 과 body 를 publish 를 사용해 서버단에 보냄) destination: "/pub/chat/message/" + charRoom.no, body: JSON.stringify(newMessage), });
spring -> Chat DB에 저장 후 client 객체에게 다시 보내줌
// stompConfig 에서 설정한 applicationDestinationPrefixes 와 @MessageMapping 경로가 병합됨 (/pub + ...) // /pub/chat/message 에 메세지가 오면 동작 @MessageMapping("chat/message/{roomId}") @SendTo("/sub/chat/{roomId}") // react callback 함수 실행 public ChatMessageDto message(@DestinationVariable Long roomId, ChatMessageDto messageDto) { chatService.saveMessage(roomId, messageDto); // chat DB 저장 return ChatMessageDto.builder() .roomId(roomId) .sender(messageDto.getSender()) .message(messageDto.getMessage()) .createdAt(messageDto.getCreatedAt()) .build(); }
react -> 전달 받은 메시지를 대화 내역에 추가
// 구독과 동시에 실행할 콜백함수를 인자로 넘김 const callback = function (message) { if (message.body) { let msg = JSON.parse(message.body); setChatList((chats) => [...chats, msg]);// 채팅 배열에 새로 받은 메시지를 추가 } };
웹소켓을 사용할 때 요청을 보내는 사람의 인증을 처리하는 방법은 여러 가지가 있다.
웹소켓은 HTTP 프로토콜을 업그레이드하여 연결(Handshake)을 시작하기 때문에 연결 초기 단계에서 인증을 수행할 수 있는 방법을 활용할 수 있다.
웹소켓 연결을 시작하기 전에 클라이언트가 서버에 HTTP 요청을 보내는 초기 핸드셰이크 과정에서 인증 토큰(JWT 토큰 등)을 Authorization 헤더에 포함시키는 방법이다. Sec-WebSocket-Protocol 헤더를 통해 토큰 전달하고, 서버는 이 토큰을 검증하여 사용자를 인증한 후 유효한 경우에만 웹소켓 연결을 수락한다.
웹소켓 URL의 쿼리 파라미터에 인증 토큰을 포함하여 전달하는 방법도 있다. 이 방법은 구현이 간단하지만, URL에 토큰이 노출되므로 보안상의 리스크가 존재한다.
웹소켓 연결 후 첫 메시지로 토큰을 전송한다.
서버는 토큰 검증 후 연결 유지 또는 종료를 결정한다.
STOMP 프로토콜 사용 시 connect 헤더에 토큰 포함한다.
이는 웹소켓 연결이 수립된 후 STOMP 프로토콜 레벨에서 이루어지는 인증 방식
STOMP 프로토콜 사용 시 connect 헤더에 토큰 포함
// Stomp 구현
const connect = () => {
// WebSocket connection
try {
const stompClient = new StompJs.Client({
brokerURL: "wss://tmarket.store/stomp/chat",
connectHeaders: {
// 연결 시 인증 헤더 포함
Authorization: `${isAuthorization}`
},
...
}
};
: STOMP 프로토콜 기반의 웹소켓 통신에서 구체적인 처리를 담당
public class StompHandler implements ChannelInterceptor {
private final TokenProvider tokenProvider; // jwt 토큰 인증 핸들러
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
// 헤더에 있는 토큰값을 가져오기 위해 StompHeaderAccessor.wrap()
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
// websocket 을 통해 들어온 요청이 처리 되기 전 실행된다.
// websocket 연결시 클라이언트가 보낸 헤더의 jwt token 유효성 검증
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String jwt = accessor.getFirstNativeHeader("Authorization");
log.info("jwt=============="+jwt);
if (!tokenProvider.validToken(tokenProvider.getAccessToken(jwt))) {
throw new BusinessException(ErrorCode.INVALID_AUTH_TOKEN);
}
}
return message;
}
...
}
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
@EnableWebSocketMessageBroker
@Configuration
@RequiredArgsConstructor
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler; // jwt 토큰 인증 핸들러
...
@Override
public void configureClientInboundChannel(ChannelRegistration registration) { // 핸들러 등록
// connect / disconnect 인터셉터
registration.interceptors(stompHandler);
}
}
이러한 구조를 통해 웹소켓 연결의 보안을 강화하고 인증된 사용자만 실시간 통신에 참여할 수 있도록 제어한다.