트랜잭션은 여러 쿼리를 묶는 기능이 아니다

vx_developer·약 7시간 전

개발하다가

목록 보기
36/37
post-thumbnail

프로그래밍을 처음 배울 때 데이터베이스 트랜잭션은 보통 여러 쿼리를 하나로 묶어 모두 성공시키거나 모두 취소하는 기능이라고 배운다.

BEGIN;

UPDATE wallets
SET balance_cents = balance_cents - 1000
WHERE id = 'sender-wallet';

UPDATE wallets
SET balance_cents = balance_cents + 1000
WHERE id = 'receiver-wallet';

COMMIT;

두 UPDATE가 모두 성공하면 변경사항을 확정하고, 중간에 문제가 발생하면 ROLLBACK으로 되돌린다.

기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 쿼리 개수보다 더 많은 판단이 필요하다.

  • 어느 작업까지 같은 트랜잭션에 포함해야 하는가?
  • 잔액 차감은 성공했는데 입금 기록 저장이 실패하면 어떻게 되는가?
  • 트랜잭션 안에서 외부 결제 API나 이메일을 호출해도 되는가?
  • 이미 전송된 알림도 롤백할 수 있는가?
  • 같은 송금 요청이 다시 들어오면 중복 처리되지 않는가?
  • 트랜잭션이 오래 유지되면 다른 요청에는 어떤 영향을 주는가?
  • 데이터베이스가 여러 개라면 하나의 트랜잭션으로 묶을 수 있는가?

트랜잭션은 단순히 여러 쿼리를 묶는 기능이 아니다.

트랜잭션은 서비스가 하나의 비즈니스 작업으로 약속한 변경들이 함께 성공하거나 함께 실패하도록 일관성의 경계를 정의하는 방법이다.


먼저 쿼리가 아니라 하나의 업무가 어디까지인지 정해야 한다

크리스가 사용자끼리 잔액을 전송할 수 있는 전자지갑 서비스를 만든다고 생각해 보자.

사용자가 10달러를 송금하면 서비스는 최소한 다음 작업을 수행해야 한다.

보내는 지갑의 잔액 확인
        ↓
보내는 지갑에서 10달러 차감
        ↓
받는 지갑에 10달러 추가
        ↓
송금 기록 저장
        ↓
원장 기록 저장

각 작업은 별도의 쿼리로 구현될 수 있지만 사용자에게는 하나의 송금이다.

보내는 지갑의 잔액만 차감되고 받는 지갑에는 금액이 추가되지 않았다면 서비스는 송금을 일부만 처리한 것이다. 쿼리 몇 개가 성공했다는 사실보다 비즈니스 작업 전체가 유효한 상태로 끝났는지가 중요하다.

트랜잭션 없이 순서대로 처리하면 다음과 같은 코드가 만들어질 수 있다.

await walletRepository.decreaseBalance({
  walletId: senderWalletId,
  amountCents,
});

await walletRepository.increaseBalance({
  walletId: receiverWalletId,
  amountCents,
});

await transferRepository.create({
  senderWalletId,
  receiverWalletId,
  amountCents,
  status: "completed",
});

첫 번째 쿼리가 성공한 뒤 두 번째 또는 세 번째 쿼리가 실패하면 보내는 사용자의 돈만 줄어든 상태가 남을 수 있다.

개선된 코드는 송금의 핵심 변경을 같은 트랜잭션 안에서 처리한다.

await database.transaction(async (tx) => {
  await tx.wallets.decreaseBalance({
    walletId: senderWalletId,
    amountCents,
  });

  await tx.wallets.increaseBalance({
    walletId: receiverWalletId,
    amountCents,
  });

  await tx.transfers.create({
    senderWalletId,
    receiverWalletId,
    amountCents,
    status: "completed",
  });
});

중간 작업이 실패하면 트랜잭션 안에서 수행된 변경을 확정하지 않는다.

중요한 것은 세 쿼리를 함께 실행했다는 사실이 아니다. 세 변경이 하나의 송금이라는 비즈니스 의미를 공유한다는 사실이다.


함께 성공해야 하는 데이터만 같은 경계에 들어가야 한다

송금이 완료되면 알림도 보내야 한다고 생각해 보자.

await database.transaction(async (tx) => {
  await tx.wallets.decreaseBalance({
    walletId: senderWalletId,
    amountCents,
  });

  await tx.wallets.increaseBalance({
    walletId: receiverWalletId,
    amountCents,
  });

  await notificationService.sendTransferCompleted({
    receiverUserId,
    amountCents,
  });
});

