Redis Pub/Sub + WebSocket + Spring Boot로 채팅 서버 구현

Ahn·2026년 9월 2일

1. 들어가며

앞에서 Redis Pub/Sub의 기본 개념을 공부했다.

이번에는 Redis Pub/Sub을 WebSocket 기반 실시간 채팅 서버에 실제로 적용해본다.

특히 중요한 목표는 단순히 WebSocket으로 1:1 통신을 구현하는 것이 아니라,

Spring Boot 서버가 여러 대로 늘어나더라도 사용자들이 서로 다른 서버에 연결되어 있는 상황에서 메시지를 전달할 수 있도록 만드는 것

이다.

최종적으로 다음과 같은 구조를 구현한다.

                        Redis
                   ┌─────────────┐
                   │   Pub/Sub  |
                   │            │
                   │ chat:room:1│
                   └──────┬──────┘
                          │
              ┌────────────┼────────────┐
              │           │           │
              ▼           ▼           ▼
          Server 1      Server 2   Server 3
              │           │           │
              ▼           ▼           ▼
           User A        User B     User C

2. 먼저 WebSocket부터 이해하기

WebSocket은 클라이언트와 서버 사이에 지속적인 양방향 통신 연결을 유지할 수 있도록 해주는 프로토콜이다.

일반적인 HTTP 요청은 다음과 같다.

Client
  │
  │ HTTP Request
  ▼
Server
  │
  │ HTTP Response
  ▼
Client

클라이언트가 요청해야 서버가 응답할 수 있다.

하지만 채팅에서는 서버가 새로운 메시지가 발생했을 때 클라이언트에게 바로 전달해야 한다.

Client ←──────────→ Server
       양방향 통신

WebSocket을 사용하면 연결을 유지하면서 양쪽에서 자유롭게 메시지를 보낼 수 있다.


3. WebSocket만 사용하면 안 되는가?

서버가 한 대라면 WebSocket만으로도 충분히 채팅을 구현할 수 있다.

Client A
   │
   │ WebSocket
   ▼
Server
   ▲
   │ WebSocket
   │
Client B

Server는 연결된 Client들의 WebSocket Session을 관리하고 메시지를 전달하면 된다.

문제는 서버가 여러 대가 되는 순간 발생한다.

                 Load Balancer
                 /     |     \
                /      |      \
               ▼       ▼       ▼
           Server 1 Server 2 Server 3
              │        │        │
           User A    User B    User C

예를 들어:

  • User A → Server 1
  • User B → Server 2

에 연결되어 있다고 하자.

User A가 메시지를 보내면 Server 1은 User B가 Server 2에 연결되어 있다는 사실을 알고 있어야 한다.

그런데 Server 1은 Server 2의 WebSocket Session을 직접 가지고 있지 않다.

이때 Redis Pub/Sub을 사용할 수 있다.


4. 전체 구조

최종적인 구조는 다음과 같다.

                         Redis
                    ┌─────────────┐
                    │   Pub/Sub   │
                    │             │
                    │ chat:room:1 │
                    └──────┬──────┘
                           │
             ┌─────────────┼─────────────┐
             │             │             │
             ▼             ▼             ▼
        ┌─────────┐   ┌─────────┐   ┌─────────┐
        │ Server1 │   │ Server2 │   │ Server3 │
        └────┬────┘   └────┬────┘   └────┬────┘
             │             │             │
           WebSocket     WebSocket     WebSocket
             │             │             │
             ▼             ▼             ▼
          User A         User B         User C

각 Spring Boot 서버는 Redis의 동일한 Channel을 구독한다.

Server 1 ─┐
Server 2 ─┼── subscribe → chat:room:1
Server 3 ─┘

User A가 메시지를 보내면:

User A
  │
  │ WebSocket
  ▼
Server 1
  │
  │ PUBLISH
  ▼
Redis
  │
  ├────────────► Server 1
  │
  ├────────────► Server 2
  │
  └────────────► Server 3
                       │
                       ▼
                    User B/C

