프로그래밍을 처음 배울 때 데이터베이스 트랜잭션은 보통 여러 쿼리를 하나로 묶어 모두 성공시키거나 모두 취소하는 기능이라고 배운다.
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으로 되돌린다.
기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 쿼리 개수보다 더 많은 판단이 필요하다.
트랜잭션은 단순히 여러 쿼리를 묶는 기능이 아니다.
트랜잭션은 서비스가 하나의 비즈니스 작업으로 약속한 변경들이 함께 성공하거나 함께 실패하도록 일관성의 경계를 정의하는 방법이다.
크리스가 사용자끼리 잔액을 전송할 수 있는 전자지갑 서비스를 만든다고 생각해 보자.
사용자가 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();
});
하지만 이 트랜잭션은 다음 문제를 만들 수 있다.
송금의 핵심 일관성에 필요한 작업만 같은 경계에 두는 편이 낫다.
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[실패 시 독립 재시도]
이 흐름에는 서로 다른 책임이 존재한다.
하나의 거대한 트랜잭션으로 전체 흐름을 감싸는 것이 아니라, 각 책임에 맞는 실패와 복구 방식을 선택해야 한다.
INSERT, UPDATE, DELETE는 무엇인가?같은 함수에서 실행된다는 이유로 보고서 생성과 송금 처리를 함께 묶으면 트랜잭션이 불필요하게 길어진다.
반드시 함께 확정되어야 하는 비즈니스 변경만 포함해야 한다.
확인 후 차감하기 전까지 다른 요청이 잔액을 변경할 수 있다.
변할 수 있는 값의 검증과 수정은 같은 트랜잭션 경계에서 처리해야 한다.
외부 요청은 느리거나 실패할 수 있으며 데이터베이스 롤백으로 취소할 수도 없다.
외부 작업은 가능한 한 분리하고 이벤트와 재시도 흐름을 사용해야 한다.
await database.transaction(async (tx) => {
try {
await executeTransfer(tx);
} catch (error) {
logger.error(error);
}
});
오류를 기록한 뒤 다시 던지지 않으면 트랜잭션 함수는 정상적으로 끝났다고 판단되어 일부 변경이 커밋될 수 있다.
실패가 전체 작업을 취소해야 한다면 트랜잭션 실행부가 실패를 인식할 수 있도록 예외를 전달해야 한다.
롤백은 해당 데이터베이스에서 커밋되지 않은 변경을 취소한다.
이미 실행된 외부 작업에는 별도의 보상과 재시도 설계가 필요하다.
네트워크 오류 후 같은 송금을 다시 생성하면 중복 출금이 발생할 수 있다.
멱등성 키와 데이터베이스 고유 제약으로 동일한 의도를 식별해야 한다.
트랜잭션은 원자적인 경계를 제공하지만 동시에 실행되는 요청이 어떤 데이터를 읽고 변경하는지는 별도의 판단이 필요하다.
격리 수준, 조건부 업데이트, 잠금, 충돌 처리 정책을 서비스 규칙에 맞게 선택해야 한다.
프로그래밍을 처음 배울 때는 트랜잭션을 여러 쿼리를 하나로 묶어 커밋하거나 롤백하는 기능이라고 이해해도 충분하다.
BEGIN;
-- 여러 데이터 변경
COMMIT;
하지만 실제 서비스에서는 쿼리의 개수보다 어떤 변경이 하나의 비즈니스 작업을 구성하는지가 중요하다.
트랜잭션은 여러 쿼리를 묶는 기능이 아니다.
트랜잭션은 서비스가 하나의 비즈니스 작업으로 약속한 변경들이 함께 성공하거나 함께 실패하도록 일관성의 경계를 정의하는 방법이다.