icon

안동민 개발노트

10장 : 트랜잭션

트랜잭션의 개념


계좌 이체 중 서버가 죽으면? — 돈은 빠져나갔는데 안 들어온 상황을 트랜잭션이 막아줍니다. 데이터 일관성의 핵심입니다. 트랜잭션은 관계형 데이터베이스가 신뢰할 수 있는 시스템으로 기능하는 근본적인 메커니즘이며, 금융, 의료, 전자상거래 등 데이터 정확성이 생명인 모든 시스템의 기반입니다.


트랜잭션이란

트랜잭션(Transaction)은 하나의 논리적 작업 단위를 구성하는 SQL 문들의 집합입니다. "전부 성공하거나, 전부 취소되거나" — 중간 상태는 허용하지 않습니다. 데이터베이스를 하나의 일관된 상태에서 다른 일관된 상태로 변환하는 과정이라 정의할 수 있습니다.

트랜잭션과 로그
COMMIT 성공 ─▶ ┌──────────────┐
               │ 데이터베이스 │
               └──────────────┘
               Redo 로그 → 지속성(D) 보장
               변경 사항 재적용으로 복원

ROLLBACK    ─▶ ┌──────────────┐
               │ 데이터베이스 │
               └──────────────┘
               Undo 로그 → 원자성(A) 보장
               변경 이전 값으로 되돌림

일관성 (Consistency)

일관성은 트랜잭션이 실행되기 전과 후에 데이터베이스가 일관된 상태를 유지해야 한다는 것입니다. "일관된 상태"란 모든 제약 조건(기본키, 외래키, CHECK, 도메인 등)이 만족되는 상태를 의미합니다.

일관성의 책임은 DBMS와 애플리케이션 양쪽에 있습니다. DBMS는 선언적 제약 조건(PRIMARY KEY, FOREIGN KEY, CHECK, NOT NULL)을 자동으로 검사합니다. 그러나 이체 전후 총합이 같아야 한다는 비즈니스 규칙은 애플리케이션 로직이나 트리거에서 보장해야 합니다.

일관성 위반 사례
잔고 합계 불변 제약
  이체 전: A(500만) + B(200만) = 700만
  이체 후: A(400만) + B(300만) = 700만  ← 일관성 유지

일관성 위반
  이체 전: A(500만) + B(200만) = 700만
  이체 후: A(400만) + B(200만) = 600만  ← 100만원 소멸! 위반!

격리성 (Isolation)

격리성은 동시에 실행되는 여러 트랜잭션이 서로의 중간 상태를 볼 수 없어야 한다는 것입니다. 마치 각 트랜잭션이 혼자서 실행되는 것처럼 보여야 합니다.

격리성이 없으면 어떤 문제가 발생하는지 살펴보겠습니다.

격리성 부재 시 문제
T1: A → B 이체 (100만원)
T2: A 잔고 조회

시간 순서
  T1: A에서 100만원 출금 (A: 500→400)
  T2: A 잔고 조회 → 400만원으로 보임
  T1: 오류 발생! 전체 ROLLBACK
  T1: A 잔고 복원 (A: 400→500)

  → T2는 400만원을 "진짜 잔고"로 판단했지만, 실제로는 500만원!
  → 이것이 "Dirty Read" 문제

격리성은 락(Lock)이나 MVCC(Multi-Version Concurrency Control)로 구현됩니다. 락은 다른 트랜잭션이 데이터에 접근하지 못하게 막는 방식이고, MVCC는 데이터의 여러 버전을 유지하여 읽기와 쓰기가 서로 방해하지 않도록 하는 방식입니다.

격리 수준(Isolation Level)에 따라 격리성의 정도를 조절할 수 있으며, 이는 이후 절에서 상세히 다룹니다.

지속성 (Durability)

지속성은 트랜잭션이 성공적으로 커밋되면 그 결과가 영구적으로 데이터베이스에 반영되어야 한다는 것입니다. 서버가 죽든, 정전이 나든, 커밋된 데이터는 사라지지 않아야 합니다.

