트랜잭션 관리와 데이터 정합성
ACID와 커밋·롤백의 경계를 이해하고 TypeORM과 Prisma로 여러 데이터 작업의 원자성과 정합성을 지킵니다.
이제 데이터베이스 통합의 마지막이자 가장 중요한 주제 중 하나인 트랜잭션(Transaction) 관리와 데이터 정합성(Data Consistency)에 대해 이야기할 차례입니다.
복잡한 애플리케이션에서는 단일 작업처럼 보이지만, 실제로는 여러 데이터베이스 작업(예: 여러 테이블 삽입, 업데이트, 삭제)이 연속으로 수행되는 경우가 많습니다.
예를 들어 온라인 쇼핑몰의 데이터베이스 처리에서는 재고 감소, 주문 생성, 결제 요청 기록이 모두 성공해야 하고, 하나라도 실패하면 같은 트랜잭션의 변경을 취소해야 합니다.
이때 트랜잭션이 중요한 역할을 합니다.
다만 결제 게이트웨이 호출처럼 데이터베이스 밖에서 발생한 효과는 DB 롤백으로 취소되지 않습니다. 따라서 주문 데이터의 원자성과 외부 결제의 신뢰성은 트랜잭션 경계, outbox, 멱등성, 필요 시 보상 작업을 함께 설계해야 합니다.
트랜잭션(Transaction)이란 무엇인가?
트랜잭션은 데이터베이스에서 수행되는 하나 이상의 연산(읽기, 쓰기, 수정, 삭제)을 논리적으로 하나의 단위로 묶는 작업입니다.
이 단위 안의 모든 연산은 모두 성공적으로 완료되거나(Commit), 아니면 모두 실패해 원래 상태로 되돌아가야 합니다(Rollback).
즉 전부 아니면 전무(All or Nothing) 원칙을 따릅니다.
주문 처리처럼 재고, 주문, 결제 요청 기록이 연결된 작업에서는 실패 지점에 따라 DB 변경을 되돌릴지, 최종 결과를 확정할지 명확히 나누어야 합니다.
DB 트랜잭션과 외부 결제 경계
본문의 경계 설계를 outbox로 확장할 때 선택한 성공 흐름입니다. 완성된 worker 실행 구현은 없으며 아래 DB·외부 결제·결과 저장은 확장 설계의 계약입니다.
Nest Outbox · DB와 외부 효과
DB 변경을 먼저 commit하고, 외부 결제는 worker가 별도 처리합니다
본문의 경계 설계를 구체화한 outbox 확장 예시입니다. 재고·주문·outbox를 같은 DB commit에 기록하고, worker가 트랜잭션 밖에서 결제한 뒤 별도 TX에 결과를 기록합니다. 본문에 완성된 worker 구현이 있는 것은 아닙니다.
TX A · 주문을 접수한다
조건부 재고 감소 — 충분한 재고 조건과 원자적
decrement를 한 DB 쓰기로 실행합니다.같은 commit —
PENDING주문과 결제 요청 outbox 이벤트를 재고 변경과 같은 트랜잭션에 기록합니다. 하나라도 실패하면 셋 모두 rollback됩니다.확정된 상태만 응답 — commit 뒤 주문 접수 상태를 반환합니다. 아직 외부 결제가 완료됐다고 응답하지 않습니다.
네트워크 · TX B · 결과를 확정한다
commit 뒤 호출 — worker가 outbox를 가져온 뒤 DB 트랜잭션 밖에서 결제 API를 호출합니다. 긴 네트워크 대기 동안 DB 잠금을 유지하지 않습니다.
멱등 요청 — 주문 기반 idempotency key를 사용해 worker 재시도가 중복 결제를 만들지 않도록 합니다.
결과 저장 — 짧은 두 번째 트랜잭션에서 결제 결과와 주문 상태를 함께 갱신합니다. 외부 성공 뒤 로컬 저장이 실패하면 같은 이벤트를 재시도하고, 되돌려야 하는 효과에는 승인 취소·환불 같은 보상 작업이 필요합니다.
DB rollback은 해당 DB 변경만 취소합니다. 이미 완료된 외부 결제는 별도 보상 작업이 필요합니다. 같은 멱등 키의 재시도가 중복 청구를 만들지 않으려면 결제 제공자도 해당 키를 지원해야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 클라이언트 주문을 받은 OrderService가 첫 DB 트랜잭션에서 조건부 재고 감소, 대기 주문, outbox 이벤트를 함께 commit한다. Worker는 outbox를 읽어 트랜잭션 밖에서 멱등 결제 요청을 보내고, 두 번째 DB 트랜잭션에서 결제 결과와 주문 상태를 기록한다.
- TX A는 재고·대기 주문·outbox를 함께 commit한 뒤 접수 응답한다. Worker는 outbox를 가져와 DB 트랜잭션 밖에서 결제 제공자가 지원하는 같은 멱등 키로 결제를 요청한다. TX B가 성공하면 COMMIT으로 결과와 주문 상태를 확정한다. 왼쪽 별도 분기는 TX B의 저장 실패이며 로컬 변경을 ROLLBACK하고 같은 이벤트의 외부 결과를 재확인·재처리한다. DB rollback만으로 외부 결제가 취소되지 않는다. 본문 완성 구현이 아닌 확장 설계다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | DB 트랜잭션과 외부 결제 경계 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 설계안의 첫 DB 경계 outbox 확장 설계의 선택한 성공 흐름에서 재고·대기 주문·outbox를 TX A에 함께 기록하도록 정합니다. | 범위: 완성된 worker 구현이 없는 outbox 확장 설계안 · 설계상 TX A: 재고·PENDING 주문·outbox 원자 commit · 접수 응답: 설계상 202·PENDING, 결제 완료 아님 | 확장 설계가 구현되면 commit 뒤에만 주문 접수를 확정합니다. 본문의 실행 결과로 제시하지 않습니다. |
| 설계안의 외부 결제 설계상 worker가 outbox를 claim한 뒤 DB transaction 밖에서 결제 요청을 보냅니다. | 범위: 확장 설계의 네트워크 단계 · 외부 요청: TX 밖·같은 멱등 키 · 전제: 결제 제공자의 해당 멱등 키 지원 | 설계상 DB commit과 긴 네트워크 대기를 분리하며 같은 이벤트 재시도의 중복 청구 조건을 드러냅니다. |
| 설계안의 결과 commit 선택한 성공 흐름에서는 TX B가 결제 결과와 주문 상태를 저장하고 commit하도록 정합니다. | 범위: 확장 설계의 성공한 TX B · 설계상 저장: 결제 결과·주문 상태 · 외부 성공 뒤 저장 실패: 원표의 별도 rollback·재확인·보상 계약 | 후속 DB commit이 상태를 확정합니다. 다른 저장 실패 경로의 DB rollback은 이미 발생한 외부 결제를 취소하지 않습니다. |
DB 트랜잭션과 외부 결제 경계
본문의 경계 설계를 outbox로 확장할 때 선택한 성공 흐름입니다. 완성된 worker 실행 구현은 없으며 아래 DB·외부 결제·결과 저장은 확장 설계의 계약입니다.
Nest Outbox · DB와 외부 효과
DB 변경을 먼저 commit하고, 외부 결제는 worker가 별도 처리합니다
본문의 경계 설계를 구체화한 outbox 확장 예시입니다. 재고·주문·outbox를 같은 DB commit에 기록하고, worker가 트랜잭션 밖에서 결제한 뒤 별도 TX에 결과를 기록합니다. 본문에 완성된 worker 구현이 있는 것은 아닙니다.
TX A · 주문을 접수한다
조건부 재고 감소 — 충분한 재고 조건과 원자적
decrement를 한 DB 쓰기로 실행합니다.같은 commit —
PENDING주문과 결제 요청 outbox 이벤트를 재고 변경과 같은 트랜잭션에 기록합니다. 하나라도 실패하면 셋 모두 rollback됩니다.확정된 상태만 응답 — commit 뒤 주문 접수 상태를 반환합니다. 아직 외부 결제가 완료됐다고 응답하지 않습니다.
네트워크 · TX B · 결과를 확정한다
commit 뒤 호출 — worker가 outbox를 가져온 뒤 DB 트랜잭션 밖에서 결제 API를 호출합니다. 긴 네트워크 대기 동안 DB 잠금을 유지하지 않습니다.
멱등 요청 — 주문 기반 idempotency key를 사용해 worker 재시도가 중복 결제를 만들지 않도록 합니다.
결과 저장 — 짧은 두 번째 트랜잭션에서 결제 결과와 주문 상태를 함께 갱신합니다. 외부 성공 뒤 로컬 저장이 실패하면 같은 이벤트를 재시도하고, 되돌려야 하는 효과에는 승인 취소·환불 같은 보상 작업이 필요합니다.
DB rollback은 해당 DB 변경만 취소합니다. 이미 완료된 외부 결제는 별도 보상 작업이 필요합니다. 같은 멱등 키의 재시도가 중복 청구를 만들지 않으려면 결제 제공자도 해당 키를 지원해야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 클라이언트 주문을 받은 OrderService가 첫 DB 트랜잭션에서 조건부 재고 감소, 대기 주문, outbox 이벤트를 함께 commit한다. Worker는 outbox를 읽어 트랜잭션 밖에서 멱등 결제 요청을 보내고, 두 번째 DB 트랜잭션에서 결제 결과와 주문 상태를 기록한다.
- TX A는 재고·대기 주문·outbox를 함께 commit한 뒤 접수 응답한다. Worker는 outbox를 가져와 DB 트랜잭션 밖에서 결제 제공자가 지원하는 같은 멱등 키로 결제를 요청한다. TX B가 성공하면 COMMIT으로 결과와 주문 상태를 확정한다. 왼쪽 별도 분기는 TX B의 저장 실패이며 로컬 변경을 ROLLBACK하고 같은 이벤트의 외부 결과를 재확인·재처리한다. DB rollback만으로 외부 결제가 취소되지 않는다. 본문 완성 구현이 아닌 확장 설계다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | DB 트랜잭션과 외부 결제 경계 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 설계안의 첫 DB 경계 outbox 확장 설계의 선택한 성공 흐름에서 재고·대기 주문·outbox를 TX A에 함께 기록하도록 정합니다. | 범위: 완성된 worker 구현이 없는 outbox 확장 설계안 · 설계상 TX A: 재고·PENDING 주문·outbox 원자 commit · 접수 응답: 설계상 202·PENDING, 결제 완료 아님 | 확장 설계가 구현되면 commit 뒤에만 주문 접수를 확정합니다. 본문의 실행 결과로 제시하지 않습니다. |
| 설계안의 외부 결제 설계상 worker가 outbox를 claim한 뒤 DB transaction 밖에서 결제 요청을 보냅니다. | 범위: 확장 설계의 네트워크 단계 · 외부 요청: TX 밖·같은 멱등 키 · 전제: 결제 제공자의 해당 멱등 키 지원 | 설계상 DB commit과 긴 네트워크 대기를 분리하며 같은 이벤트 재시도의 중복 청구 조건을 드러냅니다. |
| 설계안의 결과 commit 선택한 성공 흐름에서는 TX B가 결제 결과와 주문 상태를 저장하고 commit하도록 정합니다. | 범위: 확장 설계의 성공한 TX B · 설계상 저장: 결제 결과·주문 상태 · 외부 성공 뒤 저장 실패: 원표의 별도 rollback·재확인·보상 계약 | 후속 DB commit이 상태를 확정합니다. 다른 저장 실패 경로의 DB rollback은 이미 발생한 외부 결제를 취소하지 않습니다. |
트랜잭션은 데이터베이스의 ACID 속성을 보장하는 핵심 메커니즘입니다.
- 원자성(Atomicity): 트랜잭션의 DB 변경은 모두 commit되거나 모두 rollback됩니다. 외부 API에서 이미 발생한 효과까지 되돌리는 성질은 아닙니다.
- 일관성(Consistency): commit 전후에 데이터베이스가 선언한 제약 조건을 만족합니다. 애플리케이션 규칙은 제약 조건과 올바른 트랜잭션 로직으로 직접 지켜야 합니다.
- 고립성(Isolation): 동시 트랜잭션에서 허용하거나 막는 이상 현상은 데이터베이스와 고립성 수준에 따라 달라집니다. 낮은 수준이 모든 동시 실행을 직렬 실행처럼 보이게 하지는 않습니다.
- 지속성(Durability): commit이 성공하면 결과는 데이터베이스가 제공하는 내구성 보장에 따라 장애 후에도 유지됩니다.
이러한 ACID 속성은 특히 금융 거래, 재고 관리 등 데이터의 정확성과 신뢰성이 매우 중요한 시스템에서 필수적입니다.
NestJS에서 트랜잭션 관리하기
Nest Prisma · API와 동시성
쿼리 의존성으로 API를 선택하고, 경쟁하는 값에는 별도 안전장치를 둡니다
앞 쿼리 결과나 앱 로직이 필요하면 interactive transaction의 tx로 실행합니다. 조건부 원자 갱신과 격리 수준, P2034의 제한적 재시도는 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를 섞어 쓰면 안 됩니다.
아래 코드는 앞 절의 User CRUD와 별도로 Product·Order·OrderItem을 사용하는 서비스 발췌입니다. 세 엔티티의 정의와 DataSource 등록은 이 절에서 제공하지 않습니다. 사용하는 프로젝트에 이 모델이 준비되어 있는지 먼저 확인하세요.
코드가 참조하는 필드는 Product의 id·stock·price, Order의 id·userId·orderDate, OrderItem의 order 관계와 productId·quantity·price입니다. 이 목록은 완전한 엔티티 정의가 아니므로 필드 타입과 관계 매핑도 해당 프로젝트에 맞춰야 합니다. 앞 절의 User 설정만으로 주문 코드를 이어 붙일 수는 없습니다. 뒤의 Prisma 예제도 이름이 같은 별도 OrderService이므로, 한 파일에 함께 넣지 말고 사용하는 ORM의 발췌를 선택해 읽으세요.
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은 해당 트랜잭션의 변경을 자동으로 롤백합니다.
모든 작업이 성공적으로 완료되면 트랜잭션은 자동으로 커밋됩니다.
중간 실패에서 재고가 돌아오는 과정
상품 A의 재고가 2개, B가 0개이고 한 주문이 각각 1개씩 요청했다고 가정합니다. 다른 동시 요청은 없고 아래 모든 DB 쓰기가 같은 manager를 사용합니다. 주문 생성 뒤 A를 1개로 줄여도 아직 commit한 상태는 아닙니다. B의 조건부 감소가 0건이어서 예외를 던지면 주문 생성과 A 감소를 함께 rollback합니다. 최종 재고는 A 2개·B 0개이고 이 주문과 항목도 남지 않습니다. A의 쓰기가 먼저 실행되었다는 사실만으로 A 감소가 확정되지는 않습니다.
독립적으로 결과 계산하기
초기 재고가 A 1개이고, 한 주문의 입력 배열에 A 1개 요청이 두 번 들어 있습니다. 각 반복에서 stock >= quantity인 행만 원자적으로 감소하고, 변경 건수가 1이 아니면 예외를 던집니다. 두 항목 모두 같은 트랜잭션에 포함되고 다른 요청은 없을 때, 최종 재고와 주문 저장 결과는 무엇일까요?
두 번째 조건 실패 뒤의 결과
첫 감소는 재고를 0개로 만들지만, 두 번째 감소 조건은 만족하지 않아 0건이 됩니다. 예외로 전체 트랜잭션을 rollback하므로 최종 재고는 1개이고 해당 주문·항목은 저장되지 않습니다. 재고 부족은 아래의 동시성 충돌 P2034와 다른 실패이므로 같은 주문을 무조건 재시도하는 근거가 되지 않습니다. 입력에서 중복 상품을 합칠지 거절할지도 별도 업무 규칙으로 정할 수 있습니다.
queryRunner)
Nest 공식 문서는 트랜잭션 수명 주기를 완전히 제어해야 할 때 QueryRunner 사용을 권장합니다. 직접 시작(startTransaction()), 커밋(commitTransaction()), 롤백(rollbackTransaction()), 연결 해제(release())를 책임져야 합니다.
DataSource를 주입받은 서비스에서 시작·커밋·롤백·해제 위치를 읽기 위한 메서드 발췌입니다. SomeEntity와 AnotherEntity는 설명용 엔티티 이름이고 someData는 저장할 값의 자리표시자입니다. 자신의 코드로 옮길 때는 등록된 엔티티와 입력 값으로 바꾸어야 합니다.
async complexTransaction(): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
const errors: unknown[] = [];
try {
await queryRunner.connect();
await queryRunner.startTransaction();
// queryRunner.manager를 통해 작업 수행
await queryRunner.manager.save(SomeEntity, someData);
await queryRunner.manager.update(AnotherEntity, { id: 1 }, { status: 'completed' });
await queryRunner.commitTransaction(); // 성공 시 커밋
} catch (err) {
errors.push(err);
if (queryRunner.isTransactionActive) {
try {
await queryRunner.rollbackTransaction();
} catch (rollbackError) {
errors.push(rollbackError);
}
}
} finally {
try {
await queryRunner.release();
} catch (releaseError) {
errors.push(releaseError);
}
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) {
throw new AggregateError(errors, 'Transaction or cleanup failed');
}
}연결 확보와 트랜잭션 시작도 try 안에 두어야 이 단계의 실패 뒤에 finally로 진행합니다. 실패 시 isTransactionActive가 참일 때만 rollback을 시도하고, 그 결과와 관계없이 release를 시도합니다. 정리가 실패해도 처음 오류를 잃지 않도록 errors에 순서대로 모읍니다. 하나만 있으면 그 오류를 그대로 던지고, 여러 개면 AggregateError.errors에서 함께 확인할 수 있습니다.
이는 정리를 시도하는 경로를 보여 주며, 연결 장애 때 DB의 최종 상태나 rollback·release의 성공까지 보장하지는 않습니다. commit이 성공한 뒤 release만 실패했다면 변경이 취소됐다고 판단하지 않습니다. 이 발췌에는 그런 상황의 자동 재시도 정책을 넣지 않았습니다.
Prisma에서 트랜잭션 관리하기
Prisma ORM 7은 nested write와 bulk operation 외에도 두 가지 $transaction 형식을 제공합니다.
앞 쿼리의 결과나 쿼리 사이 애플리케이션 로직이 필요한지가 선택 기준입니다.
$transaction with a function)
Prisma의 상호작용적 트랜잭션은 TypeORM의 dataSource.transaction()과 유사하게 콜백 함수 기반으로 동작합니다.
이 방식은 이전 쿼리의 결과를 검사하거나 그 결과로 다음 쿼리를 만들 때 사용합니다. 콜백 안의 DB 접근은 모두 전달받은 tx로 실행하고, 트랜잭션은 짧게 유지해야 합니다.
이 주문 발췌는 앞 절의 User/Post 스키마와 별개의 모델을 전제로 합니다. Order·Product·OrderItem 모델과 관계를 정의하고 그 스키마로 Client를 생성한 프로젝트에서 읽는 코드입니다. 여기서는 모델 정의와 데이터베이스 준비 코드를 제공하지 않습니다.
코드가 사용하는 필드는 Product의 id·stock·price, Order의 id·userId·orderDate, OrderItem의 orderId·productId·quantity·price입니다. 완전한 스키마 정의를 뜻하는 목록은 아닙니다. 앞 절의 스키마만 생성한 Client에는 Order 타입과 tx.order·tx.product·tx.orderItem이 없으므로, 이 이름을 찾을 수 없다면 트랜잭션 호출보다 모델 전제부터 확인하세요.
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을 사용합니다.
다음 배열형 발췌는 User에 Boolean isActive 필드를 따로 정의하고 그 스키마로 Client를 생성한 프로젝트를 전제로 합니다. 앞 절의 User에는 이 필드가 없으므로 그대로 사용할 수 없습니다. 여기서는 여러 PrismaPromise를 배열 순서대로 한 트랜잭션에 묶는 형태를 읽습니다.
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는 연결·시작부터 실패를 잡고, 활성 트랜잭션의 rollback과 finally의 release를 시도한 뒤 원래 오류와 정리 오류를 함께 전달합니다. - 관찰 가능성: 트랜잭션 소요 시간, commit·rollback, 재시도 횟수와 최종 오류 코드를 기록하되 민감한 결제 정보는 로그에 남기지 않습니다.
트랜잭션 관리와 데이터 정합성은 안정적인 백엔드 애플리케이션의 핵심입니다.
NestJS에서는 TypeORM과 Prisma 같은 ORM 라이브러리를 통해 트랜잭션 경계와 데이터 접근 패턴을 구조화할 수 있습니다.
이것으로 나 혼자 Nest.js의 3장 데이터베이스 통합을 모두 마칩니다.
이제 NestJS 애플리케이션에서 트랜잭션 API, 동시성 제어, 외부 효과의 경계를 함께 설계할 수 있습니다.