1. 트랜잭션은 왜 필요할까?
데이터베이스에서는 하나의 작업을 처리하기 위해 여러 SQL을 실행해야 하는 경우가 많다.
대표적인 예로 계좌 이체가 있다.
A 계좌에서 B 계좌로 10만 원을 이체한다고 생각해보자.
UPDATE accounts
SET balance = balance - 100000
WHERE id = 'A';
UPDATE accounts
SET balance = balance + 100000
WHERE id = 'B';
정상적으로 처리된다면,
A 계좌에서 10만 원 차감
↓
B 계좌에 10만 원 추가
되어야 한다.
그런데 첫 번째 SQL은 성공했지만 두 번째 SQL을 실행하는 도중 서버에 문제가 발생한다면 어떻게 될까?
A 계좌 -10만원 O
↓
서버 장애
↓
B 계좌 +10만원 X
A의 돈은 사라졌지만 B에게는 돈이 들어오지 않는 문제가 발생한다.
따라서 두 작업을 각각 별개의 작업으로 처리해서는 안 된다.
A 계좌 출금
+
B 계좌 입금
= 하나의 작업
처럼 묶어서 처리해야 한다.
이때 사용하는 것이 **Transaction(트랜잭션)**이다.
2. 트랜잭션이란?
트랜잭션은 데이터베이스에서 수행되는 하나의 논리적인 작업 단위이다.
하나의 트랜잭션은 여러 SQL로 구성될 수 있으며 기본적인 원칙은:
모든 작업이 성공하거나, 모든 작업이 실패해야 한다.
즉, All or Nothing이다.
BEGIN;
UPDATE accounts
SET balance = balance - 100000
WHERE id = 'A';
UPDATE accounts
SET balance = balance + 100000
WHERE id = 'B';
COMMIT;
모든 작업이 정상적으로 실행되면 COMMIT을 수행한다.
COMMIT
→ 트랜잭션의 변경사항 확정
반대로 문제가 발생하면 ROLLBACK을 수행한다.
ROLLBACK
→ 트랜잭션에서 수행한 변경사항 취소
예를 들어,
BEGIN
A 계좌 -10만원
↓
B 계좌 +10만원
↓
COMMIT
→ 모두 반영
도중 문제가 발생하면,
BEGIN
A 계좌 -10만원
↓
오류 발생
↓
ROLLBACK
→ A 계좌 변경도 취소
3. ACID란?
트랜잭션은 데이터의 신뢰성을 보장하기 위해 일반적으로 ACID라고 불리는 네 가지 성질을 가진다.
A → Atomicity
C → Consistency
I → Isolation
D → Durability
3.1 Atomicity - 원자성
원자성은 트랜잭션에 포함된 작업이:
전부 실행되거나 전부 실행되지 않아야 한다
는 성질이다.
예를 들어 주문을 생성한다고 생각해보자.
주문 생성
↓
주문 상품 저장
↓
재고 감소
이 세 작업이 하나의 트랜잭션이라면
주문 생성 O
주문 상품 저장 O
재고 감소 O
전부 성공하거나
주문 생성 X
주문 상품 저장 X
재고 감소 X
전부 취소되어야 한다.
중간까지만 반영되면
주문은 생성됐는데
재고는 줄지 않음
같은 데이터 불일치가 발생할 수 있기 때문이다.
DBMS는 Undo Log 등의 메커니즘을 통해 실패한 작업을 되돌릴 수 있다.
3.2 Consistency - 일관성
일관성은:
트랜잭션 실행 전과 실행 후에도 데이터베이스의 규칙이 유지되어야 한다
는 성질이다.
예를 들어 계좌의 잔액은 음수가 될 수 없다는 규칙이 있다고 하자.
balance >= 0
잔액이 5만 원인데 10만 원을 출금해서
balance = -50000
이 된다면 데이터베이스의 규칙이 깨진 것이다.
또한
Primary Key
Foreign Key
Unique
Not Null
Check
같은 무결성 제약조건이나 애플리케이션의 비즈니스 규칙 역시 일관성을 유지하는 데 사용된다.
즉,
Consistency
트랜잭션 전
정상적인 DB 상태
↓
트랜잭션 실행
↓
트랜잭션 후
정상적인 DB 상태
를 유지해야 한다.
3.3 Isolation - 격리성
여러 사용자가 동시에 요청을 보내면 데이터베이스에서도 여러 트랜잭션이 동시에 실행된다.
Transaction A ─┐
├→ Database
Transaction B ─┘
이때 한 트랜잭션의 작업 때문에 다른 트랜잭션이 예상하지 못한 데이터를 읽게 된다면 문제가 발생할 수 있다.
격리성은
동시에 실행되는 트랜잭션들이 서로에게 잘못된 영향을 주지 않도록 보장하는 성질
이다.
이론적으로는 각 트랜잭션이 마치 혼자 실행되는 것과 같은 결과를 만드는 것이 가장 안전하다.
하지만 모든 트랜잭션을 실제로 하나씩 실행한다면
T1 실행
↓
T1 완료
↓
T2 실행
↓
T2 완료
동시성이 크게 떨어져 성능이 나빠질 수 있다.
그래서 DBMS는 **Isolation Level(격리 수준)**을 통해 격리성과 동시성 사이의 균형을 조절한다.
3.4 Durability - 지속성
지속성은
한번 COMMIT된 데이터는 시스템에 문제가 발생해도 유지되어야 한다
는 성질이다.
Transaction
↓
COMMIT
↓
서버 장애
↓
재시작
↓
COMMIT된 데이터 유지
DBMS는 WAL(Write-Ahead Logging), Redo Log 등의 로그를 이용해 장애가 발생했을 때 데이터를 복구할 수 있다.
즉,
COMMIT 완료
→ 변경사항이 영구적으로 보장
되는 것이 지속성이다.
4. 왜 격리 수준이 필요할까?
가장 안전한 방법은 모든 트랜잭션을 완전히 격리하는 것이다.
하지만 격리를 강하게 할수록 다른 트랜잭션을 기다리거나 충돌을 검사해야 하는 경우가 늘어날 수 있다.
격리 수준 낮음
동시성 ↑
성능 ↑
데이터 이상 현상 가능성 ↑
반대로
격리 수준 높음
동시성 ↓
충돌/대기 가능성 ↑
데이터 안정성 ↑
이러한 균형을 조절하기 위해 SQL 표준에서는 대표적으로 네 가지 격리 수준을 정의한다.
READ UNCOMMITTED
↓
READ COMMITTED
↓
REPEATABLE READ
↓
SERIALIZABLE
아래로 갈수록 격리 수준이 높아진다.
5. 트랜잭션에서 발생할 수 있는 이상 현상
격리 수준을 이해하려면 먼저 동시에 트랜잭션을 실행할 때 어떤 문제가 발생할 수 있는지 알아야 한다.
대표적으로
Dirty Read
Non-Repeatable Read
Phantom Read
가 있다.
추가로 실무에서 자주 언급되는 Lost Update도 있다.
5.1 Dirty Read
Dirty Read는 다른 트랜잭션이 아직 COMMIT하지 않은 데이터를 읽는 현상이다.
예를 들어
초기 balance = 1000
Transaction A가
A: balance = 500으로 변경
아직 COMMIT하지 않음
상태라고 하자.
이때 Transaction B가
B: balance 조회
→ 500
을 읽어버린다.
그런데 이후 A가
ROLLBACK
한다면 실제 데이터는 다시
balance = 1000
이 된다.
결국 B는 실제로 확정된 적이 없는 500이라는 값을 기반으로 작업한 것이다.
T1
1000 → 500
아직 COMMIT X
↓
T2
500 읽음
↓
T1 ROLLBACK
↓
실제 값 = 1000
이를 Dirty Read라고 한다.
5.2 Non-Repeatable Read
Non-Repeatable Read는
하나의 트랜잭션 안에서 같은 데이터를 두 번 읽었는데 값이 달라지는 현상
이다.
예를 들어
balance = 1000
일 때
T1
SELECT balance
→ 1000
그 사이 T2가
T2
UPDATE balance = 500
COMMIT
한다.
그리고 T1이 다시 조회한다.
T1
SELECT balance
→ 500
그러면 같은 트랜잭션 안에서
첫 번째 조회 → 1000
두 번째 조회 → 500
처럼 결과가 바뀐다.
T1 T2
SELECT
→ 1000
UPDATE
→ 500
COMMIT
SELECT
→ 500
이를 Non-Repeatable Read라고 한다.
5.3 Phantom Read
Phantom Read는 하나의 트랜잭션에서 같은 범위 조건으로 조회했는데 없던 행이 나타나거나 기존 행이 사라지는 현상이다.
예를 들어
SELECT *
FROM products
WHERE price >= 10000;
을 실행했더니 10개의 상품이 조회됐다고 하자.
그 사이 다른 트랜잭션이
INSERT INTO products(name, price)
VALUES ('New Product', 20000);
COMMIT;
을 수행한다.
첫 번째 트랜잭션에서 같은 조건으로 다시 조회했더니
첫 번째 조회 → 10개
두 번째 조회 → 11개
가 될 수 있다.
T1 T2
price >= 10000 조회
→ 10개
새로운 상품 INSERT
COMMIT
다시 조회
→ 11개
마치 없던 데이터가 갑자기 나타난 것처럼 보이기 때문에 Phantom Read라고 한다.
Non-Repeatable Read와 차이는
Non-Repeatable Read
→ 기존 행의 값이 변경됨
Phantom Read
→ 조회되는 행 자체가 추가되거나 사라짐
이다.
5.4 Lost Update
Lost Update는 두 트랜잭션이 같은 데이터를 동시에 수정하면서 한쪽의 변경사항이 사라지는 현상이다.
초기 값이
balance = 1000
이라고 하자.
두 트랜잭션이 동시에 읽는다.
T1 → 1000 조회
T2 → 1000 조회
T1은 100을 추가한다.
1000 + 100
→ 1100
T2는 200을 추가한다.
1000 + 200
→ 1200
그리고 각각 저장하면
T1 UPDATE → 1100
T2 UPDATE → 1200
최종 값은
1200
이 된다.
하지만 실제로 두 변경사항이 모두 적용됐다면
1000 + 100 + 200
= 1300
이 되어야 한다.
즉 T1의 수정이 T2에게 덮어씌워져 사라진 것이다.
이를 Lost Update라고 한다.
이런 문제는 상황에 따라
SELECT ...
FOR UPDATE;
와 같은 명시적 Lock이나,
UPDATE accounts
SET balance = balance + 100
WHERE id = 1;
처럼 DB에서 직접 원자적으로 값을 변경하는 방법 등을 사용할 수 있다.
6. Isolation Level
6.1 READ UNCOMMITTED
가장 낮은 격리 수준이다.
다른 트랜잭션이 아직 COMMIT하지 않은 데이터도 읽을 수 있다.
T1
UPDATE
아직 COMMIT X
↓
T2
변경된 값 조회 가능
따라서 Dirty Read가 발생할 수 있다.
READ UNCOMMITTED
Dirty Read O
Non-Repeatable Read O
Phantom Read O
데이터의 신뢰성이 낮기 때문에 일반적인 서비스에서는 거의 사용하지 않는다.
6.2 READ COMMITTED
COMMIT된 데이터만 읽을 수 있는 격리 수준이다.
즉, Dirty Read를 방지할 수 있다.
하지만 하나의 트랜잭션이 실행되는 동안 다른 트랜잭션이 데이터를 수정하고 COMMIT하면 다음 조회에서 변경된 값이 보일 수 있다.
따라서
Non-Repeatable Read
Phantom Read
는 발생할 수 있다.
READ COMMITTED
Dirty Read X
Non-Repeatable Read O
Phantom Read O
PostgreSQL과 Oracle 등에서 기본 격리 수준으로 사용된다.
6.3 REPEATABLE READ
REPEATABLE READ는 같은 트랜잭션 안에서 이미 읽은 데이터가 반복해서 동일하게 보이도록 하는 격리 수준이다.
따라서
Dirty Read
→ 방지
Non-Repeatable Read
→ 방지
한다.
SQL 표준 기준으로는 Phantom Read가 발생할 수 있다.
REPEATABLE READ
Dirty Read X
Non-Repeatable Read X
Phantom Read O
다만 실제 DBMS의 구현은 SQL 표준과 완전히 동일하지 않을 수 있다.
예를 들어 PostgreSQL의 REPEATABLE READ는 MVCC 기반 Snapshot을 사용하기 때문에 일반적인 Phantom Read도 발생하지 않는다.
MySQL InnoDB 역시 MVCC와 Lock을 이용해 SQL 표준보다 강한 동작을 제공하는 경우가 있다.
따라서 격리 수준을 사용할 때는 사용하는 DBMS의 실제 구현을 함께 확인해야 한다.
6.4 SERIALIZABLE
가장 높은 격리 수준이다.
동시에 실행되는 트랜잭션의 결과가 마치
T1 전체 실행
↓
T2 전체 실행
또는
T2 전체 실행
↓
T1 전체 실행
한 것과 같은 결과가 나오도록 보장한다.
따라서 대표적인 이상 현상을 가장 강하게 방지한다.
SERIALIZABLE
Dirty Read X
Non-Repeatable Read X
Phantom Read X
다만 중요한 것은
SERIALIZABLE이 모든 트랜잭션을 실제로 하나씩 실행한다는 뜻은 아니다.
가능하면 동시에 실행하되 그 결과가 어떤 직렬 실행과 동일하도록 보장하는 것이다.
충돌을 안전하게 처리할 수 없는 경우 DBMS는 한 트랜잭션을 실패시키고 애플리케이션에 재시도를 요구할 수도 있다.
격리 수준이 높아질수록 이러한 충돌이나 대기 비용이 증가할 수 있기 때문에 무조건 높은 격리 수준을 사용하는 것이 정답은 아니다.
7. 격리 수준 정리
SQL 표준을 기준으로 대표적인 이상 현상을 정리하면 다음과 같다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | O | O | O |
| READ COMMITTED | X | O | O |
| REPEATABLE READ | X | X | O |
| SERIALIZABLE | X | X | X |
외우기보다는 단계적으로 이해하는 것이 쉽다.
READ UNCOMMITTED
→ 아무것도 제대로 막지 않음
READ COMMITTED
→ COMMIT된 데이터만 읽음
→ Dirty Read 방지
REPEATABLE READ
→ 같은 데이터를 반복해서 읽어도 동일
→ Non-Repeatable Read 방지
SERIALIZABLE
→ 직렬 실행과 동일한 결과 보장
→ 가장 강한 격리
단, 이 표는 SQL 표준에서 정의한 최소 기준이고 실제 동작은 PostgreSQL, MySQL 등의 DBMS 구현에 따라 달라질 수 있다.
8. Serializable은 정말 하나씩 실행하는 것일까?
SERIALIZABLE이라는 이름 때문에
Transaction 1 끝날 때까지
Transaction 2는 무조건 기다린다
라고 생각하기 쉽다.
하지만 Serial Execution과 Serializable은 다른 개념이다.
Serial Execution
트랜잭션을 실제로 하나씩 실행한다.
T1
READ
WRITE
COMMIT
↓
T2
READ
WRITE
COMMIT
Serializable
트랜잭션을 동시에 실행할 수도 있지만 최종 결과가 어떤 Serial Execution과 동일하다.
T1 ─────────┐
├→ 동시에 실행
T2 ─────────┘
결과
=
T1 → T2
또는
T2 → T1
순서로 실행한 결과
즉 Serializable의 핵심은
동시에 실행하더라도 데이터의 결과는 순차 실행한 것처럼 올바르게 만들자
는 것이다.
9. MVCC는 왜 등장할까?
격리성을 높이기 위해 모든 읽기와 쓰기에 Lock을 사용하면 데이터의 안전성은 높아질 수 있지만 동시성이 떨어질 수 있다.
예를 들어
Writer가 데이터 수정 중
↓
Reader가 기다림
같은 상황이 계속 발생하면 많은 요청을 동시에 처리하기 어렵다.
이를 해결하기 위해 PostgreSQL과 MySQL InnoDB 같은 DBMS에서는 **MVCC(Multi-Version Concurrency Control)**를 사용한다.
MVCC의 핵심 아이디어는
하나의 데이터에 여러 Version을 유지하고 각 트랜잭션에게 자신이 볼 수 있는 Version을 보여준다.
예를 들어
기존 데이터
User
name = Kim
T2가 이름을 수정한다.
새 Version
User
name = Lee
이때 이전 Version을 바로 없애지 않고 유지한다.
Version 1
name = Kim
Version 2
name = Lee
그러면 먼저 시작한 트랜잭션은 상황에 따라:
Version 1 → Kim
을 계속 읽고,
나중에 시작한 트랜잭션은
Version 2 → Lee
를 읽을 수 있다.
따라서 일반적인 Snapshot Read에서는
Reader
→ 이전 Version 읽기
Writer
→ 새로운 Version 생성
방식으로 읽기와 쓰기 사이의 불필요한 Blocking을 줄일 수 있다.
즉 MVCC는
여러 Version 유지
↓
트랜잭션별 Snapshot 제공
↓
읽기와 쓰기의 충돌 감소
↓
높은 동시성
을 위한 동시성 제어 방식이다.
다만 MVCC가 있다고 해서 모든 동시성 문제가 자동으로 해결되는 것은 아니다.
같은 데이터를 동시에 수정하는 경우에는 여전히 Lock이나 충돌 검사 등이 필요할 수 있다.
10. 격리 수준이 높으면 무조건 좋은가?
그렇지는 않다.
격리 수준이 높으면 데이터의 안전성은 높아지지만 그만큼
Lock 대기
트랜잭션 충돌
Rollback
Retry
등이 증가해 처리량이 떨어질 수 있다.
따라서 실제 시스템에서는
얼마나 강한 데이터 일관성이 필요한가?
동시에 얼마나 많은 요청을 처리해야 하는가?
충돌이 얼마나 자주 발생하는가?
를 고려해 격리 수준을 선택해야 한다.
예를 들어 단순한 게시글 조회와 금융 거래가 요구하는 데이터 일관성 수준은 다를 수 있다.
따라서
가장 높은 격리 수준을 선택하는 것이 목적이 아니라 서비스에 필요한 수준의 격리성을 선택하는 것이 중요하다.
11. 핵심 정리
트랜잭션과 격리 수준을 정리하면 다음과 같다.
| 개념 | 의미 |
|---|---|
| Transaction | 여러 DB 연산을 하나의 논리적인 작업으로 처리 |
| COMMIT | 트랜잭션의 변경사항 확정 |
| ROLLBACK | 트랜잭션의 변경사항 취소 |
| Atomicity | 모두 성공하거나 모두 실패 |
| Consistency | DB의 규칙과 일관성 유지 |
| Isolation | 동시 트랜잭션 사이의 간섭 제어 |
| Durability | COMMIT된 데이터 영구 보장 |
| Dirty Read | COMMIT되지 않은 데이터 읽음 |
| Non-Repeatable Read | 같은 행을 다시 읽었는데 값이 변경됨 |
| Phantom Read | 같은 범위 조회에서 행이 추가되거나 사라짐 |
| Lost Update | 한 트랜잭션의 수정이 다른 수정에 의해 사라짐 |
| MVCC | 여러 Version을 이용해 동시성을 높이는 방식 |
격리 수준은
READ UNCOMMITTED
↓
READ COMMITTED
↓
REPEATABLE READ
↓
SERIALIZABLE
순으로 격리성이 높아진다고 볼 수 있다.
가장 중요한 흐름은
여러 SQL을 하나의 작업으로 묶는다
↓
Transaction
↓
신뢰할 수 있는 처리를 위해 ACID 보장
↓
하지만 여러 Transaction이 동시에 실행됨
↓
이상 현상 발생 가능
↓
Isolation Level로 제어
↓
MVCC / Lock 등의 방식으로 실제 구현
이다.
즉 트랜잭션의 핵심은 단순히 @Transactional을 붙이는 것이 아니라
여러 작업의 원자성을 보장하고, 동시에 실행되는 트랜잭션 사이에서 데이터의 일관성을 어느 수준까지 지킬 것인지 결정하는 것
이라고 이해할 수 있다.