Tx
Tx는 낙관적 UI 업데이트와 서버 요청을 하나의 트랜잭션으로 묶고, 실패 시 자동으로 롤백해 주는 실행 레이어입니다.
- 여러 단계를 한 번에 실행하고
- 중간에 실패하면 보상(compensate) 단계를 역순으로 실행해서 UI를 되돌리며
- 재시도(선형/지수 백오프)와 전역 타임아웃, ViewTransition, DevTools 이벤트까지 일관되게 처리합니다.
낙관적 UI를 쓰고 싶은데 반쯤만 롤백된 상태가 자꾸 생길 때
여러 API 호출/로컬 모델 변경을 원자적으로 다루고 싶을 때
- 실패 케이스까지 DevTools에서 한 번에 추적하고 싶을 때
1. 설치 & 가장 단순한 사용: startTransaction
먼저 Tx를 설치합니다. 이 문서의 예제는 Local-First 모델을 사용하므로 함께 설치하는 형태로 보여줍니다.
@firsttx/tx자체는 어느 상태 관리 레이어에도 런타임 의존이 없습니다.
React 훅(useTx)을 위해react가peerDependency로 요구되며,
이 문서의 예제에서는 Local-First 모델을 함께 사용하는 방식으로 설명합니다. (GitHub)
이제 단일 트랜잭션으로 Cart에 아이템을 추가하는 예제입니다.
import { startTransaction } from "@firsttx/tx";
import { CartModel } from "./models/cart";
async function addToCart(item: { id: string; name: string; qty: number }) {
// ViewTransition을 켜고 트랜잭션 시작 (timeout은 기본 30초)
const tx = startTransaction({
transition: true,
// timeout: 30_000, // 필요하면 전역 타임아웃(ms)을 이렇게 조정할 수 있습니다.
});
// 1단계: 로컬 모델에 낙관적 추가 + 보상(compensate)
await tx.run(
() =>
CartModel.patch((draft) => {
draft.items.push(item);
}),
{
compensate: () =>
CartModel.patch((draft) => {
draft.items.pop();
}),
},
);
// 2단계: 서버 요청 (AbortSignal을 인자로 받을 수 있습니다)
await tx.run((signal) =>
fetch("/api/cart", {
method: "POST",
body: JSON.stringify(item),
signal, // 전체 트랜잭션 타임아웃에 맞춰 자동으로 abort됩니다.
}),
);
// 모든 단계가 성공했다면 commit
await tx.commit();
}
여기서 중요한 포인트,
-
startTransaction(options?)- 트랜잭션의 ID, ViewTransition 사용 여부, 전역 타임아웃 등을 설정합니다.
-
tx.run(fn, options?)- 스텝 함수
fn을 실행합니다. - 중간에 실패하면 이후 단계는 실행되지 않고, 지금까지 성공한 단계들만 역순으로 보상(compensate) 됩니다.
- 스텝 함수
-
tx.commit()- 모든 단계가 문제 없이 끝났다는 신호입니다.
- commit 전에 에러가 나면 자동으로 롤백 경로로 들어갑니다.
- 이미
committed상태라면 두 번째commit()호출은 아무 일도 하지 않는 idempotent 동작입니다.
startTransaction(options?)
| Name | Type | Default | Description |
|---|---|---|---|
id | string | 자동 생성 (UUID) | 트랜잭션 ID입니다. DevTools에서 트랜잭션을 식별할 때 사용됩니다. |
transition | boolean | false | 롤백 경로에서 ViewTransition API를 사용할지 여부입니다. <code>true</code>이고 브라우저가 <code>document.startViewTransition</code>을 지원하는 경우, 롤백 시 화면 전환을 부드럽게 연출합니다. |
timeout | number | 30000 | 트랜잭션 전체에 대한 전역 타임아웃(ms)입니다. 여러 스텝이 이 시간을 나눠 쓰며, 초과되면 <code>TransactionTimeoutError</code>가 발생합니다. |
2. 트랜잭션 수명주기 & 상태
내부 트랜잭션 엔진은 대략 아래 상태를 가집니다,
pending- 아직 아무 단계도 실행하지 않은 상태running- 하나 이상의 단계를 실행 중인 상태committed- 모든 단계 실행 + commit 완료rolled-back- 실패 후 compensate가 모두 성공적으로 끝난 상태failed- 롤백(compensate) 과정에서조차 문제가 생긴 상태 (아래CompensationFailedError참고)
롤백 대상 스텝
- 실패가 발생하면, 지금까지 성공했던 스텝들만 대상이 됩니다.
- 실패한 그 스텝의
compensate는 호출되지 않습니다. - 보상은 항상 “성공한 스텝의 역순”으로 실행됩니다.
유효한 호출 시점
pending/running상태에서만run()과commit()을 호출할 수 있습니다.- 이미
committed이거나rolled-back/failed상태에서 다시 호출하면 **TransactionStateError**가 발생합니다.
전역 타임아웃 & Abort
-
트랜잭션에는 기본 30초(
timeout: 30000)의 전역 타임아웃이 걸려 있습니다. -
각 스텝 실행 시 내부적으로
AbortController가 생성되고,tx.run에 넘긴 함수는 선택적으로AbortSignal을 인자로 받을 수 있습니다. -
제한 시간이 지나면 해당 스텝의
AbortSignal에abort가 발생하고,fetch(url, { signal }),abortableSleep(ms, signal)처럼 시그널을 인자로 사용하는 API는 즉시 중단될 수 있습니다.
-
스텝이
AbortSignal을 사용하지 않는다면, 타임아웃 이후에도 실제 함수는 끝까지 실행될 수 있지만, Tx 입장에서는 이미 타임아웃으로 실패 처리됩니다.
run중 에러가 발생하면, 이미 성공한 단계들의compensate를 역순으로 실행합니다.이 보상 중 하나라도 실패하면
CompensationFailedError가 던져지며, 원래 오류 + 각 보상 단계의 오류들이 모두 하나의 에러 안에 배열 형태로 묶입니다.따라서
compensate는 실패 확률이 낮은, idempotent한 연산으로 설계하는 것이 중요합니다.
3. tx.run과 재시도(retry)
Tx는 각 스텝마다 재시도 전략을 지정할 수 있습니다. 내부적으로는 retry.ts에서 선형/지수 백오프를 처리하고, 마지막 시도까지 실패한 경우 RetryExhaustedError를 던집니다.
const tx = startTransaction();
await tx.run(
async (signal) => {
const res = await fetch("/api/order", {
method: "POST",
signal,
});
if (!res.ok) {
throw new Error("Order failed");
}
},
{
// 실패 시 이 스텝만 재시도 (보상 로직은 최종 실패 이후에만 실행)
retry: {
maxAttempts: 3,
delayMs: 300,
backoff: "exponential", // 또는 "linear"
},
compensate: async () => {
// 필요한 경우 롤백 API 호출 등
await fetch("/api/order/cancel", { method: "POST" });
},
},
);
tx.run(fn, options?)
| Name | Type | Default | Description |
|---|---|---|---|
fn | (signal?: AbortSignal) => Promise<T> | T | - | 트랜잭션 단계에서 실제로 실행될 함수입니다. 첫 번째 인자로 <code>AbortSignal</code>을 받을 수 있으며, 이를 <code>fetch</code> 등의 API에 넘기면 타임아웃/취소에 반응하는 코드를 작성할 수 있습니다. 이 함수가 에러를 던지면 이후 단계는 실행되지 않습니다. |
options.compensate | () => Promise<void> | void | - | 이 단계까지 성공한 변경 사항을 되돌리는 보상 함수입니다. 롤백 시 <strong>성공한 스텝들만</strong> 역순으로 호출되며, 실패한 스텝의 <code>compensate</code>는 호출되지 않습니다. |
options.retry.maxAttempts | number | 1 | 이 단계에서 실패 시 재시도 최대 횟수입니다. 기본값은 1회(즉, 재시도 없음)입니다. |
options.retry.delayMs | number | 100 | 재시도 간격의 기본 지연(ms)입니다. 지정한 백오프 전략에 따라 누적 지연 시간이 계산됩니다. |
options.retry.backoff | "exponential" | "linear" | "exponential" | 재시도 간격 증가 방식을 정합니다. 지수: 100 → 200 → 400 → 800ms, 선형: 100 → 200 → 300 → 400ms. |
라이브러리에서는 DEFAULT_RETRY_CONFIG와 RETRY_PRESETS도 함께 export합니다.
import { DEFAULT_RETRY_CONFIG, RETRY_PRESETS } from "@firsttx/tx";
tx.run(doSomething, {
retry: RETRY_PRESETS.aggressive,
});
4. React 훅: useTx
실제 앱에서는 startTransaction + tx.run을 직접 쓰기보다는,
옵티미스틱 업데이트 + 롤백 + 서버 요청을 한 번에 묶어주는 useTx 훅을 많이 사용하게 됩니다.
import { useTx } from "@firsttx/tx";
import { CartModel } from "./models/cart";
export function AddToCartButton({
item,
}: {
item: { id: string; name: string; qty: number };
}) {
const { mutate, isPending, isError, error } = useTx({
// 1) 낙관적 UI 업데이트
optimistic: (input) => {
CartModel.patch((draft) => {
draft.items.push(input);
});
// 여기서 반환한 값은 snapshot으로 rollback / onSuccess 등에 전달됩니다.
// 이 예제에서는 굳이 사용할 값이 없으므로 반환하지 않아도 됩니다(void snapshot).
},
// 2) 롤백 (변수와 snapshot을 모두 받을 수 있습니다)
rollback: (input, _snapshot) => {
CartModel.patch((draft) => {
// 가장 단순한 예: 마지막으로 추가한 아이템 제거
draft.items.pop();
});
},
// 3) 실제 서버 요청 (snapshot 인자를 선택적으로 사용할 수 있습니다)
request: (input, _snapshot) =>
fetch("/api/cart", {
method: "POST",
body: JSON.stringify(input),
}),
// 선택: ViewTransition 사용 여부 (기본 false)
transition: true,
// 선택: 재시도 전략
retry: {
maxAttempts: 2,
delayMs: 200,
backoff: "exponential",
},
// 선택: 언마운트 시 자동으로 취소 (논리적 취소)
cancelOnUnmount: true,
onSuccess: () => {
// 토스트 등 후처리
console.log("Added to cart");
},
onError: (err) => {
console.error("Failed to add item", err);
},
});
return (
<div className="flex items-center gap-2">
<button
type="button"
disabled={isPending}
onClick={() => mutate(item)}
className="rounded-full bg-foreground px-4 py-2 text-xs font-medium text-background transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{isPending ? "Adding..." : "Add to cart"}
</button>
{isError && (
<p className="text-xs text-destructive">
{error?.message ?? "Failed to add item"}
</p>
)}
</div>
);
}
useTx는 제네릭으로 variables(V), result(R), snapshot(S)을 다룹니다,
optimistic(variables) => snapshotrollback(variables, snapshot)request(variables, snapshot) => resultonSuccess(result, snapshot)onError(error, snapshot)
useTx(config)
| Name | Type | Default | Description |
|---|---|---|---|
config.optimistic | (variables: V) => S | Promise<S> | - | 낙관적 UI 단계입니다. Local-First 모델 patch, React 상태 업데이트 등 트랜잭션 시작 시 가장 먼저 실행됩니다. 반환값 <code>S</code>는 snapshot으로 보관되어 <code>rollback</code>, <code>request</code>, <code>onSuccess</code>, <code>onError</code>에 전달됩니다. 값을 반환하지 않으면 snapshot 타입은 <code>void</code>가 됩니다. |
config.rollback | (variables: V, snapshot: S) => Promise<void> | void | - | 낙관적 단계에서의 변경을 되돌리는 함수입니다. 요청 실패, 타임아웃, 재시도 소진 후에 호출됩니다. snapshot을 이용해 이전 상태를 복원할 수 있습니다. |
config.request | (variables: V, snapshot: S) => Promise<R> | - | 실제 서버 요청을 담당합니다. 실패하면 rollback이 호출됩니다. snapshot을 이용해 서버에 필요한 메타데이터를 함께 보낼 수 있습니다. |
config.transition | boolean | false | ViewTransition API로 롤백 시 UI 상태 전환을 부드럽게 연출할지 여부입니다. 지원 브라우저에서만 적용되며, 미지원 브라우저에서는 자동으로 degrade 됩니다. |
config.retry | { maxAttempts?: number; delayMs?: number; backoff?: 'exponential' | 'linear' } | DEFAULT_RETRY_CONFIG | 요청 단계에 대한 재시도 정책입니다. 지정하지 않으면 라이브러리 기본값(1회 시도, 100ms, 지수 백오프)을 사용합니다. <code>RETRY_PRESETS</code>를 함께 사용하면 공통 패턴을 쉽게 재활용할 수 있습니다. |
config.onSuccess | (result: R, snapshot: S) => void | - | 트랜잭션 전체가 성공적으로 commit된 뒤 호출되는 콜백입니다. 서버 응답과 snapshot을 함께 사용할 수 있습니다. |
config.onError | (error: Error, snapshot: S) => void | - | 트랜잭션이 실패하거나 롤백 중 오류가 발생했을 때 호출됩니다. <code>TxError</code> 계층이 전달될 수 있습니다. |
config.cancelOnUnmount | boolean | false | 컴포넌트 언마운트 시 자동으로 <code>cancel()</code>을 호출할지 여부입니다. 현재 구현에서는 네트워크 요청 자체를 중단하지 않고, 완료 후 상태 업데이트/콜백 실행만 막는 <strong>논리적 취소</strong>입니다. |
useTx(config) 반환값
| Name | Type | Default | Description |
|---|---|---|---|
mutate | (variables: V) => void | - | 트랜잭션을 실행하는 함수입니다. fire-and-forget 스타일로, Promise를 반환하지 않습니다. 에러는 내부적으로 처리되며, 상태 플래그(<code>isError</code> 등)와 <code>onError</code>를 통해 관찰합니다. |
mutateAsync | (variables: V) => Promise<R> | - | 트랜잭션을 Promise 기반으로 실행하는 함수입니다. <code>await</code> / <code>try-catch</code>가 필요한 경우에 사용합니다. |
cancel | () => void | - | 현재 진행 중인 트랜잭션 결과를 UI에 반영하지 않도록 논리적으로 취소합니다. 이미 시작된 네트워크/타이머 작업은 계속 진행되지만, 완료 후 상태 업데이트와 콜백 실행은 무시됩니다. |
isPending | boolean | - | 트랜잭션이 진행 중인지 여부입니다 (optimistic / request / rollback 포함). |
isError | boolean | - | 마지막 트랜잭션이 오류로 끝났는지 여부입니다. |
isSuccess | boolean | - | 마지막 트랜잭션이 정상적으로 commit되었는지 여부입니다. |
error | Error | null | - | 마지막 오류 객체입니다. <code>TxError</code> 계층을 포함할 수 있습니다. |
useTx는 호출될 때마다 내부적으로 새로운startTransaction을 생성해 각 단계를 트랜잭션으로 묶습니다.cancelOnUnmount가true면 언마운트 시 자동으로cancel()이 호출되어 이후 상태 업데이트와 콜백 실행만 막습니다. 이미 진행 중인 네트워크 요청 자체는 취소하지 않습니다.transition: true이고 브라우저가document.startViewTransition을 지원하면, 실패로 인해 롤백이 실행될 때 ViewTransition으로 감싸져 자연스러운 되돌리기 애니메이션을 제공합니다. (성공 경로에서는 ViewTransition을 사용하지 않습니다.)
5. 오류 모델 & DevTools
Tx는 공통 오류 계층 TxError를 기반으로 여러 에러 타입을 제공합니다.
5.1 에러 계층
-
TxError(추상 클래스)getUserMessage(): string- 사용자에게 보여줄 메시지getDebugInfo(): string- 로깅/디버깅용 상세 문자열isRecoverable(): boolean- 재시도/재시도 UX가 의미 있는지 여부
-
TransactionTimeoutError- 전역 타임아웃(또는 남은 시간이 0인 상황)으로 인해 트랜잭션이 중단되었을 때 발생합니다.
- 일반적으로 네트워크/시스템 일시 오류로 간주하여 복구 가능(true) 입니다.
-
RetryExhaustedError- 설정된 재시도 횟수(maxAttempts)를 모두 써도 스텝이 성공하지 못했을 때 발생합니다.
- 네트워크 불안정/서버 일시 오류 등으로, 역시 복구 가능(true) 입니다.
-
CompensationFailedError- 롤백 과정에서 하나 이상의
compensate가 실패했을 때 발생합니다. - 내부에 원래 에러 + 각 보상 단계의 에러들이 배열로 담겨 있습니다.
- 데이터 불일치/수동 개입이 필요한 케이스로 간주하여 복구 불가능(false) 으로 취급됩니다.
- 롤백 과정에서 하나 이상의
-
TransactionStateError- 이미
committed되거나rolled-back/failed상태인 트랜잭션에run/commit을 호출했을 때 발생합니다. - 코드 버그에 가깝기 때문에 복구 불가능(false) 으로 간주됩니다.
- 이미
UI 레이어에서는 대략 이런 식으로 사용할 수 있습니다,
import { TxError } from "@firsttx/tx";
try {
await mutateAsync(input);
} catch (error) {
if (error instanceof TxError) {
toast(error.getUserMessage());
logger.error(error.getDebugInfo(), {
recoverable: error.isRecoverable(),
});
if (error.isRecoverable()) {
// "다시 시도" 버튼 노출 등의 UX를 고려할 수 있습니다.
}
} else {
// 일반 Error
toast("알 수 없는 오류가 발생했습니다.");
}
}
5.2 DevTools 이벤트
Tx는 브라우저 전역에 window.__FIRSTTX_DEVTOOLS__가 존재할 경우,
트랜잭션 진행 상황을 아래 형태의 이벤트로 전파합니다.
window.__FIRSTTX_DEVTOOLS__?.emit({
id: crypto.randomUUID(),
category: "tx",
type: "step.success", // 예: 'start' | 'step.start' | 'step.success' | ...
timestamp: Date.now(),
priority: 1,
data: { /* 트랜잭션/스텝 메타데이터 */ },
});
대표적인 type 값은 다음과 같습니다.
start- 트랜잭션 시작step.start/step.success/step.retry/step.fail- 스텝 단위 실행/성공/재시도/실패commit- 커밋 완료rollback.start/rollback.success/rollback.fail- 롤백 시작/완료/실패timeout- 전역 타임아웃 발생
priority 필드는 DevTools에서 이벤트 중요도를 구분하는 용도로 사용됩니다.
- 0 : 정상 흐름 (
step.success등) - 1 : 주요 이벤트 (
start,commit,step.retry등) - 2 : 실패/롤백/타임아웃 등 중요한 에러 이벤트
현재 구현에서는 step.success 이벤트의 payload 중 attempt 필드가 실제 시도 횟수가 아니라 설정된 maxAttempts 기준으로 기록되는 이슈가 있어, DevTools에서 재시도 횟수 통계가 약간 부정확하게 보일 수 있습니다. 트랜잭션 ID와 타임라인을 함께 참고하는 것이 안전합니다.
6. Local-First / Prepaint와 함께 쓰기
마지막으로, Tx는 단독으로도 쓸 수 있지만, Local-First + Prepaint와 함께 썼을 때 진가가 드러납니다,
- Prepaint - 재방문 시 마지막 화면을 바로 복원해 빈 화면 노출을 없애고, (GitHub)
- Local-First - IndexedDB를 단일 소스로 두어 작업 중 새로고침/오프라인에도 상태를 유지하며, (GitHub)
- Tx - 이 위에서 낙관적 업데이트를 원자적으로 묶어 실패 시 자연스럽게 롤백합니다. (GitHub)
각 레이어는 서로 독립적인 패키지이지만,
- Prepaint: 렌더/초기화 레이어
- Local-First: 데이터/동기화 레이어
- Tx: 실행/트랜잭션 레이어
로 분리되어 있어서, 필요에 따라 조합해서 사용할 수 있습니다.
각 레이어의 상세 설정은 /docs/prepaint, /docs/local-first 페이지에서 이어서 다룹니다.