"실시간" 서비스는 주변에 이미 많이 널려있다.
실시간을 구현하기 위해서, http 통신만 있으면 될까?
결론은 "안된다." 이다.
일반적인 HTTP 통신은 기본적으로 다음과 같은 응답 구조를 가진다.
클라이언트 -> 요청 -> 서버 -> 응답 -> 클라이언트
서버에 새로운 데이터가 생겼더라도, 클라이언트가 요청하지 않으면 이를 바로 전달하기 어렵기 때문에,
주기적으로 클라이언트에서 데이터 요청을 보내야하는 불상사가 생기게 된다.
서버야 너 뭐해?
서버야 너 뭐해?
서버야 너 뭐해?
서버야 너 뭐해?
이처럼 일정한 간격으로 요청을 보내는 방식이 폴링이라고 한다.
요청을 보내는 텀이 그리 짧지 않으면 상관없겠지만, 텀이 짧아지면 짧아질 수록 서버의 정신은 나가게 된다.
서버의 정신 건강을 위해서라도, 웹소켓을 도입해서 이 문제를 해결해야한다.
웹소켓이란?
클라이언트와 서버 사이에 연결을 유지하면서, 데이터를 양방향으로 주고받을 수 있게 하는 통신 프로토콜
클라이언트와 서버간에 연결을 한번 해놓으면, 클라이언트가 주기적으로 요청을 할 필요 없이 서버가 먼저 데이터를 클라이언트로 전송할 수 있게 된다.
클라이언트 <-> 서버
웹소켓 vs http
| 구분 | HTTP | WebSocket |
| 기본 구조 | 요청과 응답 | 지속적인 양방향 구조 |
| 서버의 선제 전송 | 불가 | 쉬움 |
| 연결 유지 | 일반적으로 요청 단위 | 연결 종료 전까지 유지 |
| 실시간 통신 | 폴링 등이 필요 | 기본적으로 지원 |
| 대표 활용 | rest api, 파일 조회 | 채팅, 알림, 게임 상태 |
| 통신 주소 | http, https | ws, wss |
웹소켓을 활용한다면
웹소켓을 활용할 수 있는 기능은 다음과 같다.
- 채팅
- 실시간 알림
- 온라인 게임
- 협업 문서 (google docs같은 느낌)
- 실시간 대시보드
- 주식 차트
웹소켓 연결 과정
클라이언트와 서버간에, HTTP를 이용해서 연결을 맺는다.
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13
이 요청의 의미는, "서버야, 나 클라이언트인데 나 이 http 연결 WebSocket으로 업그레이드 하고 싶어" 이다.
서버가 이를 수락하면, 다음과 같은 응답을 클라이언트에 주게 된다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
이 과정을 WebSocket Handshake라고 한다.
STOMP(Simple Text Oriented Messaging Protocol)
WebSocket 연결 방식 위해서 메세지를 주고 받는 방식
STOMP의 명령어
- CONNECT: 서버 연결
- SUBSCRIBE: 특정 채널 구독
- SEND: 메시지 전송
- MESSAGE: 서버가 구독자에게 메시지 전달
- DISCONNECT: 연결 종료
STOMP의 Destination
STOMP에서는 메시지를 보내거나 구독할 위치를 Destination으로 표현한다.
/app/rooms/1/chat
/topic/rooms/1/chat
STOMP 메시지 흐름
채팅방을 예로 들면 다음과 같다.
1. 사용자가 /topic/rooms/1/chat 구독
2. 사용자가 /app/rooms/1/chat으로 메시지 전송
3. 서버가 메시지를 처리
4. 서버가 /topic/rooms/1/chat으로 메시지 발행
5. 해당 주소를 구독한 사용자들이 메시지 수신
Spring Boot에서 STOMP가 동작하는 방식
@Controller
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
@MessageMapping("/rooms/{roomId}/chat")
@SendTo("/topic/rooms/{roomId}/chat")
public ChatMessage send(
@DestinationVariable Long roomId,
ChatMessage message
) {
return chatService.createMessage(roomId, message);
}
}
@MessageMapping
- HTTP 통신에서, PostMapping과 같다.
- 해당 경로로 메세지가 들어오면, 이 애노테이션이 걸린 메서드가 실행이 된다.
@SendTo
- 해당 메서드의 리턴값을 특정 주소로 보내고 싶을 때 사용
대략적인 flow
사용자 A
│
│ SEND /app/rooms/123/chat
▼
@MessageMapping 메서드 실행
│
│ ChatResponse 반환
▼
@SendTo /topic/rooms/123/chat
│
├── 사용자 A
├── 사용자 B
└── 사용자 C
WebSocket 사용시 주의사항
1. 연결이 항상 유지된다고 생각하면 안 됨
네트워크 변경, 브라우저 절전, 서버 재시작, 프록시 타임아웃 등으로 연결이 언제든 끊길 수 있다.
클라이언트에서의 고려사항
- 연결 종료 감지
- 일정 시간 후 재연결
- 재연결 성공 후 기존 채널 재구독
- 재연결 중 중복 요청 방지
function connect() {
stompClient.activate();
}
stompClient.onWebSocketClose = () => {
console.log("WebSocket 연결 종료");
setTimeout(connect, 3000);
};
2. 메시지는 중복으로 도착할 수 있음
재연결이나 재전송 과정에서 같은 메시지가 두 번 처리될 수 있기 때문에, 중요한 이벤트에는 eventId를 부여하는 게 좋다.
{
"eventId": "evt_01JZ9K",
"eventType": "CARD_ACTIVATED",
"roomId": 10,
"occurredAt": "2026-07-19T09:30:00+09:00",
"payload": {
"cardId": 30
}
}
서버나 클라이언트는 이미 처리한 eventId인지 확인해서 중복 처리를 막을 수 있다.
3. 메시지 순서가 항상 보장된다고 생각하면 안 됨
같은 연결 안에서는 순서가 비교적 유지되지만, 다음 상황에서는 순서가 뒤바뀔 수 있다.
- 서버가 여러 대인 경우
- Redis Pub/Sub을 사용하는 경우
- 비동기 처리하는 경우
- 재연결 이후 누락된 메시지를 다시 받는 경우
예를 들어 다음과 같은 순서로 노래가 시작이 된다고 가정해보자.
1. 참가자 입장
2. 참가자 준비
3. 노래 시작
이 순서가 항상 보장된다면 좋겠지만, 클라이언트에는 준비 → 입장 → 노래 시작처럼 전달될 가능성을 고려해야 한다.
그래서 방 단위 상태 이벤트에는 version이나 sequence를 넣는다.
{
"eventType": "PARTICIPANT_READY_CHANGED",
"roomId": 10,
"version": 19,
"payload": {
"participantId": 204,
"ready": true
}
}
클라이언트는 현재 버전보다 낮거나 같은 메시지는 무시할 수 있다.
4. WebSocket 메시지만으로 상태를 복구하려 하면 안 됨
WebSocket은 현재 발생하는 변화를 전달하기 좋지만, 접속 전에 발생한 이벤트까지 자동으로 알려주지는 않는다.
마치 카톡 단톡방처럼, 내가 들어온 시점 이전의 대화 내용은 알 수 없다.
그렇기 때문에, rest api와 같이 병행한다.
REST API
→ 현재 방 상태 전체 조회
WebSocket
→ 이후 발생하는 변경 사항 수신
예를 들어 방에 입장할 때:
1. GET /api/v1/rooms/{roomId}
2. /topic/rooms/{roomId} 구독
3. 이후 WebSocket 이벤트로 상태 갱신
5. 동시 요청에 대한 처리가 필요함
WebSocket은 여러 사용자가 거의 동시에 메시지를 보내기 때문에 동시성 문제가 자주 발생한다.
예를 들어 두 사용자가 동시에 카드를 사용하면 다음 문제가 생길 수 있다.
사용자 A: 카드 사용 가능 확인
사용자 B: 카드 사용 가능 확인
사용자 A: 카드 적용
사용자 B: 카드 적용
둘 다 사용 가능하다고 판단하는 경쟁 조건이 발생할 수 있다.
따라서 중요한 상태 변경에는 다음 중 하나가 필요하다.
- DB 비관적 락
- Redis 분산 락
- 원자적 Redis 연산
- 단일 방 이벤트 큐
- 버전 기반 낙관적 락
싸스케의 카드 전역 잠금처럼 여러 Spring 서버가 접근하는 데이터라면 Redisson 분산 락을 사용할 수 있다.
6. 서버 여러 대를 사용할 때 연결 상태 공유를 고려해야 함
WebSocket 연결은 특정 서버 인스턴스에 붙어 있기 때문에, 여러 Spring 서버를 사용하는 경우 유의해야한다.
사용자 A → Spring 서버 1
사용자 B → Spring 서버 2
Spring의 단순 메모리 브로커만 사용하면 서버 1에서 발생한 메시지가 서버 2의 사용자에게 전달되지 않을 수 있다.
서버1과 서버2는 서로 뭘하고 있는지 바로 알아챌 수 없다.
그렇기 때문에, 다음과 같이 여러 서버간에 메세지를 알아챌 수 있는 브로커가 필요하다.
Spring 서버 1 ─┐
├─ Redis Pub/Sub 또는 외부 메시지 브로커
Spring 서버 2 ─┘
단, Redis Pub/Sub은 메시지를 저장하지 않기 때문에 구독자가 잠시 끊겨 있으면 메시지가 유실될 수 있다.
반드시 보존해야 하는 이벤트는 DB, Redis Stream, Kafka 같은 별도 저장 구조를 고려해야 한다.
7. 연결 종료 시 정리 작업이 필요함
브라우저를 정상적으로 닫으면 DISCONNECT 이벤트를 받을 수 있지만,
인터넷이 갑자기 끊기거나 프로세스가 종료되면 즉시 감지하지 못할 수 있다.
따라서 다음과 같은 설정이 필요하다.
- STOMP heartbeat 설정
- 마지막 접속 시간 기록
- 일정 시간 응답이 없으면 오프라인 처리
- 방 참가자 상태 정리
- 공연자 연결 종료 시 노래 중단 또는 복구 처리
@EventListener
public void handleDisconnect(SessionDisconnectEvent event) {
String sessionId = event.getSessionId();
participantService.disconnect(sessionId);
}
Redis Pub/Sub 구조
Redis Pub/Sub은 여러 서버 사이에서 실시간 메시지를 전달하기 위한 발행·구독 방식이다.
메시지를 보내는 서버는 특정 Redis 채널에 이벤트를 발행하고, 해당 채널을 구독하고 있는 모든 서버가 같은 이벤트를 전달받는다.
사용자
↓ WebSocket
Spring 서버 1
↓ Publish
Redis Pub/Sub
↓ ↓
Spring 서버 1 Spring 서버 2
↓ ↓
각 서버에 연결된 사용자에게 WebSocket 메시지 전달
예를 들어 사용자가 Spring 서버 1에 채팅을 보내면, 서버 1은 해당 채팅 이벤트를 Redis에 발행한다.
Redis는 이를 구독 중인 모든 Spring 서버에 전달하고,
각 서버는 자신에게 연결된 사용자들에게 WebSocket으로 메시지를 전송한다.
즉, 역할은 다음과 같이 나뉜다.
Redis Pub/Sub
→ 여러 Spring 서버 사이의 메시지 전달
WebSocket
→ Spring 서버와 클라이언트 사이의 메시지 전달
단, Redis Pub/Sub은 메시지를 저장하지 않기 때문에 구독자가 연결되어 있지 않은 동안 발생한 메시지는 다시 받을 수 없다.
따라서 반드시 보존해야 하는 데이터는 DB에 저장하고, Pub/Sub은 실시간 이벤트 전파 용도로 사용하는 것이 적절하다.