들어가기
실시간성을 지원하는 서비스에서는 모든 데이터가 항상 완벽하게 일치되지 않는 상황이 있습니다. 예를 들어 SNS에서 누군가가 게시글을 업로드 했을 때, 친구 목록에는 바로 뜨지 않지만 몇 초 후에 보이는 경우가 있습니다. 이것을 최종적 일관성이라고 합니다.
이 글에서는 최종적 일관성이 무엇인지, 최종적 일관성을 바탕으로 시스템을 어떻게 설계할 수 있을지에 대해 설명하려고 합니다.
최종적 일관성이란
분산 시스템에서 최종적 일관성이란 시스템의 각 부분이 특정 시점에서는 서로 다른 데이터 상태를 가지고 있을 수 있단 것을 의미합니다. 하지만 추가적인 업데이트가 발생하지 않는다면 시스템은 모든 구성 요소가 최종적으로는 동일한 상태가 될 것을 보장합니다.
즉, 최종적 일관성을 기반으로 설계된 시스템은 노드 간의 순서 유지나 동기적 업데이트를 강제하지 않습니다. 대신 단기적으로 불일치를 허용합니다. 위의 게시글이 몇몇 사용자에게 보이지 않았던 것처럼 말이죠. 이런 불일치는 업데이트가 비동기적으로 전파되면서 점점 해결됩니다. 여기서 데이터가 업데이트된 시점부터 모든 노드가 일관된 상태에 도달하기까지의 시간 구간을 window of inconsistency라고 합니다. 이 window of inconsistency가 넓을수록 사용자들이 불일치를 인지할 가능성이 높아집니다. 물론 window of inconsistency를 줄일 수는 있지만 이를 0으로 만드는 것은 강한 일관성이 필요하고 이는 가용성과 레이턴시에서 큰 트레이드 오프를 가져옵니다.
최종적 일관성을 택하는 것은 사용자들의 편리성을 위함입니다. 완벽보다는 불일치가 존재하는 대신 빠른 응답을 주는 것이 더 편리성을 높여줄 수 있습니다. 즉, 속도나 가용성, 복원력 등의 트레이드 오프를 고려한 엔지니어링 결정입니다.
하지만 최종적 일관성이 최종적 정합성을 의미하는 것은 아닙니다. 정합성을 보장하기 위해선 추가적인 보완책이 필요합니다. 또한 이벤트들이 서로 다른 노드에서 서로 다른 순서로 도착할 수도 있기 때문에 이런 문제들에 대한 대비책이 필요합니다.
Event-Driven Architecture(이벤트 기반 아키텍쳐, EDA)