이 코드는 데이터베이스 트랜잭션 안에서 외부 알림 서비스를 호출한다.

알림 API가 느리게 응답하면 데이터베이스 트랜잭션도 오래 열린 상태로 남는다. 알림은 전송되었지만 이후 데이터베이스 커밋이 실패할 수도 있다. 이 경우 사용자는 완료 알림을 받았지만 실제 송금은 저장되지 않는다.

데이터베이스의 ROLLBACK은 이미 외부 서비스가 전송한 푸시 알림을 취소하지 못한다.

따라서 작업의 성격을 구분해야 한다.

작업실패 시 송금을 취소해야 하는가?일반적인 처리 위치
보내는 지갑 잔액 차감예데이터베이스 트랜잭션
받는 지갑 잔액 증가예데이터베이스 트랜잭션
송금 기록 생성예데이터베이스 트랜잭션
원장 기록 생성예데이터베이스 트랜잭션
알림 발송아니오, 재시도 가능커밋 이후 또는 비동기 작업
분석 이벤트 전송아니오, 재시도 가능커밋 이후 또는 비동기 작업

알림이 실패했다고 이미 완료된 송금을 되돌리는 것은 적절하지 않을 수 있다. 대신 송금은 확정하고 알림을 다시 시도할 수 있어야 한다.

트랜잭션 경계는 “한 함수에서 실행되는 모든 코드”가 아니다. 비즈니스 결과가 유효하려면 반드시 함께 확정되어야 하는 데이터 변경의 범위다.


검증은 트랜잭션 밖에서 끝나는 것이 아니라 경계 안에서도 다시 보장되어야 한다

송금 요청은 외부에서 들어오는 입력이다. 외부 입력은 검증 전까지 신뢰할 수 없다.

import { z } from "zod";

const TransferInputSchema = z.object({
  receiverWalletId: z.string().uuid(),
  amountCents: z.number().int().positive().max(1_000_000),
  idempotencyKey: z.string().uuid(),
});

const input = TransferInputSchema.parse(request.body);

이 검증은 금액이 양수인지, 허용된 범위인지, ID 형식이 올바른지를 확인한다.

하지만 입력 형식이 올바르다고 송금이 가능한 것은 아니다.

  • 보내는 지갑이 존재하는가?
  • 받는 지갑이 존재하는가?
  • 같은 지갑으로 송금하려는 것은 아닌가?
  • 보내는 지갑이 정지 상태가 아닌가?
  • 잔액이 충분한가?
  • 현재 사용자가 보내는 지갑을 사용할 권한이 있는가?

일부 검사는 트랜잭션 전에 수행할 수 있다.

if (senderWalletId === input.receiverWalletId) {
  throw new InvalidTransferError(
    "The sender and receiver must be different."
  );
}

그러나 잔액처럼 처리 중에 바뀔 수 있는 값은 실제 변경과 같은 트랜잭션 안에서 확인해야 한다.

await database.transaction(async (tx) => {
  const senderWallet =
    await tx.wallets.findForUpdate(senderWalletId);

  if (!senderWallet) {
    throw new WalletNotFoundError();
  }

  if (senderWallet.balanceCents < input.amountCents) {
    throw new InsufficientBalanceError();
  }

  await tx.wallets.decreaseBalance({
    walletId: senderWalletId,
    amountCents: input.amountCents,
  });

  await tx.wallets.increaseBalance({
    walletId: input.receiverWalletId,
    amountCents: input.amountCents,
  });
});

findForUpdate는 개념적으로 송금이 완료될 때까지 해당 잔액을 변경 대상으로 읽는다는 의미다. 실제 구현은 사용하는 데이터베이스와 ORM에 따라 달라진다.

트랜잭션 밖에서 잔액을 확인한 뒤 나중에 차감하면 그 사이에 다른 요청이 같은 돈을 사용할 수 있다. 검증과 변경 사이에 데이터가 달라질 수 있기 때문이다.

검증이 필요한 위치는 규칙의 성격에 따라 달라진다.

  • 문자열 형식과 금액 범위는 요청 경계에서 확인한다.
  • 현재 사용자 권한은 도메인 작업을 시작하기 전에 확인한다.
  • 변할 수 있는 잔액과 상태는 실제 변경과 같은 트랜잭션에서 확인한다.
  • 음수 잔액 금지 같은 마지막 규칙은 데이터베이스 제약으로도 보호한다.