지속성은 Redo 로그(Redo Log)WAL(Write-Ahead Logging) 원칙으로 보장됩니다. WAL 원칙은 데이터를 변경하기 전에 로그를 먼저 디스크에 기록한다는 것입니다. 이렇게 하면 시스템 장애 시 로그를 이용하여 변경 사항을 재적용(Redo)할 수 있습니다.

WAL로 지속성 보장
트랜잭션 실행 과정
  1. 데이터 변경 (메모리의 Buffer Cache)
  2. Redo 로그 기록 (디스크의 로그 파일) ← 먼저!
  3. COMMIT 수행 → Redo 로그를 디스크에 flush
  4. 데이터 파일에 쓰기 (나중에 Checkpoint 시)

서버 장애 시
  → 메모리의 Buffer Cache는 소실
  → 그러나 Redo 로그는 디스크에 있음
  → Redo 로그를 재적용하여 커밋된 데이터 복원

핵심: 데이터 파일보다 로그 파일이 항상 앞서 있음 → "Write-Ahead"

각 속성의 비용

ACID를 보장하는 것은 성능 비용이 따릅니다. 데이터베이스가 안전성을 위해 치르는 대가입니다.

속성비용설명
원자성Undo 로그 기록모든 변경 전 원래 값을 기록
일관성제약 조건 검사INSERT/UPDATE마다 CHECK, FK 검증
격리성락 대기, 버전 관리동시성↓ 또는 메모리↑
지속성Redo 로그 디스크 쓰기매 커밋마다 디스크 동기화

이 비용 때문에 모든 상황에서 최고 수준의 ACID를 적용하는 것은 비현실적입니다. 예를 들어, SNS의 좋아요 카운트처럼 약간의 부정확성이 허용되는 경우에는 격리 수준을 낮춰 성능을 확보할 수 있습니다. 반면 금융 거래처럼 절대적인 정확성이 요구되는 경우에는 최고 수준의 격리가 필요합니다.


트랜잭션 SQL 명령어

트랜잭션을 제어하는 SQL 명령어를 TCL(Transaction Control Language)이라 합니다.

COMMIT과 ROLLBACK

COMMIT - 영구 반영
BEGIN TRANSACTION;

UPDATE accounts SET balance = balance - 1000000
WHERE account_id = 'A';

UPDATE accounts SET balance = balance + 1000000
WHERE account_id = 'B';

COMMIT;  -- 두 UPDATE 모두 영구 반영
-- 이 시점 이후 어떤 장애가 발생해도 결과는 보존됨
ROLLBACK - 전체 취소
BEGIN TRANSACTION;

UPDATE accounts SET balance = balance - 1000000
WHERE account_id = 'A';

-- B 계좌가 동결 상태라면?
UPDATE accounts SET balance = balance + 1000000
WHERE account_id = 'B';  -- 실패!

ROLLBACK;  -- A 계좌의 출금도 취소됨
-- 데이터베이스는 트랜잭션 시작 전 상태로 복원

SAVEPOINT

SAVEPOINT는 트랜잭션 내부에 중간 저장 지점을 설정하는 명령입니다. ROLLBACK TO SAVEPOINT를 사용하면 해당 지점까지만 되돌릴 수 있습니다.

SAVEPOINT 활용
BEGIN TRANSACTION;

INSERT INTO orders (order_id, user_id, total)
VALUES (1001, 42, 50000);

SAVEPOINT sp_order_created;  -- 중간 저장 지점

INSERT INTO order_items (order_id, product_id, qty)
VALUES (1001, 'P001', 2);

INSERT INTO order_items (order_id, product_id, qty)
VALUES (1001, 'P002', 1);  -- 재고 부족으로 실패!

ROLLBACK TO SAVEPOINT sp_order_created;
-- order_items INSERT만 취소, orders INSERT는 유지

-- 대안 상품으로 재처리
INSERT INTO order_items (order_id, product_id, qty)
VALUES (1001, 'P003', 1);

