한 줄 정의

핵심 메시지

단일 머신의 동시성 문제는 여러 머신으로 확장되는 순간 질적으로 달라집니다. 공유 메모리 위에서 통하던 잠금·CAS 기법이 더 이상 통하지 않고, 대신 네트워크라는 “신뢰할 수 없는 매개체”를 가정한 새로운 추상화 — 세대 시계, 사전 기록 로그(WAL), 2단계 커밋, 쿼럼·합의 프로토콜 — 가 필요합니다.

이 장은 분산 컴퓨팅의 8가지 오류(fallacies)를 인정하는 데서 출발합니다. “네트워크는 신뢰할 수 있다”, “지연 시간은 0이다”, “토폴로지는 변하지 않는다” 같은 가정을 깰 때마다 새로운 메커니즘이 필요했고, 그 결과물이 오늘날 카산드라·카프카·주키퍼 같은 시스템의 기본 빌딩 블록입니다.

쉽게 말하면

한 대의 JVM 안에서는 AtomicLong 하나로 카운터를 안전하게 늘릴 수 있습니다. 그런데 머신이 10대가 되면 어떤 머신의 카운터가 “진짜”일까요?

네트워크 패킷은 손실되고, 머신은 갑자기 죽으며, 일부 노드는 다른 노드와 연결이 끊긴 상태로 계속 살아 있을 수 있습니다. 이런 환경에서 “모두가 같은 값을 본다”를 보장하려면 단순히 변수 하나에 락을 거는 것으로는 부족합니다.

이 장은 단일 머신의 동시성 기법을 분산 환경으로 일반화할 때 무엇이 깨지고, 그것을 어떻게 보완하는가에 대한 카탈로그입니다. 세대 시계로 메시지 순서를 부여하고, WAL로 변경을 안전하게 기록하고, 2단계 커밋으로 원자성을 확보하고, 팩소스·래프트로 노드들이 합의에 이르게 만드는 일련의 패턴이 차례로 등장합니다.

왜 중요한가?

백엔드는 본질적으로 분산 시스템입니다

요즘 운영하는 서비스는 거의 모두 멀티 인스턴스로 배포됩니다. 무중단 배포, 수평 확장, 가용성을 위해서입니다. 그 순간 “Spring Bean 안의 ConcurrentHashMap은 진실의 원천이 아닙니다” — 다른 인스턴스의 상태와 어떻게 일치시킬지를 매번 고민해야 합니다.

분산 컴퓨팅의 8가지 오류

L. 피터 도이치와 제임스 고슬링이 1990년대 중반에 정리한 잘못된 가정입니다.

  • 네트워크는 항상 신뢰할 수 있다
  • 지연 시간은 0이다
  • 대역폭은 무한하다
  • 네트워크는 안전하다
  • 토폴로지는 변하지 않는다
  • 관리자는 한 명이다
  • 전송 비용은 0이다
  • 네트워크는 동질적(homogeneous)이다

이 모든 가정은 거짓이며, 분산 시스템에서 발생하는 거의 모든 골치 아픈 버그는 이 중 하나 이상을 무의식적으로 가정한 결과입니다.

SOA·마이크로서비스가 영향을 증폭합니다

서비스 지향 아키텍처(SOA)나 마이크로서비스 기반 아키텍처는 분산 연결(distributed connections)의 수를 크게 증가시킵니다. 한 요청을 처리하기 위해 거치는 네트워크 홉이 늘어날수록, 위 오류들이 시스템 전체에 미치는 영향도 비례하여 커집니다.

분산이 만병통치는 아닙니다

애플리케이션을 분산하여 최적화·확장성을 높이려 했지만, 하나 이상의 오류를 간과하면 오히려 정반대의 결과를 초래합니다. 즉, “분산하면 빨라진다”는 직관은 통신 비용·실패 모드·일관성 비용을 무시할 때만 성립합니다.

핵심 내용

14.1 기본적인 분산 데이터 구조

분산 시스템 라이브러리의 토대가 되는 저수준 빌딩 블록들입니다. 이후 등장하는 합의 프로토콜·고수준 라이브러리에서도 반복적으로 재사용됩니다.

14.1.1 시계, ID와 사전 기록 로그

세대 시계 (generation clock)

분산 데이터 구조의 가장 간단한 구성 요소 중 하나가 세대 시계(generation clock)입니다. 단조 증가하는 값(monotonically increasing value)으로, 분산 시스템에서 이벤트의 순서를 정하는 데 사용됩니다. 이 값은 시스템에서 전송되는 모든 메시지에 첨부되며, 복제된 로그에 저장됩니다.

대표적인 사용 사례는 리더-팔로워(leader-follower) 클러스터에서 리더가 일시적으로 팔로워와 연결이 끊어지는 상황입니다. 새로운 리더 선출(leadership election)이 발생하면 생성 클럭이 증가하므로, 팔로워 노드는 이전 리더가 클러스터에 다시 연결되더라도 그 리더가 보낸 메시지가 “과거의 것”임을 시계 비교만으로 알 수 있습니다.

불변 객체로 일반화하기

단일 머신의 동시성을 분산 시스템으로 쉽게 일반화하는 기법 중 하나가 불변 객체(immutable object)를 사용하는 것입니다. 불변 객체는 직렬화·역직렬화가 가능하다면 네트워크를 통해 안전하게 전송할 수 있고, 객체 상태가 생성 이후 변경되지 않으므로 업데이트 손실(lost update) 가능성이 없습니다. 업데이트 손실 안티 패턴을 분산 환경에서 회피하는 가장 단순한 방법입니다.

클러스터 전역 고유 ID — 세그먼트 할당 방식

분산 시스템에서 고유 ID를 생성하는 방법은 여러 가지지만, 네트워크 트래픽을 가장 적게 쓰는 방법이 클러스터 전역 세그먼트 방식입니다.

  • 리더 노드는 팔로워에게 ID 세그먼트(예: 100만 개)를 할당합니다.
  • 팔로워는 할당받은 세그먼트 내에서 로컬 카운터로 ID를 생성합니다.
  • 세그먼트가 소진되면 새로운 세그먼트를 요청합니다.

리더 프로세스는 AtomicLong을 사용해 다음 세그먼트의 시작 값을 관리할 수 있습니다.

sequenceDiagram
    participant L as 리더(AtomicLong)
    participant F1 as 팔로워 1
    participant F2 as 팔로워 2
    F1->>L: 세그먼트 요청
    L-->>F1: [0, 1_000_000)
    F2->>L: 세그먼트 요청
    L-->>F2: [1_000_000, 2_000_000)
    Note over F1: 로컬 카운터로 ID 발급<br/>네트워크 트래픽 없음
    Note over F2: 로컬 카운터로 ID 발급<br/>네트워크 트래픽 없음
    F1->>L: (소진 후) 다음 세그먼트 요청
    L-->>F1: [2_000_000, 3_000_000)
왜 이 방식이 빠른가

거의 모든 ID 생성이 로컬 메모리에서 일어나므로 매우 빠릅니다. 또한 단순 AtomicLong을 매번 리더에서 가져오는 것보다 경쟁이 훨씬 적기 때문에 확장성도 뛰어납니다. 리더로부터 100만 개짜리 세그먼트를 한 번 가져오는 것이 ID를 하나씩 요청하는 것보다 훨씬 효율적이라는 단순한 산수입니다.

유의 사항

중복 ID는 생기지 않지만 몇 가지 함의가 있습니다.

  • 서로 다른 팔로워들이 생성한 ID는 순서가 뒤섞일 수 있습니다. (시간 순서와 ID 순서가 다름)
  • 특정 멤버가 세그먼트를 모두 쓰지 않고 종료되면 ID에 간격(gap)이 생깁니다. 대부분의 경우 큰 문제는 아닙니다.
  • 클러스터 리더가 재시작하면(가장 단순한 경우) ID 생성기가 0부터 다시 시작할 수 있습니다.

마지막 문제는 지속적인 저장소(예: 데이터베이스)에 마지막으로 할당된 세그먼트를 기록하여 완화할 수 있습니다. java.util.UUID 같은 형태도 대안이지만, 단순한 숫자 ID만으로도 대부분 충분합니다.

사전 기록 로그 (WAL)

사전 기록 로그(write-ahead log, WAL)는 데이터베이스 시스템에서 원자성(atomicity)과 지속성(durability)을 보장하기 위해 널리 사용됩니다. ACID 특성 중 두 가지를 충족하는 핵심 메커니즘으로, 순차적으로 추가만 가능한(append-only) 로그 구조이며 지속적인 저장소에 저장됩니다.

전통적으로는 충돌 복구(crash recovery)에 사용됩니다. 프로세스 재시작 시 로그를 확인하여 변경이 완료되었는지, 롤백해야 하는지, 로그 항목을 폐기해야 하는지 결정할 수 있습니다.

분산 시스템 관점에서는 노드가 수행하려는 가설적 변경 사항을 기록하는 로그로 볼 수 있습니다. 특정 완료 조건이 충족되면 로그는 체크포인트에 도달하고, 변경을 적용한 후 로그를 정리합니다. 이 사고방식이 다음 절의 2단계 커밋에서 그대로 활용됩니다.

