LabHub
시작하기
배우기 러닝패스 코스

EAI 중간 계층 만들기

큐는 잃지 않지만 두 번 준다

LabHub 에서 이어서 보기

한 줄 요약

비동기 연동은 "지금 처리해 주세요" 가 아니라 "잃어버리지 말고 나중에 처리해 주세요" 를 약속한다. 큐는 그 약속을 지키는 장치이지만, 약속이 성립하려면 발행 쪽은 브로커가 받았다는 확인(publisher confirms)을, 소비 쪽은 처리한 뒤에만 보내는 확인(수동 ack)을 해야 하고 — 그 대가로 같은 메시지가 두 번 올 수 있다.

왜 이게 필요했나

보험 청구 접수를 생각해 보자. 고객이 앱에서 청구서를 올리면 채널은 즉시 "접수됐습니다" 를 보여 주고 싶다. 그런데 청구 시스템은 서류 검증 때문에 건당 몇 초가 걸리고, 월말에는 몇 시간씩 밀린다. 동기로 부르면 채널이 청구 시스템의 속도에 묶이고, 청구 시스템이 잠깐 내려가면 접수 자체가 실패한다. 큐를 사이에 두면 채널은 큐에 넣는 순간 답할 수 있고, 청구 시스템은 자기 속도로 꺼내 간다. 한쪽이 멈춰도 다른 쪽은 계속 일한다.

그 대신 새로운 질문이 생긴다. 넣었다는 것을 어떻게 아는가? 꺼내 간 쪽이 처리 도중 죽으면 메시지는 어디로 가는가? 처리할 수 없는 메시지(서류 미비)는 영원히 큐를 도는가? 청구 시스템이 느리면 소비자는 몇 건까지 떠안아야 하는가? 이 모듈은 이 질문들에 하나씩 답한다.

어떻게 동작하나

AMQP 0-9-1 의 세 부품. 발행자는 큐가 아니라 교환기(exchange) 로 보낸다. 교환기는 라우팅 키와 바인딩을 보고 메시지를 큐에 넣는다. direct 교환기는 라우팅 키가 바인딩 키와 정확히 같은 큐로 보낸다(AMQP 개념). 발행자가 큐 이름을 모르게 하는 이 한 단계 덕분에, 나중에 감사용 큐를 하나 더 묶어도 발행자는 고칠 것이 없다.

durable 과 persistent 는 다르다. durable 큐는 브로커가 재기동해도 큐 정의가 남는다. 메시지가 남으려면 발행할 때 persistent(delivery_mode=2)로 보내야 한다. 둘 중 하나만 하면 재기동 뒤에 큐는 있는데 비어 있다.

발행 확인(publisher confirms). basic_publish 가 오류 없이 돌아왔다고 브로커가 받은 것은 아니다. 채널을 확인 모드로 두면 브로커가 메시지마다 ack 를 돌려주고, 문서에 따르면 durable 큐로 가는 persistent 메시지는 디스크에 쓴 뒤에 확인한다(Confirms). 라우팅할 곳이 없는 메시지는 조용히 버려지는데, mandatory 로 보내면 브로커가 ack 보다 먼저 basic.return 으로 돌려준다. pika 의 BlockingChannel 은 이것을 UnroutableError 로 알려 준다.

수동 ack. 자동 ack 모드에서는 브로커가 메시지를 보내는 순간 전달이 끝난 것으로 치므로, 소비자가 처리 도중 죽으면 그 메시지는 사라진다. 수동 ack 에서는 소비자가 처리를 끝내고 ack 를 보내야 끝난다. ack 하지 않은 채 채널이 닫히면 브로커는 그 메시지를 자동으로 되돌리고, 다시 줄 때 redelivered 표시를 붙인다(같은 문서). 이것이 최소 한 번(at-least-once) 전달이고, 뒤집어 말하면 처리는 끝났는데 ack 직전에 죽은 메시지가 한 번 더 온다. 큐는 멱등을 주지 않는다. 소비자가 message_id 로 처리 기록을 남겨 걸러야 한다(8모듈의 원장과 같은 생각).

