Adventures in message queues

Salvatore Sanfilippo

메시지 큐 모험기

원문은 Salvatore Sanfilippo님이 에 게재했습니다. 이 블로그 구독하기

편집자 주: 혹시 놓치셨다면, Disque 소스 코드는 현재 http://github.com/antirez/disque에서 보실 수 있습니다.

몇 달째 제 시간의 15~20% 정도를, 대부분 밤과 주말에서 쪼개낸 시간으로 새로운 시스템 작업에 쓰고 있습니다. 메시지 브로커인데 이름은 Disque입니다. 원래 명세에 있던 내용의 80% 정도는 이미 구현했지만 아직 릴리스할 준비가 됐다고 느껴지진 않습니다. 배포할 수 없으니 최소한 글로라도 남기려 합니다… 그래서 이 글에서는 어떻게 시작됐는지, 그리고 이 시스템이 무엇인지에 대해 조금 이야기해보려 합니다.

~ 첫걸음 ~

많은 개발자들이 Redis를 메시지 큐로 활용합니다. 대개는 Redis의 저수준 프리미티브를 추상화한 라이브러리로 감싸서 쓰거나, 때로는 Redis의 로우 API를 이용해 단순한 임시 큐를 직접 만들기도 합니다. 이 유스케이스는 주로 블로킹 리스트 연산과 리스트 push 연산으로 구현됩니다. 얼핏 보면 Redis는 이런 용도로 쓰기에 최고의 시스템이자 동시에 최악의 시스템이기도 합니다. 빠르다는 점, 들여다보기 쉽고 배포와 사용이 간편하다는 점, 그리고 이미 많은 환경에서 인프라의 일부로 자리 잡고 있다는 점에서는 훌륭합니다. 하지만 단점도 분명합니다. Redis의 가변 데이터 구조는 불변인 메시지와는 본질적으로 다르기 때문입니다. Redis의 HA/클러스터 트레이드오프는 크고 가변적인 값을 다루는 데 완전히 최적화되어 있는데, 같은 트레이드오프가 메시지를 다루는 데는 최선이 아닙니다.

메시지 브로커에서 반드시 보장해야 할 것 중 하나는 메시지가 ‘적어도 한 번(at least once)’ 혹은 ‘최대 한 번(at most once)’ 전달된다는 점입니다. 간단히 말해 메시지의 ‘정확히 한 번(exactly once)’ 전달을 보장하는 것 — 여기서 전달이란 워커가 메시지를 받아 *처리까지* 완료한 것을 의미합니다 — 은 사실상 불가능하므로, 브로커가 보장할 수 있는 선택지는 ‘0번 또는 1번’ 전달과 ‘1번에서 무한대’ 전달 중 하나입니다. 이는 흔히 at-most-once 시맨틱과 at-least-once 시맨틱이라고 불립니다. 전자에 대한 유스케이스도 있지만, 더 흥미롭고 실용적인 쪽은 후자입니다. 즉, 메시지가 최소 한 번은 전달되도록 보장하고, 장애가 발생하면 여러 번 전달하는 방식입니다.