14.1.2 2단계 커밋

왜 필요한가

2단계 커밋(two-phase commit)은 하나의 트랜잭션을 데이터베이스에 저장하면서 동시에 메시징 시스템에도 전파해야 하는 상황에서 자주 쓰입니다. 두 개의 독립적인 트랜잭션으로 처리하면 DB 트랜잭션은 성공했는데 메시징 전송은 실패하는 식의 일관성 깨짐이 발생할 수 있습니다.

두 단계의 구조

리더-팔로워 시스템을 예로 들면 다음과 같습니다.

sequenceDiagram
    participant L as 리더
    participant F1 as 팔로워 1
    participant F2 as 팔로워 2

    Note over L,F2: 준비(투표) 단계
    L->>L: WAL에 변경 기록
    L->>F1: 변경 사항 복제
    L->>F2: 변경 사항 복제
    F1->>F1: 로컬 WAL 기록
    F2->>F2: 로컬 WAL 기록
    F1-->>L: Promise (커밋 준비됨)
    F2-->>L: Promise (커밋 준비됨)

    Note over L,F2: 커밋 단계
    L->>L: 항목 최종 표시
    L->>F1: 완료 메시지
    L->>F2: 완료 메시지
    F1->>F1: WAL 항목 적용
    F2->>F2: WAL 항목 적용
    F1-->>L: ACK
    F2-->>L: ACK

핵심은 모든 팔로워가 동의해야 다음 단계로 넘어갈 수 있다는 점입니다.

장점
  • 단순하고 이해하기 쉽습니다.
  • 원자성과 일관성을 제공합니다. 트랜잭션은 전부 성공하거나 전부 실패하며, 서로 다른 노드가 서로 다른 결과를 갖는 일이 없습니다.
  • 생존성(liveness)을 보장합니다. 결국 합의에 도달하게 되며, 여러 번의 재시도를 거칠 수도 있습니다.
2단계 커밋의 단점
단점의미
모든 노드의 약속·승인을 받아야 함클러스터 크기에 선형적으로 비용 증가
단 하나의 오류·충돌로 트랜잭션 중단(aborted)처음부터 재시도 필요
리더가 모든 응답을 기다림전체 지연 시간 = 가장 느린 노드 응답 시간 이상
차단 동작의 위험

2단계 커밋은 차단(blocking) 동작을 유발할 수 있습니다. 준비 단계에서 노드들이 트랜잭션 완료에 필요한 리소스를 미리 할당해야 하므로, 클러스터 전체가 승인되지 않은 약속을 대기하는 상태가 됩니다.

트랜잭션이 커밋되거나 롤백될 때까지 영향을 받는 리소스에 대한 새로운 업데이트는 수락할 수 없습니다. 실무에서 2단계 커밋이 “이론적으로는 우아하지만 운영하기 까다롭다”는 평을 듣는 이유입니다.

실제 환경 구현의 필수 요소

실제 환경에서의 2단계 구현은 타임아웃 처리기본적인 장애 허용을 제공해야 합니다. 예를 들어, 노드가 응답하지 않거나 느린 경우, 또는 일시적으로 사용할 수 없는 경우(가비지 컬렉션 일시 정지 등)에도 정상적으로 동작해야 합니다.

14.1.3 객체 직렬화

자바 내장 직렬화의 역사

자바 내장 직렬화는 길고 다소 불명예스러운 역사를 가집니다. 직렬화는 객체 인스턴스의 상태를 바이트 스트림으로 변환하고, 이를 역직렬화하여 다시 객체로 복원하는 과정입니다.

이 기능은 자바 1.1부터 플랫폼 기본 기능으로 제공되었지만, 언어 차원의 직렬화 메커니즘은 일반적인 경우 근본적으로 결함이 있다는 점이 널리 인정되고 있습니다.

브라이언 괴츠, '더 나은 직렬화를 향하여(towards better serialization)'

직렬화는 보이지 않지만 공개된 생성자 역할을 하며, 보이지 않지만 공개된 접근자(accessor) 역할을 통해 내부 상태에 접근할 수 있도록 합니다.

즉, private 필드도 직렬화 형식을 통해 외부로 노출되는 셈이며, 이는 캡슐화의 근본적인 약속을 깨뜨립니다.

라이브러리 기반 솔루션

내장 직렬화의 보안 문제 때문에 대부분의 애플리케이션 팀은 라이브러리 기반 솔루션을 선택했습니다.

형식특징적합한 상황
JSON단순, 사람이 읽을 수 있음, 거의 모든 언어 지원일반적인 REST API, 디버깅 친화적 환경
프로토콜 버퍼(Protobuf)바이너리, 스키마 기반, 작고 빠름고성능 RPC, gRPC 기반 시스템
에이브로(Avro)바이너리, 스키마 진화 지원카프카 등 메시징·이벤트 스트리밍

JSON은 단순하다는 매력이 크지만, 직렬화된 객체의 크기가 성능 저하를 초래할 수 있어 이런 장점이 상쇄되기도 합니다. 특히 대용량 메시지의 경우 성능 저하가 더욱 두드러집니다.

직렬화는 두 가지 오류와 직결됩니다

데이터 직렬화에 대한 트레이드오프와 아키텍처적 선택은 이 장의 초반에 언급된 두 가지 오류와 관련 있습니다 — **‘전송 비용은 0이다’**와 **‘대역폭은 무한하다’**라는 가정입니다.

20여 년 전 XML 직렬화로 인한 성능 병목과, 직렬화 비용을 줄이기 위한 적절한 서비스 세분화 수준 찾기는 오늘날 형식이 XML이 아닐 뿐 본질은 그대로입니다.

14.1.4 데이터 분할과 복제

데이터 샤딩

가장 단순한 형태에서 데이터 분할(데이터 샤딩, sharding)은 대규모 데이터셋을 더 작고 관리하기 쉬운 하위 집합, 즉 파티션으로 나누는 과정입니다. 각 파티션은 전체 데이터의 일부를 포함하며, 여러 노드에 분산됩니다.

방식분할 단위사용 예
수평 분할(horizontal partitioning)행(row)사용자 ID 해시로 사용자별 데이터 분산
수직 분할(vertical partitioning)열(column)자주 읽는 컬럼과 큰 BLOB 컬럼 분리

수평 분할의 경우 특정 키(예: ID)나 중요한 필드의 해시 값을 기반으로 데이터를 분할할 수 있습니다. 아파치 카프카 같은 시스템이 이러한 접근 방식을 사용합니다.

복제와 쿼럼

장애 허용을 위해 각 파티션의 여러 복사본을 유지합니다. ‘네트워크는 항상 신뢰할 수 있다’는 잘못된 가정을 피하기 위한 방법입니다.

  • 가장 기본은 동기식 복제본 하나를 유지하는 것
  • 두 개의 동기식 복제본도 가능하지만 메모리 소비가 증가 (각 백업이 원본과 동일한 크기 사용)
  • 일반적 방법: 클러스터 내 호스트 간 하트비트(heartbeating) + 쿼럼(quorum) 조합으로 충분한 ‘투표’ 확보

모든 동기식 백업은 데이터 복사본 간 잠금이 필요하며, 이를 통해 업데이트 손실 안티 패턴이 분산 환경에서 발생하는 것을 방지합니다. 그러나 백업 복사본이 많아질수록 잠금(locking) 비용도 증가합니다.

동기식 쓰기 vs 비동기식 쓰기

일부 애플리케이션은 메모리 내 데이터 저장소를 백업 저장소에 지속적으로 저장합니다. 완전한 신뢰성을 위해서는 메모리 내 변경 사항이 물리적 저장소에 동기식 쓰기(written through)된 후 변경이 확정되어야 합니다. CPU 캐시와 메인 메모리 사이에서 일어나는 쓰기 동기화를, 메모리와 디스크 사이로 한 단계 끌어올린 구조입니다.

다만 이런 동기적 쓰기가 과도한 오버헤드일 수 있는 경우, 많은 시스템은 비동기식 쓰기(write-behind) 방식을 지원하여 짧은 시간 동안의 데이터 손실을 감수합니다.

재분할 비용과 관측성

관측성(observability)은 복제나 분할 환경에서도 유용합니다. 데이터가 정상적으로 파이프라인을 통해 흐르는지뿐만 아니라, 재분할 이벤트를 모니터링하는 것도 중요합니다. 단순히 재분할 발생 여부뿐만 아니라, 재분할이 얼마나 오래 지속되는지를 분석하면 메시징 인프라가 용량 한계에 가까워지는지를 조기에 감지할 수 있습니다.

재분할의 성능은 최선의 경우 노드 수에 따라 선형적으로 증가(linear complexity) 하지만, 경우에 따라 이차적으로 증가(quadratic complexity)할 수도 있습니다. 클러스터 전체에서 재분할을 관찰하는 기능이 중요합니다 — 개별 노드만 봐서는 전체 시스템 상태를 파악할 수 없습니다.

14.1.5 CAP 정리

정의

CAP 정리(CAP theorem)는 분산 시스템 이론에서 가장 잘 알려진 개념입니다. 이름은 세 가지 동작 특성의 앞 글자를 딴 것입니다.

  • C — 일관성(consistency)
  • A — 가용성(availability)
  • P — 네트워크 파티션 허용(partition-tolerance)