COMMIT;  -- orders + 수정된 order_items 영구 반영

SAVEPOINT는 복잡한 트랜잭션에서 부분 롤백이 필요할 때 유용합니다. 그러나 과도한 SAVEPOINT 사용은 트랜잭션 로직을 복잡하게 만들므로 신중하게 사용해야 합니다.


트랜잭션의 자동 커밋

DBMS마다 트랜잭션 시작 방식이 다릅니다.

MySQL은 기본적으로 Auto Commit 모드입니다. 각 SQL 문이 자동으로 하나의 트랜잭션으로 처리됩니다. 명시적 트랜잭션을 사용하려면 START TRANSACTION 또는 BEGIN을 사용해야 합니다.

MySQL - Auto Commit
-- Auto Commit ON (기본값)
INSERT INTO users (name) VALUES ('김철수');
-- 이 한 줄이 자동으로 트랜잭션 + COMMIT

-- Auto Commit 끄기
SET autocommit = 0;
INSERT INTO users (name) VALUES ('이영희');
INSERT INTO users (name) VALUES ('박민수');
COMMIT;  -- 명시적 커밋 필요

-- 또는 명시적 트랜잭션
START TRANSACTION;
INSERT INTO users (name) VALUES ('최지은');
INSERT INTO users (name) VALUES ('정현우');
COMMIT;

Oracle은 기본적으로 Auto Commit이 꺼져 있습니다. 모든 DML(INSERT, UPDATE, DELETE)은 명시적으로 COMMIT할 때까지 확정되지 않습니다. 그러나 DDL(CREATE, ALTER, DROP)은 묵시적 COMMIT을 발생시킵니다.

Oracle - 묵시적 커밋 주의
-- Oracle에서 DDL은 자동 COMMIT
INSERT INTO temp_data VALUES (1, 'data');  -- 커밋 안 함

CREATE TABLE new_table (id INT);  -- DDL 실행
-- → 위의 INSERT도 묵시적으로 COMMIT됨!
-- → ROLLBACK 불가!

PostgreSQL의 Auto Commit 동작은 MySQL과 유사합니다. 그러나 DDL도 트랜잭션 내에서 롤백할 수 있다는 점이 Oracle과 다릅니다.

PostgreSQL - DDL도 롤백 가능
BEGIN;
CREATE TABLE test_table (id INT);
INSERT INTO test_table VALUES (1);
ROLLBACK;
-- → test_table 생성도 취소됨! (PostgreSQL만 가능)

트랜잭션 설계 원칙

실무에서 트랜잭션을 설계할 때 지켜야 하는 원칙이 있습니다.

트랜잭션은 짧게

트랜잭션이 오래 실행되면 락을 오랫동안 점유하여 다른 트랜잭션이 대기하게 됩니다. 이는 시스템의 동시 처리 능력을 심각하게 저하시킵니다.

나쁜 예 - 긴 트랜잭션
BEGIN TRANSACTION;

SELECT * FROM orders WHERE user_id = 42;
-- 사용자 화면에 주문 목록 표시
-- ... 사용자가 5분 동안 화면을 보고 있음 ...
-- ... 이 동안 orders 테이블에 락이 걸려 있음 ...

UPDATE orders SET status = '취소'
WHERE order_id = 1001;

COMMIT;
-- 5분 동안 다른 트랜잭션의 orders 접근이 제한됨!
좋은 예 - 짧은 트랜잭션
-- 조회는 트랜잭션 바깥에서
SELECT * FROM orders WHERE user_id = 42;
-- 사용자 화면에 주문 목록 표시 (락 없음)

-- 변경이 필요할 때만 짧은 트랜잭션
BEGIN TRANSACTION;
UPDATE orders SET status = '취소'
WHERE order_id = 1001 AND status = '주문완료';
-- 낙관적 락: 상태가 이미 변경되었으면 0행 수정 → 충돌 감지
COMMIT;

트랜잭션 내에서 사용자 입력을 기다리지 않기

