
TL;DR
- 트랜잭션은 "전부 성공하거나, 아예 일어나지 않은 상태로 되돌리거나(All or Nothing)"를 보장하는 작업 단위이며, ACID(원자성·일관성·격리성·지속성) 원칙을 따릅니다.
- 여러 트랜잭션이 같은 데이터에 동시에 접근하면 이상 현상이 생기므로, SQL 표준은 Read Uncommitted부터 Serializable까지 4가지 격리 수준을 정의합니다.
- 격리 수준을 높이면 안전해지지만 성능은 떨어집니다. 서비스 특성에 맞는 수준을 고르고, 필요하면 분산 락 같은 애플리케이션 레벨 대응을 함께 검토합니다.
- NestJS + Prisma 환경에서는
$transaction콜백으로 비즈니스 로직 검증과 원자적 처리를 함께 묶을 수 있습니다.
사용자가 상품을 주문하거나 송금을 하는 찰나의 순간, 데이터베이스 내부에서는 수많은 일이 일어납니다. 만약 재고 차감과 결제 기록 중 하나만 성공하고 나머지가 실패하면 어떻게 될까요? 혹은 두 명의 사용자가 동시에 남은 마지막 재고 1개를 주문하면 어떻게 될까요?
이런 상황에서 데이터의 일관성을 보장하려고 트랜잭션(Transaction)을 사용합니다. 트랜잭션은 "전부 성공하거나, 아니면 아예 일어나지 않은 상태로 되돌리거나(All or Nothing)"를 보장하는 작업의 단위입니다.
트랜잭션을 논할 때 빠질 수 없는 4가지 원칙입니다.
여러 트랜잭션이 같은 데이터에 동시에 접근하면 이상 현상(Anomalies)이 발생할 수 있습니다. 이를 방지하려고 SQL 표준은 4가지 격리 수준을 정의합니다.
NestJS 환경에서 Prisma ORM으로 트랜잭션을 안전하게 처리하는 예시입니다.
단순히 쿼리를 나열하는 것을 넘어, 비즈니스 로직 중간에 검증이 필요한 경우 사용합니다.
async transferMoney(fromId: number, toId: number, amount: number) { // $transaction을 통해 모든 작업이 원자적으로 실행됨을 보장합니다. return await this.prisma.$transaction(async (tx) => { // 1. 송금자 잔액 확인 const sender = await tx.account.findUnique({ where: { id: fromId } }); if (sender.balance < amount) { throw new Error("잔액이 부족합니다."); // 에러 발생 시 자동 롤백 } // 2. 잔액 차감 await tx.account.update({ where: { id: fromId }, data: { balance: { decrement: amount } } }); // 3. 잔액 증액 await tx.account.update({ where: { id: toId }, data: { balance: { increment: amount } } }); }); }
격리 수준을 높이면 데이터는 안전해지지만 성능은 떨어집니다. 반대로 수준을 낮추면 성능은 올라가지만 Dirty Read 같은 정합성 문제가 생길 수 있습니다.
따라서 "안전하니까 무조건 높은 수준"을 택하기보다, 서비스의 특성에 맞는 적절한 수준을 선택해야 합니다. 필요하다면 애플리케이션 레벨에서 Redis 등을 이용한 분산 락(Distributed Lock)도 함께 고려합니다.
트랜잭션은 데이터베이스가 제공하는 강력한 도구이지만, 내부 원리를 모른 채 사용하면 성능 저하나 데드락(Deadlock)의 함정에 빠지기 쉽습니다. 특히 JavaScript/TypeScript 환경에서는 비동기 루프 안에서 트랜잭션을 잘못 사용하지 않도록 주의해야 합니다.
오늘 정리한 격리 수준의 차이를 이해하고, 프로젝트에 맞는 전략을 세워 보시기 바랍니다.