C: Consistency
CAP에서 C는 보통 linearizability(선형화 가능성)를 뜻한다. 완료된 모든 연산이 실제 시간 순서를 지키는 하나의 원본에서 실행된 것처럼 보여야 한다.
쓰기 완료 뒤 시작한 읽기는 그 쓰기 또는 더 새로운 값을 봐야 한다.분류: Layer 9 - 아키텍처 & 설계 패턴
분산 시스템은 네트워크로 연결된 여러 노드가 상태와 작업을 나누면서 하나의 서비스처럼 동작하는 시스템이다. 노드를 늘리면 처리량과 장애 격리 가능성은 커지지만, 부분 실패(partial failure) 와 관측 불확실성이 생긴다. 한 노드는 성공했는데 다른 노드는 실패할 수 있고, 응답이 없을 때 상대가 죽었는지 느린지 알 수 없다.
Twelve-Factor App이 프로세스를 배포 환경에서 교체 가능한 실행 단위로 만드는 런타임 계약을 세워도, 프로세스 사이 통신이 네트워크를 건너는 순간 부분 실패는 사라지지 않는다. 이 문서는 그 불확실성을 다루는 모델과 메커니즘을 제공하고, 다음 MSA 패턴은 이를 서비스 경계와 데이터 소유권에 적용한다.
CAP 정리는 이 불확실성의 한 경계를 설명한다. 네트워크 파티션이 실제로 발생한 동안에는 선형화 가능 일관성과 모든 비고장 노드의 응답 가능성을 동시에 보장할 수 없다. 따라서 설계의 핵심 질문은 “AP인가 CP인가”라는 라벨이 아니라 다음 세 가지다.
transaction-basics에서 배운 ACID 트랜잭션은 한 데이터베이스 경계 안의 여러 변경을 하나의 커밋으로 묶는다. 선택 비교로 HTTP 캐시를 보면, 캐시는 원본 왕복을 줄이는 대신 정해진 시간 동안 오래된 응답을 허용한다. 두 개념 모두 분산 시스템의 재료지만, 노드가 여러 개가 되면 새로운 질문이 생긴다.
단일 프로세스에서는 함수가 반환되지 않으면 그 프로세스 안의 상태를 조사할 수 있다. 분산 호출에서는 네트워크가 관측 경계를 가린다. 호출자는 상대 노드의 메모리와 디스크를 볼 수 없고, 오직 응답·타임아웃·재연결 같은 간접 신호만 본다.
다음 요청이 500ms 뒤 타임아웃됐다고 하자.
클라이언트 결제 서비스 | | |--- POST /payments --------->| | | | 500ms 동안 응답 없음 | |<--------- timeout ----------|같은 타임아웃에는 적어도 세 세계가 숨어 있다.
| 가능한 세계 | 실제로 일어난 일 | 무조건 재시도하면 생기는 위험 |
|---|---|---|
| 요청 유실 | 서버에 요청이 도착하지 않음 | 재시도가 필요하다 |
| 처리 중 | 서버가 아직 결제를 처리하는 중 | 두 작업이 동시에 진행될 수 있다 |
| 응답 유실 | 결제는 끝났지만 응답만 돌아오지 못함 | 같은 결제가 두 번 생길 수 있다 |
타임아웃은 실패의 증거가 아니라 결과를 모른다는 증거다. 비동기 네트워크에서는 아주 느린 노드와 끊어진 노드를 유한 시간 안에 완벽히 구별할 수 없다. 그래서 장애 감지기는 보통 “죽었다”를 증명하지 않고 “정해진 시간 안에 응답하지 않아 의심한다”고 판단한다.
분산 시스템 설계는 불확실성을 없애는 일이 아니라, 불확실성이 들어갈 자리를 제한하는 일이다.
“분산 데이터베이스 하나를 골랐으니 일관성 정책도 하나”라는 생각은 너무 거칠다. 주문 생성, 주문 목록, 재고 차감, 조회수 증가는 서로 다른 실패 비용과 읽기 계약을 가질 수 있다.
CAP은 Consistency, Availability, Partition tolerance의 머리글자다. 여기서 각 단어는 일상적인 뜻보다 좁고 엄격하다. Gilbert와 Lynch의 정식화는 비동기 네트워크에서 이 세 조건의 동시 보장이 불가능함을 보인다.
CAP에서 C는 보통 linearizability(선형화 가능성)를 뜻한다. 완료된 모든 연산이 실제 시간 순서를 지키는 하나의 원본에서 실행된 것처럼 보여야 한다.
쓰기 완료 뒤 시작한 읽기는 그 쓰기 또는 더 새로운 값을 봐야 한다.모든 비고장 노드가 받은 요청은 유한 시간 안에 해당 연산의 응답을 반환해야 한다. 파티션 때문에 일부 요청을 의도적으로 거부하면 이 정의의 A를 포기한 것이다.
오래된 값일 수는 있어도 통신 가능한 노드는 읽기·쓰기를 계속 처리한다.노드 집합 사이의 메시지가 임의로 오래 지연되거나 유실되어도 시스템의 정의된 동작을 유지한다.
AZ(Availability Zone, 리전 안의 독립 장애 영역)·리전·랙 사이 연결이 끊겨도 남은 노드가 정책에 따라 동작한다.초기에는 A와 B가 모두 x=5를 가지고 있다. 두 노드 사이 통신이 끊긴 뒤 클라이언트 1이 A에 x=10을 쓴다.
실제 시간 노드 A 노드 Bt0 x = 5 x = 5t1 <------ 네트워크 파티션 ------>t2 Write(x = 10) 완료 x = 5t3 Read(x) 요청B는 t2의 쓰기가 존재하는지 알 수 없다.
x=5를 반환하면 요청에는 응답했지만 선형화 가능성을 깨뜨린다.x=10을 추측할 수는 없다. 쓰기 자체가 없었을 가능성과 구별할 정보가 없기 때문이다.이것이 CAP의 핵심이다. 평상시에 세 속성 중 두 개를 고르는 메뉴가 아니다. 파티션이 지속되는 구간에서 C와 A를 동시에 보장할 수 없다는 조건문이다.
여러 노드가 네트워크로 통신한다면 패킷 유실, 긴 지연, 라우팅 오류, 프로세스 정지, AZ 단절을 완전히 제거할 수 없다. “P를 선택하지 않는다”는 말은 대개 파티션을 처리하지 못해 전체 시스템이 멈춰도 받아들이겠다는 뜻이다.
그래서 실무 질문은 다음처럼 바꾸는 편이 정확하다.
파티션이 발생했을 때 이 연산은 최신성을 확인할 수 없으면 실패시킬 것인가? 아니면 충돌과 오래된 값을 감수하고 계속 처리할 것인가?좌석 예약처럼 중복 성공이 불가능해야 하는 쓰기는 과반수와 통신하지 못한 쪽에서 거부하는 편이 낫다. 좋아요 수처럼 나중에 합칠 수 있는 쓰기는 양쪽에서 받아 충돌 해결 규칙으로 수렴시킬 수 있다.
CAP 슬로건을 설계 결론으로 쓰면 다음 오해가 생긴다.
| 오해 | 정확한 경계 |
|---|---|
| ”세 가지 중 언제나 둘” | 강제 선택은 파티션 구간에 관한 것이다. 정상 상태의 지연 문제는 PACELC가 더 잘 설명한다. |
| ”AP는 절대 요청을 실패시키지 않음” | 과부하, 인증 오류, 용량 제한은 CAP와 별개로 실패를 만든다. |
| ”CP는 모든 읽기가 항상 최신” | 어떤 엔드포인트와 옵션을 읽는지에 따라 복제본 읽기는 오래될 수 있다. |
| ”시스템 하나는 AP 또는 CP” | 보장은 연산, 설정, 리전, 인덱스별로 달라질 수 있다. |
| ”CA 분산 시스템을 선택” | 파티션을 무시한 모델이다. 실제 파티션에서 C와 A 중 하나는 깨진다. |
예를 들어 DynamoDB가 가용성을 중시하는 분산 저장소라고 해도 한 물리 파티션에 부하가 몰리면 throttling으로 요청이 실패할 수 있다. 반대로 과반수 합의를 쓰는 저장소도 합의가 필요 없는 캐시 읽기나 오래된 복제본 읽기를 별도로 제공할 수 있다.
파티션에서 일관성을 우선하는 연산은 최신성을 증명할 수 없는 쪽의 요청을 거부한다. 리더(쓰기 순서를 정하는 대표 복제본)를 과반수 동의로 선출하거나 조건부 쓰기, 분산 락처럼 동시에 둘이 성공하면 안 되는 경로가 여기에 가깝다. 실패 신호는 timeout, leader 없음, quorum(정족수: 전체 복제본 중 연산 성공에 필요한 응답 수) 부족, 쓰기 거부다.
파티션에서 가용성을 우선하는 연산은 통신 가능한 복제본에서 요청을 계속 받는다. 대신 같은 키의 여러 버전, 오래된 읽기, 충돌 병합이 설계의 일부가 된다. 실패 신호는 조용한 값 되돌림, 충돌 증가, 수렴 지연이다.
은행 ATM과 좋아요 수 비유는 출발점으로는 유용하지만 충분하지 않다. ATM도 잔액 조회는 오래된 값을 표시할 수 있고, 좋아요 시스템도 사용자별 “한 번만 누르기”는 유일성 규칙이 필요하다. 선택은 서비스 이름이 아니라 개별 불변식을 기준으로 한다.
CAP은 파티션이 발생한 동안의 한계를 다룬다. 그러나 대부분의 시간에는 파티션이 없고, 이때도 여러 복제본의 확인을 기다리면 지연이 늘어난다. PACELC는 이를 다음 조건으로 표현한다.
if Partition: Availability vs Consistencyelse: Latency vs ConsistencyP가 있으면 A와 C 사이를, Else인 정상 상태에는 Latency와 C 사이를 선택한다. 이 분류는 PACELC 논문이 강조하는 실무적 보완이다.
서울의 애플리케이션이 서울 복제본에서는 3ms, 도쿄 복제본에서는 35ms, 싱가포르 복제본에서는 70ms 왕복 시간이 걸린다고 하자.
가장 가까운 복제본 하나에서 읽기: 약 3ms + 처리 시간두 리전 확인 뒤 읽기: 최소 약 35ms + 처리 시간세 리전 모두 확인 뒤 읽기: 느린 응답 때문에 약 70ms 이상강한 보장이 항상 정확히 이 숫자만큼 느린 것은 아니다. 리더 위치, 병렬 요청, 캐시, 합의 프로토콜에 따라 달라진다. 학습 포인트는 확인 범위를 넓힐수록 느린 네트워크 경로가 지연 하한에 들어온다는 것이다.
같은 서비스에서도 결제 확인은 일관성 쪽, 상품 목록은 지연 쪽을 고를 수 있다. PACELC는 제품 전체에 한 글자 라벨을 붙이는 도구가 아니라, 요청 경로마다 비용을 드러내는 질문 틀이다.
Replication(복제)은 같은 논리 데이터를 여러 노드에 보관하는 메커니즘이다. 복제는 읽기 처리량, 장애 복구, 지리적 지연을 개선하지만 “복사본 중 무엇이 최신인가”라는 문제를 만든다.
동기 복제는 필요한 복제본의 확인을 받은 뒤 쓰기 성공을 반환한다. 성공 응답 뒤 데이터 유실 가능성을 줄이지만, 느린 복제본이나 네트워크 지연이 쓰기 지연과 가용성에 들어온다.
비동기 복제는 leader가 먼저 성공을 반환하고 replica가 나중에 따라간다. 쓰기는 빠르지만, 전파 전에 leader가 영구 손실되면 성공 응답한 변경이 새 leader에 없을 수 있다.
동기:Client -> Leader -> Replica ACK -> Client success
비동기:Client -> Leader -> Client success \-> Replica catches up later“동기=안전, 비동기=위험”으로 끝내면 부족하다. 동기 복제도 몇 개의 ACK를 기다리는지, 서로 다른 장애 영역에 복사하는지에 따라 내구성이 다르다. 비동기 복제도 lag가 10ms인지 10분인지에 따라 제품 영향이 완전히 다르다.
t0 Writer: INSERT order-42t1 Writer: COMMIT, API는 201 Created 반환t2 Replica: 아직 order-42를 적용하지 못함t3 GET /orders가 Replica로 라우팅됨 -> order-42 없음t4 Replica가 로그 적용 완료t5 같은 GET -> order-42 보임이 현상은 쓰기 실패와 다르다. t1에 커밋은 성공했지만 읽기 경로의 보장이 read-your-writes가 아니었다. 해결 선택지는 세 가지다.
모든 읽기를 writer로 보내면 단순하지만 읽기 확장 효과가 줄어든다. 따라서 “몇 초 동안 어떤 API만”이라는 경계를 정해야 한다.
복제본 수를 N, 쓰기 성공에 필요한 응답 수를 W, 읽기에 확인할 복제본 수를 R이라고 하자.
먼저 이후 설명에 필요한 세 용어를 구분한다.
N과 새 N을 같은 집합처럼 계산할 수 없다.N에서 선택된다는 전제를 깨뜨릴 수 있다.아래 교집합 수식은 값의 의미를 해석하기 전, 다음 strict quorum(엄격 정족수)의 집합 전제만 사용한다.
N에서 대상을 고른다.W개 복제본의 ACK를 받았다.R개 복제본을 확인하며, 임시 대체 노드의 응답을 세지 않는다.N = 전체 복제본 수W = write quorumR = read quorum
읽기와 직전 쓰기의 교집합을 만들 조건: R + W > N서로 다른 두 쓰기의 교집합을 만들 조건: W > N / 2이 전제에서 R + W > N이면 읽기 집합에는 어떤 완료된 쓰기에 ACK한 복제본이 적어도 하나 포함된다. W > N / 2이면 같은 고정 N에서 완료된 임의의 두 쓰기 ACK 집합도 적어도 한 노드에서 겹친다. 여기까지는 값의 최신성이나 쓰기 순서를 말하지 않는 순수한 집합 교집합 결론이다.
Membership 변경과 sloppy quorum은 읽기·쓰기 대상이 같은 고정 N이라는 전제 자체를 위반할 수 있으므로 위 계산을 그대로 적용할 수 없다. 반면 같은 고정 N에서 두 쓰기가 concurrent(동시 실행)하더라도 각각 W개의 ACK를 받아 완료됐다면 W > N / 2의 교집합은 사라지지 않는다. 다만 동시 쓰기에는 실제 시간상 하나의 “최신” 순서가 없을 수 있어, 공통 노드가 있다는 사실만으로 어느 값을 반환할지 결정할 수 없다. 아직 W개 ACK를 받지 못한 불완전한 쓰기는 완료된 쓰기 집합에 대한 보장 대상이 아니지만, 일부 복제본에 남아 읽기와 충돌할 수 있다.
이 예제에서는 프로토콜이 모든 복제본에서 비교 가능한 버전 v1 < v2를 정의했다고 가정한다. 값 자체의 크기가 아니라 이 버전 순서가 최신 후보를 고른다.
복제본: A, B, C
Write(x=10/v2): A와 B가 ACK -> 성공Read(x): B와 C를 읽음
교집합: BB의 x=10/v2와 C의 x=5/v1을 비교프로토콜의 버전 규칙 v2 > v1에 따라 x=10/v2 선택교집합은 읽기 후보에 완료된 쓰기를 본 복제본 B가 포함된다는 사실만 보장한다. 교집합 자체는 어느 응답이 최신인지 식별하지 못하며, 이 예제에서는 프로토콜이 정의한 비교 가능한 버전 규칙이 v2를 선택한다. 한 노드가 느리거나 중단되어도 두 노드가 살아 있으면 읽기와 쓰기가 가능하다. 그러나 네트워크가 2개 | 1개로 갈라지면 한 개뿐인 쪽은 W=2, R=2를 만족하지 못한다. 이 설정은 파티션에서 소수 쪽의 가용성을 포기하고 교집합을 지킨다.
Write(x=10): A만 ACK -> 성공Read(x): C 하나에서 x=5 반환R + W = 2이고 N = 3이므로 교집합이 보장되지 않는다. 낮은 지연과 높은 응답 가능성을 얻지만 stale read를 허용한다. “복제본이 세 개이니 안전하다”는 직관이 깨지는 지점이다. 복제본 개수보다 성공 조건이 중요하다.
Aurora의 저장 계층 설명은 한 protection group을 단순화해 N=6, 쓰기 정족수 W=4, 읽기 정족수 R=3으로 소개한다.
R + W = 3 + 4 = 7 > 6W = 4 > 6 / 2따라서 읽기·쓰기 집합과 서로 다른 쓰기 집합이 겹친다. Aurora는 세 Availability Zone(AZ)에 여섯 저장 복사본을 두고 네 응답을 받으면 쓰기를 완료하는 구조를 사용한다. 실제 읽기 경로와 멤버십 관리는 이 수식보다 복잡하므로, 이 예제는 quorum 교집합을 이해하는 모델로 사용한다. 출처: Aurora quorum과 correlated failure, Aurora 고가용성.
R + W > N은 필요한 교집합을 만들지만 선형화 가능성의 충분조건 전체는 아니다. 집합이 겹친 뒤 그 응답을 어떤 의미로 해석할지는 프로토콜이 별도로 정한다.
이 메커니즘들이 모든 시스템에 한꺼번에 필요한 것은 아니다. 단일 순서를 제공하는 leader/consensus 프로토콜은 합의된 로그 위치와 임기를 비교해 오래된 응답을 배제할 수 있다. Dynamo 계열의 다중 writer 프로토콜은 version vector처럼 동시성을 보존하는 버전과 충돌 병합을 택하고, read repair를 장기 수렴 수단으로 사용할 수 있다. 불변식에 따라 조건부 쓰기나 트랜잭션을 별도로 둘 수도 있다. 특히 응답 수만 세고 해당 프로토콜의 버전·로그·충돌 규칙을 적용하지 않으면 교집합이 있어도 최신 값이나 linearizability(선형화 가능성)를 추론할 수 없다.
Failover는 단순히 replica 한 대의 역할 이름을 바꾸는 일이 아니다. 기존 leader가 더 이상 쓰기를 받을 자격이 없음을 보장하고, 새 leader가 어디까지 반영된 상태인지 확인하는 과정이다.
AZ 장애 -> 기존 writer 응답 중단 -> 장애 감지와 후보 선택 -> replica를 새 writer로 승격 -> endpoint가 새 writer를 가리킴이 구간에는 읽기·쓰기가 예외로 실패할 수 있다. AWS 문서는 Aurora 서비스가 보통 60초 미만, 흔히 30초 미만에 복구된다고 설명한다. 이 숫자는 애플리케이션의 timeout과 재시도 예산에 직접 영향을 준다. 1초 timeout으로 60회 즉시 재시도하면 복구를 돕는 것이 아니라 부하를 늘린다. 출처: Aurora high availability.
다음은 특정 제품의 동작을 주장하는 사례가 아니라, 비동기 복제에서 장애 감지 후 replica를 승격하는 일반 모델이다. 구 primary를 확실히 격리하기 전에 새 primary가 쓰기를 받으면 다음 위험이 생긴다.
네트워크 단절 구 Primary A: 자신이 여전히 primary라고 판단, 일부 client write 수신 Replica B: 새 primary로 승격, 다른 client write 수신
파티션 해소 A와 B의 변경이 서로 다름Lease(임대) 는 조정자가 특정 노드에 제한된 기간 동안 leader 권한을 부여하는 계약이다. 그러나 각 노드가 자신의 wall clock(벽시계)에서 만료 시각만 비교해서는 안전하지 않다. 벽시계는 시각 동기화 보정이나 수동 변경으로 앞뒤로 뛸 수 있고, 노드마다 오차도 다르기 때문에 구 leader와 신 leader가 동시에 유효하다고 판단할 수 있다.
Epoch/term(세대/임기 번호) 은 leader가 바뀔 때 증가해 쓰기가 어느 leadership 세대에서 나왔는지 구분하는 값이다. 새 leader는 합의나 quorum을 통해 더 큰 epoch를 받고 모든 쓰기에 이를 붙인다. 실제 저장소는 지금까지 본 가장 큰 epoch를 기억하고 그보다 작은 epoch의 쓰기를 거부해야 한다. 이 저장소 측 강제가 fencing(격리) 이며, 구 leader의 프로세스가 살아 있고 네트워크가 뒤늦게 복구되어도 오래된 쓰기를 차단하는 안전 경계다.
DNS 전환과 재시도만으로는 이미 양쪽에 생긴 충돌을 없애지 못한다. 프로토콜에 따라 안전한 승격은 consensus/quorum으로 직접 leader와 epoch를 정하거나, 안전한 lease에 fencing을 결합한다. Fencing token은 이때 쓰는 단조 증가 epoch를 자원 접근 토큰으로 표현한 것이다. 토큰을 발급만 하고 저장소가 작은 토큰을 거부하지 않으면 fencing은 성립하지 않는다.
멱등성은 같은 요청의 중복 효과를 줄이지만, 서로 다른 두 leader가 만든 서로 다른 쓰기의 순서를 정해주지는 않는다. split-brain과 재시도 중복은 별도 문제다.
관리형 서비스는 제품별 합의, lease, 강제 종료, fencing을 적용해 이 일반 시나리오를 차단할 수 있다. 실제 보장은 서비스 이름으로 추정하지 말고 해당 제품 버전과 구성의 공식 문서에서 승격 조건과 구 primary 쓰기 차단 메커니즘을 확인해야 한다.
첫 읽기에서는 lease가 시간 제한 권한이고, fencing이 저장소에서 오래된 epoch를 거부한다는 본문 결론만 잡고 넘어가도 된다. 아래 조건은 lease 자체로 leader 유일성을 주장할 때 필요한 엄밀한 증명 전제다.
안전한 lease 프로토콜은 시간 가정을 명시한다. 예를 들어 holder(임대 보유자)는 뒤로 가지 않는 monotonic clock(단조 시계)과 알려진 최대 clock drift, 즉 bounded drift(상한이 있는 시계 진행률 오차)를 사용해 보수적으로 일찍 권한을 포기하고, grantor(임대 발급자)는 자신이 준 기존 lease가 만료됐다는 조건을 만족하기 전에는 새 lease를 주지 않는다. 이때 holder가 실제로 사용하는 임대 시간은 명목 lease에서 최대 drift와 측정·전송 오차를 위한 안전 여유분을 뺀 값이어야 하며, 정확한 여유분은 프로토콜이 가정한 drift 상한에서 도출해야 한다. 또는 과반수 grantor가 grant와 만료 상태를 기록하고, 기존 grant와 겹치는 새 quorum을 만들 수 없다는 grantor 측 만료 규칙을 둔다. 단조 시계만 있고 drift 상한이나 grantor/quorum의 비중첩 만료 가정이 없다면, lease 시간만으로 leader 유일성을 증명할 수 없다.
이때 “재시도 횟수를 늘린다”는 해결책이 아니다. 먼저 쓰기 권한의 유일성과 새 leader의 반영 위치를 확인해야 한다.
Consistency model(일관성 모델)은 여러 클라이언트의 읽기와 쓰기가 어떤 순서로 관찰될 수 있는지 정의하는 계약이다. “strong consistency”는 제품 문서마다 뜻이 다를 수 있으므로, 가능하면 linearizable, serializable, causal, read-your-writes처럼 구체적인 이름을 쓴다.
전역 실시간 순서가 필요한가? -> Linearizability트랜잭션 결과를 직렬 순서로 볼 것인가? -> Serializability원인과 결과 순서만 지키면 되는가? -> Causal consistency같은 사용자의 세션 경험만 보호할까? -> Session guarantees업데이트가 멈추면 언젠가 수렴하면 되는가? -> Eventual consistencyLinearizability(선형화 가능성)는 각 연산이 호출과 응답 사이의 한 순간에 원자적으로 일어난 것처럼 보이게 한다. 쓰기 응답이 끝난 뒤 시작한 읽기는 그 쓰기보다 오래된 값을 반환할 수 없다.
실제 시간 ->
Client A: |--- Write(x=10) ---|Client B: |--- Read(x) -> 10 ---|B의 읽기는 A의 쓰기가 완료된 뒤 시작했으므로 10 또는 더 새로운 값이어야 한다. 분산 락, 리더 선출, 좌석 한 개의 소유권처럼 실제 시간 순서가 중요한 연산에 적합하다.
비용은 최신 상태를 확인하는 통신과 대기다. 파티션 중 최신성을 증명하지 못하면 요청을 거부해야 할 수 있다.
Sequential consistency(순차 일관성)는 모든 연산이 하나의 전역 순서로 보이고, 각 클라이언트 내부의 프로그램 순서는 유지되게 한다. 그러나 서로 다른 클라이언트 사이의 실제 완료 시각까지 보장하지는 않는다.
실제 시간: A의 Write(x=10)가 먼저 완료허용 가능한 관찰 순서: B의 Read(x=5) -> A의 Write(x=10)모든 참여자가 같은 순서를 보는 한, 실제 벽시계 순서와 다를 수 있다. 따라서 “쓰기 응답을 받은 뒤 다른 사용자가 즉시 최신 값을 봐야 한다”는 요구에는 부족할 수 있다.
Causal consistency(인과 일관성)는 원인이 된 사건과 그 결과의 순서를 모든 관찰자가 지키게 한다. 서로 독립적인 동시 사건은 다른 순서로 보아도 된다.
A: 게시글 P 작성B: P를 읽고 댓글 C 작성C: 댓글 C를 읽음
보장: C는 댓글의 원인인 게시글 P도 볼 수 있어야 한다.서로 관련 없는 두 게시글의 생성 순서까지 전 세계에서 합의할 필요는 없다. 협업 도구, 피드, 메시지 스레드처럼 관계는 중요하지만 모든 연산의 실시간 총순서는 과한 경우에 유용하다.
세션 보장은 전역 일관성보다 좁은 범위에서 사용자 경험을 안정시킨다.
클라이언트 A: Write(profile="새 이름") -> 성공클라이언트 A: Read(profile) -> "새 이름" // RYW클라이언트 B: Read(profile) -> "구 이름" 가능동일 replica에 고정하는 sticky routing은 간단하지만 failover와 부하 분산에 약하다. 더 명시적인 방식은 마지막으로 본 버전이나 로그 위치를 세션 토큰에 담고, 그 위치 이상 따라온 replica에서 읽는 것이다.
Eventual consistency(최종 일관성)는 새로운 업데이트가 더 들어오지 않을 때 모든 복제본이 언젠가 같은 값으로 수렴한다는 보장이다. 언제 수렴하는지, 그동안 어떤 값을 읽는지, 충돌을 어떻게 합치는지는 이 한 단어만으로 알 수 없다.
Write(x=10) -> 노드 A 반영
즉시: A -> 10 B -> 5 C -> 5
복제와 repair 완료 뒤: A -> 10 B -> 10 C -> 10DNS 레코드, 비동기 검색 인덱스, 추천 점수, 읽기 복제본이 대표적인 예다. 하지만 수렴 메커니즘이 멈추거나 충돌 해결 함수가 결정적이지 않으면 “eventual”이라는 이름만으로 실제 수렴을 보장할 수 없다.
Serializability(직렬화 가능성)는 여러 트랜잭션의 결과가 어떤 직렬 실행과 동등해야 한다는 격리 계약이다. Linearizability는 개별 연산이 실제 시간 순서를 지키는지 묻는다.
| 질문 | Linearizability | Serializability |
|---|---|---|
| 중심 대상 | 단일 연산의 실시간 관찰 | 여러 트랜잭션의 동시 실행 |
| 지키는 순서 | 실제 완료 시각 | 가능한 어떤 직렬 순서 |
| 대표 실패 | 완료된 쓰기 뒤 stale read | write skew, 직렬 실행으로 설명 못 할 결과 |
| 서로 대신하는가? | 아니다 | 아니다 |
직렬화 가능한 데이터베이스도 외부 replica 읽기에서 오래된 값을 줄 수 있다. 반대로 단일 키의 선형화 가능한 저장소가 여러 키 트랜잭션의 직렬성을 자동으로 제공하는 것도 아니다.
분산 노드에는 완벽히 같은 시계가 없다. NTP(Network Time Protocol) 같은 동기화가 오차를 줄여도 clock skew(시계 편차)와 갑작스러운 보정은 남는다. 그래서 벽시계 timestamp 하나로 모든 쓰기의 진짜 순서를 정하면 정상 업데이트를 잃을 수 있다.
timeout 500ms를 측정할 때는 단조 시계가 적합하다. 여러 리전의 이벤트를 업무 시각으로 표시할 때는 벽시계가 필요하지만, 그 timestamp만으로 인과 순서를 확정해서는 안 된다.
Last-Write-Wins(LWW)는 timestamp가 큰 값을 남기는 충돌 해결 방식이다. 단순하지만 시계 편차에 민감하다.
실제 시간 t0: 노드 A의 시계가 실제보다 +200ms 빠름실제 시간 t1: A가 status="pending", timestamp=12:00:00.300 기록실제 시간 t2: B가 더 나중에 status="paid", timestamp=12:00:00.150 기록
LWW 병합 결과: A의 "pending" 승리실제 나중 쓰기: B의 "paid" 유실결제 상태처럼 전이가 중요한 값은 LWW보다 상태 전이 조건, 버전 번호, 단일 leader, 합의가 낫다. LWW는 손실을 허용할 수 있고 병합 규칙이 명확한 프로필 장식값 등에 제한해서 쓴다.
Lamport clock(램포트 시계)은 각 노드가 논리 카운터를 증가시키고 메시지를 받을 때 max(local, received) + 1로 갱신하는 방식이다.
A: event a1, L=1A: message(L=2) -----------------> BB: receive, L=max(0, 2)+1 = 3B: event b1, L=4a -> b라는 인과관계가 있으면 L(a) < L(b)가 성립한다. 그러나 L(a) < L(b)라고 해서 반드시 a가 b의 원인인 것은 아니다. 서로 다른 노드에서 같은 Lamport 값이 나올 수 있으므로 모든 이벤트의 total order(총순서)가 필요하면 (Lamport 값, node ID)처럼 deterministic tie-breaker(결정적 동률 해소 기준)를 함께 쓴다. 이렇게 만든 총순서는 정렬 규칙일 뿐이며, 앞에 놓인 이벤트가 뒤 이벤트의 원인이라는 역방향 인과관계를 증명하지 않는다. Lamport clock은 인과관계와 양립하는 순서를 만들지만 동시성을 판별하지는 못한다.
Hybrid Logical Clock(HLC, 하이브리드 논리 시계) 은 물리 시각에 가까운 값과 논리 카운터를 함께 기록해 벽시계와의 근접성을 유지하면서 인과 순서가 뒤집히지 않게 보정하는 시계다. 이벤트를 시간대별로 조회하면서 논리적 순서도 보존할 때 유용하지만, HLC만으로 모든 쓰기에 합의된 유일 순서나 업무 불변식이 생기지는 않는다.
Vector clock(벡터 시계)은 노드별 카운터 배열을 저장한다. 두 벡터의 모든 원소가 작거나 같고 하나 이상이 작으면 인과적으로 이전이다. 어느 쪽도 포함하지 않으면 concurrent(동시)다.
초기: [0, 0]A에서 수정: [1, 0]B에서 독립 수정: [0, 1]
[1, 0]과 [0, 1]은 어느 쪽도 다른 쪽을 포함하지 않음-> 동시 충돌로 판단하고 병합 필요장점은 조용히 하나를 버리지 않고 충돌을 드러낸다는 점이다. 단점은 참여 노드 수에 따라 메타데이터가 커지고, 노드 가입·탈퇴 관리가 필요하다는 점이다. 모든 이벤트 총순서가 필요하다면 leader나 합의 로그가 더 단순할 수 있다.
| 요구사항 | 적합한 도구 후보 |
|---|---|
| 경과 시간과 timeout 측정 | monotonic clock |
| 감사 로그에 사람이 읽을 시각 표시 | wall clock + 동기화 상태 |
| 인과관계를 보존한 대략적 정렬 | Lamport clock, Hybrid Logical Clock |
| 동시 수정을 탐지해 병합 | vector clock, version vector |
| 모든 쓰기의 유일한 순서와 불변식 보호 | leader log, consensus, conditional write |
시계는 합의를 대신하지 않는다. “timestamp가 더 크다”는 규칙이 업무적으로 올바른 승자를 만든다는 보장이 있어야 한다.
Retry(재시도)는 일시 실패를 복구할 수 있지만, 앞서 본 세 개의 가능한 세계 때문에 같은 부작용을 여러 번 만들 수 있다. Idempotency(멱등성)는 같은 논리 요청을 여러 번 실행해도 최종 부작용이 한 번 실행한 것과 같게 만드는 성질이다.
POST /payments가 같은 결제 행을 한 번만 만들었다고 끝이 아니다. 외부 결제 승인, outbox 이벤트, 이메일, 포인트 차감까지 같은 논리 작업의 경계에 있다면 각 부작용도 중복을 견뎌야 한다.
안전한 멱등성 키 처리에는 보통 다음 요소가 필요하다.
tenant + operation처럼 명확히 한다.202를 반환한다.Idempotency key: tenant-7:payment:order-42Payload hash: sha256({orderId: 42, amount: 30000, currency: KRW})
첫 요청: key 없음 -> PROCESSING 저장 -> 결제 승인 -> SUCCEEDED + 응답 저장
동일 요청 재시도: key와 hash 같음, SUCCEEDED -> 저장된 같은 응답 반환
키만 같고 금액이 다른 요청: hash 다름 -> 409 Idempotency-Key-Reused핵심은 확인 후 저장을 두 단계로 나누지 않는 것이다.
// 개념 예시: 실제 구현은 DB의 unique constraint와 transaction을 사용한다.async function createPayment(command: PaymentCommand, key: string) { const hash = hashPayload(command); const existing = await idempotencyStore.find(key);
if (existing && existing.payloadHash !== hash) { throw new ConflictException("idempotency key reused"); } if (existing?.status === "SUCCEEDED") return existing.response;
const claim = await idempotencyStore.claimAtomically(key, hash); if (!claim.acquired) return waitForExistingResult(key);
// 외부 결제사도 key를 받아야 프로세스 장애 후 재호출이 안전하다. const payment = await paymentProvider.charge(command, { idempotencyKey: key, }); return idempotencyStore.completeAtomically(key, payment);}프로세스가 외부 승인 뒤 completeAtomically 전에 죽을 수 있다. 그래서 하위 결제사의 멱등성 지원 또는 승인 조회 API가 필요하다. 로컬 DB의 unique key만으로 외부 부작용까지 exactly-once가 되지는 않는다.
네트워크는 메시지를 유실하거나 중복 전달할 수 있다. 실무의 “exactly once”는 흔히 다음 조합의 결과다.
at-least-once delivery+ idempotent consumer+ atomic state/result record+ retry and reconciliation= 관찰 가능한 부작용을 한 번으로 수렴이벤트 소비자가 DB 업데이트 뒤 ACK 전에 죽으면 broker는 같은 메시지를 다시 보낼 수 있다. consumer는 event_id를 처리 결과와 같은 트랜잭션에 기록해야 한다. 이메일처럼 원자적 DB 경계 밖의 부작용은 provider key나 별도 발송 상태와 reconciliation이 필요하다.
멱등성은 중복 실행의 경계다. 순서, 권한, 업무 검증, 충돌 병합을 대신하지 않는다.
서비스를 하나의 AP/CP 라벨로 고정하지 않고, 실제 연산과 설정을 읽는 연습을 한다.
DynamoDB table과 Local Secondary Index(LSI)는 요청별로 eventually consistent read와 strongly consistent read를 선택할 수 있다. Global Secondary Index(GSI)와 stream 읽기는 eventually consistent다. Global Tables도 설정에 따라 multi-Region eventual consistency와 multi-Region strong consistency를 제공하므로 “DynamoDB는 언제나 AP”라고만 외우면 현재 기능을 놓친다. 출처: DynamoDB read consistency.
기본 multi-Region eventual consistency에서는 리전 간 변경이 보통 1초 안에 전파되지만, 이것은 개별 요청의 deadline 보장이 아니다. 쓰기 직후 불일치 확률을 “대부분 성공한다” 같은 고정 비율로 가정하지 않는다. staging에서 같은 키를 쓰고 0ms, 50ms, 100ms, 500ms 뒤 읽기를 각각 1,000회 이상 반복해 경로별 분포를 측정한다. 측정값이 제품의 staleness budget을 넘으면 strong read 지원 범위, writer routing, 세션 보장 중 하나를 선택한다.
RCU(Read Capacity Unit, 읽기 용량 단위)는 DynamoDB가 항목 크기와 읽기 일관성에 따라 읽기 처리량을 계산하는 단위다. 4KB 이하 항목 한 개를 읽을 때 용량 감각은 다음과 같다.
| 읽기 방식 | 소비량 | 보장과 용도 |
|---|---|---|
| Eventually consistent | 0.5 RCU | 최근 완료된 쓰기가 아직 안 보일 수 있음 |
| Strongly consistent | 1 RCU | 지원되는 대상에서 이전 성공 쓰기를 반영 |
| Transactional read | 2 RCU | 여러 항목의 트랜잭션 읽기 계약 |
모든 읽기를 strong으로 바꾸면 같은 항목 크기와 요청 수에서 eventual 대비 읽기 용량 비용이 2배가 된다. 4KB 항목을 초당 10,000번 읽으면 단순 계산상 eventual은 5,000 RCU, strong은 10,000 RCU가 필요하다. 출처: DynamoDB read/write operations.
따라서 로그인 직후 권한 확인처럼 stale value의 실패 비용이 큰 경로만 strong으로 하고, 카탈로그 목록과 집계는 staleness budget 안에서 eventual을 선택할 수 있다.
AP 성향의 설계를 선택해도 용량 제한은 사라지지 않는다. DynamoDB의 한 물리 파티션은 최대 3,000 read unit/s와 1,000 write unit/s를 제공하도록 설계된다. RCU와 WCU(Write Capacity Unit)는 항목 크기와 읽기 방식에 따라 실제 요청 수로 환산해야 한다. 출처: DynamoDB partition key design.
여기서 p99(99번째 백분위 지연) 는 전체 요청의 99%가 그 값 이하에서 끝나고 가장 느린 1%는 그보다 오래 걸린다는 뜻이다.
나쁜 partition key: date = "20241219"하루의 모든 쓰기가 같은 키 범위에 집중-> p99 증가, throttling, ProvisionedThroughputExceededException
write sharding: date + "#" + random(0..9)-> "20241219#3", "20241219#7" 등 10개 논리 키로 분산10개의 suffix는 쓰기 부하를 여러 논리 키로 나눌 후보를 만들 뿐, 10개의 물리 파티션이나 10배 처리량을 보장하지 않는다. DynamoDB는 데이터 크기와 트래픽에 따라 여러 논리 키를 같은 물리 파티션에 둘 수 있다. 또한 adaptive capacity(적응형 용량) 는 불균등한 트래픽에 맞춰 파티션별 처리 여력을 자동 조정하지만, 하나의 hot key에 물리 파티션의 상한을 넘어서는 용량을 만들어 주지는 않는다. 따라서 실제 분산과 유효 용량은 매핑을 추측하지 말고 부하 테스트에서 suffix별 요청 분포, consumed capacity, throttling, p99를 측정해 확인해야 한다.
쓰기가 실제로 여러 물리 파티션에 분산되더라도 읽기는 10개 키를 scatter-gather(여러 대상에 병렬 요청을 흩뿌린 뒤 응답을 모아 하나의 결과로 합치는 방식) 해야 한다. suffix를 10개에서 100개로 늘리면 쓰기 여유가 커질 가능성이 있지만 조회 fan-out, 비용, 부분 실패 가능성도 커진다. 먼저 예상 key별 WCU와 읽기 fan-out을 함께 계산한 뒤, 측정된 분포와 용량이 계산을 뒷받침하는지 확인해야 한다.
이 문제는 큰 장애보다 p99 지연 증가와 간헐적 throttling으로 먼저 드러날 수 있다. ThrottledRequests 합계만 보지 말고 어떤 partition key에 요청이 집중됐는지 애플리케이션 지표와 함께 본다.
Aurora storage의 4/6 quorum과 DB instance의 reader endpoint는 같은 계층이 아니다. Writer가 성공한 뒤 reader instance에서 읽으면 비동기 replica lag 때문에 오래된 결과를 볼 수 있다.
정확성이 필요한 쓰기 직후 읽기 -> writer endpoint검색, 리포트, 집계 -> reader endpoint 가능AuroraReplicaLag는 reader가 writer보다 뒤처진 시간을 밀리초로 보여주는 관측값이다. 1,000ms를 경보로 쓴다면 그것은 AWS의 보편적 정상/비정상 경계가 아니라 해당 API가 정한 1초 staleness budget이어야 한다. 평상시 baseline과 사용자 허용 시간을 근거로 임계값을 정한다. 출처: Aurora CloudWatch metrics.
S3는 2020년 12월부터 GET, PUT, LIST 등에 강한 read-after-write consistency를 제공한다. 그러나 Cross-Region Replication은 별도 비동기 경로다. 이 변화는 제품 이름의 고정 라벨보다 현재의 API와 복제 경로를 확인해야 한다는 반례다. 출처: Amazon S3 strong consistency.
남은 수량이 1개일 때 두 요청이 replica에서 동시에 remaining=1을 읽는다고 하자.
Request A: Read remaining=1 -> 발급 가능 판단Request B: Read remaining=1 -> 발급 가능 판단Request A: remaining=0 저장, coupon 발급Request B: remaining=0 저장, coupon 발급
결과: 수량은 0이지만 쿠폰은 2개 발급강한 읽기만 추가해도 읽기와 쓰기 사이 경쟁은 남는다. 필요한 것은 다음 중 하나다.
remaining > 0 조건을 포함한 원자적 conditional update예를 들어 DynamoDB에서는 읽고 나서 별도 저장하는 대신 조건을 쓰기 자체에 붙인다.
await dynamoClient.update({ TableName: "coupon-campaigns", Key: { campaignId }, UpdateExpression: "SET remaining = remaining - :one", ConditionExpression: "remaining >= :one", ExpressionAttributeValues: { ":one": 1 },});동시에 두 요청이 들어와도 남은 수량이 1이면 조건을 만족한 한 요청만 성공한다. 실패한 요청은 최신 값을 다시 읽어 추측하지 않고 “소진”이라는 업무 결과로 번역한다.
불변식은 “최신 값을 읽는다”가 아니라 “성공한 발급 수가 초기 수량을 넘지 않는다”다. 설계는 읽기 옵션보다 불변식을 직접 보호해야 한다.
| 경로 | 허용 가능한 거짓말 | 적합한 선택 후보 |
|---|---|---|
| 결제 승인 | 같은 주문의 이중 승인 불가 | idempotency key + 원자적 상태 전이 |
| 재고 차감 | 0개 아래로 내려가면 안 됨 | conditional write 또는 transaction |
| 주문 생성 직후 상세 | 내 주문이 없다고 보이면 안 됨 | writer read 또는 read-your-writes |
| 상품 카탈로그 목록 | 가격 반영이 수 초 늦을 수 있음 | replica/eventual + 명시한 staleness budget |
| 조회수 | 잠깐 덜 보이거나 중복 합산 가능 | 비동기 집계, 교환·결합 가능한 연산 |
| 권한 변경 직후 접근 | 철회된 권한을 계속 허용하면 안 됨 | 강한 읽기, 짧은 캐시, version 검증 |
“금융은 CP, 소셜은 AP”보다 이 표가 유용하다. 같은 주문 도메인 안에서도 목록 읽기와 재고 쓰기의 계약이 다르다.
요구사항을 “최대한 최신”이라고 쓰면 설계와 테스트가 불가능하다. 다음처럼 관측 가능한 예산으로 바꾼다.
주문 상세 RYW: 성공 응답 후 50ms 이내에 시작한 같은 사용자의 읽기도 최신 버전 반환 (예시 경계)
상품 검색: 가격 변경의 99%는 5초 안에 검색 인덱스 반영
리전 장애: 60초 동안 쓰기 거부 허용, 중복 주문은 0건
이벤트 소비: 중복 전달 허용, event_id별 외부 부작용은 1회로 수렴이 숫자는 제품이 결정해야 한다. 저장소 이름은 그 예산을 만족시키는 구현 선택이다.
1. 깨지면 안 되는 불변식은 무엇인가? -> 돈, 재고, 유일 소유권, 권한이라면 강한 원자 경계 후보
2. timeout 뒤 결과를 조회할 식별자가 있는가? -> 없다면 idempotency key와 operation status부터 설계
3. 오래된 읽기를 누가, 얼마나 오래 볼 수 있는가? -> 사용자/경로별 staleness budget 정의
4. 파티션 중 쓰기를 거부할 수 있는가? -> 가능하면 quorum/leader로 불변식 보호 -> 불가능하면 충돌 표현과 결정적 병합 필요
5. 정상 상태 지연 예산은 얼마인가? -> 리전 간 확인 수, R/W quorum, read routing 결정CRDT(Conflict-free Replicated Data Type)는 복제본이 업데이트를 서로 다른 순서로 받아도 정해진 규칙 아래 같은 상태에 수렴하게 만드는 자료구조 계열이다. 다만 “CRDT를 쓴다”는 이름만으로 수렴이 보장되지는 않으며, 복제 방식에 맞는 대수적·전달 조건이 필요하다.
따라서 독립 업데이트를 손실 없이 합칠 수 있을 때 CRDT가 후보가 되지만, 어떤 업무 변경이 허용되는지까지 대신 결정하지는 않는다. 장바구니 항목의 합집합은 수렴시킬 수 있어도 마지막 재고 한 개의 유일 소유권은 별도 합의나 원자적 조건이 필요하다.
첫 읽기에서는 결정적 병합 가능성과 업무 불변식 보호는 별개라는 위 경계만 잡고 넘어가도 된다. 아래는 CvRDT의 join-semilattice 조건과 CmRDT의 전달 조건으로 수렴을 엄밀히 설명할 때 필요한 전제다.
(S, ≤)가 join-semilattice(조인 반격자), 즉 임의의 두 상태에 유일한 최소 상한이 존재하는 부분 순서여야 한다. 로컬 업데이트 u는 s ≤ u(s)를 만족하는 inflationary update(팽창적 갱신)여야 하고, merge(s, t)는 두 상태의 least upper bound(최소 상한)인 join s ⊔ t를 반환해야 한다. 이 join에서 결합·교환·멱등 성질이 따라온다. 각 복제본의 전진한 상태가 다른 모든 복제본에 eventual delivery(결국 전달)되면, 중복되거나 순서가 바뀐 전달에도 같은 상태로 수렴한다.| 조건 | 우선할 메커니즘 | 피해야 할 단순화 |
|---|---|---|
| 중복 성공이 금전·재고를 깨뜨림 | transaction, conditional write, quorum | stale read 뒤 read-modify-write |
| 같은 사용자의 쓰기 직후 경험만 중요 | RYW token, writer pinning | 모든 전역 읽기를 무조건 strong |
| 독립 업데이트를 합칠 수 있음 | eventual replication, CRDT 후보 | LWW로 조용히 한쪽 삭제 |
| 결과 미상 요청을 재시도해야 함 | end-to-end idempotency | timeout을 실패 확정으로 간주 |
| 전 세계 낮은 읽기 지연이 중요 | 지역 replica + 명시적 staleness budget | 저장소 전체를 AP라고만 분류 |
| leader 유일성이 중요 | consensus, lease + fencing | DNS 전환만으로 구 leader 차단 |
분산은 항상 성숙한 선택이 아니다. 쓰기 처리량과 장애 요구가 단일 DB로 충분하고, 팀이 conflict resolution과 failover를 운영할 여력이 없다면 단일 writer와 백업·복구 계획이 더 안전할 수 있다.
다음 신호가 먼저 있어야 복제를 넘어 더 복잡한 분산 쓰기를 검토할 근거가 생긴다.
가용성을 위해 다중 writer를 추가하면 충돌 해결이 제품 로직이 된다. 이를 감당하지 못하면 더 많은 노드가 더 높은 가용성이 아니라 더 많은 불확실성을 만든다.
| 관찰한 증상 | 먼저 의심할 개념 | 의미와 다음 질문 |
|---|---|---|
201 뒤 GET에서 새 주문이 없음 | replication lag, RYW | 어느 endpoint를 읽었고 마지막 로그 위치가 어디인가? |
| timeout 뒤 같은 결제가 두 건 생김 | idempotency boundary | provider까지 같은 key가 전달됐는가? |
| failover 뒤 성공한 쓰기가 사라짐 | async replication, promotion | 새 leader가 어느 로그 위치까지 반영했는가? |
| 두 노드가 동시에 leader라고 주장 | split-brain, fencing | 이전 epoch의 쓰기를 저장소가 거부하는가? |
| 값이 최신과 과거 사이를 오감 | monotonic reads | 요청마다 서로 다른 lag의 replica를 읽는가? |
| 같은 키에 형제 버전이 계속 쌓임 | conflict resolution | repair와 병합 함수가 실제로 진행되는가? |
p99 증가와 ThrottledRequests가 함께 보임 | hot partition | key별 RCU/WCU가 물리 파티션 상한에 몰렸는가? |
| strong read 전환 뒤 RCU가 거의 2배 | consistency cost | 모든 경로가 최신 읽기를 정말 요구하는가? |
| timestamp가 큰 오래된 업무 상태가 살아남음 | clock skew, LWW | 벽시계를 업무 순서로 오용했는가? |
| retry를 켠 뒤 하위 서비스 QPS가 배수로 증가 | retry amplification | 몇 계층이 각각 재시도하며 최대 시도 수의 곱은 얼마인가? |
실패 신호는 자동 해결책이 아니다. 예를 들어 replica lag가 1초라고 무조건 writer로 전환하면 writer 과부하가 새 장애를 만들 수 있다. 먼저 해당 API의 staleness budget과 writer headroom을 확인한다.
API gateway, 주문 서비스, 결제 client가 각각 최대 3번 시도하면 최악의 하위 호출 수는 단순 합이 아니다.
3 x 3 x 3 = 27회원래 1,000개 요청이 동시에 timeout되면 결제 서비스는 최대 27,000개 호출을 볼 수 있다. Exponential backoff(지수 백오프) 는 실패할 때마다 다음 시도까지의 대기 상한을 지수적으로 늘려 복구 중인 하위 시스템에 숨 쉴 시간을 주는 전략이다. Jitter(무작위 지연) 는 그 대기 시간에 무작위 편차를 넣어 많은 클라이언트가 같은 순간에 다시 몰리는 현상을 줄인다. 재시도는 한 계층이 소유하고, 전체 deadline 안에서 두 전략을 함께 사용하며, 부작용 경로는 멱등해야 한다.
@Injectable()class OrderService { constructor( @InjectDataSource("writer") private readonly writer: DataSource, @InjectDataSource("reader") private readonly reader: DataSource, ) {}
async createAndGet(dto: CreateOrderDto) { return this.writer.transaction(async (manager) => { const order = await manager.save(Order, dto); // 쓰기 직후 같은 트랜잭션/Writer에서 읽어 RYW를 보호한다. return manager.findOneByOrFail(Order, { id: order.id }); }); }
async listForReport(userId: string) { // 보고서는 합의한 staleness budget 안에서 Reader를 사용한다. return this.reader.getRepository(Order).find({ where: { userId } }); }}모든 목록이 reader에 적합한 것은 아니다. 주문 완료 화면의 목록은 사용자 계약상 RYW가 필요할 수 있고, 일일 정산 보고서는 수 초 지연을 허용할 수 있다. 메서드 이름과 주석에는 저장소 종류보다 요구한 보장을 남긴다.
// 일반 프로필 목록: 최근 쓰기의 즉시 반영이 필수는 아니다.const eventual = await dynamoClient.get({ TableName: "profiles", Key: { userId },});
// 권한 변경 직후 확인: 지원되는 table/LSI에서 strong read를 선택한다.const strong = await dynamoClient.get({ TableName: "permissions", Key: { userId }, ConsistentRead: true,});ConsistentRead: true를 repository 전체 기본값으로 숨기면 비용과 지연의 이유가 사라진다. use case가 보장을 요구하고 adapter가 해당 저장소 옵션으로 번역하는 편이 설계 의도를 드러낸다.
아래 절차는 AWS 계정, 엔진 버전, 권한, 모니터링 구성에 따라 달라진다. 본문 개념을 이해하는 데 필수는 아니며, 실제 운영에서는 팀 runbook과 현재 공식 문서를 우선한다.
POST /orders -> 201, operation_id=op-42, version=813GET writer/orders/42 -> version=813GET reader/orders/42 -> not found
판정: 쓰기 유실이 아니라 reader가 version 813 이전 상태코드에서 ConsistentRead: true 사용 경로를 찾고, API별 요구사항과 함께 검토한다.
rg "ConsistentRead:\s*true" src/CloudWatch에서는 다음을 같은 시간축으로 본다.
ConsumedReadCapacityUnitsThrottledRequestsSuccessfulRequestLatencyStrong read 사용 증가와 RCU 증가가 같이 나타나면 의도한 보장인지 확인한다. throttling과 특정 key 집중이 같이 나타나면 AP/CP 라벨보다 partition key 분포를 먼저 본다.
Aurora PostgreSQL에서는 엔진 버전과 권한을 확인한 뒤 aurora_replica_status()를 사용할 수 있다.
SELECT server_id, CASE WHEN session_id = 'MASTER_SESSION_ID' THEN 'writer' ELSE 'reader' END AS instance_role, replica_lag_in_msec AS replica_lag_msFROM aurora_replica_status()ORDER BY replica_lag_in_msec NULLS FIRST;CloudWatch의 AuroraReplicaLag와 AuroraReplicaLagMaximum을 함께 보되, 고정된 1초를 보편적 장애선으로 사용하지 않는다. API별 staleness budget을 초과했고 writer에 여유가 있을 때만 중요 읽기를 writer로 전환한다. 장시간 쿼리 종료나 자동 전환은 영향 범위가 크므로 팀 runbook과 승인 절차를 따른다.
SELECT @@hostname 같은 엔진별 명령은 새 연결이 어느 인스턴스로 갔는지 확인하는 보조 수단이다. hostname 변경만으로 데이터 연속성이나 old leader fencing을 증명할 수는 없다.
HTTP 캐시는 freshness lifetime이 남아 있어 의도적으로 원본을 확인하지 않을 수 있다. DB replica는 원본 로그를 아직 적용하지 못해 뒤처질 수 있다. 둘 다 오래된 값을 보이지만 관측값과 해결 메커니즘이 다르다.
예를 들어 Cache-Control: max-age=3600은 캐시가 한 시간 동안 원본을 확인하지 않아도 된다는 명시적 계약이다. 반면 replica가 한 시간 뒤처진 상태는 대개 계약이 아니라 복제 파이프라인의 실패 신호다.
Age, Cache-Control, cache key, revalidation을 본다.캐시 TTL을 줄이는 해결책은 DB replica lag에 영향을 주지 않는다.
최신 단일 항목을 읽는 보장과 여러 항목을 원자적으로 바꾸는 트랜잭션은 다르다. DynamoDB strong read를 켰다고 쿠폰 발급의 read-modify-write 경쟁이 사라지지 않는다. 조건부 쓰기나 트랜잭션이 추가로 필요하다.
CAP의 A는 모델 속 응답 보장이고, 운영의 availability는 보통 SLI(Service Level Indicator) 성공률과 지연 목표를 뜻한다. AP 설계도 throttling으로 99.9% 목표를 놓칠 수 있고, CP 설계도 파티션이 없는 대부분의 시간에는 높은 성공률을 낼 수 있다.
앞서 본 CRDT 이름의 conflict-free는 복제본 병합이 같은 결과로 수렴한다는 성질을 가리킨다. 업무 충돌 자체가 사라지는 것은 아니다. 장바구니 항목 합치기는 가능해도 마지막 재고 한 개를 두 사용자에게 동시에 약속하는 문제는 별도 불변식이 필요하다. 또한 counter, set, register 중 어떤 CRDT를 고르는지에 따라 허용 연산과 메타데이터 비용이 달라지므로, “자동 병합”이라는 이름만으로 선택해서는 안 된다.
N=6, W=4, R=3 예제를 확인한다.| 개념 | 핵심 문장 |
|---|---|
| Partial failure | 일부 노드만 실패하며 timeout은 성공·실패·처리 중을 구별하지 못한다. |
| CAP | 파티션 동안 linearizability와 모든 비고장 노드의 응답을 함께 보장할 수 없다. |
| PACELC | 정상 상태에도 낮은 지연과 더 강한 일관성 사이의 비용이 남는다. |
| Replication | 복사본은 처리량과 복구를 늘리지만 lag, promotion, conflict를 만든다. |
| Quorum | R + W > N, W > N/2는 교집합 조건이며 프로토콜별 순서·충돌 규칙 없이는 최신성을 추론할 수 없다. |
| Consistency model | 최신성, 트랜잭션 순서, 인과 순서, 세션 경험은 서로 다른 계약이다. |
| Clocks | 벽시계는 완전한 순서가 아니며 논리 시계와 version이 인과·충돌을 드러낸다. |
| Idempotency | 중복 요청을 한 논리 작업으로 합치되 외부 부작용까지 경계를 이어야 한다. |
| Selection philosophy | 저장소 라벨보다 불변식과 허용 가능한 거짓말을 먼저 정한다. |
분산 시스템의 핵심은 모든 노드가 항상 같은 답을 하게 만드는 데 있지 않다. 어떤 답이 늦거나 거부되거나 중복될 수 있는지 명시하고, 그 불확실성이 돈·재고·권한 같은 불변식을 넘지 못하게 경계를 설계하는 데 있다.