logo
Firsttx

Tx

Tx는 낙관적 UI 업데이트와 서버 요청을 하나의 트랜잭션으로 묶고, 실패 시 자동으로 롤백해 주는 실행 레이어입니다.

  • 여러 단계를 한 번에 실행하고
  • 중간에 실패하면 보상(compensate) 단계를 역순으로 실행해서 UI를 되돌리며
  • 재시도(선형/지수 백오프)와 전역 타임아웃, ViewTransition, DevTools 이벤트까지 일관되게 처리합니다.
Tx를 쓰면 좋은 상황
  • 낙관적 UI를 쓰고 싶은데 반쯤만 롤백된 상태가 자꾸 생길 때

  • 여러 API 호출/로컬 모델 변경을 원자적으로 다루고 싶을 때

  • 실패 케이스까지 DevTools에서 한 번에 추적하고 싶을 때

1. 설치 & 가장 단순한 사용: startTransaction

먼저 Tx를 설치합니다. 이 문서의 예제는 Local-First 모델을 사용하므로 함께 설치하는 형태로 보여줍니다.

Tx + Local-First 설치
pnpm add @firsttx/tx @firsttx/local-first

@firsttx/tx 자체는 어느 상태 관리 레이어에도 런타임 의존이 없습니다.
React 훅(useTx)을 위해 reactpeerDependency로 요구되며,
이 문서의 예제에서는 Local-First 모델을 함께 사용하는 방식으로 설명합니다. (GitHub)

이제 단일 트랜잭션으로 Cart에 아이템을 추가하는 예제입니다.

ts
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?)

Options
requiredoptional
NameTypeDefaultDescription
id
string자동 생성 (UUID)트랜잭션 ID입니다. DevTools에서 트랜잭션을 식별할 때 사용됩니다.
transition
booleanfalse롤백 경로에서 ViewTransition API를 사용할지 여부입니다. <code>true</code>이고 브라우저가 <code>document.startViewTransition</code>을 지원하는 경우, 롤백 시 화면 전환을 부드럽게 연출합니다.
timeout
number30000트랜잭션 전체에 대한 전역 타임아웃(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을 인자로 받을 수 있습니다.

  • 제한 시간이 지나면 해당 스텝의 AbortSignalabort가 발생하고,

    • fetch(url, { signal }),
    • abortableSleep(ms, signal) 처럼 시그널을 인자로 사용하는 API는 즉시 중단될 수 있습니다.
  • 스텝이 AbortSignal을 사용하지 않는다면, 타임아웃 이후에도 실제 함수는 끝까지 실행될 수 있지만, Tx 입장에서는 이미 타임아웃으로 실패 처리됩니다.

CompensationFailedError
  • run 중 에러가 발생하면, 이미 성공한 단계들의 compensate를 역순으로 실행합니다.

  • 이 보상 중 하나라도 실패하면 CompensationFailedError가 던져지며, 원래 오류 + 각 보상 단계의 오류들이 모두 하나의 에러 안에 배열 형태로 묶입니다.

  • 따라서 compensate실패 확률이 낮은, idempotent한 연산으로 설계하는 것이 중요합니다.


3. tx.run과 재시도(retry)

Tx는 각 스텝마다 재시도 전략을 지정할 수 있습니다. 내부적으로는 retry.ts에서 선형/지수 백오프를 처리하고, 마지막 시도까지 실패한 경우 RetryExhaustedError를 던집니다.

ts
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?)

Options
requiredoptional
NameTypeDefaultDescription
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
number1이 단계에서 실패 시 재시도 최대 횟수입니다. 기본값은 1회(즉, 재시도 없음)입니다.
options.retry.delayMs
number100재시도 간격의 기본 지연(ms)입니다. 지정한 백오프 전략에 따라 누적 지연 시간이 계산됩니다.
options.retry.backoff
"exponential" | "linear""exponential"재시도 간격 증가 방식을 정합니다. 지수: 100 → 200 → 400 → 800ms, 선형: 100 → 200 → 300 → 400ms.

라이브러리에서는 DEFAULT_RETRY_CONFIGRETRY_PRESETS도 함께 export합니다.

ts
import { DEFAULT_RETRY_CONFIG, RETRY_PRESETS } from "@firsttx/tx";

tx.run(doSomething, {
  retry: RETRY_PRESETS.aggressive,
});

4. React 훅: useTx

실제 앱에서는 startTransaction + tx.run을 직접 쓰기보다는, 옵티미스틱 업데이트 + 롤백 + 서버 요청을 한 번에 묶어주는 useTx을 많이 사용하게 됩니다.

tsx
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) => snapshot
  • rollback(variables, snapshot)
  • request(variables, snapshot) => result
  • onSuccess(result, snapshot)
  • onError(error, snapshot)

useTx(config)

Options
requiredoptional
NameTypeDefaultDescription
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
booleanfalseViewTransition 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
booleanfalse컴포넌트 언마운트 시 자동으로 <code>cancel()</code>을 호출할지 여부입니다. 현재 구현에서는 네트워크 요청 자체를 중단하지 않고, 완료 후 상태 업데이트/콜백 실행만 막는 <strong>논리적 취소</strong>입니다.

useTx(config) 반환값

Component props
requiredoptional
NameTypeDefaultDescription
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> 계층을 포함할 수 있습니다.
React 통합 디테일
  • useTx는 호출될 때마다 내부적으로 새로운 startTransaction을 생성해 각 단계를 트랜잭션으로 묶습니다.

  • cancelOnUnmounttrue면 언마운트 시 자동으로 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 레이어에서는 대략 이런 식으로 사용할 수 있습니다,

ts
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__가 존재할 경우, 트랜잭션 진행 상황을 아래 형태의 이벤트로 전파합니다.

ts
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 : 실패/롤백/타임아웃 등 중요한 에러 이벤트
DevTools에서 재시도 카운트 해석 주의

현재 구현에서는 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 페이지에서 이어서 다룹니다.