트랜잭션이 지키는 데이터의 경계
트랜잭션은 여러 데이터 변경을 하나의 단위로 묶어 부분 처리 상태를 막는 장치다. WAL, Lock, MVCC, 격리 수준, TypeORM 사용 시의 실패 모드를 함께 이해해야 운영 중 정합성 문제를 진단할 수 있다.
Script Companion
오디오와 함께 스크립트 보기
- 01
트랜잭션을 이해하는 출발점은 부분 처리의 위험이다. A 계좌에서 돈을 빼고 B 계좌에 돈을 넣는 작업이 중간에 멈추면 돈이 사라질 수 있다. 주문 생성과 재고 차감도 마찬가지다. 프론트엔드에서 폼 제출 뒤 API 500 에러를 받았을 때, 서버가 트랜잭션을 제대로 사용했다면 주문 생성과 재고 차감은 함께 되돌려지고 데이터베이스 상태는 변경 전과 같아야 한다. 핵심은 여러 SQL을 업무상 하나의 All-or-Nothing 단위로 묶는 것이다.
- 02
데이터베이스가 테이블, 인덱스, 스토리지 엔진을 제공한다고 해서 업무 규칙까지 자동으로 묶어주지는 않는다. 애플리케이션이 주문 INSERT와 재고 UPDATE를 별도 요청처럼 다루면, 네트워크 타임아웃이나 프로세스 종료, 제약 조건 오류가 두 작업 사이에 끼어들 수 있다. 이때 BEGIN은 여러 SQL을 하나의 커밋 후보로 묶고, COMMIT 전 오류가 나면 ROLLBACK이 후보 전체를 폐기한다. 트랜잭션이 있으면 데이터베이스는 커밋된 단위와 버릴 단위를 기계적으로 구분한다.
- 03
이 보장은 내부적으로 세 가지 축에 기대고 있다. WAL, Write-Ahead Logging은 실제 데이터 파일을 바꾸기 전에 무엇을 할 것인지 로그에 먼저 남긴다. PostgreSQL은 커밋 보장을 위해 모든 데이터 파일이 아니라 WAL 파일을 디스크에 flush하는 방식으로 복구 기준을 만든다. COMMIT 전에 서버가 죽으면 WAL에 커밋 마크가 없으므로 재시작 시 롤백된다. 동시에 Lock은 같은 행을 동시에 수정하지 못하게 하고, MVCC는 읽기와 쓰기의 가시성을 조절한다.
- 04
MVCC, Multi-Version Concurrency Control은 PostgreSQL에서 동시성을 높이기 위한 핵심 전략이다. 전통적인 Lock 중심 구조에서는 A가 행을 수정하는 동안 B의 읽기도 기다릴 수 있지만, MVCC에서는 쓰기 트랜잭션이 새 버전을 만드는 동안 읽기 트랜잭션이 이전 버전을 읽는다. 각 행의 숨겨진 시스템 컬럼인 xmin과 xmax가 어떤 트랜잭션에서 보이는 버전인지를 판단하게 돕는다. 대신 오래된 버전인 dead tuple이 쌓이고, 이를 VACUUM이 청소해야 한다. RDS에서는 autovacuum이 자동으로 실행된다.
- 05
트랜잭션의 성질은 ACID로 요약된다. Atomicity는 전부 성공하거나 전부 실패한다는 뜻이고, Consistency는 트랜잭션 전후 데이터가 규칙에 맞아야 한다는 뜻이다. Isolation은 동시에 실행되는 트랜잭션이 서로 간섭하지 않도록 하는 성질이고, Durability는 커밋된 데이터가 장애 뒤에도 보존된다는 성질이다. Commit은 작업을 확정하는 신호이고 Rollback은 트랜잭션 시작 전 상태로 되돌리는 신호다. 이 네 가지가 깨지면 부분 처리, 제약 위반, 더티 리드나 팬텀 리드, 장애 후 데이터 손실 같은 문제가 드러난다.
- 06
격리 수준은 동시에 실행되는 트랜잭션이 서로를 어디까지 볼 수 있는지 정하는 선택지다. PostgreSQL 기본값은 Read Committed이고, MySQL InnoDB 기본값은 Repeatable Read다. Read Committed에서는 각 SELECT가 쿼리 시작 시점의 스냅샷을 보므로 같은 트랜잭션 안의 두 SELECT가 다른 값을 볼 수 있다. 단일 계좌 행처럼 대상이 명확한 업데이트에는 보통 충분하지만, 두 테이블 합계가 항상 0이어야 하거나 좌석 N개 중 최대 1개만 배정해야 하는 흐름에서는 에러 없이 두 요청이 모두 가능하다고 판단하는 silent failure가 날 수 있다.
- 07
격리 수준을 올리는 판단은 단순히 더 안전한 옵션을 고르는 일이 아니다. 먼저 기본값을 두고, 충돌 지점을 SELECT FOR UPDATE나 유니크 제약으로 명시할 수 있는지 본다. 한 트랜잭션 안에서 같은 조회 결과가 계속 같아야 하는 리포트나 정산 작업이면 Repeatable Read를 검토하지만, PostgreSQL에서는 could not serialize access due to concurrent update가 발생할 수 있어 전체 트랜잭션 재시도가 필요하다. 여러 행과 테이블의 불변식을 데이터베이스가 검증해야 하면 Serializable을 검토하고, 40001 serialization failure를 일반 재시도 경로로 다룬다.
- 08
WAL과 MVCC의 원리는 PostgreSQL 밖에서도 전이된다. Kafka의 Partition은 순서가 보장된 Append-Only Log이고, Redis AOF는 모든 쓰기 명령을 순서대로 기록한 뒤 재시작 시 재실행한다. Event Sourcing은 상태 자체보다 상태 변경 이벤트를 Append-Only로 저장한다. SQLite의 journal_mode=WAL도 변경을 -wal 파일에 먼저 누적하고 체크포인트 시점에 메인 db 파일에 반영한다. 새 데이터 시스템을 볼 때는 변경을 별도 로그에 먼저 기록하는지, 읽기와 쓰기가 서로 차단되는지, 이전 상태가 보존되는지, dead version이나 tombstone 정리가 필요한지 묻는 방식으로 패턴을 분류할 수 있다.
- 09
NestJS와 TypeORM에서는 트랜잭션을 쓰는 방식 자체가 실패 원인이 될 수 있다. dataSource.transaction()은 간결하고 대부분의 경우에 적합하지만, 콜백 안의 모든 작업은 제공된 transactionalEntityManager를 사용해야 한다. 이 규칙을 어기고 원본 repository를 섞으면 코드상 트랜잭션 안에 있어 보여도 다른 커넥션에서 실행되어 롤백 시 일부 테이블만 남는 silent failure가 생길 수 있다. QueryRunner는 복잡한 흐름을 세밀하게 제어할 수 있지만 try, catch, finally 처리가 필요하고 release()를 빼먹으면 커넥션 풀이 고갈된다.
- 10
운영에서 자주 만나는 문제는 잠금 순서와 트랜잭션 범위에서 나온다. 두 트랜잭션이 같은 행을 서로 반대 순서로 잠그면 PostgreSQL은 ERROR: deadlock detected (40P01)을 반환하고 하나를 강제 롤백한다. 방지 원칙은 여러 행을 잠글 때 항상 같은 순서, 예를 들어 id 오름차순으로 잠금을 획득하는 것이다. 또 트랜잭션 안에 외부 API 호출이나 무거운 연산을 넣으면 Lock 점유 시간이 길어져 다른 요청들이 대기한다. 그래서 범위는 DB 작업만으로 줄이고, DB 커밋 뒤 이벤트 발행이 실패할 수 있는 경우에는 Outbox Pattern 같은 장치가 필요하다.
- 11
트랜잭션 관련 버그를 볼 때는 증상과 경계를 함께 본다. deadlock detected는 잠금 획득 순서가 다른지 확인하고, dataSource.transaction() 안에서 일부 데이터만 남았다면 manager 대신 원본 repository를 사용했는지 본다. @Transactional 데코레이터가 적용되지 않으면 initializeTransactionalContext()가 NestFactory.create()보다 먼저 호출됐는지 확인해야 한다. 트랜잭션 안에서 이벤트를 발행했는데 리스너가 데이터를 못 읽는다면, 아직 커밋 전이라 다른 커넥션에서 보이지 않는 상태일 수 있다. 정리하면 트랜잭션은 Commit과 Rollback만의 문법이 아니라, WAL, MVCC, 격리 수준, 잠금 순서, 커넥션 경계가 함께 만드는 정합성 장치다.
같은 레이어