트랜잭션은 검증을 대신하지 않는다. 검증한 상태와 실제 변경 사이에 다른 작업이 끼어들어 서비스 규칙을 깨뜨리지 않도록 경계를 제공한다.


데이터베이스 제약 조건은 트랜잭션의 마지막 안전망이 된다

애플리케이션 코드에서 잔액을 확인하더라도 데이터베이스에 잘못된 값이 저장되지 않도록 제약 조건을 추가할 수 있다.

CREATE TABLE wallets (
  id UUID PRIMARY KEY,
  owner_id UUID NOT NULL,
  balance_cents BIGINT NOT NULL,
  status TEXT NOT NULL,
  CONSTRAINT wallets_non_negative_balance
    CHECK (balance_cents >= 0)
);

이 제약 조건은 어떤 코드 경로에서 지갑을 수정하더라도 음수 잔액이 커밋되지 않게 한다.

잔액 차감도 읽기와 쓰기를 분리하지 않고 조건부 변경으로 표현할 수 있다.

UPDATE wallets
SET balance_cents = balance_cents - $1
WHERE id = $2
  AND status = 'active'
  AND balance_cents >= $1;

수정된 행이 없다면 다음 중 하나일 수 있다.

  • 지갑이 존재하지 않는다.
  • 지갑이 활성 상태가 아니다.
  • 잔액이 부족하다.

애플리케이션은 결과를 확인하고 송금을 중단할 수 있다.

const updated =
  await tx.wallets.decreaseBalanceIfAvailable({
    walletId: senderWalletId,
    amountCents,
  });

if (!updated) {
  throw new TransferRejectedError();
}

예외가 발생하면 트랜잭션 전체가 롤백된다.

애플리케이션 검증은 사용자에게 구체적인 오류를 설명하고, 데이터베이스 제약 조건은 어떤 실행 경로에서도 깨져서는 안 되는 규칙을 보호한다. 둘은 서로 대체하는 것이 아니라 다른 위치에서 같은 서비스 약속을 지킨다.


원장과 현재 잔액의 책임을 분명히 해야 한다

전자지갑 서비스에는 현재 잔액과 금액 이동 기록이 함께 필요하다.

CREATE TABLE wallet_ledger_entries (
  id UUID PRIMARY KEY,
  transfer_id UUID NOT NULL,
  wallet_id UUID NOT NULL,
  entry_type TEXT NOT NULL,
  amount_cents BIGINT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL
);

하나의 송금에는 두 개의 원장 항목이 만들어질 수 있다.

await tx.ledgerEntries.createMany([
  {
    transferId,
    walletId: senderWalletId,
    entryType: "debit",
    amountCents,
  },
  {
    transferId,
    walletId: receiverWalletId,
    entryType: "credit",
    amountCents,
  },
]);

보내는 지갑에는 출금 기록이, 받는 지갑에는 입금 기록이 남는다.

문제는 wallets.balance_cents와 원장 항목을 서로 다른 작업으로 저장할 때 발생한다. 잔액만 바뀌고 원장이 남지 않거나, 원장은 생겼지만 잔액이 바뀌지 않을 수 있다.

await database.transaction(async (tx) => {
  await tx.wallets.decreaseBalance({
    walletId: senderWalletId,
    amountCents,
  });

  await tx.wallets.increaseBalance({
    walletId: receiverWalletId,
    amountCents,
  });

  await tx.transfers.create({
    id: transferId,
    senderWalletId,
    receiverWalletId,
    amountCents,
    status: "completed",
  });

  await tx.ledgerEntries.createMany([
    {
      transferId,
      walletId: senderWalletId,
      entryType: "debit",
      amountCents,
    },
    {
      transferId,
      walletId: receiverWalletId,
      entryType: "credit",
      amountCents,
    },
  ]);
});

잔액, 송금 기록, 원장 기록을 하나의 경계에서 확정하면 서로 다른 데이터가 같은 비즈니스 사실을 표현하게 된다.

이때 Source of Truth도 명확히 정해야 한다.

예를 들어 원장 항목을 금액 이동의 Source of Truth로 정하고 wallets.balance_cents를 빠른 현재 잔액 조회를 위한 값으로 사용할 수 있다. 이 경우 잔액은 원장 합계와 일치해야 하며, 정기적인 검증이나 복구 방법이 필요하다.

