본문 바로가기

트랜잭션 관리와 데이터 정합성

ACID와 커밋·롤백의 경계를 이해하고 TypeORM과 Prisma로 여러 데이터 작업의 원자성과 정합성을 지킵니다.

이 글은 데이터베이스 통합의 마지막 주제인 트랜잭션(transaction) 관리와 데이터 정합성을 다룹니다. 복잡한 애플리케이션에서는 하나의 작업처럼 보여도 실제로는 여러 테이블의 삽입, 수정, 삭제가 이어지는 경우가 많습니다. 예를 들어 온라인 쇼핑몰에서는 재고 감소, 주문 생성, 결제 요청 기록이 모두 성공해야 하고, 하나라도 실패하면 같은 트랜잭션의 변경을 취소해야 합니다.

다만 결제 게이트웨이 호출처럼 데이터베이스 밖에서 일어난 효과는 DB의 롤백으로 취소되지 않습니다. 그래서 주문 데이터의 원자성과 외부 결제의 신뢰성은 트랜잭션의 범위, 아웃박스(outbox), 멱등성, 필요하면 보상 작업까지 함께 설계해야 합니다.


트랜잭션이란

트랜잭션은 데이터베이스에서 하나 이상의 읽기, 쓰기, 수정, 삭제를 논리적으로 하나의 단위로 묶는 작업입니다. 이 단위 안의 연산은 모두 성공해 확정(commit)되거나, 모두 실패해 원래 상태로 되돌아가야(rollback) 합니다. 주문 처리처럼 재고, 주문, 결제 요청 기록이 이어진 작업에서는, 실패한 지점에 따라 DB 변경을 되돌릴지 최종 결과를 확정할지를 분명히 나눠야 합니다.

DB 트랜잭션과 외부 결제 경계

본문의 경계 설계를 outbox로 확장할 때 선택한 성공 흐름입니다. 완성된 worker 실행 구현은 없으며 아래 DB·외부 결제·결과 저장은 확장 설계의 계약입니다.

Nest Outbox · DB와 외부 효과

DB 변경을 먼저 commit하고, 외부 결제는 worker가 별도 처리합니다

본문의 경계 설계를 구체화한 outbox 확장 예시입니다. 재고·주문·outbox를 같은 DB commit에 기록하고, worker가 트랜잭션 밖에서 결제한 뒤 별도 TX에 결과를 기록합니다. 본문에 완성된 worker 구현이 있는 것은 아닙니다.

Outbox를 이용한 주문과 외부 결제 시퀀스 클라이언트 주문을 받은 OrderService가 첫 DB 트랜잭션에서 조건부 재고 감소, 대기 주문, outbox 이벤트를 함께 commit한다. Worker는 outbox를 읽어 트랜잭션 밖에서 멱등 결제 요청을 보내고, 두 번째 DB 트랜잭션에서 결제 결과와 주문 상태를 기록한다. 주문 요청 재고·주문·outbox COMMIT 202 · PENDING outbox claim event charge · idempotent result 결과·주문 상태 COMMIT Client HTTP OrderService Nest provider Database orders · outbox Worker retryable job Payment API external effect DB TX A DB 밖 · 결제 호출 DB TX B
DB 접수와 외부 결제, 결과 저장을 분리한 outbox TX A는 재고·대기 주문·outbox를 함께 commit한 뒤 접수 응답한다. Worker는 outbox를 가져와 DB 트랜잭션 밖에서 결제 제공자가 지원하는 같은 멱등 키로 결제를 요청한다. TX B가 성공하면 COMMIT으로 결과와 주문 상태를 확정한다. 왼쪽 별도 분기는 TX B의 저장 실패이며 로컬 변경을 ROLLBACK하고 같은 이벤트의 외부 결과를 재확인·재처리한다. DB rollback만으로 외부 결제가 취소되지 않는다. 본문 완성 구현이 아닌 확장 설계다. DB 접수 경계 DB 밖 · 네트워크 DB 결과 기록 경계 성공 OrderService 주문 요청 DB TX A 재고 · PENDING 주문 outbox 함께 기록 COMMIT 접수 응답 202 아직 결제 완료 아님 Worker outbox claim · event 외부 결제 API 같은 멱등 키로 요청 DB 트랜잭션 밖 DB TX B 결과 · 주문 상태 저장 COMMIT 성공 결과 · 주문 상태 확정 확정 뒤 후속 처리 저장 실패 TX B ROLLBACK 외부 결과 재확인 외부 취소는 별도 보상

