# NATS

NATS 큐 그룹 없이 구독자 셋이면 주문이 세 번 처리될까?

@Hong2026-10-14

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
NATS에서 같은 주제를 구독하는 서버를 늘렸을 때 메시지를 한 서버만 받게 하려면 어떻게 할까?
답
큐 이름 없이 구독한 3개에 10건을 보내자 서버는 30건을 내보냈고, 같은 큐 이름으로 구독하자 3000건을 세 번 보내 매번 중복과 누락 없이 나뉘었다.
조건
NATS 2.15.0(nats:2.15.0, JetStream 끔), nats CLI 0.5.0, Python 3.12와 nats-py 2.16.0, 구독자마다 컨테이너와 연결 하나

환경

NATS
2.15.0(nats:2.15.0), JetStream 끔, Docker Compose
명령줄 도구
natsio/nats-box:0.20.0의 nats CLI 0.5.0
구독자
Python 3.12, nats-py 2.16.0
구성
구독자마다 컨테이너 하나, NATS 연결 하나
목차
  1. 실습 준비
  2. 실습 1: 일반 구독은 모두에게 전달된다
  3. 실습 2: 큐 이름을 붙이면 한 구독자만 받는다
  4. 실습 3: 구독자가 죽으면 큐 그룹은 다시 보내지 않는다
  5. 정리

이번 시간에는 NATS에서 일반 구독과 큐 그룹의 차이를 건수로 확인하고, 큐 그룹이 메시지를 어떤 규칙으로 나누는지, 처리 중에 구독자가 죽을 때 메시지가 어떻게 되는지 재현해 보며 배워가는 시간입니다.

예를 들어서 주문 이벤트를 처리하는 서비스가 바빠져서 서버를 한 대에서 세 대로 늘렸다고 해볼게요.

세 서버는 같은 코드로 orders.created 주제를 구독해요. 그런데 주문 10건을 보내면 일을 나눠 맡을 줄 알았던 서버 세 대가 같은 주문을 하나씩 모두 처리해서, 주문 10건이 30번 처리돼요.

NATS에서 구독자를 늘려 일을 나누려면 구독에 큐 이름을 붙여 큐 그룹을 만들어야 해요.

실습 준비

이 글의 명령은 모두 실습 코드의 nats-queue-group/ 디렉터리에서 실행합니다.

  • compose.yaml: NATS 서버와 tools 프로필의 box(nats CLI)·py(Python) 서비스
  • Dockerfile: py 서비스 이미지(nats-py 2.16.0)
  • sub.py: 일반·큐 그룹 구독자(--queue, --delay, --save)
  • tally.py: 구독자들이 남긴 순번 파일로 건수, 중복, 누락, 연속 구간을 세는 스크립트

서버 여러 대는 구독자 컨테이너 여러 개로 대신할게요.

구독자 하나가 컨테이너 하나이고, NATS 연결도 하나씩 따로 맺어요. 그래서 특정 구독자만 골라 종료할 수 있고, 서버의 모니터링 화면에서 연결별로 몇 건을 보냈는지도 볼 수 있어요.

NATS 서버는 -m 8222로 모니터링 HTTP 포트를 엽니다. 이 글은 JetStream을 쓰지 않으므로 -js를 주지 않습니다.

box와 py는 tools 프로필에 넣어 두었어요. 그래서 docker compose up으로는 뜨지 않고, 명령을 실행할 때만 컨테이너를 만들어요. 세 서비스는 compose.yaml에 있습니다.

아래는 py 서비스의 Dockerfile이에요.

FROM python:3.12-slim
RUN pip install --no-cache-dir --root-user-action=ignore nats-py==2.16.0

구독자 sub.py는 메시지 본문을 order-3처럼 구간 이름과 순번으로 받아요.

