트랜잭션 경계가 공유되지 않는 Postgres와 TigerBeetle 사이에서, 시스템 오브 레코드/레퍼런스를 정하고 연산 순서(Write Last, Read First)와 멱등 연산, 내구 실행으로 일관성과 추적 가능성을 유지하는 방법을 설명한다.
URL: https://tigerbeetle.com/blog/2025-11-06-the-write-last-read-first-rule/
TigerBeetle은 정확성을 위해 구축된 금융 트랜잭션 데이터베이스입니다. 하지만 올바른 구성 요소로부터 올바른 시스템을 만드는 일은 여전히 도전적입니다.
각각이 고립된 상태에서 올바른 시스템들을 합성한다고 해서 반드시 올바른 시스템이 되는 것은 아닙니다. 이 글에서는 트랜잭션이 없는 상황에서 일관성을 유지하는 방법, 중간 상태가 외부로 노출될 때 정확성을 어떻게 사고할지, 그리고 부분 실패에서 어떻게 복구할지를 살펴봅니다.
TigerBeetle은 복식부기를 위한 두 가지 프리미티브(기본 구성 요소)인 계정(accounts) 과 이체(transfers) 를 제공하는 금융 트랜잭션 데이터베이스입니다. 계정 소유자의 이름과 주소, 또는 계정의 약관 같은 마스터 데이터는 Postgres 같은 별도의 데이터 저장소에 저장합니다.
이러한 분리는 일반 목적의 마스터 데이터와 독립적으로 이체를 확장할 수 있게 해주며(예: 블랙 프라이데이 이벤트 대응), 서로 독립된 데이터셋에 대해 각기 다른 보안/컴플라이언스/보존 요구사항을 해결합니다(예: 이체의 불변성 강제).
은행이 서류 보관 캐비닛과 금고를 모두 필요로 할 수 있듯이, Postgres는 문자열 처리와 엔터티(마스터 데이터) 기술에 특화되어 있고, TigerBeetle은 정수와 엔터티들 사이에서 정수를 이동시키는 데 특화되어 있습니다.
트랜잭션은 커밋으로 끝나는(모든 연산이 효력을 갖는) 또는 중단(abort)으로 끝나는(어떤 연산도 효력을 갖지 않는) 일련의 연산입니다. 완료는 즉시 이루어지며, 중간 상태는 외부로 노출되지 않고 관측 불가능합니다. 장애(프로세스 실패나 네트워크 실패 등)는 투명하게 완화됩니다.
하지만 두 개의 트랜잭션을 순차적으로 합성한 것은 그 자체로 트랜잭션이 아닙니다. 전체 시퀀스의 완료는 (기껏해야) 결국(eventual) 일어나며, 중간 상태가 외부로 노출되어 관측 가능합니다. 장애는 투명하게 완화되지 않습니다.
Postgres와 TigerBeetle은 트랜잭션 경계를 공유하지 않기 때문에, 애플리케이션은 트랜잭션이 아니라 완료를 위한 반복 시도와 조정(coordination) 을 통해 일관성을 보장해야 합니다.
이러한 조정을 사고하기 위해서는, 시스템이 지켜야 한다고 기대하는 보장(guarantee)을 이해할 필요가 있습니다.
시스템은 안전성(safety) 속성과 활성(liveness) 속성의 집합으로 특징지을 수 있습니다. 안전성 속성은 “나쁜 일이 절대 일어나지 않는다”를 말하고, 활성 속성은 “좋은 일이 언젠가는 일어난다”를 말합니다.
이 글에서는 다음 두 가지 안전성 속성에 집중합니다.
일관성(Consistent)
Postgres의 모든 계정이 TigerBeetle에도 계정으로 존재하고, 그 반대도 성립할 때 시스템을 일관적이라고 봅니다.
Consistent =
∧ ∀ a₁ ∈ PG: ∃ a₂ ∈ TB: id(a₁) = id(a₂)
∧ ∀ a₁ ∈ TB: ∃ a₂ ∈ PG: id(a₁) = id(a₂)
추적 가능성(Traceable)
TigerBeetle에서 잔액이 양수인 모든 계정이 Postgres의 계정과 대응할 때 시스템을 추적 가능하다고 봅니다.
Traceable = ∀ a₁ ∈ TB: balance(a₁) > 0 => ∃ a₂ ∈ PG: id(a₁) = id(a₂)
트랜잭션이 없는 상황에서는 시스템이 일시적으로 불일치할 수 있습니다. 하지만 돈을 잃는(보다 정확히는 돈이 고아(orphan) 상태가 되는) 가능성을 피하기 위해 시스템은 항상 추적 가능해야 합니다.
트랜잭션이 없을 때는, 트랜잭션이 암묵적으로 해주던 것을 명시적인 아키텍처 결정으로 내려야 합니다: 어떤 시스템이 계정의 존재를 결정하는가? 다시 말해, 어떤 시스템이 진실의 원천(source of truth) 인가?
우리는 다음을 지정해야 합니다.
시스템 오브 레코드(System of Record): 챔피언. 여기에서 계정이 존재하면, 시스템 수준에서 계정이 존재합니다.
시스템 오브 레퍼런스(System of Reference): 지원자. 여기에는 계정이 존재하지만 시스템 오브 레코드에는 없다면, 시스템 수준에서는 계정이 존재하지 않습니다.
그렇다면 어떤 시스템이 시스템 오브 레코드이고 어떤 시스템이 시스템 오브 레퍼런스일까요? 이는 요구사항과 하위 시스템의 성질에 따라 달라지는 아키텍처 결정입니다. 이 경우 TigerBeetle이 시스템 오브 레코드입니다.
계정이 Postgres에만 존재하면, 이체를 처리할 수 없으므로 Postgres의 계정은 단지 준비(staged) 레코드를 나타냅니다.
계정이 TigerBeetle에 존재하면, 이체를 처리할 수 있으므로 TigerBeetle의 계정은 커밋된(committed) 레코드를 나타냅니다.
즉, TigerBeetle에서 계정이 생성되는 즉시, 계정은 시스템 전반에서 존재하게 됩니다.
시스템 오브 레코드를 선택하고 나면, 정확성은 올바른 순서로 연산을 수행하는 것에 달려 있습니다.
시스템 오브 레퍼런스는 존재를 결정하지 않기 때문에, 아무것도 커밋하지 않은 채로 먼저 안전하게 쓸 수 있습니다. 오직 시스템 오브 레코드에 쓸 때에만 계정이 “존재하게” 됩니다.
반대로 존재 여부를 확인하기 위해 읽을 때는 시스템 오브 레코드를 조회해야 합니다. 시스템 오브 레퍼런스를 읽어서는 계정이 실제로 존재하는지 알 수 없습니다.
이 원칙—Write Last, Read First(마지막에 쓰고, 먼저 읽기)—은 애플리케이션 수준의 일관성을 유지하도록 보장합니다.
흥미롭게도, TigerBeetle처럼 시스템 오브 레코드가 엄격 직렬화(strict serializability)를 제공하고, 순서를 올바르게 적용한다면, 시스템 전체도 엄격 직렬화를 보존하여 개발자 경험이 매우 좋아집니다.
올바른 시스템 오브 레코드와 올바른 연산 순서를 선택하는 일은 단지 철학적 연습이 아닙니다. 잘못된 시스템을 진실의 원천으로 지정하고 잘못된 순서로 연산을 수행하면, 안전성 속성을 빠르게 위반할 수 있습니다.
예를 들어 TigerBeetle에는 계정을 만들었지만 Postgres에는 만들지 않았다면, 시스템은 이 계정의 소유자가 누구인지에 대한 정보 없이 이체를 처리하기 시작할 수 있습니다. 시스템이 크래시되고 포렌식으로도 소유권을 확립할 정보가 드러나지 않으면, 황금률인 추적 가능성을 위반한 것입니다.
반대로 Postgres에는 계정을 만들었지만 이후 TigerBeetle에는 만들지 못했다면, 문제될 것이 없습니다. 어떤 이체 시도도 TigerBeetle에서 그냥 거부되며, 원장(ledger)에 존재하지 않는 계정으로는 돈이 흐를 수 없습니다.
클라이언트는 애플리케이션 계층이 노출하는 API(Application Programming Interface)를 통해서만 시스템과 상호작용하며, 애플리케이션 계층은 다시 Postgres와 TigerBeetle 같은 하위 시스템이 노출하는 인터페이스를 통해 상호작용합니다.
API는 두 가지 책임을 가집니다: 오케스트레이션(orchestration) 과 집계(aggregation) 입니다. API는 연산 순서를 결정하고, 연산 결과를 애플리케이션 수준의 의미론으로 집계합니다.
우리는 Resonate의 내구 실행(durable execution) 프레임워크인 Distributed Async Await로 API를 구현할 것입니다. Distributed Async Await는 결국 완료(eventual completion)를 보장하여, 트랜잭션이 없는 상황에서도 일관성에 도달하는 것을 단순화합니다.
Resonate는 언어 통합 체크포인팅(checkpointing)과 장애 시 신뢰할 수 있는 재개(resumption)를 통해 결국 완료를 보장합니다. 실행은 처음부터 재시작하되 이미 기록된 단계는 건너뛰어 중단된 지점부터 이어서 진행합니다(그림 4 참고).
하지만 체크포인팅에 내재한 미묘한 이슈를 고려해야 합니다. 장애가 발생했을 때, 어떤 연산을 수행한 뒤 그 완료를 기록하기 전에 중단되면, 해당 연산은 다시 수행됩니다.
따라서 모든 연산은 멱등(idempotent)해야 합니다. 즉, 연산을 반복 적용하더라도 최초 적용을 넘어서는 추가 효과가 없어야 합니다.
각 하위 시스템(Postgres와 TigerBeetle)에 대해, 우리는 계정을 생성하는 멱등 함수를 구현합니다. 이 경우 Postgres와 TigerBeetle의 계정 생성 함수는 계정이 생성되었는지, 같은 값으로 이미 존재하는지, 다른 값으로 이미 존재하는지를 반환합니다.
type Result =
| { type: “created” }
| { type: “exists_same” }
| { type: “exists_diff” }
// Create account in Postgres
async function pgCreateAccount(uuid: string, data: any): Promise<Result>
// Create Account in TigerBeetle
async function tbCreateAccount(uuid: string, data: any): Promise<Result>
아래 코드는 tbCreateAccount를 보여줍니다.
async function tbCreateAccount(context: Context, guid: number) {
const client = context.getDependency("client");
// Construct account object
const account: Account = {
...
};
const errors = await client.createAccounts([account]);
// Success case: account was created
if (errors.length === 0) {
return { type: “created” };
}
const error = errors[0];
// Account exists with the same properties (idempotent)
if (error.result === CreateAccountError.exists) {
return { type: “exists_same” };
}
// Account exists with different properties
if (
error.result === CreateAccountError.exists_with_different_flags ||
error.result === ...)
return { type: “exists_diff” };
}
// For any other error, throw
throw new Error(`Failed to create account: ${JSON.stringify(error)}`);
}
시스템 수준에서 애플리케이션은 이 멱등 빌딩 블록들을 합성하고, 하위 시스템의 응답을 해석하여 애플리케이션 수준 의미론으로 변환합니다.
예를 들어 Postgres나 TigerBeetle이 “계정이 이미 존재하지만 값이 다르다”를 반환할 수 있습니다. 애플리케이션 계층은 이것이 성공인지 실패인지 결정해야 하며, 플랫폼 수준 의미론을 애플리케이션 수준 의미론으로 번역해야 합니다.
이 경우 연산이 반복될 수 있으므로, 생성됨(created) 또는 같은 값으로 이미 존재(exists_same) 는 성공을 의미하지만, 다른 값으로 이미 존재(exists_diff) 는 시스템의 버그를 나타냅니다. 또한 write last, read first에 따라 Postgres 계정이 생성되었는데 TigerBeetle 계정이 이미 존재한다면, 순서 위반이 발생한 것입니다.
| Postgres 결과 | TigerBeetle 결과 | 시스템 결과 | 시나리오 |
|---|---|---|---|
| Created | Created | Success | |
| Created | Exists/Same | Panic | 순서 위반 |
| Created | Exists/Diff | Panic | 순서 위반 |
| Exists/Same | Created | Success | 복구 |
| Exists/Same | Exists/Same | Success | 복구 |
| Exists/Same | Exists/Diff | Panic | 충돌 |
| Exists/Diff | Any | Panic | 충돌 |
아래 코드는 createAccount를 보여줍니다.
function* createAccount(context: Context, uuid: string, data: any) {
// Generate internal account ID for TigerBeetle
const guid = yield* context.run(generateId);
// Create account in Postgres
const pgResult =
yield* context.run(pgCreateAccount, uuid, { ...data, guid: guid });
// Panic and alert the operator if the account exists
// but with different values
yield* context.panic(pgResult.type == “exists_diff”);
// Create account in TigerBeetle
const tbResult =
yield* context.run(tbCreateAccount, guid);
// Panic and alert the operator if the account exists
// but with different values
yield* context.panic(tbResult.type == “exists_diff”);
// Panic and alert the operator if ordering was violated
yield* context.panic(pgResult.type == "created" &&
tbResult.type == “exists_same”);
return {uuid, guid};
}
우리는 정확성 위반을 감지하고 완화해야 하며, 여기서는 진행을 거부하고 운영자에게 알리는 방식으로 처리합니다.
계정을 생성하기 위해 개발자는 고유 ID(예: 계정 ID)로 createAccount를 실행합니다.
await resonate.run(create-${uuid}, createAccount, uuid, {...});
여기에 할당된 고유 식별자 create-${uuid} 는 최상위 실행에 고유한 정체성을 부여하고, 하위 실행들에도 전이적으로 동일한 정체성을 부여하여 일관된 체크포인팅을 보장하고, 명시적인 복구 로직의 필요를 제거합니다.
트랜잭션이 없는 상황에서는 애플리케이션의 정확성을 보장하기 위해 조정(coordination)에 의존합니다. 정확성을 사고하기 위한 강력한 프레임워크는 시스템을 시스템 오브 레코드와 시스템 오브 레퍼런스로 구분하고, 요구사항에 따라 연산 순서를 정하는 것입니다.
정확성에는 여전히 신중한 설계가 필요하지만, 내구 실행, 의도적인 순서 지정, 멱등 연산을 통해 우리는 올바른 구성 요소들로부터 올바른 시스템을 구축할 수 있습니다.
참고: 예제를 실행하려면 GitHub 저장소를 방문하세요: https://github.com/resonatehq-examples/example-tigerbeetle-account-creation-ts.
이 게스트 포스트를 집필해 준 Resonate의 창업자이자 CEO인 Dominik Tornow에게 감사를 전합니다!