CAP 정리는 어떤 시스템이 이 세 가지 특성 중 두 가지만 만족할 수 있다는 것을 의미합니다. 예를 들어, AP 시스템(availability & partition-tolerance)은 가용성과 네트워크 파티션 허용을 선택하는 방식입니다.

직관적 이해

이 정리는 분산 컴퓨팅의 잘못된 가정 중 두 가지 — ‘네트워크는 항상 신뢰할 수 있다’와 ‘토폴로지는 변하지 않는다’ — 와 밀접하게 관련됩니다. 실제로 네트워크 파티션은 대표적인 토폴로지 변경 사례이며, 네트워크의 신뢰성을 보장할 수 없음을 의미합니다.

직관적으로는, 네트워크에서 메시지 손실 가능성이 있다면 네트워크 분할 발생 시 완전한 가용성과 완벽한 일관성을 동시에 유지할 수 없다는 점을 고려하면 됩니다.

분할 뇌 (split brain)

네트워크 파티션 발생 시 노드들이 계속 실행 중이지만 서로 다른 클라이언트 그룹과 연결된 경우, 하나의 전략은 일관성을 포기하고 가용성을 유지하는 것입니다. 이렇게 하나의 클러스터가 서로 통신하지 못하는 여러 조각으로 갈라져 각자 독립적으로 동작하는 상황을 분할 뇌(split brain)라고 합니다.

이 경우, 각 파티션과 연결된 클라이언트는 해당 파티션 내에서는 일관된 결과를 볼 수 있지만, 서로 다른 파티션과 연결된 클라이언트는 동일한 데이터를 볼 수 없습니다. 예를 들어, 원자적이어야 할 값이 각 파티션마다 다른 값을 가질 가능성이 생깁니다.

CAP의 현대적 해석

또 다른 접근 방식은 네트워크 파티션, 즉 메시지의 완전한 손실이 현대적인 LAN이나 데이터 센터 내에서는 매우 드물다는 점에 주목하는 것입니다. 이러한 이유로, 많은 사람은 CAP 정리가 광역 네트워크(WAN) 또는 글로벌 네트워크에서만 적용된다고 간주하기도 합니다.

시스템의 클라이언트가 항상 연결할 수 있는 클러스터 노드 목록을 알고 있다면, 데이터 센터나 특정 지역이 손실되는 경우에도 영향 받지 않은 노드에 다시 연결해 정상적인 운영을 지속할 수 있습니다.

14.2 합의 프로토콜

이 절에서는 가장 널리 사용되는 두 가지 합의 프로토콜(consensus protocol) — 팩소스와 래프트 — 을 다룹니다. 이러한 프로토콜은 분산 시스템에서 클러스터 내 노드들이 연산 중 필요한 변경 가능한 데이터 값에 대해 합의에 이루도록 보장하는 데 사용됩니다.

CAP 정리와의 연관

합의 알고리즘의 사용은 CAP 정리에서 살펴본 두 가지 오류, 즉 ‘네트워크는 신뢰할 수 있다’와 ‘토폴로지는 변하지 않는다’와 관련이 있습니다.

논의는 2단계 커밋 개념을 기반으로 진행됩니다. 또한 이 주제는 관측성의 중요한 요소를 보여주는 사례이기도 합니다 — 클러스터에서 발생하는 전환 이벤트(transition event)가 어떻게 나타나는지 파악하는 것이 중요합니다.

전환 이벤트는 비교적 드물지만 정상적인 운영에서도 자연스럽게 발생할 수 있으므로 첫 번째 이벤트 발생 시 즉시 경고를 보내서는 안 됩니다. 예를 들어 주키퍼(ZooKeeper) 같은 모니터링 시스템에서는 연속된 두 개의 모니터링 주기(기본적으로 분 단위) 동안 여러 노드가 동시에 리더로 보고되는 경우가 없어야 합니다.

14.2.1 팩소스

개요

팩소스(Paxos)는 투표 기반 합의 알고리즘으로, 보다 정확히는 여러 알고리즘의 집합(family of algorithm)입니다. 1980년대 레슬리 램포트(Leslie Lamport)에 의해 제안되었으며, 이전에 다룬 세대 시계 등의 개념을 활용합니다.

이 알고리즘은 카산드라, 다이나믹DB(DynamoDB), 처비(Chubby, 구글의 분산 잠금 시스템) 등 다양한 시스템에서 사용됩니다.

주키퍼의 ZAB

주키퍼는 ZAB(ZooKeeper atomic broadcast) 프로토콜을 사용하며, 이는 주키퍼의 사용 사례에 맞춰 특화된 팩소스의 변형이라고 볼 수 있습니다.

역할

클러스터 노드는 두 가지 기본 역할을 수행할 수 있습니다.

  • 제안자(proposer)
  • 수락자(acceptor)

일반적으로 메시지는 라운드 번호(round number) N (세대 시계 시간)을 통해 식별되며, 합의해야 할 값을 포함할 수 있습니다.

단계별 흐름

팩소스는 두 개의 주요 단계를 가지며, 각 단계는 하위 단계로 구성됩니다.

단계이름주체동작
1a준비요청제안자Prepare 메시지를 모든 수락자에게 전송
1b약속수락자제안자에게 Promise 반환 — 이후 해당 제안자의 값을 존중하겠다는 약속
2a수락제안자과반수 이상의 약속을 받으면 특정 값 V를 설정하고 (N, V) 쌍을 Accept 메시지로 모든 수락자에게 전송
2b승인됨수락자Accept를 받으면 조건 확인. 이전 1b 단계에서 자신이 더 높은 라운드 번호만 고려하겠다고 약속한 경우 거부 가능, 그렇지 않다면 승인
sequenceDiagram
    participant P as 제안자
    participant A1 as 수락자 1
    participant A2 as 수락자 2
    participant A3 as 수락자 3

    Note over P,A3: 1a 준비요청
    P->>A1: Prepare(N)
    P->>A2: Prepare(N)
    P->>A3: Prepare(N)

    Note over P,A3: 1b 약속
    A1-->>P: Promise(N)
    A2-->>P: Promise(N)
    A3-->>P: Promise(N)

    Note over P,A3: 2a 수락 (과반수 이상 약속 확보)
    P->>A1: Accept(N, V)
    P->>A2: Accept(N, V)
    P->>A3: Accept(N, V)

    Note over P,A3: 2b 승인됨
    A1-->>P: Accepted
    A2-->>P: Accepted
쿼럼 기반의 함의

팩소스는 쿼럼 기반 프로토콜이므로 단순 과반수만으로 진행할 수 있습니다. 이는 일부 노드가 연결이 끊기거나, 느리거나, 과부하 상태여도 계속 진행할 수 있도록 보장하기 위함입니다.

이 프로토콜에서 중요한 부분 중 하나가 승인 기준입니다. 충돌하는 값이 합의에 이르는 과정을 방해하지 않도록 설계되었습니다.

학습자 확장

팩소스는 매우 잘 연구된 알고리즘으로, 다양한 확장이 존재합니다. 가장 대표적인 확장은 세 번째 역할인 학습자(learner) — 주키퍼의 관찰자(observer)라고도 불립니다 — 를 추가하는 것입니다.

학습자는 클러스터의 수동적인 관찰자입니다.

  • 새로운 값을 제안하지 않습니다
  • 투표에도 참여하지 않습니다
  • 대신 값이 합의에 도달했을 때 그 결과를 통지받습니다

이는 합의 알고리즘에 참여하는 노드들의 읽기 부하를 줄이는 데 매우 유용합니다. 실무에서는 많은 팩소스 구현이 역할을 병합하여 수락자가 학습자 역할도 수행하도록 합니다.

FLP 불가능성 결과

여기서 합의 알고리즘에 대한 가장 중요한 이론적 결과인 피셔(Fischer), 린치(Lynch), 피터슨(Paterson)의 FLP 불가능성 결과(FLP Impossibility Result)를 언급해야 합니다.

이들의 1985년 논문은 비동기 시스템에서 합의 알고리즘은 안전성·생존·장애 허용 중 두 가지만 보장할 수 있다는 점을 증명합니다.

이 정리는 강력한 결과이지만 그 제한을 최소화하기 위한 여러 우회 방법이 개발되었습니다. 예를 들어, 제안된 값(proposed values) 간에 짧은 무작위 지연을 도입하면 생존성 실패의 가능성을 줄일 수 있습니다(여러 노드가 서로 양보하다 끝없이 재시도하는 활성 잠금(livelock)과 유사한 문제). 그러나 이론적으로 완전히 배제할 수는 없습니다.

14.2.2 래프트

설계 목표: 이해하기 쉬울 것

래프트(Raft)가 만들어질 때, 주요 설계 목표 중 하나는 단순하고 이해하기 쉬운 합의 알고리즘을 제공하는 것이었습니다. 이를 위해 래프트는 합의 문제를 두 개의 개별 하위 문제로 나눠 접근합니다.

  • 리더 선출
  • 로그 복제
노드 상태와 역할