TX A · 주문을 접수한다

  1. 조건부 재고 감소 — 충분한 재고 조건과 원자적 decrement를 한 DB 쓰기로 실행합니다.

  2. 같은 commit — PENDING 주문과 결제 요청 outbox 이벤트를 재고 변경과 같은 트랜잭션에 기록합니다. 하나라도 실패하면 셋 모두 rollback됩니다.

  3. 확정된 상태만 응답 — commit 뒤 주문 접수 상태를 반환합니다. 아직 외부 결제가 완료됐다고 응답하지 않습니다.

네트워크 · TX B · 결과를 확정한다

  1. commit 뒤 호출 — worker가 outbox를 가져온 뒤 DB 트랜잭션 밖에서 결제 API를 호출합니다. 긴 네트워크 대기 동안 DB 잠금을 유지하지 않습니다.

  2. 멱등 요청 — 주문 기반 idempotency key를 사용해 worker 재시도가 중복 결제를 만들지 않도록 합니다.

  3. 결과 저장 — 짧은 두 번째 트랜잭션에서 결제 결과와 주문 상태를 함께 갱신합니다. 외부 성공 뒤 로컬 저장이 실패하면 같은 이벤트를 재시도하고, 되돌려야 하는 효과에는 승인 취소·환불 같은 보상 작업이 필요합니다.

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만으로 외부 결제가 취소되지 않는다. 본문 완성 구현이 아닌 확장 설계다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1DB 트랜잭션과 외부 결제 경계
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
설계안의 첫 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 구현이 있는 것은 아닙니다.

Outbox를 이용한 주문과 외부 결제 시퀀스 클라이언트 주문을 받은 OrderService가 첫 DB 트랜잭션에서 조건부 재고 감소, 대기 주문, outbox 이벤트를 함께 commit한다. Worker는 outbox를 읽어 트랜잭션 밖에서 멱등 결제 요청을 보내고, 두 번째 DB 트랜잭션에서 결제 결과와 주문 상태를 기록한다. 주문 요청 재고·주문·outbox COMMIT 202 · PENDING outbox claim event charge · idempotent result 결과·주문 상태 COMMIT Client HTTP OrderService Nest provider Database orders · outbox Worker retryable job Payment API external effect DB TX A DB 밖 · 결제 호출 DB TX B
DB 접수와 외부 결제, 결과 저장을 분리한 outbox TX A는 재고·대기 주문·outbox를 함께 commit한 뒤 접수 응답한다. Worker는 outbox를 가져와 DB 트랜잭션 밖에서 결제 제공자가 지원하는 같은 멱등 키로 결제를 요청한다. TX B가 성공하면 COMMIT으로 결과와 주문 상태를 확정한다. 왼쪽 별도 분기는 TX B의 저장 실패이며 로컬 변경을 ROLLBACK하고 같은 이벤트의 외부 결과를 재확인·재처리한다. DB rollback만으로 외부 결제가 취소되지 않는다. 본문 완성 구현이 아닌 확장 설계다. DB 접수 경계 DB 밖 · 네트워크 DB 결과 기록 경계 성공 OrderService 주문 요청 DB TX A 재고 · PENDING 주문 outbox 함께 기록 COMMIT 접수 응답 202 아직 결제 완료 아님 Worker outbox claim · event 외부 결제 API 같은 멱등 키로 요청 DB 트랜잭션 밖 DB TX B 결과 · 주문 상태 저장 COMMIT 성공 결과 · 주문 상태 확정 확정 뒤 후속 처리 저장 실패 TX B ROLLBACK 외부 결과 재확인 외부 취소는 별도 보상

TX A · 주문을 접수한다

  1. 조건부 재고 감소 — 충분한 재고 조건과 원자적 decrement를 한 DB 쓰기로 실행합니다.

  2. 같은 commit — PENDING 주문과 결제 요청 outbox 이벤트를 재고 변경과 같은 트랜잭션에 기록합니다. 하나라도 실패하면 셋 모두 rollback됩니다.

  3. 확정된 상태만 응답 — commit 뒤 주문 접수 상태를 반환합니다. 아직 외부 결제가 완료됐다고 응답하지 않습니다.

