콘텐츠로 이동

Transaction Basics

분류: Layer 8 - 데이터베이스 심화 | 선수지식: RDS Basics

트랜잭션은 “여러 작업을 하나의 단위로 묶어서, 전부 성공하거나 전부 실패하게 만드는” 데이터 처리 방식이다.

“A 계좌에서 돈을 빼고 B 계좌에 돈을 넣는” 작업에서, 중간에 실패하면 돈이 사라진다. 트랜잭션을 이해하지 못하면 데이터가 꼬이는 버그를 만들거나, 장애 상황에서 데이터 정합성 문제를 진단할 수 없다.

프론트엔드 개발자를 위한 브릿지: React에서 폼을 제출할 때 API가 500 에러를 반환하는 경우, 서버 내부에서는 트랜잭션 롤백이 일어난 것이다. 예를 들어 “주문 생성 → 재고 차감” 중 재고 차감에서 실패하면, 트랜잭션이 전체를 되돌려서 주문도 생성되지 않은 것처럼 처리한다. 프론트에서 500을 받았다면 DB 상태는 변경 전과 동일하게 안전하다.

부분 처리의 한계에서 트랜잭션이 등장한 이유

RDS Basics에서 배운 데이터베이스는 테이블·인덱스·스토리지 엔진을 제공하지만, 애플리케이션이 INSERT ordersUPDATE products SET stock = stock - 1을 별도 요청으로 보내면 “두 작업이 반드시 함께 성공해야 한다”는 업무 규칙까지 자동으로 묶어주지는 않는다. 네트워크 타임아웃, 프로세스 종료, 제약 조건 오류가 두 SQL 사이에 끼면 주문은 생겼는데 재고는 줄지 않는 부분 처리 상태가 남는다. 이 문서의 lineage_oneliner처럼 트랜잭션은 이 부분 처리 문제를 All-or-Nothing 단위로 바꾸기 위해 등장한다.

