트랜잭션 관리와 데이터 정합성
ACID와 커밋·롤백의 경계를 이해하고 TypeORM과 Prisma로 여러 데이터 작업의 원자성과 정합성을 지킵니다.
이제 데이터베이스 통합의 마지막이자 가장 중요한 주제 중 하나인 트랜잭션(Transaction) 관리와 데이터 정합성(Data Consistency)에 대해 이야기할 차례입니다.
복잡한 애플리케이션에서는 단일 작업처럼 보이지만, 실제로는 여러 데이터베이스 작업(예: 여러 테이블 삽입, 업데이트, 삭제)이 연속으로 수행되는 경우가 많습니다.
예를 들어 온라인 쇼핑몰의 데이터베이스 처리에서는 재고 감소, 주문 생성, 결제 요청 기록이 모두 성공해야 하고, 하나라도 실패하면 같은 트랜잭션의 변경을 취소해야 합니다.
이때 트랜잭션이 중요한 역할을 합니다.
다만 결제 게이트웨이 호출처럼 데이터베이스 밖에서 발생한 효과는 DB 롤백으로 취소되지 않습니다. 따라서 주문 데이터의 원자성과 외부 결제의 신뢰성은 트랜잭션 경계, outbox, 멱등성, 필요 시 보상 작업을 함께 설계해야 합니다.
트랜잭션(Transaction)이란 무엇인가?
트랜잭션은 데이터베이스에서 수행되는 하나 이상의 연산(읽기, 쓰기, 수정, 삭제)을 논리적으로 하나의 단위로 묶는 작업입니다.
이 단위 안의 모든 연산은 모두 성공적으로 완료되거나(Commit), 아니면 모두 실패해 원래 상태로 되돌아가야 합니다(Rollback).
즉 전부 아니면 전무(All or Nothing) 원칙을 따릅니다.
주문 처리처럼 재고, 주문, 결제 요청 기록이 연결된 작업에서는 실패 지점에 따라 DB 변경을 되돌릴지, 최종 결과를 확정할지 명확히 나누어야 합니다.
Nest · Transaction Boundary
DB rollback은 외부 결제를 되돌리지 못한다
재고·주문·outbox는 짧은 DB 트랜잭션에 묶고, 결제 네트워크 호출은 commit 뒤 worker가 멱등 키로 수행한 다음 결과를 별도 트랜잭션에 기록합니다.
TX A · 주문을 접수한다
조건부 재고 감소 — 충분한 재고 조건과 원자적
decrement를 한 DB 쓰기로 실행합니다.같은 commit —
PENDING주문과 결제 요청 outbox 이벤트를 재고 변경과 같은 트랜잭션에 기록합니다. 하나라도 실패하면 셋 모두 rollback됩니다.확정된 상태만 응답 — commit 뒤 주문 접수 상태를 반환합니다. 아직 외부 결제가 완료됐다고 응답하지 않습니다.
네트워크 · TX B · 결과를 확정한다
commit 뒤 호출 — worker가 outbox를 가져온 뒤 DB 트랜잭션 밖에서 결제 API를 호출합니다. 긴 네트워크 대기 동안 DB 잠금을 유지하지 않습니다.
멱등 요청 — 주문 기반 idempotency key를 사용해 worker 재시도가 중복 결제를 만들지 않도록 합니다.
결과 저장 — 짧은 두 번째 트랜잭션에서 결제 결과와 주문 상태를 함께 갱신합니다. 외부 성공 뒤 로컬 저장이 실패하면 같은 이벤트를 재시도하고, 되돌려야 하는 효과에는 승인 취소·환불 같은 보상 작업이 필요합니다.
DB rollback은 해당 DB 트랜잭션의 변경만 취소합니다. 이미 완료된 결제 승인·청구·이메일 같은 외부 효과는 자동으로 되돌아가지 않습니다.
트랜잭션은 데이터베이스의 ACID 속성을 보장하는 핵심 메커니즘입니다.
- 원자성(Atomicity): 트랜잭션의 DB 변경은 모두 commit되거나 모두 rollback됩니다. 외부 API에서 이미 발생한 효과까지 되돌리는 성질은 아닙니다.
- 일관성(Consistency): commit 전후에 데이터베이스가 선언한 제약 조건을 만족합니다. 애플리케이션 규칙은 제약 조건과 올바른 트랜잭션 로직으로 직접 지켜야 합니다.
- 고립성(Isolation): 동시 트랜잭션에서 허용하거나 막는 이상 현상은 데이터베이스와 고립성 수준에 따라 달라집니다. 낮은 수준이 모든 동시 실행을 직렬 실행처럼 보이게 하지는 않습니다.
- 지속성(Durability): commit이 성공하면 결과는 데이터베이스가 제공하는 내구성 보장에 따라 장애 후에도 유지됩니다.
이러한 ACID 속성은 특히 금융 거래, 재고 관리 등 데이터의 정확성과 신뢰성이 매우 중요한 시스템에서 필수적입니다.
NestJS에서 트랜잭션 관리하기
Nest · Prisma ORM 7
API 선택과 동시성 제어는 다른 결정이다
쿼리 사이 애플리케이션 로직이 필요한지 먼저 고르고, 재고처럼 경쟁하는 값에는 별도의 원자 갱신·격리·재시도 정책을 적용합니다.
트랜잭션 API 선택
독립 쿼리 —
$transaction([q1, q2])는 배열의PrismaPromise를 입력 순서대로 실행하고 전체를 함께 commit 또는 rollback합니다. 앞 쿼리에서 생성된 ID를 다음 쿼리에 전달할 수는 없습니다.의존 로직 — 앞 쿼리 결과를 검사하거나 그 결과로 다음 쿼리를 만들면
$transaction(async tx => …)를 사용합니다. 모든 DB 접근은 전달받은tx를 통하고 네트워크 호출은 밖으로 뺍니다.더 좁은 API — 관계 생성은 nested write, 같은 형태의 다건 변경은
createMany·updateMany같은 bulk operation으로 표현할 수 있는지 먼저 확인합니다.
동시성 안전장치
원자적 재고 차감 — 읽은
stock을 계산해 다시 쓰면 동시 요청이 서로의 변경을 덮을 수 있습니다.stock >= qty조건과decrement를 한 쓰기로 실행하고 변경 건수를 확인합니다.격리 수준 — 여러 행·제약이 충돌할 수 있는 작업은 데이터베이스가 지원하는
Serializable을 명시해 직렬화 충돌을 성공으로 숨기지 않습니다.제한적 재시도 — Prisma가 write conflict 또는 deadlock을
P2034로 반환할 때만 횟수를 제한해 재시도합니다. 품절·검증 오류와 다른 오류는 즉시 다시 던집니다.
트랜잭션은 병렬 실행 도구가 아닙니다. 같은 연결은 한 번에 한 쿼리를 처리하므로 interactive transaction 안의 Promise.all도 쿼리를 직렬로 실행합니다.
NestJS에서 트랜잭션을 관리하는 방법은 사용하는 ORM 또는 ODM에 따라 달라집니다.
여기서는 TypeORM과 Prisma를 중심으로 살펴보겠습니다.
TypeORM에서 트랜잭션 관리하기
TypeORM은 다양한 방법으로 트랜잭션을 지원합니다.
가장 일반적인 두 가지 방법을 소개합니다.
방법 1:DataSource.transaction() 콜백
이 방법은 비동기 함수를 콜백으로 전달하여 트랜잭션 스코프 내에서 모든 데이터베이스 작업을 수행합니다.
콜백에 전달된 manager로 실행한 작업만 해당 트랜잭션에 포함됩니다. 전역 manager나 주입받은 일반 repository를 섞어 쓰면 안 됩니다.
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Product } from '../product/entities/product.entity';
import { Order } from './entities/order.entity';
import { OrderItem } from './entities/order-item.entity';
@Injectable()
export class OrderService {
constructor(private readonly dataSource: DataSource) {}
async createOrder(userId: number, productIds: number[]): Promise<Order> {
// 트랜잭션 시작
return this.dataSource.transaction(async (manager) => {
// manager를 통해 트랜잭션 스코프 내의 Repository를 사용합니다.
const productRepo = manager.getRepository(Product);
const orderRepo = manager.getRepository(Order);
const orderItemRepo = manager.getRepository(OrderItem);
// 1. 주문 생성
const order = orderRepo.create({ userId, orderDate: new Date() });
await orderRepo.save(order);
// 2. 각 상품의 재고를 조건부 원자 연산으로 감소
for (const productId of productIds) {
const changed = await productRepo
.createQueryBuilder()
.update()
.set({ stock: () => 'stock - 1' })
.where('id = :productId', { productId })
.andWhere('stock > 0')
.execute();
if (changed.affected !== 1) {
throw new Error(`Product with ID ${productId} is out of stock or not found.`);
}
const product = await productRepo.findOneByOrFail({ id: productId });
const orderItem = orderItemRepo.create({
order,
productId: product.id,
quantity: 1,
price: product.price,
});
await orderItemRepo.save(orderItem); // 주문 아이템 저장
}
return order; // 모든 작업 성공 시 자동 커밋
}); // 트랜잭션 블록 끝
}
}위 예시에서 this.dataSource.transaction(async (manager) => { ... });에 전달된 manager의 작업은 하나의 트랜잭션으로 묶입니다.
재고는 stock을 읽고 JavaScript에서 1을 뺀 값을 저장하지 않습니다. stock > 0 조건과 stock - 1을 한 SQL 갱신으로 실행하므로, 동시에 들어온 주문이 같은 과거 값을 읽고 서로의 차감을 덮는 lost update를 피할 수 있습니다.
중간에 에러가 발생하면 콜백이 예외를 던지고, TypeORM은 해당 트랜잭션의 변경을 자동으로 롤백합니다.
모든 작업이 성공적으로 완료되면 트랜잭션은 자동으로 커밋됩니다.
방법 2: 수동 트랜잭션 제어 (queryRunner)
Nest 공식 문서는 트랜잭션 수명 주기를 완전히 제어해야 할 때 QueryRunner 사용을 권장합니다. 직접 시작(startTransaction()), 커밋(commitTransaction()), 롤백(rollbackTransaction()), 연결 해제(release())를 책임져야 합니다.
async complexTransaction(): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// queryRunner.manager를 통해 작업 수행
await queryRunner.manager.save(SomeEntity, someData);
await queryRunner.manager.update(AnotherEntity, { id: 1 }, { status: 'completed' });
await queryRunner.commitTransaction(); // 성공 시 커밋
} catch (err) {
await queryRunner.rollbackTransaction(); // 실패 시 롤백
throw err; // 실패를 성공으로 숨기지 않음
} finally {
await queryRunner.release(); // queryRunner 해제
}
}Prisma에서 트랜잭션 관리하기
Prisma ORM 7은 nested write와 bulk operation 외에도 두 가지 $transaction 형식을 제공합니다.
앞 쿼리의 결과나 쿼리 사이 애플리케이션 로직이 필요한지가 선택 기준입니다.
$transaction with a function)
Prisma의 상호작용적 트랜잭션은 TypeORM의 dataSource.transaction()과 유사하게 콜백 함수 기반으로 동작합니다.
이 방식은 이전 쿼리의 결과를 검사하거나 그 결과로 다음 쿼리를 만들 때 사용합니다. 콜백 안의 DB 접근은 모두 전달받은 tx로 실행하고, 트랜잭션은 짧게 유지해야 합니다.
import { Injectable } from '@nestjs/common';
import { Order, Prisma } from '../generated/prisma/client';
import { PrismaService } from '../prisma/prisma.service';
function isP2034(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'P2034'
);
}
@Injectable()
export class OrderService {
constructor(private readonly prisma: PrismaService) {}
private async withSerializableRetry<T>(
work: (tx: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await this.prisma.$transaction(work, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
});
} catch (error) {
if (!isP2034(error) || attempt === maxAttempts) throw error;
await new Promise((resolve) => setTimeout(resolve, attempt * 50));
}
}
throw new Error('unreachable');
}
async createOrder(userId: number, productIds: number[]): Promise<Order> {
return this.withSerializableRetry(async (tx) => {
const order = await tx.order.create({
data: { userId, orderDate: new Date() },
});
for (const productId of productIds) {
const changed = await tx.product.updateMany({
where: { id: productId, stock: { gte: 1 } },
data: { stock: { decrement: 1 } },
});
if (changed.count !== 1) {
throw new Error(`Product with ID ${productId} is out of stock or not found.`);
}
const product = await tx.product.findUniqueOrThrow({
where: { id: productId },
select: { id: true, price: true },
});
await tx.orderItem.create({
data: {
orderId: order.id,
productId: product.id,
quantity: 1,
price: product.price,
},
});
}
return order;
});
}
}updateMany()의 조건과 decrement는 하나의 원자적 쓰기로 실행됩니다. 변경 건수가 1이 아니면 품절로 처리해 트랜잭션 전체를 rollback합니다.
Serializable에서 write conflict나 deadlock이 발생하면 Prisma는 P2034를 반환할 수 있습니다. 예시는 이 코드에만 횟수를 제한한 재시도를 적용하고, 품절과 다른 오류는 다시 던집니다.
$transaction with an array)
배열형 $transaction([])은 독립적인 PrismaPromise 여러 개를 하나의 트랜잭션으로 묶고 배열에 넣은 순서대로 실행합니다. 동시에 실행하는 API가 아닙니다.
모든 쿼리가 성공하면 commit하고 하나라도 실패하면 rollback합니다. 앞 쿼리에서 데이터베이스가 생성한 ID를 다음 쿼리에 전달해야 한다면 nested write, 미리 만든 ID, 또는 interactive transaction을 사용합니다.
async updateMultipleUsersStatus(ids: number[], isActive: boolean) {
const queries = ids.map((id) =>
this.prisma.user.update({
where: { id },
data: { isActive },
}),
);
return this.prisma.$transaction(queries);
}한 트랜잭션은 같은 연결을 사용하며 그 연결은 한 번에 한 쿼리를 처리합니다. 따라서 interactive transaction 안에서 Promise.all()로 쿼리를 감싸도 DB 쿼리가 병렬로 실행되지는 않습니다.
트랜잭션 사용 시 주의사항 및 모범 사례
트랜잭션은 데이터 정합성을 보장하는 도구이지만, 잘못 사용하면 성능 저하나 데드락(Deadlock)과 같은 문제를 야기할 수 있습니다.
- 트랜잭션 범위 최소화: 필요한 DB 읽기와 쓰기만 묶습니다. 긴 계산과 대기는 잠금과 연결 점유 시간을 늘리고 충돌·데드락 가능성을 높입니다.
- 외부 효과 분리: 결제 게이트웨이와 이메일 같은 네트워크 호출은 DB 트랜잭션 안에서 기다리지 않습니다. 주문 변경과 outbox 이벤트를 같은 commit에 기록하고, worker가 commit 뒤 멱등 키로 외부 작업을 실행하게 할 수 있습니다.
- lost update 방지:
find로 읽은 재고에서 값을 뺀 뒤update하는 두 단계는 동시 요청이 서로의 변경을 덮을 수 있습니다. 조건부updateMany()와decrement처럼 검증과 산술 갱신을 한 DB 문장으로 실행합니다. - 고립성 수준과 재시도: 지원 수준과 기본값은 데이터베이스마다 다릅니다. 충돌 가능성이 높은 작업에
Serializable을 선택했다면P2034같은 직렬화 충돌을 제한된 횟수와 backoff로 재시도하고, 비즈니스 실패와 다른 오류는 재시도하지 않습니다. - 에러 경계: 콜백은 실패를 다시 던져 ORM이 rollback하도록 합니다. 수동
QueryRunner는 catch에서 rollback한 뒤 오류를 다시 던지고 finally에서 연결을 release합니다. - 관찰 가능성: 트랜잭션 소요 시간, commit·rollback, 재시도 횟수와 최종 오류 코드를 기록하되 민감한 결제 정보는 로그에 남기지 않습니다.
트랜잭션 관리와 데이터 정합성은 안정적인 백엔드 애플리케이션의 핵심입니다.
NestJS에서는 TypeORM과 Prisma 같은 ORM 라이브러리를 통해 트랜잭션 경계와 데이터 접근 패턴을 구조화할 수 있습니다.
이것으로 나 혼자 Nest.js의 3장 데이터베이스 통합을 모두 마칩니다.
이제 NestJS 애플리케이션에서 트랜잭션 API, 동시성 제어, 외부 효과의 경계를 함께 설계할 수 있습니다.