그래서 몇 달 전, 복제나 클러스터링 없이 여러 대의 Redis 마스터를 이용해 이런 보장을 제공하는 클라이언트 측 프로토콜을 고민하기 시작했습니다. 때로는 Redis를 사용하는 방식을 조금만 바꿔도 훨씬 나은 시스템을 만들 수 있습니다. 예컨대 분산 락의 경우, 싱글 인스턴스+페일오버 방식보다 구현은 간단하면서도 더 견고한 알고리즘을 문서화해보려 한 적이 있습니다(http://redis.io/topics/distlock).

하지만 며칠 작업해보니 설계 초안은 차라리 전용 시스템을 새로 만드는 쪽이 더 낫다는 것을 시사했습니다. 클라이언트 측 알고리즘은 너무 복잡하고 비효율적이었으며, 제가 꼭 넣고 싶었던 몇 가지 기능은 구현이 불가능하거나 매우 어려웠기 때문입니다. Redis에 기능을 더 추가하는 것도 나쁜 아이디어처럼 보였습니다. Redis는 이미 많은 일을 하고 있고, 메시징을 제대로 다루려면 Redis의 동작 방식과는 전혀 다른 것들이 필요했습니다. 그런데 세상에 메시지 브로커가 넘쳐나는데 왜 또 새로운 시스템을 만들어야 할까요? 놀랍도록 많은 사용자가 이 목적을 위해 특별히 설계된 시스템 대신 Redis를 쓰고 있었고, 그 사실 자체가 이상했습니다. 소수가 잘못 선택할 수는 있지만, 이렇게 많은 사람이 같은 선택을 한다면 이유가 있을 겁니다. 어쩌면 Redis가 가진 낮은 진입 장벽, 쉬운 API, 속도가 메시지 브로커 생태계를 둘러봤을 때 사람들이 익숙하게 접하던 것과는 달랐기 때문일지도 모릅니다. 그 생태계는 필요 이상으로 단순해 애플리케이션에 너무 많은 일을 떠넘기는 솔루션과, 지나치게 복잡하지만 기능은 엄청나게 많은 솔루션으로 양분된 것처럼 보입니다. 어쩌면 ‘메시징계의 Redis’가 들어갈 틈이 있지 않을까요?

~ Redis를 가차 없이 포크하다 ~

살면서 처음으로 바로 코드부터 작성하지 않았습니다. 몇 주 동안 틈틈이 설계를 들여다보며, 그것을 Redis 클라이언트 라이브러리가 아닌 완전히 새로운 시스템으로 전환했고, 사용자 입장에서 메시지 브로커에 무엇이 있다면 정말 만족스러울지 고민했습니다. 원래 유스케이스는 그대로 유지됐습니다. 지연 작업(delayed jobs)이었죠. Disque는 범용 시스템이지만 설계 과정에서 90%는 ‘처리해야 할 작업일 가능성이 높은 메시지를 보내야 하는 사용자’를 기준 삼았습니다. 이 유스케이스에 어긋나는 것은 과감히 제거했습니다.

설계가 준비되자 마침내 코딩을 시작했습니다. 하지만 어디서부터 시작할까요? “vi main.c”부터? 다행히 Redis는 어느 정도 C로 분산 시스템을 작성하기 위한 프레임워크이기도 합니다. 프로토콜, 네트워크 라이브러리, 클라이언트 처리, 노드 간 메시지 버스까지 이미 갖추고 있었습니다. 이 모든 것을 처음부터 다시 쓰는 것은 엄청난 낭비처럼 느껴졌습니다. 동시에 필요하다면 Disque가 세부적인 부분에서 Redis와 완전히 다른 길로 갈 수 있길 바랐고, Redis 자체에는 영향을 주지 않는 사이드 프로젝트로 유지하고 싶었습니다. 그래서 Redis를 실제 프레임워크 부분과 Redis 구현 부분으로 분리하는 대공사를 시도하는 대신, 더 실용적인 접근을 택했습니다. 코드를 포크한 뒤 소스 코드에서 Redis 특유의 부분을 모두 제거해 뼈대만 남긴 것입니다. 이제 명세를 구현할 준비가 됐습니다.

~ Disque란 무엇인가? ~

몇 달 동안 그리 집중하지 않은 채 200번 정도의 커밋을 거친 끝에, 마침내 장nan감처럼 보이지 않는 시스템이 나왔습니다. 몇 주 동안은 정말 장난감처럼 보였기에 이야기하는 것조차 두려웠습니다. 언제 소스 트리를 통째로 지워버릴지 모른다는 생각이 들 정도였으니까요. 이제 대부분의 아이디어가 테스트를 갖춘 동작 코드가 되었고, 앞으로 반드시 릴리스될 것이라는 확신이 생겼으며, 설계에서 어떤 트레이드오프를 택했는지 이야기할 수 있게 됐습니다.

Disque는 기본적으로 분산 시스템입니다. AP 시스템인 만큼 Redis처럼 싱글 노드 모드와 분산 모드를 따로 두는 것은 의미가 없었습니다. 단일 Disque 노드는 그저 노드가 하나인 클러스터의 특수한 경우일 뿐입니다. 그래서 설계에서 중요한 지점 중 하나는 장애 허용(fault tolerant), 파티션에 대한 내성, 그리고 몇 개의 노드가 살아 있든 상관없이 가용성을 유지한다는 것, 즉 AP였습니다. 또한 본질적으로 다양한 시나리오에서 확장 가능한 시스템을 원했습니다. 많은 프로듀서와 컨슈머가 수많은 큐를 사용하는 경우든, 반대로 모든 프로듀서와 컨슈머가 여러 노드에 분산될 수 있는 단일 큐에 집중하는 경우든 모두 잘 동작해야 했습니다.

제 요구사항은 한 가지를 아주 분명하게 말하고 있었습니다… Disque가 큰 설계적 희생을 치러야 한다는 것이었죠. 메시지 순서입니다. Disque는 최선 노력(best-effort) 순서만 제공합니다. 하지만 이 희생 덕분에 얻을 수 있는 것도 많습니다… 트레이드오프는 때때로 설계 공간을 완전히 열어주니까요.

이런 식으로 Disque가 무엇인지 계속 설명할 수도 있겠지만, 몇 달 전 Hacker News에서 Jacques Chester가 남긴 댓글을 봤습니다. https://news.ycombinator.com/item?id=8709146 [편집자 주: 죄송합니다, Adrian의 이름을 잘못 복사해 붙여넣는 실수를 했습니다 (Adrian, 잘못 인용해서 미안합니다!)]를 참고하세요. 저처럼 Pivotal에서 일하는 Jacques는 서로 다른 메시징 시스템들이 기능과 속성이 매우 다르고, 세부 사항을 알지 못하면 서로 다른 선택지를 평가하는 것이나 한 시스템이 더 빠른 이유가 구현이 더 좋아서인지 아니면 단순히 보장하는 것이 훨씬 적어서인지 판단하는 것이 거의 불가능하다고 지적했습니다. 그래서 그는 메시징 시스템을 평가할 때 물어봐야 할 질문 목록을 작성했습니다. 그의 질문에 몇 가지를 더해 Disque가 무엇인지 설명해보려 합니다. 그저 얼버무리는 것이 아니라 실질적인 정보를 드리고 싶어서입니다.

Q: 메시지는 최소 한 번 전달되나요?

Disque에서는 최소 한 번 전달(기본값) 혹은 최대 한 번 전달 중 선택할 수 있습니다. 이 속성은 메시지마다 설정할 수 있습니다. 최대 한 번 전달은 최소 한 번 전달의 특수한 경우로, 메시지의 “retry” 파라미터를 0으로 설정하고 단일 노드에만 복제하는 방식입니다.

Q: 메시지는 컨슈머가 확인(acknowledge)하나요?

네, 컨슈머가 메시지를 올바르게 전달받았다고 시스템에 알리는 유일한 방법은 이를 확인 응답하는 것입니다.

Q: 확인되지 않은 메시지는 여러 번 전달되나요?

네, Disque는 “retry” 시간이 지나면 메시지를 자동으로 다시 전달하며, 이는 (메시지의 최대 TTL까지) 무한히 반복될 수 있습니다. 메시지가 확인되면 그 확인 정보는 메시지의 복사본을 가진 노드들에 전파됩니다. 시스템이 모든 노드에 도달했다고 판단하면 메시지는 최종적으로 가비지 컬렉션되어 제거됩니다. 확인된 메시지는 메모리 압박 상황에서도 제거됩니다.

노드들은 동일한 메시지를 여러 번 큐에 넣지 않도록 최선을 다하는 알고리즘을 실행해, 단일 전달에 가깝게 근사시키려 합니다. 그러나 장애 상황에서는 여러 노드가 동시에 같은 메시지를 여러 번 재전달할 수 있습니다.

Q: 큐잉은 영속적인가요, 아니면 휘발성인가요?

영속적입니다.

Q: 영속성은 모든 메시지를 먼저 디스크에 쓰는 방식으로 달성되나요, 아니면 서버 간 복제를 통해 달성되나요?

기본적으로 Disque는 메모리 전용으로 동작하며 동기식 복제를 통해 영속성을 달성합니다(다만 메시지마다 비동기 복제를 요청할 수도 있습니다). 전체 재시작 같은 상황을 겪을 가능성이 있다면 Redis와 유사한 AOF를 켤 수 있습니다. 시스템을 업그레이드할 때는 평소에 디스크 영속성을 사용하지 않더라도 재시작 후 상태를 잃지 않도록 업그레이드 동안만 AOF를 디스크에 기록할 수 있습니다.

Q: 큐잉은 서버 그룹에 걸쳐 부분적/전체적으로 일관성을 유지하나요, 아니면 최대 처리량을 위해 분산되나요?

처리량을 위해 분산되지만, 메시지 순서는 최선을 다해 유지됩니다. 각 메시지는 불변의 “ctime”을 가지는데, 이는 밀리초 단위 wall-clock 타임스탬프에 같은 밀리초 안에 생성된 메시지들을 위한 증분 ID를 더한 값입니다. 노드들은 전달 시 메시지를 정렬하기 위해 이 ctime을 사용합니다.

Q: 압박 상황에서 메시지가 완전히 유실될 수 있나요? (이른바 best effort)

아니요. 다만 메모리에 공간이 없으면 새로운 메시지가 거부될 수 있습니다. 메모리의 75%가 사용 중일 때 메시지를 받는 노드는 복사본을 갖지 않고 외부 노드에 메시지를 복제하려고 시도하지만, 다른 노드들도 메모리 부족 상태라면 실패할 수 있습니다.

Q: 컨슈머와 프로듀서가 큐를 들여다볼 수 있나요, 아니면 완전히 불투명한가요?

큐를 들여다보는 “PEEK” 명령어가 있습니다.

Q: 큐잉은 순서가 없나요, FIFO인가요, 아니면 우선순위가 있나요?

위에서 설명한 대로 최선 노력(best-effort) 방식의 FIFO에 가깝습니다.

Q: 브로커가 있나요, 없나요?

마스터들의 집합으로서의 브로커가 있습니다. 클라이언트는 원하는 어떤 노드와도 통신할 수 있습니다.

Q: 브로커가 독립적이고 이름이 있는 큐(토픽, 라우트 등)를 소유하나요, 아니면 프로듀서와 컨슈머가 연결을 직접 조율해야 하나요?

이름이 있는 큐가 있습니다. 프로듀서와 컨슈머는 연결을 조율할 필요가 없습니다. 노드들이 페더레이션을 이용해 클러스터 내부의 경로를 발견하고 컨슈머에게 필요한 대로 메시지를 전달하기 때문입니다. 다만 클라이언트가 더 많은 컨슈머가 있는 쪽으로 재배치되길 원할 경우를 위해 힌트가 제공됩니다.

Q: 메시지 전송은 트랜잭션인가요?

네, 메시지를 추가하는 명령이 반환되면 시스템은 클러스터 내부에 원하는 수만큼의 복사본이 존재함을 보장합니다.

Q: 메시지 수신은 트랜잭션인가요?

아마 아닐 겁니다. 확인되지 않으면 Disque가 같은 메시지를 다시 전달하려 하기 때문입니다.

Q: 컨슈머는 수신 시 블로킹되나요, 아니면 새 메시지를 직접 확인할 수 있나요?

두 동작 모두 지원되며, 기본값은 블로킹입니다.

Q: 프로듀서는 전송 시 블로킹되나요, 아니면 큐가 가득 찼는지 확인할 수 있나요?

프로듀서는 메시지를 푸시하는 로컬 노드에서 이미 큐 길이가 지정된 값을 초과한 경우, 새 메시지를 추가할 때 에러를 받도록 요청할 수 있습니다.

또한 프로듀서는 최대한 빨리 빠져나가고 클러스터가 메시지를 최선 노력 방식으로 복제하도록 하고 싶다면 비동기 복제를 요청할 수 있습니다.

큐에 메시지가 너무 많을 때 컨슈머를 블로킹했다가 메시지가 줄어들면 다시 블로킹을 해제하는 방법은 없습니다.

Q: 지연 작업(delayed jobs)이 지원되나요?

네, 초 단위 정밀도로 수년까지 지원됩니다. 다만 메모리를 사용합니다.

Q: 컨슈머와 프로듀서가 서로 다른 노드에 연결할 수 있나요?

네.

이 글을 통해 Disque가 조금은 덜 뜬구름 같은 존재가 되었길 바랍니다. 물론 코드를 보지 않고는 판단하기 어렵겠지만, 적어도 가장 중요한 기능이 공개된 만큼 불만이라도 제기할 수 있게 됐으니까요. 위에서 언급한 것 중 얼마나 이미 구현되어 잘 동작하고 있을까요? AOF 디스크 영속성과 API에서 다듬고 싶은 몇 가지 사소한 부분을 제외하면 전부입니다. 그래서 첫 릴리스가 그리 멀지 않았지만, 작업 빈도가 워낙 낮다 보니 진행이 아주 빠르지는 않습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글