努力未来

Spring Boot로 실시간 채팅 구현하기 (WebSocket + STOMP)

홍서현
홍서현Jul 4, 2025

단체 채팅 기능을 구현하기 위해 WebSocket과 STOMP 프로토콜을 기반으로 실시간 채팅 시스템을 구축했습니다.

Spring Boot를 기반으로 사용자 인증(JWT), 채팅방 참여자 검증, 메시지 저장 및 전송 기능을 통합한 구조입니다.

🌐 WebSocket & STOMP 이론 정리

단체 채팅 기능은 단순한 HTTP 요청/응답 구조로는 구현이 어렵습니다. 왜냐하면 채팅은 "양방향 실시간 통신" 이 필요하기 때문입니다. 이 문제를 해결하기 위해 사용한 기술이 WebSocket + STOMP입니다.

✅ WebSocket이란?

WebSocket은 HTTP와 달리 연결을 끊지 않고 지속적으로 데이터를 주고받을 수 있는 양방향 통신 프로토콜입니다.

  • 클라이언트가 WebSocket 서버에 연결을 맺으면, 양쪽 모두 자유롭게 메시지를 주고받을 수 있음
  • HTTP보다 오버헤드가 적고, 실시간 데이터 처리에 적합
  • 기본적으로 ws:// 또는 wss:// 프로토콜 사용

🧠 기본 흐름

1. 클라이언트 → 서버: WebSocket 연결 요청
2. 서버 → 클라이언트: 연결 수락 (Handshake)
3. 이후 실시간으로 메시지 송수신

✅ STOMP란?

STOMP는 WebSocket 위에서 동작하는 메시징 프로토콜로, 메시지의 목적지(주소)를 명확히 정의해줍니다.

WebSocket이 TCP 소켓이라면, STOMP는 그 위의 HTTP 같은 역할입니다.

STOMP의 주요 개념

용어설명
SEND서버에 메시지를 보낼 때 사용 (@MessageMapping)
SUBSCRIBE서버의 특정 주제(topic)를 구독하여 메시지 수신
MESSAGE구독 중인 클라이언트에게 전달되는 메시지
CONNECT / DISCONNECT연결 및 종료

✅ 실시간 채팅의 동작 원리

  1. 클라이언트는 WebSocket을 통해 서버와 연결을 맺음 (/ws-chat 엔드포인트 사용)
  1. 연결 시 JWT를 헤더에 담아 사용자 인증
  1. 연결된 사용자는 특정 채팅방(roomId)을 구독 (/topic/chatroom/{id})
  1. 채팅 입력 시 메시지를 /app/chat.send 경로로 전송
  1. 서버는 해당 채팅방에 대한 메시지를 DB에 저장 후, 구독 중인 사용자에게 실시간으로 메시지 전송 (convertAndSend)

✅ 채팅 요청/응답 형식

📤 클라이언트 → 서버 (SEND)

발행 주소: /app/chat.send

{
  "roomId": 1,
  "content": "안녕하세요!"
}

json
STOMP는 application/json 형식으로 메시지를 전송하며, roomId를 통해 서버가 채팅방을 구분함

📥 서버 → 클라이언트 (SUBSCRIBE)

구독 주소: /topic/chatroom/1

서버에서 전송되는 메시지 형식:

{
  "roomId": 1,
  "sender": "홍길동",
  "content": "안녕하세요!"
}

json
구독 중인 사용자는 이 주소를 통해 실시간으로 메시지를 수신하게 됩니다.

✅ SockJS란?

  • WebSocket을 지원하지 않는 브라우저에서도 동작하도록 도와주는 호환성 레이어
  • 브라우저와 서버 간 통신을 WebSocket > XHR > Long Polling 순으로 자동 대체
  • Spring에서는 withSockJS() 설정만으로 자동 적용 가능

⚙️ 전체 구조

✅ 사용 기술

  • WebSocket + STOMP (Spring WebSocket)
  • JWT 인증 기반 사용자 연결
  • 채팅방 권한 검증
  • DB에 메시지 저장 (JPA)
  • 채팅방-사용자 관계 관리

📡 WebSocket 기본 설정