기존의 시스템에서는 사용자가 주문을 하거나 사진을 업로드 하는 등 변화가 발생할 때마다 필요한 기록을 즉시 업데이트합니다. 하지만 EDA에서는 이러한 변화를 하나의 이벤트로 생각합니다. 서비스들을 이러한 이벤트를 발행하고 비동기적으로 이에 반응합니다. 이벤트는 하나의 기록입니다. "사용자 A가 주문 1을 생성했다."와 같이 무슨 일이 발생했는지를 기록합니다. 이런 이벤트에 기반한 통신은 디커플링을 제공합니다. 디커플링은 프로듀서와 컨슈머가 독립적으로 확장할 수 있게 해줍니다. 또한 실패가 격리되어 컨슈머가 일시적으로 중단되도 이후에 다시 따라잡을 수 있는 복원력을 제공합니다. 하지만 디커플링은 네트워크 지연이나 재시도 과정에서 이벤트 순서가 뒤바껴 도착하거나 중복된 이벤트 처리가 발생할 수 있는 단점도 있습니다.
그렇다면 왜 EDA에서는 강한 일관성이 아닌 최종적 일관성을 따르는 걸까요? 물론 위에서 사용자의 편리성을 위함이라고 밝혔지만 본질적으로 들어가보면 분산 시스템의 한계와 연관되어 있습니다. 분산 시스템에서 강한 일관성을 유지하려면 모든 서비스가 성공적으로 완료되거나 모두 실패해야 합니다. 이것은 네트워크가 정상적으로 동작할 때는 아무 문제가 없습니다. 하지만 네트워크에 장애가 발생하면 문제가 시작됩니다. 만약 강한 일관성을 따른다면 네트워크에 장애가 발생했을 때 전체 시스템의 일관성을 위해 시스템은 멈춰있어야 합니다. 하지만 사용자에게 이런 상황은 유익한 경험을 주지 못합니다. 그렇기에 일관성보단 가용성을 택해 일시적으로 불일치 하더라도 결국에는 일관성이 복원될 것이라는 가정하에 사용자에게 응답을 줍니다. 이 내용은 CAP 이론으로 정형화되어 있으니 학습해보길 권장드립니다.
순서가 뒤바뀐 이벤트 처리
EDA에서는 이벤트의 순서가 뒤바뀔 수 있습니다. 만약 이를 고려하지 않으면 잘못된 순서로 업데이트가 덮어써지거나 사용자에게 오래된 상태가 노출될 수도 있습니다. 그렇기에 이벤트의 순서가 뒤바뀔 것을 가정하고 대응 전략을 세워야 합니다.
Event Versioning
이벤트 버저닝은 각 이벤트마다 버전이나 시퀀스 번호를 명시해주는 방법입니다. 서비스가 이벤트를 처리할 때는 수신된 이벤트의 버전을 현재 자신이 보유한 버전과 비교하여 더 최신이면 이벤트를 적용하고 만약 이전 버전이면 무시하고 중복이면 멱등하게 처리하는 방식으로 처리해줍니다. 만약 버저닝이 없다면 나중에 도착한 이전 이벤트가 최근 이벤트를 덮어씌어 시스템의 진행 상태를 과거로 되돌릴 수 있습니다.
Idempotent Event Handlers
멱등성(idempotency)은 동일한 이벤트를 여러 번 처리하더라도 항상 같은 결과를 보장합니다. 여기서 같은 결과란 것은 응답이 같다는 뜻이 아닌 서버의 상태가 같다는 것을 의미합니다. 이를 통해 이벤트가 재시도 되거나 중복되는 경우에도 시스템을 보호해줍니다. 이런 멱등성을 확보하기 위한 기법으로는 이미 처리된 이벤트 ID를 기록하여 중복을 건너뛰거나 업데이트 방식을 덧셈(출하 건수 증가)이 아닌 덮어쓰기 방식("출하됨" 상태로 설정)으로 처리하는 방식이 있습니다. 또한 Repository 계층에서 조건부 쓰기 또는 CAS 연산을 사용하는 방법도 있습니다.
Reordering Buffers
만약 이벤트가 예측 가능한 패턴(버전에 순차적인 번호를 부여)을 가진다면 순서 재정렬 버퍼를 사용할 수 있습니다. 버퍼에 들어온 이벤트를 저장하여 버전이나 타임스탬프를 기준으로 순서를 재정렬하고 순서가 안정된 뒤에 처리하는 방식입니다.
여기서 버퍼 크기와 타임아웃 시간을 적절히 선택하는 것이 중요합니다. 만약 너무 작으면 순서가 뒤바뀐 이벤트가 그대로 통과될 수 있고, 너무 크면 레이턴시가 불필요하게 커질 수 있습니다.
State Reconciliation: Fixing Inconsistencies Later
상황에 따라 버퍼링이나 순서 재정렬 전략도 데이터 수신 시점에는 완벽한 상태를 보장할 수 없습니다. 이런 경우, 시스템은 정기적으로 서비스들을 스캔하고 비교하여 수정하는 상태 조정 작업을 통해 불일치를 해결할 수 있습니다. 즉, "모든 오류를 사전에 방지하자"라는 관점이 아닌 "오류를 빠르게 감지하고 수정하자"라는 관점을 의미합니다.
멱등성과 중복 이벤트 처리
만약 메시징 시스템이 exactly-once delivery(정확히 한 번만 전달)을 보장한다면 중복 상황을 고려하지 않아도 됩니다. 하지만 이는 이상적인 얘기입니다. 예를 들어 프로듀서가 이벤트를 전송했지만 네트워크 문제로 응답(ACK)을 받지 못하면 프로듀서에서 이벤트를 재전송합니다. 또는 컨슈머가 이벤트를 처리했지만 완료 응답을 보내기 전에 장애가 발생하면 시스템이 해당 이벤트를 재전송합니다. 즉, at-least-once(최소 한 번 전달)을 통해 중복이 발생하더라도 유실이 발생하지 않는 방법을 택합니다. 그렇기에 애플리케이션에서 중복을 처리해줘야 합니다. 이를 위해 서비스는 멱등성을 고려하여 설계해야 합니다. 아래는 멱등성을 지원하기 위한 몇 가지 핵심 전략들입니다.
Event ID
각 이벤트마다 고유한 ID를 부여합니다. 컨슈머가 이벤트를 받게되면 처리하기 전에 해당 이벤트 ID가 이전에 처리된 적이 있는지 확인합니다. 만약 처리된 적이 있다면 해당 이벤트를 무시하고 처리된 적이 없다면 이벤트를 처리한 뒤 해당 ID를 처리 완료로 기록합니다.
여기서 고려할 점은 이벤트 ID는 재시도나 실패가 존재해도 반드시 Unique한 값이어야 합니다. 또한 처리한 ID를 조회하는 저장소는 일관성과 성능을 모두 갖춰야 합니다.
Deduplication Caches
모든 이벤트 ID를 저장하고 조회하는 방식은 많은 리소스를 필요로 합니다. 그렇기에 절충안으로 중복 제거 캐시를 사용합니다. 먼저 최근 이벤트 ID를 Redis와 같은 인메모리 저장소에 TTL과 함께 저장합니다. 만약 TTL 이내에 중복된 이벤트가 온다면 캐시를 통해 빠르게 조회한 뒤 무시합니다. 이 방식은 중복 이벤트가 TTL 이내에 다시 나타난다는 가정하에 사용하는 방식입니다.
Natural Idempotency
이 방식은 위에 언급한 업데이트 방식을 덮어쓰기 방식으로 사용하는 방법입니다. 예를 들어 배송 상태를 "배송 완료" 상태로 설정하거나, 사용자 프로필을 업데이트하면 전체를 덮어쓰기하고, 장바구니의 기존 내용에 덧붙이는 것이 아닌 전체 내용을 교체하는 방법을 사용하는 것입니다.
Transactional Writes
Transactional Writes는 1) 이벤트 id가 존재하는 지 확인 2) 비즈니스 로직 실행을 하나의 트랜잭션으로 실행해 원자적 연산을 보장하는 방식입니다. 이는 동시성 문제를 해결하기 위한 방식입니다. 만약 동시에 서로 다른 컨슈머가 이벤트 id가 존재하는지 확인하게 되면 두 개의 컨슈머 모두 해당 이벤트가 아직 처리되지 않았다고 판단하여 중복이 발생하게 됩니다. 그렇기에 동시성을 DB단에서 처리하도록 하여 중복 처리가 발생하지 않도록 하는 방식입니다.
Dead Letter Queues (DLQs)
EDA에서는 처리할 수 없는 이벤트가 발생할 수 있습니다. 이는 잘못된 형식의 페이로드나 의존하는 서비스의 장애, 비즈니스 로직의 변경으로 과거 이벤트가 더 이상 유효하지 않는 등 여러 상황에서 발생할 수 있습니다. 이때 해당 이벤트를 계속 재시도 하는 것은 리소스를 과하게 낭비합니다. 그렇기에 해당 문제가 되는 이벤트를 분리해서 문제를 격리할 필요가 있습니다. 이때 사용되는 것이 Dead Letter Queue입니다.
실패한 이벤트가 바로 DLQ로 가는 것은 아닙니다. 일시적인 시스템의 장애로 실패한 이벤트는 일정 시간이 지나면 성공할 수 있기 때문입니다. 그렇기에 EDA에서는 실패한 이벤트의 경우 다시 재시도하도록 동작합니다. 하지만 재시도 횟수가 일정 횟수를 넘거나 제한 시간을 초과하게 되면 그때 DLQ로 이동시킵니다. 이렇게 DLQ로 이동한 이벤트는 추가적인 검토를 거쳐 문제를 해결하고 제시도하거나 해당 이벤트는 취소하게 됩니다.
최종적 일관성에서의 안티 패턴
DLQ 없이 과도한 재시도
EDA에서 실패한 이벤트를 끝없이 재시도하면서 격리하지 않는 것은 큰 리소스를 낭비하는 행위입니다. 이것은 결국 정상적인 이벤트의 흐름을 방해하고 레이턴시를 증가시키며 전체 처리량을 저하시키는 결과를 도출합니다. 그렇기에 일정 횟수 이상 실패한 작업은 DLQ로 격리하여 운영자가 해당 문제를 분석할 수 있도록 해야합니다.
강한 결합
강하게 결합된 서비스는 결국에는 동기적인 흐름을 발생시킵니다. 그렇기에 장애를 고립시키거나 복원력이나 확장성을 저해합니다. 그렇기에 각 서비스가 이벤트를 독립적으로 소비할 수 있도록 설계하여 커플링을 낮춰야 합니다. 만약 조정이 필요한 상황에는 아웃박스 패턴이나 Saga 패턴, 보상 트랜잭션과 같은 방식을 사용하여 비동기성을 지원해줘야 합니다.
이벤트가 항상 순서대로 도착한다고 가정
이벤트가 항상 순서대로 도착한다는 것은 매우 이상적인 상황입니다. 실제 환경에선 언제든지 이벤트의 순서가 뒤바뀔 수 있습니다. 그렇기에 서비스는 이벤트 처리를 순서에 의존하지 않고 이벤트 버전이나 타임스탬프를 사용하여 해당 이벤트를 적용할 지 무시할 지를 선택해야 합니다.
멱등성 무시
메시징 서비스는 at-least-once 원칙을 따릅니다. 그렇기에 언제든 중복이 발생할 수 있습니다. 만약 사용자 결제 이벤트가 중복으로 처리되면 사용자에게 안 좋은 경험을 줄 수 있습니다. 그렇기에 멱등성 있게 설계하여 중복 처리가 발생하지 않도록 지원해주는 것이 중요합니다.
참고자료
Engineering Trade-offs: Eventual Consistency in Practice
Engineering Trade-offs: Eventual Consistency in Practice
Modern applications don’t run on a single database or monolithic backend anymore. They run on event-driven, distributed systems.
blog.bytebytego.com
'System Architecture' 카테고리의 다른 글
| HA 아키텍처 (0) | 2025.07.12 |
|---|---|
| 메시징 패턴: Pub-Sub, Queues, and Event Streams (1) | 2025.06.05 |
| Slack은 어떻게 하루 수십억 개의 메시지를 처리할까 (0) | 2025.05.22 |
| 마이크로서비스 설계 패턴 핵심 요약 (0) | 2025.05.10 |
| 당신이 알아야 할 시스템 설계의 핵심 개념 20가지 - 2. 캐싱 (0) | 2025.05.01 |