Redis Streams를 이용한 컨슈머 그룹: 신뢰할 수 있는 이벤트 처리
1. 개요
Redis의 Pub/Sub에는 큰 단점이 있습니다. 게시(publish) 시점에 컨슈머가 꺼져있으면 메시지가 손실됩니다. Redis Streams는 이 문제를 해결합니다. 메시지는 stream에 지속적으로 저장되며, 각 컨슈머 그룹은 처리 완료된 메시지를 기억합니다.
이 글은 Spring Boot 3과 Spring Data Redis를 사용하여 주문 이벤트 처리 파이프라인을 구축하는 방법을 안내합니다. 파이프라인은 다음 단계들로 구성됩니다: 이벤트 기록, 컨슈머 그룹을 통한 읽기, 수동 ACK, 미처리 메시지 재수신 및 실패한 메시지를 dead-letter stream으로 이동.
2. Redis Streams와 컨슈머 그룹은 어떻게 작동하는가
Java를 작성하기 전에 redis-cli에서 기본 명령어를 시도해봐야 합니다:
XADD orders * orderId 1001 amount 250000
XGROUP CREATE orders shipping-group 0
XREADGROUP GROUP shipping-group worker-1 COUNT 1 STREAMS orders >
XPENDING orders shipping-group
XACK orders shipping-group 1728640000000-0
각 명령어의 의미:
XADD는 streamorders에 항목을 추가합니다.*문자는 Redis에게<millis>-<seq>형식의 ID를 자동으로 생성하도록 지시합니다.XGROUP CREATE ... 0은 groupshipping-group을 만들고 stream의 처음부터 읽기를 시작합니다.$를 사용하면 그룹은 새로운 메시지만 읽습니다.XREADGROUP ... >는 그룹 내에서 아직 누구에게도 할당되지 않은 메시지를 가져옵니다. 메시지는worker-1에게 할당되고 동시에 그룹의 **Pending Entries List (PEL)**에 기록됩니다.XPENDING은 할당되었지만 아직 ACK되지 않은 메시지의 개수를 보여줍니다.XACK은 메시지를 PEL에서 제거합니다. 이 시점부터 그룹은 메시지가 처리되었다고 봅니다.
ACK 이전의 XPENDING 출력은 다음과 같습니다:
1) (integer) 1
2) "1728640000000-0"
3) "1728640000000-0"
4) 1) 1) "worker-1"
2) "1"
PEL은 신뢰성의 핵심입니다. 컨슈머가 메시지를 받은 후 ACK하기 전에 충돌한다고 가정합시다. 메시지는 여전히 PEL에 남아있으며, 다른 컨슈머가 그것을 받도록(claim) 할 수 있습니다. 이것이 at-least-once 의미론입니다.
3. 설치
Spring Data Redis 의존성을 추가합니다. 이 starter는 기본적으로 Lettuce를 클라이언트로 사용합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
연결 설정과 stream 이름을 구성합니다:
spring:
data:
redis:
host: localhost
port: 6379
app:
stream:
key: orders
group: shipping-group
consumer: ${HOSTNAME:worker-local}
컨슈머 이름은 HOSTNAME에서 가져오므로 각 인스턴스(pod)는 고유한 이름을 가집니다. 이것은 매우 중요합니다. 두 인스턴스가 같은 이름을 사용하면 Redis는 그들을 하나의 컨슈머로 취급하고 PEL이 섞입니다.
4. Stream에 이벤트 기록
@Service
public class OrderEventPublisher {
private final StringRedisTemplate redisTemplate;
private final String streamKey;
public OrderEventPublisher(StringRedisTemplate redisTemplate,
@Value("${app.stream.key}") String streamKey) {
this.redisTemplate = redisTemplate;
this.streamKey = streamKey;
}
public RecordId publishOrderCreated(String orderId, long amount) {
MapRecord<String, String, String> record = StreamRecords.newRecord()
.in(streamKey)
.ofMap(Map.of(
"type", "ORDER_CREATED",
"orderId", orderId,
"amount", String.valueOf(amount)));
return redisTemplate.opsForStream().add(record);
}
}
StreamRecords.newRecord().in(...).ofMap(...)는 문자열 필드를 가진 MapRecord를 생성합니다. opsForStream().add() 명령어는 XADD orders * ...와 동등하며 Redis가 생성한 RecordId를 반환합니다. 예: 1728640000000-0.
StringRedisTemplate을 사용하면 Redis의 데이터를 redis-cli로 읽을 수 있습니다. JDK serialization의 바이트 문자열을 다룰 필요가 없습니다.
5. 안전한 컨슈머 그룹 생성
읽기 전에 그룹이 존재해야 합니다. 애플리케이션이 stream에 아직 메시지가 없을 때 시작될 수 있으므로 MKSTREAM 파라미터가 필요합니다. StreamOperations의 createGroup() 메서드는 이 파라미터를 활성화하지 않습니다. 따라서 connection 레벨로 내려가서 호출합니다:
@Component
public class StreamGroupInitializer {
private final StringRedisTemplate redisTemplate;
public StreamGroupInitializer(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void ensureGroup(String streamKey, String group) {
try {
redisTemplate.execute((RedisCallback<String>) connection ->
connection.streamCommands().xGroupCreate(
streamKey.getBytes(StandardCharsets.UTF_8),
group,
ReadOffset.from("0"),
true)); // MKSTREAM
} catch (DataAccessException e) {
String msg = NestedExceptionUtils.getMostSpecificCause(e).getMessage();
if (msg == null || !msg.contains("BUSYGROUP")) {
throw e;
}
// 그룹이 이미 존재함: 애플리케이션 재시작 시 정상
}
}
}
마지막 파라미터 true는 MKSTREAM을 활성화합니다. Redis는 stream이 없으면 빈 stream을 생성합니다. 애플리케이션 재시작 시 Redis는 BUSYGROUP Consumer Group name already exists 오류를 반환합니다. 이 특정 오류는 무시하고 다른 오류는 다시 throw합니다.
6. StreamMessageListenerContainer로 메시지 읽기
6.1. 수동 ACK를 가진 Listener
@Component
public class OrderEventListener
implements StreamListener<String, MapRecord<String, String, String>> {
private final StringRedisTemplate redisTemplate;
private final ShippingService shippingService;
private final String streamKey;
private final String group;
public OrderEventListener(StringRedisTemplate redisTemplate,
ShippingService shippingService,
@Value("${app.stream.key}") String streamKey,
@Value("${app.stream.group}") String group) {
this.redisTemplate = redisTemplate;
this.shippingService = shippingService;
this.streamKey = streamKey;
this.group = group;
}
@Override
public void onMessage(MapRecord<String, String, String> record) {
Map<String, String> body = record.getValue();
shippingService.createShipment(body.get("orderId"));
redisTemplate.opsForStream().acknowledge(streamKey, group, record.getId());
}
}
ACK 명령어는 createShipment이 성공한 후에만 실행됩니다. 메서드가 예외를 throw하면 메시지는 여전히 PEL에 남아있어 나중에 재처리됩니다. 처리하기 전에 ACK하지 마세요. 그렇게 하면 at-least-once가 at-most-once로 변합니다.
ShippingService 이 글의 간단한 서비스입니다. 배송 전표를 생성한 orderId들을 기록합니다:
@Service
public class ShippingService {
private final Set<String> shippedOrders = ConcurrentHashMap.newKeySet();
public void createShipment(String orderId) {
if (!shippedOrders.add(orderId)) {
return; // 이미 처리됨, 무시 (idempotent)
}
// 배송 회사 API 호출...
}
public Set<String> shippedOrders() {
return Set.copyOf(shippedOrders);
}
}
6.2. Container 설정
@Configuration
public class StreamConfig {
@Bean(destroyMethod = "stop")
public StreamMessageListenerContainer<String, MapRecord<String, String, String>>
orderStreamContainer(RedisConnectionFactory connectionFactory,
StreamGroupInitializer initializer,
OrderEventListener listener,
@Value("${app.stream.key}") String streamKey,
@Value("${app.stream.group}") String group,
@Value("${app.stream.consumer}") String consumerName) {
initializer.ensureGroup(streamKey, group);
var options = StreamMessageListenerContainer.StreamMessageListenerContainerOptions
.builder()
.pollTimeout(Duration.ofSeconds(2))
.batchSize(10)
.build();
var container = StreamMessageListenerContainer.create(connectionFactory, options);
var request = StreamMessageListenerContainer.StreamReadRequest
.builder(StreamOffset.create(streamKey, ReadOffset.lastConsumed()))
.consumer(Consumer.from(group, consumerName))
.autoAcknowledge(false)
.cancelOnError(t -> false)
.build();
container.register(request, listener);
container.start();
return container;
}
}
설정의 주요 포인트:
ReadOffset.lastConsumed()는XREADGROUP의>에 해당하며, 이는 그룹의 새로운 메시지만 가져오는 것을 의미합니다.pollTimeout은 각 읽기의 block 시간입니다.batchSize는 한 번의 읽기에서 가져올 최대 메시지 수입니다.autoAcknowledge(false)는 자동 ACK를 비활성화하므로 listener에서 직접 ACK합니다.cancelOnError(t -> false)는 가장 중요한 라인입니다. 기본적으로 listener가 예외를 throw하면 container는 subscription을 취소합니다. 컨슈머는 명확한 경고 없이 조용히 읽기를 중단합니다.
참고: 중요한 비즈니스 로직에는
receiveAutoAck(...)을 사용하지 마세요. 이 모드는 읽기 직후 ACK하므로 listener 실행 전에 ACK합니다. 애플리케이션이 중간에 충돌하면 메시지는 영구적으로 손실됩니다.
7. 미처리 메시지 및 dead-letter 처리
Listener는 새로운 메시지만 읽습니다(>). 오류가 있는 메시지나 죽은 컨슈머의 메시지는 아무도 처리하지 않으면 PEL에 영구적으로 남아있습니다. 정기적으로 실행되는 job이 필요합니다:
@Component
public class PendingOrderReclaimer {
private static final Duration MIN_IDLE = Duration.ofMinutes(1);
private static final long MAX_DELIVERIES = 5;
private final StringRedisTemplate redisTemplate;
private final ShippingService shippingService;
private final String streamKey;
private final String group;
private final String consumerName;
public PendingOrderReclaimer(StringRedisTemplate redisTemplate,
ShippingService shippingService,
@Value("${app.stream.key}") String streamKey,
@Value("${app.stream.group}") String group,
@Value("${app.stream.consumer}") String consumerName) {
this.redisTemplate = redisTemplate;
this.shippingService = shippingService;
this.streamKey = streamKey;
this.group = group;
this.consumerName = consumerName;
}
@Scheduled(fixedDelay = 30_000)
public void reclaim() {
StreamOperations<String, String, String> ops = redisTemplate.opsForStream();
PendingMessages pending = ops.pending(streamKey, group, Range.unbounded(), 50);
for (PendingMessage pm : pending) {
if (pm.getElapsedTimeSinceLastDelivery().compareTo(MIN_IDLE) < 0) {
continue;
}
List<MapRecord<String, String, String>> claimed =
ops.claim(streamKey, group, consumerName, MIN_IDLE, pm.getId());
for (MapRecord<String, String, String> record : claimed) {
if (pm.getTotalDeliveryCount() >= MAX_DELIVERIES) {
moveToDeadLetter(record);
} else {
shippingService.createShipment(record.getValue().get("orderId"));
}
ops.acknowledge(streamKey, group, record.getId());
}
}
}
private void moveToDeadLetter(MapRecord<String, String, String> record) {
Map<String, String> body = new HashMap<>(record.getValue());
body.put("originalId", record.getId().getValue());
redisTemplate.opsForStream().add(
StreamRecords.newRecord().in(streamKey + ":dlq").ofMap(body));
}
}
Job의 처리 흐름:
pending(...)은XPENDING orders shipping-group - + 50과 동등합니다. 각PendingMessage는 어느 컨슈머가 메시지를 보유하고 있는지, idle 시간 및 할당 횟수를 나타냅니다.claim(...)은XCLAIM과 동등합니다. 이 명령어는 소유권을 현재 컨슈머로 이전합니다. Redis는 메시지가 최소MIN_IDLE동안 idle 상태인 경우에만 이전하므로 두 인스턴스가 동시에 job을 실행해도 같은 메시지를 중복으로 claim하지 않습니다. 각 claim도 delivery count를 증가시킵니다.MAX_DELIVERIES횟수를 초과한 메시지는orders:dlqstream으로 복사된 후 ACK됩니다. 영구적으로 실패하는 메시지(poison message)는 무한 재시도를 피합니다.
createShipment이 예외를 throw하면 ACK 명령어가 실행되지 않습니다. 메시지는 PEL에 남아있고 다음 주기에 재시도됩니다. @Configuration 클래스에 @EnableScheduling을 활성화해야 합니다.
참고: Redis 6.2부터는
XAUTOCLAIM명령어가 있습니다. 이 명령어는XPENDING과XCLAIM단계를 결합하며, PEL이 클 때 더 효율적입니다. 현재 사용 중인 Spring Data Redis 버전이 편리한 API를 아직 제공하지 않으면redisTemplate.execute(...)를 통해 호출할 수 있습니다.
8. Testcontainers로 테스트 작성
테스트용 의존성을 추가합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
아래 테스트는 이벤트를 게시합니다. 그 다음 배송 전표가 생성되었고 PEL이 비어있는지 확인합니다:
@SpringBootTest
@Testcontainers
class OrderStreamIntegrationTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired OrderEventPublisher publisher;
@Autowired ShippingService shippingService;
@Autowired StringRedisTemplate redisTemplate;
@Test
void whenOrderPublished_thenShipmentCreatedAndMessageAcked() {
publisher.publishOrderCreated("1001", 250_000);
await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
assertThat(shippingService.shippedOrders()).contains("1001");
PendingMessagesSummary summary = redisTemplate.opsForStream()
.pending("orders", "shipping-group");
assertThat(summary.getTotalPendingMessages()).isZero();
});
}
}
@ServiceConnection(name = "redis")는 Spring Boot가 컨테이너에서 host와 port를 자동으로 가져오게 합니다. @DynamicPropertySource가 필요 없습니다. await()는 listener가 별도 thread에서 실행되기 때문에 필요합니다. getTotalPendingMessages() assertion이 0인 것은 메시지가 읽혔을 뿐만 아니라 ACK되었음을 증명합니다.
9. 흔한 오류와 언제 사용하면 안 되는지
멱등성이 없는 처리. At-least-once는 메시지가 두 번 올 수 있다는 뜻입니다. 예를 들어: 컨슈머가 처리를 끝낸 후 ACK하기 전에 충돌합니다. 따라서 비즈니스 로직은 반복 실행을 견디어야 합니다. 일반적인 방법은 orderId가 이미 처리되었는지 확인하거나 데이터베이스의 unique constraint를 사용하는 것입니다.
Stream이 무한정 커집니다. ACK는 stream에서 항목을 삭제하지 않습니다. 기록할 때 크기를 제한해야 합니다:
redisTemplate.opsForStream().add(record, XAddOptions.maxlen(100_000).approximateTrimming(true));
이 옵션은 XADD orders MAXLEN ~ 100000 * ...와 동등합니다. ~ 문자는 Redis가 내부 노드에 따라 자르도록 허용하여 정확한 자르기보다 훨씬 빠릅니다.
cancelOnError 잊기. 첫 예외가 발생하면 컨슈머가 조용히 중단됩니다. 증상은 메시지가 stream에 쌓이지만 로그에 명확한 오류가 없는 것입니다.
“유령” 컨슈머. 배포할 때마다 새로운 컨슈머 이름(예: pod 이름)으로 실행하면 이전 이름은 그룹에 남아있습니다. XGROUP DELCONSUMER를 사용하여 정기적으로 정리하세요. 다만 해당 컨슈머의 모든 pending 메시지를 claim한 후에만 정리합니다.
Redis Streams를 사용하면 안 되는 경우:
- 여러 날 동안 이벤트를 저장하고 대량의 메시지를 재생해야 합니다. Redis는 모든 데이터를 RAM에 유지하므로 비용이 매우 높습니다.
- 데이터 손실을 절대 허용할 수 없습니다. AOF
everysec또는 비동기 failover replica를 사용해도 최근에 기록한 일부 메시지가 손실될 수 있습니다. - 처리량이 매우 크고 수백 개의 컨슈머로 분할 및 확장이 필요합니다. 이 경우 Kafka가 더 적합합니다.
10. 결론
Redis Streams를 컨슈머 그룹과 결합하면 이미 가지고 있는 Redis에서 바로 실행되는 가볍고 신뢰할 수 있는 이벤트 큐를 얻습니다. 네 가지 핵심 사항을 기억해야 합니다:
- 처리가 완료된 후에만 수동으로 ACK합니다.
cancelOnError(t -> false)를 설정하여 컨슈머가 조용히 멈추지 않도록 합니다.- 미처리 메시지를 claim하는 job과 poison message용 dead-letter stream을 가집니다.
- 처리 로직을 멱등성 있게 작성합니다.
MAXLEN을 추가하여 메모리를 제어하고 Testcontainers로 통합 테스트하면, 대부분의 Spring Boot 애플리케이션에 충분히 견고한 at-least-once 이벤트 처리 파이프라인을 갖게 됩니다.