네트워크 · TX B · 결과를 확정한다

  1. commit 뒤 호출 — worker가 outbox를 가져온 뒤 DB 트랜잭션 밖에서 결제 API를 호출합니다. 긴 네트워크 대기 동안 DB 잠금을 유지하지 않습니다.

  2. 멱등 요청 — 주문 기반 idempotency key를 사용해 worker 재시도가 중복 결제를 만들지 않도록 합니다.

  3. 결과 저장 — 짧은 두 번째 트랜잭션에서 결제 결과와 주문 상태를 함께 갱신합니다. 외부 성공 뒤 로컬 저장이 실패하면 같은 이벤트를 재시도하고, 되돌려야 하는 효과에는 승인 취소·환불 같은 보상 작업이 필요합니다.

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만으로 외부 결제가 취소되지 않는다. 본문 완성 구현이 아닌 확장 설계다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1DB 트랜잭션과 외부 결제 경계
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
설계안의 첫 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 트랜잭션 A에서 OrderService가 조건부 재고 감소, 대기(PENDING) 주문, 아웃박스 이벤트를 함께 커밋하고 202로 접수만 응답합니다. 작업자(worker)는 아웃박스를 가져와 DB 트랜잭션 밖에서 같은 멱등 키로 결제 API를 호출하고, 두 번째 트랜잭션 B에서 결제 결과와 주문 상태를 기록합니다. B의 저장이 실패하면 로컬 변경만 롤백되고 외부 결제의 결과를 다시 확인해야 하며, DB 롤백만으로 외부 결제가 취소되지는 않습니다. 본문에 이 작업자의 완성된 구현이 있는 것은 아닙니다.

트랜잭션은 데이터베이스의 ACID 성질을 보장하는 핵심 장치입니다. 원자성(atomicity)은 트랜잭션의 DB 변경이 모두 커밋되거나 모두 롤백된다는 뜻이며, 외부 API에서 이미 일어난 효과까지 되돌린다는 뜻은 아닙니다. 일관성(consistency)은 커밋 전후에 데이터베이스가 선언한 제약 조건을 만족한다는 뜻이고, 애플리케이션의 규칙은 제약 조건과 올바른 트랜잭션 로직으로 직접 지켜야 합니다. 격리성(isolation, 고립성이라고도 합니다)은 동시에 실행되는 트랜잭션에서 어떤 이상 현상을 허용하거나 막는지를 말하며, 데이터베이스와 격리 수준에 따라 다르고 낮은 수준이 모든 동시 실행을 차례로 실행한 것처럼 보이게 하지는 않습니다. 지속성(durability)은 커밋에 성공한 결과가 데이터베이스가 보장하는 범위에서 장애 뒤에도 남는다는 뜻입니다. 금융 거래나 재고 관리처럼 데이터의 정확성이 중요한 시스템에서는 이 성질들이 꼭 필요합니다.


NestJS에서 트랜잭션 관리하기

Nest Prisma · API와 동시성

쿼리 의존성으로 API를 선택하고, 경쟁하는 값에는 별도 안전장치를 둡니다

앞 쿼리 결과나 앱 로직이 필요하면 interactive transaction의 tx로 실행합니다. 조건부 원자 갱신과 격리 수준, P2034의 제한적 재시도는 API 형식과 별도로 검토합니다.

Prisma 트랜잭션 방식과 동시성 안전장치 의사결정 앞 쿼리 결과나 애플리케이션 로직이 필요 없으면 배열형 트랜잭션을, 필요하면 상호작용적 트랜잭션을 선택한다. 두 방식 모두 한 연결에서 쿼리를 순차 실행하며, 경쟁 재고에는 조건부 원자 감소를 사용하고 Serializable 충돌의 P2034 오류만 제한적으로 재시도한다. 아니요 예 여러 DB 작업 앞 결과·앱 로직이 필요한가? $transaction([q1, q2]) 독립 PrismaPromise 묶음 순차 실행 · 함께 commit/rollback $transaction(async tx) 읽기·판단·쓰기 또는 의존 쿼리 tx만 사용 · 짧게 · network 제외 한 연결은 한 번에 한 쿼리 배열·Promise.all도 순차 실행 병렬성 ≠ 트랜잭션 원자성 경쟁 재고는 원자적으로 감소 stock >= qty · decrement · count read → stock - qty → write 금지 충돌 가능성은 격리와 재시도 Serializable에서 write conflict 감지 P2034만 bounded retry · 그 외 rethrow
쿼리 의존성과 동시성 안전장치를 구분한다 앞 쿼리 결과나 앱 로직이 필요 없으면 배열형, 필요하면 콜백형 tx를 선택한다. 두 방식 모두 같은 연결의 순차 실행이다. 경쟁 재고는 조건부 원자 감소와 건수 확인을 사용하며 격리 수준과 P2034 제한 재시도는 별도 안전장치다. 모든 요청에서 재시도가 발생한다는 뜻은 아니다. 아니요 예 여러 DB 작업 앞 결과·앱 로직 필요? 배열형 불필요 독립 쿼리 콜백형 필요 tx로 실행 같은 연결 한 번에 한 쿼리 전체 commit / rollback 경쟁 재고 조건부 감소 · 건수 확인 충돌 처리 격리 수준 검토 P2034만 제한 재시도

