최근 진행 중인 프로젝트에서 실시간 채팅 기능을 구현하게 되었습니다.유저와 판매자 간의 1:1 커뮤니케이션을 위한 기능으로, WebSocket과 STOMP를 활용해 서버와 클라이언트 간의 실시간 양방향 통신을 구현했습니다. 이번 글에서는 해당 기능에 대한 구현 과정 정리해보려 합니다.
웹 애플리케이션에서 클라이언트와 서버 간 통신은 주로 HTTP와 WebSocket 프로토콜을 통해 이루어집니다.
HTTP : 요청-응답 기반의 비연결성 단방향 통신 프로토콜
WebSocket : 지속적인 연결을 유지하는 양방향 통신 프로토콜
실시간 채팅 기능은 사용자 간 빠르고 끊김 없는 메시지 전달이 핵심이기 때문에, 요청마다 연결을 새로 맺는 HTTP보다는 지속적인 연결과 양방향 통신이 가능한 WebSocket이 더 적합했습니다.
웹소켓은 한 번의 핸드셰이크를 통해 연결이 수립되면 이 후 연결이 유지되는 동안 양방향 통신이 가능합니다.

Stomp(Simple Text Oriented Messaging Protocol) : WebSocket 위에서 메시지를 주고받기 쉽게 만들어주는 텍스트 기반 메시징 프로토콜
WebSocket은 단순히 소켓 연결만 해줄 뿐, 메시지를 어떤 형식으로, 어떤 대상에게 보낼지에 대한 표준이 없습니다. STOMP는 WebSocket 위에서 동작하면서,메시지를 발행(publish) 하고 구독(subscribe) 할 수 있게 해주는 규칙을 제공합니다.
Pub/Sub(Publish/Subscribe) 은 메시지를 발행하는 쪽(Publisher)과 이를 구독해서 받는 쪽(Subscriber)을 분리해주는 메시징 방식입니다.
쉽게 말해, 채팅방은 우체통(Topic) 이고, 메시지를 보내는 사용자는 Publisher, 채팅방에 입장해 메시지를 받는 사용자는 Subscriber라고 볼 수 있습니다.
여기서 중요한 역할을 하는 것이 Message Broker입니다.
Message Broker는 발행자(Publisher)가 보낸 메시지를 적절한 구독자(Subscriber)에게 전달해주는 중간 전달자 역할을 합니다.
STOMP 프로토콜을 사용하는 Spring WebSocket 환경에서는, Spring이 내부적으로 Simple In-Memory Broker를 사용해 메시지를 라우팅하고 구독 중인 사용자에게 브로드캐스트하는 구조를 자동으로 구성해줍니다.
따라서 복잡한 외부 설정 없이도 효율적인 실시간 통신이 가능해집니다.
implementation 'org.springframework.boot:spring-boot-starter-websocket'
우선 build.gradle에 웹소켓 의존성을 추가해줍니다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler jwtChannelInterceptor;
public WebSocketConfig(StompHandler jwtChannelInterceptor) {
this.jwtChannelInterceptor = jwtChannelInterceptor;
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/chat/inbox")
.setAllowedOriginPatterns("*")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub");
registry.setApplicationDestinationPrefixes("/pub");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(jwtChannelInterceptor);
}
}
@EnableWebSocketMessageBroker : 메시지 브로커 활성화
registry.addEndpoint("/chat/inbox") : 해당 경로로 최초 핸드셰이크 요청이 들어옵니다. (HTTP/HTTPS)
configureMessageBroker : 메시지발행, 구독 모델의 경로를 설정합니다. /sub으로 시작하는 경로는 메시지 브로커가 직접 처리하고, /pub으로 시작하는 경로는 애플리케이션의 @MessageMapping 메소드로 연결됩니다.
configureClientInboundChannel : 클라이언트가 메시지를 보낼 때 거치는ChannelInterceptor를 등록하여, CONNECT, SEND 등의 메시지가 컨트롤러나 브로커로 전달되기 전에 가로채 JWT 인증을 수행합니다.
@Component
@RequiredArgsConstructor
public class StompHandler implements ChannelInterceptor {
private final JwtUtil jwtUtil;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
try{
jwtUtil.validateToken(token);
String jwt = jwtUtil.substringToken(token);
Claims claims = jwtUtil.extractClaims(jwt);
Long userId = Long.valueOf(claims.getSubject());
String email = claims.get("email", String.class);
String nickname = claims.get("nickname", String.class);
accessor.getSessionAttributes().put("userId", userId);
accessor.getSessionAttributes().put("email", email);
accessor.getSessionAttributes().put("nickname", nickname);
log.info("[WebSocket 인증 성공] userId: {}, email: {}", userId, email);
} catch (Exception e){
log.error("WebSocket 인증 실패 {}", e.getMessage());
throw new MessagingException("JWT 인증 실패");
}
}
}
if (StompCommand.SEND.equals(accessor.getCommand())) {
Object userId = accessor.getSessionAttributes().get("userId");
if (userId == null) {
log.warn("SEND: WebSocket세션에 사용자 정보 없음");
throw new MessagingException("세션 인증 정보 없음");
}
log.info("SEND: userId={} ", userId);
}
return message;
}
}
WebSocket 연결 후 STOMP 프로토콜을 통신시 JWT 인증/인가 처리를 수행하기 위해 Spring의 ChannelInterceptor를 구현한 StompHandler를 사용했습니다. 이 핸들러는 클라이언트로부터 오는 메시지를 가로채 필요한 검증 로직을 수행합니다.
연결 시 인증 (CONNECT): 클라이언트가 웹소켓 연결 후 STOMP CONNECT 프레임을 보낼 때, 함께 전달된 Authorization 헤더의 JWT 토큰을 검증합니다.
토큰 검증에 성공하면, 토큰에서 추출한 사용자 정보(userId, email 등)를 해당 웹소켓 세션의 속성(Attributes)에 저장합니다.
메시지 전송 시 인증 확인 (SEND): 클라이언트가 메시지를 보내는 SEND 프레임을 가로챕니다.해당 연결의 세션 속성에 유저 정보가 정상적으로 저장되어 있는지 확인합니다.
간단한 JS와 Spring Boot 코드를 작성해 채팅 기능을 구현해보겠습니다.
유저가 판매자에게 채팅을 요청하면 채팅방이 생성되고, 이후 http://localhost:8080/chat/inbox 엔드포인트로 최초 핸드셰이크 요청을 보냅니다.
연결이 수립된 후, 유저가 /pub 경로로 메시지를 전송하면
@MessageMapping("/message")으로 지정된 서버의 컨트롤러 메서드가 해당 메시지를 수신하고 처리하게 됩니다.
메시지 처리 로직이 수행된 후, 구독 중인 채팅방 참가자들에게 메시지가 실시간으로 전파됩니다.
@Entity
@Getter
@NoArgsConstructor
@Table(name="ChatRoom")
public class ChatRoom extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private Long sellerId;
public ChatRoom(Long userId, Long sellerId) {
this.userId = userId;
this.sellerId = sellerId;
}
}
요청 유저(sender) -> 판매자(userId)의 정보를 담고있습니다.
@PostMapping("/v1/chatRoom")
public ResponseEntity<ChatRoomResponse> createChatRoom(@RequestBody CreateChatRoomRequest req,
@AuthenticationPrincipal AuthUser authUser){
return new ResponseEntity<>(chatRoomService.createChatRoom(authUser.getId(), req.getSellerId()), HttpStatus.CREATED);
}
채팅 요청시 핸드셰이크 전에 우선 채팅방을 반환합니다.
@Transactional
public ChatRoomResponse createChatRoom(Long userId, Long sellerId) {
if (userId.equals(sellerId)) {
throw new ClientException(ErrorCode.INVALID_CHAT_REQUEST);
}
userRepository.findById(sellerId).orElseThrow(()-> new ClientException(ErrorCode.USER_NOT_FOUND));
Optional<ChatRoom> chatRoom = chatRoomRepository.findByUserIdAndSellerId(userId, sellerId);
if (chatRoom.isPresent()) {
List<ChatMessageResponse> messages = chatMessageRepository.findMessagesWithUserByChatRoom(chatRoom.get());
return ChatRoomResponse.fromEntity(chatRoom.get(), messages);
}
ChatRoom newChatRoom = chatRoomRepository.save(new ChatRoom(userId, sellerId));
return ChatRoomResponse.fromEntity(newChatRoom, Collections.emptyList());
}
@Entity
@Table(name="ChatMessage")
@Getter
@NoArgsConstructor
public class ChatMessage extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_room_id")
private ChatRoom chatRoom;
private Long senderId;
private String content;
public ChatMessage(ChatRoom chatRoom, Long senderId, String content) {
this.chatRoom = chatRoom;
this.senderId = senderId;
this.content = content;
}
}
메시지 정보를 담는 간단한 ChatMessage 엔티티입니다.
@MessageMapping("/message")
public void sendMessage(ChatMessageRequest req,
SimpMessageHeaderAccessor accessor) {
Long userId = (Long) accessor.getSessionAttributes().get("userId");
ChatMessageResponse response = chatMessageService.createChatMessage(req, userId);
messagingTemplate.convertAndSend("/sub/channel/" + req.getChatRoomId(), response);
}
@Transactional
public ChatMessageResponse createChatMessage(ChatMessageRequest req, Long senderId) {
ChatRoom chatRoom = chatRoomRepository.findById(req.getChatRoomId())
.orElseThrow(()-> new ClientException(ErrorCode.INVALID_CHAT_REQUEST));
User sender = userRepository.findById(senderId)
.orElseThrow(()->new ClientException(ErrorCode.USER_NOT_FOUND));
ChatMessage message = new ChatMessage(chatRoom, senderId, req.getContent());
chatMessageRepository.save(message);
ChatMessageResponse response = ChatMessageResponse.fromEntity(message, sender);
response.setNickname(sender.getNickname());
return response;
}
전송된 채팅 메시지를 검증 후 DB에 저장합니다.

채팅 요청시 채팅방을 반환 후, 핸드셰이크 요청을 보냅니다.

이 후 연결이 수립되면 위와 같이 실시간으로 메시지를 주고받을 수 있습니다.