트랜잭션 관리와 데이터 정합성
ACID와 커밋·롤백의 경계를 이해하고 TypeORM과 Prisma로 여러 데이터 작업의 원자성과 정합성을 지킵니다.
이제 데이터베이스 통합의 마지막이자 가장 중요한 주제 중 하나인 트랜잭션(Transaction) 관리와 데이터 정합성(Data Consistency)에 대해 이야기할 차례입니다.
복잡한 애플리케이션에서는 단일 작업처럼 보이지만, 실제로는 여러 데이터베이스 작업(예: 여러 테이블 삽입, 업데이트, 삭제)이 연속으로 수행되는 경우가 많습니다.
예를 들어 온라인 쇼핑몰 구매 처리에서는 재고 감소, 주문 생성, 결제 내역 기록이 모두 성공해야 하고, 하나라도 실패하면 전체 작업을 취소해야 합니다.
이때 트랜잭션이 중요한 역할을 합니다.
주문 처리처럼 재고 감소, 주문 생성, 결제 기록이 함께 움직이는 작업은 부분 성공을 남기면 데이터 정합성이 깨진다.
- 재고 감소
상품 수량을 줄인다.
- 주문 생성
주문과 주문 항목을 저장한다.
- 결제 기록
결제 결과를 저장한다.
- 결과 확정
모두 성공하면 커밋하고 실패하면 되돌린다.
트랜잭션(Transaction)이란 무엇인가?
트랜잭션은 데이터베이스에서 수행되는 하나 이상의 연산(읽기, 쓰기, 수정, 삭제)을 논리적으로 하나의 단위로 묶는 작업입니다.
이 단위 안의 모든 연산은 모두 성공적으로 완료되거나(Commit), 아니면 모두 실패해 원래 상태로 되돌아가야 합니다(Rollback).
즉 전부 아니면 전무(All or Nothing) 원칙을 따릅니다.
주문 처리처럼 재고, 주문, 결제 기록이 연결된 작업에서는 실패 지점에 따라 전체 변경을 되돌릴지, 최종 결과를 확정할지 명확히 나누어야 합니다.
주문 처리에서는 마지막 쓰기까지 성공해야 commit한다. 중간에 실패하면 이미 줄인 재고와 생성한 주문도 함께 rollback한다.
- 01 Request
Request 주문 요청 수신 상품, 수량, 결제 정보를 검증한다.
- 02 Begin
Begin tx client 확보 이후 쓰기를 같은 DB 연결과 트랜잭션에 묶는다.
- 03 Write
Write 재고·주문·결제 기록 한 단계라도 실패하면 확정하지 않는다.
- 04 Decide
Decide commit 또는 rollback 모든 쓰기 성공이면 commit, 예외면 rollback한다.
- 05 Response
Response 확정된 결과만 응답 부분 주문 번호나 결제 흔적을 남기지 않는다.
| 실패 지점 | 되돌릴 대상 | 응답 기준 |
|---|---|---|
| 재고 감소 실패 | 주문과 결제 기록을 만들지 않는다. | 품절 또는 수량 부족으로 실패한다. |
| 주문 생성 실패 | 이미 줄인 재고를 rollback한다. | 부분 주문 번호를 노출하지 않는다. |
| 결제 기록 실패 | 재고, 주문, 항목 기록을 모두 되돌린다. | 결제 실패 사유만 매핑한다. |
트랜잭션은 데이터베이스의 ACID 속성을 보장하는 핵심 메커니즘입니다.
- 원자성(Atomicity): 트랜잭션 내의 모든 연산은 완전하게 성공하거나, 완전하게 실패하여 롤백됩니다. 부분적인 성공은 없습니다.
- 일관성(Consistency): 트랜잭션이 성공적으로 완료되면, 데이터베이스는 항상 일관된 상태를 유지합니다. 예를 들어, 모든 제약 조건(Primary Key, Foreign Key 등)이 충족됩니다.
- 고립성(Isolation): 여러 트랜잭션이 동시에 실행될 때, 각 트랜잭션은 마치 독립적으로 실행되는 것처럼 동작합니다. 한 트랜잭션의 중간 결과가 다른 트랜잭션에 영향을 미치지 않습니다.
- 지속성(Durability): 트랜잭션이 성공적으로 완료(Commit)되면, 그 결과는 시스템 오류가 발생하더라도 영구적으로 데이터베이스에 반영됩니다.
이러한 ACID 속성은 특히 금융 거래, 재고 관리 등 데이터의 정확성과 신뢰성이 매우 중요한 시스템에서 필수적입니다.
NestJS에서 트랜잭션 관리하기
ORM에 따라 트랜잭션을 여는 위치와 확정 지점이 달라진다. 핵심은 같은 작업 단위를 하나의 성공 또는 실패로 묶는 것이다.
- 처리 단위
서비스 메서드가 주문, 결제, 재고처럼 함께 움직일 변경을 모은다. 2 TypeORM은 queryRunner, Prisma는 $transaction으로 경계를 만든다. 3 모든 쓰기는 같은 매니저 또는 같은 콜백 안에서 처리한다. 4 전부 성공하면 commit, 하나라도 실패하면 rollback한다.
- 요청 진입
서비스 메서드가 주문, 결제, 재고처럼 함께 움직일 변경을 모은다.
- 트랜잭션 시작
TypeORM은 queryRunner, Prisma는 $transaction으로 경계를 만든다.
- 변경 실행
모든 쓰기는 같은 매니저 또는 같은 콜백 안에서 처리한다.
- 결과 확정
전부 성공하면 commit, 하나라도 실패하면 rollback한다.
| 도구 | 경계 |
|---|---|
| TypeORM | queryRunner.manager 사용을 끝까지 유지 |
| Prisma | $transaction() 콜백 안에서 묶기 |
| 공통 | 외부 API 호출은 DB 잠금 안에 오래 두지 않기 |
NestJS에서 트랜잭션을 관리하는 방법은 사용하는 ORM 또는 ODM에 따라 달라집니다.
여기서는 TypeORM과 Prisma를 중심으로 살펴보겠습니다.
TypeORM에서 트랜잭션 관리하기
TypeORM은 다양한 방법으로 트랜잭션을 지원합니다.
가장 일반적인 두 가지 방법을 소개합니다.
방법 1:DataSource.transaction() 메서드 사용 (권장)
이 방법은 비동기 함수를 콜백으로 전달하여 트랜잭션 스코프 내에서 모든 데이터베이스 작업을 수행합니다.
콜백 함수 내에서 발생한 모든 작업은 자동으로 트랜잭션에 포함됩니다.
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm'; // DataSource 임포트
import { InjectRepository } from '@nestjs/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 dataSource: DataSource, // DataSource 주입
@InjectRepository(Product)
private productRepository: Repository<Product>,
@InjectRepository(Order)
private orderRepository: Repository<Order>,
@InjectRepository(OrderItem)
private orderItemRepository: Repository<OrderItem>,
) {}
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 product = await productRepo.findOneBy({ id: productId });
if (!product || product.stock <= 0) {
// 재고가 없거나 상품이 존재하지 않으면 트랜잭션 롤백
throw new Error(`Product with ID ${productId} is out of stock or not found.`);
}
product.stock -= 1; // 재고 감소
await productRepo.save(product); // 상품 업데이트
const orderItem = orderItemRepo.create({
order,
productId: product.id,
quantity: 1,
price: product.price,
});
await orderItemRepo.save(orderItem); // 주문 아이템 저장
}
return order; // 모든 작업 성공 시 자동 커밋
}); // 트랜잭션 블록 끝
}
}위 예시에서 this.dataSource.transaction(async (manager) => { ... }); 블록 내의 모든 작업은 하나의 트랜잭션으로 묶입니다.
만약 중간에 에러가 발생하면, throw new Error(...)에 의해 트랜잭션은 자동으로 롤백되어 데이터베이스는 초기 상태를 유지합니다.
모든 작업이 성공적으로 완료되면 트랜잭션은 자동으로 커밋됩니다.
방법 2: 수동 트랜잭션 제어 (queryRunner)
더 세밀한 제어가 필요할 때 queryRunner를 직접 사용하여 트랜잭션을 시작(startTransaction()), 커밋(commitTransaction()), 롤백(rollbackTransaction())할 수 있습니다.
// (이 방법은 필요할 때만 사용하며, 위 방법이 더 일반적입니다.)
import { QueryRunner } from 'typeorm';
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(); // 실패 시 롤백
} finally {
await queryRunner.release(); // queryRunner 해제
}
}Prisma에서 트랜잭션 관리하기
Prisma는 두 가지 주요 트랜잭션 관리 방법을 제공합니다.
상호작용적 트랜잭션(Interactive Transactions)과 배치 트랜잭션(Batch Transactions).
방법 1: 상호작용적 트랜잭션 ($transaction with a function)
Prisma의 상호작용적 트랜잭션은 TypeORM의 dataSource.transaction()과 유사하게 콜백 함수 기반으로 동작합니다.
이 방식은 트랜잭션 내에서 여러 쿼리를 순차적으로 실행하며, 이전 쿼리의 결과를 다음 쿼리에 사용할 수 있도록 해줍니다.
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; // PrismaService 임포트
import { Prisma, Order, OrderItem, Product } from '@prisma/client'; // Prisma Client에서 생성된 타입 임포트
@Injectable()
export class OrderService {
constructor(private prisma: PrismaService) {}
async createOrder(userId: number, productIds: number[]): Promise<Order> {
// 상호작용적 트랜잭션 시작
return this.prisma.$transaction(async (tx) => {
// 'tx'는 트랜잭션 클라이언트입니다. 모든 쿼리는 'tx'를 통해 실행됩니다.
// 1. 주문 생성
const order = await tx.order.create({
data: {
userId,
orderDate: new Date(),
},
});
// 2. 각 상품에 대한 주문 아이템 생성 및 재고 감소
for (const productId of productIds) {
const product = await tx.product.findUnique({
where: { id: productId },
});
if (!product || product.stock <= 0) {
// 재고가 없거나 상품이 존재하지 않으면 트랜잭션 롤백
throw new Error(`Product with ID ${productId} is out of stock or not found.`);
}
await tx.product.update({
where: { id: productId },
data: { stock: product.stock - 1 }, // 재고 감소
});
await tx.orderItem.create({
data: {
orderId: order.id,
productId: product.id,
quantity: 1,
price: product.price,
},
});
}
return order; // 모든 작업 성공 시 자동 커밋
}); // 트랜잭션 블록 끝
}
}$transaction with an array)
배치 트랜잭션은 독립적인 여러 쿼리를 하나의 트랜잭션으로 묶어 동시에 실행할 때 사용합니다.
모든 쿼리가 성공하면 커밋되고, 하나라도 실패하면 모두 롤백됩니다.
이 방식은 각 쿼리 간의 의존성이 적을 때 유용하며, 단일 데이터베이스 왕복으로 여러 작업을 처리하여 성능상 이점을 얻을 수 있습니다.
// 여러 업데이트/삭제 작업을 묶을 때
async updateMultipleUsersStatus(ids: number[], newStatus: boolean): Promise<any> {
const queries = ids.map(id =>
this.prisma.user.update({
where: { id },
data: { isActive: newStatus },
})
);
return this.prisma.$transaction(queries); // 모든 쿼리가 성공해야 커밋
}트랜잭션 사용 시 주의사항 및 모범 사례
트랜잭션은 데이터 정합성을 보장하는 도구이지만, 잘못 사용하면 성능 저하나 데드락(Deadlock)과 같은 문제를 야기할 수 있습니다.
- 트랜잭션 범위 최소화: 트랜잭션은 필요한 최소한의 작업만 포함하도록 합니다. 트랜잭션이 길어지면 데이터베이스 리소스 점유 시간이 길어져 다른 트랜잭션의 성능에 영향을 미치고 데드락 발생 가능성이 높아집니다.
- 외부 API 호출 주의: 트랜잭션 내부에서 외부 API 호출(예: 결제 게이트웨이, 이메일 발송)을 수행하는 것은 지양해야 합니다. 외부 API 호출은 네트워크 지연 등으로 트랜잭션 시간을 늘리고, 롤백이 불가능해 데이터 불일치를 유발할 수 있습니다.
이런 작업은 트랜잭션 커밋 후 비동기 처리하거나 메시지 큐 등으로 분리하는 것이 좋습니다.
- 고립성 수준 이해: 데이터베이스는 다양한 트랜잭션 고립성 수준(Isolation Level)을 제공합니다 (예: Read Uncommitted, Read Committed, Repeatable Read, Serializable). 각 수준은 데이터 일관성과 동시성 사이의 트레이드오프가 있으므로, 애플리케이션 요구사항에 맞게 선택해야 합니다.
대부분은 기본 설정(Read Committed 또는 Repeatable Read)으로 충분하지만, 특정 상황에서는 조절이 필요할 수 있습니다.
- 에러 처리: 트랜잭션 블록 내에서 예외가 발생했을 때 적절히 롤백되도록 코드를 구성해야 합니다. NestJS의 예외 필터와 결합하여 사용자에게 친절한 에러 메시지를 제공하는 것도 중요합니다.
- 로깅: 트랜잭션의 시작, 커밋, 롤백 과정을 로깅하여 문제 발생 시 디버깅을 용이하게 합니다.
트랜잭션 관리와 데이터 정합성은 안정적인 백엔드 애플리케이션의 핵심입니다.
NestJS에서는 TypeORM과 Prisma 같은 ORM/ODM 라이브러리를 통해 트랜잭션 경계와 데이터 접근 패턴을 구조화할 수 있습니다.
이것으로 나 혼자 Nest.js의 3장 데이터베이스 통합을 모두 마칩니다.
이제 NestJS 애플리케이션의 데이터를 더 안전하고 효율적으로 관리하는 기본 구조를 이해할 수 있습니다.
트랜잭션을 설계할 때는 다음 다이어그램처럼 범위, 외부 호출, 고립성, 실패 응답을 함께 점검하면 좋습니다.
트랜잭션은 코드가 길어지는 문제가 아니라 실패했을 때 데이터가 어떤 상태로 남는지의 문제다.
- 짧은 범위
DB 변경만 묶고 계산과 외부 호출은 밖으로 뺀다.
- 단일 실패 응답
중간 단계 실패도 하나의 rollback 기준으로 해석한다.
- 명시적 격리
동시 주문, 재고 차감처럼 충돌 가능성이 있는 지점을 표시한다.
- 재시도 기준
일시 실패와 비즈니스 실패를 같은 예외로 섞지 않는다.
| 냄새 | 결과 |
|---|---|
| 트랜잭션 안의 네트워크 호출 | 잠금 시간이 길어지고 타임아웃이 늘어난다. |
| 여러 repository가 각자 저장 | 일부 성공 상태가 남을 수 있다. |
| catch 후 계속 진행 | 실패를 성공 응답으로 숨길 수 있다. |
| rollback 누락 | 연결 반환과 데이터 정합성이 함께 흔들린다. |