1초 동안 새 메시지가 없으면 그때까지 구간별로 받은 건수를 출력하고, 20건 이하면 받은 순번도 같이 보여 줘요.

  • --queue: 큐 이름입니다. 비워 두면 일반 구독입니다.
  • --delay: 메시지 하나를 처리하는 데 걸리는 시간(초)입니다. 0보다 크면 메시지마다 콜백에 들어온 때와 처리를 마친 때를 한 줄씩 출력합니다.
  • --save: 받은 순번을 파일로 남깁니다. 분배 패턴을 셀 때 씁니다.

큐 그룹 구독은 subscribe에 queue=를 하나 더 넘기는 것이 전부예요.

처리를 흉내 내는 asyncio.sleep은 이벤트 루프를 멈추지 않아요. 반면 time.sleep을 쓰면 그동안 이벤트 루프 전체가 멈춰서, 소켓에서 메시지를 읽는 일까지 같이 멈춥니다.

docker stop이 보내는 SIGTERM을 받으면 drain으로 정상 종료해요.

compose 프로젝트 이름을 natsq로 정하고 서버를 띄운 뒤, 실습에서 쓸 함수를 정의할게요.

아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

export COMPOSE_PROJECT_NAME=natsq
docker compose up -d
docker compose build py

n() { docker compose run --rm -T box nats "$@"; }

varz() { curl -s localhost:7681/varz | grep -E '"(in_msgs|out_msgs)"'; }

subs_ready() {
  local i got
  for i in $(seq 1 60); do
    got=$(curl -s "localhost:7681/subsz?subs=1&test=$1" | grep -c '"subject":')
    if [ "$got" = "$2" ]; then echo "$1 구독 ${got}개 확인"; return 0; fi
    sleep 0.5
  done
  echo "구독 수 ${got}개, 기대 ${2}개"; return 1
}

worker() { local name=$1; shift; docker compose run -d --name "natsq-$name" py python sub.py --name "$name" "$@" > /dev/null; }

connz() { curl -s "localhost:7681/connz?$1" | grep -E '"(name|out_msgs)"'; }
  • n: nats-box 컨테이너에서 nats 명령을 실행합니다.
  • varz: 서버 통계 /varz에서 서버가 받은 메시지 수(in_msgs)와 구독자에게 보낸 메시지 수(out_msgs)를 꺼냅니다. 서버가 뜬 뒤로 누적된 값이라 발행 전후 두 번 찍어 차이를 봅니다.
  • subs_ready: /subsz에서 주어진 주제에 맞는 구독이 기대한 개수가 될 때까지 기다립니다. test= 쿼리를 주면 그 주제로 발행했을 때 받을 구독만 보여 줍니다.
  • worker: natsq-<이름> 컨테이너로 구독자를 하나 띄웁니다. 뒤에 붙인 인자는 sub.py로 넘어갑니다.
  • connz: /connz에서 연결 이름과 그 연결로 보낸 메시지 수를 꺼냅니다.

구독자를 띄우자마자 발행하지 않고 subs_ready로 확인하는 건 왜일까요?

Core NATS는 발행하는 순간 서버에 등록된 구독에만 메시지를 넘겨요. 그래서 구독 등록보다 발행이 먼저 도착하면 그 메시지는 아무도 받지 못해요. 그러면 뒤에서 볼 "구독자가 죽어서 사라진 메시지"와 구별할 수 없게 됩니다.

실습 1: 일반 구독은 모두에게 전달된다

큐 이름 없이 구독자 3개를 띄우고 10건을 보내 볼게요. {{Count}}는 nats CLI가 1부터 매기는 번호로 바꿔 줍니다.

worker worker-1
worker worker-2
worker worker-3
subs_ready orders.created 3
varz

n pub orders.created "order-{{Count}}" --count 10 > /dev/null 2>&1
sleep 2
varz

docker stop natsq-worker-1 natsq-worker-2 natsq-worker-3 > /dev/null
docker logs natsq-worker-1; docker logs natsq-worker-2; docker logs natsq-worker-3
docker rm natsq-worker-1 natsq-worker-2 natsq-worker-3 > /dev/null
orders.created 구독 3개 확인
  "in_msgs": 0,
  "out_msgs": 0,
  "in_msgs": 10,
  "out_msgs": 30,
