Pub/Sub과 Redis Streams: 차이점과 Spring Boot에서의 사용법
1. 개요
Redis는 Pub/Sub과 Streams라는 두 가지 메시징 메커니즘을 제공합니다. Redis를 처음 사용하는 분들은 이 두 가지를 헷갈리기 쉬워서 잘못된 도구를 선택하고 시스템 재시작 시 데이터를 잃는 문제가 발생합니다.
이 글에서는 Pub/Sub과 Streams의 핵심 차이를 설명하고, 두 가지 모두 작동하는 Java 코드 예제를 제공하며, 어느 것을 언제 사용해야 하는지 명확히 설명합니다. 이 글을 읽고 나면, 실제 메시징 문제에 맞는 올바른 모델을 자신 있게 선택할 수 있을 것입니다.
2. Redis Pub/Sub이란?
Pub/Sub(Publish/Subscribe)은 “fire-and-forget” 모델로 작동합니다. Publisher가 메시지를 채널로 보내면, Redis는 그 채널을 구독 중인 모든 subscriber에게 즉시 전달합니다. 구독 중인 subscriber가 없으면, 메시지는 영구적으로 사라집니다.
2.1. Maven 의존성
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2.2. Publisher
@Service
public class OrderEventPublisher {
private final StringRedisTemplate redisTemplate;
public OrderEventPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void publish(String orderId, String status) {
String message = "orderId=%s;status=%s".formatted(orderId, status);
redisTemplate.convertAndSend("order-events", message);
}
}
convertAndSend는 order-events 채널로 메시지를 보냅니다. 아직 구독자가 없다면, 명령은 성공하지만 메시지는 저장되지 않습니다.
2.3. Subscriber
@Component
public class OrderEventListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String body = new String(message.getBody());
System.out.println("이벤트 수신: " + body);
}
}
@Configuration
public class RedisPubSubConfig {
@Bean
public RedisMessageListenerContainer container(
RedisConnectionFactory connectionFactory,
OrderEventListener listener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(listener, new ChannelTopic("order-events"));
return container;
}
}
RedisMessageListenerContainer는 order-events 채널을 수신하기 위한 별도의 연결(blocking 연결 사용)을 관리합니다. Publisher가 메시지를 보내면 onMessage가 즉시 호출됩니다.
이벤트를 publish할 때의 출력:
이벤트 수신: orderId=ORD-001;status=CONFIRMED
주의:
OrderEventListener가 아직 시작되지 않았다면(container가 bind되지 않음), 이전에 publish된 메시지는 완전히 손실되며 다시 재생할 수 없습니다.
3. Redis Streams란?
Stream은 순서가 있는 append-only 로그 데이터 구조로, 각 메시지는 <timestamp>-<sequence> 형태의 고유한 ID를 가집니다. Pub/Sub과 달리, Streams는 메시지를 Redis에 저장하며, 직접 삭제(XDEL) 또는 자르기(XTRIM)할 때까지 유지됩니다. Consumer는 처음부터 다시 읽거나, Kafka와 유사한 acknowledge 메커니즘이 있는 consumer group으로 읽을 수 있습니다.
3.1. Stream에 메시지 추가
@Service
public class OrderStreamPublisher {
private final StringRedisTemplate redisTemplate;
public OrderStreamPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public RecordId publish(String orderId, String status) {
Map<String, String> payload = Map.of(
"orderId", orderId,
"status", status
);
return redisTemplate.opsForStream()
.add("order-stream", payload);
}
}
opsForStream().add는 order-stream으로 record를 쓰고 RecordId를 반환합니다 — record의 자동 생성된 primary key처럼 동작합니다. 이 record는 trim되거나 삭제될 때까지 Redis에 존재하며, 아무도 읽지 않았어도 유지됩니다.
3.2. Consumer Group으로 Stream 읽기
@Component
public class OrderStreamConsumer {
private final StringRedisTemplate redisTemplate;
public OrderStreamConsumer(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
createGroupIfAbsent();
}
private void createGroupIfAbsent() {
try {
redisTemplate.opsForStream()
.createGroup("order-stream", "order-processors");
} catch (Exception e) {
// 그룹이 이미 존재합니다. 무시합니다.
}
}
public void pollOnce() {
List<MapRecord<String, Object, Object>> records = redisTemplate.opsForStream()
.read(Consumer.from("order-processors", "worker-1"),
StreamReadOptions.empty().count(10),
StreamOffset.create("order-stream", ReadOffset.lastConsumed()));
for (MapRecord<String, Object, Object> record : records) {
System.out.println("record " + record.getId() + " 처리: " + record.getValue());
redisTemplate.opsForStream().acknowledge("order-stream", "order-processors", record.getId());
}
}
}
createGroup은 consumer grouporder-processors를 생성합니다. Kafka의 consumer group과 유사합니다.Consumer.from("order-processors", "worker-1")은 해당 그룹에 속한 worker-1이라는 이름의 consumer를 선언합니다. 같은 그룹의 여러 worker는 record를 나눠서 처리하므로 중복으로 읽지 않습니다.ReadOffset.lastConsumed()는 그룹의 consumer에게 아직 전달되지 않은 새로운 record를 읽는다는 의미입니다.acknowledge는 처리 완료를 확인하면 Redis가 그룹의 “pending list”에서 record를 제거합니다.
출력:
record 1696500000000-0 처리: {orderId=ORD-001, status=CONFIRMED}
주의:
acknowledge를 호출하지 않으면 record는 pending list에 남아 있습니다. 다른 consumer 또는 이 consumer 자신(재시작 후)이XCLAIM/readPending을 사용하여 다시 처리할 수 있습니다 — 이것이 Pub/Sub에는 없는 “at-least-once delivery” 메커니즘입니다.
4. Pub/Sub과 Streams 직접 비교
| 기준 | Pub/Sub | Streams |
|---|---|---|
| 메시지 저장소 | 아니요 | 예, trim/삭제될 때까지 |
| 이전 메시지 재생 | 아니요 | 예 (XRANGE, 사용자 정의 offset) |
| Consumer 오프라인 시 손실 | 오프라인 중 전송된 모든 메시지 손실 | 손실 없음, 온라인 복귀 후 다시 읽기 |
| 여러 consumer의 부하 분산 | 각 subscriber가 자신의 사본 수신(broadcast) | Consumer group이 작업을 분배, 각 record를 1개 consumer만 처리 |
| Acknowledge / 재시도 | 없음 | 있음 (XACK, XCLAIM) |
| 복잡도 | 매우 간단함 | 더 복잡함, Kafka와 유사 |
| 적합한 사용 사례 | 실시간 알림, 보증 불필요 | Event sourcing, job queue, 처리 보증 필요 |
4.1. Pub/Sub 사용 시기
실시간으로 broadcast해야 하고 구독자가 없을 때 메시지 손실을 받아들일 수 있을 때 Pub/Sub을 사용합니다. 전형적인 예: 채팅의 “입력 중…” 상태 업데이트, 여러 애플리케이션 인스턴스 간의 cache 동기화(cache invalidation), 또는 몇 개를 놓쳐도 중요하지 않은 실시간 알림입니다.
@Service
public class CacheInvalidationPublisher {
private final StringRedisTemplate redisTemplate;
public CacheInvalidationPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void invalidate(String cacheKey) {
redisTemplate.convertAndSend("cache-invalidation", cacheKey);
}
}
각 애플리케이션 인스턴스는 cache-invalidation 채널을 구독하여 로컬 cache에서 제거할 key를 받습니다. 한 인스턴스가 재시작 중일 때 이벤트를 놓쳐도, 잠시 동안만 오래된 cache를 사용할 뿐 심각한 오류가 아닙니다.
4.2. Streams 사용 시기
메시지가 완전히 처리되어야 하는 중요한 비즈니스 데이터인 경우 Streams을 사용합니다: 주문, 결제 거래, 감사 로그 등입니다. Consumer가 도중에 충돌해도 재시작 후 해당 record를 다시 처리할 수 있도록 보장해야 합니다.
@Service
public class PaymentEventPublisher {
private final StringRedisTemplate redisTemplate;
public PaymentEventPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public RecordId publish(String paymentId, BigDecimal amount) {
Map<String, String> payload = Map.of(
"paymentId", paymentId,
"amount", amount.toString()
);
return redisTemplate.opsForStream().add("payment-stream", payload);
}
}
각 payment event는 payment-stream에 기록됩니다. PaymentWorker가 배포를 위해 꺼져 있어도, 이벤트는 Redis에 대기하고 있으며, worker가 재시작되면 계속 읽을 수 있습니다.
5. Stream Consumer 테스트 작성
Streams는 상태가 영구적(persistent)이므로 publish – consume – acknowledge 흐름을 철저히 테스트해야 합니다. Testcontainers를 사용하여 실제 Redis를 실행하고 잘못된 모의 동작을 피하세요.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@Testcontainers
class OrderStreamConsumerTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
StringRedisTemplate redisTemplate;
@BeforeEach
void setUp() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(
redis.getHost(), redis.getFirstMappedPort());
LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
factory.afterPropertiesSet();
redisTemplate = new StringRedisTemplate(factory);
}
@Test
void shouldConsumeAndAcknowledgeRecord() {
OrderStreamPublisher publisher = new OrderStreamPublisher(redisTemplate);
OrderStreamConsumer consumer = new OrderStreamConsumer(redisTemplate);
publisher.publish("ORD-002", "SHIPPED");
consumer.pollOnce();
PendingMessages pending = redisTemplate.opsForStream()
.pending("order-stream", Consumer.from("order-processors", "worker-1"),
Range.unbounded());
assertThat(pending.getTotalPendingMessages()).isZero();
}
}
이 테스트는 record를 publish하고, pollOnce()를 호출하여 consume 및 acknowledge한 후, pending list가 비어 있는지 확인합니다 — 즉, record가 처리 완료로 확인되었고 그룹에서 더 이상 대기 중이 아니라는 의미입니다.
6. 흔한 오류
- 중요한 데이터에 Pub/Sub 사용: 많은 초보자들이 주문 이벤트를 보내기 위해 Pub/Sub을 사용한 후, consumer 재시작으로 이벤트가 손실될 때 놀랍니다. Pub/Sub은 message queue가 아닙니다.
- Stream을 trim하지 않음: Stream은 오래된 데이터를 자동으로 삭제하지 않습니다. 지속적으로 publish하면서 add할 때
XTRIM을 호출하거나MAXLEN을 설정하지 않으면, Redis 메모리가 시간에 따라 계속 커집니다.
redisTemplate.opsForStream().add(
StreamRecords.newRecord()
.in("order-stream")
.ofMap(payload),
RedisStreamCommands.XAddOptions.maxlen(10_000)
);
- 읽기 전에 consumer group 생성 잊음: group이 없으면
XREADGROUP명령이 오류를 발생합니다. 항상 “BUSYGROUP” 오류(group이 이미 있음)를 포착하는 try-catch로createGroup을 호출하세요. - Pub/Sub을 위한 connection timeout 설정 안 함: subscriber는 blocking 연결을 사용합니다. 네트워크가 끊어졌는데 reconnect 메커니즘이 없으면, 그 시점부터 발견 및 재연결할 때까지 모든 메시지를 잃습니다.
7. 결론
Pub/Sub과 Streams는 Redis 내에 있지만 두 가지 다른 문제를 해결합니다. Pub/Sub은 간단하고 빠르지만 어떤 보증도 하지 않으므로 놓쳐도 되는 실시간 알림에 적합합니다. Streams는 더 복잡하지만 견고하며 consumer group과 acknowledge를 가지고 있어서, 완전히 처리되어야 하는 비즈니스 데이터에 적합하며 경량 message queue 같습니다.
시스템이 더 높은 신뢰성(exactly-once, 대규모 파티셔닝, 대규모 병렬 여러 consumer group)이 필요하다면 Redis Streams에 과도하게 의존하기보다는 Kafka 또는 RabbitMQ를 고려하세요.