최종 일관성: 전략, 절충안 및 UX 패턴
사용자가 프로필을 업데이트하고 '저장'을 클릭하면 이전 이름이 계속 표시됩니다. 페이지를 다시 로드하세요. 이제 새 이름이 표시됩니다. 중앙에는 이벤트 중심 시스템이 자리잡고 있습니다. 서비스 간에 업데이트를 전파합니다. 이것은최종 일관성 실제로 — 잘못된 대답은 사용자가 시스템에 있다고 생각하는 대답입니다. 버그. 건축가의 정답은 시스템을 다음과 같이 설계하는 것입니다. 이 불일치 창은 눈에 보이지 않거나 허용 가능합니다.
가능한 일관성은 숨겨야 할 타협이 아니라 아키텍처 선택입니다. 실제 이점(가용성, 확장성, 탄력성)과 실제 과제를 인식하고 있습니다. (복잡성, 테스트, UX). 일관성 패턴 및 관리 전략 이해 일시적인 불일치는 작동하는 분산 시스템을 구축하는 데 필수적입니다. 실제로 생산 중입니다.
무엇을 배울 것인가
- CAP 정리 및 BASE 대 ACID: 이론적 틀
- 일관성 패턴: 강력부터 최종까지
- Read-Your-Writes: 쓰기 직후 오래된 데이터를 표시하지 않는 전략
- 낙관적 UI: 서버가 응답하기 전에 인터페이스를 업데이트합니다.
- 동시에 수정된 데이터에 대한 충돌 감지 및 조정
- 차이를 감지하기 위해 벡터 시계를 사용한 버전 관리
- 사용자를 혼란스럽게 하지 않고 가능한 일관성을 사용자에게 전달하는 방법
CAP 정리 및 BASE: 이론적 틀
Il CAP 정리 파티션이 있는 분산 시스템에서는 네트워크(프로덕션에서는 불가피함) 중 하나를 가질 수 있습니다. C지속성 (모든 노드는 동일한 데이터를 봅니다) o A가용성(시스템 항상 응답함) 그러나 파티션 중에 동시에 둘 다 응답하지는 않습니다.
| 재산 | ACID(관계형 DB) | 기본(분산 시스템) |
|---|---|---|
| 일관성 | 강력함: 모든 읽기에는 마지막 쓰기가 표시됩니다. | 최종: 시간이 지남에 따라 노드가 수렴됩니다. |
| 유효성 | 파티션 중에는 보장되지 않음 | 높음: 시스템이 항상 응답합니다. |
| 머무르다 | 원자성: 전부 아니면 전무 | 소프트 상태: 입력 없이 상태가 변경될 수 있음 |
| Examples | 포스트그레SQL, MySQL, 오라클 | DynamoDB, 카산드라, CouchDB |
일관성 모델: 스펙트럼
강력한 일관성과 최종 일관성 사이에는 많은 중간 모델이 있습니다. 이를 알면 각 사용 사례에 대해 올바른 절충안을 선택할 수 있습니다.
// Spettro dei modelli di consistenza (dal piu forte al piu debole)
// 1. STRONG (Linearizable) Consistency
// Ogni operazione sembra atomica e globalmente ordinata
// Esempio: PostgreSQL con una singola istanza
// Trade-off: latenza alta, disponibilita ridotta
// 2. SEQUENTIAL Consistency
// Le operazioni appaiono nell'ordine in cui vengono eseguite
// ma non necessariamente in tempo reale
// Esempio: spanner.google.com (TrueTime)
// 3. CAUSAL Consistency
// Le operazioni causalmente correlate sono viste nell'ordine corretto
// Le operazioni non correlate possono essere viste in ordine diverso
// Esempio: MongoDB con causally consistent sessions
// 4. READ-YOUR-WRITES (Session Consistency)
// Un client vede sempre le sue ultime scritture
// Altri client potrebbero vedere dati vecchi
// Esempio: DynamoDB con sticky sessions
// 5. MONOTONIC READ Consistency
// Una volta letto un valore, non vedrai mai un valore piu vecchio
// Non garantisce di vedere le proprie ultime scritture
// 6. EVENTUAL Consistency
// I nodi convergono allo stesso stato "eventualmente"
// Nessuna garanzia su quando o nell'ordine delle operazioni
// Esempio: DNS, DynamoDB default, AWS S3
Read-Your-Writes: 쓴 직후에 이전 데이터를 표시하지 않음
프로덕션에서 최종 일관성과 관련하여 가장 자주 발생하는 문제: 사용자 무언가를 업데이트하고, 세부정보 페이지로 리디렉션되고, 다시 확인됩니다. 복제본이 아직 변경 사항을 전파하지 않았기 때문에 이전 값입니다. 작성한 내용을 읽을 수 있는 일관성 클라이언트가 볼 수 있도록 보장 항상 최신 글을 쓰세요.
// Strategia 1: Sticky routing — leggi sempre dallo stesso nodo
// Il client viene indirizzato sempre alla replica primaria per
// un periodo dopo la scrittura (es. 1-5 secondi)
// Implementazione con un token di versione
interface WriteResult {
entityId: string;
version: number; // numero di versione dopo la scrittura
timestamp: number; // timestamp della scrittura
}
// Il client salva il WriteResult e lo invia nelle successive letture
interface ReadRequest {
entityId: string;
minVersion?: number; // "voglio almeno questa versione"
}
// Il server: attendi che la replica raggiunga la versione richiesta
class ConsistentReadService {
async readWithVersion(
entityId: string,
minVersion?: number,
timeoutMs = 5000
): Promise<Entity> {
const startTime = Date.now();
while (true) {
const entity = await this.replica.findById(entityId);
if (!minVersion || entity.version >= minVersion) {
return entity;
}
if (Date.now() - startTime > timeoutMs) {
// Timeout: leggi dal primario come fallback
return await this.primary.findById(entityId);
}
await this.sleep(50); // Breve attesa prima del retry
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
// Strategia 2: Redirect al primario per N secondi dopo scrittura
// Il CDN/LB indirizza le letture del client alla primary replica
// per 2-3 secondi dopo una scrittura
// Strategia 3: Cache invalidation
// Dopo la scrittura, invalida la cache del client
// La prossima lettura va direttamente alla primary
낙관적 UI: 먼저 업데이트하고 나중에 조정
L'낙관적인 UI 시스템의 기본 UX 패턴 최종 일관성을 위해: 인터페이스를 즉시 업데이트합니다(가정 쓰기가 성공할 것이라는 점) 그런 다음 서버와 조정합니다. 만든다 네트워크 대기 시간이 긴 경우에도 시스템은 즉각적인 것으로 인식됩니다.
// Optimistic UI con React e reconciliazione
import { useState, useOptimistic } from 'react';
interface Todo {
id: string;
text: string;
completed: boolean;
synced: boolean; // false = in attesa di conferma server
}
function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [todos, setTodos] = useState(initialTodos);
// useOptimistic: hook React 19 per Optimistic UI
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
);
async function handleToggle(todoId: string): Promise<void> {
const todo = todos.find((t) => t.id === todoId);
if (!todo) return;
const optimisticUpdate = { ...todo, completed: !todo.completed, synced: false };
// 1. Aggiorna UI immediatamente (ottimistico)
addOptimisticTodo(optimisticUpdate);
try {
// 2. Invia al server
const confirmed = await api.toggleTodo(todoId);
// 3. Sostituisci con il dato confermato dal server
setTodos((prev) =>
prev.map((t) => (t.id === todoId ? { ...confirmed, synced: true } : t))
);
} catch (error) {
// 4. ROLLBACK: ripristina lo stato originale se la scrittura fallisce
setTodos((prev) => prev.map((t) => (t.id === todoId ? todo : t)));
// Mostra feedback all'utente
showErrorToast('Impossibile aggiornare il task. Riprova.');
}
}
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} className={todo.synced ? '' : 'pending'}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
{todo.text}
{!todo.synced && <span className="sync-indicator">Salvataggio...</span>}
</li>
))}
</ul>
);
}
벡터 클록을 사용한 충돌 감지
두 명의 사용자가 시스템에서 동시에 동일한 데이터를 수정하는 경우 다중 복제를 사용하면 충돌이 발생합니다. 그만큼 벡터 시계 자동으로 차이를 감지하고 수행할 작업을 결정할 수 있습니다. 자동 병합 또는 충돌을 사용자에게 제시합니다.
// Vector Clock: implementazione base in TypeScript
type VectorClock = Record<string, number>;
function increment(clock: VectorClock, nodeId: string): VectorClock {
return { ...clock, [nodeId]: (clock[nodeId] ?? 0) + 1 };
}
function merge(a: VectorClock, b: VectorClock): VectorClock {
const result: VectorClock = { ...a };
for (const [node, time] of Object.entries(b)) {
result[node] = Math.max(result[node] ?? 0, time);
}
return result;
}
type CompareResult = 'before' | 'after' | 'concurrent' | 'equal';
function compare(a: VectorClock, b: VectorClock): CompareResult {
const allNodes = new Set([...Object.keys(a), ...Object.keys(b)]);
let aGreater = false;
let bGreater = false;
for (const node of allNodes) {
const aTime = a[node] ?? 0;
const bTime = b[node] ?? 0;
if (aTime > bTime) aGreater = true;
if (bTime > aTime) bGreater = true;
}
if (aGreater && bGreater) return 'concurrent'; // conflitto
if (aGreater) return 'after';
if (bGreater) return 'before';
return 'equal';
}
// Utilizzo per rilevare conflitti di scrittura concorrente
interface Document {
id: string;
content: string;
vectorClock: VectorClock;
lastModifiedBy: string;
}
async function updateDocument(
docId: string,
newContent: string,
clientClock: VectorClock,
nodeId: string
): Promise<{ success: boolean; conflict?: { local: Document; remote: Document } }> {
const current = await db.findById(docId);
const comparison = compare(clientClock, current.vectorClock);
if (comparison === 'concurrent') {
// Conflitto! Entrambe le versioni hanno avanzato indipendentemente
return {
success: false,
conflict: {
local: { ...current, content: newContent, vectorClock: clientClock },
remote: current,
},
};
}
if (comparison === 'before') {
// Il client ha una versione stale: rifiuta e richiedi re-fetch
throw new Error('Stale write: fetch the latest version first');
}
// OK: scrivi con clock aggiornato
const newClock = increment(merge(clientClock, current.vectorClock), nodeId);
await db.update(docId, newContent, newClock, nodeId);
return { success: true };
}
화해 전략
충돌을 감지하면 여러 가지 조정 전략을 사용할 수 있습니다. 각각은 서로 다른 데이터 유형에 적합합니다.
// Strategie di reconciliazione dei conflitti
// 1. Last-Write-Wins (LWW)
// Il timestamp piu recente vince. Semplice ma perde dati.
// Usato da: AWS DynamoDB (default), Apache Cassandra
function resolveWithLWW(a: Document, b: Document): Document {
return a.updatedAt > b.updatedAt ? a : b;
}
// 2. Merge automatico per strutture dati CRDT-friendly
// Counter: somma i delta (non i valori assoluti)
interface Counter {
nodeIncrements: Record<string, number>; // per ogni nodo: totale incrementi
}
function mergeCounters(a: Counter, b: Counter): Counter {
const result: Record<string, number> = { ...a.nodeIncrements };
for (const [node, count] of Object.entries(b.nodeIncrements)) {
result[node] = Math.max(result[node] ?? 0, count);
}
return { nodeIncrements: result };
}
function getCounterValue(counter: Counter): number {
return Object.values(counter.nodeIncrements).reduce((sum, v) => sum + v, 0);
}
// 3. Three-way merge (come Git)
// Confronta le due versioni divergenti con il loro antenato comune
function threeWayMerge(
ancestor: string,
versionA: string,
versionB: string
): { merged: string; hasConflicts: boolean } {
// Usa diff3 o similar per testi
// Per strutture dati: merge field-by-field
// Se un campo e stato modificato in A ma non in B: accetta la modifica di A
// Se un campo e stato modificato in entrambi: conflitto da risolvere manualmente
// Implementazione completa dipende dal tipo di dato
return { merged: versionA, hasConflicts: true }; // placeholder
}
// 4. User-driven conflict resolution
// Presenta entrambe le versioni all'utente e lascia scegliere
async function presentConflictToUser(
conflict: { local: Document; remote: Document }
): Promise<Document> {
const resolution = await showConflictDialog({
localVersion: conflict.local,
remoteVersion: conflict.remote,
message: 'Il documento e stato modificato da un altro utente. Quale versione vuoi mantenere?',
});
return resolution.chosen === 'local' ? conflict.local : conflict.remote;
}
최종 일관성을 위한 UX 패턴
최종 일관성에서 가장 과소평가되는 부분은 사용자와의 커뮤니케이션입니다. 사용자는 일관성 모델을 이해하지 못하거나 이해해서는 안 됩니다. 뭔가가 예상대로 작동하지 않을 때 즉시.
권장 UX 패턴
- 오래된 지표: 대기 시간을 숨기는 대신 "2분 전에 업데이트됨"이 표시됩니다. 사용자는 데이터가 최신이 아닐 수 있다는 점을 이해합니다.
- 보류 중인 상태 시각적 자료: 작업이 진행 중일 때 스피너, 회색 표시기 또는 "저장 중..." 텍스트를 사용하십시오.
- 성공 확인: 중요한 작업에 대해서는 낙관적이지 않고 서버 확인 후에만 확인 토스트/배너를 표시합니다.
- 자동 재시도: 중요하지 않은 작업의 경우 자동 재시도를 수행하세요. 모든 재시도가 실패한 경우에만 오류를 표시합니다.
- 영구 삭제 전 일시 삭제: 항목은 즉시 삭제된 것으로 표시되지만 실제 삭제는 서버 확인 후에만 수행됩니다.
// Pattern: Stale-While-Revalidate per dati non critici
async function useStaleWhileRevalidate<T>(
key: string,
fetcher: () => Promise<T>
): Promise<{ data: T; isStale: boolean }> {
// 1. Servi immediatamente i dati in cache (potenzialmente stale)
const cached = await cache.get<{ data: T; timestamp: number }>(key);
if (cached) {
const isStale = Date.now() - cached.timestamp > STALE_THRESHOLD_MS;
if (isStale) {
// 2. Avvia revalidation in background (non bloccante)
fetcher()
.then((freshData) => {
cache.set(key, { data: freshData, timestamp: Date.now() });
})
.catch(console.error);
}
return { data: cached.data, isStale };
}
// 3. Nessuna cache: fetch bloccante
const freshData = await fetcher();
await cache.set(key, { data: freshData, timestamp: Date.now() });
return { data: freshData, isStale: false };
}
// Utilizzo nel frontend:
// const { data: userProfile, isStale } = await useStaleWhileRevalidate(
// `profile:${userId}`,
// () => api.getUserProfile(userId)
// );
// if (isStale) showBanner('Dati potenzialmente non aggiornati');
고려해야 할 절충안
모든 데이터가 최종 일관성을 허용하는 것은 아닙니다. 제품 가격, 판매 당좌 계정, 항공권의 가용성: 이러한 작업에는 다음이 필요합니다. 강한 일관성. 최종 일관성은 소셜 피드, 카운터에 적합합니다. 좋아요, 분석 데이터, 사용자 프로필, 중요하지 않은 인벤토리. 식별 어떤 데이터에 아키텍처의 일관성 수준이 필요한지 명시적으로 설명합니다.
결론: 의식적인 선택으로서의 최종 일관성
최종 일관성은 분산 시스템의 약점이 아니라 선택입니다. ACID 시스템이 제공하는 확장성, 가용성 및 복원성을 가능하게 하는 의도 그들은 제공할 수 없습니다. 핵심은 불일치가 발생하지 않도록 시스템을 설계하는 것입니다. 임시 항목은 사용자에게 표시되지 않습니다(낙관적 UI, 작성한 내용 읽기). 불가피할 경우 명시적으로 전달됩니다.
이 기사로 이벤트 중심 아키텍처 시리즈를 마무리합니다. 이제 이해가 되셨군요 안정적인 분산 시스템을 구축하기 위한 도구와 패턴이 완비되어 있습니다. 도메인 이벤트에서 Outbox 패턴까지, DLQ에서 소비자 멱등성까지, 생산의 일관성을 관리하는 전략까지.







