pandaterry's 개발로그

[SaaS 개발로그] Redis Pub/Sub이면 충분한 줄 알았습니다 본문

개발/Saas 개발로그

[SaaS 개발로그] Redis Pub/Sub이면 충분한 줄 알았습니다

pandaterry 2025. 6. 11. 13:31

Kafka로 전환하기 전, 직접 실험해봤습니다

 

SaaS에서 비동기 메시지는 선택이 아니라 필수입니다

제가 구축하고 있는 SaaS 시스템은 마케팅/비즈니스 부서가 자연어로 데이터를 요청하면, 그 요청을 백엔드에서 SQL로 변환하고 엑셀로 응답해주는 플랫폼입니다.

 

 

문제는 여기서 끝나지 않았습니다.

 

 

 

  • 사용자의 요청은 내부 시스템에서 백엔드 작업자에게 전송돼야 하고
  • 동시에 로그로 남겨져야 하며
  • 실패 시 재시도도 가능해야 합니다

 

 

처음엔 Redis Pub/Sub으로 충분하다고 생각했습니다.


하지만 “요청이 처리됐는지 안 됐는지조차 확인할 수 없다면, 그건 SaaS가 아니라 알 수 없는 상자”가 되어버리더군요.

 

그래서 Kafka를 검토하게 되었고,

 

“진짜로 필요한가?”라는 의문에 답하기 위해 직접 실험을 진행했습니다.

 

 

실험 목표: 메시지 유실 가능성과 재처리 구조 검증

 

 

단순 메시지 전달이 아닌, SaaS 플랫폼으로서 요청이 실패하더라도 반드시 재처리되거나 기록에 남는 구조가 가능한지를 비교해봤습니다.

 

공통 실험 조건

  • 총 5개 메시지를 전송 (event-0 ~ event-4)
  • 소비자는 event-2 수신 시 강제 종료
  • 그 사이에 발행된 메시지가 유실되지 않고
    재시작 후 다시 수신 가능한지를 확인

 

Redis Pub/Sub 실험

발행자 코드

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisPublisher {
    private final RedisTemplate<String, String> redisTemplate;

    @Qualifier("redisChannel")
    private final String channel;

    public void publish(String message) {
        log.info("Redis Publisher: 메시지 발행 시작 - {}", message);
        redisTemplate.convertAndSend(channel, message);
        log.info("Redis Publisher: 메시지 발행 완료 - {}", message);
    }
}

구독자 코드

@Slf4j
@Component
public class RedisSubscriber implements MessageListener {
    @Override
    public void onMessage(Message message, byte[] pattern) {
        String receivedMessage = new String(message.getBody());
        log.info("Redis Subscriber: 메시지 수신 - {}", receivedMessage);

        if (receivedMessage.equals("event-2")) {
            log.error("Redis Subscriber: event-2 수신으로 인한 종료 예정");
            log.error("Redis Subscriber: event-3, event-4는 유실될 예정입니다.");
            log.error("Redis Subscriber: 메시지 유실 확인을 위해 애플리케이션을 재시작해도 event-3, event-4는 수신되지 않습니다.");
            System.exit(1);
        }
    }
}

 

테스트 API

    @PostMapping("/redis")
    public void runRedisExperiment() throws InterruptedException {
        log.info("Redis 실험 시작");
        log.info("event-2 수신 시 애플리케이션이 종료되며, 이후 메시지는 유실됩니다.");

        for (int i = 0; i < 5; i++) {
            String message = "event-" + i;
            log.info("Redis 실험: 메시지 발행 예정 - {}", message);
            redisPublisher.publish(message);
            Thread.sleep(200);
        }

        log.info("Redis 실험: 모든 메시지 발행 완료");
    }

 

 

1회 호출 - 콘솔 로그(발행은 다 되었고 event-2까지 수신되고 서버가 종료됨)

 

2회 호출(재시작) - 콘솔 결과(재시작하면 event-3부터는 수신된 흔적이 없음)

 

결과

  • 구독자가 살아있는 동안만 메시지를 수신
  • event-3, event-4는 구독자가 죽은 사이 전송되었기 때문에 완전히 유실됨
  • 메시지 재처리 불가
  • 어떤 메시지가 전달됐는지도 확인 불가

 