worker-1: 구독 시작 (queue=없음)
worker-1: order 10건 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
worker-1: 종료
worker-2: 구독 시작 (queue=없음)
worker-2: order 10건 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
worker-2: 종료
worker-3: 구독 시작 (queue=없음)
worker-3: order 10건 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
worker-3: 종료

서버는 10건을 받아서 30건을 내보냈어요. 메시지 하나를 구독 수만큼 복사해 보낸 거죠.

일반 구독은 "이 주제의 메시지를 나도 받겠다"는 뜻이에요. 그래서 구독자를 늘리면 받는 쪽이 늘 뿐 일이 나뉘지 않아요.

알림, 캐시 무효화처럼 모든 인스턴스가 같은 메시지를 받아야 하는 곳에는 이 동작이 맞아요. 하지만 주문 처리처럼 한 번만 해야 하는 일에는 맞지 않습니다.

실습 2: 큐 이름을 붙이면 한 구독자만 받는다

같은 구독자 3개를 이번에는 --queue workers로 띄워 볼게요.

같은 큐 이름으로 같은 주제를 구독한 구독자들은 하나의 큐 그룹이 되고, 서버는 메시지마다 그룹 안의 한 구독자에게만 보내요. 큐 그룹을 따로 만드는 명령이나 서버 설정은 없어요. 구독할 때 이름만 같으면 됩니다.

아래 명령은 3000건 실험까지 한 번에 돌려요. 첫 번째 반복에서만 10건을 먼저 보내고, 반복마다 구독자를 새로 띄워 3000건을 보냅니다. 받은 순번은 out/s2-<반복>/ 아래 파일로 남겨요.

반복마다 마지막에 실행하는 tally.py는 구독자들이 남긴 순번 파일을 모아 구독자별 건수, 중복과 누락, 같은 구독자가 연속으로 받은 가장 긴 구간을 계산해요.

for run in 1 2 3; do
  mkdir -p out/s2-$run

  worker worker-1 --queue workers --save out/s2-$run/worker-1.txt
  worker worker-2 --queue workers --save out/s2-$run/worker-2.txt
  worker worker-3 --queue workers --save out/s2-$run/worker-3.txt
  subs_ready orders.created 3
  varz

  if [ $run = 1 ]; then
    n pub orders.created "order-{{Count}}" --count 10 > /dev/null 2>&1
    sleep 2
  fi

  n pub orders.created "bulk-{{Count}}" --count 3000 > /dev/null 2>&1
  sleep 2
  varz

  docker stop natsq-worker-1 natsq-worker-2 natsq-worker-3 > /dev/null
  docker logs natsq-worker-1; docker logs natsq-worker-2; docker logs natsq-worker-3
  docker rm natsq-worker-1 natsq-worker-2 natsq-worker-3 > /dev/null
  docker compose run --rm -T py python tally.py out/s2-$run bulk 3000
done

첫 번째 반복에서 10건을 받은 결과예요.

orders.created 구독 3개 확인
...
worker-1: 구독 시작 (queue=workers)
worker-1: order 4건 [1, 5, 7, 8]
...
worker-2: 구독 시작 (queue=workers)
worker-2: order 1건 [2]
...
worker-3: 구독 시작 (queue=workers)
worker-3: order 5건 [3, 4, 6, 9, 10]

10건이 4건, 1건, 5건으로 나뉘었고, 어느 순번도 두 구독자가 받지 않았어요.

NATS 큐 그룹은 파티션도 저장된 위치도 없이, 메시지가 도착할 때마다 받을 구독자를 골라요.

10건 결과를 다시 보면 1번은 worker-1, 2번은 worker-2, 3번은 worker-3이 받아서 처음에는 차례대로 도는 것처럼 보여요.

그런데 4번은 다시 worker-3에게 갔고, 7번과 8번은 worker-1이 연달아 받았어요. 1, 2, 3, 1, 2, 3 순서로 돌려 주는 방식이 아니라는 거죠.

NATS 문서(Queue Groups)는 그룹 안의 선택을 이렇게 설명해요.