SELECT
  wallet_id,
  SUM(
    CASE
      WHEN entry_type = 'credit' THEN amount_cents
      WHEN entry_type = 'debit' THEN -amount_cents
    END
  ) AS calculated_balance_cents
FROM wallet_ledger_entries
WHERE wallet_id = $1
GROUP BY wallet_id;

이 조회는 원장 기록으로 계산한 잔액을 보여준다.

트랜잭션은 두 저장 위치를 함께 변경할 수 있지만 어느 데이터가 원본인지 결정해 주지는 않는다. Source of Truth와 파생 값의 책임은 서비스가 별도로 정의해야 한다.


커밋은 데이터베이스 변경이 외부에 보이는 시점을 결정한다

트랜잭션 안에서 수행된 변경은 COMMIT이 완료되기 전까지 최종 결과가 아니다.

await database.transaction(async (tx) => {
  await tx.transfers.create({
    id: transferId,
    status: "processing",
  });

  await tx.wallets.decreaseBalance({
    walletId: senderWalletId,
    amountCents,
  });

  await tx.wallets.increaseBalance({
    walletId: receiverWalletId,
    amountCents,
  });

  await tx.transfers.update(transferId, {
    status: "completed",
  });
});

함수 안에서는 여러 중간 상태가 만들어지지만 외부 요청은 일반적으로 커밋된 결과를 기준으로 데이터를 읽어야 한다.

트랜잭션 안에서 오류가 발생하면 다음 변경은 모두 확정되지 않는다.

송금 상태 processing 저장
보내는 잔액 차감
받는 잔액 증가
송금 상태 completed 변경

이 덕분에 다른 요청이 영구적으로 남은 불완전한 상태를 보는 일을 줄일 수 있다.

그러나 트랜잭션 안에서 어떤 중간 상태든 자유롭게 만들어도 된다는 뜻은 아니다. 트랜잭션이 너무 오래 유지되거나 많은 데이터를 변경하면 잠금과 리소스를 오래 점유할 수 있다.

다음 코드는 트랜잭션 안에서 불필요하게 긴 작업을 수행한다.

await database.transaction(async (tx) => {
  await tx.wallets.decreaseBalance({
    walletId: senderWalletId,
    amountCents,
  });

  const riskResult =
    await externalRiskService.analyseTransfer({
      senderWalletId,
      receiverWalletId,
      amountCents,
    });

  if (!riskResult.approved) {
    throw new TransferRejectedError();
  }

  await tx.wallets.increaseBalance({
    walletId: receiverWalletId,
    amountCents,
  });
});

외부 위험 분석 API가 10초 동안 응답하지 않으면 데이터베이스 자원도 그동안 유지될 수 있다.

가능하다면 외부 검사는 트랜잭션 전에 수행하고, 트랜잭션 안에서는 데이터베이스에서 반드시 함께 확정해야 하는 짧은 작업만 처리하는 편이 낫다.

단, 외부 검사 이후 내부 상태가 바뀔 수 있다면 트랜잭션 안에서 필요한 조건을 다시 확인해야 한다.

트랜잭션은 가능한 한 작게 유지하되 비즈니스 일관성을 보호하는 데 필요한 변경은 빠뜨리지 않아야 한다.


외부 시스템까지 데이터베이스 롤백으로 되돌릴 수는 없다

송금 이후 영수증 이메일을 보내는 코드를 생각해 보자.

await database.transaction(async (tx) => {
  await completeTransfer(tx, transferInput);

  await emailService.sendTransferReceipt({
    userId: senderUserId,
    transferId,
  });
});

이메일이 성공한 뒤 데이터베이스 커밋이 실패하면 실제 송금 기록이 없는데 영수증이 전송될 수 있다.

반대로 먼저 커밋하고 이후 이메일을 보내면 애플리케이션이 중간에 종료되어 이메일 발송이 누락될 수 있다.

await database.transaction(async (tx) => {
  await completeTransfer(tx, transferInput);
});

await emailService.sendTransferReceipt({
  userId: senderUserId,
  transferId,
});

두 작업은 서로 다른 시스템에 있으므로 하나의 일반적인 데이터베이스 트랜잭션으로 완전히 묶이지 않는다.