단순성을 더욱 강조하기 위해, 래프트는 각 노드가 두 가지 선거 상태(안정·선거)와 세 가지 역할 상태(리더·팔로워·후보자)만 갖도록 설계되었습니다.

이는 리더-팔로워 기반 프로토콜이므로, 하나의 노드만 리더가 될 수 있으며, 선거가 진행되지 않는 한 나머지 모든 노드는 팔로워 상태를 유지합니다.

동작 흐름

모든 업데이트는 리더를 통해 처리됩니다.

  1. 리더가 업데이트를 받으면 자신의 로그에 기록합니다.
  2. 비동기적으로 팔로워들에게 변경 사항을 복제합니다.
  3. 데이터를 쿼럼에 성공적으로 복제하면, 커밋 인덱스가 증가합니다.
  4. 상태 머신(state machine)에 해당 연산이 적용되며, 업데이트가 성공한 것으로 간주됩니다.

팔로워는 완전히 수동적인 역할을 합니다 — 리더가 보내는 업데이트를 단순히 수락하는 역할만 수행합니다. 리더 노드는 정해진 간격(약 100ms)으로 모든 팔로워에게 하트비트 메시지를 전송합니다. 팔로워가 일정 시간 동안 하트비트를 받지 못하면 다른 노드들에게 메시지를 보내 선거를 요청할 수 있습니다.

임기 (term)

래프트는 팩소스와 마찬가지로 세대 시계 개념을 기반으로 하며, 이를 임기(term)라고도 부릅니다. 클러스터 내의 모든 노드는 현재 리더가 누구인지, 현재 임기 번호가 무엇인지 알고 있으며, 팔로워 목록(서로 피어 관계인 노드들)도 가지고 있습니다.

메시지 두 가지

래프트에서 사용되는 두 가지 주요 메시지는 AppendEntryRequestVote입니다.

  • AppendEntry: 리더가 팔로워에게 업데이트를 전송하는 메시지. null 업데이트를 보내면 하트비트 메시지 역할도 수행합니다.
  • RequestVote: 팔로워가 일정 시간 동안 AppendEntry를 받지 못했을 때 모든 피어에게 선거를 요청하는 메시지입니다.

자바 코드로는 다음과 같이 표현할 수 있습니다.

record AppendEntry (
    int term,
    int leaderId,
    List<Payload> payloads,
    int prevLogIndex,
    int prevLogTerm,
    int leaderCommit) { }
 
record AppendEntryResponse (
    int term,
    boolean accepted,
    int conflictIndex,
    int conflictTerm) { }
 
record RequestVote (
    int term,
    int candidateId,
    int lastLogIndex,
    int lastLogTerm
) {}
 
record RequestVoteResponse(
    int term,
    boolean inFavor
) {}
상태 머신
stateDiagram-v2
    [*] --> 팔로워: 시작
    팔로워 --> 후보자: 타임아웃<br/>(선거 시작)
    후보자 --> 후보자: 타임아웃<br/>(새로운 선거)
    후보자 --> 리더: 과반수 서버로부터<br/>투표 획득
    후보자 --> 팔로워: 현재 리더 또는<br/>새로운 임기 발견
    리더 --> 팔로워: 더 높은 임기를<br/>가진 서버 발견

클러스터가 시작될 때는 리더 노드가 존재하지 않으므로 모든 노드는 팔로워 상태에서 시작합니다. 이를 처리하기 위해 노드는 스스로 후보자로 승격할 수 있습니다. 승격 과정에서는 임기 번호를 증가시키고 다른 모든 노드에게 RequestVote 메시지를 전송합니다.

래프트의 이론적 한계

래프트는 광범위하게 연구된 알고리즘이며, 추가적인 수정 없이 네트워크 장애 발생 시 생존성을 완전히 보장하지는 못한다는 점이 입증되었습니다. 즉, 이론적으로 선거가 영원히 계속될 가능성이 존재합니다.

그러나 래프트는 다음 두 가지 장치로 이러한 가능성을 최소화합니다.

  • 무작위 선거 타임아웃(random election timeout) 도입
  • 후보자가 자신의 로그가 최신 상태일 때만 선거에서 승리할 수 있도록 제한

실제로는 활성 잠금이 발생하지 않도록 설계되었지만, 팩소스와 마찬가지로 이론적으로 불가능하다고 증명할 수는 없습니다. 이는 FLP 불가능성 결과의 또 다른 결과입니다.

알고리즘과 구현은 별개입니다

알고리즘이 올바르다고 해서 구현 코드 또한 올바르다는 보장은 없습니다. 래프트의 자바 구현 중 하나로 jgroups-raft 프로젝트가 있으며, 레드햇의 인피니스팬(Infinispan) 같은 제품에서 사용됩니다. 이 구현은 정확성 관련 버그를 찾기 위해 철저하게 테스트되었습니다.

14.3 분산 시스템 예제

앞서 다룬 빌딩 블록(세대 시계·쿼럼·WAL·합의 프로토콜)이 실제 시스템에서 어떻게 조립되는지를 보여주는 사례 연구입니다. 핵심 메시지는 이 복잡성을 인프라 계층이 흡수해 주기 때문에 애플리케이션 프로그래머는 분산의 어려움을 비즈니스 로직과 분리할 수 있다는 점입니다. 여기서 다루는 세 시스템(카산드라·인피니스팬·카프카)은 모두 자바로 작성되었고, 앞 절의 추상화를 각자의 방식으로 구현한 결과물입니다.

14.3.1 분산 데이터베이스: 카산드라

카산드라의 정체성 — AP이지만 조절 가능

카산드라(Cassandra)는 가장 널리 쓰이는 오픈 소스 NoSQL 분산 데이터베이스 중 하나입니다. 자바로 작성되었으며 비관계형(non-relational)·컬럼 지향 데이터베이스입니다. 기본적으로 CAP 정리에서의 AP(가용성·파티션 허용) 데이터베이스이지만, 흥미로운 점은 쿼리 단위로 일관성 수준(consistency level)을 조정할 수 있다는 것입니다.

이것이 백엔드 엔지니어 관점에서 가장 중요한 대목입니다. CAP 정리는 “둘 중 하나를 포기하라”고 말하지만, 카산드라는 그 선택을 시스템 전체가 아니라 개별 쓰기/읽기 연산마다 다르게 가져갈 수 있게 합니다. 결제 같은 중요한 데이터는 강한 일관성으로, 조회수 카운터 같은 데이터는 약한 일관성으로 — 같은 클러스터 안에서 혼용할 수 있습니다.

일관성 수준 — 코디네이터가 몇 표를 기다릴까

NoSQL 시스템은 일반적으로 결과적 일관성(eventual consistency)을 보입니다. 카산드라는 여기에 일관성 수준 개념을 얹어, 코디네이터 노드가 클라이언트에게 “쓰기 성공”을 알리기 전에 몇 개의 복제본 노드(replica node)가 확인해야 하는지를 지정하게 합니다.

일관성 수준확인 조건위치
ONE단 하나의 복제본 노드가장 빠름
LOCAL_QUORUM동일 데이터센터 내 단순 과반수중간
QUORUM클러스터 전체 단순 과반수느림
ALL모든 노드가장 느림

이 옵션들은 위에서 아래로 빠른 순서에서 느린 순서로 정렬되어 있습니다. 카산드라는 대량 데이터를 빠르게 쓰는 데 최적화된 시스템이므로, 엄격한 일관성 수준을 설정할수록 쓰기 속도가 느려집니다. 이는 단순히 더 많은 노드의 응답을 기다리기 때문입니다 — 14.1.2절 2단계 커밋에서 본 “리더가 가장 느린 노드를 기다린다”는 비용과 같은 구조입니다.

특히 카산드라는 글로벌 분산 환경에서 운영될 수 있다는 점에 유의해야 합니다. QUORUMALL 수준을 사용하면 WAN 트래픽이 발생하며, 광속 한계(speed-of-light limitations) 때문에 쓰기 확인 시간이 수십에서 수백 밀리초까지 증가할 수 있습니다. 분산 컴퓨팅의 오류 중 ‘지연 시간은 0이다’가 물리 법칙 앞에서 어떻게 깨지는지를 보여주는 실제 사례입니다.

ALL은 아키텍처 결함의 신호

ALL 설정은 권장되지 않습니다. 가장 느릴 뿐 아니라 가용성에도 부정적이기 때문입니다 — 하나의 노드만 실패해도 모든 쓰기 작업이 실패할 수 있습니다. 이는 AP 데이터베이스로 설계된 카산드라의 원칙과 정면으로 어긋나는 동작입니다. ALL을 쓰고 싶은 유혹이 든다면, 시스템 설계 어딘가에 문제가 있다는 아키텍처 잠재적 결함(architecture smell)일 가능성이 큽니다.

CQL — SQL과 닮았지만 SQL이 아니다

카산드라의 주요 인터페이스는 카산드라 쿼리 언어(Cassandra Query Language, CQL)입니다. SQL과 일부 유사한 구조를 가지며, NoSQL임에도 기본적인 테이블·컬럼·행 개념을 공유합니다. 그러나 비관계형이므로 조인이나 서브쿼리는 지원하지 않습니다.

다음 구문들은 SQL과 CQL 모두에서 유효합니다.

