단일 VPS 메시지 큐 선택 가이드: NATS, RabbitMQ, Kafka 비교
단일 서버 환경에서 메시지 큐를 선택할 때 고려할 전달 보장 방식과 리소스 비용을 정리했습니다. Postgres 활용부터 RabbitMQ, NATS, Kafka의 적합성까지, 시스템 장애 시 재시작 동작과 백로그 확인 방법을 상세히 안내합니다.
단일 서버를 위한 요약
단일 VPS에서 메시지 큐를 사용하는 것은 속도가 아닌 전달 보장 방식에 관한 결정입니다. 단일 서버 환경에서는 애플리케이션 코드, 데이터베이스, 디스크 I/O가 병목의 주원인이므로 브로커가 병목이 되는 경우는 드뭅니다. 장애 발생 시의 동작 방식을 감당할 수 있는 도구를 선택한 뒤, 실제 사용 중인 서버의 성능을 측정하십시오.
대다수의 사용자가 고려해야 할 순서대로 네 가지 옵션을 제시합니다.
- 이미 운영 중인 데이터베이스를 사용하십시오.
SELECT ... FOR UPDATE SKIP LOCKED를 사용하는 Postgres는 훌륭한 작업 큐이며, 모니터링해야 할 새로운 프로세스를 추가하지 않아도 됩니다. - 각 메시지가 반드시 확인(acknowledge)되어야 하고, 정해진 횟수만큼 재시도해야 하며, 실패 시 사람이 확인할 수 있는 곳에 보관해야 하는 작업 단위라면 RabbitMQ를 사용하십시오.
- 메시지가 시스템의 여러 구성 요소가 반응해야 하는 이벤트라면 NATS를 사용하십시오. 재시작 후에도 유지되어야 하는 이벤트에는 JetStream을 활성화하십시오.
- 다운스트림 도구가 오직 Kafka 프로토콜만 지원하는 경우에만 Kafka를 사용하십시오. 단일 서버 환경에서는 사실상 이것이 유일한 선택 이유입니다.
이 가이드의 나머지 부분에서는 각 옵션에 대한 근거를 설명합니다. 소규모 VPS에서 각 옵션이 차지하는 메모리와 디스크 비용, 서버 재부팅 시의 동작, 그리고 사용자가 체감하기 전에 백로그를 확인할 수 있는 정확한 명령어를 다룹니다.
전송 보장(delivery guarantee)의 실제 의미
At most once(최대 한 번)는 브로커가 메시지를 전달한 뒤 이를 잊어버리는 방식을 의미합니다. 연결된 컨슈머가 없거나 작업 도중 컨슈머가 종료되면 메시지는 사라지며, 이에 대한 보고도 이루어지지 않습니다.
At least once(최소 한 번)는 작업이 성공한 뒤 컨슈머가 승인(ack)을 보내는 방식을 의미합니다. 승인 신호가 도착하기 전까지 브로커는 메시지를 보관하며 다시 전달을 시도합니다. 재전송이 발생하기 때문에 핸들러는 반드시 멱등성(idempotent)을 갖추어야 합니다. 즉, 동일한 메시지를 두 번 처리하더라도 결제가 두 번 이루어지지 않아야 합니다. 종단 간(end to end) Exactly once는 브로커가 제공하는 기능이 아닙니다. 이는 데이터베이스의 고유 키를 활용하여 직접 구현해야 합니다.
Replay(재생)는 별개의 속성입니다. 큐(queue)는 메시지가 승인되면 즉시 삭제합니다. 반면 로그(log)는 보존 기간 동안 메시지를 유지하므로, 새로운 컨슈머가 처음부터 전체 기록을 읽을 수 있습니다. Kafka와 NATS JetStream은 로그 방식이며, RabbitMQ는 큐 방식입니다. 이러한 차이는 처리량(throughput)보다 아키텍처 설계에 더 큰 영향을 미칩니다.
Dead lettering(데드 레터링)은 지속적으로 처리에 실패하는 메시지를 처리하는 방식입니다. 이 기능이 없으면 잘못된 메시지(poison message)가 무한 루프에 빠지게 되며, 시스템은 이를 고장 난 상태가 아닌 바쁜 작업 상태로 오인하게 됩니다.
Postgres로 시작하여 브로커의 성능을 검증하십시오
대부분의 단일 애플리케이션 워크로드는 하루에 수천 건 정도의 백그라운드 작업을 처리합니다. 이는 테이블 하나로 충분히 감당할 수 있는 수준입니다.
CREATE TABLE job (
id bigserial PRIMARY KEY,
payload jsonb NOT NULL,
run_after timestamptz NOT NULL DEFAULT now(),
attempts int NOT NULL DEFAULT 0
);
CREATE INDEX job_ready_idx ON job (run_after, id);워커는 트랜잭션 내부에서 작업 하나를 점유합니다.
BEGIN;
SELECT id, payload
FROM job
WHERE run_after <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
-- run the work, then remove the row
DELETE FROM job WHERE id = $1;
COMMIT;FOR UPDATE SKIP LOCKED가 이 방식의 핵심입니다. 이 쿼리는 반환하는 행에 잠금을 걸고, 다른 트랜잭션이 이미 잠근 행은 건너뜁니다. 따라서 두 워커가 동일한 작업을 가져가는 일은 발생하지 않습니다. 워커가 비정상 종료되면 Postgres는 해당 트랜잭션을 중단하고 잠금을 해제하므로, 다음 워커가 해당 행을 즉시 처리할 수 있습니다. 이를 통해 최소 한 번의 전달(at-least-once delivery)을 보장하며, attempts 값을 증가시켜 재시도를 구현하고, 데드 레터 테이블을 구성할 수 있습니다. 이 모든 과정은 이미 비용을 지불하고 있는 데이터베이스의 내구성을 그대로 활용합니다. 백로그 확인은 다음 쿼리 하나로 가능합니다: SELECT count(*) FROM job WHERE run_after <= now();
이 방식이 한계에 부딪히는 지점은 다음과 같습니다. 모든 점유와 삭제는 쓰기 작업이므로, 작업 처리 속도가 빠르면 죽은 행 버전(dead row versions)이 쌓이게 됩니다. 큐 테이블은 autovacuum이 처리 속도를 따라가지 못해 비대해지는 전형적인 사례입니다. 작업 시간이 길어지면 문제는 더 심각해집니다. 작업 시간 동안 트랜잭션이 열려 있으면 데이터베이스 전체의 vacuum horizon이 뒤로 밀리기 때문입니다. 폴링(polling) 방식은 지연 시간을 발생시키며, LISTEN과 NOTIFY를 사용하면 폴링은 제거할 수 있지만 쓰기 부하는 여전히 남습니다. 작업 테이블이 데이터베이스에서 가장 바쁜 테이블이 되거나, 다른 서비스에서 동일한 이벤트가 필요해지면 브로커를 분리해야 합니다. 이 선택은 데이터베이스 자체의 배포 방식과 밀접하게 연관되므로, 브로커를 추가하기 전에 데이터베이스를 Docker에서 실행할지 호스트에서 실행할지 먼저 결정하십시오.
Redis는 이미 운영 중일 가능성이 높은 또 다른 선택지입니다. Redis Streams는 XADD과 XREADGROUP을 통한 소비자 그룹, 그룹별 대기 목록, 그리고 죽은 소비자로부터 작업을 회수하는 XAUTOCLAIM 기능을 제공합니다. 작고 빠릅니다. 단일 서버 환경에서 고려해야 할 솔직한 단점은, 일반적인 appendfsync everysec 설정에서는 전원 차단 시 약 1초 분량의 쓰기 데이터가 유실될 수 있다는 점입니다. 캐시 무효화에는 적합하지만 결제 처리에는 부적합합니다. 만약 애플리케이션이 프로덕션 환경의 VPS에서 SQLite를 중심으로 구축된 단일 프로세스라면, 동일한 점유-삭제 패턴을 사용할 수 있습니다. 다만 SQLite는 SKIP LOCKED에 대응하는 기능이 없으며, 모든 워커가 단일 쓰기 잠금에서 직렬화된다는 점을 유의하십시오.
NATS core: 메모리 없는 서브젝트 라우팅
docker run -d --name nats \
-p 4222:4222 -p 127.0.0.1:8222:8222 \
nats:2.14 -m 82222026년 8월 기준 현재 서버 라인은 2.14입니다. -m 8222는 HTTP 모니터링 포트를 활성화합니다. 이 포트는 기본적으로 꺼져 있으며 인증 기능이 없으므로, 위와 같이 localhost에 바인딩하십시오.
Core NATS는 최대 한 번(at most once) 전달을 보장하며 데이터를 저장하지 않습니다. 발행자(publisher)가 orders.created과 같은 서브젝트로 메시지를 보내면, 필터가 일치하는 모든 구독자(subscriber)에게 메시지 사본이 전달됩니다. 구독자가 없으면 메시지는 삭제되며 발행자는 오류를 확인하지 못합니다. 서버가 바이트를 수신하는 시점에 발행자의 작업은 종료되기 때문입니다. 큐 그룹(하나의 그룹 이름을 공유하는 여러 구독자)을 사용하면 서버가 메시지당 한 명의 멤버를 선택하므로, 큐를 저장하지 않고도 작업을 분산할 수 있습니다.
메모리 사용량은 구독 상태와 각 연결의 쓰기 버퍼에 따라 결정됩니다. 따라서 메시지 양이 아닌 연결 수에 비례하며, 디스크에 누적되는 데이터는 없습니다. 재시작 동작도 이와 같습니다. 전송 중이던 메시지는 사라지고, 클라이언트는 스스로 재연결하며, 대기해야 할 복구 단계도 없습니다.
모니터링할 백로그가 없으므로 메시지 손실 여부를 감시해야 합니다. 구독자가 서버의 쓰기 속도보다 느리게 소켓을 읽으면 해당 클라이언트의 서버 버퍼가 가득 찹니다. 쓰기 제한 시간까지 클라이언트가 따라잡지 못하면 서버는 연결 전체를 끊고 카운터를 증가시킵니다.
curl -s http://localhost:8222/varz | jq '.slow_consumers, .connections, .in_msgs, .out_msgs'slow_consumers 값이 계속 상승한다면 메시지가 삭제되고 있다는 의미이므로, 일회성 확인보다는 알림을 설정하십시오. Core NATS는 메트릭, 상태 업데이트, 다음 이벤트로 대체될 캐시 무효화와 같이 값이 빠르게 만료되는 메시지에 적합합니다.
NATS JetStream: 동일 프로세스 내의 영구 스트림 및 재전송
JetStream은 별도의 제품이 아닙니다. 동일한 바이너리에 포함된 하위 시스템이며, 하나의 플래그로 활성화됩니다.
docker run -d --name nats \
-p 4222:4222 -p 127.0.0.1:8222:8222 \
-v nats-data:/data \
nats:2.14 -js -sd /data -m 8222-sd /data은 저장소 디렉터리를 설정합니다. 이 설정을 생략하면 JetStream은 /tmp 아래에 데이터를 저장하는데, 이는 이름 그대로의 영속성만 가집니다. nats-box 이미지에 포함된 CLI를 사용하여 스트림을 생성하십시오.
docker run --rm -it --network host natsio/nats-box:latest \
nats stream add ORDERS \
--subjects 'orders.>' \
--storage file \
--retention limits \
--max-age 72h \
--max-bytes=1073741824 \
--discard old \
--defaults여기에 나열된 모든 제한 사항은 소규모 서버에서 중요한 역할을 합니다. --storage file은 메모리 스트림과 달리 충돌 후에도 데이터가 유지되도록 합니다. --max-bytes=1073741824은 스트림의 크기를 바이트 단위로 계산하여 1 GiB로 제한하며, --discard old는 제한 도달 시 새로운 쓰기를 거부하는 대신 가장 오래된 메시지를 삭제합니다. 제한 설정을 생략하면 제어되지 않는 게시자가 디스크를 가득 채울 수 있으며, 이 경우 데이터베이스와 디스크를 공유하므로 데이터베이스까지 중단됩니다.
영구 컨슈머(durable consumer)는 스트림 내에서 자신의 위치를 유지하며 재시작 후에도 이를 보존합니다. 컨슈머에 --max-deliver을 설정하면 지속적으로 실패하는 메시지가 무한히 재전송되는 것을 방지할 수 있습니다. 메시지의 재전송 횟수가 소진되면 JetStream은 $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>에 알림(advisory)을 게시합니다. 해당 주제(subject)를 구독하는 방식으로 RabbitMQ가 기본 기능으로 제공하는 데드 레터 경로(dead letter path)를 직접 구현할 수 있습니다. 이는 사용자가 직접 작성해야 하는 실제 작업입니다.
백로그를 확인하려면 저장된 메시지 수를 보려면 nats stream report를, 컨슈머별 미처리 확인 응답 및 메시지를 보려면 nats consumer report ORDERS을 실행하십시오. 미처리 메시지 수는 알람을 설정해야 하는 지표입니다. 디스크 사용량은 저장소 디렉터리에 대해 du -sh을 실행하여 확인할 수 있으며, 보존 제한(retention limit)에 의해 정리되기 전까지 계속 증가합니다.
RabbitMQ: 각 메시지 승인 및 실패 처리
docker run -d --name rabbitmq \
-p 5672:5672 -p 127.0.0.1:15672:15672 \
-v rabbitmq-data:/var/lib/rabbitmq \
rabbitmq:4-management2026년 8월 기준 현재 버전은 4.3입니다. 포트 5672는 AMQP(advanced message queuing protocol)용이며, 15672는 관리 인터페이스용입니다. 관리 인터페이스는 localhost로 제한하고 SSH 터널을 통해 접근하십시오.
큐를 선언할 때 x-queue-type 인수를 quorum로 설정하십시오. 기본값은 여전히 classic입니다. Quorum queue는 항상 내구성이 보장되며 다른 작업을 수행하기 전에 데이터를 디스크에 기록합니다. 따라서 단일 노드 환경에서도 내구성 및 일시적 옵션의 복잡한 조합 대신 명확하고 일관된 동작을 얻을 수 있습니다. Dead letter 대상은 정책을 통해 설정하십시오.
docker exec rabbitmq rabbitmqctl set_policy DLX ".*" \
'{"dead-letter-exchange":"my-dlx", "dead-letter-routing-key":"my-routing-key"}' \
--apply-to queues --priority 7메시지가 dead letter 처리되는 이유는 네 가지입니다. 소비자가 basic.reject 또는 basic.nack와 함께 requeue을 false로 설정하여 거부한 경우, 메시지별 TTL(time to live)이 만료된 경우, 큐가 길이 제한을 초과한 경우, 또는 Quorum queue의 전달 제한을 초과한 경우입니다. 해당 제한은 RabbitMQ 4.0부터 기본값이 20이므로, 예외를 발생시키고 nack을 반환하는 핸들러는 20번 재시도한 뒤 무한 루프에 빠지지 않고 메시지를 dead letter exchange로 전달합니다.
소규모 VPS에서 RabbitMQ를 운영할 때 메모리 문제는 예상치 못한 결과를 초래합니다. 기본 메모리 사용량 상한(high watermark)은 가용 RAM의 0.6이며, 노드가 이를 초과하면 RabbitMQ는 메시지를 발행하는 모든 연결을 차단합니다. 이때 애플리케이션은 오류를 수신하지 않습니다. 발행 요청이 응답을 반환하지 않고 대기 상태로 남게 되어, 코드상에서는 마치 멈춘 것처럼 보입니다. 시작 로그에는 노드가 계산한 임계값이 출력됩니다.
Memory high watermark set to 1024 MiB (1073741824 bytes) of 8192 MiB (8589934592 bytes) total디스크 알람도 기본적으로 여유 공간이 50 MB 미만으로 떨어지면 동일한 방식으로 발행자를 차단합니다. Quorum queue는 자체적인 계산 방식을 추가합니다. 문서에 따르면 메시지당 최소 32바이트의 메모리 내 메타데이터가 필요하며, 이는 메시지 30,000개당 약 1 MB에 해당합니다. 또한 유효한 write-ahead log 크기의 최소 3배를 RAM에 확보할 것을 권장합니다. WAL 제한의 기본값은 512 MiB이므로 이 권장 사항만으로도 1.5 GB의 RAM이 필요합니다. 2 GB 서버를 사용 중이라면 기본값이 맞기를 바라기보다 rabbitmq.conf에서 해당 값을 낮추십시오.
raft.wal_max_size_bytes = 64000000
vm_memory_high_watermark.relative = 0.5백로그는 두 개의 숫자로 구성되며, 이 쌍을 통해 어떤 유형의 장애가 발생했는지 파악할 수 있습니다.
docker exec rabbitmq rabbitmqctl list_queues name messages messages_ready messages_unacknowledgedmessages_ready는 소비자를 기다리는 메시지 수입니다. messages_unacknowledged은 전달되었으나 승인(ack)되지 않은 메시지 수입니다. Ready 메시지 수는 일정한데 unacknowledged 메시지 수가 증가한다면, 워커가 작업을 가져간 뒤 완료하지 못하고 있다는 의미입니다. 이는 단순히 큐가 밀린 상황과는 다른 버그입니다.
단일 서버에서의 Kafka와 그 한계
KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
bin/kafka-storage.sh format --standalone -t $KAFKA_CLUSTER_ID -c config/server.properties
bin/kafka-server-start.sh config/server.properties위 내용은 2026년 8월 기준 최신 버전인 Kafka 4.3.1의 퀵스타트 가이드이며, Kafka 4.0부터 ZooKeeper를 대체한 내장 컨트롤러인 KRaft 모드로 실행됩니다. 컨테이너 환경에서의 대응 설정은 apache/kafka:4.3.1입니다.
시작 스크립트는 사용자가 별도로 설정하지 않으면 export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G"을 자동으로 지정합니다. 따라서 브로커는 메시지를 단 하나도 저장하기 전에 1 GB의 Java 힙을 먼저 점유하며, 읽기 작업을 위한 페이지 캐시 용도로 추가적인 여유 RAM을 요구합니다. 2 GB RAM을 가진 VPS 환경에서는 애플리케이션이 남은 자원을 두고 JVM과 경쟁하게 됩니다.
데이터 보존 정책은 다음으로 주의해야 할 부분입니다. log.retention.hours의 기본값은 168(7일)이며, log.retention.bytes의 기본값은 -1로, 이는 용량 제한이 없음을 의미합니다. Kafka는 모든 컨슈머가 메시지를 읽었는지 여부와 관계없이 설정된 기간 동안 메시지를 보관합니다. 이것이 Kafka를 사용하는 핵심 기능이지만, 작은 디스크 환경에서는 장애의 원인이 되기도 하므로 운영 전에 토픽별로 바이트 단위의 제한을 설정해야 합니다.
이제 솔직한 현실을 짚어보겠습니다. 단일 브로커는 복제 계수(replication factor)가 1임을 의미하며, acks=all은 단일 디스크에 대한 한 번의 fsync로 귀결됩니다. 즉, JVM 브로커와 컨트롤러를 운영하는 비용을 지불하면서 단일 장비 수준의 내구성만 얻게 됩니다. 파티션은 여러 브로커에 걸친 병렬 처리를 가능하게 하지만, 단일 브로커 환경에서는 의미가 없습니다. 복제, 랙 인식(rack awareness) 및 기타 클러스터 기능은 비활성화된 상태로 남습니다. JetStream을 사용하면 동일한 서버에서 훨씬 적은 메모리로 동일한 수준의 내구성을 갖춘 재전송 기능을 구현할 수 있습니다. 그럼에도 불구하고 단일 서버에서 Kafka를 사용하는 이유를 굳이 꼽자면, 하위 도구(Debezium을 이용한 변경 데이터 캡처나 분석 로더 등)가 Kafka 프로토콜만 지원하거나, 운영 환경의 토폴로지를 소규모로 재현해야 하는 경우뿐입니다. 클러스터 확장을 계획한다는 것은 결국 더 많은 장비를 도입하겠다는 의미이며, 그전까지는 단일 노드에서 k3s 실행하기와 동일한 트레이드오프를 겪게 됩니다. 즉, 단일 노드의 신뢰성 수준에 클러스터 운영의 복잡성이라는 비용을 지불하는 것입니다.
Kafka에서의 백로그는 컨슈머 랙(consumer lag)을 의미합니다.
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group my-groupLAG 열을 확인하십시오. 이는 각 파티션별로 LOG-END-OFFSET에서 CURRENT-OFFSET를 뺀 값입니다. 다른 파티션은 일정한데 특정 파티션에서만 랙이 증가한다면 키 불균형을 의심해야 합니다. 동일한 키를 가진 모든 메시지는 같은 파티션에 할당되며, 해당 파티션은 단일 컨슈머가 전담하여 처리하기 때문입니다.
박스가 재부팅되면 발생하는 일
Core NATS는 복구할 대상이 없으므로 전송 중인 모든 데이터를 잃고 즉시 복구됩니다. JetStream은 저장소 디렉터리에서 스트림과 컨슈머 위치를 다시 불러오므로, 컨슈머는 이전 오프셋부터 작업을 재개합니다. RabbitMQ는 디스크에서 쿼럼 큐(quorum queues)를 복구하지만, 클래식 임시 큐(classic transient queues)와 영구 전달 모드(persistent delivery mode) 없이 발행된 메시지는 모두 사라집니다. Kafka는 시작 시 로그 세그먼트를 재생하며, 비정상 종료 후에는 브로커가 연결을 수락하기 전까지 작은 디스크에서도 복구 스캔에 수 분이 소요될 수 있습니다.
두 가지 설정은 미리 해두는 것이 좋습니다. 컨테이너에 재시작 정책(restart: unless-stopped)을 부여하거나 systemd 유닛을 활성화하여, 커널 업그레이드 후 재부팅 시 브로커가 자동으로 복구되도록 하십시오. 그다음 순서를 관리해야 합니다. 애플리케이션보다 20초 늦게 준비된 브로커는 초기 연결을 거부하며, 일부 클라이언트 라이브러리는 재시도 대신 종료되기도 합니다. 브로커가 준비될 때까지 종속 서비스를 대기시키는 Compose 헬스체크를 사용하여 애플리케이션의 시작 시점을 브로커에 맞추십시오.
직접 운영하는 VPS에서의 비용, 추정치가 아닌 실측값
공개된 처리량 수치는 사용자가 보유하지 않은 하드웨어, 대개 로컬 NVMe를 장착한 멀티코어 서버에서 측정된 것입니다. 이를 상한선으로 간주하고 본인의 서버에서 직접 측정하십시오.
docker stats --no-stream
free -m
sudo du -sh /var/lib/docker/volumes/*/_data브로커가 유휴 상태일 때 한 번 실행하고, 실제 트래픽을 가한 상태에서 다시 실행하십시오. 두 수치의 차이가 브로커를 애플리케이션과 함께 운영할 수 있을지 결정하는 기준이 됩니다. 대략적인 처리량 하한선을 확인하려면 다른 사람의 블로그 게시물 대신 각 프로젝트에서 제공하는 부하 생성기를 사용하십시오. NATS는 nats bench pub test --msgs 100000 --clients 2, Kafka는 bin/kafka-producer-perf-test.sh, RabbitMQ는 PerfTest를 사용합니다. 생성기를 동일한 VPS에서 실행하면 브로커와 생성기를 함께 측정하게 되는데, 수치를 보고할 때 이 점을 명시한다면 문제없습니다.
모든 브로커에 공통으로 적용되는 한계점이 하나 있습니다. 여기에 언급된 모든 내구성 옵션은 fsync를 기다려야 하므로, 네트워크 연결형 스토리지를 사용하는 VPS에서는 디스크가 성능 제한을 결정합니다. 따라서 브로커를 교체해도 이 제한은 변하지 않습니다.
세 가지 워크로드와 각 워크로드가 요구하는 메시지 큐
- 이메일 발송, 이미지 크기 조정, 웹훅 전달 등 웹 애플리케이션을 위한 백그라운드 작업. 처음에는 Postgres와
SKIP LOCKED로 시작하십시오. 메시지별 수신 확인(ack), 전달 제한, 직접 로직을 구현하지 않고도 검사할 수 있는 데드 레터 큐가 필요하거나, 작업 테이블이 데이터베이스에서 가장 부하가 높은 테이블이 되었을 때 쿼럼 큐(quorum queues)를 지원하는 RabbitMQ로 전환하십시오. - 여러 내부 서비스가 반응하며, 유실된 메시지가 새로운 메시지로 빠르게 대체되는 이벤트. 라우팅 체계로 서브젝트(subjects)를 사용하고 작업 공유가 필요할 때 큐 그룹(queue groups)을 사용하는 Core NATS를 선택하십시오. 재시작 후에도 유지되어야 하는 일부 서브젝트에는 JetStream 스트림을 추가하고, 나머지는 메모리에 두십시오.
- 감사 추적, 읽기 모델 재구축, 추후 분석 데이터 제공을 위해 소비자가 처음부터 읽어 들여야 하는 이벤트 로그. 파일 저장소와 명시적인 바이트 제한을 설정한 JetStream을 사용하십시오. Kafka 프로토콜을 요구하는 다운스트림 도구가 있을 때만 Kafka를 선택하고, 그 호환성을 위한 대가로 JVM 힙 메모리 사용을 감수하십시오.
단일 서버에서 잘못된 선택을 했을 때 발생하는 비용은 처리량(throughput)이 아닙니다. 메시지가 여전히 존재하는지 확인해야 하는 새벽 3시의 복구 과정이 바로 그 비용입니다. 복구 상황을 고려하여 선택하십시오.
FAQ
2 GB VPS에서 Kafka를 실행할 수 있습니까?
실행은 가능하지만 매우 빠듯합니다. bin/kafka-server-start.sh를 별도로 재정의하지 않으면 KAFKA_HEAP_OPTS="-Xmx1G -Xms1G"이 설정되므로, 메시지를 저장하기도 전에 JVM이 1 GB를 점유합니다. Kafka는 그 외의 여유 메모리를 페이지 캐시로 사용합니다. 같은 서버에 애플리케이션과 데이터베이스를 함께 올리면 스왑(swap)이 발생합니다. 또한 복제 계수(replication factor)가 1이 되므로, acks=all은 단일 디스크에 대한 fsync 1회로 처리됩니다. 즉, Kafka의 내구성 모델을 활용하지 못하면서 운영 비용만 지불하는 셈입니다. NATS JetStream을 사용하면 동일한 하드웨어에서 훨씬 적은 메모리로 내구성 있는 메시지 재전송이 가능합니다.
이미 Postgres를 사용 중인데 메시지 큐가 따로 필요합니까?
대개는 필요하지 않습니다. 트랜잭션 내에서 job 테이블을 SELECT ... FOR UPDATE SKIP LOCKED으로 읽으면 최소 한 번 전달(at-least-once delivery), 안전한 동시 작업자 처리, 재시도, 데드 레터 테이블 기능을 모두 구현할 수 있습니다. 별도의 서비스를 모니터링할 필요도 없고 이미 수행 중인 백업을 그대로 활용하면 됩니다. 큐를 분리해야 하는 경우는 명확합니다. 큐 테이블의 쓰기 부하가 너무 커서 autovacuum이 지연되거나, 장시간 실행되는 작업이 트랜잭션을 점유하여 데이터베이스 전체의 vacuum을 차단하거나, 다른 서비스가 동일한 이벤트를 독립적으로 소비해야 할 때입니다.
백그라운드 작업을 위해 NATS JetStream과 RabbitMQ 중 무엇을 써야 합니까?
메시지별 승인(acknowledgement), 전달 제한, 데드 레터 라우팅이 내장된 기능을 원한다면 RabbitMQ가 적합합니다. Quorum 큐는 항상 내구성을 보장하며, RabbitMQ 4.0부터 전달 제한 기본값은 20입니다. 정책을 설정하면 처리 실패한 메시지를 데드 레터 익스체인지로 보내 나중에 확인하고 처리할 수 있습니다. 반면, 동일한 이벤트를 나중에 다른 소비자가 다시 재생해야 한다면 JetStream이 적합합니다. 큐와 달리 스트림은 승인 후에도 메시지를 보관하기 때문입니다. JetStream에서는 --max-deliver를 설정하고 $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.> 권고 사항을 바탕으로 직접 데드 레터 경로를 구축해야 합니다.
소비자가 얼마나 뒤처져 있는지 어떻게 확인합니까?
각 브로커마다 전용 명령어가 있습니다. RabbitMQ의 경우 rabbitmqctl list_queues name messages messages_ready messages_unacknowledged을 사용하여 소비자를 기다리는 작업과 전달되었으나 승인되지 않은 작업을 구분할 수 있습니다. JetStream의 경우 nats consumer report <stream>을 통해 소비자별 미처리 메시지와 미승인 상태를 확인할 수 있습니다. Kafka의 경우 kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group <group>을 실행하여 파티션별 LAG 열을 확인합니다. Core NATS는 데이터를 저장하지 않으므로 읽을 백로그가 없습니다. 대신 http://localhost:8222/varz의 slow_consumers 카운터를 확인하십시오. 이 값은 서버가 처리 지연으로 인해 연결을 끊은 횟수를 나타내며, 이는 곧 메시지 유실을 의미합니다.