S3 JSON 파일 하나로 분산 큐 구현하기: 광기인가 천재인가?


엔지니어링에서 가장 어려운 것은 ‘복잡한 문제를 단순하게 푸는 것’입니다. 우리는 흔히 분산 큐(Distributed Queue)가 필요하다고 하면 반사적으로 Kafka, RabbitMQ, 혹은 AWS SQS부터 떠올립니다. Redis를 띄우기도 하죠. 그런데 최근 Turbopuffer 엔지니어링 블로그에 꽤나 도발적인 글이 올라왔습니다. “Object Storage(S3/GCS)에 있는 단 하나의 JSON 파일로 분산 큐를 구현했다” 는 내용입니다.

처음 제목만 봤을 때는 “이거 토이 프로젝트인가?” 싶었습니다. 하지만 내용을 뜯어보고 Hacker News의 댓글들을 읽어보니, 이 설계에는 분산 시스템의 핵심 원칙을 꿰뚫는 통찰이 담겨 있었습니다. 오늘은 이 ‘미친’ 설계가 어떻게 프로덕션 레벨에서 동작하는지, 그리고 우리가 여기서 무엇을 배울 수 있는지 딥다이브 해보겠습니다.

왜 굳이 Object Storage인가?

Turbopuffer 팀이 기존에 사용하던 큐 시스템은 샤딩(Sharding) 방식이었습니다. 하지만 특정 노드가 느려지면 그 샤드 전체가 막히는 ‘Head-of-line blocking’ 문제가 있었죠. 그들이 원한 건 단순함, 예측 가능성, 그리고 쉬운 온콜(On-call) 이었습니다.

데이터베이스나 큐 시스템을 운영해 본 분들은 아시겠지만, 상태(State)를 가진 시스템을 관리하는 건 고통스럽습니다. 반면 S3나 GCS 같은 Object Storage는 무한한 확장성과 내구성을 보장합니다. “우리가 그 한계 안에서만 설계하면, S3는 절대 우리를 배신하지 않는다”는 믿음이 이 설계의 핵심입니다.

구현 단계별 진화: 단순함에서 확장성까지

이 시스템은 처음부터 복잡하게 설계되지 않았습니다. 바닥부터 쌓아 올리는 방식(Bottom-up)을 취했죠.

1단계: 가장 단순한 형태 (The Naive Approach)

가장 먼저 시도한 건 queue.json 파일 하나를 두고, 클라이언트(Pusher/Worker)들이 이 파일을 읽고 쓰는 것입니다. 여기서 핵심은 CAS(Compare-And-Set) 입니다.

  • S3/GCS는 HTTP ETag를 통해 조건부 쓰기(Conditional Write)를 지원합니다.
  • 파일을 읽어서 수정하고 업로드할 때, 내가 읽은 시점의 ETag와 현재 서버의 ETag가 같을 때만 쓰기가 성공합니다.
  • 만약 그사이 누군가 파일을 수정했다면? 실패하고 다시 읽어서 재시도합니다.
/* queue.json */
{"jobs":["◐","○","○","○","○"]}

이 방식은 Atomic 합니다. 별도의 락(Lock) 서버 없이도 강력한 일관성을 보장하죠. 하지만 Object Storage의 쓰기 Latency(약 200ms) 때문에 초당 처리량(RPS)이 턱없이 낮다는 치명적인 단점이 있습니다.

2단계: 그룹 커밋 (Group Commit)

Latency가 문제라면 답은 배칭(Batching) 입니다. 데이터베이스가 디스크 I/O를 줄이기 위해 사용하는 Group Commit 기법을 그대로 적용했습니다.

쓰기 요청이 들어오면 바로 S3에 쏘는 게 아니라, 메모리 버퍼에 모아둡니다. 그리고 현재 진행 중인 쓰기가 끝나면, 버퍼에 쌓인 변경 사항을 한 번에 모아서 다음 CAS 요청으로 보냅니다. 이렇게 하면 처리량(Throughput)이 쓰기 Latency와 분리됩니다. 네트워크 대역폭이 허락하는 한 처리량을 늘릴 수 있게 된 것이죠.

3단계: 브로커(Broker) 도입

하지만 여전히 문제가 남습니다. 수백 개의 클라이언트가 하나의 queue.json 파일에 대해 동시에 CAS를 시도하면 어떻게 될까요? 경합(Contention)이 발생하고, 대부분의 요청은 실패하고 재시도하느라 리소스를 낭비하게 됩니다. Hacker News의 한 유저가 지적했듯이, 이는 높은 동시성 환경에서 Livelock 과 유사한 상황을 만들 수 있습니다.

