WebSocket을 활용한 실시간 채팅 애플리케이션 개발 과정을 Spring Boot 백엔드와 React 프론트엔드 연동 중심으로 상세하게 다룹니다. 직접 구현하며 얻은 실질적인 경험과 팁을 공유합니다.
안녕하세요! 개발자로서 다양한 프로젝트를 진행하다 보면, 사용자 간의 즉각적인 상호작용이 필요한 기능을 구현해야 할 때가 많습니다. 특히 실시간 채팅 기능은 많은 서비스에서 필수적인 요소로 자리 잡았죠. 사용자들이 서로 메시지를 주고받는 모습을 보면서, '나도 이런 기능을 직접 구현해보고 싶다'는 생각을 해보신 적이 있지 않나요?
저는 최근에 Spring Boot를 활용한 백엔드와 React를 활용한 프론트엔드를 연동하여 WebSocket 기반의 실시간 채팅 애플리케이션을 개발하는 프로젝트를 진행했습니다. 직접 부딪히고 해결하며 얻은 경험과 노하우를 이 글을 통해 여러분과 공유하고자 합니다. 이 가이드를 통해 여러분도 자신만의 풀스택 실시간 채팅 서비스를 구축하는 데 자신감을 얻으시길 바랍니다.
📑 목차
- WebSocket 기반 실시간 채팅의 아키텍처 이해
- WebSocket vs. HTTP 폴링/롱 폴링
- STOMP 프로토콜 활용
- Spring Boot 백엔드 구현: WebSocket 서버 구축
- 의존성 추가 및 WebSocket 설정
- 메시지 DTO 및 컨트롤러 구현
- React 프론트엔드 구현: 실시간 메시지 처리
- 의존성 설치 및 WebSocket 연결
- 실제 연동 과정에서 마주한 난관과 해결책
- 1. CORS (Cross-Origin Resource Sharing) 문제
- 2. WebSocket 연결 끊김 및 재연결 로직
- 3. 메시지 형식 불일치 및 직렬화/역직렬화
- 성능 최적화 및 확장성을 위한 고려사항
- 1. 메시지 브로커 확장
- 2. 세션 관리 및 인증/인가
- 3. 데이터베이스 연동 및 메시지 영속성
- 마무리하며: 실시간 웹 서비스 개발의 무한한 가능성
Image by antonbe on Pixabay
WebSocket 기반 실시간 채팅의 아키텍처 이해
실시간 채팅 애플리케이션을 구축하기 위한 핵심은 클라이언트와 서버 간의 지속적인 양방향 통신 채널을 확보하는 것입니다. 기존의 HTTP 요청-응답 방식으로는 매번 연결을 맺고 끊어야 하므로 실시간성이 떨어지고 비효율적입니다. 이럴 때 WebSocket이 강력한 대안이 됩니다.
WebSocket vs. HTTP 폴링/롱 폴링
먼저, WebSocket이 왜 실시간 채팅에 적합한지 다른 통신 방식과 비교하며 살펴보겠습니다.
| 특징 | HTTP 폴링 | HTTP 롱 폴링 | WebSocket |
|---|---|---|---|
| 통신 방식 | 단방향 (클라이언트 -> 서버) | 단방향 (클라이언트 -> 서버, 응답 대기) | 양방향 (클라이언트 <-> 서버) |
| 연결 유지 | 요청마다 새로운 연결 | 응답 올 때까지 연결 유지 후 재연결 | 한 번의 handshake 후 영구 연결 |
| 오버헤드 | 높음 (헤더 반복) | 중간 (응답 대기) | 낮음 (최초 handshake 후 프레임 기반) |
| 실시간성 | 낮음 (주기적 요청) | 중간 (응답 지연) | 높음 (즉각적인 푸시) |
표에서 볼 수 있듯이, WebSocket은 한 번의 연결 수립 후 양방향으로 데이터를 주고받을 수 있어 실시간성이 중요한 채팅 애플리케이션에 가장 적합한 기술입니다. 특히 헤더 오버헤드가 적어 네트워크 효율성도 뛰어납니다.
STOMP 프로토콜 활용
WebSocket 자체는 low-level 프로토콜이라 메시지 라우팅, 구독, 발행 등의 기능을 직접 구현하려면 복잡합니다. 이때 STOMP (Simple Text Oriented Message Protocol)를 사용하면 이러한 복잡성을 크게 줄일 수 있습니다. STOMP는 WebSocket 위에 계층을 추가하여 메시지 브로커와 유사한 기능을 제공합니다. 이를 통해 클라이언트와 서버는 특정 토픽을 구독하거나 메시지를 발행하는 방식으로 쉽게 통신할 수 있습니다.
Spring Boot 백엔드 구현: WebSocket 서버 구축
이제 Spring Boot를 사용하여 WebSocket 서버를 구축하는 과정을 살펴보겠습니다. 저는 주로 Gradle을 사용하는데, 프로젝트 생성 시 `spring-boot-starter-websocket` 의존성을 추가해주는 것이 핵심입니다.
의존성 추가 및 WebSocket 설정
build.gradle 파일에 다음 의존성을 추가합니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation' // 필요시
// 기타 필요한 의존성
}
다음으로 WebSocket을 활성화하고 STOMP 메시지 브로커를 설정하는 WebSocketConfig 클래스를 생성합니다.
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker // WebSocket 메시지 브로커를 활성화합니다.
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 메시지를 구독하는 요청의 prefix를 설정합니다. (예: /topic/public)
config.enableSimpleBroker("/topic");
// 메시지를 발행하는 요청의 prefix를 설정합니다. (예: /app/chat)
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 클라이언트가 WebSocket 연결을 맺을 엔드포인트를 등록합니다.
// 클라이언트에서는 ws://localhost:8080/ws 로 연결합니다.
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*") // 모든 Origin 허용 (개발 단계에서만, 실제 서비스에서는 특정 Origin만 허용)
.withSockJS(); // SockJS는 WebSocket을 지원하지 않는 브라우저를 위해 fallback 옵션을 제공합니다.
}
}
여기서 enableSimpleBroker("/topic")은 /topic으로 시작하는 경로로 메시지를 발행하면 해당 토픽을 구독한 모든 클라이언트에게 메시지를 전달하는 단순 메시지 브로커를 활성화합니다. setApplicationDestinationPrefixes("/app")은 클라이언트가 서버로 메시지를 보낼 때 사용되는 경로의 prefix를 설정합니다. /ws 엔드포인트는 클라이언트가 WebSocket 연결을 시작하는 지점이며, withSockJS()는 WebSocket을 지원하지 않는 환경에서도 통신할 수 있도록 SockJS를 활성화합니다.
메시지 DTO 및 컨트롤러 구현
채팅 메시지를 주고받기 위한 DTO(Data Transfer Object)를 정의합니다. 저는 ChatMessage 클래스를 사용했습니다.
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ChatMessage {
public enum MessageType {
CHAT,
JOIN,
LEAVE
}
private MessageType type;
private String content;
private String sender;
private long timestamp; // 메시지 전송 시간 추가
}
이제 클라이언트로부터 메시지를 받아 처리하고, 다시 클라이언트에게 메시지를 전송하는 WebSocket 컨트롤러를 구현합니다.
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;
@Controller
public class ChatController {
@MessageMapping("/chat.sendMessage") // 클라이언트가 /app/chat.sendMessage로 메시지를 보냅니다.
@SendTo("/topic/public") // 이 토픽을 구독하는 모든 클라이언트에게 메시지를 보냅니다.
public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
chatMessage.setTimestamp(System.currentTimeMillis()); // 서버에서 타임스탬프 설정
return chatMessage;
}
@MessageMapping("/chat.addUser") // 클라이언트가 /app/chat.addUser로 메시지를 보냅니다.
@SendTo("/topic/public")
public ChatMessage addUser(@Payload ChatMessage chatMessage,
SimpMessageHeaderAccessor headerAccessor) {
// WebSocket 세션에 사용자 이름을 저장합니다.
headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
chatMessage.setTimestamp(System.currentTimeMillis());
return chatMessage;
}
}
@MessageMapping은 클라이언트가 특정 경로로 메시지를 보낼 때 해당 메서드가 호출되도록 합니다. @SendTo는 메서드 반환 값을 지정된 토픽을 구독하는 모든 클라이언트에게 브로드캐스트합니다. 예를 들어, /app/chat.sendMessage로 메시지를 보내면, ChatController의 sendMessage 메서드가 호출되고, 이 메시지는 /topic/public을 구독하는 모든 사용자에게 전송됩니다.
React 프론트엔드 구현: 실시간 메시지 처리
이제 React를 사용하여 WebSocket 서버와 통신하는 프론트엔드를 구현할 차례입니다. 저는 stompjs 라이브러리를 주로 사용합니다.
의존성 설치 및 WebSocket 연결
먼저 필요한 라이브러리를 설치합니다.
npm install stompjs sockjs-client
# 또는
yarn add stompjs sockjs-client
다음으로 WebSocket 연결을 설정하고 메시지를 주고받는 React 컴포넌트를 만듭니다.
import React, { useState, useEffect, useRef } from 'react';
import SockJS from 'sockjs-client';
import Stomp from 'stompjs';
function ChatRoom() {
const [messages, setMessages] = useState([]);
const [messageInput, setMessageInput] = useState('');
const [username, setUsername] = useState('');
const [connected, setConnected] = useState(false);
const stompClient = useRef(null);
// 컴포넌트 마운트 시 WebSocket 연결 시도
useEffect(() => {
const socket = new SockJS('http://localhost:8080/ws'); // Spring Boot 서버의 WebSocket 엔드포인트
stompClient.current = Stomp.over(socket);
stompClient.current.debug = null; // STOMP 디버그 메시지 비활성화
stompClient.current.connect({}, onConnected, onError);
// 컴포넌트 언마운트 시 WebSocket 연결 해제
return () => {
if (stompClient.current && stompClient.current.connected) {
stompClient.current.disconnect(() => console.log("Disconnected"));
}
};
}, []); // 빈 배열: 최초 1회만 실행
const onConnected = () => {
setConnected(true);
console.log("Connected to WebSocket");
// public 토픽 구독
stompClient.current.subscribe('/topic/public', onMessageReceived);
// 사용자 입장 메시지 전송
if (username) {
stompClient.current.send("/app/chat.addUser",
{},
JSON.stringify({ sender: username, type: 'JOIN' })
);
}
};
const onError = (error) => {
console.error("WebSocket connection error:", error);
setConnected(false);
// 연결 실패 시 재연결 로직 구현 가능
};
const onMessageReceived = (payload) => {
const message = JSON.parse(payload.body);
setMessages(prevMessages => [...prevMessages, message]);
};
const sendMessage = (event) => {
event.preventDefault();
if (stompClient.current && stompClient.current.connected && messageInput) {
const chatMessage = {
sender: username,
content: messageInput,
type: 'CHAT'
};
stompClient.current.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage));
setMessageInput('');
}
};
const handleUsernameSubmit = (event) => {
event.preventDefault();
if (username.trim() && !connected) {
// 사용자 이름 설정 후 연결 재시도
// 실제 앱에서는 연결 로직 분리 및 상태 관리 필요
stompClient.current.connect({}, onConnected, onError);
}
};
return (
실시간 채팅방
{!username ? () : ( <>환영합니다, {username}님!
{msg.sender}님이 입장하셨습니다.
} {msg.type === 'LEAVE' &&{msg.sender}님이 퇴장하셨습니다.
} {msg.type === 'CHAT' &&{msg.sender}: {msg.content}
}
);
}
export default ChatRoom;
이 코드는 React의 useState와 useEffect 훅을 활용하여 WebSocket 연결 상태와 메시지 목록을 관리합니다. SockJS를 통해 WebSocket 연결을 맺고, Stomp.over(socket)으로 STOMP 클라이언트를 생성합니다. stompClient.current.connect()로 서버에 연결하고, 연결 성공 시 /topic/public을 구독하여 서버로부터 오는 메시지를 onMessageReceived 함수로 처리합니다. 메시지 전송 시에는 stompClient.current.send()를 사용하여 /app/chat.sendMessage 엔드포인트로 메시지를 보냅니다.
Image by Sunriseforever on Pixabay
실제 연동 과정에서 마주한 난관과 해결책
직접 Spring Boot와 React를 연동하며 실시간 채팅을 구현하는 과정에서 몇 가지 예상치 못한 난관에 부딪혔습니다. 이를 어떻게 해결했는지 공유합니다.
1. CORS (Cross-Origin Resource Sharing) 문제
가장 먼저 마주한 것은 CORS 문제였습니다. React 개발 서버(예: localhost:3000)에서 Spring Boot 서버(예: localhost:8080)의 WebSocket 엔드포인트에 접속하려 할 때, 브라우저 보안 정책으로 인해 연결이 차단되는 현상입니다.
해결책: Spring Boot 서버에서 CORS 설정을 명시적으로 허용해야 합니다. 개발 단계에서는 setAllowedOriginPatterns("*")를 사용하여 모든 Origin을 허용할 수 있지만, 실제 서비스에서는 보안을 위해 React 애플리케이션이 배포될 특정 도메인만 허용하도록 변경해야 합니다.
// WebSocketConfig.java
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("http://localhost:3000", "https://your-frontend-domain.com") // 실제 서비스 도메인 추가
.withSockJS();
2. WebSocket 연결 끊김 및 재연결 로직
네트워크 불안정이나 서버 재시작 등으로 인해 WebSocket 연결이 예기치 않게 끊길 수 있습니다. 이때 사용자 경험을 저해하지 않으려면 재연결 로직이 필수적입니다.
해결책: React 프론트엔드에서 onError 콜백 함수를 활용하여 연결 실패 시 일정 시간 간격으로 재연결을 시도하는 로직을 구현했습니다.
// React ChatRoom.js (일부)
const reconnectInterval = 3000; // 3초마다 재연결 시도
const onError = (error) => {
console.error("WebSocket connection error:", error);
setConnected(false);
setTimeout(() => {
console.log("Attempting to reconnect...");
stompClient.current.connect({}, onConnected, onError);
}, reconnectInterval);
};
또한, 서버 측에서 연결이 끊긴 세션에 대한 처리가 필요할 수 있습니다. 예를 들어, @EventListener를 사용하여 SessionDisconnectEvent를 감지하고, 해당 사용자의 퇴장 메시지를 브로드캐스트하는 등의 처리를 할 수 있습니다.
// Spring Boot WebSocketEventListener.java
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
@Component
public class WebSocketEventListener {
private final SimpMessageSendingOperations messagingTemplate;
public WebSocketEventListener(SimpMessageSendingOperations messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String username = (String) headerAccessor.getSessionAttributes().get("username");
if (username != null) {
System.out.println("User Disconnected: " + username);
ChatMessage chatMessage = new ChatMessage();
chatMessage.setType(ChatMessage.MessageType.LEAVE);
chatMessage.setSender(username);
chatMessage.setTimestamp(System.currentTimeMillis());
messagingTemplate.convertAndSend("/topic/public", chatMessage);
}
}
}
3. 메시지 형식 불일치 및 직렬화/역직렬화
클라이언트와 서버 간에 주고받는 JSON 메시지의 필드명이나 타입이 일치하지 않아 메시지 파싱 오류가 발생하는 경우가 있었습니다. 특히 Java 객체와 JavaScript 객체 간의 매핑에서 주의가 필요합니다.
해결책: DTO 클래스와 React에서 메시지를 생성하는 객체의 필드명을 정확히 일치시키는 것이 중요합니다. Lombok의 @Getter, @Setter를 사용하면 편리하게 JSON 직렬화/역직렬화를 처리할 수 있습니다. JavaScript에서는 JSON.stringify()와 JSON.parse()를 사용하여 객체를 문자열로 변환하고 다시 객체로 변환할 때 오류가 없는지 꼼꼼히 확인해야 합니다.
Image by rawpixel on Pixabay
성능 최적화 및 확장성을 위한 고려사항
간단한 실시간 채팅 애플리케이션을 구축하는 것은 비교적 쉽지만, 수많은 사용자가 동시에 접속하는 대규모 서비스로 확장하려면 몇 가지 추가적인 고려사항이 필요합니다.
1. 메시지 브로커 확장
enableSimpleBroker()는 단일 서버에서 동작하는 간단한 인메모리 브로커입니다. 이는 소규모 애플리케이션에는 적합하지만, 여러 Spring Boot 인스턴스를 클러스터링하거나 대규모 메시지 처리가 필요할 때는 한계가 있습니다.
해결책: RabbitMQ, Kafka, ActiveMQ와 같은 외부 메시지 브로커를 연동하는 것을 고려해야 합니다. Spring은 config.enableStompBrokerRelay("host", port)와 같은 메서드를 통해 외부 메시지 브로커와 쉽게 연동할 수 있는 기능을 제공합니다. 이렇게 하면 여러 WebSocket 서버 인스턴스 간에 메시지를 공유하고, 서버를 수평 확장할 수 있습니다.
2. 세션 관리 및 인증/인가
실시간 채팅에서 사용자의 인증은 매우 중요합니다. 누가 메시지를 보내는 사람인지, 특정 채팅방에 접근할 권한이 있는지 등을 확인해야 합니다.
해결책: WebSocket 연결 시 HTTP 핸드셰이크 과정에서 세션 정보나 JWT (JSON Web Token)를 활용하여 사용자를 인증하고, 연결된 세션에 사용자 정보를 저장하여 메시지 전송 시 인가를 처리할 수 있습니다. Spring Security와 함께 사용하면 더욱 견고한 보안 시스템을 구축할 수 있습니다.
3. 데이터베이스 연동 및 메시지 영속성
사용자가 접속해 있지 않을 때도 메시지를 받아볼 수 있도록 하거나, 과거 채팅 기록을 조회할 수 있도록 하려면 메시지를 데이터베이스에 저장해야 합니다.
해결책: Spring Data JPA나 MongoDB 같은 NoSQL 데이터베이스를 활용하여 ChatMessage 객체를 영속화하는 기능을 추가할 수 있습니다. 메시지 전송 시 데이터베이스에 저장하고, 사용자가 채팅방에 입장할 때 최근 메시지 기록을 불러와 보여주는 방식으로 구현할 수 있습니다.
마무리하며: 실시간 웹 서비스 개발의 무한한 가능성
WebSocket을 활용한 실시간 채팅 애플리케이션 개발은 Spring Boot와 React라는 강력한 조합을 통해 생각보다 수월하게 진행할 수 있었습니다. 특히 STOMP 프로토콜 덕분에 메시지 브로커 기능을 쉽게 구현할 수 있었고, React의 useEffect와 useState 훅을 활용하여 연결 및 메시지 상태 관리를 깔끔하게 처리할 수 있었습니다.
이 경험을 통해 실시간 웹 서비스 개발에 대한 자신감을 얻었으며, WebSocket이 채팅뿐만 아니라 실시간 알림, 주식 시세, 게임 등 다양한 분야에서 무한한 가능성을 가지고 있음을 다시 한번 깨달았습니다. 초기에는 CORS나 재연결 로직 등 몇 가지 난관에 부딪히기도 했지만, 해결책을 찾아 적용하면서 풀스택 개발자로서 한 단계 더 성장할 수 있었습니다.
여러분도 이 가이드를 통해 자신만의 실시간 채팅 애플리케이션을 성공적으로 구축하고, 더 나아가 다양한 실시간 웹 서비스 개발에 도전해보시길 바랍니다. 궁금한 점이나 더 좋은 구현 방법이 있다면 언제든지 댓글로 공유해주세요!
📌 함께 읽으면 좋은 글
- [튜토리얼] OpenAPI와 Swagger를 활용한 REST API 문서 자동화 및 효율적인 테스트 전략
- [튜토리얼] Go 언어와 Fiber 프레임워크로 빠르고 견고한 RESTful API 서버 구축하기
- [AI 머신러닝] LLM 애플리케이션을 위한 RAG 아키텍처: 구현 전략과 실전 적용 가이드
이 글이 도움이 되셨다면 공감(♥)과 댓글로 응원해 주세요!
궁금한 점이나 다루었으면 하는 주제가 있다면 댓글로 남겨주세요.
'튜토리얼' 카테고리의 다른 글
| Docker Compose 로컬 개발 환경 구축: PostgreSQL, Redis, Kafka 연동 완벽 가이드 (1) | 2026.06.01 |
|---|---|
| OpenAPI와 Swagger를 활용한 REST API 문서 자동화 및 효율적인 테스트 전략 (0) | 2026.05.31 |
| Docker와 Spring Boot, 컨테이너로 빌드하고 배포까지: 실전 가이드 (0) | 2026.05.30 |
| Kafka를 활용한 분산 메시지 큐 시스템 구축 실전 가이드 (0) | 2026.05.30 |
| Go 언어와 Fiber 프레임워크로 빠르고 견고한 RESTful API 서버 구축하기 (0) | 2026.05.28 |