✅ WebSocketConfig

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final WebSocketInterceptor webSocketInterceptor;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic");  // 구독 주소
        registry.setApplicationDestinationPrefixes("/app"); // 발행 주소
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-chat")
                .setAllowedOriginPatterns("*")
                .withSockJS();  // SockJS 지원
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(webSocketInterceptor);
    }
}

java
  • 클라이언트는 /app/chat.send로 메시지 전송
  • 서버는 /topic/chatroom/{roomId}로 메시지 전달

🔐 WebSocket 연결 시 사용자 인증 (JWT)

✅ WebSocketInterceptor

@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketInterceptor implements ChannelInterceptor {

    private final TokenService tokenService;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        if (StompCommand.CONNECT.equals(accessor.getCommand())) {
            String token = accessor.getFirstNativeHeader("Authorization");

            if (token != null && token.startsWith("Bearer ")) {
                token = token.substring(7);
                User user = tokenService.getUserFromToken(token);
                log.info("✅ WebSocket 연결 요청: {}", user.getEmail());

                accessor.setUser(new StompPrincipal(user.getEmail())); // Principal 설정
            }
        }
        return message;
    }
}

java
🧠 WebSocket은 기본적으로 HTTP처럼 필터 체인을 타지 않기 때문에 직접 Interceptor에서 인증 정보를 파싱하고, Principal에 수동 주입해야 함

💬 채팅 메시지 처리 흐름

✅ Controller - ChatController

@Slf4j
@RequiredArgsConstructor
@Controller
public class ChatController {

    private final SimpMessagingTemplate messagingTemplate;
    private final ChatRoomRepository chatRoomRepository;
    private final ChatMessageRepository chatMessageRepository;
    private final UserRepository userRepository;
    private final ChatParticipantRepository chatParticipantRepository;

    @MessageMapping("/chat.send")
    public void sendMessage(ChatMessageDto messageDto, Principal principal) {
        if (principal == null) {
            throw new CustomException(ErrorCode.UNAUTHORIZED_EXCEPTION, "인증되지 않은 사용자입니다.");
        }

        String email = principal.getName();
        User sender = userRepository.findByEmail(email)
                .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER_EXCEPTION, "유저를 찾을 수 없음"));

        ChatRoom room = chatRoomRepository.findById(messageDto.getRoomId())
                .orElseThrow(() -> new IllegalArgumentException("채팅방 없음"));

        boolean isParticipant = chatParticipantRepository.existsByChatRoomIdAndUserId(room.getId(), sender.getId());
        if (!isParticipant) {
            throw new CustomException(ErrorCode.ACCESS_DENIED_EXCEPTION, "채팅방 참여자가 아닙니다.");
        }

        ChatMessage msg = new ChatMessage();
        msg.setChatRoom(room);
        msg.setContent(messageDto.getContent());
        msg.setSender(sender.getName());
        msg.setSentAt(LocalDateTime.now());
        chatMessageRepository.save(msg);

        log.info("🔥 메시지 수신됨: roomId={}, sender={}, content={}", room.getId(), sender.getName(), messageDto.getContent());

        ChatMessageDto sendDto = new ChatMessageDto();
        sendDto.setRoomId(room.getId());
        sendDto.setSender(sender.getName());
        sendDto.setContent(messageDto.getContent());

        messagingTemplate.convertAndSend("/topic/chatroom/" + room.getId(), sendDto);
    }
}

java

🧱 Entity 설계

✅ ChatRoom

@Entity
@Getter
@Setter
public class ChatRoom {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToOne
    private Board board;

    @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL)
    private List<ChatParticipant> participants = new ArrayList<>();
}

java

✅ ChatParticipant (참여자 권한 검증용)

@Entity
@Getter
@Setter
public class ChatParticipant {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    private ChatRoom chatRoom;

    @ManyToOne
    private User user;
}

java

✅ ChatMessage

@Entity
@Getter
@Setter
public class ChatMessage {
    @Id @GeneratedValue
    private Long id;

    private String sender;
    private String content;
    private LocalDateTime sentAt;

    @ManyToOne
    private ChatRoom chatRoom;
}

java

🗃️ Repository 계층

@Repository
public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {
    List<ChatMessage> findByChatRoomIdOrderBySentAtAsc(Long chatRoomId);
}