각 서버는 Redis에서 메시지를 받은 후 자신에게 연결되어 있는 WebSocket Client들에게 메시지를 전달한다.


5. 프로젝트 구성

예제에서는 다음과 같은 기술을 사용한다.

Spring Boot
Spring WebSocket
Spring Data Redis
Redis
Java

프로젝트 구조는 간단하게 다음과 같이 구성할 수 있다.

src/main/java
└── com.example.chat
    ├── config
    │   ├── WebSocketConfig.java
    │   └── RedisConfig.java
    │
    ├── chat
    │   ├── ChatMessage.java
    │   ├── ChatController.java
    │   ├── ChatPublisher.java
    │   └── ChatSubscriber.java
    │
    └── websocket
        └── WebSocketHandler.java

6. Redis 실행

로컬 Redis가 설치되어 있다면 Redis를 실행한다.

Docker를 사용한다면:

docker run -d \
  --name redis \
  -p 6379:6379 \
  redis

Redis가 정상적으로 실행되었는지 확인한다.

redis-cli ping

정상이라면:

PONG

이 출력된다.


7. Spring Boot 의존성 추가

Gradle 기준으로 필요한 의존성은 다음과 같다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

Spring WebSocket은 WebSocket 통신을 담당하고,

Spring Data Redis는 Redis와의 통신을 담당한다.


8. Redis 연결 설정

application.yml

spring:
  data:
    redis:
      host: localhost
      port: 6379

Docker Compose나 별도의 Redis 서버를 사용한다면 해당 Redis의 Host와 Port에 맞게 변경한다.


9. WebSocket 설정

Spring에서 WebSocket Endpoint를 등록한다.

@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final WebSocketHandler webSocketHandler;

    @Override
    public void registerWebSocketHandlers(
            WebSocketHandlerRegistry registry
    ) {
        registry
            .addHandler(webSocketHandler, "/ws/chat")
            .setAllowedOrigins("*");
    }
}

이제 클라이언트는 다음 Endpoint에 WebSocket 연결을 요청할 수 있다.

ws://localhost:8080/ws/chat

10. WebSocketHandler 구현

WebSocket 연결을 관리하기 위한 Handler를 만든다.

@Component
@RequiredArgsConstructor
public class WebSocketHandler
        extends TextWebSocketHandler {

    private final ChatPublisher chatPublisher;

    @Override
    protected void handleTextMessage(
            WebSocketSession session,
            TextMessage message
    ) {
        chatPublisher.publish(message.getPayload());
    }
}

클라이언트가 WebSocket으로 메시지를 보내면 handleTextMessage()가 호출된다.

Client
  │
  │ WebSocket Message
  ▼
WebSocketHandler
  │
  ▼
ChatPublisher
  │
  ▼
Redis

11. ChatMessage 정의

실제 채팅에서는 단순히 "hello"만 보내는 것보다 메시지의 정보를 구조화하는 것이 좋다.

예를 들어:

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {

    private String roomId;
    private Long senderId;
    private String message;
}

JSON으로 표현하면:

{
  "roomId": "1",
  "senderId": 100,
  "message": "안녕하세요!"
}

이렇게 구성할 수 있다.


12. Publisher 구현

Redis에 메시지를 발행하는 객체를 만든다.

@Component
@RequiredArgsConstructor
public class ChatPublisher {

    private final RedisTemplate<String, Object> redisTemplate;

    public void publish(ChatMessage message) {

        String channel = "chat:room:" + message.getRoomId();

        redisTemplate.convertAndSend(
                channel,
                message
        );
    }
}

핵심은 이 부분이다.

redisTemplate.convertAndSend(
    channel,
    message
);

Redis Pub/Sub의 Publish를 수행한다.

결과적으로:

ChatPublisher
      │
      │ PUBLISH
      ▼
chat:room:1

이 된다.


13. Subscriber 구현

이제 Redis에서 메시지를 받는 Subscriber를 만든다.