이 문제를 줄이기 위해 송금 데이터와 “이메일을 보내야 한다”는 이벤트를 같은 트랜잭션에 저장할 수 있다.

CREATE TABLE outbox_events (
  id UUID PRIMARY KEY,
  event_type TEXT NOT NULL,
  payload JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ NOT NULL
);

송금과 이벤트를 함께 생성한다.

await database.transaction(async (tx) => {
  await completeTransfer(tx, transferInput);

  await tx.outboxEvents.create({
    id: crypto.randomUUID(),
    eventType: "transfer.completed",
    payload: {
      transferId,
      senderUserId,
      receiverUserId,
      amountCents,
    },
  });
});

트랜잭션이 커밋되면 송금과 이벤트가 함께 존재한다. 트랜잭션이 롤백되면 둘 다 존재하지 않는다.

별도의 작업자가 아직 처리되지 않은 이벤트를 읽어 알림을 전송한다.

const events =
  await outboxRepository.findPending({ limit: 100 });

for (const event of events) {
  await notificationService.handle(event);
  await outboxRepository.markAsProcessed(event.id);
}

작업자가 중간에 실패하면 이벤트를 다시 처리할 수 있다. 이때 알림 처리도 같은 이벤트 ID를 기준으로 중복을 견딜 수 있어야 한다.

아웃박스는 외부 작업을 데이터베이스 트랜잭션 안으로 강제로 넣는 방법이 아니다. 외부 작업이 필요하다는 사실을 트랜잭션 안에 남기고 실제 실행은 별도의 복구 가능한 흐름으로 분리하는 방법이다.


롤백은 이미 일어난 모든 일을 시간을 되돌리듯 취소하지 않는다

다음 코드가 실패한다고 생각해 보자.

await database.transaction(async (tx) => {
  await tx.wallets.decreaseBalance({
    walletId: senderWalletId,
    amountCents,
  });

  console.info("Balance decreased");

  await emailService.send({
    to: senderEmail,
    subject: "Transfer started",
  });

  throw new Error("Transfer failed");
});

데이터베이스 잔액 변경은 롤백할 수 있다. 하지만 로그에 출력된 문장과 이미 전송된 이메일은 롤백되지 않는다.

트랜잭션이 직접 보호하는 것은 해당 데이터베이스가 관리하는 변경이다.

작업일반적인 데이터베이스 롤백 가능 여부
같은 데이터베이스의 INSERT가능
같은 데이터베이스의 UPDATE가능
같은 데이터베이스의 DELETE가능
이미 전송된 이메일불가능
외부 결제 API 호출불가능
파일 업로드불가능
로그 출력불가능
다른 데이터베이스의 독립된 변경자동으로는 불가능

데이터베이스 밖에서 이미 발생한 작업을 취소해야 한다면 별도의 보상 작업이 필요할 수 있다.

예를 들어 외부 시스템에서 금액을 승인한 뒤 내부 저장이 실패했다면 승인 취소 API를 호출해야 할 수 있다.

try {
  const authorization =
    await paymentProvider.authorize(paymentInput);

  await saveAuthorization(authorization);
} catch (error) {
  await paymentProvider.voidAuthorization(
    authorization.id
  );

  throw error;
}

이 방식도 보상 요청 자체가 실패할 수 있으므로 상태 저장과 재시도 정책이 필요하다.

롤백과 보상은 같은 개념이 아니다.

  • 롤백은 아직 커밋되지 않은 데이터베이스 변경을 취소한다.
  • 보상은 이미 완료된 외부 작업의 영향을 반대 작업으로 줄이려는 새로운 비즈니스 작업이다.

트랜잭션 경계를 데이터베이스 밖으로 확장할 때는 “모두 되돌릴 수 있다”는 가정보다 실패 후 어떻게 복구할지를 설계해야 한다.


같은 요청의 재시도는 새로운 송금이 되어서는 안 된다

사용자가 송금 버튼을 누른 직후 네트워크 연결이 끊겼다고 생각해 보자.

서버는 송금을 완료했지만 프론트엔드는 응답을 받지 못했다. 프론트엔드가 같은 요청을 다시 보내면 송금이 두 번 처리될 수 있다.

클라이언트는 하나의 송금 시도를 식별하는 키를 보낼 수 있다.

const idempotencyKey = crypto.randomUUID();

await fetch("/api/transfers", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey,
  },
  body: JSON.stringify({
    receiverWalletId,
    amountCents,
  }),
});

