트랜잭션 관리와 데이터 정합성
이제 데이터베이스 통합의 마지막이자 가장 중요한 주제 중 하나인 트랜잭션(Transaction) 관리와 데이터 정합성(Data Consistency) 에 대해 이야기할 차례입니다.
복잡한 애플리케이션에서는 단일 작업으로 보이지만 실제로는 여러 개의 개별 데이터베이스 작업(예: 여러 테이블에 걸친 삽입, 업데이트, 삭제)이 연속적으로 이루어져야 하는 경우가 많습니다. 예를 들어, 온라인 쇼핑몰에서 상품을 구매할 때 '재고 감소', '주문 생성', '결제 내역 기록'과 같은 일련의 작업들이 모두 성공하거나, 아니면 하나라도 실패하면 모든 작업을 취소해야 합니다. 이때 트랜잭션이 중요한 역할을 합니다.
트랜잭션(Transaction)이란 무엇인가?
트랜잭션은 데이터베이스에서 수행되는 하나 이상의 연산(읽기, 쓰기, 수정, 삭제)들을 논리적으로 하나의 단위로 묶는 작업입니다. 이 단위 안의 모든 연산은 반드시 모두 성공적으로 완료되거나(Commit), 아니면 모두 실패하여 원래 상태로 되돌려져야 합니다(Rollback). 즉, '전부 아니면 전무(All or Nothing)'의 원칙을 따릅니다.
트랜잭션은 데이터베이스의 ACID 속성을 보장하는 핵심 메커니즘입니다.
- 원자성(Atomicity): 트랜잭션 내의 모든 연산은 완전하게 성공하거나, 완전하게 실패하여 롤백됩니다. 부분적인 성공은 없습니다.
- 일관성(Consistency): 트랜잭션이 성공적으로 완료되면, 데이터베이스는 항상 일관된 상태를 유지합니다. 예를 들어, 모든 제약 조건(Primary Key, Foreign Key 등)이 충족됩니다.
- 고립성(Isolation): 여러 트랜잭션이 동시에 실행될 때, 각 트랜잭션은 마치 독립적으로 실행되는 것처럼 동작합니다. 한 트랜잭션의 중간 결과가 다른 트랜잭션에 영향을 미치지 않습니다.
- 지속성(Durability): 트랜잭션이 성공적으로 완료(Commit)되면, 그 결과는 시스템 오류가 발생하더라도 영구적으로 데이터베이스에 반영됩니다.
이러한 ACID 속성은 특히 금융 거래, 재고 관리 등 데이터의 정확성과 신뢰성이 매우 중요한 시스템에서 필수적입니다.
NestJS에서 트랜잭션 관리하기
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; // 모든 작업 성공 시 자동 커밋
}); // 트랜잭션 블록 끝
}
}
방법 2: 배치 트랜잭션 ($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 애플리케이션의 데이터를 안전하고 효율적으로 관리할 수 있는 기반을 다지셨습니다.