Selection is random per message and does not account for how busy a member is.

받을 구독자는 메시지마다 무작위로 고르고, 그 구독자가 얼마나 바쁜지는 보지 않는다는 뜻이에요. 무작위로 고르면 같은 구독자가 몇 번 연달아 뽑히는 일도 생기죠.

3000건으로 이 성질을 세어 볼게요. 앞의 반복문에서 3000건 부분의 출력이에요.

orders.created 구독 3개 확인
  "in_msgs": 10,
  "out_msgs": 30,
  "in_msgs": 3020,
  "out_msgs": 3040,
...
worker-1: 979건
worker-2: 1026건
worker-3: 995건
합계: 3000건, 둘 이상이 받은 순번: 0개, 아무도 안 받은 순번: 0개
같은 구독자가 연속으로 받은 최대 길이: 9건 (worker-3, 2620~2628번)
...
worker-1: 1034건
worker-2: 1026건
worker-3: 940건
합계: 3000건, 둘 이상이 받은 순번: 0개, 아무도 안 받은 순번: 0개
같은 구독자가 연속으로 받은 최대 길이: 8건 (worker-3, 156~163번)
...
worker-1: 1012건
worker-2: 1009건
worker-3: 979건
합계: 3000건, 둘 이상이 받은 순번: 0개, 아무도 안 받은 순번: 0개
같은 구독자가 연속으로 받은 최대 길이: 7건 (worker-3, 419~425번)

세 번 모두 합계는 3000건이고, 중복도 누락도 없었어요.

/varz를 보면 첫 번째 반복에서 서버가 받은 메시지(10건과 3000건)와 내보낸 메시지가 똑같이 3010건 늘었어요. 일반 구독에서는 받은 것의 세 배를 내보냈는데, 큐 그룹에서는 한 메시지를 한 번만 내보낸 거예요.

구독자별 건수는 세 번의 반복에서 940건에서 1034건 사이였어요.

여기서 눈여겨볼 건, 세 구독자의 건수가 같았던 적은 없고 같은 구독자가 7~9건을 연달아 받은 구간도 매번 있었다는 점이에요. 차례대로 돌려 주는 방식이라면 연속 길이는 1이어야 하죠.

건수가 비슷한 건 무작위로 고른 결과가 3000번 쌓였기 때문이에요. 서버에는 건수를 맞춰 주는 장치가 없으니, 짧은 구간에서는 한 구독자에게 메시지가 몰릴 수 있어요.

실습 3: 구독자가 죽으면 큐 그룹은 다시 보내지 않는다

이번에는 처리할 메시지가 쌓여 있는 구독자가 죽으면 어떻게 되는지 볼게요.

slow는 메시지 하나에 0.1초가 걸리는 구독자이고, 나머지 둘은 바로 처리해요. 0.1초는 처리가 밀리는 상황을 만들려고 일부러 넣은 지연이에요.

3000건을 빠르게 보내고, 서버가 각 연결로 몇 건을 보냈는지 기록한 다음 바로 docker kill로 slow를 죽입니다.

nats-py는 서버에서 받은 메시지를 구독마다 클라이언트 메모리의 대기열에 넣고, 콜백을 하나씩 차례로 실행해요(nats/aio/subscription.py의 _wait_for_msgs). 그래서 slow가 받은 메시지는 대부분 콜백을 기다리며 이 대기열에 쌓여요.

/connz?state=closed는 이미 닫힌 연결의 기록을 보여 주는데, 앞 실습의 구독자 기록도 남아 있어요. 그래서 서버를 다시 시작해 비우고 시작할게요.

docker compose restart nats > /dev/null 2>&1; sleep 2

worker worker-1 --queue workers
worker worker-2 --queue workers
worker slow --queue workers --delay 0.1
subs_ready orders.created 3

n pub orders.created "order-{{Count}}" --count 3000 > /dev/null 2>&1
connz

docker kill natsq-slow > /dev/null
sleep 3
connz "state=closed" | grep -B1 '"slow"'