트랜잭션 도중에 사용자에게 확인을 요청하는 것은 절대 금물입니다. 사용자가 화면을 열어두고 자리를 비우면 트랜잭션이 몇 시간이고 열린 채로 유지될 수 있습니다.

필요한 최소한의 데이터만 잠그기

전체 테이블을 잠그는 것보다 특정 행만 잠그는 것이 효율적입니다. 행 단위 락(Row-Level Lock)을 사용하면 서로 다른 행을 수정하는 트랜잭션은 동시에 실행될 수 있습니다.

일관된 순서로 리소스에 접근

데드락을 예방하기 위해 여러 테이블이나 행에 접근할 때는 항상 동일한 순서로 접근해야 합니다.

데드락 예시
T1: accounts(A) 잠금 → accounts(B) 잠금 시도
T2: accounts(B) 잠금 → accounts(A) 잠금 시도

→ T1은 B를 기다리고, T2는 A를 기다림 → 영원히 대기 (데드락!)

해결: 항상 account_id 오름차순으로 접근
T1: accounts(A) → accounts(B)
T2: accounts(A) → accounts(B)  ← A부터 잠금!
→ T2는 A를 잠그려 하지만 T1이 잠고 있으므로 대기
→ T1이 완료되면 T2 진행 → 데드락 없음

암묵적 트랜잭션과 명시적 트랜잭션

프로그래밍에서 트랜잭션을 사용하는 두 가지 방식이 있습니다.

명시적 트랜잭션(Explicit Transaction)은 BEGIN/COMMIT/ROLLBACK을 직접 작성하는 방식입니다. 트랜잭션의 범위가 명확하고 제어가 정확합니다.

명시적 트랜잭션
START TRANSACTION;

INSERT INTO orders (user_id, total) VALUES (42, 50000);
SET @order_id = LAST_INSERT_ID();

INSERT INTO order_items (order_id, product_id, qty)
VALUES (@order_id, 'P001', 2);

UPDATE products SET stock = stock - 2
WHERE product_id = 'P001';

COMMIT;

암묵적 트랜잭션(Implicit Transaction)은 프레임워크나 ORM이 트랜잭션을 자동으로 관리하는 방식입니다. Spring의 @Transactional, Django의 ATOMIC_REQUESTS 등이 이에 해당합니다.

Spring의 @Transactional 동작
@Transactional
public void transferMoney(String from, String to, int amount) {
    // Spring이 자동으로 BEGIN TRANSACTION 실행

    accountRepository.debit(from, amount);
    accountRepository.credit(to, amount);

    // 메서드 정상 종료 → Spring이 자동으로 COMMIT
    // 예외 발생 → Spring이 자동으로 ROLLBACK
}

보상 트랜잭션

분산 시스템이나 마이크로서비스 환경에서는 여러 서비스에 걸친 트랜잭션을 하나의 ACID 트랜잭션으로 묶기 어렵습니다. 이 경우 보상 트랜잭션(Compensating Transaction) 패턴을 사용합니다.

보상 트랜잭션 - Saga 패턴
온라인 쇼핑 주문 프로세스
  1. 주문 서비스: 주문 생성
  2. 결제 서비스: 카드 승인
  3. 재고 서비스: 재고 차감
  4. 배송 서비스: 배송 요청

3번(재고 차감)이 실패하면?
  → 2번 보상: 카드 승인 취소 (환불)
  → 1번 보상: 주문 상태를 '취소'로 변경

각 단계의 "되돌리기" 작업이 보상 트랜잭션입니다.

보상 트랜잭션은 Undo 로그에 의한 자동 롤백이 아니라, 역방향 트랜잭션을 새로 실행하여 논리적으로 되돌리는 방식입니다. 출금의 보상은 입금이고, 재고 차감의 보상은 재고 추가입니다.


트랜잭션과 동시성

트랜잭션의 격리성과 동시성은 상충 관계(Trade-off)입니다. 격리 수준이 높을수록 데이터 정확성은 높아지지만 동시 처리 능력은 떨어집니다.

