트랜잭션
트랜잭션은 여러 개의 SQL 문을 하나의 작업 단위로 묶는 것입니다. 계좌 이체처럼 "출금"과 "입금"이 각각 하나의 SQL문이어도, 두 문장이 모두 성공하거나 모두 실패해야만 데이터가 올바른 상태를 유지합니다. 이걸 보장하기 위한 4가지 성질이 ACID이고, 트랜잭션은 보통 COMMIT(확정) 또는 ROLLBACK(취소)으로 끝납니다.
핵심 개념
원자성 (Atomicity)
트랜잭션 안의 문장은 전부 성공하거나 전부 실패합니다. "반만 적용"되는 상태는 존재하지 않습니다.
핵심 개념
일관성 (Consistency)
트랜잭션 전후로 데이터는 정해진 규칙(제약조건과 업무 규칙)을 만족해야 합니다. 예: 두 계좌 잔액의 합은 이체 전후로 같아야 합니다.
핵심 개념
고립성 (Isolation)
동시에 여러 트랜잭션이 실행돼도 서로의 영향을 받지 않게 합니다. 커밋되지 않은 중간 상태가 얼마나 보이는지는 격리 수준에 따라 다릅니다(아래 격리 수준 참고).
핵심 개념
지속성 (Durability)
기본 설정에서 COMMIT이 끝나면 그 결과는 디스크(로그)에 남습니다. 이후 시스템이 다운되어도 사라지지 않습니다.
핵심 개념
COMMIT
트랜잭션 안의 모든 문장이 성공했을 때, 임시 반영된 변경을 영구적으로 확정합니다. COMMIT 이후에는 되돌릴 수 없습니다.
핵심 개념
ROLLBACK
문장 중 하나라도 실패해 ROLLBACK하면(또는 DB가 트랜잭션을 중단하면), 이미 실행됐던 것까지 포함해 트랜잭션 시작 시점 상태로 되돌립니다.
@Transactional을 붙이는 이유가 여기 있습니다. 메서드 안에서 런타임 예외가 발생하면 Spring이 기본적으로 ROLLBACK을 호출해줍니다(검사 예외는 기본적으로 롤백하지 않습니다).
트랜잭션 개념의 배경
트랜잭션 개념은 1970~80년대 데이터베이스 연구에서 다듬어졌습니다. 짐 그레이(Jim Gray)는 1981년 글에서 트랜잭션의 개념과 한계를 정리했고, ACID라는 이름은 1983년 헤어더(Haerder)와 로이터(Reuter)의 논문에서 붙은 것으로 알려져 있습니다. 지속성은 보통 WAL(Write-Ahead Logging)으로 구현하고, 원자성은 로그(undo 정보)나 다중 버전 같은 방식으로 구현하며 DB마다 다릅니다. WAL은 데이터 페이지를 고치기 전에 변경 내용을 로그에 먼저 기록하고, 기본 설정에서 커밋은 그 로그가 디스크에 남은 뒤에 확정합니다.
격리 수준과 이상 현상
고립성을 완전히 지키면 성능이 떨어지므로, SQL 표준은 네 가지 격리 수준을 정의하고 수준마다 허용하는 이상 현상이 다릅니다. 더티 리드는 커밋되지 않은 값을 읽는 것, 반복 불가능한 읽기는 같은 행을 두 번 읽었는데 값이 달라지는 것, 팬텀 리드는 같은 조건으로 두 번 조회했는데 행의 집합이 달라지는 것입니다.
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
트랜잭션 격리 수준 네 가지와 각각이 막는 이상 현상은 무엇인가요?
READ UNCOMMITTED는 아무것도 막지 않고, READ COMMITTED는 더티 리드를, REPEATABLE READ는 여기에 반복 불가능한 읽기를, SERIALIZABLE은 팬텀까지 막는다는 것이 표준의 설명입니다. DB마다 구현이 달라 같은 이름이어도 막는 범위가 다를 수 있습니다.
면접 질문
COMMIT 이후 장애가 나도 데이터가 남는 이유는 무엇인가요?
WAL 때문입니다. 변경 내용을 로그에 먼저 기록하고 기본 설정에서는 그 로그가 디스크에 쓰인 뒤에 커밋을 알리므로, 재시작하면 로그를 다시 적용해 커밋된 변경을 복구합니다.
면접 질문
낙관적 락과 비관적 락은 트랜잭션과 어떻게 연결되나요?
둘 다 같은 데이터를 동시에 고치는 트랜잭션끼리의 충돌을 다루는 방법입니다. 비관적 락은 DB 락으로 미리 막고, 낙관적 락은 버전이 바뀌었는지로 충돌을 감지합니다.
면접 질문
@Transactional이 동작하지 않는 흔한 경우는 언제인가요?
같은 클래스 안의 메서드를 직접 호출할 때, private 같은 프록시가 가로챌 수 없는 메서드에 붙였을 때, 검사 예외를 롤백 대상으로 지정하지 않았을 때가 대표적입니다.
실제 시스템에서는
함께 보면 좋은 토픽
참고 자료
Java — @Transactional로 이체 서비스 구현
@Service @RequiredArgsConstructor public class TransferService { private final AccountRepository accountRepository; @Transactional public void transfer(Long fromId, Long toId, long amount) { Account from = accountRepository.findById(fromId) .orElseThrow(() -> new NotFoundException("계좌 없음: " + fromId)); Account to = accountRepository.findById(toId) .orElseThrow(() -> new NotFoundException("계좌 없음: " + toId)); from.withdraw(amount); // 첫 번째 UPDATE에 해당 if (to.isFrozen()) { throw new IllegalStateException("계좌가 정지 상태라 입금할 수 없습니다."); } to.deposit(amount); // 두 번째 UPDATE에 해당 } }
withdraw() 이후 예외가 던져지면, @Transactional이 감싸고 있는 이 메서드 전체가 RuntimeException 기준으로 자동 ROLLBACK됩니다 — 이미 반영된 from.withdraw()도 함께 취소됩니다.
이 시각화의 "롤백(실패)" 탭에서 두 번째 UPDATE가 "계좌가 정지 상태"로 실패하는 것과 정확히 같은 상황입니다.
SQL — @Transactional이 내부적으로 실행하는 것
START TRANSACTION; UPDATE accounts SET balance = balance - 20000 WHERE id = 1; UPDATE accounts SET balance = balance + 20000 WHERE id = 2; -- 둘 다 성공하면: COMMIT; -- 하나라도 실패하면(애플리케이션 예외 포함): ROLLBACK;
@Transactional이 붙은 메서드에 진입하는 순간 START TRANSACTION이, 정상 종료되면 COMMIT이, 예외가 던져지면 ROLLBACK이 자동으로 실행됩니다.
즉 개발자가 직접 COMMIT/ROLLBACK을 호출하지 않아도, 메서드의 정상/예외 종료 여부만으로 트랜잭션 경계가 결정됩니다.
Java — 흔한 함정: 같은 클래스 안에서 호출하면 롤백이 안 걸림
@Service public class OrderService { public void placeOrder(Long userId) { // this.saveOrder(userId) — 프록시를 거치지 않는 내부 호출! saveOrder(userId); // @Transactional이 적용되지 않는다 } @Transactional public void saveOrder(Long userId) { // ... } }
@Transactional은 스프링이 만든 프록시 객체를 거쳐야 동작하는데, 같은 클래스 안에서 this.saveOrder()처럼 직접 호출하면 프록시를 우회해서 트랜잭션이 아예 시작되지 않습니다.
해결책은 saveOrder()를 별도의 @Service로 분리해서 주입받아 호출하는 것입니다 — 이 프로젝트의 생성자 주입(@RequiredArgsConstructor) 규칙과도 자연스럽게 맞물립니다.
Q1
ACID 중 '원자성(Atomicity)'의 의미는?
Q2
COMMIT과 ROLLBACK의 차이는?
Q3
ACID 중 '고립성(Isolation)'이 필요한 이유는?