분산 시스템은 응답 없는 요청을 어떻게 다룰까¶
서버를 여러 대로 늘리면 용량과 장애 대응 능력을 얻지만, 상대가 죽었는지 느린지 알 수 없는 문제가 생깁니다. 한 번의 쓰기 요청으로 그 불확실성을 따라갑니다.
결제 서버가 데이터베이스에 승인 결과를 저장했고, 성공 응답을 보내는 순간 네트워크가 끊겼다고 하자. 클라이언트가 본 것은 시간 초과뿐이다. 하지만 결제는 실행됐을 수도 있고 실행되지 않았을 수도 있다. 같은 요청을 그대로 다시 보내면 결제가 두 번 처리될 수 있다.
한 프로세스 안의 함수 호출에서는 예외가 어디서 났는지 비교적 명확하다. 네트워크 너머에서는 메시지가 늦거나, 중복되거나, 순서가 바뀌거나, 응답만 사라질 수 있다. 상대 프로세스가 멈춘 것과 잠시 느린 것도 완벽하게 구분할 수 없다. 분산 시스템의 핵심은 서버 수가 아니라 이런 부분 실패와 불확실성을 다루는 계약이다.
일관성은 모든 복제본이 매 순간 같다는 한 문장이 아니다¶
같은 데이터가 여러 노드에 있으면 사용자가 어떤 순서와 최신성을 볼 수 있는지 정해야 한다. 선형 일관성(linearizability)은 각 연산이 호출과 응답 사이의 한 시점에 원자적으로 일어난 것처럼 보이게 한다. 성공한 업데이트 뒤의 조회는 그 업데이트보다 오래된 값을 보여서는 안 된다.
최종 일관성(eventual consistency)은 새 업데이트가 멈추면 복제본들이 언젠가 같은 값으로 수렴한다는 약속이다. 사용자가 언제 최신 값을 볼지, 동시에 바뀐 두 값을 어떤 규칙으로 합칠지는 이 이름만으로 정해지지 않는다. “최종적으로 맞는다”는 말 대신 최대 지연과 충돌 해결 규칙을 적어야 한다.
네트워크 분할은 노드가 살아 있지만 서로 통신하지 못하는 상태다. 이때 양쪽 쓰기를 모두 성공시키면 가용성은 유지하지만 서로 다른 값이 생길 수 있다. 하나의 정족수와 통신하는 쪽만 쓰기를 허용하면 모순되는 확정을 막는 대신 일부 요청을 실패시킨다. 업무마다 이 선택이 다르다. 상품 설명은 잠시 오래돼도 되지만 마지막 재고나 잔액은 같은 허용 범위를 쓰기 어렵다.
복제는 장애를 견디고 쓰기 순서를 맞추는 일을 추가한다¶
복제는 같은 데이터의 사본을 여러 노드에 둔다. 리더 기반 복제에서는 리더 하나가 쓰기 순서를 정하고 팔로워가 로그를 따라간다. 필요한 복제본의 확인을 기다린 뒤 성공을 응답하면 리더 하나가 사라져도 확정된 기록을 유지할 가능성이 커진다. 대신 가장 느린 확인과 네트워크 왕복이 쓰기 지연에 들어온다.
비동기 복제는 리더가 복제본 반영을 기다리지 않아 빠르지만, 장애 직전 변경이 새 리더에 없을 수 있다. 조회를 복제본으로 분산하면 방금 쓴 값보다 오래된 결과를 볼 수도 있다. “복제본이 세 개”보다 성공 응답 전에 몇 개가 어디까지 기록했는지, 장애 조치 때 어떤 사본을 승격하는지가 실제 보장이다.
리더가 없는 구조는 여러 복제본에 직접 읽고 쓰고 정족수와 충돌 해결 규칙을 사용할 수 있다. 특정 노드 하나에 쓰기가 집중되지 않지만, 동시에 작성된 버전과 복구 조회를 애플리케이션이 이해해야 할 수 있다.
데이터 분할은 복제와 다른 문제를 푼다¶
복제가 같은 데이터의 사본을 늘린다면, 샤딩은 키 공간을 나눠 서로 다른 데이터를 여러 노드에 둔다. 한 노드의 저장 용량과 쓰기 처리량을 넘을 때 필요하다.
범위 분할은 날짜나 식별자 구간 조회에 유리하지만 최근 날짜나 특정 구간에 쓰기가 몰릴 수 있다. 해시 분할은 키를 비교적 고르게 흩뜨리지만 범위 조회와 가까운 데이터의 지역성을 잃는다. 특정 유명 사용자처럼 한 키에 요청이 몰리는 핫 키는 해시로도 나뉘지 않는다.
분할 뒤에는 모든 조각을 건너는 조회, 보조 인덱스, 조각 사이 트랜잭션과 데이터 재분배가 새 문제가 된다. 현재 병목이 읽기 부하라면 복제본으로 해결할 수 있고, 큰 분석 질의 하나가 원인이라면 별도 분석 경로가 더 맞을 수 있다. “규모가 커졌으니 샤딩”이 아니라 어떤 단일 노드 자원이 지속적으로 한계에 닿았는지 먼저 측정한다.
장애 감지는 사실 판정이 아니라 의심의 강도를 정한다¶
노드는 주기적인 하트비트나 응답 시간을 보고 상대를 의심한다. 시간 제한을 짧게 하면 장애 조치를 빨리 시작하지만 일시적인 GC 정지나 네트워크 혼잡을 장애로 오판하기 쉽다. 길게 잡으면 오판은 줄지만 실제 장애 복구가 늦어진다. 완벽한 장애 감지기는 없으므로, 잘못된 의심이 생겨도 두 리더가 동시에 쓰지 못하게 별도 안전장치가 필요하다.
가십(gossip)은 각 노드가 자신이 아는 구성원과 상태 정보를 일부 이웃에게 반복해서 전달하는 방식이다. 중앙 서버 하나 없이 큰 클러스터에서 정보가 퍼지지만 모든 노드가 즉시 같은 구성원 목록을 보지는 않는다. 라우팅과 복제는 잠시 오래된 구성원 정보를 사용해도 복구할 수 있어야 한다.
벽시계만으로 사건의 인과 순서를 증명할 수 없다¶
서버 시계는 서로 조금씩 어긋나고 동기화 과정에서 앞뒤로 움직일 수 있다. 서버 A의 기록 시간이 더 작다고 항상 먼저 일어난 사건은 아니다.
램포트 시계(Lamport clock)는 사건이 다른 사건의 원인이면 논리 시간도 더 크게 되도록 만든다. 두 사건이 동시에 독립적으로 일어났는지는 완전히 구분하지 못한다. 벡터 시계(vector clock)는 노드별 진행 정보를 함께 보관해 인과와 동시성을 더 잘 표현하지만 노드 수에 따라 메타데이터가 커진다.
일반 업무에서 모든 논리 시계를 직접 구현할 필요는 없다. 다만 여러 서버의 생성 시간을 “절대적인 최신 순서”로 사용할 때 시계 오차와 동시 업데이트를 어떻게 처리할지 질문해야 한다.
리더 선출과 분산 잠금에는 권한 만료 뒤의 쓰기를 막는 장치가 필요하다¶
리더 선출은 한 시점에 하나의 조정자가 쓰기 순서나 작업 배정을 맡게 한다. 분산 잠금도 특정 자원에 한 작업자만 들어가게 하려는 목적이 있다. 문제는 이전 리더가 긴 정지 뒤 돌아와 자신이 여전히 권한이 있다고 믿는 경우다.
잠금의 만료 시간만 믿으면 이전 소유자와 새 소유자가 동시에 외부 저장소를 바꿀 수 있다. 펜싱 토큰(fencing token)은 새 권한을 얻을 때마다 증가하는 번호다. 실제 자원을 수정하는 저장소가 이전보다 큰 토큰만 받아들이면 늦게 돌아온 옛 리더의 쓰기를 거절할 수 있다. 잠금을 얻었다는 사실뿐 아니라 보호할 자원까지 이 토큰을 검사해야 의미가 있다.
합의는 여러 복제본이 하나의 로그 순서를 결정하게 한다¶
합의는 장애와 메시지 지연 중에도 여러 노드가 하나의 값이나 명령 로그 순서를 결정하는 문제다. 안전성은 어떤 지연이 있어도 서로 모순되는 두 값이 동시에 확정되지 않는 성질이고, 활성은 네트워크와 노드가 필요한 조건으로 회복됐을 때 언젠가 다음 결정을 내리는 성질이다. 안전을 지키려고 네트워크 분할 동안 소수 쪽이 잠시 멈출 수 있다.
정족수는 결정을 인정하기 위해 필요한 겹치는 노드 집합이다. 세 노드 가운데 두 노드, 다섯 노드 가운데 세 노드가 과반 정족수다. 두 과반은 적어도 한 노드에서 겹치므로 이전 결정 정보를 전혀 모르는 새 과반이 따로 생기기 어렵다. 세 노드를 네 노드로 늘려도 정족수는 세 개라 허용 장애 수가 늘지 않아 흔히 홀수 구성을 쓴다.
Raft는 합의를 임기, 리더 선출, 로그 복제와 커밋 규칙으로 나눠 설명한다. 클라이언트 쓰기는 리더의 로그에 추가되고, 리더는 팔로워의 앞선 로그와 일치하는 지점을 확인하며 새 항목을 복제한다. 안전한 과반에 기록된 현재 임기 항목을 기준으로 커밋을 진전시킨다. 리더가 바뀌면 확정되지 않은 로그 끝부분은 새 리더의 이력에 맞춰질 수 있다.
Paxos는 증가하는 제안 번호와 서로 겹치는 정족수를 사용해 다른 두 값이 함께 선택되지 않게 한다. Multi-Paxos는 안정된 리더 아래에서 반복되는 합의 비용을 줄인다. Raft와 Paxos의 이름을 제품 점수처럼 비교하기보다, 실제 구현이 디스크 기록·구성 변경·스냅샷·느린 노드와 로그 복구를 어떻게 다루는지 본다.
합의는 여러 업무 시스템의 트랜잭션을 자동으로 하나로 묶지 않는다. 복제된 상태 기계가 같은 명령을 같은 순서로 적용하게 하는 기반이다. 주문·결제·배송의 부수 효과는 다음 패턴처럼 별도의 업무 설계가 필요하다.
재시도는 멱등성과 중복 제거 기간까지 한 묶음이다¶
시간 초과는 기다림을 끝내는 클라이언트의 결정이지 서버가 실행하지 않았다는 증거가 아니다. 재시도에는 지수 백오프와 무작위 지연을 넣어 장애 순간의 동시 폭주를 줄이고, 전체 재시도 예산으로 한 요청이 부하를 무한히 늘리지 못하게 한다.
결제처럼 부수 효과가 있는 요청에는 업무별 멱등성 키를 사용한다. 서버는 키와 처리 결과를 업무 변경과 같은 트랜잭션에 저장한다. 같은 키가 다시 오면 새 결제를 만들지 않고 이전 결과를 반환한다. 중복 제거 기록을 일주일 보관하는데 클라이언트가 한 달 뒤에도 같은 키를 재사용할 수 있다면 오래된 중복은 다시 실행될 수 있다. 재시도 가능 기간과 기록 보관 기간을 맞춰야 한다.
Saga·Outbox·CQRS·이벤트 소싱·CDC는 각각 다른 틈을 다룬다¶
Saga는 여러 서비스의 로컬 트랜잭션을 이어 실행하고, 중간 실패 시 이미 끝난 업무를 보상 작업으로 상쇄한다. 결제 승인 뒤 배송 준비가 실패했다면 시간을 되돌리는 것이 아니라 결제 취소라는 새 업무를 실행한다. 보상도 실패할 수 있으므로 재시도와 수동 복구 상태가 필요하다.
트랜잭셔널 아웃박스(Transactional Outbox)는 업무 행과 발행할 사건을 같은 데이터베이스 트랜잭션에 기록한다. 데이터베이스 커밋은 됐지만 메시지 발행 전에 프로세스가 죽어 사건이 사라지는 이중 쓰기 틈을 줄인다. 전달기는 같은 사건을 다시 보낼 수 있으므로 소비자의 멱등성은 여전히 필요하다.
CDC(Change Data Capture, 변경 데이터 캡처)는 데이터베이스 변경 로그를 읽어 다른 시스템으로 전달한다. 아웃박스 전달이나 분석 저장소 적재에 쓸 수 있다. 로그 보관, 스키마 변경, 순서와 전체 재적재를 운영해야 한다.
CQRS(Command Query Responsibility Segregation, 명령과 조회 책임 분리)는 업데이트 모델과 조회 모델을 서로 다른 필요에 맞게 나눈다. 조회는 빨라질 수 있지만 변경이 조회 모델에 반영되는 지연과 재구축 절차가 생긴다.
이벤트 소싱(Event Sourcing)은 현재 상태보다 상태를 만든 사건 이력을 원본으로 둔다. 과거 상태 재구성과 감사에 유리하지만 사건 스키마를 오래 호환하고, 재생할 때 외부 이메일이나 결제가 다시 실행되지 않게 하며, 긴 이력을 빠르게 복원할 스냅샷을 관리해야 한다.
이 다섯 이름은 “분산 시스템 모범 답안” 묶음이 아니다. 현재 틈이 데이터베이스와 메시지의 이중 쓰기인지, 여러 서비스의 업무 보상인지, 읽기 모델 분리인지에 맞는 최소 도구만 선택한다.
분산 설계의 결론은 실패 시 사용자에게 보이는 상태다¶
RTO(Recovery Time Objective, 복구 시간 목표)는 서비스를 다시 사용할 수 있을 때까지 허용 시간이고, RPO(Recovery Point Objective, 복구 시점 목표)는 복구 때 허용할 데이터 손실 범위다. “고가용성”이라는 말 대신 노드·지역 장애별 RTO와 RPO, 쓰기 성공 조건을 적는다.
좋은 설계 기록은 다음 질문에 답한다. 시간 초과 난 쓰기의 결과를 사용자가 어떻게 다시 확인하는가. 네트워크가 갈라졌을 때 어느 쪽이 쓰기를 멈추는가. 오래된 리더의 쓰기를 실제 저장소가 어떻게 거절하는가. 중복 요청 기록과 이벤트를 얼마나 오래 보관하는가. 이 답이 없다면 복제본 수나 알고리즘 이름이 많아도 업무는 안전하지 않다.