트랜잭션 API 선택

  1. 독립 쿼리 — $transaction([q1, q2])는 배열의 PrismaPromise를 입력 순서대로 실행하고 전체를 함께 commit 또는 rollback합니다. 앞 쿼리에서 생성된 ID를 다음 쿼리에 전달할 수는 없습니다.

  2. 의존 로직 — 앞 쿼리 결과를 검사하거나 그 결과로 다음 쿼리를 만들면 $transaction(async tx => …)를 사용합니다. 모든 DB 접근은 전달받은 tx를 통하고 네트워크 호출은 밖으로 뺍니다.

  3. 더 좁은 API — 관계 생성은 nested write, 같은 형태의 다건 변경은 createMany·updateMany 같은 bulk operation으로 표현할 수 있는지 먼저 확인합니다.

동시성 안전장치

  1. 원자적 재고 차감 — 읽은 stock을 계산해 다시 쓰면 동시 요청이 서로의 변경을 덮을 수 있습니다. stock >= qty 조건과 decrement를 한 쓰기로 실행하고 변경 건수를 확인합니다.

  2. 격리 수준 — 여러 행·제약이 충돌할 수 있는 작업은 데이터베이스가 지원하는 Serializable을 명시해 직렬화 충돌을 성공으로 숨기지 않습니다.

  3. 제한적 재시도 — Prisma가 write conflict 또는 deadlock을 P2034로 반환할 때만 횟수를 제한해 재시도합니다. 품절·검증 오류와 다른 오류는 즉시 다시 던집니다.

트랜잭션은 병렬 실행 도구가 아닙니다. 같은 연결은 한 번에 한 쿼리를 처리하므로 interactive transaction 안의 Promise.all도 쿼리를 직렬로 실행합니다.

이 그림은 Prisma에서 트랜잭션 API를 고르는 기준과 동시성 안전장치를 나눠 보여 줍니다. 앞 쿼리의 결과나 애플리케이션 로직이 필요 없으면 배열형 $transaction([...])을, 필요하면 콜백형 $transaction(async (tx) => ...)를 고르며, 두 방식 모두 같은 연결에서 쿼리를 한 번에 하나씩 차례로 실행합니다. 경쟁하는 재고는 조건부 원자 감소와 변경 건수 확인으로 다루고, 격리 수준과 P2034 오류의 제한된 재시도는 API 형식과 별개의 안전장치로 검토합니다.

NestJS에서 트랜잭션을 다루는 방법은 쓰는 ORM에 따라 다릅니다. 여기서는 TypeORM과 Prisma를 중심으로 살펴봅니다.

TypeORM의 DataSource.transaction()

TypeORM에서 가장 흔한 방법은 비동기 함수를 콜백으로 넘겨, 그 범위 안에서 모든 데이터베이스 작업을 하는 것입니다. 콜백에 전달된 manager로 실행한 작업만 그 트랜잭션에 포함되므로, 전역 manager나 주입받은 일반 리포지토리를 섞어 쓰면 안 됩니다.

아래 코드는 앞 글들의 User CRUD와 별개로 Product, Order, OrderItem을 쓰는 서비스의 발췌입니다. 코드가 참조하는 필드는 Product의 id·stock·price, Order의 id·userId·orderDate, OrderItem의 order 관계와 productId·quantity·price입니다. 세 엔티티의 정의와 DataSource 등록은 이 글에서 제공하지 않으므로, 쓰는 프로젝트에 이 모델이 준비되어 있는지 먼저 확인하고 필드 타입과 관계 매핑도 그 프로젝트에 맞춥니다. 뒤의 Prisma 예제도 이름이 같은 별도의 OrderService이므로, 한 파일에 함께 넣지 말고 쓰는 ORM의 발췌를 골라 읽습니다.

