[DATABASE] 트랜잭션 완전 정복 – ACID 원칙과 격리 수준을 중심으로
트랜잭션(Transaction)은 하나의 작업 단위를 구성하는 연산들의 집합으로, 데이터베이스의 상태를 변화시키는 하나의 논리적 작업 단위를 의미한다. 트랜잭션은 반드시 완전하게 수행되거나 전혀 수행되지 않아야 하며, 이를 보장하기 위해 다음 네 가지 속성, 즉 ACID 원칙을 지켜야 한다.
1. 트랜잭션의 핵심 개념
- 데이터 무결성 보장: 중간에 문제가 발생해도 전체 작업이 일관된 상태를 유지하도록 함.
- 예: 은행 송금 시스템
- A 계좌에서 1000원을 출금하고 -> B 계좌에 1000원을 입금해야 함
- 출금만 되고 입금이 실패한다면? -> 데이터 불일치 발생
- -> 하나의 트랜잭션으로 묶어 모두 성공하거나 모두 실패시킴
2. ACID 원칙
1. Atomicity (원자성)
- 정의: 트랜잭션 내의 작업들이 모두 성공하거나, 모두 실패해야 한다는 원칙.
- 예시: 온라인 쇼핑몰에서 주문 결제를 처리할 때 (재고 수량 차감 + 결제 정보 저장 + 주문 내역 저장)
➡ 이 중 하나라도 실패하면, 다른 작업도 모두 무효화되어야 한다. 예를 들어 결제는 성공했는데 주문 내역 저장이 실패하면 전체 롤백되어야 함.
➡ 실패 시 -> ROLLBACK 수행
2. Consistency (일관성)
- 정의: 트랜잭션이 실행되기 전과 실행된 후의 데이터는 정의된 제약조건이나 비즈니스 룰을 항상 만족해야 한다는 원칙
- 예시: 은행 시스템에서 한 계좌에서 1000원이 출금되고 다른 계좌에 1000원이 입금되는 경우
➡ 총합 = 출금 전 + 입금 전 -> 트랜잭션 이후에도 총합은 동일해야 함
➡ 중간에 어떤 실패로 총합이 달라지면 일관성 위배
3. Isolation (격리성)
- 정의: 여러 트랜잭션이 동시에 실행될 때, 각 트랜잭션은 서로의 작업이 보이지 않도록 격리되어야 함. 결과적으로 순차적으로 실행된 것과 동일한 결과가 나와야 함.
- 예시: 사용자 A가 상품 재고를 1개 줄이는 작업 수행 중. 동시에 사용자 B가 같은 상품의 재고를 조회
➡ B는 트랜잭션이 완료된 이후의 상태를 봐야 함 (중간 상태를 보면 안 됨)
4. Durability (지속성)
- 정의: 트랜잭션이 성공적으로 커밋되면, 그 결과는 영구적으로 저장되어야 하며, 시스템 장애가 발생해도 손실되지 않아야 함.
- 예시: 고객이 은행 계좌에 100만원을 입금하고 “완료” 메시지를 받았음 -> 이후 시스템이 재부팅돼도 반영된 잔액은 그대로 유지되어야 함 ➡ WAL(Write-Ahead Logging), Undo/Redo 로그 등의 매커니즘 필요 ➡ 디스크에 안전하게 기록된 후에야 커밋 처리
🔧 해결 방법 ①: WAL (Write-Ahead Logging)
개념
- 모든 변경 내용을 실제 데이터에 적용하기 전에 먼저 로그 파일에 기록하는 방식
어떻게 작동하나?
- 트랜잭션 시작
- 변경될 내용을 로그 파일(WAL)에 먼저 기록 (ex: “A 계좌 -> +100만원”)
- 로그 기록 완료되면 -> 실제 데이터에 반영
- 커밋
장점
- 시스템이 꺼지더라도 로그를 복구하면 이전 상태로 되돌릴 수 있음
- 데이터보다 로그를 먼저 남기기 때문에 일관성 보장
📌 PostgreSQL, MySQL(InnoDB), Oracle 모두 WAL 사용
🔧 해결 방법 ②: Undo / Redo Log
Undo 로그
- “이전 상태”를 기록해둠 -> 롤백할 때 사용
- 예: “기존 잔액은 500만원이었다”
Redo 로그
- “최종 상태”를 기록해둠 -> 시스템 재시작 시 적용
- 예: “최종적으로 잔액은 600만원이다”
어떻게 보장하나?
- 트랜잭션 커밋 전까지는 둘 다 디스크에 저장해둔다
- 이후 시스템이 꺼지면:
- Undo 로그로 원래대로 복구하거나
- Redo 로그로 다시 적용함
🔧 해결 방법 ③: 디스크 플러시 (fsync)
개념
- 트랜잭션 커밋 시, 메모리 버퍼에 있는 데이터를 디스크에 완전히 기록 완료될 때까지 기다림
왜 중요하냐면?
- 대부분의 DBMS는 성능을 위해 데이터를 일단 메모리 버퍼에만 적어둠
- 하지만 전원 끊기면 메모리는 사라짐 -> 플러시(fsnyc)를 통해 디스크에 실제로 쓰게 함
비유하자면:
- “저장” 버튼 눌렀다고 해서 문서가 저장된 게 아니라, 실제로 하드에 쓰여야 진짜 저장된 것
3. 트랜잭션의 동작 과정
- BEGIN: 트랜잭션 시작
- 작업 수행 (INSERT/UPDATE/DELETE 등)
- COMMIT: 변경사항을 확정하고 반영
- ROLLBACK: 문제가 생기면 이전 상태로 되돌림
4. 트랜잭션의 격리 수준 (Isolation Level)
여러 사용자가 동시에 데이터베이스를 사용할 때 다른 트랜잭션이 보는 데이터의 일관성을 어디까지 보장할 것인가를 결정한다. 격리 수준이 낮으면 성능은 좋지만, Dirty Read, Non-Repeatable Read, Pahntom Read 같은 문제가 생길 수 있다.
격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read | 성능 |
---|---|---|---|---|
Read Uncommitted | ✅ 발생 | ✅ 발생 | ✅ 발생 | 🔼 빠름 |
Read Committed | ❌ 차단 | ✅ 발생 | ✅ 발생 | 🔼 빠름 |
Repeatable Read | ❌ 차단 | ❌ 차단 | ✅ 발생 (MySQL은 ❌) |
⬆ 중간 |
Serializable | ❌ 차단 | ❌ 차단 | ❌ 차단 | 🔽 느림 |
1. Read Uncommited (읽기 허용)
- 커밋되지 않은 데이터도 다른 트랜잭션이 읽을 수 있음
- Dirty Read 가능
🔸 예시
- 트랜잭션 A가 상품 가격을 100 -> 80으로 수정 (커밋 전)
- 트랜잭션 B가 이걸 읽음 -> 가격이 80으로 보임
- A가 롤백 -> 실제 가격은 100, B는 잘못된 값을 읽음
🔹 사용 주의: 대부분의 시스템에서는 사용하지 않음
2. Read Commited (커밋된 것만 읽기)
- 커밋된 데이터만 읽을 수 있음 -> Dirty Read 방지
- 하지만 Non-Repeatable Read, Phantom Read는 발생
🔸 예시
- A 트랜잭션에서 고객 이름을 두 번 조회했는데
- 그 사이 B 트랜잭션이 이름을 바꾸고 커밋함
- A가 두 번째 조회했을 때 값이 달라짐 -> Non-Repeatable Read 발생
🔹 Oracle, SQL Server의 기본 격리 수준
3. Repeatable Read (반복 읽기 보장)
- 트랜잭션 내에서 같은 데이터를 두 번 읽으면 결과가 항상 동일함
- Non-Repeatable Read 차단
- 하지만 여전히 Phantom Read 가능
- 단, MySQL InnoDB에서는 Next-Key Locking으로 Phantom Read까지 막음
🔸 예시
- A 트랜잭션: “가격이 1만 원 이상인 상품” 조회 -> 10건
- B 트랜잭션이 조건에 맞는 상품 새로 추가하고 커밋
- A가 다시 조회했을 때 11건 -> Phantom Read 발생
🔹 MySQL의 기본 격리 수준
4. Serializable (직렬화)
- 가장 높은 격리 수준
- 모든 트랜잭션을 순차적으로 실행한 것처럼 보이도록 강제
- 모든 문제 (Dirty/Non-Repeatable/Phantom) 방지
- 성능은 가장 낮음 (락, 대기 발생 많음)
🔸 예시
- SELECT도 공유 잠금(Shared Lock)을 걸어서 다른 트랜잭션이 데이터 수정/삽입 불가
- 동시성이 매우 제한됨
🔹 금융 시스템, 회계 시스템처럼 정합성이 최우선인 환경에서 사용
Dirty Read: 커밋 전 데이터를 읽음
Non-Repeatable Read: 같은 행을 두 번 읽었는데 다름
Phantom Read: 같은 조건인데 행 수가 다름
Next-Key Locking (InnoDB만의 기술)
➡ 행 + 다음 인덱스 간 범위까지 잠금 -> Phantom Read 방지
공유 잠금(Shared Lock): 데이터를 읽을 때 설정 -> 다른 트랜잭션이 쓰기 불가
배타 잠금(Exclusive Lock): 데이터를 수정할 때 설정 -> 다른 트랜잭션이 읽기/쓰기 불가
5. Spring에서 트랜잭션 사용 예시
@Transactional
public void transferMoney(Long fromId, Long toId, int amount) {
Account from = accountRepository.findById(fromId);
Account to = accountRepository.findById(toId);
from.decrease(amount);
to.increase(amount);
}
@Transactional
을 사용하면 예외가 발생할 경우 자동으로 rollback됨- DB 연결, 커밋, 롤백을 직접 제어할 필요 없이 Spring이 관리
@Transactional
은 프록시(proxy) 기반 AOP 방식으로 동작하기 때문에, 같은 클래스 내부의 메서드 호출에는 트랜잭션이 적용되지 않는다.
@Transactional
이 동작할 때, Spring은 트랜잭션 상태와 연결(Connection)을 ThreadLocal에 저장하여 관리한다.
즉, 같은 쓰레드 안에서는 여러 DAO/Repository 호출이 하나의 트랜잭션으로 묶여 동작한다.
반면, 다른 쓰레드로 넘어가면 트랜잭션 컨텍스트는 전달되지 않기 때문에 별도로 트랜잭션을 시작해야 한다.
6. 실무 적용 시 고려사항
- 트랜잭션 범위 최소화
→ 너무 길게 유지하면 자원 점유율 상승, Deadlock 위험 증가 - 필요한 곳에만 명확히 지정
→ 읽기 작업은@Transactional(readOnly = true)
로 설정하여 성능 최적화 가능 - 예외 처리 주의
→
@Transactional
은 unchecked 예외(RuntimeException) 발생 시에만 rollback → checked 예외까지 rollback 하려면rollbackFor = Exception.class
지정 필요
Deadlock: 데이터베이스나 운영체제에서 여러 트랜잭션(또는 쓰레드)이 서로 자원을 점유하고 있으면서, 동시에 상대방의 자원을 기다리느라 영원히 진행되지 못하는 상태를 말한다.
댓글남기기