Kafka 실험

프로듀서 코드

    public void send(String message) {
        log.info("Kafka Producer: 토픽[{}]에 메시지 발행 시작 - {}", topic, message);
        kafkaTemplate.send(topic, message)
                .whenComplete((result, ex) -> {
                    if (ex == null) {
                        log.info("Kafka Producer: 토픽[{}]에 메시지 발행 완료 - {}", topic, message);
                    } else {
                        log.error("Kafka Producer: 토픽[{}]에 메시지 발행 실패 - {}", topic, message, ex);
                    }
                });
    }

컨슈머 코드

    @KafkaListener(topics = "${app.kafka.topic}", groupId = "${spring.kafka.consumer.group-id}")
    public void consume(String message, Acknowledgment ack) {
        log.info("Kafka Consumer: 토픽[{}]에서 메시지 수신 - {}", topic, message);

        if (message.equals("event-2")) {
            log.error("Kafka Consumer: event-2 수신으로 인한 종료 예정");
            log.error("Kafka Consumer: event-3, event-4는 Kafka에 저장되어 있음");
            log.error("Kafka Consumer: 컨슈머 그룹[{}]의 오프셋이 저장되어 있어 재시작 시 event-3, event-4를 이어서 처리합니다.", groupId);
            ack.acknowledge();

            try {
                // 커밋이 완료될 시간을 확보
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }

            log.error("Kafka Consumer: 오프셋 커밋 후 종료");
            System.exit(1);
        }

        ack.acknowledge();
    }

 

1회 호출 - 콘솔 로그(발행은 다 되었고 event-2까지 수신되고 서버가 종료됨)

 

2회 호출(재시작) - 콘솔로그(재시작하자마자 API 호출없이 event-3부터 수신)

 

결과

  • event-3, event-4는 컨슈머가 죽은 동안에도 Kafka에 저장되어 있음
  • 재시작 시, 동일 consumer group으로 그대로 다시 소비 가능
  • offset 조절을 통해 한 번 소비한 메시지도 재처리 가능

 

 

 

비교 요약

항목 Redis Pub/Sub Kafka
메시지 유실 여부 구독자 중단 시 즉시 유실 저장됨
재처리 가능 여부 불가 가능 (offset 기반)
소비 상태 추적 불가능 consumer group offset으로 가능
장애 복구 구조 없음 보장 (디스크 로그 기반)
적합한 역할 실시간 알림, 단순 이벤트 요청 추적, 로그 보존, 재처리 가능한 시스템

 

SaaS 시스템에서 Kafka가 필요한 순간

처음에는 Redis Pub/Sub이 훨씬 간편했습니다.
하지만 실시간 응답을 넘어서 요청의 책임을 가져야 하는 구조로 발전하면서 Kafka가 필요해졌습니다.

 

  • Redis Pub/Sub은 "누가 받았는지"조차 모릅니다
  • Kafka는 “누가 언제 무엇을 받았는지”를 기록합니다

 

SaaS 플랫폼은 요청이 언제든 실패할 수 있다는 걸 전제로 해야 합니다.
그래서 중요한 건 "전달"이 아니라, "기록과 복구"입니다.

 

 

그래서 결론은?

Kafka는 느리고 무겁습니다.
하지만 “요청을 다시 보낼 수 없는 구조”라면, 유일하게 믿을 수 있는 선택입니다.


Redis Pub/Sub은 여전히 훌륭합니다.

 

하지만 SaaS에서 신뢰할 수 있는 비동기 처리 시스템을 만들고 싶다면,

 

Kafka는 비용이 아니라 보험처럼 생각해야 합니다.

 

 

 

TODO (후속 실험 제안)

  • Redis Streams를 활용한 재처리 보완 구조 실험
  • Kafka retention 정책 단축 시 유실 임계 확인
  • SaaS에서 Kafka와 DB 트랜잭션을 안전하게 묶는 방식 실험

이 글은 SaaS 구조에서 메시지 브로커 선택의 실질적인 기준을 세우기 위한 실험 기록입니다.
이후 글에서는 Redis Streams와 Kafka의 소비 그룹 차이,
그리고 Spring 기반 구조에서 트랜잭션과 어떻게 묶을 수 있는지도 다뤄볼 예정입니다.