이번 시간에는 구독자가 없을 때 보낸 메시지가 Core NATS와 JetStream에서 각각 어떻게 되는지 nats CLI로 직접 확인해 보며 배워가는 시간입니다.
예를 들어서 주문 서비스가 orders.created 주제로 주문 이벤트 5건을 보냈다고 해볼게요.
그런데 알림 서비스는 배포 중이라 몇 초 늦게 구독을 시작했고, 그 뒤에 5건이 더 왔어요. 알림 서비스는 구독을 시작한 뒤에 온 5건만 받았고, 앞의 5건은 오류 없이 사라졌습니다. 왜 그럴까요?
NATS의 기본 메시징(Core NATS)은 메시지를 저장하지 않기 때문이에요. 메시지를 저장하고 처리 결과를 확인받으려면 NATS 서버에 들어 있는 JetStream을 써야 합니다.
여기서 처리를 마쳤다고 받는 쪽이 서버에 보내는 응답을 ack라고 해요.
실습 준비
이 글의 명령은 모두 실습 코드의 nats-jetstream/ 디렉터리에서 실행합니다.
compose.yaml: NATS 서버와 nats-box(box) 서비스
서버는 nats:2.15.0 이미지 하나로 띄울게요(compose.yaml, 메모리 256MB). 클라이언트 포트 4222는 호스트 7230, 모니터링 포트 8222는 호스트 7231로 열어요. 명령 인자의 뜻은 이렇습니다.
-js: JetStream을 켭니다.-sd /data: JetStream이 스트림을 저장할 디렉터리입니다.-m 8222: 모니터링 HTTP 포트입니다./varz에서 서버가 받은 메시지 수와 내보낸 메시지 수를 볼 수 있습니다.
box는 tools 프로필에 넣어 두었어요. 그래서 docker compose up으로는 뜨지 않고, 명령을 실행할 때만 잠깐 컨테이너를 만듭니다.
compose 프로젝트 이름을 nats로 정하고 서버를 띄울게요. n은 nats-box 컨테이너에서 nats 명령을 실행하는 함수이고, varz는 서버 통계에서 필요한 값만 꺼내는 함수예요.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
export COMPOSE_PROJECT_NAME=nats
docker compose up -d
n() { docker compose run --rm -T box nats "$@"; }
varz() { curl -s localhost:7231/varz | grep -E '"(in_msgs|out_msgs|slow_consumers)"'; }
/varz의 세 값은 이런 뜻이에요.
in_msgs: 서버가 발행자에게서 받은 메시지 수out_msgs: 서버가 구독자에게 보낸 메시지 수slow_consumers: 서버가 느리다고 판단한 구독자 수
실습 1: Core NATS는 지금 구독 중인 구독자에게만 보냅니다
구독자가 없는 상태에서 orders.created로 5건을 보내 볼게요. {{Count}}는 nats CLI가 1부터 매기는 번호로 바꿔 줍니다.
n pub orders.created "before {{Count}}" --count 5
varz
08:55:24 Published 8 bytes to "orders.created"
08:55:24 Published 8 bytes to "orders.created"
08:55:24 Published 8 bytes to "orders.created"
08:55:24 Published 8 bytes to "orders.created"
08:55:24 Published 8 bytes to "orders.created"
"in_msgs": 5,
"out_msgs": 0,
"slow_consumers": 0,
발행은 성공했고 서버도 5건을 받았어요. 그런데 내보낸 메시지는 0건이에요.
이제 5건을 받으면 끝나는 구독자를 뒤에서 띄우고, 구독이 시작될 때까지 기다린 뒤 5건을 더 보내 볼게요.
n sub orders.created --count 5 > sub.txt 2>&1 &
sleep 5
n pub orders.created "after {{Count}}" --count 5
wait
cat sub.txt
varz
08:55:31 Subscribing on orders.created
[#1] Received on "orders.created"
after 1
[#2] Received on "orders.created"
after 2
[#3] Received on "orders.created"
after 3
[#4] Received on "orders.created"
after 4
[#5] Received on "orders.created"
after 5
"in_msgs": 10,
"out_msgs": 5,
"slow_consumers": 0,
구독자는 after 5건만 받았어요. 서버는 10건을 받았지만 5건만 내보냈죠.
처음 5건은 서버에 들어왔다가 받을 구독자가 없어서 버려졌고, 구독자가 나중에 연결해도 다시 받을 방법이 없어요.
NATS 문서는 Core NATS의 전달 방식을 at-most-once로 설명해요. 메시지는 발행하는 순간 연결되어 있는 구독자에게 최대 한 번 전달되고, 그때 구독하지 않은 구독자는 그 메시지를 받지 못해요. 서버는 나중을 위해 메시지를 저장해 두지 않습니다.
그리고 Core NATS 발행에는 서버의 응답도 없어요. 그래서 nats CLI가 찍은 Published는 서버로 보냈다는 뜻일 뿐, 누가 받았는지는 알려 주지 않아요.
실습 2: JetStream은 메시지를 스트림에 저장합니다
JetStream은 두 가지를 서버에 둬요.
- 스트림: 지정한 주제로 들어온 메시지를 순서대로 저장하는 곳입니다. 메시지마다 1부터 커지는 stream sequence가 붙습니다.
- 컨슈머: 스트림을 읽는 쪽의 상태입니다. 어디까지 보냈는지, 어떤 메시지의 ack를 기다리는지를 서버가 기록합니다.
컨슈머는 몇 가지 기준으로 나눌 수 있어요.
- durable 컨슈머: 이름이 있고, 클라이언트가 연결을 끊어도 서버에 남습니다. 다시 연결하면 기록된 위치부터 이어서 받습니다.
- pull 컨슈머: 클라이언트가 원할 때 "몇 건 달라"고 요청해서 받습니다.
이제 orders.로 시작하는 모든 주제를 파일에 저장하는 ORDERS 스트림을 만들어 볼게요. --retention limits는 보관 정책인데, 실습 3에서 다룰게요.
n stream add ORDERS --subjects 'orders.>' --storage file --retention limits --defaults
Stream ORDERS was created
Information for Stream ORDERS created 2026-10-01 08:56:39
Subjects: orders.>
Replicas: 1
Storage: File
Options:
Retention: Limits
Acknowledgments: true
Discard Policy: Old
Duplicate Window: 2m0s
...
State:
Host Version: 2.15.0
Required API Level: 0 hosted at level 5
Messages: 0
Bytes: 0 B
First Sequence: 0
Last Sequence: 0
Active Consumers: 0
주문 3건을 보내요. --jetstream을 붙이면 서버가 스트림에 저장한 뒤 돌려주는 응답을 기다리고, 그 응답에 담긴 sequence를 보여 줍니다.
n pub orders.created "order {{Count}}" --count 3 --jetstream
08:56:46 Published 7 bytes to "orders.created"
08:56:46 Stored in Stream: ORDERS Sequence: 1
08:56:46 Published 7 bytes to "orders.created"
08:56:46 Stored in Stream: ORDERS Sequence: 2
08:56:46 Published 7 bytes to "orders.created"
08:56:46 Stored in Stream: ORDERS Sequence: 3
이제 구독자가 아무도 없어도 메시지는 스트림에 남아 있어요. 이 메시지를 읽을 pull 컨슈머 worker를 만들게요. 옵션은 이렇습니다.
--ack explicit: 메시지마다 ack를 받아야 처리된 것으로 칩니다.--wait 5s: ack_wait입니다. 메시지를 보낸 뒤 5초 안에 ack가 없으면 다시 보낼 대상이 됩니다.--max-deliver=-1: 한 메시지를 몇 번까지 보낼지 정합니다. -1은 제한이 없다는 뜻입니다.
n consumer add ORDERS worker --pull --ack explicit --wait 5s --max-deliver=-1 --defaults
Information for Consumer ORDERS > worker created 2026-10-01 08:56:53
Configuration:
Name: worker
Pull Mode: true
Deliver Policy: All
Ack Policy: Explicit
Ack Wait: 5.00s
Replay Policy: Instant
Max Ack Pending: 1,000
Max Waiting Pulls: 512
State:
Host Version: 2.15.0
Required API Level: 0 hosted at level 5
Last Delivered Message: Consumer sequence: 0 Stream sequence: 0
Acknowledgment Floor: Consumer sequence: 0 Stream sequence: 0
Outstanding Acks: 0 out of maximum 1,000
Redelivered Messages: 0
Unprocessed Messages: 3
Waiting Pulls: 0 of maximum 512
worker에서 메시지 1건을 받되, ack는 하지 않을게요(--no-ack). 그다음 컨슈머 상태를 봅니다.
n consumer next ORDERS worker --no-ack
n consumer info ORDERS worker | grep -E "Delivered|Floor|Outstanding|Redelivered|Unprocessed"
[08:57:06] subj: orders.created / tries: 1 / cons seq: 1 / str seq: 1 / pending: 2
order 1
Last Delivered Message: Consumer sequence: 1 Stream sequence: 1 Last delivery: 6.55s ago
Acknowledgment Floor: Consumer sequence: 0 Stream sequence: 0
Outstanding Acks: 1 out of maximum 1,000
Redelivered Messages: 0
Unprocessed Messages: 2
구독자가 없을 때 저장한 1번 메시지(str seq: 1)를 컨슈머를 만든 뒤에 받았어요.
첫 줄에서는 이 값들을 보시면 됩니다.
tries: 이 메시지를 이 컨슈머에 보낸 횟수입니다. 지금은 1입니다.cons seq: 컨슈머가 전달할 때마다 1씩 올리는 번호입니다. 같은 메시지를 다시 보내도 새 번호가 붙습니다.str seq: 스트림에 저장된 메시지의 번호입니다. 같은 메시지면 항상 같습니다.pending: 아직 한 번도 보내지 않은 메시지 수입니다.
컨슈머 상태의 Outstanding Acks: 1은 보냈지만 ack를 받지 못한 메시지가 1건이라는 뜻이에요. Acknowledgment Floor는 이 번호까지는 모두 ack를 받았다는 경계인데, 아직 0이에요.
n 함수는 명령마다 컨테이너를 새로 띄우니까 Last delivery가 ack_wait인 5초보다 길게 찍혔어요. pull 컨슈머는 ack_wait이 지난 메시지를 다음 요청이 왔을 때 다시 보냅니다.
JetStream의 기본 전달 방식은 at-least-once이고, 처리하는 쪽이 중복을 견뎌야 해요. 이 방법이 더 궁금하면 at-least-once와 멱등 처리를 다룬 글을 참고하세요.
실습 3: 보관 정책이 메시지를 지우는 시점을 정합니다
스트림이 메시지를 언제 지울지는 컨슈머의 ack가 아니라 스트림의 보관 정책이 정해요.
- limits: 기본값입니다. 메시지 수, 크기, 보관 기간 같은 한도에 걸릴 때까지 남깁니다. ack와 상관없이 남습니다.
- interest: 그 메시지의 주제를 읽는 컨슈머가 모두 ack하면 지웁니다. 읽을 컨슈머가 하나도 없으면 바로 지웁니다.
- workqueue: 컨슈머 하나가 ack하면 지웁니다. 그래서 같은 메시지를 받을 수 있는 컨슈머를 둘 이상 만들 수 없습니다.
interest 정책의 EVENTS를 만들어 볼게요.
n stream add EVENTS --subjects 'events.>' --retention interest --defaults > /dev/null
먼저 컨슈머가 하나도 없는 EVENTS에 5건을 보내요.
n pub events.created "event {{Count}}" --count 5 --jetstream
n stream state EVENTS | grep Messages
08:59:46 Published 7 bytes to "events.created"
08:59:46 Stored in Stream: EVENTS Sequence: 1
08:59:46 Published 7 bytes to "events.created"
08:59:46 Stored in Stream: EVENTS Sequence: 2
08:59:46 Published 7 bytes to "events.created"
08:59:46 Stored in Stream: EVENTS Sequence: 3
08:59:46 Published 7 bytes to "events.created"
08:59:46 Stored in Stream: EVENTS Sequence: 4
08:59:46 Published 7 bytes to "events.created"
08:59:46 Stored in Stream: EVENTS Sequence: 5
Messages: 0
발행자는 5건 모두 sequence가 붙은 저장 응답을 받았는데, 스트림에 남은 메시지는 0건이에요. interest 스트림은 관심 있는 컨슈머가 없으면 저장 응답을 준 뒤에도 메시지를 남기지 않아요.
정리
범위: NATS 2.15(nats:2.15.0) 단일 서버, nats CLI 0.5.0.
- 구독자가 없을 때 Core NATS로 보낸 5건은 서버가 받았지만(
in_msgs10,out_msgs5) 나중에 구독한 쪽에 오지 않았어요. 구독 전에 보낸 메시지도 받아야 하면 Core NATS가 아니라 JetStream 스트림에 저장하세요. - 구독자가 없을 때
--jetstream으로 보낸 3건은 스트림ORDERS에 sequence 1~3으로 저장됐고, 나중에 만든 컨슈머worker가 1번부터 받았어요. 저장됐는지 확인해야 하는 발행에는--jetstream처럼 저장 응답을 기다리는 방식을 써 주세요. - 이 글에 싣지 않은 추가 측정에서는 ack하지 않은 메시지가 ack_wait 5초가 지나자 같은
str seq: 1이tries2, 3으로 다시 왔어요. JetStream 처리 코드는 같은 메시지를 다시 받아도 결과가 한 번만 남게 만들어 주세요. - 컨슈머가 없을 때 interest 스트림
EVENTS에 보낸 5건은 저장 응답을 받고도 남지 않았어요. interest 스트림은 컨슈머를 먼저 만들어 주세요. - 이 글에 싣지 않은 추가 측정에서는 같은 5건을 ack한 뒤 limits 스트림은 그대로 남았고, workqueue는 컨슈머 하나의 ack로, interest는 두 컨슈머가 모두 ack해야 0건이 됐어요. ack 뒤에 지워야 하면 workqueue나 interest를 고르세요.