격리성과 동시성의 트레이드오프
Low ──── 격리 수준 ────── High
READ     READ       REPEATABLE  SERIALIZABLE
UNCOMMIT COMMITTED  READ
TED

High ── 동시성(성능) ──── Low
빠름     보통        느림        매우 느림

Low ──── 데이터 정확성 ── High
Dirty    Non-         Phantom    완벽
Read     Repeatable    Read
가능     Read 가능     가능       보장

각 격리 수준에서 발생할 수 있는 동시성 문제는 다음 절에서 상세히 다룹니다.


실무에서의 트랜잭션

실무에서 트랜잭션은 단순히 COMMIT과 ROLLBACK을 넘어서 시스템 설계의 핵심 요소입니다. 몇 가지 실무적 고려사항을 정리합니다.

프레임워크의 트랜잭션 전파(Propagation)는 기존 트랜잭션이 있는 상태에서 새로운 트랜잭션이 시작될 때 어떻게 처리할지를 결정합니다. Spring에서는 REQUIRED(기존 트랜잭션에 참여), REQUIRES_NEW(새 트랜잭션 생성), MANDATORY(기존 트랜잭션 필수) 등의 전파 속성을 제공합니다.

읽기 전용 트랜잭션(Read-Only Transaction)은 SELECT만 수행하는 트랜잭션에 대해 DBMS가 최적화를 적용할 수 있게 합니다. Undo 로그 생성을 건너뛰거나, 락 획득을 생략할 수 있습니다.

읽기 전용 트랜잭션
-- MySQL
START TRANSACTION READ ONLY;
SELECT * FROM orders WHERE user_id = 42;
SELECT * FROM order_items WHERE order_id = 1001;
COMMIT;  -- 변경이 없으므로 커밋 비용이 최소화됨

타임아웃(Timeout)은 트랜잭션이 지정된 시간 내에 완료되지 않으면 자동으로 롤백하는 기능입니다. 무한 대기를 방지합니다.

배치 작업에서의 트랜잭션은 대량의 데이터를 처리할 때 한 번의 트랜잭션으로 모든 것을 처리하면 Undo 로그가 폭발적으로 증가합니다. 1000건 또는 10000건 단위로 COMMIT하는 것이 일반적입니다.

배치 트랜잭션 분할
-- 100만 건 업데이트를 1000건씩 분할
SET @batch_size = 1000;
SET @processed = 0;

REPEAT
    START TRANSACTION;
    UPDATE large_table SET status = 'processed'
    WHERE status = 'pending'
    LIMIT @batch_size;
    SET @processed = @processed + ROW_COUNT();
    COMMIT;
UNTIL ROW_COUNT() = 0 END REPEAT;

트랜잭션과 로그의 관계 요약

트랜잭션의 ACID 속성이 어떤 로그에 의해 보장되는지 한눈에 정리합니다.

트랜잭션과 로그
COMMIT 성공 ─▶ ┌──────────────┐
               │ 데이터베이스 │
               └──────────────┘
               Redo 로그 → 지속성(D) 보장
               변경 사항을 재적용하여 복원

ROLLBACK    ─▶ ┌──────────────┐
               │ 데이터베이스 │
               └──────────────┘
               Undo 로그 → 원자성(A) 보장
               변경 이전 값으로 되돌림

Redo 로그는 커밋된 것을 다시 살리는 역할이고, Undo 로그는 커밋 안 된 것을 되돌리는 역할입니다. 이 두 로그가 함께 작동하여 어떤 장애 상황에서도 데이터의 일관성이 보장됩니다.

트랜잭션은 단순한 SQL 명령이 아니라, 데이터베이스의 신뢰성을 지탱하는 근본 메커니즘입니다. 원자성으로 부분 실행을 방지하고, 일관성으로 규칙 위반을 차단하며, 격리성으로 동시 접근 문제를 해결하고, 지속성으로 영구 보존을 보장합니다. 다음 절에서는 동시에 실행되는 트랜잭션 간의 간섭 문제와 격리 수준(Isolation Level)을 다루겠습니다.

목차