트랜잭션 기초 — ACID 원칙과 트랜잭션 라이프사이클
계좌 이체 중에 서버가 죽으면 돈은 어디로 갈까요? 출금은 됐는데 입금이 안 된 상태로 남는 건 아닐까요?
트랜잭션이란
하나의 논리적 작업 단위 입니다. 여러 SQL 문이 모여서 하나의 작업을 구성하고, 이 작업은 전부 성공하거나 전부 실패 해야 합니다.
계좌 이체를 예로 들면, "A 계좌에서 출금"과 "B 계좌에 입금"은 반드시 함께 성공하거나 함께 실패해야 합니다. 출금만 되고 입금이 안 되면 돈이 증발하는 거니까요.
-- 이 두 문장은 하나의 트랜잭션으로 묶여야 한다
UPDATE account SET balance = balance - 10000 WHERE id = 'A';
UPDATE account SET balance = balance + 10000 WHERE id = 'B';
ACID 원칙
트랜잭션이 보장해야 할 네 가지 속성입니다. 데이터베이스를 공부하면 가장 먼저 만나는 개념인데, 각각이 어떤 문제를 해결하는지 에 초점을 맞추면 이해가 빨라집니다.
Atomicity — 원자성
전부 성공하거나, 전부 실패하거나.
트랜잭션 안의 모든 연산은 하나의 단위로 취급됩니다. 중간에 하나라도 실패하면 이전에 성공한 연산도 모두 취소(ROLLBACK)됩니다.
InnoDB에서는 Undo Log 가 이걸 가능하게 합니다. 각 변경 전의 데이터를 Undo Log에 저장해 두고, 롤백이 필요하면 이 로그를 역순으로 적용합니다.
BEGIN;
UPDATE account SET balance = balance - 10000 WHERE id = 'A'; -- 성공
UPDATE account SET balance = balance + 10000 WHERE id = 'B'; -- 실패!
ROLLBACK; -- A의 출금도 취소됨
Consistency — 일관성
트랜잭션 전후로 데이터베이스는 항상 유효한 상태여야 한다.
"유효한 상태"란 제약 조건(PK, FK, UNIQUE, CHECK 등)이 모두 만족되는 상태 를 의미합니다. 잔액이 마이너스가 되면 안 되는 규칙이 있다면, 트랜잭션이 그 규칙을 깨뜨리는 순간 롤백되어야 합니다.
공부하다 보면 Consistency가 가장 추상적으로 느껴지는데, 사실 나머지 세 속성(A, I, D)이 제대로 보장되면 자연스럽게 따라오는 성질에 가깝습니다.
Isolation — 격리성
동시에 실행되는 트랜잭션은 서로의 중간 상태를 보지 못한다.
A가 이체 중인데, B가 동시에 같은 계좌 잔액을 조회하면 어떤 값이 보여야 할까요? 이체 전 금액? 이체 후 금액? 중간 금액? 이걸 결정하는 게 격리 수준(Isolation Level) 입니다.
InnoDB에서는 두 가지 메커니즘으로 격리성을 구현합니다.
- MVCC(Multi-Version Concurrency Control): 데이터의 여러 버전을 유지해서, 읽기 트랜잭션이 쓰기 트랜잭션을 기다리지 않게 함
- Lock(잠금): 동시에 같은 데이터를 수정하려는 경우 순서를 강제
격리 수준별 차이가 궁금하다면 ** 격리 수준 심화** 글을 참고해 주세요.
Durability — 지속성
한번 커밋되면, 서버가 죽어도 데이터는 살아 있어야 한다.
커밋된 트랜잭션의 결과는 어떤 장애 상황에서도 유실되지 않아야 합니다. InnoDB는 Redo Log(WAL, Write-Ahead Logging) 로 이걸 보장합니다.
동작 방식을 간단히 정리하면 이렇습니다.
- 데이터를 변경하면 Buffer Pool(메모리)의 페이지를 수정
- 커밋 시 변경 내용을 Redo Log에 먼저 기록 (디스크에 flush)
- 실제 데이터 파일에는 나중에 비동기로 반영 (체크포인트)
- 크래시가 발생하면 Redo Log를 ** 재생(replay)**해서 커밋된 내용을 복구
"데이터 파일에 직접 쓰기 전에 로그에 먼저 쓴다"는 게 WAL의 핵심입니다.
트랜잭션 라이프사이클
트랜잭션은 아래와 같은 상태를 거칩니다.
Active → Partially Committed → Committed
↓
Failed → Aborted
각 상태의 의미는 다음과 같습니다.
| 상태 | 설명 |
|---|---|
| Active | 트랜잭션이 실행 중. SQL 문들이 수행되고 있는 상태 |
| Partially Committed | 마지막 SQL 문까지 실행 완료. 아직 디스크에 반영(commit)되지 않은 상태 |
| Committed | 변경 내용이 디스크에 영구 반영된 상태. 이후 롤백 불가 |
| Failed | 오류 발생으로 더 이상 진행 불가한 상태 |
| Aborted | ROLLBACK 완료. 모든 변경이 취소되고 트랜잭션 이전 상태로 복원 |
실제 SQL로 보면 이런 흐름입니다.
-- 계좌 이체 트랜잭션
BEGIN; -- Active 상태 시작
-- A 계좌에서 출금
UPDATE account SET balance = balance - 10000 WHERE id = 'A';
-- 잔액 확인
SELECT balance INTO @remaining FROM account WHERE id = 'A';
-- 잔액이 부족하면 롤백
IF @remaining < 0 THEN
ROLLBACK; -- Failed → Aborted
END IF;
-- B 계좌에 입금
UPDATE account SET balance = balance + 10000 WHERE id = 'B';
COMMIT; -- Partially Committed → Committed
실무에서는 보통 애플리케이션 코드에서 try-catch로 감싸서 처리합니다.
// Spring @Transactional 없이 수동 관리하는 예시
Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false); // BEGIN
// 출금
PreparedStatement ps1 = conn.prepareStatement(
"UPDATE account SET balance = balance - ? WHERE id = ?");
ps1.setInt(1, 10000);
ps1.setString(2, "A");
ps1.executeUpdate();
// 입금
PreparedStatement ps2 = conn.prepareStatement(
"UPDATE account SET balance = balance + ? WHERE id = ?");
ps2.setInt(1, 10000);
ps2.setString(2, "B");
ps2.executeUpdate();
conn.commit(); // 성공하면 커밋
} catch (Exception e) {
conn.rollback(); // 실패하면 롤백
} finally {
conn.setAutoCommit(true);
conn.close();
}
AUTOCOMMIT
MySQL의 기본 설정은 AUTOCOMMIT = 1 (활성화)입니다. 이 상태에서는 ** 각 SQL 문이 자동으로 하나의 트랜잭션으로 실행 **됩니다.
-- AUTOCOMMIT = 1 (기본값)
-- 아래 INSERT는 실행 즉시 커밋된다
INSERT INTO orders (user_id, amount) VALUES (1, 50000);
-- ↑ 내부적으로 BEGIN → INSERT → COMMIT이 자동 실행됨
AUTOCOMMIT 상태에서 명시적 트랜잭션 사용
BEGIN(또는 START TRANSACTION)을 사용하면 AUTOCOMMIT이 ** 일시적으로 비활성화 **됩니다. COMMIT이나 ROLLBACK을 하면 다시 AUTOCOMMIT 모드로 돌아갑니다.
-- AUTOCOMMIT이 켜져 있어도, BEGIN을 쓰면 명시적 트랜잭션이 시작됨
BEGIN;
INSERT INTO orders (user_id, amount) VALUES (1, 50000);
INSERT INTO order_items (order_id, product_id) VALUES (LAST_INSERT_ID(), 42);
-- 여기서 두 INSERT는 아직 커밋되지 않은 상태
COMMIT; -- 이때 비로소 둘 다 커밋됨
AUTOCOMMIT 비활성화
세션 단위로 끌 수도 있습니다.
SET AUTOCOMMIT = 0;
-- 이제 모든 SQL은 명시적 COMMIT 전까지 커밋되지 않는다
INSERT INTO logs (message) VALUES ('작업 시작');
UPDATE inventory SET qty = qty - 1 WHERE product_id = 42;
COMMIT; -- 여기서 커밋
-- 다음 SQL부터 다시 새 트랜잭션 시작
UPDATE inventory SET qty = qty - 1 WHERE product_id = 43;
ROLLBACK; -- 위의 UPDATE 취소
공부하다 보면 "AUTOCOMMIT을 끄면 성능이 좋아진다"는 말을 가끔 보는데, 이건 배치 처리처럼 여러 문장을 한 트랜잭션으로 묶을 때 커밋 오버헤드가 줄어서 그렇습니다. 일반적인 웹 애플리케이션에서는 Spring의 @Transactional이 알아서 관리해 주기 때문에 직접 건드릴 일은 거의 없습니다.
암묵적 커밋 (Implicit Commit)
트랜잭션 중에 ** 특정 SQL을 실행하면 MySQL이 자동으로 현재 트랜잭션을 커밋 **합니다. 이게 함정인데, 롤백하려고 했는데 이미 커밋되어 버린 상황이 생길 수 있습니다.
암묵적 커밋이 발생하는 경우
DDL (Data Definition Language)
CREATE TABLE,ALTER TABLE,DROP TABLECREATE INDEX,DROP INDEXTRUNCATE TABLECREATE DATABASE,DROP DATABASE
** 관리 명령어**
LOCK TABLES,UNLOCK TABLESLOAD DATA INFILESET AUTOCOMMIT = 1(0에서 1로 바꿀 때)
** 트랜잭션 관련**
BEGIN(이미 트랜잭션 진행 중이면, 기존 트랜잭션을 커밋하고 새로 시작)
왜 위험한가
BEGIN;
UPDATE account SET balance = balance - 10000 WHERE id = 'A';
-- 여기까지는 아직 커밋 안 됨
ALTER TABLE account ADD COLUMN memo VARCHAR(255);
-- ⚠️ 암묵적 커밋 발생! 위의 UPDATE가 자동 커밋됨
UPDATE account SET balance = balance + 10000 WHERE id = 'B';
-- 이 UPDATE는 새로운 트랜잭션에서 실행됨
ROLLBACK;
-- A의 출금은 이미 커밋되어서 롤백 안 됨!
-- B의 입금만 롤백됨 → 돈 증발
이런 상황을 방지하려면 DDL과 DML을 같은 트랜잭션에 섞지 않는 게 원칙 입니다. 스키마 변경은 별도로 수행하세요.
SAVEPOINT
트랜잭션 전체를 롤백하는 대신, 특정 지점까지만 되돌리고 싶을 때 사용합니다.
기본 문법
SAVEPOINT 세이브포인트_이름; -- 지점 설정
ROLLBACK TO SAVEPOINT 세이브포인트_이름; -- 해당 지점까지 되돌리기
RELEASE SAVEPOINT 세이브포인트_이름; -- 세이브포인트 삭제 (선택)
실제 예시: 배치 주문 처리
여러 건의 주문을 처리하는데, 일부가 실패해도 성공한 건은 유지하고 싶은 경우입니다.
BEGIN;
-- 주문 1 처리
SAVEPOINT order_1;
INSERT INTO orders (user_id, amount) VALUES (1, 30000);
UPDATE inventory SET qty = qty - 1 WHERE product_id = 101;
-- 주문 1 성공
-- 주문 2 처리
SAVEPOINT order_2;
INSERT INTO orders (user_id, amount) VALUES (2, 50000);
UPDATE inventory SET qty = qty - 1 WHERE product_id = 102;
-- 재고 부족으로 실패 가정
ROLLBACK TO SAVEPOINT order_2;
-- 주문 2만 취소, 주문 1은 살아있음
-- 주문 3 처리
SAVEPOINT order_3;
INSERT INTO orders (user_id, amount) VALUES (3, 20000);
UPDATE inventory SET qty = qty - 1 WHERE product_id = 103;
-- 주문 3 성공
COMMIT; -- 주문 1과 3만 최종 반영
주의할 점이 있습니다.
ROLLBACK TO SAVEPOINT는 해당 세이브포인트 ** 이후 **의 변경만 취소합니다- 세이브포인트 자체는 유지되므로 다시 사용 가능합니다
COMMIT이나ROLLBACK(전체)을 하면 모든 세이브포인트가 해제됩니다- 같은 이름으로 세이브포인트를 다시 설정하면 이전 것은 덮어쓰기됩니다
트랜잭션과 InnoDB 내부
트랜잭션의 ACID 각 속성이 InnoDB 내부에서 어떤 구조로 구현되는지 간단히 연결해 두겠습니다.
| ACID 속성 | InnoDB 메커니즘 | 역할 |
|---|---|---|
| Atomicity | Undo Log | 롤백 시 변경 전 데이터로 복원 |
| Consistency | 제약 조건 + 위 세 속성의 조합 | 데이터 무결성 유지 |
| Isolation | MVCC + Lock | 동시 접근 제어, 스냅샷 읽기 |
| Durability | Redo Log (WAL) | 크래시 후 커밋된 데이터 복구 |
각각의 상세한 동작 방식은 아래 글에서 다룹니다.
- Undo Log와 Redo Log — 크래시 복구의 핵심 메커니즘
- InnoDB MVCC — 락 없이 읽기를 처리하는 방법
- Row Lock, Gap Lock, Next-Key Lock — InnoDB 잠금의 종류
실무 주의사항
트랜잭션은 짧게 유지
긴 트랜잭션이 가져오는 문제는 생각보다 많습니다.
- **Lock 점유 시간 증가 **: 다른 트랜잭션이 대기하면서 전체 처리량 저하
- **Undo Log 누적 **: MVCC를 위해 이전 버전을 유지해야 하므로, 긴 트랜잭션은 Undo Log 정리를 막음
- ** 데드락 확률 증가 **: 여러 리소스를 오래 잡고 있을수록 교착 상태 발생 가능성이 올라감
트랜잭션 안에서 외부 API 호출 금지
// ❌ 나쁜 예
@Transactional
public void processOrder(Order order) {
orderRepository.save(order);
paymentGateway.charge(order.getAmount()); // 외부 API 호출
// API 응답이 느리면 트랜잭션이 오래 열려 있게 됨
// API 타임아웃 시 DB 커넥션도 낭비
}
// ✅ 좋은 예
public void processOrder(Order order) {
PaymentResult result = paymentGateway.charge(order.getAmount()); // 먼저 호출
if (result.isSuccess()) {
orderService.saveOrder(order); // 트랜잭션은 DB 작업만
}
}
외부 API 호출은 트랜잭션 밖에서 처리하고, 결과에 따라 DB 작업을 수행하는 게 안전합니다.
데드락 가능성 인지
두 트랜잭션이 서로가 잡고 있는 Lock을 기다리면 데드락이 발생합니다.
트랜잭션 1: A 잠금 획득 → B 잠금 대기
트랜잭션 2: B 잠금 획득 → A 잠금 대기
→ 서로 영원히 기다림 → 데드락
InnoDB는 데드락을 자동 감지해서 한쪽을 강제 롤백합니다. 하지만 애초에 데드락이 덜 발생하도록 ** 리소스 접근 순서를 통일 하는 게 좋습니다. 데드락 관련 상세 내용은 ** 데드락 글을 참고해 주세요.
정리
- ** 트랜잭션 **은 여러 SQL을 하나의 논리적 작업 단위로 묶는 것
- ACID 는 트랜잭션이 보장해야 할 네 가지 속성 — 각각 Undo Log, 제약조건, MVCC/Lock, Redo Log로 구현
- **라이프사이클 **:
BEGIN→ SQL 실행 →COMMIT또는ROLLBACK - AUTOCOMMIT 은 기본 활성화 —
BEGIN을 쓰면 일시적으로 비활성화 - **암묵적 커밋 **: DDL을 트랜잭션 중에 실행하면 이전 변경이 자동 커밋됨 — 주의 필요
- SAVEPOINT: 트랜잭션 내에서 부분 롤백이 가능한 중간 지점
- 실무에서는 트랜잭션을 짧게, 외부 호출은 밖에서, 데드락 순서를 통일하는 게 핵심