CREATE TABLE IF NOT EXISTS demoTable (id INT PRIMARY KEY);
ALTER TABLE demoTable ADD newField INT;
CREATE INDEX myIndex ON demoTable (newField);
INSERT INTO demoTable (id, newField) VALUES (1, 2);
SELECT * FROM demoTable WHERE newField = 2;
SELECT COUNT(*) FROM demoTable;
DELETE FROM demoTable WHERE newField = 2;

하지만 이것은 마치 다음 문장과 같습니다.

var x = 15;

이 한 줄은 자바와 자바스크립트 양쪽에서 동일한 문법으로 유효하지만, 의미적으로는 완전히 다릅니다. 마찬가지로 단순한 예제만 보고 CQL과 SQL이 동일하다고 착각해서는 안 됩니다. 표면적으로는 유사하지만 상당한 차이가 존재하며, 특히 INSERTDELETE 문은 전형적인 SQL 데이터베이스와 카산드라에서의 동작이 크게 다릅니다.

컬럼패밀리와 키스페이스

CQL에서 우리가 ‘테이블’이라고 부르는 것은 더 정확하게는 컬럼패밀리(column family)입니다. ‘테이블’이라는 별칭은 SQL 경험이 있는 사용자가 더 쉽게 적응하도록 제공된 것입니다.

키스페이스(keyspace)는 데이터가 노드에 어떻게 복제되는지를 정의하는 네임스페이스이며, SQL RDBMS의 데이터베이스 스키마와 대체로 유사합니다. 복제는 키스페이스 단위로 제어되므로, 서로 다른 복제 요구 사항을 가진 데이터는 서로 다른 키스페이스에 존재합니다. 그 결과 클러스터는 일반적으로 각 애플리케이션마다 하나의 키스페이스를 갖게 됩니다.

경량 트랜잭션 — 결과적 일관성으로 부족할 때

카산드라의 기본 설정인 결과적 일관성이 충분하지 않은 경우, 즉 읽기와 쓰기의 엄격한 순서를 유지해야 하는 경우가 있습니다. 이럴 때는 선형적 일관성(linearizable consistency)이 필요합니다.

카산드라에는 두 가지 유형의 CQL 연산이 있습니다 — 일반 연산과, 조건부 업데이트에 대해 선형적 일관성을 제공하는 경량 트랜잭션(lightweight transaction)입니다. 경량 트랜잭션은 팩소스의 구현을 사용합니다. 14.2.1절에서 본 합의 알고리즘이 데이터베이스의 조건부 쓰기 기능으로 직접 활용되는 지점입니다. 가장 단순한 형태에서 경량 트랜잭션은 비교와 교환(compare-and-swap) 연산으로, INSERT ... IF NOT EXIST와 같은 형태를 가집니다.

혼용 금지

일반 연산은 경량 트랜잭션과 혼용해서는 안 됩니다. 애플리케이션이 경량 트랜잭션을 사용할 적절한 사례에 해당한다면, 해당 데이터에 대해서는 전체적으로 일관되게 경량 트랜잭션을 사용해야 합니다.

SERIAL 일관성 수준이 경량 트랜잭션에 사용되며, 조건부 쓰기(conditional writes)가 격리되고 원자적으로 적용되도록 보장합니다. 다만 이름의 ‘경량’은 다른 데이터베이스의 트랜잭션 전략이나 2단계 커밋과 비교했을 때 경량인 것일 뿐, 절대적으로 가벼운 연산은 아닙니다. 팩소스 라운드를 거치는 만큼 일반 쓰기보다 훨씬 비쌉니다.

14.3.2 인메모리 데이터 그리드: 인피니스팬

분산된 거대한 HashMap

인피니스팬(Infinispan)은 레드햇이 제이보스 캐시(JBoss Cache)의 후속 제품으로 개발한 분산 캐시(distributed cache)이자 키-값 NoSQL 인메모리 데이터 저장소입니다. 단순한 인메모리 저장소를 넘어, 데이터를 더 영구적인 캐시 저장소에 지속할 수도 있습니다. 이 영속성은 플러그인 방식으로 구현되어 다양한 기존 시스템에 인피니스팬 데이터를 저장할 수 있습니다.

사용자 관점에서 인피니스팬은 java.util.Map을 확장한 Cache 인터페이스를 제공하므로, ‘여러 머신에 걸쳐 확장 가능한 대형 HashMap’ 으로 이해하면 됩니다. 자바 가상 머신이 아닌 플랫폼도 클라이언트로 지원합니다.

jgroups와 래프트 — 어디서 본 조각들

인피니스팬의 클러스터링 기능은 라이브러리나 프레임워크에 클러스터링(clustering)과 고가용성(high availability)을 추가하는 데 사용됩니다. 상태 관리를 인피니스팬에 위임하면, 인피니스팬이 제공하는 내부 분산 데이터 구조를 활용하여 클러스터링이 구현됩니다.

그 토대에는 jgroups 라이브러리가 있습니다. 신뢰할 수 있는 그룹 네트워크 통신을 제공하는 툴킷으로, 다음 기능을 지원합니다.

  • 노드 자동 검색(node discovery)
  • 포인트 투 포인트(point-to-point) 또는 포인트 투 멀티포인트(point-to-multipoint) 통신
  • 장애 감지(failure detection)
  • 데이터 전송(data transfer)

특히 장애 허용 기능은 jgroups-raft 컴포넌트를 기반으로 하며, 이름 그대로 14.2.2절의 래프트 프로토콜을 구현한 것입니다. 인피니스팬은 복제 계수(replication factor)를 설정할 수 있으며, 카산드라와 유사한 AP 시스템으로 동작합니다.

트랜잭션과 관측성

인피니스팬은 JTA(Java Transaction API)와 XA 확장 트랜잭션을 포함한 트랜잭션 기반 케이스를 지원하며, JTA 트랜잭션 관리자가 중재하는 분산 트랜잭션에도 참여할 수 있습니다. 다만 트랜잭션 기능은 선택적(optional)이므로, 애플리케이션 요구 사항에 따라 비활성화하여 성능을 최적화할 수 있습니다.

관측성 측면에서는 오픈텔레메트리(OpenTelemetry) 기반 추적을 포함합니다. 인피니스팬은 HTTP 기반이 아니지만 jgroups 프로토콜을 확장하여 오픈텔레메트리 지원을 추가했습니다.

14.3.3 이벤트 스트리밍: 카프카

발행-구독과 처리량 중심 설계

아파치 카프카(Apache Kafka)는 발행-구독(publish-subscribe) 패러다임에서 이벤트 스트림(event stream)을 제공하는 핵심 인프라 기술입니다. 이벤트는 두 가지 용도로 사용됩니다 — 액션을 트리거하는 용도와, 노드에 상태를 분배하는 용도입니다. 이는 마이크로서비스 아키텍처를 구현하는 일반적인 패턴입니다.

다른 메시징 시스템(JMS, AMQP 등)과 달리, 카프카는 확장성과 처리량을 주요 설계 목표로 개발되었습니다. 카프카는 클라이언트-브로커(client-broker) 시스템이며, 메시지는 클라이언트에서 카프카 브로커 프로세스 클러스터로 전송됩니다.

전송 계층에서의 인증(authentication)과 권한 부여(authorization) 기능도 제공하여, 분산 컴퓨팅 오류 중 ‘네트워크는 안전하다’는 잘못된 가정을 완화합니다. 물론 이 문제를 완전히 해결하려면 모든 마이크로서비스에서도 인증을 포함해야 합니다.

추가만 가능한 로그 — 카프카의 본질

카프카의 핵심은 순서가 보장된(ordered) 복제 로그(replicated log)입니다.

flowchart LR
    subgraph Log["복제 로그 (append-only)"]
        direction LR
        O1[처리됨] --> O2[처리됨] --> R1[읽는 중] --> R2[읽는 중] --> W[다음 쓰기 위치]
    end
    Read["읽기<br/>(단일 찾기 + 스캔)"] -.탐색.-> R1
    Write["쓰기<br/>(추가만 가능)"] --append--> W

카프카는 오직 추가(append)만 가능한 시스템으로, 업데이트 연산이 존재하지 않습니다. 즉, 읽기와 쓰기가 모두 순차적(sequential) 연산으로 이루어집니다. 그 결과 카프카는 메모리나 디스크 같은 기본 저장 매체의 선형적(linear) 특성을 활용하여 프리페치(prefetch)와 메모리 캐시(memory cache)를 최적화할 수 있고, 배치 작업을 함께 처리할 기회도 얻습니다.

이것이 카프카가 빠른 근본적인 이유입니다. 랜덤 액세스 대신 순차 액세스만 하므로, 디스크조차도 거의 메모리에 가까운 속도로 다룰 수 있습니다.

토픽·파티션·소비자의 관계

카프카 프로세서는 토픽(topic)을 구독하며, 모든 토픽은 하나 이상의 파티션(partition)으로 구성됩니다. 토픽은 본질적으로 여러 파티션에 대한 공통 구성과 라벨 역할을 하는 추상화입니다.

각 파티션은 다음 두 가지 속성을 가집니다.

  • 각 파티션은 정확히 하나의 소비자에 의해 소비됩니다.
  • 하나의 소비자는 여러 개의 파티션을 소비할 수 있습니다.