Turbopuffer는 이를 해결하기 위해 Stateless Broker 를 도입했습니다.

  • 모든 클라이언트는 S3에 직접 쓰지 않고 브로커에게 요청을 보냅니다.
  • 브로커는 요청을 메모리에 버퍼링하고, 단일 스레드로 S3에 Group Commit을 수행합니다.
  • 중요: 브로커는 데이터가 S3에 안전하게 저장되었다는 응답을 받은 후에야 클라이언트에게 ACK를 보냅니다.

이제 queue.json에 대한 쓰기 경합은 사라졌습니다. 브로커 하나가 초당 수천 건의 요청을 처리할 수 있게 된 겁니다.

고가용성(HA)은 어떻게 해결했나?

브로커가 하나라면, 그 브로커가 죽으면 끝장 아닌가요? 여기서 이 설계의 백미가 나옵니다. 브로커는 Stateless 입니다. 상태는 오직 S3에만 있습니다.

  1. 브로커가 죽거나 응답이 느려지면, 클라이언트는 새로운 브로커를 띄웁니다.
  2. 새 브로커의 주소를 queue.json에 씁니다.
  3. 기존 브로커가 살아있었다 하더라도, 다음번 S3 쓰기 시도에서 CAS 실패(ETag 불일치)를 겪고 자신이 더 이상 리더가 아님을 깨닫게 됩니다.

이것은 일종의 Fencing Token 메커니즘을 S3의 버전 관리 기능으로 구현한 셈입니다. 복잡한 리더 선출 알고리즘(Raft, Paxos) 없이도 안전한 페일오버가 가능합니다.

Hacker News의 반응과 기술적 통찰

이 글에 대한 HN의 반응은 뜨거웠습니다. 몇 가지 인상 깊은 코멘트를 짚어보겠습니다.

“이건 HTTP 위에서 구현한 낙관적 락(Optimistic Locking)이다. 벡터 시계(Vector Clocks) 대신 Object Storage의 네이티브 버전 관리를 사용했을 뿐이다.”

정확한 지적입니다. 복잡한 분산 시스템 이론을 클라우드 네이티브 프리미티브(Primitive)로 단순화했습니다.

또 다른 흥미로운 시각은 이 구조가 Delta LakeIceberg 같은 최신 데이터 레이크 기술의 축소판이라는 것입니다. “Manifest 파일 + 불변 객체(Immutable Objects) + CAS” 조합은 사실상 현대적인 클라우드 데이터 구조의 표준이 되어가고 있습니다.

물론 비판적인 시각도 있습니다. “왜 SQS를 안 쓰고 굳이?”라는 질문이죠. 이에 대해 저자는 “복잡성은 그만한 가치가 있을 때만 도입해야 한다”고 답합니다. 이미 S3를 헤비하게 쓰고 있는 조직 입장에서, 관리 포인트(RDS, Redis, SQS 등)를 늘리는 것보다 S3 하나로 통일하는 것이 운영 복잡도를 획기적으로 낮춘다는 것입니다.

마치며: 엔지니어로서의 생각

솔직히 말해서, 제가 처음 이 아키텍처를 제안받았다면 “말도 안 된다”며 반려했을지도 모릅니다. 하지만 곰곰이 뜯어볼수록 이 설계는 ‘Constraints(제약 사항)’ 를 완벽하게 이해하고 활용한 사례입니다.

  1. 데이터 크기가 작다: 큐 전체 크기가 1GB 미만이라 메모리에 로드 가능.
  2. Latency보다 Throughput이 중요: 200ms 정도의 지연은 허용 가능.
  3. 운영 편의성이 최우선: 관리할 인프라를 최소화.

이 조건하에서 S3 기반 큐는 Redis나 Kafka보다 훨씬 저렴하고, (역설적이게도) 더 안정적일 수 있습니다. S3가 다운될 확률이 여러분이 구축한 Redis 클러스터가 터질 확률보다 낮으니까요.

우리는 종종 “Google 스케일”이나 “Netflix 아키텍처”를 맹목적으로 쫓습니다. 하지만 Turbopuffer의 사례는 “현재 우리 시스템의 규모와 요구사항에 맞는 가장 단순한 도구” 가 무엇인지 다시 한번 생각하게 만듭니다. S3는 단순히 파일 저장소가 아닙니다. 어떻게 쓰느냐에 따라 무한한 가능성을 가진 강력한 분산 시스템 빌딩 블록입니다.