Transaction Basics
분류: Layer 8 - 데이터베이스 심화 | 선수지식: RDS Basics
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”트랜잭션은 “여러 작업을 하나의 단위로 묶어서, 전부 성공하거나 전부 실패하게 만드는” 데이터 처리 방식이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”“A 계좌에서 돈을 빼고 B 계좌에 돈을 넣는” 작업에서, 중간에 실패하면 돈이 사라진다. 트랜잭션을 이해하지 못하면 데이터가 꼬이는 버그를 만들거나, 장애 상황에서 데이터 정합성 문제를 진단할 수 없다.
프론트엔드 개발자를 위한 브릿지: React에서 폼을 제출할 때 API가 500 에러를 반환하는 경우, 서버 내부에서는 트랜잭션 롤백이 일어난 것이다. 예를 들어 “주문 생성 → 재고 차감” 중 재고 차감에서 실패하면, 트랜잭션이 전체를 되돌려서 주문도 생성되지 않은 것처럼 처리한다. 프론트에서 500을 받았다면 DB 상태는 변경 전과 동일하게 안전하다.
부분 처리의 한계에서 트랜잭션이 등장한 이유
RDS Basics에서 배운 데이터베이스는 테이블·인덱스·스토리지 엔진을 제공하지만, 애플리케이션이 INSERT orders와 UPDATE 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가 커밋된 단위와 버릴 단위를 기계적으로 구분한다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”트랜잭션이 동작하는 방식 (전체 흐름)
섹션 제목: “트랜잭션이 동작하는 방식 (전체 흐름)”비유: 계좌이체와 같다.
계좌이체 작업 (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는 내부적으로 어떻게 트랜잭션을 보장하는가
- WAL (Write-Ahead Logging): 실제 데이터를 수정하기 전에 먼저 “무엇을 할 것인지”를 로그에 기록. 장애 시 이 로그로 복구.
- Lock: 같은 행을 동시에 수정하지 못하도록 잠금.
INSERT/UPDATE시 해당 행에 배타적 잠금(Exclusive Lock) 걸림. - 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와 대응되는 개념 | 차이점 |
|---|---|---|
| Kafka | Partition = 순서가 보장된 Append-Only Log. 메시지가 추가되면 절대 수정되지 않음 | WAL은 복구용 내부 로그, Kafka는 외부 소비자가 직접 읽는 이벤트 스트림 |
| Redis AOF | Append 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 계열인지 즉시 분류할 수 있다.
- 변경을 즉시 적용하는가, 별도 로그에 먼저 기록한 후 적용하는가? → 후자면 WAL 패턴
- 같은 키에 대한 동시 읽기·쓰기가 서로 차단되는가? → 차단되지 않으면 MVCC 또는 그 변종
- 이전 상태가 일정 시점까지 보존되는가? (스냅샷·버전·이벤트 히스토리) → MVCC 또는 Event Sourcing
- 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 Read | Non-Repeatable Read | Phantom 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 에러 코드: 40P01Deadlock 방지법: 여러 트랜잭션에서 항상 동일한 순서로 잠금을 획득한다.
실전 아키텍처 패턴
섹션 제목: “실전 아키텍처 패턴”패턴 1: @Transactional 데코레이터 패턴 (프로덕션 권장)
typeorm-transactional 라이브러리를 사용하면 메서드에 데코레이터를 붙이는 것만으로 트랜잭션을 관리할 수 있다. 2025년 현재 수백만 건 트랜잭션을 처리하는 프로덕션 환경에서 검증된 패턴이다.
// npm install typeorm-transactional// main.ts에서 반드시 먼저 초기화 (NestFactory.create() 전!)import { initializeTransactionalContext } from "typeorm-transactional";initializeTransactionalContext(); // ← 이게 먼저여야 함
// order.service.tsimport { 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 마이그레이션을 프로덕션에 안전하게 적용하는 트랜잭션 설정:
# 기본값: 모든 마이그레이션을 단일 트랜잭션으로 실행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_timeout이 0으로 비활성화된 상태라면 긴 트랜잭션이 무기한 대기로 보일 수 있다(출처: https://www.postgresql.org/docs/current/runtime-config-client.html).
4. 실무에서 어디에 쓰이나
섹션 제목: “4. 실무에서 어디에 쓰이나”- 결제 처리 (주문 생성 + 재고 감소 + 결제 확인을 하나로)
- 사용자 가입 (계정 생성 + 프로필 생성 + 초기 설정을 하나로)
- 배치 데이터 처리 (여러 레코드를 한 번에 업데이트)
- 데이터 마이그레이션 (안전하게 변환 후 커밋)
5. 현재 내 업무와 연결점
섹션 제목: “5. 현재 내 업무와 연결점”- 데이터 정합성 이슈 발생 시 트랜잭션이 제대로 처리됐는지 확인
- Deadlock 에러 로그 발생 시 원인 분석
- DB 성능 이슈 시 격리 수준과 잠금(lock) 문제 의심
- 새 기능 개발 시 “이 작업들이 하나의 트랜잭션이어야 하는지” 판단
6. 자주 헷갈리는 개념 비교
섹션 제목: “6. 자주 헷갈리는 개념 비교”| 개념 A | 개념 B | 차이점 |
|---|---|---|
| Commit | Rollback | Commit은 확정, Rollback은 취소 |
| Lock | Transaction | Lock은 데이터 잠금 메커니즘, Transaction은 작업 묶음 (Lock을 사용함) |
| Optimistic Lock | Pessimistic Lock | Optimistic은 충돌 시 재시도, Pessimistic은 미리 잠그고 시작 — 선택 기준은 충돌 빈도와 재시도 비용 (아래 참고) |
| ACID | BASE | ACID는 강한 일관성(RDB), BASE는 최종 일관성(NoSQL) |
| dataSource.transaction() | QueryRunner | 전자는 간결함, 후자는 세밀한 제어 가능 (둘 다 TypeORM 트랜잭션 방법) |
| @Transactional | dataSource.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주일 충돌률 측정 — Optimistic 코드 경로의
OptimisticLockVersionMismatchError발생 수 ÷ 해당 엔드포인트 트랜잭션 총수. 5% 미만이면 그대로 유지. - 5% 이상이면 Pessimistic 후보 검토 — 단, 단순 전환 전에
pg_stat_activity에서wait_event = 'transactionid'비율을 베이스라인으로 측정한다. 전환 후 lock wait 시간이 늘어나 throughput이 더 떨어지는 사례가 자주 보고되므로, 카나리 비율(예: 10%)로 한 주 비교 후 전체 적용. - 재시도 backoff 의무화 — Optimistic을 유지하든 Pessimistic으로 전환하든, retry는 50~150ms 랜덤 지터를 적용해 retry storm을 차단한다 (이 문서의 시나리오 B
DeadlockRetryInterceptor코드와 동일한 패턴).
이 3단계로 결정하면 “충돌률 / 재시도 비용 / lock wait time” 3축이 본인 데이터에 맞춰진 결정이 된다 — 위 표의 일반 가이드와 본인 시스템 실측의 차이를 직접 발견할 수 있다.
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”🔧 TypeORM에서 “deadlock detected” 에러가 발생한다
섹션 제목: “🔧 TypeORM에서 “deadlock detected” 에러가 발생한다”증상: QueryFailedError: deadlock detected 로그 발생, 일부 주문이 실패
원인: 두 트랜잭션이 같은 행들을 반대 순서로 잠금 획득 시도 (예: 트랜잭션A는 Product→Order 순서, B는 Order→Product 순서)
해결:
- 코드에서 모든 트랜잭션이 항상 동일한 순서로 잠금 획득하도록 통일
- 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을 오래 점유 해결:
-
트랜잭션 범위를 DB 작업만으로 최소화 — 외부 API 호출은 트랜잭션 밖으로 이동
-
외부 결제 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 레코드를 읽어서 발행7. 체크리스트
섹션 제목: “7. 체크리스트”- ACID 4가지를 각각 한 문장으로 설명할 수 있다
- Commit과 Rollback의 차이를 설명할 수 있다
- Deadlock이 뭔지 설명할 수 있다
- “이 작업에 트랜잭션이 필요한가?”를 판단할 수 있다
- dataSource.transaction()과 QueryRunner의 차이를 설명할 수 있다
- 트랜잭션 범위를 최소화해야 하는 이유를 설명할 수 있다
8. 추가 학습 키워드
섹션 제목: “8. 추가 학습 키워드”분산 트랜잭션, Two-Phase Commit, Saga Pattern, Optimistic Locking, WAL(Write-Ahead Logging), Connection Pool, typeorm-transactional
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”- 📖 ACID Databases Explained - FreeCodeCamp — ACID 각 속성의 의미와 위반 시 어떤 문제가 생기는지 예시 중심으로 설명 (입문)
- 📖 TypeORM Transactions with NestJS - Medium — dataSource.transaction()과 QueryRunner 두 가지 방식 비교 구현 가이드 (입문)
- 📖 NestJS Transactions: A Comprehensive Guide — 실무 NestJS 트랜잭션 패턴 총정리, 격리 수준 설정 포함 (중급)
- 📖 PostgreSQL Understanding Deadlocks - Cybertec — PostgreSQL 데드락 발생 원리와 시스템 로그에서 진단하는 방법 상세 (중급)
- 📖 PostgreSQL Lock Management — 공식 문서 —
deadlock_timeout(기본 1s),max_locks_per_transaction(기본 64) 등 lock 관련 파라미터의 공식 정의와 부하가 큰 환경에서의 권장 조정 가이드 (공식) - 📖 typeorm-transactional @Transactional 데코레이터 가이드 - Medium — @Transactional 데코레이터로 비즈니스 로직에서 트랜잭션 코드를 분리하는 프로덕션 패턴 (중급)
9. 내가 직접 확인해볼 것
섹션 제목: “9. 내가 직접 확인해볼 것”-
팀 서비스 코드에서 트랜잭션이 사용된 부분 찾아보기
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
10. 5줄 요약
섹션 제목: “10. 5줄 요약”- 트랜잭션은 여러 작업을 하나로 묶어 전부 성공 또는 전부 실패하게 한다
- ACID(원자성, 일관성, 격리성, 지속성)가 트랜잭션의 핵심 성질이다
- Commit은 확정, Rollback은 취소 — 중간 상태를 방지한다
- 격리 수준이 높을수록 안전하지만 성능이 떨어진다
- 데이터 정합성 문제의 원인은 대부분 트랜잭션 처리에 있다
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 "트랜잭션을 걸었는데도 데이터가 꼬인다" 항목 참고2025년 최신 동향
섹션 제목: “2025년 최신 동향”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;}
// 재고 차감 시 버전 체크 → 충돌 시 OptimisticLockVersionMismatchErrorawait 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 패턴 (중급)