따라서 활성 소비자(active consumer)의 수는 파티션 개수보다 많을 수 없으며, 같거나 적어야 합니다. 이 제약 덕분에 카프카는 소비자 풀(consumer pool) 내에서 순서 보장과 로드 밸런싱을 동시에 제공할 수 있습니다 — 파티션이 곧 병렬성의 단위이자 순서의 단위입니다.

순서 보장의 범위

카프카는 동일한 파티션 내에서는 이벤트 순서를 보장합니다. 하지만 기본적으로 모든 파티션에 걸친 전체 순서는 보장하지 않습니다. 전역 순서가 필요하다면 파티션을 하나만 쓰거나, 키 설계로 같은 키를 같은 파티션에 몰아야 합니다.

ProducerRecord와 파티션 결정

살펴봐야 할 핵심 클래스는 org.apache.kafka.clients.producer 패키지의 ProducerRecord입니다.

public class ProducerRecord<K, V> {
    private final String topic;
    private final Integer partition;
    private final Headers headers;
    private final K key;
    private final V value;
    private final Long timestamp;
    // ...
}

대부분의 배포에서 대상 파티션은 키의 해시를 기반으로 계산됩니다. 동일한 키를 가진 메시지가 항상 동일한 파티션에 배치되도록 보장하므로, 항상 순서가 유지됩니다.

이 동작은 변경할 수도 있습니다 — 명시적으로 파티션 번호를 제공하거나, 카프카가 메시지를 라운드 로빈 방식으로 분배하도록 설정할 수 있습니다. 단, 라운드 로빈을 사용하면 동일한 키를 가진 서로 다른 메시지가 서로 다른 소비자에게 전달될 수 있어, 순서 보장이 깨질 수 있습니다.

주키퍼에서 KRaft로

카프카는 전통적으로 독립적인 주키퍼(ZooKeeper) 서비스를 사용했지만, 이제는 자체적으로 구현한 래프트인 KRaft를 사용합니다. 이로써 배포·유지보수·모니터링해야 할 구성 요소의 수가 줄어 카프카 도입이 다소 간편해졌습니다. 14.2.2절에서 본 래프트 프로토콜이 카프카의 메타데이터 관리 계층으로 내재화된 셈입니다.

14.4 Fighting Animals 개선

무엇을 개선하는가

battle에서 부상을 입은 animal을 치료하기 위한 VeterinaryHospital 서비스를 도입합니다. 이 hospital의 목적은 너무 많은 battle에 참여한 animal을 돌보는 것입니다.

앞 절에서 다룬 기술들로 hospital 서비스를 구현하는 방법은 여러 가지입니다.

  • 기존 서비스들이 animal이 battle에 참여할 때마다 카산드라의 공유 인스턴스에 데이터를 기록
  • 인피니스팬을 사용하여 현재 이용 가능한 모든 animal을 맵 컬렉션 형태로 저장하고, 각 서비스가 로컬 맵 대신 이를 참조
  • 기존 서비스들이 카프카를 통해 hospital과 통신

jgroups-raft로 기존 웹 서비스를 클러스터링하는 방법도 고려할 수 있지만, 기존 웹 서비스들은 사실상 무상태(stateless)이므로 거의 확실하게 과도한 설계입니다. 무상태 서비스의 부하 분산은 쿠버네티스의 로드 밸런싱으로 처리하는 것이 더 적절합니다 — 도구의 성격에 맞는 책임을 부여하는 전형적인 판단입니다.

여기서는 우선 카프카를 통한 통신을 선택하여, 카프카로 업데이트를 수신하는 VeterinaryHospital의 초기 버전을 구현하고 이후 반복적으로 개선해 나갑니다.

14.4.1 Fighting Animals에 카프카 도입

세 가지 구축 요소

카프카를 도입하려면 세 가지가 필요합니다.

  1. 카프카 인프라 — 카프카 브로커 클러스터
  2. hospital 서비스 — 여전히 마이크로서비스이지만, HTTP가 아니라 카프카 토픽에서 메시지를 수신하는 방식으로 동작한다는 점이 기존 서비스와 다름
  3. 리프 서비스 수정 — animal을 반환하는 feline·fish·mustelid가 반환된 animal을 나타내는 카프카 메시지를 발행하도록 변경. 각 마이크로서비스는 자체 토픽을 가지므로 hospital은 이 모든 토픽을 수신
flowchart LR
    animal[animal 서비스] --> mammal[mammal 서비스]
    animal --> fish[fish 서비스]
    mammal --> feline[feline 서비스]
    mammal --> mustelid[mustelid 서비스]
    feline -.발행.-> kafka((카프카))
    mustelid -.발행.-> kafka
    fish -.발행.-> kafka
    kafka -.읽기.-> hospital[hospital 서비스]
카프카 인프라 설정

Fighting Animals 저장소의 distributed_systems 분기에 관련 코드가 포함됩니다. Debezium 프로젝트의 카프카 도커 이미지를 사용하며, latest 같은 유동적 버전이 아니라 고정된 버전을 사용합니다. 재현 가능한 빌드를 위한 기본적인 운영 규율입니다.

# Kafka
kafka-1:
  image: debezium/kafka:2.7.0.Final
  ports:
    - "19092:9092"
    - "19093:9093"
  environment:
    - CLUSTER_ID=g4xWbaRgd-b-zQYgIS1rY5
    - NODE_ID=1
    - KAFKA_CONTROLLER_QUORUM_VOTERS=1@kafka-1:9093,2@kafka-2:9093,3@kafka-3:9093

KAFKA_CONTROLLER_QUORUM_VOTERS가 바로 KRaft의 쿼럼 구성입니다 — kafka-2, kafka-3도 유사하게 구성하여 3노드 컨트롤러 쿼럼을 이룹니다. 리프 서비스는 카프카에 의존하도록 구성합니다.

# Feline service
feline-service:
  image: feline_demo:latest
  ports:
    - "8085:8085"
  depends_on:
    - kafka-1
    - kafka-2
    - kafka-3

작성 시점(2024년 8월) 기준 최신 버전은 별도 주키퍼 클러스터 없이 KRaft 프로토콜을 지원합니다. 단일 노드 클러스터를 쓰면 시작 로그의 불필요한 출력을 줄일 수 있지만, 클러스터링·리더 선출·KRaft 동작 과정을 관찰하는 것이 학습에 더 유익하다는 판단으로 다중 노드를 택했습니다.

리프 서비스를 프로듀서로 변경

각 리프 서비스는 자신의 선택을 카프카에 알리기 위해 적절한 토픽에 메시지를 발행해야 합니다.

@RestController
public class FelineController {
  private final List<String> CATS = List.of("tabby", "jaguar", "leopard");
 
  private final KafkaProducer<String, String> producer;
 
  public FelineController() {
    Properties properties = new Properties();
    properties.put("bootstrap.servers", "kafka-1:9092"); // PLAINTEXT
    properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
    properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
    producer = new KafkaProducer<>(properties);
  }
 
  @GetMapping("/getAnimal")
  public String makeBattle() throws InterruptedException {
    // 무작위 중단
    Thread.sleep((int) (20 * Math.random()));
 
    // 무작위로 cat을 반환하고, 동시에 카프카로 전송
    var cat = CATS.get((int) (CATS.size() * Math.random()));
    var key = UUID.randomUUID().toString();
    var producerRecord = new ProducerRecord<>("FELINE", key, cat);
    producer.send(producerRecord);
    return cat;
  }
}

생성자는 카프카 프로듀서를 설정하여 메시지를 브로커로 전송할 수 있도록 하는 역할만 합니다. ProducerRecord를 통해 토픽("FELINE")·키·값을 명시적으로 지정합니다.

토픽 자동 생성 확인

카프카가 활성화된 animal 서비스에 첫 요청을 전송하면 로그에서 다음과 같은 출력을 확인할 수 있습니다.

kafka-1-1  | 2024-07-14 09:13:19,275 - INFO  [data-plane-kafka-request-handler-7:Logging@66]
  - Sent auto-creation request for Set(MUSTELID) to the active controller.
kafka-1-1  | 2024-07-14 09:13:19,275 - INFO  [quorum-controller-1-event-handler:ReplicationControlManager@670]
  - [QuorumController id=1] CreateTopics result(s):
    CreatableTopic(name='MUSTELID', numPartitions=1, replicationFactor=1, assignments=[], configs=[]): SUCCESS
kafka-1-1  | 2024-07-14 09:13:19,276 - INFO  [quorum-controller-1-event-handler:ReplicationControlManager@457]
  - [QuorumController id=1] Replayed TopicRecord for topic MUSTELID with topic ID cnzLVYp8QOO7FIUd6mUkzg.

토픽과 파티션이 자동 생성·설정되는 과정을 보여줍니다 — 수정된 서비스들이 정상적으로 카프카에 연결되어 메시지를 전송하고 있다는 의미입니다. 다만 발신된(outgoing) 메시지를 별도로 로그에 기록하지는 않습니다. 다음은 이 스트림을 수신·처리할 hospital 서비스 차례입니다.

14.4.2 간단한 hospital 서비스