서버는 송금 생성과 멱등성 기록을 같은 트랜잭션에서 처리한다.

CREATE TABLE transfer_requests (
  sender_wallet_id UUID NOT NULL,
  idempotency_key UUID NOT NULL,
  transfer_id UUID NOT NULL,
  PRIMARY KEY (
    sender_wallet_id,
    idempotency_key
  )
);

같은 사용자의 같은 키는 하나의 송금만 가리킨다.

await database.transaction(async (tx) => {
  const existingRequest =
    await tx.transferRequests.find({
      senderWalletId,
      idempotencyKey,
    });

  if (existingRequest) {
    return tx.transfers.findById(
      existingRequest.transferId
    );
  }

  const transfer = await executeTransfer(tx, {
    senderWalletId,
    receiverWalletId,
    amountCents,
  });

  await tx.transferRequests.create({
    senderWalletId,
    idempotencyKey,
    transferId: transfer.id,
  });

  return transfer;
});

데이터베이스의 고유 제약도 동시에 들어오는 동일한 요청이 중복 기록되는 것을 막는 마지막 기준이 된다.

트랜잭션은 한 번의 실행 안에서 변경을 묶는다. 멱등성은 같은 의도가 여러 번 전달되었을 때 하나의 비즈니스 작업으로 처리되도록 한다. 실제 서비스에서는 두 개념이 함께 필요한 경우가 많다.


실패는 트랜잭션 경계를 기준으로 분류해야 한다

송금 과정의 실패는 모두 같은 의미를 가지지 않는다.

type TransferFailure =
  | "invalid_input"
  | "wallet_not_found"
  | "forbidden"
  | "insufficient_balance"
  | "database_conflict"
  | "database_unavailable"
  | "notification_failed";

실패 종류에 따라 처리 방식도 달라진다.

실패송금 데이터일반적인 대응
잘못된 입력변경 없음사용자에게 수정 요청
권한 없음변경 없음요청 거부
잔액 부족변경 없음비즈니스 오류 반환
데이터베이스 충돌롤백조건에 따라 짧게 재시도
데이터베이스 장애결과 확인 필요멱등성 키로 안전하게 재조회·재시도
알림 실패송금은 이미 완료될 수 있음알림만 재시도

특히 클라이언트가 응답을 받지 못했다는 사실은 서버에서 롤백되었다는 뜻이 아니다. 커밋은 성공했지만 응답 전송만 실패했을 수도 있다.

따라서 실패 후 무조건 송금을 다시 실행해서는 안 된다. 같은 멱등성 키로 기존 결과를 확인해야 한다.

트랜잭션 경계를 명확히 하면 무엇이 확정되었고 무엇을 다시 시도할 수 있는지 판단하기 쉬워진다.


트랜잭션은 짧고 명확한 서비스 흐름으로 유지해야 한다

트랜잭션 안에 너무 많은 작업을 넣으면 코드상으로는 안전해 보일 수 있다.

await database.transaction(async (tx) => {
  await validateTransfer(tx);
  await updateWallets(tx);
  await createLedgerEntries(tx);
  await generateMonthlyReport(tx);
  await recalculateRewardLevel(tx);
  await callExternalFraudApi();
  await sendEmails();
});

하지만 이 트랜잭션은 다음 문제를 만들 수 있다.

  • 데이터베이스 연결을 오래 점유한다.
  • 변경 중인 데이터를 다른 요청이 오래 기다릴 수 있다.
  • 외부 API 지연이 데이터베이스 작업까지 붙잡는다.
  • 실패 원인과 재시도 범위를 구분하기 어렵다.
  • 중요하지 않은 부가 작업 때문에 핵심 송금까지 실패한다.

송금의 핵심 일관성에 필요한 작업만 같은 경계에 두는 편이 낫다.

const transfer =
  await database.transaction(async (tx) => {
    return executeTransfer(tx, transferInput);
  });

await scheduleTransferSideEffects({
  transferId: transfer.id,
});

여기서 executeTransfer는 잔액, 송금 기록, 원장, 아웃박스 이벤트처럼 반드시 함께 확정되어야 하는 데이터를 처리한다.

보고서 생성, 이메일 발송, 분석 이벤트 같은 후속 작업은 커밋된 송금 ID를 기준으로 별도로 처리한다.

