AMQP 0-9-1 모델 이해하기

AMQP 0-9-1 모델 이해하기

RabbitMQ가 사용하는 프로토콜인 AMQP 0-9-1을 전체적으로 살펴볼게요. 메시지가 퍼블리셔에서 익스체인지를 거쳐 큐, 그리고 컨슈머에게까지 흐르는 길을 한 번에 잡아두면, 뒤에 나오는 라우팅 키와 익스체인지 타입 이야기가 훨씬 쉽게 다가와요.

출처: AMQP 0-9-1 Model Explained | RabbitMQ

본문

AMQP 0-9-1(Advanced Message Queuing Protocol)은 메시징 브로커와 클라이언트 애플리케이션이 서로 통신할 수 있게 해 주는 메시징 프로토콜이에요. 브로커는 퍼블리셔(메시지를 내보내는 애플리케이션, 프로듀서라고도 불러요)로부터 메시지를 받아서, 이를 처리하는 컨슈머에게 라우팅해요. 네트워크 프로토콜이기 때문에 퍼블리셔·컨슈머·브로커는 서로 다른 머신에 있어도 상관없어요.

AMQP 0-9-1 모델의 세상은 이렇게 생겼어요. 메시지는 익스체인지에 게시되는데, 익스체인지는 흔히 우체국이나 사서함에 비유돼요. 익스체인지는 바인딩이라는 규칙을 이용해 메시지 사본을 들로 나눠 주고, 브로커는 큐를 구독한 컨슈머에게 메시지를 전달하거나, 컨슈머가 필요할 때 직접 가져가게 해요.

게시할 때 퍼블리셔는 다양한 메시지 속성(메타데이터)을 지정할 수 있어요. 이 중 일부는 브로커가 사용하지만, 나머지는 브로커에게 완전히 불투명하고 메시지를 받는 애플리케이션만 사용해요.

네트워크는 신뢰할 수 없고 애플리케이션은 메시지 처리를 실패할 수 있기 때문에, AMQP 0-9-1 모델에는 메시지 인정(acknowledgement) 개념이 있어요. 메시지가 컨슈머에게 전달되면 컨슈머는 자동으로, 또는 애플리케이션 개발자가 정한 때에 브로커에게 통지하는 방식이에요. 인정을 사용하면 브로커는 해당 메시지(또는 메시지 그룹)에 대한 통지를 받을 때만 큐에서 그 메시지를 완전히 제거해요.

메시지를 라우팅할 수 없는 상황에서는 메시지가 퍼블리셔에게 반환되거나, 버려지거나, 브로커가 확장을 지원한다면 이른바 "데드 레터 큐"(dead letter queue)에 들어갈 수 있어요. 퍼블리셔는 특정 파라미터로 메시지를 게시해 이런 상황을 어떻게 처리할지 선택해요.

큐·익스체인지·바인딩을 통틀어 AMQP 엔티티라고 불러요.

프로그래밍 가능한 프로토콜

AMQP 0-9-1은 그 자체로 프로그래밍 가능한 프로토콜이에요. 엔티티와 라우팅 스킴은 브로커 관리자가 아니라 애플리케이션이 정의하기 때문이에요. 그래서 큐·익스체인지를 선언하고, 그 사이의 바인딩을 정의하고, 큐를 구독하는 등의 프로토콜 연산이 마련돼 있어요.

이는 개발자에게 큰 자유를 주지만, 정의 충돌 가능성을 알고 있어야 해요. 실제로 정의 충돌은 드물고 대부분 설정 오류를 뜻해요. 애플리케이션은 필요한 엔티티를 선언하고 라우팅 스킴을 정의하며, 더 이상 쓰지 않을 때는 엔티티를 삭제할 수도 있어요.

익스체인지

익스체인지는 메시지가 전송되는 엔티티예요. 익스체인지는 메시지를 받아 0개 이상의 큐로 라우팅해요. 라우팅 알고리즘은 익스체인지 타입바인딩 규칙에 따라 달라져요. AMQP 0-9-1 브로커는 네 가지 익스체인지 타입을 제공해요.

