
사용자가 상품을 주문하거나 송금을 하는 찰나의 순간, 데이터베이스 내부에서는 수많은 일이 일어납니다. 만약 재고 차감과 결제 기록 중 하나만 성공하고 나머지가 실패한다면 어떻게 될까요? 혹은 두 명의 사용자가 동시에 남은 마지막 재고 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 환경에서는 비동기 루프 내에서 트랜잭션을 잘못 사용하지 않도록 주의해야 합니다.
오늘 정리한 격리 수준의 차이를 이해하고, 여러분의 프로젝트에 최적인 전략을 세워보세요!