src/order/order.service.ts
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의 작업은 하나의 트랜잭션으로 묶입니다. 콜백이 예외를 던지면 TypeORM이 그 트랜잭션의 변경을 자동으로 롤백하고, 모든 작업이 성공하면 자동으로 커밋합니다.

재고는 stock을 읽어 자바스크립트에서 1을 뺀 값을 저장하지 않습니다. stock > 0 조건과 stock - 1을 SQL 갱신 하나로 실행하므로, 동시에 들어온 주문이 같은 옛 값을 읽고 서로의 차감을 덮어쓰는 손실 갱신(lost update)을 피할 수 있습니다.

TypeORM의 QueryRunner

트랜잭션의 수명을 직접 제어해야 할 때는 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가 참일 때만 롤백을 시도하고, 그 결과와 관계없이 release()를 시도합니다. 정리 단계가 실패해도 처음 오류를 잃지 않도록 errors에 차례로 모으고, 하나뿐이면 그 오류를, 여럿이면 AggregateError로 함께 던집니다.

이 코드는 정리를 시도하는 경로를 보여 줄 뿐, 연결 장애가 났을 때 DB의 최종 상태나 롤백과 해제의 성공까지 보장하지는 않습니다. 커밋에 성공한 뒤 release()만 실패했다면 변경이 취소됐다고 판단하지 않으며, 이 발췌에는 그런 상황의 자동 재시도 정책을 넣지 않았습니다.

Prisma의 상호작용 트랜잭션

Prisma ORM 7은 중첩 쓰기(nested write)와 대량 작업 외에 두 가지 $transaction 형식을 제공하며, 앞 쿼리의 결과나 쿼리 사이의 애플리케이션 로직이 필요한지가 선택 기준입니다.

상호작용 트랜잭션(interactive transaction)은 TypeORM의 dataSource.transaction()처럼 콜백 함수로 동작합니다. 앞 쿼리의 결과를 검사하거나 그 결과로 다음 쿼리를 만들 때 쓰며, 콜백 안의 DB 접근은 모두 전달받은 tx로 실행하고 트랜잭션은 짧게 유지합니다.

이 발췌는 Prisma를 이용한 데이터베이스 작업의 User·Post 스키마와 별개로, Order·Product·OrderItem 모델과 관계를 정의하고 그 스키마로 클라이언트를 생성한 프로젝트를 전제로 합니다. 앞 글의 스키마만으로 생성한 클라이언트에는 Order 타입과 tx.order, tx.product, tx.orderItem이 없으므로, 이 이름을 찾지 못한다면 트랜잭션 호출보다 모델 전제부터 확인합니다.

src/order/order.service.ts
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이 아니면 품절로 보고 트랜잭션 전체를 롤백합니다. Serializable 수준에서 쓰기 충돌이나 교착 상태(deadlock)가 생기면 Prisma가 P2034를 돌려줄 수 있으며, 이 예제는 그 코드에만 횟수를 제한한 재시도를 적용하고 품절과 다른 오류는 다시 던집니다.

Prisma의 순차 트랜잭션

배열형 $transaction([])은 서로 독립된 PrismaPromise 여러 개를 하나의 트랜잭션으로 묶어, 배열에 넣은 순서대로 실행합니다. 동시에 실행하는 API가 아닙니다. 모든 쿼리가 성공하면 커밋하고 하나라도 실패하면 롤백하며, 앞 쿼리에서 데이터베이스가 만든 ID를 다음 쿼리에 넘겨야 한다면 중첩 쓰기, 미리 만든 ID, 상호작용 트랜잭션 가운데 하나를 씁니다.

다음 발췌는 User에 불리언 isActive 필드를 따로 정의하고 그 스키마로 클라이언트를 생성한 프로젝트를 전제로 하며, 앞 글의 User에는 이 필드가 없습니다.

async updateMultipleUsersStatus(ids: number[], isActive: boolean) {
  const queries = ids.map((id) =>
    this.prisma.user.update({
      where: { id },
      data: { isActive },
    }),
  );

  return this.prisma.$transaction(queries);
}

한 트랜잭션은 같은 연결을 쓰고, 그 연결은 한 번에 한 쿼리만 처리합니다. 그래서 상호작용 트랜잭션 안에서 Promise.all()로 쿼리를 감싸도 DB 쿼리가 병렬로 실행되지는 않습니다.