익스체인지 타입 기본 사전 선언 이름
Direct exchange (빈 문자열) 및 amq.direct
Fanout exchange amq.fanout
Topic exchange amq.topic
Headers exchange amq.match (RabbitMQ에서는 amq.headers)

익스체인지는 타입 외에도 여러 속성과 함께 선언되는데, 가장 중요한 것은:

  • 이름
  • 내구성(익스체인지가 브로커 재시작에도 살아남는지)
  • 자동 삭제(마지막 큐가 바인딩 해제되면 익스체인지 삭제)
  • 인자(옵션, 플러그인과 브로커 특정 기능용)

익스체인지는 내구성(durable)일 수도, 일시적(transient)일 수도 있어요. 내구성 익스체인지는 브로커 재시작에도 살아남지만, 일시적 익스체인지는 그렇지 않아서 브로커가 다시 뜨면 재선언해야 해요. 모든 시나리오가 익스체인지의 내구성을 요구하는 건 아니에요.

기본 익스체인지는 이름이 없는(빈 문자열) direct 익스체인지로 브로커가 미리 선언해 둬요. 여기에는 간단한 애플리케이션에 매우 유용한 특별한 성질이 하나 있어요. 새로 만들어지는 모든 큐가, 큐 이름과 같은 라우팅 키로 기본 익스체인지에 자동으로 바인딩된다는 점이에요.

예를 들어 search-indexing-online이라는 이름으로 큐를 선언하면, 브로커는 이 큐를 라우팅 키 search-indexing-online(이 맥락에서는 바인딩 키라고도 불러요)으로 기본 익스체인지에 바인딩해요. 그래서 라우팅 키가 search-indexing-online인 메시지를 기본 익스체인지에 게시하면 큐 search-indexing-online으로 라우팅돼요. 즉, 기본 익스체인지는 기술적으로는 그게 아니지만, 마치 큐에 직접 메시지를 전달하는 것처럼 보이게 해 줘요. RabbitMQ의 기본 익스체인지는 바인드/언바인드 연산을 허용하지 않아요. 기본 익스체인지에 대한 바인딩 연산은 오류로 이어져요.

Direct 익스체인지는 메시지 라우팅 키를 기준으로 큐에 메시지를 전달해요. 유니캐스트 라우팅에 이상적이고, 멀티캐스트에도 쓸 수 있어요. 동작 방식은 이래요.

  • 큐는 라우팅 키 K로 익스체인지에 바인딩돼요
  • 라우팅 키가 R인 새 메시지가 direct 익스체인지에 도착하면, K = R일 때 익스체인지는 그 큐로 메시지를 라우팅해요
  • 여러 큐가 같은 라우팅 키 K로 바인딩되어 있으면, 익스체인지는 K = R인 모든 큐로 메시지를 라우팅해요

Fanout 익스체인지는 바인딩된 모든 큐로 메시지를 라우팅하고, 라우팅 키는 무시돼요. N개의 큐가 fanout 익스체인지에 바인딩되어 있으면, 새 메시지가 게시될 때 그 메시지 사본이 모든 큐에 전달돼요. 브로드캐스트 라우팅에 이상적이죠. 대표적인 쓰임새는:

  • MMO 게임의 리더보드 업데이트나 전역 이벤트
  • 스포츠 뉴스 사이트가 모바일 클라이언트에 점수 업데이트를 거의 실시간으로 배포
  • 분산 시스템의 상태·설정 변경 브로드캐스트
  • 그룹 채팅에서 참가자 간 메시지 배포(단, AMQP에는 presence 개념이 내장돼 있지 않아 XMPP가 더 나을 수 있어요)

Topic 익스체인지는 메시지 라우팅 키와, 큐가 익스체인지에 바인딩될 때 쓴 패턴 사이의 매칭으로 하나·여러 큐에 라우팅해요. 다양한 publish/subscribe 패턴 변형을 구현하는 데 자주 쓰이고, 멀티캐스트 라우팅에 흔히 사용돼요. 특정 지리적 위치(예: POS) 관련 데이터 배포, 각각 특정 작업 집합을 처리하는 워커들의 백그라운드 태스크 처리, 주식 가격 업데이트, 카테고리·태그 기반 뉴스 업데이트, 클라우드에서 서로 다른 종류의 서비스 오케스트레이션, 아키텍처/OS별 빌드·패키징 등에 활용돼요.