해결 메커니즘은 세 겹이다. BEGIN은 여러 SQL을 하나의 커밋 후보로 묶고, COMMIT 전 오류는 ROLLBACK으로 후보 전체를 폐기한다. PostgreSQL 공식 문서는 WAL의 핵심을 “데이터 파일 변경 전에 WAL 레코드를 먼저 영구 저장”하는 방식으로 설명하며, 커밋 보장을 위해 모든 데이터 파일이 아니라 WAL 파일만 디스크에 flush하면 된다고 설명한다(출처: https://www.postgresql.org/docs/current/wal-intro.html). 동시에 Lock과 MVCC가 같은 행을 동시에 바꾸는 요청의 순서와 가시성을 제어한다. 트랜잭션이 없다면 장애 복구는 “로그 보고 수동 보정”이 되고, 트랜잭션이 있으면 DB가 커밋된 단위와 버릴 단위를 기계적으로 구분한다.

트랜잭션이 동작하는 방식 (전체 흐름)

섹션 제목: “트랜잭션이 동작하는 방식 (전체 흐름)”

비유: 계좌이체와 같다.

계좌이체 작업 (A → B로 10만원):
1. A 계좌 잔액 조회: 50만원
2. A 계좌 -10만원 → 40만원
3. ← 여기서 서버가 죽으면? (트랜잭션 없으면 A에서만 돈이 빠짐!)
4. B 계좌 +10만원 → 60만원
트랜잭션 있음: 2번에서 DB가 죽으면 → 자동 Rollback → A는 여전히 50만원
트랜잭션 없음: 2번에서 DB가 죽으면 → A: 40만원, B: 50만원 → 10만원 증발

원리: DB는 내부적으로 어떻게 트랜잭션을 보장하는가

  1. WAL (Write-Ahead Logging): 실제 데이터를 수정하기 전에 먼저 “무엇을 할 것인지”를 로그에 기록. 장애 시 이 로그로 복구.
  2. Lock: 같은 행을 동시에 수정하지 못하도록 잠금. INSERT/UPDATE 시 해당 행에 배타적 잠금(Exclusive Lock) 걸림.
  3. MVCC (Multi-Version Concurrency Control): PostgreSQL이 사용하는 방식. 행을 잠그는 대신 여러 버전을 유지해서 읽기 작업이 쓰기 작업을 차단하지 않도록 함.

왜 이렇게 설계되었는가 — MVCC의 철학

전통적인 Lock 기반 DB는 읽기와 쓰기가 서로를 차단한다. “A가 행을 수정하는 동안 B는 읽기도 기다려야” 하는 것이다. PostgreSQL의 MVCC는 이 문제를 해결하기 위해 “행의 여러 버전을 동시에 유지”하는 전략을 택했다. 쓰기 트랜잭션이 새 버전을 만드는 동안 읽기 트랜잭션은 이전 버전을 읽는다. 이 덕분에 Read Committed 격리 수준에서 읽기가 쓰기를 차단하지 않아 동시성이 높아진다. 단점은 오래된 버전(dead tuple)이 쌓여 VACUUM이 필요하다는 것이다.

MVCC 내부 동작 — xmin/xmax로 행 버전 관리

PostgreSQL MVCC는 각 행(tuple)에 숨겨진 시스템 컬럼 두 개로 가시성을 제어한다.

-- xmin, xmax 컬럼 직접 확인 (일반 SELECT에는 안 보이는 숨겨진 컬럼)
SELECT xmin, xmax, id, balance FROM accounts WHERE id = 1;
-- 예상 출력:
-- xmin | xmax | id | balance
-- -------+------+-----+---------
-- 12345 | 0 | 1 | 500000
-- xmin=12345: 이 행을 생성(INSERT)한 트랜잭션 ID
-- xmax=0: 아직 삭제/수정되지 않음 (0 = 유효한 행)

UPDATE가 일어날 때 MVCC 동작:

-- 트랜잭션 99999가 balance를 400000으로 UPDATE 하는 경우:
1. 기존 행: xmin=12345, xmax=0, balance=500000
→ xmax=99999로 마킹 ("이 행은 트랜잭션 99999가 무효화했다")
2. 새 행 생성: xmin=99999, xmax=0, balance=400000
→ 새로 삽입된 버전
3. 커밋 전 다른 트랜잭션이 SELECT하면?
→ xmax=99999인 기존 행을 본다 (아직 커밋 안 됨, 99999는 진행 중)
→ Read Committed: 99999가 아직 진행 중이면 기존 500000을 읽음
4. 커밋 후:
→ xmax=99999인 행: dead tuple (죽은 행, 더 이상 visible하지 않음)
→ xmin=99999인 행: 이제 visible (새 버전)

이 dead tuple들이 쌓이면 VACUUM이 주기적으로 청소한다. RDS에서는 autovacuum이 자동으로 실행된다.

📖 더 보기: PostgreSQL MVCC Internals: From xmin/xmax to Isolation Levels - DEV Community — xmin/xmax 필드부터 dead tuple, VACUUM까지 PostgreSQL MVCC를 코드 수준으로 분석 (중급)


Deadlock 방지의 핵심 원칙 — 항상 같은 순서로 잠금 획득

두 트랜잭션이 서로 다른 순서로 동일한 행에 락을 걸면 교착 상태(Deadlock)가 발생한다. PostgreSQL은 ERROR: deadlock detected (40P01)을 반환하며 둘 중 하나를 강제 롤백한다. 방지 원칙은 단순하다: 여러 행에 락을 걸어야 할 때 항상 동일한 순서(예: id 오름차순) 로 잠금을 획득한다. 재시도 로직과 구체적인 TypeORM 구현은 트러블슈팅 섹션을 참고하세요.

WAL과 Commit의 관계 — 왜 커밋해야 저장되는가:

BEGIN 실행
→ DB: "트랜잭션 시작. 변경사항은 아직 임시 공간에만 저장"
UPDATE accounts SET balance = 40만원 WHERE id = 1
→ DB: WAL에 "accounts id=1의 balance를 40만원으로 변경할 것"을 기록
→ 실제 데이터 파일은 아직 변경 안 됨
COMMIT
→ DB: WAL을 디스크에 확정 저장 → 실제 데이터 파일 업데이트
→ "이 변경은 영구히 반영됨"

만약 COMMIT 전에 서버가 죽으면 → WAL에 커밋 마크가 없음 → 재시작 시 롤백 처리.

PostgreSQL WAL 파일 구조 (참고):

WAL 파일 위치: $PGDATA/pg_wal/ 디렉토리
파일 명명: 000000010000000000000001 (16진수)
파일 크기: 기본 16MB씩 분할 저장
크래시 복구 과정:
1. PostgreSQL 재시작
2. 마지막 체크포인트(checkpoint) 위치 찾기
3. 체크포인트 이후의 WAL 레코드를 순서대로 재실행
4. 커밋 마크 있는 트랜잭션만 반영, 없는 것은 롤백
→ 데이터베이스가 일관된 상태로 복구됨

📖 더 보기: PostgreSQL WAL - The backbone of reliable transaction logging — WAL 파일 구조, 체크포인트, 크래시 복구 흐름을 2025년 기준으로 상세 설명 (중급)

📖 더 보기: ACID Databases Explained - FreeCodeCamp — 위 WAL, Lock, MVCC 동작 원리를 시각적으로 설명, Commit/Rollback 흐름 다이어그램 제공

WAL·MVCC 개념의 전이 — 같은 원리가 다른 시스템에도 적용된다

WAL과 MVCC는 PostgreSQL만의 개념이 아니다. 동일한 설계 철학이 여러 시스템에 재사용된다.

시스템PostgreSQL WAL·MVCC와 대응되는 개념차이점
KafkaPartition = 순서가 보장된 Append-Only Log. 메시지가 추가되면 절대 수정되지 않음WAL은 복구용 내부 로그, Kafka는 외부 소비자가 직접 읽는 이벤트 스트림
Redis AOFAppend Only File: 모든 쓰기 명령을 순서대로 기록 후 재시작 시 재실행 → WAL의 크래시 복구와 동일한 패턴Redis AOF는 명령 단위 로그, PostgreSQL WAL은 페이지 변경 단위
Event Sourcing상태(state)를 저장하는 대신 상태 변경 이벤트(event)를 Append-Only로 저장 — MVCC의 “여러 버전 유지”와 동일한 철학MVCC는 DB 내부 버전 관리, Event Sourcing은 애플리케이션 레벨 이벤트 히스토리

실무 전이 포인트: Kafka 파티션 구조를 이해하는 데 WAL 개념이 직접 도움된다. “Kafka는 왜 메시지를 수정하지 않는가?” → “WAL이 Append-Only인 이유와 같다: 순차 쓰기가 랜덤 쓰기보다 빠르고, 이전 상태로의 복구가 가능해야 하기 때문이다.”

새 시스템에서 같은 패턴을 빠르게 식별하는 4가지 질문

문서·블로그·소스 코드에서 처음 보는 데이터 시스템을 만났을 때, 다음 질문 4개를 차례로 던지면 WAL/MVCC 계열인지 즉시 분류할 수 있다.

  1. 변경을 즉시 적용하는가, 별도 로그에 먼저 기록한 후 적용하는가? → 후자면 WAL 패턴
  2. 같은 키에 대한 동시 읽기·쓰기가 서로 차단되는가? → 차단되지 않으면 MVCC 또는 그 변종
  3. 이전 상태가 일정 시점까지 보존되는가? (스냅샷·버전·이벤트 히스토리) → MVCC 또는 Event Sourcing
  4. dead version·tombstone 같은 “정리(garbage collection)” 작업이 별도로 필요한가? → MVCC 계열의 부산물

적용 예시 (SQLite WAL 모드): 이 4질문을 SQLite의 journal_mode=WAL에 적용해 보면 — (1) ✓ 변경이 -wal 파일에 먼저 누적된 뒤 메인 db 파일에 체크포인트 시점에 반영된다 (출처: SQLite WAL 공식 문서). (2) ✓ 읽기는 메인 db에서, 쓰기는 -wal에 누적되어 reader-writer가 서로 차단되지 않는다. (3) 부분 ✓ — 트랜잭션이 끝날 때까지 이전 페이지가 보존된다. (4) ✓ 체크포인트가 dead 페이지를 정리한다. 즉 PostgreSQL과 구현체는 다르지만 같은 두 원리(WAL+MVCC-like)가 함께 작동한다고 즉시 분류 가능.

이 4질문이 도구가 되는 이유는 단일 도메인에서 외운 지식이 아니라 “어떤 트레이드오프가 따라오는가”(순차 쓰기 비용, GC 비용, 격리 수준 옵션 범위)까지 함께 떠올릴 수 있게 해주기 때문이다.

출처: The Write-Ahead Log: A Foundation for Reliability in Databases and Distributed Systems, Martin Kleppmann - Using logs to build a solid data infrastructure


ACID 속성

트랜잭션이 지켜야 할 4가지 성질:

속성의미위반 시 발생하는 문제
Atomicity(원자성)전부 성공 또는 전부 실패. 중간 상태 없음.부분 처리로 데이터 불일치 (돈이 증발/복제)
Consistency(일관성)트랜잭션 전후로 데이터가 규칙에 맞는 상태잔액이 음수가 되거나 외래키 제약 위반
Isolation(격리성)동시 트랜잭션이 서로 간섭하지 않음더티 리드, 팬텀 리드 등 동시성 문제
Durability(지속성)커밋된 데이터는 장애에도 보존장애 후 재시작 시 커밋된 데이터가 사라짐

Commit과 Rollback

  • Commit: “이 작업들을 확정해라” → 데이터가 실제로 저장됨. WAL에 기록 후 적용.
  • Rollback: “이 작업들을 취소해라” → 트랜잭션 시작 전 상태로 되돌림.
-- 직접 SQL로 실험해볼 수 있는 예시 (PostgreSQL)
BEGIN;
UPDATE accounts SET balance = balance - 100000 WHERE id = 1;
-- 이 시점에서 SELECT하면 변경된 값이 보임 (같은 트랜잭션 내에서)
UPDATE accounts SET balance = balance + 100000 WHERE id = 2;
COMMIT; -- 확정 (또는 ROLLBACK으로 취소)
-- ROLLBACK 테스트:
BEGIN;
DELETE FROM orders WHERE id = 9999;
ROLLBACK; -- 삭제 취소 — orders 테이블은 그대로

격리 수준(Isolation Level)

동시에 여러 트랜잭션이 실행될 때 어느 정도까지 서로를 볼 수 있는지:

격리 수준Dirty ReadNon-Repeatable ReadPhantom Read성능
Read Uncommitted발생발생발생가장 빠름
Read Committed방지발생발생빠름
Repeatable Read방지방지발생중간
Serializable방지방지방지가장 느림
  • PostgreSQL 기본값: Read Committed
  • MySQL InnoDB 기본값: Repeatable Read

실무에서 격리 수준을 변경하는 경우는 드물지만, 기본값이 항상 안전하다는 뜻은 아니다. PostgreSQL 공식 문서 기준으로 Read Committed는 기본값이며 각 SELECT가 “쿼리 시작 시점”의 스냅샷을 본다. 그래서 같은 트랜잭션 안의 두 SELECT가 다른 값을 볼 수 있고, UPDATE ... WHERE ...는 기다린 뒤 최신 행 버전에 조건을 다시 적용한다(출처: https://www.postgresql.org/docs/current/transaction-iso.html). 단일 계좌 행처럼 대상 행이 명확한 업데이트에는 이 동작이 보통 충분하지만, “두 테이블 합계가 항상 0이어야 한다”, “좌석 N개 중 최대 1개만 배정”처럼 여러 행을 읽고 업무 규칙을 판단한 뒤 쓰는 흐름은 Read Committed에서 silent failure가 날 수 있다. 에러는 없지만 두 요청이 각각 “가능하다”고 판단하고 모두 커밋하는 식이다.

격리 수준 결정은 다음 순서로 한다. 먼저 기본값으로 두고 SELECT ... FOR UPDATE나 유니크 제약으로 충돌 지점을 명시할 수 있는지 본다. 한 트랜잭션 안에서 같은 조회 결과가 계속 같아야 하는 리포트·정산 작업이면 Repeatable Read를 검토하되, PostgreSQL은 could not serialize access due to concurrent update가 발생할 수 있으므로 전체 트랜잭션 재시도를 준비한다. 여러 행/테이블의 불변식을 DB가 검증해야 하면 Serializable을 검토하고, PostgreSQL 공식 문서가 안내하듯 40001 serialization failure를 일반 재시도 경로로 처리한다. 즉 격리 수준 상향은 “성능을 더 써서 안전해진다”가 아니라 “실패 모드가 lock 대기/재시도/rollback으로 바뀐다”는 운영 결정이다.

TypeORM에서 격리 수준 명시적 설정:

// 중요한 금융 트랜잭션에서 격리 수준 명시 권장
await dataSource.transaction("SERIALIZABLE", async (manager) => {
// Serializable: 완전한 격리, 팬텀 리드 방지
const account = await manager.findOne(Account, { where: { id: 1 } });
await manager.update(
Account,
{ id: 1 },
{ balance: account.balance - 100000 },
);
});
// 기본 (Read Committed)
await dataSource.transaction(async (manager) => {
// 격리 수준을 명시하지 않으면 DB 기본값(PostgreSQL: Read Committed) 사용
await manager.save(Order, orderData);
});

NestJS + TypeORM에서 트랜잭션 사용: 두 가지 방법 비교

방법 1: dataSource.transaction() — 간결, 간단한 트랜잭션에 적합

// DataSource.transaction()으로 여러 작업을 하나의 단위로 묶기
@Injectable()
export class OrderService {
constructor(private dataSource: DataSource) {}
async createOrder(dto: CreateOrderDto) {
return this.dataSource.transaction(async (manager) => {
// 1. 재고 확인 (FOR UPDATE로 잠금 — 동시 주문 방지)
const product = await manager.findOne(Product, {
where: { id: dto.productId },
lock: { mode: "pessimistic_write" },
});
if (product.stock < dto.quantity) {
throw new BadRequestException("재고 부족");
// 예외 발생 → 자동 Rollback
}
// 2. 재고 차감
await manager.decrement(
Product,
{ id: dto.productId },
"stock",
dto.quantity,
);
// 3. 주문 생성
const order = await manager.save(Order, {
userId: dto.userId,
productId: dto.productId,
quantity: dto.quantity,
status: "pending",
});
return order;
// 여기까지 오면 자동 Commit
});
}
}

예상 동작:

성공 시: Product.stock -= quantity, Order 생성 → Commit
재고 부족 시: BadRequestException → 자동 Rollback → 재고/주문 변경 없음
DB 오류 시: QueryFailedError → 자동 Rollback → 원래 상태 유지

TypeORM 공식 문서의 핵심 제약은 transaction() 콜백 안의 모든 작업이 제공된 transactionalEntityManager를 사용해야 한다는 점이다(출처: https://typeorm.io/docs/advanced-topics/transactions/). 이 규칙을 어기고 this.orderRepository.save()를 섞으면 코드상 트랜잭션 블록 안에 있어도 실제로는 다른 커넥션에서 실행될 수 있다. 겉으로는 예외도 없고 주문 ID도 반환되지만, 롤백 시 일부 테이블만 남는 silent failure가 발생한다. 따라서 트랜잭션 내부 코드는 리뷰할 때 manager. 호출만 있는지 먼저 본다.

방법 2: QueryRunner — 복잡한 트랜잭션, 세밀한 제어가 필요할 때

// QueryRunner: 트랜잭션 생명주기를 직접 제어 (장점: 유연성, 단점: 코드량 많음)
async createOrderWithQueryRunner(dto: CreateOrderDto) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const product = await queryRunner.manager.findOne(Product, {
where: { id: dto.productId },
lock: { mode: 'pessimistic_write' },
});
await queryRunner.manager.decrement(Product, { id: dto.productId }, 'stock', dto.quantity);
const order = await queryRunner.manager.save(Order, { ...dto, status: 'pending' });
await queryRunner.commitTransaction();
return order;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release(); // ← 반드시 release! 안 하면 커넥션 풀 고갈
}
}

두 방법 비교:

항목dataSource.transaction()QueryRunner
코드 간결성간결함보일러플레이트 많음
에러 처리자동 Rollback직접 try/catch/finally 작성
복잡한 흐름중첩 어려움유연하게 제어 가능
실수 위험낮음release() 누락 시 커넥션 풀 고갈
권장 상황대부분의 경우멀티 DB, 조건부 커밋/롤백 등 복잡한 경우

📖 더 보기: TypeORM Transactions with NestJS - Medium — 위 dataSource.transaction()과 QueryRunner 두 가지 방식 비교 구현 가이드


Deadlock (교착상태)

두 트랜잭션이 서로가 가진 잠금(lock)을 기다리며 영원히 멈추는 상태. DB가 자동으로 하나를 강제 롤백한다.

트랜잭션 A: 트랜잭션 B:
LOCK Row 1 (성공) LOCK Row 2 (성공)
LOCK Row 2 대기... → LOCK Row 1 대기... →
↑ ↑
└──── 서로 기다림 → Deadlock ──┘
DB: "A를 강제 Rollback하고 B 진행"
→ PostgreSQL 에러 코드: 40P01

Deadlock 방지법: 여러 트랜잭션에서 항상 동일한 순서로 잠금을 획득한다.

패턴 1: @Transactional 데코레이터 패턴 (프로덕션 권장)

typeorm-transactional 라이브러리를 사용하면 메서드에 데코레이터를 붙이는 것만으로 트랜잭션을 관리할 수 있다. 2025년 현재 수백만 건 트랜잭션을 처리하는 프로덕션 환경에서 검증된 패턴이다.

// npm install typeorm-transactional
// main.ts에서 반드시 먼저 초기화 (NestFactory.create() 전!)
import { initializeTransactionalContext } from "typeorm-transactional";
initializeTransactionalContext(); // ← 이게 먼저여야 함
// order.service.ts
import { Transactional, IsolationLevel } from "typeorm-transactional";
@Injectable()
export class OrderService {
constructor(
@InjectRepository(Order) private orderRepo: Repository<Order>,
@InjectRepository(Product) private productRepo: Repository<Product>,
) {}
@Transactional() // 이 메서드 전체가 하나의 트랜잭션
async createOrder(dto: CreateOrderDto) {
// this.orderRepo와 this.productRepo가 자동으로 같은 트랜잭션 사용
const product = await this.productRepo.findOne({
where: { id: dto.productId },
});
if (product.stock < dto.quantity) {
throw new BadRequestException("재고 부족"); // 자동 Rollback
}
await this.productRepo.decrement(
{ id: dto.productId },
"stock",
dto.quantity,
);
return this.orderRepo.save({ ...dto, status: "pending" });
// 메서드 종료 시 자동 Commit
}
@Transactional({ isolationLevel: IsolationLevel.SERIALIZABLE })
async transferMoney(fromId: number, toId: number, amount: number) {
// 금융 트랜잭션: SERIALIZABLE 격리 수준 명시
await this.accountRepo.decrement({ id: fromId }, "balance", amount);
await this.accountRepo.increment({ id: toId }, "balance", amount);
}
}

장점: 비즈니스 로직에 트랜잭션 코드가 섞이지 않아 가독성 향상. @InjectRepository로 주입한 Repository가 자동으로 같은 트랜잭션 컨텍스트를 공유함.

패턴 2: 마이그레이션 트랜잭션 전략 (프로덕션 안전성)

TypeORM 마이그레이션을 프로덕션에 안전하게 적용하는 트랜잭션 설정:

Terminal window
# 기본값: 모든 마이그레이션을 단일 트랜잭션으로 실행
npm run typeorm migration:run
# 각 마이그레이션을 독립 트랜잭션으로 실행 (대용량 데이터 변경 시 권장)
npm run typeorm migration:run -- --transaction each
# CREATE INDEX CONCURRENTLY 등 트랜잭션 안에서 실행 불가한 작업이 있을 때
npm run typeorm migration:run -- --transaction none

중요: synchronize: true는 프로덕션에서 절대 사용하지 말 것 — 의도치 않은 스키마 변경으로 데이터 손실 가능.

패턴 3: 트랜잭션 범위 최소화 원칙

장기 트랜잭션은 다른 요청들의 Lock 대기를 유발한다. 트랜잭션 범위를 DB 작업만으로 최소화하는 것이 핵심이다.

// ❌ 잘못된 패턴: 외부 API 호출이 트랜잭션 안에 있음
await dataSource.transaction(async (manager) => {
const order = await manager.save(Order, orderData); // DB 작업
const result = await externalPaymentApi.charge(amount); // 외부 API (느림!)
await manager.update(Order, order.id, { paymentId: result.id }); // DB 작업
}); // 외부 API 응답 대기 동안 Lock 점유 → 다른 요청 대기
// ✅ 올바른 패턴: 외부 API를 트랜잭션 밖으로 이동
const order = await orderRepo.save(orderData); // 트랜잭션 없이 저장
const result = await externalPaymentApi.charge(amount); // 트랜잭션 밖에서 호출
await dataSource.transaction(async (manager) => {
// DB 작업만 트랜잭션에 포함
await manager.update(Order, order.id, {
paymentId: result.id,
status: "paid",
});
}); // Lock 점유 시간 최소화

이 분리는 단순 성능 팁이 아니라 실패 경계 결정이다. 결제 API가 성공했는데 DB 커밋이 실패하면 “환불/취소”라는 보상 흐름이 필요하고, DB 커밋 후 이벤트 발행이 실패하면 Outbox Pattern처럼 커밋된 이벤트를 다시 발행하는 장치가 필요하다. 반대로 외부 API 호출을 트랜잭션 안에 넣으면 장애 시 보상 로직은 줄어도 lock 점유 시간이 외부 API p99만큼 늘어난다. 운영에서는 pg_stat_activity에서 wait_event_type = 'Lock'이 늘거나, PostgreSQL 공식 기본값처럼 statement_timeout, lock_timeout, idle_in_transaction_session_timeout0으로 비활성화된 상태라면 긴 트랜잭션이 무기한 대기로 보일 수 있다(출처: https://www.postgresql.org/docs/current/runtime-config-client.html).

  • 결제 처리 (주문 생성 + 재고 감소 + 결제 확인을 하나로)
  • 사용자 가입 (계정 생성 + 프로필 생성 + 초기 설정을 하나로)
  • 배치 데이터 처리 (여러 레코드를 한 번에 업데이트)
  • 데이터 마이그레이션 (안전하게 변환 후 커밋)
  • 데이터 정합성 이슈 발생 시 트랜잭션이 제대로 처리됐는지 확인
  • Deadlock 에러 로그 발생 시 원인 분석
  • DB 성능 이슈 시 격리 수준과 잠금(lock) 문제 의심
  • 새 기능 개발 시 “이 작업들이 하나의 트랜잭션이어야 하는지” 판단
개념 A개념 B차이점
CommitRollbackCommit은 확정, Rollback은 취소
LockTransactionLock은 데이터 잠금 메커니즘, Transaction은 작업 묶음 (Lock을 사용함)
Optimistic LockPessimistic LockOptimistic은 충돌 시 재시도, Pessimistic은 미리 잠그고 시작 — 선택 기준은 충돌 빈도와 재시도 비용 (아래 참고)
ACIDBASEACID는 강한 일관성(RDB), BASE는 최종 일관성(NoSQL)
dataSource.transaction()QueryRunner전자는 간결함, 후자는 세밀한 제어 가능 (둘 다 TypeORM 트랜잭션 방법)
@TransactionaldataSource.transaction()데코레이터는 메서드 레벨 선언, dataSource는 코드 레벨 제어

Optimistic vs Pessimistic Lock — 경계 조건과 선택 기준

Martin Fowler의 PoEAA(Patterns of Enterprise Application Architecture)에서 제시한 판단 기준:

“Optimistic Lock을 먼저 시도하라. 단, 긴 비즈니스 트랜잭션에서 충돌이 잦으면 Pessimistic Lock으로 전환하라.” — Pessimistic Offline Lock, martinfowler.com

판단 기준Optimistic Lock 선택Pessimistic Lock 선택
충돌 빈도낮음 (사용자가 같은 행을 동시에 수정하는 일이 드뭄)높음 (동시 주문, 좌석 예약 등 동일 행 경쟁이 빈번)
재시도 비용낮음 (재시도 작업이 빠르고 부작용 없음)높음 (실패 시 사용자가 긴 작업을 처음부터 다시 해야 함)
트랜잭션 길이짧음길거나 여러 단계를 거침
Lock 대기 허용 여부대기 없이 낙관적 진행 후 충돌 시 재시도선점 잠금 → 다른 트랜잭션은 대기
실무 예시CMS 문서 편집, 프로필 정보 수정재고 차감, 티켓 예약, 금융 계좌 이체

핵심 경계 조건: 충돌 빈도가 높은데 Optimistic Lock을 쓰면 → 대부분의 트랜잭션이 OptimisticLockVersionMismatchError로 실패하고 재시도 폭풍(retry storm)이 발생한다. 반대로 충돌이 드문데 Pessimistic Lock을 쓰면 → 불필요한 SELECT ... FOR UPDATE 대기로 처리량(throughput)이 감소한다.

측정 기반 결정 절차 — 충돌률 5~10% 임계점

실무 보고와 학술 벤치마크에서 일관되게 등장하는 임계점이 있다. 충돌률(전체 트랜잭션 중 동일 행에 대한 동시 수정 시도의 비율)이 약 5~10% 미만이면 Optimistic이, 그 이상이면 Pessimistic이 throughput 우위를 가진다. OptiQL 연구(SFU, 2024)는 write-heavy + 고경합 워크로드에서도 잘 설계된 Optimistic 구현이 Pessimistic 대비 2배 이상 throughput을 낼 수 있음을 보고했지만, 이는 backoff·재시도 비용을 충분히 흡수할 수 있을 때의 이야기다 (출처: OptiQL: Robust Optimistic Locking for Memory-Optimized Indexes).

본인 시스템에서 한쪽을 결정할 때 권장 측정 절차:

  1. 1주일 충돌률 측정 — Optimistic 코드 경로의 OptimisticLockVersionMismatchError 발생 수 ÷ 해당 엔드포인트 트랜잭션 총수. 5% 미만이면 그대로 유지.
  2. 5% 이상이면 Pessimistic 후보 검토 — 단, 단순 전환 전에 pg_stat_activity에서 wait_event = 'transactionid' 비율을 베이스라인으로 측정한다. 전환 후 lock wait 시간이 늘어나 throughput이 더 떨어지는 사례가 자주 보고되므로, 카나리 비율(예: 10%)로 한 주 비교 후 전체 적용.
  3. 재시도 backoff 의무화 — Optimistic을 유지하든 Pessimistic으로 전환하든, retry는 50~150ms 랜덤 지터를 적용해 retry storm을 차단한다 (이 문서의 시나리오 B DeadlockRetryInterceptor 코드와 동일한 패턴).

이 3단계로 결정하면 “충돌률 / 재시도 비용 / lock wait time” 3축이 본인 데이터에 맞춰진 결정이 된다 — 위 표의 일반 가이드와 본인 시스템 실측의 차이를 직접 발견할 수 있다.

🔧 TypeORM에서 “deadlock detected” 에러가 발생한다

섹션 제목: “🔧 TypeORM에서 “deadlock detected” 에러가 발생한다”

증상: QueryFailedError: deadlock detected 로그 발생, 일부 주문이 실패 원인: 두 트랜잭션이 같은 행들을 반대 순서로 잠금 획득 시도 (예: 트랜잭션A는 Product→Order 순서, B는 Order→Product 순서) 해결:

  1. 코드에서 모든 트랜잭션이 항상 동일한 순서로 잠금 획득하도록 통일
  2. Deadlock 발생 시 재시도 로직 추가:
    try {
    return await this.dataSource.transaction(async (manager) => { ... });
    } catch (error) {
    if (error.driverError?.code === '40P01') { // PostgreSQL deadlock 에러 코드
    // 짧은 대기 후 재시도
    await new Promise(resolve => setTimeout(resolve, 100));
    return this.createOrder(dto); // 재귀 재시도
    }
    throw error;
    }

🔧 트랜잭션을 걸었는데도 데이터가 꼬인다

섹션 제목: “🔧 트랜잭션을 걸었는데도 데이터가 꼬인다”

증상: dataSource.transaction() 안에서 처리했는데 중간 실패 시 일부 데이터만 변경됨 원인: 트랜잭션 manager 대신 원본 repository를 사용하고 있음. 원본 repository는 별도 커넥션을 사용하므로 트랜잭션에 포함되지 않음 해결:

// 잘못된 코드 (this.orderRepository는 트랜잭션 밖)
await this.dataSource.transaction(async (manager) => {
await this.orderRepository.save(order); // ← 이 repository는 트랜잭션에 포함 안 됨!
});
// 올바른 코드 (manager 사용)
await this.dataSource.transaction(async (manager) => {
await manager.save(Order, order); // ← manager가 같은 트랜잭션 커넥션 사용
});

🔧 트랜잭션이 너무 오래 걸려서 다른 요청들이 대기한다

섹션 제목: “🔧 트랜잭션이 너무 오래 걸려서 다른 요청들이 대기한다”

증상: 특정 요청 처리 시 DB 응답이 느려지고, 로그에 “waiting for lock” 관련 경고 발생 원인: 트랜잭션 안에서 외부 API 호출, 무거운 연산 등 시간이 오래 걸리는 작업을 실행 → Lock을 오래 점유 해결:

  1. 트랜잭션 범위를 DB 작업만으로 최소화 — 외부 API 호출은 트랜잭션 밖으로 이동

  2. 외부 결제 API 호출 후 결과를 받아서 트랜잭션 시작:

    // 잘못된 패턴: 트랜잭션 안에서 외부 API 호출
    await dataSource.transaction(async (manager) => {
    await externalPaymentApi.charge(amount); // ← 느린 외부 호출이 Lock 점유
    await manager.save(Order, order);
    });
    // 올바른 패턴: 외부 호출 먼저, DB 작업만 트랜잭션에
    const paymentResult = await externalPaymentApi.charge(amount); // 트랜잭션 밖
    await dataSource.transaction(async (manager) => {
    await manager.save(Order, { ...order, paymentId: paymentResult.id }); // DB만
    });

🔧 QueryRunner를 사용 후 커넥션 풀이 고갈된다

섹션 제목: “🔧 QueryRunner를 사용 후 커넥션 풀이 고갈된다”

증상: 일정 시간 후 DB 요청이 모두 타임아웃 (DriverPackageNotInstalledError 또는 connection timeout) 원인: QueryRunner를 사용했지만 finally 블록에서 queryRunner.release()를 호출하지 않음. 커넥션이 반환되지 않아 풀이 고갈됨 해결:

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// ... DB 작업
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
// ← 이 finally 블록이 반드시 있어야 함!
// 예외가 발생해도 반드시 실행되어 커넥션을 풀에 반환
await queryRunner.release();
}

🔧 @Transactional 데코레이터가 적용되지 않는다 (typeorm-transactional)

섹션 제목: “🔧 @Transactional 데코레이터가 적용되지 않는다 (typeorm-transactional)”

증상: @Transactional() 데코레이터를 붙였는데 트랜잭션 없이 각 쿼리가 별도로 실행됨 원인: initializeTransactionalContext()NestFactory.create() 이후에 호출됨. 반드시 앱 초기화 전에 호출해야 ALS(Async Local Storage)가 정상 동작 해결:

// main.ts - 올바른 순서
import { initializeTransactionalContext } from "typeorm-transactional";
initializeTransactionalContext(); // ← 반드시 맨 위에서 먼저 호출!
async function bootstrap() {
const app = await NestFactory.create(AppModule); // ← 이 이후가 아님!
await app.listen(3000);
}
bootstrap();
// ❌ 잘못된 순서 (이렇게 하면 @Transactional이 동작 안 함)
async function bootstrap() {
const app = await NestFactory.create(AppModule);
initializeTransactionalContext(); // 너무 늦음!
await app.listen(3000);
}

🔧 트랜잭션 안에서 이벤트를 발행했는데 구독자가 DB를 못 읽는다

섹션 제목: “🔧 트랜잭션 안에서 이벤트를 발행했는데 구독자가 DB를 못 읽는다”

증상: 트랜잭션 안에서 this.eventEmitter.emit('order.created', order) 호출 → 리스너가 orderRepo.findOne()으로 해당 order를 조회하는데 찾지 못함 원인: 트랜잭션이 아직 커밋되지 않은 상태에서 이벤트가 발행됨. 리스너는 다른 DB 커넥션을 사용하므로 커밋 전 데이터가 보이지 않음 (Read Committed 격리 수준 기준) 해결:

// ❌ 잘못된 패턴: 트랜잭션 내에서 이벤트 발행
async createOrder(dto: CreateOrderDto) {
return await this.dataSource.transaction(async (manager) => {
const order = await manager.save(Order, dto);
this.eventEmitter.emit('order.created', order); // ← 커밋 전 발행!
return order;
});
}
// ✅ 올바른 패턴 1: 트랜잭션 커밋 후 발행
async createOrder(dto: CreateOrderDto) {
const order = await this.dataSource.transaction(async (manager) => {
return await manager.save(Order, dto);
}); // ← 여기서 커밋 완료
this.eventEmitter.emit('order.created', order); // 커밋 후 발행
return order;
}
// ✅ 올바른 패턴 2: Outbox Pattern (더 안전)
// 트랜잭션 안에서 outbox 테이블에 이벤트 레코드 저장
// 별도 Processor가 커밋된 outbox 레코드를 읽어서 발행
  • ACID 4가지를 각각 한 문장으로 설명할 수 있다
  • Commit과 Rollback의 차이를 설명할 수 있다
  • Deadlock이 뭔지 설명할 수 있다
  • “이 작업에 트랜잭션이 필요한가?”를 판단할 수 있다
  • dataSource.transaction()과 QueryRunner의 차이를 설명할 수 있다
  • 트랜잭션 범위를 최소화해야 하는 이유를 설명할 수 있다

분산 트랜잭션, Two-Phase Commit, Saga Pattern, Optimistic Locking, WAL(Write-Ahead Logging), Connection Pool, typeorm-transactional

  • 팀 서비스 코드에서 트랜잭션이 사용된 부분 찾아보기

    Terminal window
    grep -r "dataSource.transaction\|QueryRunner\|@Transaction" src/ --include="*.ts"

    예상 출력: 트랜잭션 있으면 파일 경로와 줄 번호 표시

  • DB에서 Deadlock 이력 확인 PostgreSQL: SELECT * FROM pg_stat_activity WHERE wait_event_type = 'Lock'; 예상 출력: 현재 Lock 대기 중인 쿼리 목록 (없으면 빈 테이블)

  • 간단한 SQL로 BEGIN → INSERT → ROLLBACK 해보기

    BEGIN;
    INSERT INTO test_table(name) VALUES ('test');
    SELECT * FROM test_table; -- 'test'가 보임 (아직 커밋 안 됨)
    ROLLBACK;
    SELECT * FROM test_table; -- 'test'가 사라짐
  • 현재 DB의 기본 격리 수준 확인 PostgreSQL: SHOW transaction_isolation; 예상 출력: read committed

  • 두 세션으로 Deadlock 직접 재현해보기 (psql 2개 터미널로 실행)

    -- 준비: 테스트 테이블 생성
    CREATE TABLE lock_test (id INT PRIMARY KEY, val TEXT);
    INSERT INTO lock_test VALUES (1, 'A'), (2, 'B');
    -- === 세션 1 (터미널 1) ===
    BEGIN;
    SELECT * FROM lock_test WHERE id = 1 FOR UPDATE; -- Row 1 잠금 성공
    -- === 세션 2 (터미널 2) ===
    BEGIN;
    SELECT * FROM lock_test WHERE id = 2 FOR UPDATE; -- Row 2 잠금 성공
    SELECT * FROM lock_test WHERE id = 1 FOR UPDATE; -- ← 세션 1이 Row 1을 잠금 중 → 대기
    -- === 세션 1로 돌아와서 ===
    SELECT * FROM lock_test WHERE id = 2 FOR UPDATE;
    -- → 즉시 에러 발생: ERROR: deadlock detected
    -- DETAIL: Process XX waits for ShareLock on transaction YYY; blocked by process ZZ.
    -- HINT: See server log for query details.
    -- DB가 세션 1 또는 세션 2 중 하나를 강제 Rollback
    -- 정리
    ROLLBACK; -- 각 세션에서 실행
    DROP TABLE lock_test;

    예상 결과: ERROR: deadlock detected (40P01) — PostgreSQL이 deadlock_timeout(기본 1초) 이후 자동 감지하여 둘 중 하나를 롤백. 출처: Debugging deadlocks in PostgreSQL - CYBERTEC

  1. 트랜잭션은 여러 작업을 하나로 묶어 전부 성공 또는 전부 실패하게 한다
  2. ACID(원자성, 일관성, 격리성, 지속성)가 트랜잭션의 핵심 성질이다
  3. Commit은 확정, Rollback은 취소 — 중간 상태를 방지한다
  4. 격리 수준이 높을수록 안전하지만 성능이 떨어진다
  5. 데이터 정합성 문제의 원인은 대부분 트랜잭션 처리에 있다

11. 실전 장애 대응 시나리오 (On-Call Runbook)

섹션 제목: “11. 실전 장애 대응 시나리오 (On-Call Runbook)”

트랜잭션 관련 장애는 데이터 정합성 문제이므로 신중한 접근이 필요하다

시나리오 A: “DB 응답이 갑자기 느려졌다” (Lock 대기 의심)

섹션 제목: “시나리오 A: “DB 응답이 갑자기 느려졌다” (Lock 대기 의심)”
PostgreSQL 기준 즉각 확인:
1. Lock 대기 중인 쿼리 확인 (RDS 콘솔 또는 직접 접속)
SELECT pid, wait_event_type, wait_event, state, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY state_change;
→ wait_event_type = 'Lock'인 행이 있으면 Lock 대기 중
2. Lock을 점유하고 있는 쿼리 찾기
SELECT
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
blocked.pid AS blocked_pid,
blocked.query AS blocked_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;
→ blocking_query가 오래 실행 중인 트랜잭션
3. 장시간 실행 중인 트랜잭션 강제 종료 (신중하게!)
-- 먼저 해당 쿼리/트랜잭션을 DBA 또는 개발자와 확인 후
SELECT pg_cancel_backend([blocking_pid]); -- 쿼리 취소 (Rollback)
SELECT pg_terminate_backend([blocking_pid]); -- 연결 강제 종료
4. 재발 방지 — 운영 환경 타임아웃 3종 조합
PostgreSQL 공식: deadlock_timeout 기본 1s. typical 트랜잭션 시간보다 크게 두어
불필요한 deadlock 검사를 줄이라는 가이드 (출처: PostgreSQL 18 Lock Management 공식 문서
https://www.postgresql.org/docs/current/runtime-config-locks.html).
OLTP 사용자 직결 API (p99 < 500ms 목표) 권장 조합:
- statement_timeout = '5s' -- 단일 쿼리 강제 취소 (사용자는 이미 새로고침했을 가능성)
- idle_in_transaction_session_timeout = '60s'
-- BEGIN 후 60s idle이면 세션 종료. 기본값 0 = 비활성이라
명시 설정 필요. lock 무한 점유와 dead tuple 누적 방지.
- lock_timeout = '3s' -- 단일 lock 대기 한계. statement_timeout보다 짧게.
배치 작업은 별도 커넥션 풀로 분리, statement_timeout 30s~수분으로 별도 설정.
TypeORM 적용 예:
{ extra: { statement_timeout: 5000,
idle_in_transaction_session_timeout: 60000,
lock_timeout: 3000 } }
주의: 위 5s/60s/3s는 OLTP 일반 권장값이며, 본인 시스템의 typical 트랜잭션 시간을
pg_stat_statements로 먼저 측정한 뒤 (mean_exec_time의 95퍼센타일 × 2~3배) 조정한다.

시나리오 B: “deadlock detected” 에러가 반복적으로 발생한다

섹션 제목: “시나리오 B: “deadlock detected” 에러가 반복적으로 발생한다”
Deadlock은 DB가 자동으로 한 트랜잭션을 강제 종료하므로 데이터 손실은 없음.
그러나 주문 실패 등 사용자 경험 문제가 발생.
즉각 확인:
1. CloudWatch Log Insights에서 deadlock 빈도 확인:
filter @message like /deadlock detected/ or @message like /40P01/
| stats count(*) as cnt by bin(5m)
| sort @timestamp desc
2. 어떤 서비스/코드에서 발생하는지 확인
filter @message like /deadlock/
| fields service, endpoint, @message
| limit 20
원인 분석 접근:
→ PostgreSQL 로그에서 Deadlock 상세 내용 확인
RDS 파라미터 그룹: log_lock_waits = on, deadlock_timeout = 200ms 설정
→ CloudWatch Logs에서 "deadlock detected on relation" 로그 확인
→ 어떤 테이블의 어떤 행들이 연관됐는지 파악
근본 해결:
→ 섹션 6.5 "deadlock detected 에러가 발생한다" 항목의 해결법 적용
→ 잠금 순서 통일 + Deadlock 자동 재시도 로직 추가
Deadlock 자동 재시도 패턴 (NestJS 전체 적용):
@Injectable()
export class DeadlockRetryInterceptor implements NestInterceptor {
async intercept(context: ExecutionContext, next: CallHandler) {
const MAX_RETRIES = 3;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return await lastValueFrom(next.handle());
} catch (error) {
const isDeadlock = error?.driverError?.code === '40P01' // PostgreSQL
|| error?.driverError?.errno === 1213; // MySQL ER_LOCK_DEADLOCK
if (isDeadlock && attempt < MAX_RETRIES - 1) {
const delay = 50 + Math.random() * 100; // 50~150ms 랜덤 대기
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
}
// 사용: 전역 또는 민감한 컨트롤러에 적용
// app.useGlobalInterceptors(new DeadlockRetryInterceptor());

시나리오 C: “데이터 정합성이 깨졌다” (트랜잭션 누락 의심)

섹션 제목: “시나리오 C: “데이터 정합성이 깨졌다” (트랜잭션 누락 의심)”
증상: 주문은 있는데 재고가 안 줄었거나, 결제 기록은 있는데 주문 상태가 여전히 pending
즉각 조치:
1. 먼저 현재 불일치 범위 파악 (얼마나 많은 건수?)
SELECT COUNT(*) FROM orders o
LEFT JOIN payments p ON p.order_id = o.id
WHERE o.status = 'paid' AND p.id IS NULL; -- 결제 없는 paid 주문
2. 수동 데이터 정합성 복구 (DB 직접 수정은 최후 수단, 반드시 트랜잭션 사용)
BEGIN;
-- 조회로 영향 범위 확인
SELECT ... ;
-- 수정 SQL 실행
UPDATE ... ;
-- 결과 재확인 후 COMMIT 또는 ROLLBACK
COMMIT;
원인 분석:
→ 코드에서 여러 DB 작업이 같은 트랜잭션으로 묶여 있는지 확인
grep -r "dataSource.transaction\|@Transactional\|QueryRunner" src/
→ transaction() 안에서 this.repository (트랜잭션 외부 레포지토리) 사용 여부 확인
→ 섹션 6.5 "트랜잭션을 걸었는데도 데이터가 꼬인다" 항목 참고

TypeORM save() vs insert()/update() Deadlock 주의

TypeORM GitHub 이슈(#10586, #5521)에서 지속적으로 보고되는 패턴: save()를 동시 호출하면 TypeORM이 내부적으로 SELECT → UPDATE/INSERT 순서로 처리하면서 Deadlock이 발생할 수 있다. 2025년 기준 권장 해결책:

  • 삽입: save() 대신 insert() 사용 (SELECT 없이 직접 INSERT)
  • 수정: save() 대신 update() 사용 (명시적 WHERE 조건)
  • 충돌 처리: createQueryBuilder().insert().orUpdate() 패턴 (Upsert)

Optimistic Locking 도입 트렌드

재고 차감, 좌석 예약 등 동시성 충돌이 잦은 도메인에서 Pessimistic Lock(FOR UPDATE) 대신 Optimistic Lock(버전 번호 기반)을 도입하는 패턴이 늘고 있다. Lock 대기 없이 충돌 시 재시도하는 방식으로 처리량을 높일 수 있다.

// TypeORM Optimistic Lock 예시
@Entity()
export class Product {
@VersionColumn()
version: number; // 업데이트마다 자동 증가
@Column()
stock: number;
}
// 재고 차감 시 버전 체크 → 충돌 시 OptimisticLockVersionMismatchError
await productRepo.save({ id, stock: newStock, version: currentVersion });
// 충돌 발생 시 → 재시도 로직으로 최신 데이터 다시 읽어서 처리

분산 트랜잭션 → Saga Pattern으로 전환

마이크로서비스 환경에서 여러 서비스에 걸친 트랜잭션은 Two-Phase Commit 대신 Saga Pattern으로 처리하는 것이 2025년 표준이다. 각 서비스가 로컬 트랜잭션을 처리하고, 실패 시 보상 트랜잭션(Compensating Transaction)을 실행한다. AWS Step Functions이 Saga 오케스트레이션의 관리형 옵션으로 많이 채택되고 있다.

📖 더 보기: How to implement DEADLOCK retry on every endpoint automatically in NestJS — NestJS 전체 엔드포인트에 Deadlock 자동 재시도를 적용하는 Interceptor 패턴 (중급)