트랜잭션을 쓸 때 주의할 점

트랜잭션은 데이터의 정합성을 지키는 도구이지만, 잘못 쓰면 성능이 떨어지거나 교착 상태가 생깁니다. 필요한 DB 읽기와 쓰기만 묶고, 긴 계산이나 기다림은 잠금과 연결을 오래 붙잡아 충돌 가능성을 높이므로 밖으로 뺍니다.

결제 게이트웨이나 이메일 같은 네트워크 호출은 DB 트랜잭션 안에서 기다리지 않습니다. 주문 변경과 아웃박스 이벤트를 같은 커밋에 기록하고, 작업자가 커밋 뒤에 멱등 키로 외부 작업을 실행하게 할 수 있습니다. find로 읽은 재고에서 값을 빼고 update하는 두 단계는 동시 요청이 서로의 변경을 덮을 수 있으므로, 조건부 updateMany()와 decrement처럼 검사와 계산을 DB 문장 하나로 실행합니다.

지원하는 격리 수준과 기본값은 데이터베이스마다 다릅니다. 충돌 가능성이 높은 작업에 Serializable을 골랐다면 P2034 같은 직렬화 충돌을 제한된 횟수와 대기 시간을 두고 재시도하고, 업무 실패와 다른 오류는 재시도하지 않습니다. 콜백형에서는 실패를 다시 던져 ORM이 롤백하게 하고, 수동 QueryRunner에서는 연결과 시작부터 실패를 잡아 활성 트랜잭션의 롤백과 finally의 해제를 시도한 뒤 원래 오류와 정리 오류를 함께 전달합니다. 트랜잭션의 소요 시간, 커밋과 롤백, 재시도 횟수와 최종 오류 코드는 기록하되 민감한 결제 정보는 로그에 남기지 않습니다.

더 깊이: 기본 고립성 수준과 상호작용 트랜잭션의 시간 제한

격리 수준을 따로 정하지 않은 트랜잭션은 데이터베이스의 기본값으로 실행되며, 그 기본값이 제품마다 다릅니다. PostgreSQL 문서에 따르면 PostgreSQL의 기본값은 Read Committed이고, Read Uncommitted를 요청해도 내부에서는 Read Committed처럼 동작합니다. 반면 MySQL의 InnoDB는 Repeatable Read가 기본값입니다. 그래서 같은 서비스 코드라도 연결한 DB에 따라 한 트랜잭션 안에서 같은 행을 다시 읽은 결과가 달라질 수 있습니다. PostgreSQL에서 Repeatable Read나 Serializable을 고르면 직렬화 실패가 SQLSTATE 40001로 돌아오며, 문서는 이 수준을 쓰는 애플리케이션이 그런 트랜잭션을 다시 시도할 준비를 해야 한다고 말합니다.

Prisma의 상호작용 트랜잭션에는 시간 제한도 있습니다. Prisma 7의 클라이언트 소스는 트랜잭션을 시작하기까지 기다리는 maxWait의 기본값을 2000밀리초로, 콜백 전체에 주어지는 timeout의 기본값을 5000밀리초로 둡니다. 시간이 지나면 Prisma가 트랜잭션을 롤백하고, 그 뒤 같은 tx로 보낸 쿼리는 만료된 트랜잭션이라는 P2028 오류로 실패합니다. 콜백 안에서 느린 외부 API를 기다리면 외부 효과는 일어났는데 DB 변경만 롤백되는 일이 이렇게도 생기므로, 시간이 더 필요한 작업이라면 $transaction(work, { timeout: 10000 })처럼 늘리기 전에 그 작업을 트랜잭션 밖으로 뺄 수 있는지 먼저 확인합니다.


정리

  • 트랜잭션은 여러 DB 작업을 모두 커밋하거나 모두 롤백하는 단위이며, 외부 API에서 일어난 효과는 롤백되지 않습니다.
  • TypeORM은 dataSource.transaction()의 manager나 QueryRunner로, Prisma는 콜백형과 배열형 $transaction으로 트랜잭션을 다룹니다.
  • 경쟁하는 재고는 읽고 계산해 쓰지 말고, 조건부 원자 갱신과 변경 건수 확인으로 처리합니다.
  • P2034 같은 직렬화 충돌만 제한적으로 재시도하고, 외부 효과는 아웃박스와 멱등성으로 커밋 뒤에 처리합니다.

