Loading the catalog…
Loading the catalog…
프로그래밍을 처음 배울 때 데이터베이스 트랜잭션은 보통 여러 쿼리를 하나로 묶어 모두 성공시키거나 모두 취소하는 기능이라고 배운다. 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를 기준으로 중복을 견딜 수 있어야 한다. 아웃박스는 외부 작업을 데이터베이스 트랜잭션 안으로 강제로 넣는 방법이 아니다. 외부 작업이 필요하다는 사실을 트랜잭션 안에 남기고 실제 실행은 별도의 복구 가능
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
트랜잭션은 여러 쿼리를 묶는 기능이 아니다. 프로그래밍을 처음 배울 때 데이터베이스 트랜잭션은 보통 여러 쿼리를 하나로 묶어 모두 성공시키거나 모두 취소하는 기능이라고 배운다. 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 으로 되돌린다. 기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 쿼리 개수보다 더 많은…
Open source