앞에서 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
WebSocket은 클라이언트와 서버 사이에 지속적인 양방향 통신 연결을 유지할 수 있도록 해주는 프로토콜이다.
일반적인 HTTP 요청은 다음과 같다.
Client
│
│ HTTP Request
▼
Server
│
│ HTTP Response
▼
Client
클라이언트가 요청해야 서버가 응답할 수 있다.
하지만 채팅에서는 서버가 새로운 메시지가 발생했을 때 클라이언트에게 바로 전달해야 한다.
Client ←──────────→ Server
양방향 통신
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에 연결되어 있다는 사실을 알고 있어야 한다.
그런데 Server 1은 Server 2의 WebSocket Session을 직접 가지고 있지 않다.
이때 Redis Pub/Sub을 사용할 수 있다.
최종적인 구조는 다음과 같다.
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들에게 메시지를 전달한다.
예제에서는 다음과 같은 기술을 사용한다.
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
로컬 Redis가 설치되어 있다면 Redis를 실행한다.
Docker를 사용한다면:
docker run -d \
--name redis \
-p 6379:6379 \
redis
Redis가 정상적으로 실행되었는지 확인한다.
redis-cli ping
정상이라면:
PONG
이 출력된다.
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와의 통신을 담당한다.
application.yml
spring:
data:
redis:
host: localhost
port: 6379
Docker Compose나 별도의 Redis 서버를 사용한다면 해당 Redis의 Host와 Port에 맞게 변경한다.
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
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
실제 채팅에서는 단순히 "hello"만 보내는 것보다 메시지의 정보를 구조화하는 것이 좋다.
예를 들어:
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
private String roomId;
private Long senderId;
private String message;
}
JSON으로 표현하면:
{
"roomId": "1",
"senderId": 100,
"message": "안녕하세요!"
}
이렇게 구성할 수 있다.
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
이 된다.
이제 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
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
모두 받을 수 있다.
이제 각 서버에 연결되어 있는 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을 직접 가지고 있는 것은 아니다.
이제 전체 흐름을 연결해보자.
User A가 메시지를 전송한다고 가정한다.
User A
│
│ WebSocket
│
▼
Server 1
│
│ ChatPublisher
▼
Redis
│
│ chat:room:1
│
├───────────────┐
▼ ▼
Server 1 Server 2
│ │
▼ ▼
User A/D User B/E
즉:
User A가 Server 1에 WebSocket 메시지를 보낸다.
User A
│
│ {"roomId":"1","message":"Hello"}
▼
Server 1
Server 1이 Redis에 Publish한다.
Server 1
│
│ PUBLISH chat:room:1
▼
Redis
Redis가 해당 Channel을 구독하는 서버에 메시지를 전달한다.
Redis
│
├──► Server 1
├──► Server 2
└──► Server 3
각 서버는 자신의 WebSocket Client에게 메시지를 전달한다.
Server 1
│
└──► User A
Server 2
│
└──► User B
Server 3
│
└──► User C
Redis가 WebSocket 메시지를 사용자에게 직접 보내는 것은 아니다.
이 부분을 헷갈리면 안 된다.
Redis의 역할은 서버와 서버 사이의 메시지 전달이다.
Redis
Server 간 전달
│
┌──────┼──────┐
▼ ▼ ▼
Server1 Server2 Server3
│ │ │
▼ ▼ ▼
WebSocket Client
즉 역할을 나누면:
WebSocket
→ Client ↔ Server 실시간 통신
Redis Pub/Sub
→ Server ↔ Server 메시지 전달
이렇게 이해하면 된다.
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 서버 사이에서 메시지를 공유하기 위해서
라고 볼 수 있다.
실제 채팅 서비스라면 모든 사용자에게 모든 메시지를 보내면 안 된다.
예를 들어:
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"
이렇게 분리할 수 있다.
앞의 예제에서는 모든 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);
이런 방식으로 관리할 수 있다.
이제 특정 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
에게만 메시지를 전달할 수 있다.
전체 코드를 개념적으로 연결하면 다음과 같다.
WebSocketHandler
│
│ receive
▼
ChatPublisher
│
│ publish
▼
Redis Pub/Sub
│
│ subscribe
▼
ChatSubscriber
│
▼
SessionManager
│
│ sendMessage()
▼
WebSocket Client
이 구조가 이번 TIL에서 가장 중요한 부분이다.
Spring WebSocket에서는 직접 WebSocketHandler를 구현하는 방법 외에도 STOMP를 사용할 수 있다.
STOMP를 사용하면 메시지의 목적지를 다음과 같이 표현할 수 있다.
/topic/chat/1
/topic/chat/2
클라이언트는:
SUBSCRIBE /topic/chat/1
과 같은 방식으로 특정 채팅방을 구독할 수 있다.
메시지 전송은:
SEND /pub/chat/1
과 같은 형태로 구성할 수 있다.
이렇게 하면 WebSocket 위에서 메시지 라우팅을 더 구조적으로 관리할 수 있다.
STOMP를 사용한다고 해서 Redis가 필요 없어지는 것은 아니다.
멀티 서버 환경에서는 다음과 같이 구성할 수 있다.
Client
│
│ WebSocket + STOMP
▼
Spring Boot Server
│
│ Redis Pub/Sub
▼
Redis
│
├──────────────┐
▼ ▼
Server 1 Server 2
│ │
▼ ▼
Clients Clients
여기서:
이라는 역할 분담을 할 수 있다.
아니다.
Redis Pub/Sub은 메시지 전달을 위한 기능이지 채팅 메시지를 영구적으로 저장하기 위한 DB가 아니다.
예를 들어 실제 채팅 서비스에서는:
Client
│
▼
Spring Boot
│
├────────► Redis Pub/Sub
│
└────────► MySQL / PostgreSQL
처럼 사용할 수 있다.
Redis Pub/Sub:
실시간 메시지 전달
RDB:
채팅 메시지 저장
역할을 분리하는 것이다.
예를 들어 사용자가 채팅방에 늦게 들어왔다고 하자.
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
과거 메시지 조회
Redis Pub/Sub은 메시지를 저장하지 않는다.
따라서 Subscriber가 장애 상태라면 메시지를 놓칠 수 있다.
Publisher
│
▼
Redis
│
X
Server 2 장애
→ Server 2는 메시지를 받지 못함
채팅 메시지를 반드시 보존해야 한다면 DB 저장이나 Redis Streams/Kafka 등의 별도 메시징 구조를 고려해야 한다.
서버 구조나 재연결 로직에 따라 중복 메시지가 발생할 가능성을 고려해야 한다.
따라서 메시지에 고유 ID를 넣는 것도 좋은 방법이다.
{
"messageId": "01J...",
"roomId": "1",
"senderId": 100,
"message": "Hello"
}
클라이언트나 서버에서 필요하다면 messageId를 기준으로 중복 처리를 할 수 있다.
채팅에서는 메시지 순서가 매우 중요하다.
예를 들어:
Message A
Message B
Message C
가 발생했는데 클라이언트에서:
Message B
Message A
Message C
순서로 표시되면 문제가 된다.
따라서 실제 서비스에서는 메시지에 서버 생성 시간이나 sequence 등을 포함하고, 필요한 경우 별도의 순서 보장 전략을 사용해야 한다.
Redis Pub/Sub은 다음과 같은 요구사항에는 적합하지 않을 수 있다.
"메시지를 반드시 전달해야 한다."
"장애가 발생해도 메시지를 나중에 다시 처리해야 한다."
"Consumer가 어디까지 처리했는지 추적해야 한다."
"메시지를 재처리해야 한다."
이런 요구사항이 있다면 Redis Pub/Sub보다는 Redis Streams나 Kafka 같은 시스템을 검토하는 것이 좋다.
간단하게 비교하면:
| 항목 | Pub/Sub | Redis Streams |
|---|---|---|
| 메시지 저장 | X | O |
| 실시간 전달 | O | O |
| 메시지 재처리 | 어려움 | 가능 |
| Consumer 상태 관리 | X | 가능 |
| 메시지 유실 | 가능 | 설계에 따라 방지 |
| 구조 | 단순 | 상대적으로 복잡 |
| 적합한 용도 | 실시간 Broadcast | 이벤트/메시지 처리 |
따라서:
단순 실시간 전달
↓
Redis Pub/Sub
메시지 저장 + 재처리
↓
Redis Streams
라고 생각하면 이해하기 쉽다.
이번 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가 메시지 수신
이번 실습을 통해 Redis Pub/Sub과 WebSocket은 서로 경쟁하는 기술이 아니라 서로 다른 역할을 담당하는 기술이라는 것을 알 수 있었다.
Client와 Server 사이의 실시간 통신을 담당한다.
Client ←── WebSocket ──→ Server
여러 Server 사이의 메시지 전달을 담당한다.
Server ←── Redis Pub/Sub ──→ Server
채팅 메시지를 영구적으로 저장한다.
Server ──→ DB
따라서 전체적인 역할은 다음과 같이 나눌 수 있다.
┌────────────────────┐
│ Client │
└─────────┬──────────┘
│
WebSocket
│
▼
┌────────────────────┐
│ Spring Boot │
│ │
│ WebSocket Handler │
└───────┬────────────┘
│
┌────────┴────────┐
▼ ▼
Redis Pub/Sub DB
실시간 전달 영구 저장
│
▼
다른 Spring 서버
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
라는 구조로 이해하면 된다.