연습 문제

1. 중간 실패에서 돌아오는 재고

상품 A의 재고가 2개, B가 0개이고, 한 주문이 각각 1개씩 요청합니다. 다른 동시 요청은 없고, 모든 DB 쓰기가 같은 manager를 씁니다. TypeORM 예제를 따라가면 최종 재고와 주문 저장 결과는 무엇인가요?

해설 보기

주문을 만든 뒤 A를 1개로 줄여도 아직 커밋한 상태는 아닙니다. B의 조건부 감소가 0건이라 예외를 던지면, 주문 생성과 A의 감소가 함께 롤백됩니다.

최종 재고는 A 2개, B 0개이고, 이 주문과 주문 항목도 남지 않습니다. A의 쓰기가 먼저 실행됐다는 사실만으로 그 감소가 확정되지는 않습니다.

2. 같은 상품을 두 번 요청하면

처음 재고가 A 1개이고, 한 주문의 입력 배열에 A가 두 번 들어 있습니다. 반복마다 stock > 0인 행만 원자적으로 줄이고, 변경 건수가 1이 아니면 예외를 던집니다. 두 항목 모두 같은 트랜잭션에 있고 다른 요청은 없을 때, 최종 재고와 주문 저장 결과는 무엇인가요? 이 실패를 P2034처럼 재시도해도 되나요?

해설 보기

첫 감소는 재고를 0개로 만들지만, 두 번째 감소는 조건을 만족하지 않아 0건이 됩니다. 예외로 전체 트랜잭션이 롤백되므로 최종 재고는 1개이고, 해당 주문과 항목은 저장되지 않습니다.

재고 부족은 동시성 충돌인 P2034와 다른 실패이므로, 같은 주문을 무조건 재시도할 이유가 되지 않습니다. 입력의 중복 상품을 합칠지 거절할지는 별도의 업무 규칙으로 정할 수 있습니다.

3. 배열형 트랜잭션에서 생성 ID 넘기기

배열형 $transaction([...])로 첫 쿼리에서 주문을 만들고, 둘째 쿼리에서 그 주문의 ID로 주문 항목을 만들려고 합니다. 이 방식이 가능한가요? 안 된다면 어떤 방법을 쓰나요?

해설 보기

가능하지 않습니다. 배열형 트랜잭션은 이미 만들어 둔 PrismaPromise들을 순서대로 실행할 뿐이므로, 배열을 만드는 시점에는 첫 쿼리가 돌려줄 ID를 알 수 없습니다.

주문과 항목을 함께 만드는 중첩 쓰기(order.create의 data에 items: { create: [...] })를 쓰거나, ID를 미리 만들어 두거나, 콜백형 상호작용 트랜잭션에서 첫 결과를 받아 다음 쿼리를 만듭니다.

4. 커밋 뒤 해제만 실패하면

QueryRunner 예제에서 두 작업과 commitTransaction()은 성공했지만, finally의 release()가 오류를 던졌습니다. complexTransaction()은 어떻게 끝나고, DB의 변경은 어떻게 되나요? 호출하는 쪽은 이 오류를 어떻게 해석해야 하나요?

해설 보기

catch에 들어가지 않았으므로 롤백은 시도되지 않고, errors에는 해제 오류 하나만 들어 있어 그 오류가 그대로 던져집니다. 그러나 커밋은 이미 성공했으므로 두 작업의 변경은 DB에 남아 있습니다.

호출하는 쪽은 오류를 받았다는 사실만으로 작업이 실패해 취소됐다고 판단하면 안 됩니다. 같은 작업을 다시 실행하면 변경이 두 번 적용될 수 있으므로, 결과를 조회해 확인하거나 멱등 키로 중복을 막는 방법이 필요합니다.

이것으로 3장을 마칩니다. 3장에서는 TypeORM의 엔티티·리포지토리·데이터소스, Mongoose의 스키마와 모델, Prisma의 스키마·마이그레이션·생성 클라이언트를 Nest 모듈에 연결하고, 여러 쓰기를 하나로 묶는 트랜잭션과 동시성 안전장치까지 살펴봤습니다. 다음 장의 Passport를 이용한 인증 구현부터는 저장한 사용자로 로그인을 처리하고, 요청마다 사용자를 확인하는 인증과 권한 부여를 다룹니다.