@Repository
public interface ChatParticipantRepository extends JpaRepository<ChatParticipant, Long> {
    boolean existsByChatRoomIdAndUserId(Long chatRoomId, Long userId);
}

@Repository
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
    boolean existsByBoard(Board board);
    Optional<ChatRoom> findByBoard(Board board);
}

java

🧪 WebSocket 채팅 테스트 클라이언트 (HTML + JS)

Spring Boot WebSocket 서버에 직접 연결하여 채팅 기능을 테스트할 수 있는 단순 HTML 테스트 페이지입니다.

SockJS와 STOMP 프로토콜을 사용하여 채팅방 메시지를 송수신하며, JWT 기반 인증을 헤더에 포함합니다.

✅ 전체 구조

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8" />
    <title>채팅 대시보드</title>
</head>
<body>
<h2>채팅 대시보드</h2>

<!-- 메시지 출력 영역 -->
<textarea id="chatLog" cols="60" rows="15" readonly></textarea><br />

<!-- 메시지 입력 -->
<input type="text" id="messageInput" placeholder="메시지를 입력하세요" size="50" />
<button onclick="sendMessage()">전송</button>

<!-- SockJS & STOMP 라이브러리 -->
<script src="https://cdn.jsdelivr.net/npm/sockjs-client/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs/lib/stomp.min.js"></script>

<script>
    // ✅ JWT 토큰 로컬스토리지에서 가져오기
    const token = localStorage.getItem("accessToken");
    if (!token) {
        // 로그인 안 된 사용자 리다이렉트
        const redirectPath = encodeURIComponent(window.location.pathname);
        window.location.href = `/admin-login.html?redirect=${redirectPath}`;
    }

    // ✅ WebSocket 연결
    const socket = new SockJS("http://localhost:8080/ws-chat");
    const stompClient = Stomp.over(socket);

    stompClient.connect(
        { Authorization: "Bearer " + token },  // 헤더에 JWT 포함
        function (frame) {
            console.log("Connected: " + frame);
            appendLog("서버에 연결되었습니다.");

            // ✅ 채팅방 1번 구독
            stompClient.subscribe("/topic/chatroom/1", function (message) {
                const msg = JSON.parse(message.body);
                appendLog(msg.sender + ": " + msg.content);
            });
        },
        function (error) {
            console.error("WebSocket 연결 오류:", error);
            alert("WebSocket 연결에 실패했습니다.");
        }
    );

    // ✅ 메시지 전송 함수
    function sendMessage() {
        const input = document.getElementById("messageInput");
        const content = input.value.trim();
        if (!content) return;

        stompClient.send(
            "/app/chat.send",
            {},
            JSON.stringify({
                roomId: 1,
                content: content,
            })
        );

        input.value = "";
    }

    // ✅ 로그 출력 함수
    function appendLog(text) {
        const chatLog = document.getElementById("chatLog");
        chatLog.value += text + "\n";
        chatLog.scrollTop = chatLog.scrollHeight;
    }
</script>
</body>
</html>

html

💡 동작 설명

항목설명
✅ 토큰 체크localStorage에 저장된 JWT 토큰을 가져와 Authorization 헤더에 포함
✅ SockJS 사용/ws-chat로 연결, 서버는 WebSocketConfig에서 등록됨
✅ 구독 주소/topic/chatroom/{roomId}를 구독하여 실시간 메시지 수신
✅ 발행 주소/app/chat.send로 메시지 전송 (@MessageMapping과 매핑됨)
✅ 메시지 저장서버에서는 ChatMessageRepository.save()로 DB 저장

🚨 주의사항

  • roomId는 테스트를 위해 고정값 1 사용 → 동적으로 처리하려면 URL 파라미터 또는 query string 활용
  • localhost:8080은 서버 도메인에 맞게 수정
  • 브라우저 콘솔에서 CORS 또는 인증 관련 오류가 발생할 수 있음 → 서버측 CORS 설정 확인



✅ 확장 아이디어

기능설명
채팅방 목록 조회/chatrooms 엔드포인트와 연결
사용자 프로필 표시sender → email 외에 프로필 이미지 등 표시
메시지 전송 시간sentAt 필드 추가하여 시간 표시
무한 스크롤메시지 페이지네이션 처리 (REST + WebSocket 혼합)