docker stop natsq-worker-1 natsq-worker-2 > /dev/null
docker logs natsq-worker-1 | grep 건; docker logs natsq-worker-2 | grep 건
docker logs natsq-slow | tail -4

docker rm natsq-worker-1 natsq-worker-2 natsq-slow > /dev/null
orders.created 구독 3개 확인
      "out_msgs": 996,
      "name": "worker-1",
      "out_msgs": 1013,
      "name": "worker-2",
      "out_msgs": 991,
      "name": "slow",
      "out_msgs": 991,
      "name": "slow",
worker-1: order 996건
worker-2: order 1013건
slow: order-25 처리 완료 (누적 13)
slow: order-26 받음 (콜백 진입 14)
slow: order-26 처리 완료 (누적 14)
slow: order-29 받음 (콜백 진입 15)
  • 서버가 slow 연결로 보낸 메시지: 991건. 종료 직전 /connz와 종료 후 닫힌 연결 기록 모두 991건입니다.
  • slow가 처리를 마친 메시지: 14건. 15번째 메시지는 콜백에 들어간 채 프로세스가 죽었습니다.
  • 나머지 두 구독자가 처리한 메시지: 996 + 1013 = 2009건. 3000 - 991과 같습니다.
  • 전체 처리: 2009 + 14 = 2023건. 3000건 가운데 977건이 처리되지 않았고, 이는 991 - 14와 같습니다.

사라진 977건은 서버가 이미 slow에게 보낸 메시지예요.

서버는 보낸 뒤로 그 메시지를 기억하지 않아요. 그래서 slow가 죽어도 worker-1이나 worker-2에게 다시 보낼 방법이 없어요.

NATS 문서(Queue Groups)도 Core NATS 큐 그룹은 다시 보내지 않는다("Core queue groups never redeliver")고 적고, 처리 도중 죽은 구독자에게 보낸 메시지는 잃는다("a message sent to a worker that crashes mid-processing is lost")고 설명해요.

또 하나 눈여겨볼 점은, 1건 처리에 0.1초가 걸리는 구독자가 바로 처리하는 구독자들과 거의 같은 991건을 받았다는 거예요. 서버가 구독자가 얼마나 바쁜지 보지 않는다는 앞의 문서 문장 그대로죠.

스트림에 저장하고 ack를 받는 JetStream 방식이 궁금하면 JetStream을 다룬 글을 참고하세요.

정리

범위: NATS 2.15.0(nats:2.15.0, JetStream 끔), nats CLI 0.5.0(natsio/nats-box:0.20.0), Python 3.12와 nats-py 2.16.0, 구독자마다 컨테이너와 연결 하나.

  • 큐 이름 없이 구독한 3개에 10건을 보내자 서버는 30건을 내보냈고, --queue workers로 구독하자 3000건을 세 번 보내 매번 중복과 누락 없이 나뉘었어요. 한 번만 해야 하는 작업을 여러 인스턴스로 나누려면 구독에 큐 이름을 붙이세요.
  • 구독자별 건수는 940~1034건으로 같았던 적이 없고 같은 구독자가 7~9건을 연달아 받은 구간이 매번 있었으며, 메시지마다 0.1초가 걸리는 구독자도 991건을 받았어요. 큐 그룹이 구독자의 처리 속도에 맞춰 나눠 준다고 기대하지 마세요.
  • 처리가 밀린 구독자를 docker kill로 죽이자 이미 보낸 메시지 977건이 다시 보내지지 않고 사라졌어요. 처리하다 죽어도 잃으면 안 되는 작업은 JetStream으로 옮기세요.
  • 이 글에 싣지 않은 추가 측정에서는 정상 종료(drain)도 Docker 유예 10초에 걸리면 1006건 가운데 108건, 유예를 120초로 늘리면 drain_timeout 30초에 걸려 980건 가운데 303건만 처리했어요. 대기열이 쌓인 구독자는 정상 종료에서도 남은 메시지를 잃는다고 보고 설계해 주세요.
내 링크 만들기