@Component
@RequiredArgsConstructor
public class ChatSubscriber
        implements MessageListener {

    private final WebSocketSessionManager sessionManager;

    @Override
    public void onMessage(
            Message message,
            byte[] pattern
    ) {

        String body = new String(
                message.getBody(),
                StandardCharsets.UTF_8
        );

        sessionManager.broadcast(body);
    }
}

Redis에서 메시지를 받으면 WebSocket 연결 사용자들에게 전달한다.

Redis
  │
  │ Message
  ▼
ChatSubscriber
  │
  ▼
WebSocketSessionManager
  │
  ▼
Connected Clients

14. Redis Subscriber 등록

Redis가 ChatSubscriber에게 메시지를 전달하도록 설정해야 한다.

@Configuration
@RequiredArgsConstructor
public class RedisConfig {

    @Bean
    public RedisMessageListenerContainer redisContainer(
            RedisConnectionFactory connectionFactory,
            ChatSubscriber chatSubscriber
    ) {

        RedisMessageListenerContainer container =
                new RedisMessageListenerContainer();

        container.setConnectionFactory(connectionFactory);

        container.addMessageListener(
                chatSubscriber,
                new PatternTopic("chat:room:*")
        );

        return container;
    }
}

여기서 중요한 부분은:

new PatternTopic("chat:room:*")

이다.

모든 채팅방 Channel을 구독한다는 의미이다.

예를 들어:

chat:room:1
chat:room:2
chat:room:3

모두 받을 수 있다.


15. WebSocket Session 관리

이제 각 서버에 연결되어 있는 WebSocket Session을 관리해야 한다.

예를 들어:

@Component
public class WebSocketSessionManager {

    private final Set<WebSocketSession> sessions =
            ConcurrentHashMap.newKeySet();

    public void add(WebSocketSession session) {
        sessions.add(session);
    }

    public void remove(WebSocketSession session) {
        sessions.remove(session);
    }

    public void broadcast(String message) {

        for (WebSocketSession session : sessions) {

            if (session.isOpen()) {
                try {
                    session.sendMessage(
                        new TextMessage(message)
                    );
                } catch (IOException e) {
                    // 예외 처리
                }
            }
        }
    }
}

여기서 중요한 점은 이 Session 목록은 해당 서버에 연결되어 있는 Session만 가지고 있다는 것이다.

예를 들어:

Server 1

sessions
├── User A
└── User D

Server 2는:

Server 2

sessions
├── User B
└── User E

를 가지고 있다.

Server 1이 Server 2의 Session을 직접 가지고 있는 것은 아니다.


16. 전체 메시지 흐름

이제 전체 흐름을 연결해보자.

User A가 메시지를 전송한다고 가정한다.

User A
  │
  │ WebSocket
  │
  ▼
Server 1
  │
  │ ChatPublisher
  ▼
Redis
  │
  │ chat:room:1
  │
  ├───────────────┐
  ▼               ▼
Server 1        Server 2
  │               │
  ▼               ▼
User A/D        User B/E

즉:

Step 1

User A가 Server 1에 WebSocket 메시지를 보낸다.

User A
  │
  │ {"roomId":"1","message":"Hello"}
  ▼
Server 1

Step 2

Server 1이 Redis에 Publish한다.

Server 1
   │
   │ PUBLISH chat:room:1
   ▼
Redis

Step 3

Redis가 해당 Channel을 구독하는 서버에 메시지를 전달한다.

Redis
   │
   ├──► Server 1
   ├──► Server 2
   └──► Server 3

Step 4

각 서버는 자신의 WebSocket Client에게 메시지를 전달한다.

Server 1
   │
   └──► User A

Server 2
   │
   └──► User B

Server 3
   │
   └──► User C

17. 여기서 중요한 포인트

Redis가 WebSocket 메시지를 사용자에게 직접 보내는 것은 아니다.

이 부분을 헷갈리면 안 된다.

Redis의 역할은 서버와 서버 사이의 메시지 전달이다.

          Redis
       Server 간 전달
             │
      ┌──────┼──────┐
      ▼      ▼      ▼
   Server1 Server2 Server3
      │      │      │
      ▼      ▼      ▼
   WebSocket Client

즉 역할을 나누면:

