카테고리 없음

웹소켓

highlev 2026. 7. 19. 09:55

"실시간" 서비스는 주변에 이미 많이 널려있다.

실시간을 구현하기 위해서, 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은 실시간 이벤트 전파 용도로 사용하는 것이 적절하다.