Headers 익스체인지는 라우팅 키보다 메시지 헤더로 더 쉽게 표현되는 여러 속성으로 라우팅하도록 설계됐어요. 헤더 익스체인지는 라우팅 키 속성을 무시하고, 대신 헤더 속성에서 라우팅에 쓸 속성을 가져와요. 바인딩 시 지정한 값과 헤더 값이 같으면 메시지가 매칭된 것으로 간주해요.

하나의 큐를 헤더 익스체인지에 여러 헤더로 바인딩해 매칭할 수도 있어요. 이 경우 브로커는 애플리케이션 개발자에게 "헤더 중 하나만 매칭돼도 되는지, 아니면 모두 매칭돼야 하는지"를 알려달라고 해요. 이때 쓰는 게 x-match 바인딩 인자예요. x-matchany로 두면 헤더 값 하나만 매칭되면 충분하고, all로 두면 모든 값이 매칭돼야 해요. anyall에서는 x-로 시작하는 헤더는 매칭 평가에 쓰이지 않아요. x-matchany-with-xall-with-x로 두면 x-로 시작하는 헤더도 매칭에 사용돼요. 헤더 익스체인지는 "direct 익스체인지의 강화판"이라고 볼 수 있어요. 헤더 값으로 라우팅하니까, 라우팅 키가 문자열일 필요가 없고 정수나 해시(딕셔너리)가 될 수도 있거든요.

AMQP 0-9-1 모델의 는 다른 메시지·태스크 큐 시스템의 큐와 매우 비슷해요. 애플리케이션이 소비하는 메시지를 저장해요. 큐는 익스체인지와 속성을 일부 공유하지만 추가 속성도 있어요.

  • 이름
  • 내구성(큐가 브로커 재시작에도 살아남는지)
  • 독점성(한 연결만 사용하고, 연결이 닫히면 큐가 삭제됨)
  • 자동 삭제(컨슈머가 한 번이라도 있었던 큐는 마지막 컨슈머가 구독 해제할 때 삭제)
  • 인자(옵션, 메시지 TTL·큐 길이 제한 같은 플러그인·브로커 특정 기능 용)

큐를 사용하려면 먼저 선언해야 해요. 선언하면 큐가 아직 없을 때는 생성돼요. 큐가 이미 존재하는데 속성이 선언과 같으면 선언은 아무 효과가 없고, 속성이 다르면 406(PRECONDITION_FAILED) 코드의 채널 레벨 예외가 발생해요.

애플리케이션은 큐 이름을 직접 고르거나 브로커가 생성해 주게 할 수 있어요. 큐 이름은 UTF-8 기준 최대 255바이트예요. 브로커가 대신 고유 이름을 생성하게 하려면 큐 이름 인자로 빈 문자열을 넘기면 되고, 생성된 이름은 큐 선언 응답과 함께 클라이언트에 반환돼요.

amq.로 시작하는 큐 이름은 브로커 내부용으로 예약되어 있어요. 이 규칙을 위반하는 이름으로 큐를 선언하면 403(ACCESS_REFUSED) 응답 코드의 채널 레벨 예외가 발생해요.

AMQP 0-9-1에서 큐는 내구성 또는 일시적(transient)으로 선언돼요. 내구성 큐의 메타데이터는 디스크에 저장되고, 일시적 큐의 메타데이터는 가능하면 메모리에 저장돼요. 게시 시점의 메시지에도 같은 구분이 적용돼요. 내구성이 중요한 환경에서는 애플리케이션이 내구성 큐를 사용하면서 퍼블리셔가 게시한 메시지를 영속(persisted)으로 표시하도록 해야 해요. 상세 내용은 큐 가이드에서 다뤄요.