WebSocket
→ Client ↔ Server 실시간 통신

Redis Pub/Sub
→ Server ↔ Server 메시지 전달

이렇게 이해하면 된다.


18. 단일 서버와 멀티 서버 비교

단일 서버

Redis 없이도 가능하다.

Client A
   │
   ▼
Server
   │
   ▼
Client B

멀티 서버

Redis Pub/Sub을 사용한다.

              Redis
             Pub/Sub
            /   |   \
           /    |    \
          ▼     ▼     ▼
      Server1 Server2 Server3
         │       │       │
        A,D     B,E     C,F

따라서 Redis Pub/Sub의 중요한 사용 이유 중 하나는:

여러 WebSocket 서버 사이에서 메시지를 공유하기 위해서

라고 볼 수 있다.


19. 채팅방별 메시지 전달

실제 채팅 서비스라면 모든 사용자에게 모든 메시지를 보내면 안 된다.

예를 들어:

Room 1
User A
User B

Room 2
User C
User D

가 있다면 Room 1의 메시지는 Room 2 사용자에게 전달되면 안 된다.

그래서 Channel을 채팅방 단위로 나눌 수 있다.

chat:room:1
chat:room:2
chat:room:3

Room 1 메시지:

PUBLISH chat:room:1 "Hello"

Room 2 메시지:

PUBLISH chat:room:2 "Hi"

이렇게 분리할 수 있다.


20. 더 현실적인 Session 관리

앞의 예제에서는 모든 WebSocket Session에게 메시지를 Broadcast했다.

하지만 실제 서비스에서는 Session이 어느 채팅방에 들어가 있는지를 관리해야 한다.

예를 들어:

roomId
   │
   ▼
Sessions

room:1
 ├── User A
 └── User B

room:2
 ├── User C
 └── User D

이를 Map으로 관리할 수 있다.

private final Map<String, Set<WebSocketSession>> roomSessions
        = new ConcurrentHashMap<>();

사용자가 채팅방에 입장하면:

roomSessions
    .computeIfAbsent(
        roomId,
        key -> ConcurrentHashMap.newKeySet()
    )
    .add(session);

이런 방식으로 관리할 수 있다.


21. Room 단위 Broadcast

이제 특정 Room의 Session에게만 메시지를 전달한다.

public void broadcast(
        String roomId,
        String message
) {

    Set<WebSocketSession> sessions =
            roomSessions.get(roomId);

    if (sessions == null) {
        return;
    }

    for (WebSocketSession session : sessions) {

        if (!session.isOpen()) {
            continue;
        }

        try {
            session.sendMessage(
                new TextMessage(message)
            );
        } catch (IOException e) {
            // 예외 처리
        }
    }
}

그러면:

Redis
  │
  │ chat:room:1
  ▼
Server
  │
  │ roomId = 1
  ▼
Room 1 Sessions
  ├── User A
  └── User B

에게만 메시지를 전달할 수 있다.


22. 메시지의 흐름을 코드 기준으로 다시 보기

전체 코드를 개념적으로 연결하면 다음과 같다.

WebSocketHandler
       │
       │ receive
       ▼
ChatPublisher
       │
       │ publish
       ▼
Redis Pub/Sub
       │
       │ subscribe
       ▼
ChatSubscriber
       │
       ▼
SessionManager
       │
       │ sendMessage()
       ▼
WebSocket Client

이 구조가 이번 TIL에서 가장 중요한 부분이다.


23. STOMP를 사용하는 경우

Spring WebSocket에서는 직접 WebSocketHandler를 구현하는 방법 외에도 STOMP를 사용할 수 있다.

STOMP를 사용하면 메시지의 목적지를 다음과 같이 표현할 수 있다.

/topic/chat/1
/topic/chat/2

클라이언트는:

SUBSCRIBE /topic/chat/1

과 같은 방식으로 특정 채팅방을 구독할 수 있다.

메시지 전송은:

SEND /pub/chat/1

과 같은 형태로 구성할 수 있다.