거절은 두 종류다. 서류 미비(업무 거절)는 백 번 다시 줘도 결과가 같다. 이것을 되돌리면(requeue) 같은 메시지가 큐를 무한히 돈다 — 흔히 독 메시지(poison message)라고 부른다. basic.reject(또는 nack)에 requeue=False 를 주면 브로커는 메시지를 버리거나, 큐에 데드레터 교환기(DLX) 가 지정돼 있으면 거기로 다시 발행한다. 큐 인자 x-dead-letter-exchange·x-dead-letter-routing-key 로 지정하고, 다시 발행된 메시지에는 x-death 헤더에 이유(rejected·expired·maxlen·delivery_limit)가 남는다(DLX). 반면 일시 오류(청구 시스템 503)는 조금 뒤에 다시 하면 된다. 이 실습에서는 한 번 되돌리고, redelivered 인데도 또 실패하면 DLQ 로 보낸다. (문서는 운영에서 인자보다 정책(policy) 으로 DLX 를 거는 것을 권한다 — 다시 배포하지 않고 바꿀 수 있어서다.)

prefetch 는 백프레셔다. 소비자 채널의 basic.qos(prefetch_count) 는 ack 하지 않은 채 떠안을 수 있는 최대 건수다. 0(무제한)이면 브로커는 큐의 메시지를 전부 소비자에게 밀어 넣는다. 청구 시스템이 느리면 소비자 메모리에 수천 건이 쌓이고, 소비자를 하나 더 띄워도 이미 다 가져간 뒤라 나눌 것이 없다. 한도를 두면 나머지는 큐에 남아 새 소비자가 나눠 갖는다.

브로커도 자기를 지킨다. 메모리가 경보 기준을 넘으면 브로커는 발행하는 연결을 전부 막고, 소비가 진행돼 메모리가 내려가면 풀어 준다(메모리 경보). 기준의 기본값은 감지한 메모리에 대한 비율(현재 문서 기준 0.6)인데, 문서는 컨테이너에서 브로커가 cgroup 한도를 항상 알아내지는 못한다고 경고하고 절대값을 권한다. 실제로 이 과정에서 잰 결과, 8GB 기계의 2Gi 컨테이너 안에서 우분투 패키지의 3.12 브로커는 기준을 3.3GB 로 잡았다 — 경보가 울리기 전에 컨테이너가 OOM 으로 죽는다는 뜻이다. 그래서 이 실습의 도우미는 vm_memory_high_watermark.absolute = 512MiB 로 띄운다.

현장에서 만나는 모습

가장 흔한 사고는 자동 ack 로 짠 소비자가 배포 중 재기동하면서 처리 중이던 메시지를 잃는 것이다. 로그에는 아무것도 없다. 두 번째는 독 메시지 — 형식이 틀린 메시지 하나가 무한 재배달되며 소비자 CPU 를 먹고, 그 뒤의 정상 메시지가 몇 시간 밀린다. 세 번째는 "MQ 에 넣었으니 안전하다" 는 믿음으로 발행 확인을 안 하는 것이다. 브로커가 메모리 경보로 발행을 막고 있는데 발행자는 타임아웃만 보고 재시도하고, 그 사이 무엇이 들어갔는지 아무도 모른다. 네 번째는 DLQ 를 만들어 놓고 아무도 안 보는 것 — DLQ 에는 감시와 재처리 절차가 짝으로 있어야 한다.

다음 실습에서 할 것

파드 안에서 RabbitMQ 를 띄우고(도우미), 교환기·큐·DLX 토폴로지를 선언하고, 발행 확인을 받는 발행자를 만든다. 그다음 소비자를 차례로 키운다 — 수동 ack, 업무 거절의 DLQ, 일시 오류의 한 번 재배달, prefetch, message_id 로 중복 거르기. 채점기는 학생 큐를 건드리지 않도록 임시 vhost 를 만들어 당신의 스크립트를 거기서 돌린다(그래서 모든 스크립트가 EAI_AMQP_URL 을 읽는다).