바인딩과 라우팅 키

바인딩은 익스체인지가 (다른 것도 있지만) 메시지를 큐로 라우팅할 때 쓰는 규칙이에요. 익스체인지 E가 큐 Q로 메시지를 라우팅하게 하려면, Q가 E에 바인딩되어야 해요. 바인딩에는 일부 익스체인지 타입이 사용하는 선택적 라우팅 키 속성이 있을 수 있어요. 라우팅 키의 목적은 익스체인지에 게시된 메시지 중 일부를 골라 바인딩된 큐로 라우팅하는 것이에요. 다시 말해, 라우팅 키는 필터처럼 작동해요.

비유를 들면:

  • 큐는 뉴욕에 있는 목적지
  • 익스체인지는 JFK 공항
  • 바인딩은 JFK에서 목적지까지의 경로 (여러 갈래가 있을 수 있어요)

이런 간접 계층 덕분에, 큐에 직접 게시하는 방식으로는 구현하기 어렵거나 불가능한 라우팅 시나리오가 가능해지고, 개발자가 해야 할 중복 작업도 줄어들어요. 메시지를 어떤 큐로도 라우팅할 수 없으면(예: 게시된 익스체인지에 바인딩이 없음) 퍼블리셔가 설정한 메시지 속성에 따라 버려지거나 퍼블리셔에게 반환돼요.

컨슈머와 인정

큐에 메시지를 저장해도 애플리케이션이 소비할 수 없다면 무용지물이에요. AMQP 0-9-1 모델에서 애플리케이션이 이를 하는 방법은 두 가지예요.

  • 구독해서 메시지를 전달받기("push API") — 이 방식이 권장돼요
  • 폴링("pull API") — 대부분의 경우 매우 비효율적이라 피해야 해요

push API에서는 애플리케이션이 특정 큐에서 메시지를 소비하겠다는 관심을 표시해요. 이걸 컨슈머 등록 또는 큐 구독이라고 해요. 큐당 컨슈머가 여러 명일 수 있고, 다른 컨슈머를 모두 배제하는 독점 컨슈머도 등록할 수 있어요. 각 컨슈머(구독)에는 컨슈머 태그라는 식별자가 있는데, 메시지 구독 해제에 쓰이고 그냥 문자열이에요.

컨슈머 애플리케이션은 개별 메시지 처리를 실패하거나, 서버와 연결이 끊기거나, 여러 방식으로 실패할 수 있어요. 그럼 브로커는 언제 큐에서 메시지를 제거해야 할까요? AMQP 0-9-1 명세는 이 제어권을 컨슈머에게 줘요. 인정 모드는 두 가지예요.

  • 브로커가 애플리케이션에 메시지를 보낸 뒤(basic.deliver 또는 basic.get-ok 메서드 사용)
  • 애플리케이션이 인정(basic.ack 메서드)을 돌려보낸 뒤

전자를 자동 인정 모델, 후자를 명시적 인정 모델이라고 불러요. 명시적 모델에서는 애플리케이션이 인정을 보낼 시점을 스스로 정해요. 메시지를 받은 직후일 수도, 처리 전에 데이터 스토어에 저장한 뒤일 수도, 메시지를 완전히 처리한 뒤(예: 웹 페이지를 가져와 처리하고 영속 스토어에 저장)일 수도 있어요.

컨슈머가 인정을 보내지 않고 죽으면, 브로커는 메시지를 다른 컨슈머에게 재전달하고, 그때 가능한 컨슈머가 없으면 같은 큐에 컨슈머가 하나 이상 등록될 때까지 기다렸다가 재전달을 시도해요.

컨슈머가 메시지를 받았지만 처리가 실패하거나 당장 처리할 수 없으면, 메시지를 *거부(reject)*해서 브로커에 알릴 수 있어요. 거부할 때 애플리케이션은 브로커에게 버리거나 재큐잉(requeue)하도록 요청할 수 있어요. 큐에 컨슈머가 하나뿐이라면, 같은 컨슈머가 메시지를 계속 거부하고 재큐잉해서 무한 전달 루프를 만들지 않도록 조심해야 해요.