이렇게 하면 WebSocket 위에서 메시지 라우팅을 더 구조적으로 관리할 수 있다.


24. STOMP + Redis 구조

STOMP를 사용한다고 해서 Redis가 필요 없어지는 것은 아니다.

멀티 서버 환경에서는 다음과 같이 구성할 수 있다.

Client
   │
   │ WebSocket + STOMP
   ▼
Spring Boot Server
   │
   │ Redis Pub/Sub
   ▼
Redis
   │
   ├──────────────┐
   ▼              ▼
Server 1       Server 2
   │              │
   ▼              ▼
Clients         Clients

여기서:

  • WebSocket → 연결
  • STOMP → 메시지 규칙과 목적지 관리
  • Redis Pub/Sub → 서버 간 메시지 전달

이라는 역할 분담을 할 수 있다.


25. Redis Pub/Sub을 사용하면 DB는 필요 없는가?

아니다.

Redis Pub/Sub은 메시지 전달을 위한 기능이지 채팅 메시지를 영구적으로 저장하기 위한 DB가 아니다.

예를 들어 실제 채팅 서비스에서는:

Client
   │
   ▼
Spring Boot
   │
   ├────────► Redis Pub/Sub
   │
   └────────► MySQL / PostgreSQL

처럼 사용할 수 있다.

Redis Pub/Sub:

실시간 메시지 전달

RDB:

채팅 메시지 저장

역할을 분리하는 것이다.


26. 메시지 저장까지 필요한 경우

예를 들어 사용자가 채팅방에 늦게 들어왔다고 하자.

10:00 User A → Hello
10:01 User B → Hi
10:02 User C 입장

User C에게 이전 메시지를 보여주려면 Pub/Sub만으로는 불가능하다.

왜냐하면 Pub/Sub은 과거 메시지를 저장하지 않기 때문이다.

따라서 DB에서 가져와야 한다.

                 ┌── Redis Pub/Sub
                 │    실시간 전달
                 │
Spring Boot ─────┤
                 │
                 └── DB
                      과거 메시지 조회

27. 실무에서 고려해야 하는 문제

27-1. 메시지 유실

Redis Pub/Sub은 메시지를 저장하지 않는다.

따라서 Subscriber가 장애 상태라면 메시지를 놓칠 수 있다.

Publisher
   │
   ▼
Redis
   │
   X
Server 2 장애

→ Server 2는 메시지를 받지 못함

채팅 메시지를 반드시 보존해야 한다면 DB 저장이나 Redis Streams/Kafka 등의 별도 메시징 구조를 고려해야 한다.


27-2. 중복 메시지

서버 구조나 재연결 로직에 따라 중복 메시지가 발생할 가능성을 고려해야 한다.

따라서 메시지에 고유 ID를 넣는 것도 좋은 방법이다.

{
  "messageId": "01J...",
  "roomId": "1",
  "senderId": 100,
  "message": "Hello"
}

클라이언트나 서버에서 필요하다면 messageId를 기준으로 중복 처리를 할 수 있다.


27-3. 순서 보장

채팅에서는 메시지 순서가 매우 중요하다.

예를 들어:

Message A
Message B
Message C

가 발생했는데 클라이언트에서:

Message B
Message A
Message C

순서로 표시되면 문제가 된다.

따라서 실제 서비스에서는 메시지에 서버 생성 시간이나 sequence 등을 포함하고, 필요한 경우 별도의 순서 보장 전략을 사용해야 한다.


28. Redis Pub/Sub의 한계

Redis Pub/Sub은 다음과 같은 요구사항에는 적합하지 않을 수 있다.

"메시지를 반드시 전달해야 한다."

"장애가 발생해도 메시지를 나중에 다시 처리해야 한다."

"Consumer가 어디까지 처리했는지 추적해야 한다."

"메시지를 재처리해야 한다."

이런 요구사항이 있다면 Redis Pub/Sub보다는 Redis Streams나 Kafka 같은 시스템을 검토하는 것이 좋다.


29. Redis Pub/Sub과 Redis Streams

간단하게 비교하면:

