NATS로 Redis 없이 예약 메시지 시스템 구현하기 (Go 템플릿 코드 제공)
2026. 05. 01. 14:34
수정됨
메신저나 알림 서비스를 개발하다 보면 "30분 후 알림 전송" 같은 예약 기능이 반드시 필요합니다.
보통은 Redis를 먼저 떠올리죠. 그런데 막상 도입하려니 운영 부담이 만만치 않습니다. RDB/AOF 같은 데이터 복구 전략까지 새로 세워야 하고, 관리 포인트가 늘어납니다.
그런데 Redis를 써도 문제가 남습니다.
Redis는 데이터를 저장할 뿐, 스스로 "시간 됐다, 보내!" 하지 않습니다. 결국 개발자가 Cron 타이머를 돌려서 직접 확인하고 전송하는 로직을 짜야 합니다. Redis를 써도 스케줄링 로직은 여전히 애플리케이션 몫입니다.
그렇다고 DB 폴링은요? 매초 DB에 쿼리를 날리는 건 트래픽이 몰리는 메시징 서비스에선 최악의 선택입니다. 인메모리 타이머는요? 서버 재시작할 때마다 데이터가 날아갑니다.
NATS JetStream v2.12.2부터 이 모든 게 해결됩니다.
헤더에 예약 시간만 넣어서 발행하면, NATS가 알아서 기다렸다가 정해진 시간에 자동으로 메시지를 전송합니다. 타이머도, 폴링도, 별도 스케줄러도 필요 없습니다.
전체 흐름은 이렇습니다:
발행: 헤더에 예약 시간을 담아 메시지 전송
보관: JetStream이 메시지를 안전하게 보관하며 대기
자동 트리거: 예약 시간이 되면 NATS가 자동으로 메시지 발행
수신: 컨슈머가 평소처럼 메시지를 받아서 처리
이 클립에서는 직접 구현한 Go 템플릿 코드와 함께 핵심 설계 개념을 설명합니다. 예약 메시지 생성, 수정, 취소, 안전한 소비 처리까지 모두 담겨 있습니다.
이런 분께 추천합니다
이 자료는 메시지 브로커(Kafka, RabbitMQ, SQS 등) 사용 경험이 있는 경력 2~5년차 백엔드 개발자를 위한 실전 패턴 가이드입니다. NATS 입문서가 아니므로 pub/sub 기초 개념은 알고 계신다고 가정합니다.
NATS를 쓰고 있는데 예약 메시지 구현이 막막한 분
Redis 없이 예약 기능을 만들고 싶은 분
JetStream을 실무에 어떻게 활용하는지 궁금한 분
이런 분께는 맞지 않을 수 있습니다
NATS나 메시지 브로커를 처음 접하시는 분
pub/sub 개념이 아직 낯선 분
포함 내용
핵심 설계 개념 설명
Go 템플릿 코드, 부하 테스트 코드 GitHub 링크
메신저나 알림 서비스를 개발할 때 '30분 후 알림 전송'과 같은 예약 기능은 필수입니다.
당시 NATS 기반 메신저 서비스를 개발하면서, 이 기능을 구현하기 위해 가장 직관적이고 일반적으로 선택하는 방법은 단연 Redis였습니다. Redis의 ZSET(Sorted Set) 자료구조를 활용하면 예약 시간을 기준으로 오름차순 정렬하여 쉽게 예약 발송 시스템을 구축할 수 있었습니다.
그러나 문제는 '운영 오버헤드'였습니다.
새로운 인프라를 추가한다는 것은 인프라팀의 관리 포인트가 늘어난다는 의미였습니다. 특히 장애 시나리오를 대비하기 위해 Redis의 새로운 데이터 영속성 및 복구 전략(RDB/AOF 등)까지 새로 수립해야 한다는 부담이 컸습니다.
"그럼 기존 데이터베이스(MySQL)를 쓰면 안 되나요?"
물론 DB에 저장하고 폴링(주기적 쿼리)으로 가져오는 방법도 있습니다. 하지만 트래픽이 급증하는 메시징 서비스 특성상, 매초 DB에 쿼리를 날리는 것은 DB 부하를 급격히 증가시키는 최악의 선택이었습니다.
반면, DB에서 읽은 예약 데이터를 애플리케이션 서버의 인메모리 스토리지에 올려두고 타이머를 직접 돌리는 방식은 서버가 재시작할 때마다 데이터 유실 위험이 높았습니다. 또한 '바퀴를 다시 발명하는' 비효율적인 작업이기도 했습니다.
Redis의 인프라 부담, DB 폴링의 성능 저하, 인메모리 구현의 위험성 — 이 세 가지 딜레마를 해결해야 했습니다.
이 책은 그 치열한 고민의 결과물입니다. 추가적인 인프라 없이 NATS(nats.io)의 'Delayed Message Scheduling' 기능만으로 예약 메시지 시스템을 구축했습니다. 필요한 것은 몇 가지 간단한 설정뿐이었습니다.
이 가이드는 실제 현장에서 겪은 시행착오와 해결 과정을 아낌없이 공유합니다.
단 몇 줄의 설정으로 인프라 비용을 줄이고 시스템을 단순화하는 마법 — 지금 시작합니다.
예약 메시지 시스템에 NATS를 도입해야 하는 이유를 살펴보기 전에, 먼저 '일반적인 예약 메시지 시스템'의 기본 동작 원리를 알아봅시다.
전통적인 시스템은 보통 다음과 같은 구조를 가집니다:
[ 데이터베이스 ] → [ 메시지 큐 (메모리) ] → [ 메시지 발송 ]
이 구조가 안정적으로 동작하려면 세 가지 필수 조건이 충족되어야 합니다.
1. 정기적 DB 쿼리: 일정 간격으로 데이터베이스에서 '지금 바로 발송할 메시지'만 안전하게 가져옵니다.
2. 인메모리 큐: 가져온 메시지는 즉시 발송해야 하므로 느린 디스크(DB)가 아닌 빠른 메모리에 큐잉해야 합니다.
3. 발송 트리거: 시간을 지속적으로 확인하며 발송을 지시하는 주체(애플리케이션 또는 스케줄러)가 필요합니다. 스토어 내의 메시지가 '정확한 시간'에 발송되어야 하기 때문입니다.
여기서 가장 골치 아픈 부분은 '중간 다리(발송 버퍼 스토리지)'의 존재입니다. 이 버퍼 없이는 애플리케이션이 매초 DB에 쿼리를 날려야 하고, 그러면 DB는 금세 죽어버립니다(과부하).
이 다리 역할의 가장 명확한 해결책으로 떠오르는 것이 바로 Redis입니다.
Redis는 훌륭한 인메모리 스토리지 솔루션입니다. 프로세스 간 메모리 공유를 쉽게 해주고, 레플리카 관리를 단순화하며, 데이터베이스 부하를 극적으로 줄여줍니다.
하지만 Redis를 사용한다고 모든 문제가 해결되는 것은 아닙니다.
예를 들어, '향후 10분 안에 발송할 메시지'를 Redis에 저장한다고 가정해봅시다. 지금부터 1분, 2분, 5분 후에 발송할 메시지들이 뒤섞여 있을 것입니다. Redis는 데이터를 저장할 뿐, 스스로 나서서 "시간이 됐다, 메시지를 보내!"라고 하지 않습니다.
결국 개발자가 애플리케이션(프로그램) 수준에서 '스케줄링 로직'을 직접 구현해야 합니다. 매분 혹은 매초 Cron 같은 타이머를 돌려 발송할 메시지를 확인하고, 발송한 뒤, 완료된 메시지를 Redis에서 삭제하는 과정이 필요합니다.
원래 메시지 브로커로 알려진 NATS를 예약 메시지에 활용할 수 있을까요?
답은 NATS Server v2.12.2에서 도입된 'Delayed Message Scheduling' 기능에 있습니다.
NATS JetStream의 데이터 영속성과 이 지연 기능을 결합하면, NATS가 애플리케이션이 직접 수행하던 번거로운 작업을 깔끔하게 처리합니다.
전체 라이프사이클(Flow)은 다음과 같습니다:
1. 발행(Publish): 애플리케이션이 헤더에 예약 시간 정보를 담아 NATS에 메시지를 전송합니다.
2. 보관(Storage): NATS JetStream이 이 메시지를 안전하게 보관하며 대기합니다. (메모리 또는 파일)
3. 자동 트리거(Auto-Trigger): 예약 시간이 되면 NATS가 자동으로 메시지를 실제로 발행합니다.
4. 대기 및 수신(Standby and Receive): 발행된 메시지는 JetStream에 다시 안전하게 저장됩니다. 컨슈머 프로그램은 평소처럼 메시지를 pull하여 처리합니다.
데이터베이스나 Redis를 사용하는 전통적인 방식과 가장 결정적으로 다른 점은 무엇일까요?
"정기적인 폴링이나 복잡한 스케줄링 로직의 부담을 최소화한다"는 것입니다.
우리는 단순히 미래의 시각을 적어 NATS에 넘기기만 하면 됩니다. 1분, 10분 간격의 치열한 시간 관리? NATS가 백그라운드에서 매끄럽게 처리합니다.
* 물론 만병통치약은 없습니다. NATS 클러스터 전체가 다운되는 것과 같은 극단적인 재해 시나리오에서는, 유실될 수 있는 메시지를 완벽히 복구하기 위해 애플리케이션 수준에서 최소한의 Reconciliation 로직이 필요할 수 있습니다. 그러나 이 패턴을 도입하는 가치는 핵심 비즈니스 로직에서 '타이머 구동의 부담'을 덜어내고 아키텍처를 획기적으로 단순화하는 데 있습니다.
이제 3장에서 기초부터 고급 재해 대비까지, 이 기능을 구현하기 위한 실제 설정과 코드를 단계별로 살펴보겠습니다.