메시지는 basic.reject 메서드로 거부해요. basic.reject에는 제한이 하나 있는데, 인정처럼 한 번에 여러 메시지를 거부할 수 없다는 점이에요. 하지만 RabbitMQ를 쓴다면 해결책이 있어요. RabbitMQ는 부정 인정(negative acknowledgements) 또는 nack이라 불리는 AMQP 0-9-1 확장을 제공해요. 자세한 내용은 Confirmationsbasic.nack 확장 가이드를 참고하세요.

여러 컨슈머가 큐를 공유할 때는, 다음 인정을 보내기 전에 각 컨슈머가 한 번에 몇 개까지 메시지를 받을 수 있는지 지정하는 게 유용해요. 이는 간단한 로드 밸런싱 기법이거나, 메시지가 배치로 게시되는 경향이 있을 때 처리량을 높이는 용도로 쓸 수 있어요. RabbitMQ는 채널 레벨의 prefetch-count만 지원하고, 연결·크기 기반 prefetch는 지원하지 않는다는 점에 주의하세요.

메시지와 채널·가상 호스트

AMQP 0-9-1 모델의 메시지에는 속성이 있어요. 일부 속성은 매우 흔해서 AMQP 0-9-1 명세가 정의해 두고 개발자가 정확한 속성명을 고민하지 않게 해줘요. 예를 들면:

  • Content type
  • Content encoding
  • Routing key
  • Delivery mode (영속 여부)
  • Message priority
  • Message publishing timestamp
  • Expiration period
  • Publisher application id

일부 속성은 AMQP 브로커가 사용하지만 대부분은 메시지를 받는 애플리케이션이 해석하기 나름이에요. 일부 속성은 옵션으로 헤더라고 불러요. HTTP의 X-Headers와 비슷하죠. 메시지 속성은 게시할 때 설정돼요.

메시지에는 페이로드(담고 있는 데이터)도 있어요. 브로커는 페이로드를 불투명한 바이트 배열로 취급해서 검사하거나 수정하지 않아요. 속성만 있고 페이로드가 없는 메시지도 가능해요. 구조화된 데이터를 JSON·Thrift·Protocol Buffers·MessagePack 같은 직렬화 형식으로 만들어 메시지 페이로드로 게시하는 게 일반적이에요. 프로토콜 피어들은 관례상 "content-type"과 "content-encoding" 필드로 이 정보를 전달하지만, 그건 관례일 뿐이에요.

메시지는 영속(persistent)으로 게시될 수 있는데, 그러면 브로커가 디스크에 저장해요. 서버가 재시작되어도 받은 영속 메시지는 유실되지 않음이 보장돼요. 하지만 메시지를 내구성 익스체인지에 게시하거나 라우팅되는 큐가 내구성이라고 해서 메시지가 영속이 되는 건 아니에요. 메시지 자체의 영속 모드에 달려 있어요. 영속으로 게시하면 데이터 스토어처럼 내구성에 성능 비용이 따르는 것처럼 성능에 영향을 줘요. 자세한 내용은 Publishers 가이드를 참고하세요.

AMQP 0-9-1은 여러 메서드로 구성돼요. 메서드는 (HTTP 메서드처럼) 연산이고, OOP 언어의 메서드와는 상관없어요. 프로토콜 메서드는 클래스로 묶이는데, 클래스는 AMQP 0-9-1 메서드의 논리적 그룹일 뿐이에요. 예를 들어 exchange 클래스는 익스체인지 연산 관련 메서드 그룹으로, 다음 연산을 포함해요.

  • exchange.declare
  • exchange.declare-ok
  • exchange.delete
  • exchange.delete-ok

위 연산은 논리적 쌍을 이뤄요. exchange.declareexchange.declare-ok, exchange.deleteexchange.delete-ok처럼요. 이 연산은 "요청"(클라이언트가 보냄)과 "응답"(브로커가 보냄)으로 나뉘어요. 예를 들어 클라이언트는 exchange.declare 메서드로 브로커에게 새 익스체인지를 선언해 달라고 요청해요.