항목Pub/SubRedis Streams
메시지 저장XO
실시간 전달OO
메시지 재처리어려움가능
Consumer 상태 관리X가능
메시지 유실가능설계에 따라 방지
구조단순상대적으로 복잡
적합한 용도실시간 Broadcast이벤트/메시지 처리

따라서:

단순 실시간 전달
      ↓
Redis Pub/Sub

메시지 저장 + 재처리
      ↓
Redis Streams

라고 생각하면 이해하기 쉽다.


30. 최종 아키텍처

이번 TIL에서 구현한 구조를 하나로 합치면 다음과 같다.

                         ┌───────────────┐
                         │     Redis     │
                         │               │
                         │   Pub / Sub   │
                         └───────┬───────┘
                                 │
                    ┌────────────┼────────────┐
                    │            │            │
                    ▼            ▼            ▼
              ┌─────────┐  ┌─────────┐  ┌─────────┐
              │ Server 1│  │ Server 2│  │ Server 3│
              └────┬────┘  └────┬────┘  └────┬────┘
                   │             │             │
              WebSocket      WebSocket      WebSocket
                   │             │             │
              ┌────┴────┐   ┌────┴────┐   ┌────┴────┐
              │         │   │         │   │         │
              ▼         ▼   ▼         ▼   ▼         ▼
            User A    User B User C  User D User E  User F

메시지 전송 과정:

1. Client가 WebSocket으로 메시지 전송
              ↓
2. Spring WebSocketHandler가 메시지 수신
              ↓
3. ChatPublisher가 Redis에 Publish
              ↓
4. Redis가 Channel Subscriber들에게 전달
              ↓
5. 각 Spring Boot 서버의 ChatSubscriber가 수신
              ↓
6. 각 서버가 자신의 WebSocket Session에게 전달
              ↓
7. Client가 메시지 수신

31. 이번 TIL에서 배운 것

이번 실습을 통해 Redis Pub/Sub과 WebSocket은 서로 경쟁하는 기술이 아니라 서로 다른 역할을 담당하는 기술이라는 것을 알 수 있었다.

WebSocket

Client와 Server 사이의 실시간 통신을 담당한다.

Client ←── WebSocket ──→ Server

Redis Pub/Sub

여러 Server 사이의 메시지 전달을 담당한다.

Server ←── Redis Pub/Sub ──→ Server

DB

채팅 메시지를 영구적으로 저장한다.

Server ──→ DB

따라서 전체적인 역할은 다음과 같이 나눌 수 있다.

              ┌────────────────────┐
              │     Client         │
              └─────────┬──────────┘
                        │
                    WebSocket
                        │
                        ▼
              ┌────────────────────┐
              │   Spring Boot      │
              │                    │
              │ WebSocket Handler  │
              └───────┬────────────┘
                      │
             ┌────────┴────────┐
             ▼                 ▼
       Redis Pub/Sub           DB
       실시간 전달             영구 저장
             │
             ▼
       다른 Spring 서버

32. 최종 정리

Redis Pub/Sub + WebSocket을 조합하면 멀티 서버 환경에서 실시간 채팅 시스템을 구현할 수 있다.

가장 중요한 개념은 다음과 같다.

WebSocket은 Client와 Server의 연결을 담당하고, Redis Pub/Sub은 Server와 Server 사이의 메시지 전달을 담당한다.

그리고 Redis Pub/Sub은 메시지를 저장하지 않기 때문에 채팅 메시지를 영구적으로 보존해야 한다면 DB와 함께 사용해야 한다.

최종적으로 기억할 구조는 이것이다.

Client
  │
  │ WebSocket
  ▼
Spring Boot
  │
  │ Publish
  ▼
Redis Pub/Sub
  │
  │ Subscribe
  ▼
다른 Spring Boot 서버
  │
  │ WebSocket
  ▼
Client

즉,

Client ↔ WebSocket ↔ Spring Boot ↔ Redis Pub/Sub ↔ Spring Boot ↔ WebSocket ↔ Client

라는 구조로 이해하면 된다.

profile
dophamine

0개의 댓글