메시지 큐 모험기
수정: 혹시 놓치셨다면 Disque 소스 코드는 현재 http://github.com/antirez/disque에서 보실 수 있습니다.
몇 달째 제 시간의 15~20% 정도를, 대부분 밤과 주말을 쪼개어 새로운 시스템을 만드는 데 쓰고 있습니다. 메시지 브로커이며 이름은 Disque입니다. 원래 명세의 80% 정도는 구현했지만 아직 릴리스할 준비가 되었다는 느낌은 들지 않습니다. 당장 출시할 수 없으니 적어도 블로그에라도 남기려 합니다. 그래서 어떻게 시작되었는지, 그리고 이 시스템이 무엇인지에 대해 간략히 이야기해보려 합니다.
~ 첫걸음 ~
많은 개발자가 Redis를 메시지 큐로 사용합니다. 종종 Redis의 저수준 프리미티브를 추상화한 라이브러리로 감싸서 사용하고, 때로는 Redis의 원시 API를 이용해 직접 간단한 큐를 즉석에서 만들어 쓰기도 합니다. 이 사용 사례는 주로 블로킹 리스트 연산과 리스트 푸시 연산으로 처리됩니다. Redis는 이런 용도로 쓰기에 겉보기에는 최고의 시스템인 동시에 최악의 시스템이기도 합니다. 빠르다는 점, 들여다보고 배포하고 사용하기 쉽다는 점, 그리고 많은 환경에서 이미 인프라의 일부로 자리 잡고 있다는 점에서는 좋습니다. 하지만 단점도 있는데, Redis의 가변 자료구조는 불변 메시지라는 개념과 매우 다르기 때문입니다. Redis의 고가용성/클러스터 관련 트레이드오프는 크고 가변적인 값을 다루는 데 완전히 편향되어 있는데, 동일한 트레이드오프가 메시지를 다루는 데는 최선이 아닙니다.
메시지 브로커에서 반드시 보장해야 하는 것 중 하나는 메시지가 최소 한 번, 혹은 최대 한 번 전달된다는 점입니다. 간단히 말해 메시지 한 건을 정확히 한 번만 전달하는 것(여기서 전달이란 메시지가 워커에 의해 수신되고 *처리*까지 완료된 경우를 뜻합니다)을 보장하는 것은 사실상 불가능하므로, 선택지는 메시지 브로커가 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번의 커밋 끝에, 마침내 더 이상 장난감처럼 보이지 않는 시스템이 나왔습니다. 여러 주 동안은 장난감처럼 보였기 때문에 이야기하는 것조차 두려웠습니다. 제가 그냥 소스 트리를 삭제해버릴 확률이 크다고 느꼈기 때문입니다. 이제 대부분의 아이디어가 테스트를 갖춘 동작하는 코드가 되었으니, 앞으로 릴리스될 것임을 확신하게 되었고 설계에서 어떤 트레이드오프를 선택했는지 이야기할 수 있게 되었습니다.
Disque는 기본적으로 분산 시스템입니다. AP 시스템인 만큼 Redis처럼 단일 노드 모드와 분산 모드를 따로 두는 것은 의미가 없었습니다. 단일 Disque 노드는 그저 노드가 하나뿐인 클러스터의 특수한 경우일 뿐입니다. 그래서 이것이 설계에서 중요한 지점 중 하나였습니다. 장애에 강하고 파티션에 견디며, 몇 개의 노드가 살아 있든 상관없이 가용성을 유지하는 것, 즉 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입니까, 아니면 우선순위가 있습니까?
앞서 설명한 대로 최선형 FIFO에 가깝습니다.
Q: 브로커가 있습니까, 없습니까?
마스터들의 집합으로서의 브로커가 있습니다. 클라이언트는 원하는 어떤 노드와도 통신할 수 있습니다.
Q: 브로커가 독립적이고 이름이 붙은 큐(토픽, 라우트 등)를 소유합니까, 아니면 프로듀서와 컨슈머가 연결을 직접 조율해야 합니까?
이름이 붙은 큐를 소유합니다. 프로듀서와 컨슈머는 연결을 조율할 필요가 없습니다. 노드들이 클러스터 내부에서 경로를 발견하고 컨슈머에게 필요한 대로 메시지를 전달하는 페더레이션을 사용하기 때문입니다. 다만 클라이언트에는 더 많은 컨슈머가 있는 곳으로 재배치하려는 경우를 위한 힌트가 제공됩니다.
Q: 메시지 전송은 트랜잭션입니까?
네, 메시지를 추가하는 명령이 반환된 시점에는 클러스터 안에 원하는 수만큼의 사본이 존재한다는 것이 보장됩니다.
Q: 메시지 수신은 트랜잭션입니까?
아마도 아닐 것입니다. Disque는 확인되지 않으면 같은 메시지를 다시 전달하려고 시도하기 때문입니다.
Q: 컨슈머는 수신 시 블로킹됩니까, 아니면 새 메시지를 직접 확인할 수 있습니까?
두 동작 모두 지원되며, 기본값은 블로킹입니다.
Q: 프로듀서는 전송 시 블로킹됩니까, 아니면 큐가 가득 찼는지 확인할 수 있습니까?
프로듀서는 메시지를 푸시하는 로컬 노드의 메시지 길이가 이미 지정된 값보다 크면 새 메시지를 추가할 때 오류를 받도록 요청할 수 있습니다.
또한 프로듀서는 가능한 한 빨리 빠져나와 클러스터가 최선형으로 메시지를 복제하도록 두고 싶다면 비동기 복제를 요청할 수 있습니다.
큐에 메시지가 너무 많을 때 컨슈머를 블로킹했다가 메시지가 줄어들면 다시 블로킹을 해제하는 방법은 없습니다.
Q: 지연 작업이 지원됩니까?
네, 초 단위 정밀도로 최대 수년까지 지원됩니다. 다만 메모리를 사용합니다.
Q: 컨슈머와 프로듀서가 서로 다른 노드에 연결할 수 있습니까?
네.
이 글을 통해 Disque가 조금은 덜 뜬구름 잡는 이야기가 되었기를 바랍니다. 물론 코드를 직접 보지 않고서는 판단하기 어렵겠지만, 적어도 최고의 기능이 공개된 만큼 불평할 거리는 생겼을 것입니다. 위에서 언급한 것 중 이미 구현되어 잘 동작하는 부분은 얼마나 될까요? AOF 디스크 영속성과 API에서 다듬고 싶은 몇 가지 사소한 부분을 제외하면 전부이며, 따라서 첫 릴리스가 그리 멀지는 않았지만 이렇게 드문드문 작업하니 속도를 내기는 어렵습니다.
글을 무작위로 읽기