AMQP 0-9-1 연결은 보통 오래 지속돼요. AMQP 0-9-1은 신뢰할 수 있는 전달을 위해 TCP를 사용하는 애플리케이션 레벨 프로토콜이에요. 연결은 인증을 사용하고 TLS로 보호할 수 있어요. 애플리케이션이 더 이상 서버에 연결할 필요가 없으면, TCP 연결을 갑자기 닫기보다 AMQP 0-9-1 연결을 우아하게(gracefully) 종료해야 해요.

어떤 애플리케이션은 브로커에 여러 연결이 필요해요. 하지만 TCP 연결을 동시에 많이 열어 두는 건 시스템 리소스를 소비하고 방화벽 설정을 어렵게 만들기 때문에 바람직하지 않아요. AMQP 0-9-1 연결은 채널로 다중화돼요. 채널은 "하나의 TCP 연결을 공유하는 경량 연결"로 생각할 수 있어요.

클라이언트가 수행하는 모든 프로토콜 연산은 채널에서 일어나요. 특정 채널의 통신은 다른 채널과 완전히 분리되므로, 모든 프로토콜 메서드는 채널 ID(채널 번호)도 함께 운반해요. 브로커와 클라이언트는 이 정수로 메서드가 어느 채널 것인지 알아내죠. 채널은 연결의 맥락에서만 존재하고, 연결이 닫히면 그 연결의 모든 채널도 닫혀요. 여러 스레드/프로세스로 처리하는 애플리케이션에서는 스레드/프로세스마다 새 채널을 열고 채널을 공유하지 않는 게 일반적이에요.

브로커 하나가 여러 개의 분리된 "환경"(사용자·익스체인지·큐 등의 그룹)을 호스팅할 수 있게, AMQP 0-9-1에는 가상 호스트(vhost) 개념이 있어요. 많은 웹 서버가 쓰는 가상 호스트와 비슷하며, AMQP 엔티티가 살아가는 완전히 격리된 환경을 제공해요. 프로토콜 클라이언트는 연결 협상 중에 어떤 vhost를 쓸지 지정해요.

AMQP 0-9-1에는 몇 가지 확장 지점이 있어요.

  • 커스텀 익스체인지 타입 — 개발자가 기본 제공 타입이 잘 다루지 못하는 라우팅 스킴(예: 지리 데이터 기반 라우팅)을 구현할 수 있어요
  • 익스체인지·큐 선언에 브로커가 사용할 추가 속성을 포함 — RabbitMQ의 큐별 메시지 TTL이 이런 방식으로 구현돼요
  • 브로커 특정 프로토콜 확장 — RabbitMQ가 구현하는 확장 참고
  • 특정 경우(예: OAuth 2 JWT 토큰 갱신)를 위한 새 AMQP 0-9-1 메서드 클래스 추가
  • 추가 플러그인으로 브로커 확장 — RabbitMQ 관리 프론트엔드와 HTTP API도 플러그인으로 구현돼 있어요

이런 기능들 덕분에 AMQP 0-9-1 모델은 더 유연해지고 아주 넓은 문제에 적용 가능해요. 많은 언어·플랫폼에 다양한 AMQP 0-9-1 클라이언트가 있어요. 일부는 AMQP 용어를 그대로 따라 AMQP 메서드 구현만 제공하고, 일부는 추가 기능·편의 메서드·추상화를 제공해요. 어떤 클라이언트는 비동기(논블로킹), 어떤 건 동기(블로킹), 어떤 건 둘 다 지원해요. 일부는 벤더 특정 확장(RabbitMQ 특정 확장)을 지원하기도 해요.

AMQP의 주요 목표 중 하나가 상호운용성이기 때문에, 개발자는 특정 클라이언트 라이브러리의 용어에 한정되지 않고 프로토콜 연산을 이해하는 게 좋아요. 그래야 다른 라이브러리를 쓰는 개발자와도 훨씬 쉽게 소통할 수 있거든요.

더 알아보기