왜 쿼커스인가

hospital 서비스는 카프카 기반의 간단한 쿼커스(Quarkus) 서비스로 구현됩니다. 스프링부트와 쿼커스 구성 요소를 별도의 메이븐 프로젝트로 분리하는 것이 일반적으로 더 쉽기 때문에 설정이 약간 복잡해질 수 있습니다.

쿼커스에 익숙하지 않아도 괜찮습니다 — 이 예제의 목적 중 하나가 쿼커스가 얼마나 간단한지를 보여주는 것입니다. 특별한 쿼커스 지식 없이 메이븐과 도커만으로 충분하며, 기본 구조(skeleton)는 쿼커스 CLI 도구를 설치한 후 create app 명령으로 카프카 확장을 지정하면 생성됩니다.

단일 클래스 구현
@ApplicationScoped
public class VeterinaryHospital {
 
  public static final String FISH_CHANNEL = "fish";
  public static final String FELINE_CHANNEL = "feline";
  public static final String MUSTELID_CHANNEL = "mustelid";
 
  @PostConstruct
  public void init() {}
 
  void onStart(@Observes StartupEvent ev) {
    Log.infof("Hospital starting up");
  }
 
  @Incoming(FISH_CHANNEL)
  @Incoming(FELINE_CHANNEL)
  @Incoming(MUSTELID_CHANNEL)
  public CompletionStage<Void> processMainFlow(Message<String> message) {
    var payload = message.getPayload();
    var topic = message.getMetadata(IncomingKafkaRecordMetadata.class)
                    .get().getTopic();
 
    Log.infof("Processed: %s on topic %s, benching them for the next round", payload, topic);
 
    return message.ack();
  }
}

단일 @ApplicationScoped 클래스가 제어기 역할을 하며 카프카 메시지에 응답하도록 설계되었습니다. init()onStart() 콜백은 이 예제에서는 특별한 일을 하지 않지만, 더 복잡한 서비스라면 수명 주기의 특정 지점에서 작업을 수행할 수 있음을 보여주는 자리입니다.

수동 ACK — 마법을 줄이고 제어권을 얻는다

processMainFlow() 핸들러는 ‘Hello World’ 수준보다 약간 더 복잡합니다. 쿼커스는 카프카와 상호작용하는 여러 방법을 제공하지만, 이 예제에서는 message.ack()카프카 승인 응답을 수동으로 제어하는 방식을 선택했습니다.

가장 단순한 구현보다는 복잡하지만, ‘마법 같은’(magical) 동작을 최소화하고 더 명확한 제어권을 제공합니다. 핸들러의 반환 타입이 CompletionStage<Void>인 점은 이 코드가 더 큰 비동기 흐름의 일부로 사용될 가능성을 암시합니다.

모든 실행 경로에서 ack를 보장하라

수동 ACK(manual Kafka ACKs)를 사용하는 경우, 모든 실행 경로(예외 발생 경로 포함)에서 message.ack()가 호출된 후 종료되도록 보장해야 합니다. 그렇지 않으면 카프카 소비자의 오프셋 위치가 갱신되지 않아, 소비자가 동일한 메시지를 다시 소비할 수 있습니다(서비스 재시작 후에도 발생 가능). 메시지 중복 처리의 흔한 원인입니다.

@Incoming 주석이 세 번 반복된 점도 주목할 만합니다. 하나의 핸들러가 여러 수신 토픽(incoming topic)을 처리하도록 설계되었기 때문입니다. getMetadata()로 메시지가 수신된 토픽을 추출합니다 — 여러 토픽을 구독하되 출처를 신경 쓰지 않는다면 이 처리는 불필요합니다.

표준 카프카 소비자가 아니다

import 문에서 몇 가지 쿼커스 전용 클래스가 눈에 띕니다.

import io.quarkus.logging.Log;
import io.quarkus.runtime.StartupEvent;
import io.smallrye.reactive.messaging.kafka.api.IncomingKafkaRecordMetadata;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message;
import java.util.concurrent.CompletionStage;

이 서비스는 표준 카프카 소비자 클래스를 사용하지 않습니다. 대신 SmallRye와 마이크로프로파일의 리액티브 메시징(reactive messaging) 기능을 활용합니다. 이는 주로 구현상의 세부 사항으로, 쿼커스 프레임워크가 리액티브·비차단 동작을 처리해 주기 때문에 개발자는 도착하는 Message 객체를 처리하는 핸들러 구현에만 집중할 수 있습니다.

구성 파일
# 카프카 부트스트랩 설정은 모든 토픽에 적용
kafka.bootstrap.servers=kafka-1:9092,kafka-2:9092,kafka-3:9092
 
# 카프카 보일러플레이트
kafka.sasl.jaas.config = ""
kafka.sasl.mechanism = PLAIN
kafka.security.protocol = PLAINTEXT
 
# 입력 큐
mp.messaging.incoming.fish.connector=smallrye-kafka
mp.messaging.incoming.fish.topic=FISH
mp.messaging.incoming.feline.connector=smallrye-kafka
mp.messaging.incoming.feline.topic=FELINE
mp.messaging.incoming.mustelid.connector=smallrye-kafka
mp.messaging.incoming.mustelid.topic=MUSTELID
 
# 로그
quarkus.log.level=INFO

hospital이 여러 토픽을 수신해야 하므로 약간의 추가 복잡성이 있습니다 — 각 토픽을 mp.messaging.incoming 설정 블록(stanza)으로 개별 지정하고, 코드에서는 이에 맞는 반복된 주석을 사용합니다. hospital 서비스도 클러스터의 일부이므로 docker-compose.yml에 추가합니다.

# Hospital
hospital-service:
  image: hospital_demo:latest
  ports:
    - "8000:8000"
  depends_on:
    - kafka-1
    - kafka-2
    - kafka-3

docker compose up 실행 전에 메인 프로젝트와 hospital 프로젝트의 jar 파일·컨테이너를 모두 빌드해야 하므로 배포 과정이 다소 복잡해집니다.

14.4.3 활성 hospital

부상 정보를 되돌려 보내기

부상자가 계속 도착하는데 아무것도 못 하는 hospital은 쓸모가 없습니다. 다음 단계는 hospital이 다른 서비스들에게 **“이 animal은 부상을 입었으니 다음 fight에 포함되지 말아야 한다”**는 사실을 알리도록 만드는 것입니다.

이를 위해 인피니스팬을 원격 캐시로 사용합니다(관련 코드는 with_infinispan 분기). 또한 카프카 클러스터를 단일 노드로 축소합니다 — 다중 노드가 추가 복잡성과 시작 지연을 초래한다는 점을 확인했으므로, 이제는 인피니스팬 컴포넌트가 가져오는 변화에 집중하기 위함입니다.

flowchart LR
    animal[animal] --> mammal
    animal --> fish
    mammal --> feline
    mammal --> mustelid
    feline -.발행.-> kafka((카프카))
    mustelid -.발행.-> kafka
    fish -.발행.-> kafka
    kafka -.읽기.-> hospital
    hospital --> infinispan[(인피니스팬)]
    infinispan -.부상 조회.-> feline
    infinispan -.부상 조회.-> mustelid
    infinispan -.부상 조회.-> fish

분기 전환 시 정리

이전에 다중 노드 클러스터를 썼다면, 카프카를 쓰지 않는 분기(main)로 전환한 후 docker compose up --remove-orphans로 이전 카프카 컨테이너를 완전히 제거한 다음 단일 노드 분기로 전환해야 합니다.

hospital 측 변경 — 부상 animal 기록

hospital 서비스에 인피니스팬 클라이언트 의존성을 추가합니다.

<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-infinispan-client</artifactId>
</dependency>

VeterinaryHospital 클래스에 원격 캐시를 주입합니다.

@Inject
@Remote("animals")
RemoteCache<String, String> lastBattledAnimal;

그리고 processMainFlow()에서 message.ack() 호출 전에 마지막 battle에 참여한 animal을 캐시에 저장하는 한 줄을 추가합니다.

lastBattledAnimal.put(topic, payload);

캐시 구성은 application.propertiesanimals.yaml에 나뉘어 저장됩니다.

# Infinispan
quarkus.infinispan-client.cache.images.configuration-uri=animals.yaml
quarkus.infinispan-client.devservices.port=11222
quarkus.infinispan-client.hosts=infinispan-1:11222
quarkus.infinispan-client.username=xxxxxx
quarkus.infinispan-client.password=yyyyyy
replicatedCache:
  mode: "SYNC"
  statistics: "true"
  encoding:
    key:
      mediaType: "application/x-protostream"
    value:
      mediaType: "application/x-protostream"
  locking:
    isolation: "REPEATABLE_READ"
  indexing:
    enabled: "true"
    storage: "local-heap"
    startupMode: "NONE"
리프 서비스 측 변경 — 부상자 회피

메인 프로젝트의 모든 리프 서비스 제어기도 수정해야 합니다. FelineController의 변경 예시입니다.