좋은 트랜잭션은 가능한 한 짧지만 필요한 비즈니스 규칙을 빠뜨리지 않는다. 짧게 만드는 목적은 쿼리 수를 무조건 줄이는 것이 아니라 공유 데이터와 자원을 점유하는 시간을 줄이는 데 있다.


송금의 트랜잭션 경계는 데이터 흐름 전체에서 한 부분을 차지한다

전체 송금 흐름을 정리하면 다음과 같다.

flowchart LR
    A[송금 입력] --> B[형식·권한 검증]
    B --> C[트랜잭션 시작]
    C --> D[잔액과 상태 확인]
    D --> E[보내는 잔액 차감]
    E --> F[받는 잔액 증가]
    F --> G[송금·원장·이벤트 저장]
    G --> H[커밋]
    H --> I[알림·분석 작업]
    I --> J[실패 시 독립 재시도]

이 흐름에는 서로 다른 책임이 존재한다.

  • 요청 검증은 외부 입력의 형태와 권한을 확인한다.
  • 트랜잭션은 반드시 함께 변경되어야 하는 내부 데이터를 보호한다.
  • 커밋은 데이터베이스 변경이 확정된 시점을 만든다.
  • 아웃박스 이벤트는 후속 작업이 필요하다는 사실을 보존한다.
  • 작업자는 알림과 분석 같은 외부 부가 작업을 재시도한다.
  • 멱등성 키는 동일한 송금 의도가 중복 실행되는 것을 막는다.

하나의 거대한 트랜잭션으로 전체 흐름을 감싸는 것이 아니라, 각 책임에 맞는 실패와 복구 방식을 선택해야 한다.


트랜잭션을 설계하기 전에 물어봐야 할 질문

비즈니스 작업의 경계는 어디까지인가

  1. 사용자가 하나의 작업으로 인식하는 변경은 무엇인가?
  2. 어떤 데이터가 일부만 변경되면 서비스 규칙이 깨지는가?
  3. 반드시 함께 성공해야 하는 INSERT, UPDATE, DELETE는 무엇인가?
  4. 실패해도 나중에 다시 처리할 수 있는 부가 작업은 무엇인가?
  5. 하나의 함수 범위와 트랜잭션 범위를 혼동하고 있지 않은가?

트랜잭션 안에서 무엇을 검증해야 하는가

  1. 처리 중 변경될 수 있는 잔액이나 상태가 있는가?
  2. 검증과 변경이 같은 데이터 상태를 기준으로 실행되는가?
  3. 권한과 소유권을 확인했는가?
  4. 데이터베이스 제약 조건으로 마지막 규칙을 보호하는가?
  5. 조건부 업데이트 결과를 확인하는가?

Source of Truth가 명확한가

  1. 송금 사실의 원본은 어느 테이블인가?
  2. 원장과 현재 잔액 중 어떤 데이터가 기준인가?
  3. 파생된 잔액이 원장과 일치하도록 같은 트랜잭션에서 갱신되는가?
  4. 불일치를 발견하고 복구할 방법이 있는가?
  5. 완료된 원장 기록을 수정할 수 없도록 보호하는가?

외부 작업을 분리했는가

  1. 트랜잭션 안에서 네트워크 요청을 실행하고 있지 않은가?
  2. 이메일이나 알림 실패가 핵심 작업을 취소해야 하는가?
  3. 커밋 이후 애플리케이션이 종료되어도 후속 작업을 복구할 수 있는가?
  4. 아웃박스나 작업 큐에 필요한 이벤트를 남기는가?
  5. 외부 작업이 중복 실행되어도 안전한가?

실패와 재시도를 구분했는가

  1. 실패가 커밋 전인지 커밋 후인지 알 수 있는가?
  2. 응답 실패를 송금 실패로 단정하고 있지 않은가?
  3. 같은 요청에 멱등성 키가 있는가?
  4. 재시도 가능한 오류와 사용자 수정이 필요한 오류를 구분하는가?
  5. 보상 작업 자체가 실패했을 때 다시 시도할 수 있는가?

트랜잭션이 불필요하게 길지 않은가

  1. 외부 API 호출이나 파일 작업이 포함되어 있는가?
  2. 큰 목록을 반복 처리하고 있지 않은가?
  3. 사용자 입력을 기다리는 코드가 포함되어 있지 않은가?
  4. 핵심 일관성과 관계없는 계산을 분리할 수 있는가?
  5. 트랜잭션이 점유하는 데이터와 시간이 명확한가?