@GetMapping("/getAnimal")
public String makeBattle() throws InterruptedException {
  // 무작위 중단
  Thread.sleep((int) (20 * Math.random()));
 
  // 마지막 부상당한 animal을 조회
  var injured = cacheManager.getCache(InfinispanConfiguration.CACHE_NAME)
                    .get("FELINE");
  String cat;
  do {
    Thread.sleep(1);
    cat = CATS.get((int) (CATS.size() * Math.random()));
    System.out.printf("Looking up uninjured cat - is %s OK?", cat);
  } while (injured == null || injured.equals(cat));
 
  // 무작위로 부상당하지 않은 cat을 반환하고, 동시에 카프카로 전송
  var key = UUID.randomUUID().toString();
  var producerRecord = new ProducerRecord<>("FELINE", key, cat);
  producer.send(producerRecord);
  return cat;
}

do 루프로 무작위 고양이를 선택하되, 부상당하지 않은 개체(hospital이 유지하는 캐시에 없는 개체)를 고를 때까지 반복합니다. 이를 위해 제어기에 RemoteCacheManager를 주입합니다.

@Autowired private RemoteCacheManager cacheManager;

animal 캐시 설정을 위한 구성 클래스도 필요합니다.

@Configuration
public class InfinispanConfiguration {
  public static final String CACHE_NAME = "animals";
 
  @Bean
  @Order(Ordered.HIGHEST_PRECEDENCE)
  public InfinispanRemoteCacheCustomizer caches() {
    return b -> {
      URI cacheConfigUri;
      try {
        cacheConfigUri = this.getClass().getClassLoader()
                        .getResource("animals.xml").toURI();
      } catch (URISyntaxException e) {
        throw new RuntimeException(e);
      }
      b.remoteCache(CACHE_NAME).configurationURI(cacheConfigUri);
    };
  }
}

메인 프로젝트의 application.properties와 캐시 구성 파일(animals.xml)도 추가합니다.

infinispan.remote.server-list=infinispan-1:11222
infinispan.remote.auth-username=xxxxxx
infinispan.remote.auth-password=yyyyyy
infinispan.remote.marshaller=org.infinispan.commons.marshall.ProtoStreamMarshaller
<?xml version="1.0"?>
<distributed-cache name="animals" mode="SYNC" statistics="false">
  <encoding media-type="application/x-java-object"/>
  <indexing enabled="false" />
</distributed-cache>

모든 구성 요소가 갖춰지면 인피니스팬 서버 시작 로그(ISPN080001: Infinispan Server 14.0.21.Final started in 10421ms)와 함께 전체 시스템을 실행할 수 있습니다.

부분적 가용성 — 이 설계의 핵심 교훈

이렇게 구축한 아키텍처는 부분적으로 비동기적입니다. HTTP 요청이 마이크로서비스에 도달한 후, 데이터는 카프카를 통해 hospital로 전달되고, hospital이 인메모리 캐시를 업데이트합니다. 이 설계는 부분적 가용성(partial availability)이라는 중요한 질문을 던집니다.

hospital 서비스를 사용할 수 없게 되면, animal 서비스는 계속 운영되어야 할까? 아니면 hospital의 장애 때문에 전체가 멈춰야 할까?

이 시나리오에서는 요청이 너무 빠르게 들어와 hospital에 수용된 animal이 다시 선택될 가능성이 있을 뿐이므로, hospital의 장애가 전체 시스템의 중단을 초래해서는 안 된다고 보는 것이 합리적입니다. 같은 원칙이 인프라 구성 요소(카프카·인피니스팬)에도 적용되어야 합니다. 즉, 부수적 기능의 장애가 핵심 흐름을 막지 않도록 의존성의 결합도를 의도적으로 낮춘 설계입니다.

마지막으로 강조되는 것이 관측성입니다. 분산된 인프라 구성 요소들은 반드시 관찰 가능해야 하며, 인프라를 포함한 클라우드 애플리케이션의 모든 구성 요소에서 무슨 일이 일어나는지 이해할 수 있어야 합니다. 이 예제에서는 명확성을 위해 인피니스팬 노드의 오픈텔레메트리를 비활성화했지만, 실제 프로덕션에서는 필수적입니다. 서비스와 구성 요소 간 동작을 관찰하고 연관시키는 능력은 점점 더 중요해지고 있습니다.

14.5 요약

이 장에서는 분산 시스템을 구축하기 위한 기본 구성 요소를 소개했습니다. 단일 머신의 동시성 데이터 구조를 분산 환경에 맞게 일반화한 구조와, 완전히 새로운 구조 모두를 다뤘습니다.

가장 일반적인 두 합의 프로토콜인 팩소스와 래프트를 개괄적으로 살펴봤고, 합의 알고리즘과 저수준 개념을 활용하여 높은 가용성을 제공하는 자바 기반 클라우드 인프라(카산드라·인피니스팬·카프카)의 사례를 검토했습니다. 마지막으로 이 중 두 가지(카프카·인피니스팬)를 활용해 Fighting Animals 예제를 개선하며 그 과정의 트레이드오프를 논의했습니다.

비교 / 트레이드오프

세 시스템의 역할 분담

이 장의 세 인프라는 경쟁 관계가 아니라 서로 다른 책임을 맡는 보완 관계입니다. Fighting Animals 예제가 이를 잘 보여줍니다 — 카프카가 이벤트 전달을, 인피니스팬이 공유 상태를, (선택지로 언급된) 카산드라가 영속 저장을 담당합니다.

시스템분류핵심 추상화합의/복제 기반일관성 모델
카산드라분산 데이터베이스컬럼패밀리·키스페이스팩소스(경량 트랜잭션)AP, 쿼리별 조정 가능
인피니스팬인메모리 데이터 그리드분산 Map/Cachejgroups-raftAP, 복제 계수 설정
카프카이벤트 스트리밍추가 전용 복제 로그KRaft(래프트)파티션 내 순서 보장

흥미로운 점은 세 시스템 모두 14.2절의 합의 프로토콜을 내부에 품고 있다는 사실입니다. 카산드라의 경량 트랜잭션은 팩소스, 인피니스팬과 카프카의 메타데이터 관리는 래프트를 씁니다. 앞 절에서 본 추상화가 학술적 이론이 아니라 매일 운영하는 인프라의 뼈대라는 점을 확인할 수 있습니다.

일관성 vs 성능의 위치 선택

카산드라의 일관성 수준(ONEALL)은 CAP 정리의 트레이드오프를 연산 단위로 미세 조정하는 도구입니다. 동일한 트레이드오프가 2단계 커밋의 “가장 느린 노드를 기다린다”, 카프카의 라운드 로빈 vs 키 해싱(순서 보장 포기 vs 유지)에서도 반복됩니다. 강한 보장은 항상 지연·가용성을 대가로 한다는 일관된 법칙입니다.

동기 HTTP vs 비동기 이벤트

Fighting Animals 개선은 동기 HTTP 호출 체인을 카프카 이벤트 + 원격 캐시로 바꾼 사례입니다. 이 전환의 본질은 hospital을 핵심 경로에서 떼어내 부분적 가용성을 확보한 것입니다 — 부수 서비스가 죽어도 핵심 흐름은 살아남습니다. 마이크로서비스에서 결합도를 낮추는 전형적 패턴입니다.

내 생각

일관성 수준은 코드가 아니라 비즈니스 결정이다

카산드라의 쿼리별 일관성 수준을 처음 보면 단순한 설정 옵션처럼 느껴지지만, 실무에서 이건 개발자가 아니라 도메인이 결정해야 하는 사안입니다. “이 데이터가 1초 늦게 보여도 되는가, 아니면 절대 안 되는가”는 결제팀·정산팀과 합의할 문제이지 ORM 설정 파일에 묻어둘 값이 아닙니다. ALL을 architecture smell이라 부르는 TIP이 인상 깊은 이유도 같습니다 — 강한 일관성을 코드로 욱여넣고 싶어질 때, 사실은 데이터 모델링이나 서비스 경계가 잘못됐다는 신호인 경우가 많습니다.

수동 ACK 경고는 멱등성과 한 묶음이다

message.ack()를 모든 경로에서 보장하라는 경고는 결국 at-least-once 전달의 현실을 말합니다. 카프카를 쓰는 순간 “메시지는 최소 한 번, 때로는 여러 번 온다”를 전제해야 하고, 그렇다면 컨슈머는 멱등하게 설계되어야 합니다. 이 예제처럼 put(topic, payload)로 덮어쓰는 연산은 자연스럽게 멱등하지만, 잔액 차감 같은 누적 연산이었다면 ack 보장만으로는 부족하고 처리 이력 테이블이나 dedup 키가 필요했을 것입니다.

”관찰 가능성” 반복은 빈말이 아니다

이 장이 14.1의 재분할 모니터링부터 14.5의 오픈텔레메트리까지 집요하게 관측성을 강조하는 건, 분산 시스템에서 장애는 “멈춤”이 아니라 “느려짐·일부만 실패”로 온다는 사실 때문입니다. 단일 노드 로그만 봐서는 split brain도, 재분할 폭주도, 쌓이는 컨슈머 lag도 보이지 않습니다. 트레이싱으로 서비스 간 인과를 연결하는 능력이 곧 분산 시스템 운영 역량 그 자체라는 메시지에 깊이 공감합니다.

관련 개념

출처

  • 『자바 최적화 2판』 14장 분산 시스템 기법과 패턴