시스템 경계를 넘는가

  1. 변경 대상이 같은 데이터베이스에 있는가?
  2. 다른 데이터베이스나 외부 서비스도 함께 변경되는가?
  3. 모든 작업을 실제로 롤백할 수 있는가?
  4. 롤백할 수 없다면 상태 기록과 보상 흐름이 있는가?
  5. 최종적으로 일관된 상태에 도달하는 과정이 정의되어 있는가?

흔한 실수는 트랜잭션을 코드 블록의 안전장치로 보는 것이다

관련 없는 쿼리까지 하나의 트랜잭션에 넣는다

같은 함수에서 실행된다는 이유로 보고서 생성과 송금 처리를 함께 묶으면 트랜잭션이 불필요하게 길어진다.

반드시 함께 확정되어야 하는 비즈니스 변경만 포함해야 한다.

잔액을 트랜잭션 밖에서 확인한다

확인 후 차감하기 전까지 다른 요청이 잔액을 변경할 수 있다.

변할 수 있는 값의 검증과 수정은 같은 트랜잭션 경계에서 처리해야 한다.

트랜잭션 안에서 외부 API를 호출한다

외부 요청은 느리거나 실패할 수 있으며 데이터베이스 롤백으로 취소할 수도 없다.

외부 작업은 가능한 한 분리하고 이벤트와 재시도 흐름을 사용해야 한다.

예외를 잡고도 트랜잭션을 성공 처리한다

await database.transaction(async (tx) => {
  try {
    await executeTransfer(tx);
  } catch (error) {
    logger.error(error);
  }
});

오류를 기록한 뒤 다시 던지지 않으면 트랜잭션 함수는 정상적으로 끝났다고 판단되어 일부 변경이 커밋될 수 있다.

실패가 전체 작업을 취소해야 한다면 트랜잭션 실행부가 실패를 인식할 수 있도록 예외를 전달해야 한다.

롤백이 이메일과 결제 요청까지 취소한다고 생각한다

롤백은 해당 데이터베이스에서 커밋되지 않은 변경을 취소한다.

이미 실행된 외부 작업에는 별도의 보상과 재시도 설계가 필요하다.

재시도를 새로운 요청처럼 처리한다

네트워크 오류 후 같은 송금을 다시 생성하면 중복 출금이 발생할 수 있다.

멱등성 키와 데이터베이스 고유 제약으로 동일한 의도를 식별해야 한다.

트랜잭션을 사용하면 동시성 문제가 모두 해결된다고 생각한다

트랜잭션은 원자적인 경계를 제공하지만 동시에 실행되는 요청이 어떤 데이터를 읽고 변경하는지는 별도의 판단이 필요하다.

격리 수준, 조건부 업데이트, 잠금, 충돌 처리 정책을 서비스 규칙에 맞게 선택해야 한다.


트랜잭션의 핵심은 비즈니스 작업의 일관성 경계를 정하는 데 있다

프로그래밍을 처음 배울 때는 트랜잭션을 여러 쿼리를 하나로 묶어 커밋하거나 롤백하는 기능이라고 이해해도 충분하다.

BEGIN;
-- 여러 데이터 변경
COMMIT;

하지만 실제 서비스에서는 쿼리의 개수보다 어떤 변경이 하나의 비즈니스 작업을 구성하는지가 중요하다.

  • 어떤 데이터가 반드시 함께 성공해야 하는가?
  • 일부만 저장되면 어떤 서비스 약속이 깨지는가?
  • 변할 수 있는 상태를 같은 경계 안에서 검증하는가?
  • 데이터베이스 제약 조건이 마지막 규칙을 보호하는가?
  • Source of Truth와 파생 값을 구분했는가?
  • 외부 API와 알림을 트랜잭션에서 분리했는가?
  • 커밋 이후의 작업을 복구할 이벤트가 남는가?
  • 같은 요청이 재시도되어도 중복 처리되지 않는가?
  • 실패가 커밋 전인지 후인지에 따라 대응이 달라지는가?
  • 트랜잭션이 필요한 범위보다 오래 유지되지 않는가?

트랜잭션은 여러 쿼리를 묶는 기능이 아니다.

트랜잭션은 서비스가 하나의 비즈니스 작업으로 약속한 변경들이 함께 성공하거나 함께 실패하도록 일관성의 경계를 정의하는 방법이다.

profile
Vision eXperience Developer

0개의 댓글