트랜잭션은 여러 작업을 하나의 단위로 묶어 처리하는 개념이다. A 계좌에서 돈을 빼고 B 계좌에 넣는 두 작업이 있을 때, 중간에 실패하면 둘 다 없던 일로 되어야 한다. 이 "둘 다 되거나, 둘 다 안 되거나"가 트랜잭션의 핵심이다.
트랜잭션이 보장해야 하는 4가지 성질이다.
트랜잭션 내 모든 작업은 전부 성공하거나 전부 실패해야 한다.
A 계좌 -10만원 성공
B 계좌 +10만원 실패 → A 계좌 -10만원도 rollback
중간 상태가 DB에 남아서는 안 된다. A 계좌에서 돈은 빠졌는데 B 계좌에 들어오지 않은 상태가 지속되면 데이터가 손상된다.
트랜잭션 전후로 DB에 정의된 제약 조건이 유지되어야 한다.
제약 조건은 단순히 데이터 타입만이 아니다. 아래처럼 다양한 규칙이 포함된다.
잔액 >= 0 (CHECK 제약)트랜잭션이 실행되기 전에 이 규칙들이 모두 만족된 상태였다면, 트랜잭션이 끝난 후에도 여전히 만족된 상태여야 한다.
동시에 실행 중인 트랜잭션들이 서로 영향을 주지 않아야 한다.
격리가 없으면 어떤 일이 생기는지 보면 이해가 빠르다.
트랜잭션 A: 재고 조회 → 1개 남음
트랜잭션 B: 재고 조회 → 1개 남음
트랜잭션 A: 재고 차감 → 0개로 업데이트
트랜잭션 B: 재고 차감 → 0개로 업데이트 (이미 0인데 또 차감)
A와 B가 동시에 같은 재고를 읽어 둘 다 구매에 성공했지만, 실제로는 재고가 1개뿐이었다. 이처럼 여러 트랜잭션이 서로의 작업에 끼어들면 데이터 정합성이 깨진다.
완벽하게 격리하려면 트랜잭션을 하나씩 순차 실행하면 된다. 하지만 그러면 성능이 너무 떨어지기 때문에 "얼마나 격리할 것인가"를 단계로 나눠 선택할 수 있게 했다 (격리 수준은 아래에서 자세히).
커밋된 데이터는 이후 시스템 장애(서버 다운, 전원 차단 등)가 발생해도 영구적으로 보존되어야 한다.
DB는 커밋 시 데이터를 메모리가 아닌 디스크에 기록하고, WAL(Write-Ahead Log) 같은 방식으로 장애 복구를 보장한다.
격리성을 완벽하게 보장할수록 성능이 떨어진다. 동시에 처리할 수 있는 트랜잭션 수가 줄어들기 때문이다. 그래서 SQL 표준은 격리 수준을 4단계로 나눠 상황에 맞게 선택할 수 있게 했다.
아직 커밋되지 않은 데이터도 읽는다.
트랜잭션 A: 잔액 10만 → 5만으로 수정 (아직 커밋 안 함)
트랜잭션 B: 잔액 조회 → 5만 읽음
트랜잭션 A: 에러 발생 → rollback (잔액 다시 10만으로 복구)
트랜잭션 B: 존재하지 않았던 5만이라는 값을 읽은 셈
이를 Dirty Read라고 한다. 커밋되지 않은, 즉 확정되지 않은 값을 읽었다가 그 값이 rollback으로 사라지면 잘못된 데이터를 기반으로 후속 로직이 실행될 수 있다. 데이터 정합성이 전혀 보장되지 않아 실제로는 거의 쓰지 않는다.
커밋이 완료된 데이터만 읽는다. Dirty Read는 막히지만 새로운 문제가 생긴다.
트랜잭션 B: 유저 조회 → 김철수
트랜잭션 A: 김철수 → 김민수로 수정 후 commit
트랜잭션 B: 같은 유저 다시 조회 → 김민수
트랜잭션 B 입장에서는 같은 트랜잭션 안에서 같은 쿼리를 두 번 실행했는데 결과가 달라졌다. 이를 Non-Repeatable Read라고 한다.
Read Committed는 "쿼리 실행 시점의 최신 커밋 데이터를 읽는다"는 방식이기 때문에, 내 트랜잭션이 끝나기 전에 다른 트랜잭션이 커밋하면 그 변경사항이 바로 보인다.
트랜잭션이 시작될 때 스냅샷을 찍어두고, 트랜잭션이 끝날 때까지 그 스냅샷 기준으로만 읽는다.
트랜잭션 B: 유저 조회 → 김철수 (스냅샷 고정)
트랜잭션 A: 김철수 → 김민수로 수정 후 commit
트랜잭션 B: 같은 유저 다시 조회 → 여전히 김철수 (스냅샷 기준)
Non-Repeatable Read는 막힌다. 트랜잭션 A가 커밋을 해도 B의 읽기 결과는 바뀌지 않는다.
그런데 스냅샷은 "기존 행의 값 변경"만 고정한다. 새로운 행이 추가되는 경우는 다르다.
트랜잭션 B: 유저 목록 조회 → [김철수] 1명
트랜잭션 A: 김영희라는 새 유저 추가 후 commit
트랜잭션 B: 같은 조회 다시 실행 → [김철수, 김영희] 2명
스냅샷이 김철수의 값이 바뀌는 건 막아줬지만, 없던 김영희가 새로 생겨서 보이는 건 막지 못했다. 유령처럼 갑자기 나타났다고 해서 이를 Phantom Read라고 한다.
Read Committed vs Repeatable Read 핵심 차이:
| Read Committed | Repeatable Read | |
|---|---|---|
| 읽기 기준 | 쿼리 실행 시점의 최신 커밋 | 트랜잭션 시작 시점의 스냅샷 |
| 같은 쿼리를 두 번 실행하면 | 결과가 달라질 수 있음 | 기존 행은 항상 같음 |
| 막는 문제 | Dirty Read | Dirty Read + Non-Repeatable Read |
| 남는 문제 | Non-Repeatable Read | Phantom Read |
MySQL InnoDB는 기본값이 Repeatable Read이고, 갭 락(Gap Lock)을 추가로 사용해 새 행 삽입 자체를 막아 Phantom Read까지 어느 정도 방어한다.
트랜잭션을 완전히 순차적으로 실행한다. 내가 읽은 범위에 다른 트랜잭션이 데이터를 추가하거나 수정하는 것 자체를 락으로 막는다.
Repeatable Read는 "내 읽기 결과를 스냅샷으로 고정"하는 방식이라 다른 트랜잭션의 쓰기 자체는 허용한다. 반면 Serializable은 내가 읽은 범위에 다른 트랜잭션이 접근하려 하면 대기하게 만든다.
모든 이상 현상이 차단되지만 동시 처리 가능한 트랜잭션 수가 급격히 줄어 실제 서비스에서는 거의 쓰지 않는다.
| 단계 | 이름 | 발생 가능한 문제 |
|---|---|---|
| 1 | Read Uncommitted | Dirty Read |
| 2 | Read Committed | Non-Repeatable Read |
| 3 | Repeatable Read | Phantom Read |
| 4 | Serializable | 없음 |
격리성을 구현하는 전략이다. 격리 수준이 "얼마나 허용할 것인가"의 정책이라면, 락은 그 정책을 실제로 구현하는 방법이다.
"충돌이 발생하지 않을 것"이라고 가정하고 먼저 작업을 수행한다. 커밋 시점에 충돌 여부를 확인하고, 충돌이 발생했으면 그때 처리한다.
JPA에서는 @Version 컬럼으로 구현한다.
@Entity
public class Account {
@Id
private Long id;
private int balance;
@Version // JPA가 자동으로 버전 비교를 처리
private Long version;
}
동작 흐름은 다음과 같다.
트랜잭션 A: account 조회 → balance=10만, version=1
트랜잭션 B: account 조회 → balance=10만, version=1
트랜잭션 A: UPDATE SET balance=9만, version=2 WHERE id=1 AND version=1
→ 성공. DB의 version이 2로 변경됨.
트랜잭션 B: UPDATE SET balance=7만, version=2 WHERE id=1 AND version=1
→ WHERE version=1에 해당하는 행이 없음 (이미 version=2)
→ 업데이트 0건 → OptimisticLockException 발생
→ 애플리케이션에서 재시도 처리
충돌이 드문 상황에 적합하다. 대부분의 경우 충돌 없이 완료되므로 락을 잡는 오버헤드가 없다. 하지만 충돌이 잦은 상황에서는 예외 발생 후 재시도를 반복해야 해서 오히려 성능이 나빠지고, 사용자 입장에서도 재시도로 인한 지연이 느껴진다.
"충돌이 발생할 것"을 전제하고, 작업 전에 먼저 락을 선점한다. 락을 잡은 동안 다른 트랜잭션은 해당 자원에 접근하지 못하고 대기한다.
구현 방식을 선택할 때 환경을 고려해야 한다.
애플리케이션 레벨 락 (synchronized, ReentrantLock 등)
단일 서버 환경에서는 동작하지만 서버가 여러 대인 분산 환경에서는 무용지물이다. 예를 들어 서버 A와 서버 B가 동시에 같은 데이터에 접근할 때, 서버 A의 synchronized 블록은 서버 A의 JVM 메모리 내에서만 유효하다. 서버 B는 서버 A의 락 상태를 알 수 없기 때문에 동시에 같은 자원에 접근하는 상황이 그대로 발생한다.
DB 레벨 락 (SELECT FOR UPDATE 등)
SELECT * FROM products WHERE id = 1 FOR UPDATE;
-- 이 트랜잭션이 끝날 때까지 다른 트랜잭션은 해당 행에 쓰기 불가
분산 환경에서도 모든 서버가 같은 DB를 바라보므로 락이 정상적으로 동작한다. 하지만 DB 커넥션을 락이 해제될 때까지 점유하기 때문에 트래픽이 몰리면 커넥션 풀이 고갈되어 전체 서비스가 멈출 수 있다.
Redis 분산 락
Redis는 단일 스레드로 동작하기 때문에 동시에 두 요청이 들어와도 하나씩 순서대로 처리된다. 이 특성을 이용해 분산 락을 구현한다.
서버 A: Redis에 락 키 SET (성공) → 작업 진행
서버 B: Redis에 락 키 SET 시도 → 이미 키가 있으므로 실패 → 대기 또는 재시도
서버 A: 작업 완료 → Redis 락 키 삭제
서버 B: 락 키 SET 성공 → 이제 작업 진행
DB 커넥션을 점유하지 않고, 메모리 기반이라 빠르며, 모든 서버가 동일한 Redis를 바라보므로 분산 환경에서도 정확하게 동작한다. 재고 차감, 선착순 이벤트처럼 동시 요청이 몰리면서 정합성이 중요한 상황에 주로 쓴다.
외부 트랜잭션이 실행 중인 상태에서 내부 메서드에도 @Transactional이 붙어 있을 때, 둘을 어떻게 묶을 것인지를 결정하는 옵션이다.
기존 트랜잭션이 있으면 그 트랜잭션에 합류하고, 없으면 새로 생성한다.
주문 서비스 (트랜잭션 시작)
└── 재고 차감 서비스 (REQUIRED) → 기존 트랜잭션에 합류
재고 차감에서 에러 발생
→ 재고 차감 롤백 + 주문 생성도 같이 롤백
가장 흔한 케이스다. 주문과 재고 차감은 하나의 작업으로 취급되어야 하므로 둘 중 하나라도 실패하면 전부 롤백된다.
기존 트랜잭션이 있어도 무시하고, 완전히 독립된 새 트랜잭션을 생성한다. 외부 트랜잭션과 내부 트랜잭션은 완전히 별개로 동작한다.
주문 서비스 (트랜잭션 A 시작)
└── 실패 로그 기록 서비스 (REQUIRES_NEW) → 별개의 트랜잭션 B 생성
트랜잭션 B (로그 기록): 커밋 완료
트랜잭션 A (주문): 에러 발생 → 롤백
결과: 주문은 취소되었지만, 실패 로그는 DB에 남아있음
주문이 실패했을 때 어떤 이유로 실패했는지 기록을 남겨야 한다. 그런데 REQUIRED를 쓰면 주문 롤백 시 로그도 같이 롤백되어 기록이 사라진다. REQUIRES_NEW를 쓰면 로그 트랜잭션이 독립적으로 커밋되므로 주문 실패 여부와 무관하게 기록이 남는다.
외부 트랜잭션 안에 savepoint를 찍어 중첩 트랜잭션을 생성한다.
주문 서비스 (외부 트랜잭션)
└── 알림 발송 서비스 (NESTED) → savepoint 생성
알림 발송 실패 → savepoint로 롤백 (알림만 없던 일로)
→ 주문 서비스는 계속 진행, 정상 커밋
주문 서비스 실패 → 전체 롤백 (알림 포함)
알림 발송은 실패해도 주문 자체에 영향을 주면 안 된다. 하지만 주문이 아예 실패했다면 알림도 보낼 필요가 없다. 내부만 부분 롤백 가능하면서 외부가 실패하면 같이 롤백되는 이 구조가 NESTED의 역할이다.
두 트랜잭션이 서로 상대방이 점유한 락을 기다리며 영원히 멈추는 상태다.
트랜잭션 A: users 테이블 락 획득 → orders 테이블 락 시도 중
트랜잭션 B: orders 테이블 락 획득 → users 테이블 락 시도 중
A는 B가 orders 락을 해제하길 기다림
B는 A가 users 락을 해제하길 기다림
→ 둘 다 영원히 대기
접근 순서가 달라서 발생하는 문제다.
타임아웃
일정 시간 이상 락을 기다리면 해당 트랜잭션을 강제로 실패 처리하고 재시도한다. 구현이 간단하지만 타임아웃이 발생할 때마다 재시도 비용이 발생한다.
락 순서 통일
데드락의 근본 원인은 트랜잭션마다 락 획득 순서가 다르다는 것이다. 코드 레벨에서 모든 트랜잭션이 항상 같은 순서로 락을 획득하도록 규칙을 정하면 데드락이 발생하지 않는다.
기존:
트랜잭션 A: users → orders 순서
트랜잭션 B: orders → users 순서 ← 순서가 달라서 데드락 발생
개선:
트랜잭션 A: users → orders 순서
트랜잭션 B: users → orders 순서 ← 강제 통일
→ B가 users 락을 기다릴 때 A가 이미 잡고 있으면 그냥 대기
→ 서로 다른 걸 잡고 기다리는 상황 자체가 없어짐
이 규칙은 코드 컨벤션이나 유틸리티 메서드로 강제해야 한다. 개발자가 실수로 순서를 바꾸면 다시 데드락이 생길 수 있기 때문이다.
Repeatable Read에서 스냅샷을 어떻게 구현하는지의 내부 동작이다.
데이터를 변경할 때 기존 값을 덮어쓰지 않는다. 대신 이전 버전을 Undo Log라는 별도 공간에 보관하고, 실제 데이터는 최신 값으로 업데이트한다.
김철수를 김민수로 UPDATE:
실제 데이터 페이지: 김민수 (최신)
Undo Log: 김철수, 트랜잭션 ID=99 (이전 버전)
각 트랜잭션은 시작 시 고유한 ID를 부여받는다. 읽기를 수행할 때 "내 트랜잭션 ID보다 이전에 커밋된 버전"을 기준으로 Undo Log를 역추적해 적절한 버전을 찾아 읽는다.
트랜잭션 B (ID=100): "나는 ID 100 이전에 커밋된 버전만 볼게"
김민수 행: 트랜잭션 ID=105에서 변경됨 → 내 기준(100)보다 이후 → 못 봄
Undo Log: 트랜잭션 ID=99에서 변경됨 → 내 기준(100)보다 이전 → 이 버전 읽음
→ 김철수를 읽음
이 방식의 핵심은 읽기 작업이 쓰기 작업을 블로킹하지 않는다는 것이다. 트랜잭션 B가 읽는 동안 트랜잭션 A는 자유롭게 데이터를 수정할 수 있다. 각자 자신이 봐야 할 버전이 다를 뿐이다. 락 없이 읽기/쓰기 동시 처리가 가능한 이유가 여기에 있다.
Undo Log는 DB가 백그라운드에서 Purge 작업으로 주기적으로 정리한다. 정리 기준은 "어떤 트랜잭션도 더 이상 참조하지 않는 버전"이다.
그런데 트랜잭션 하나가 오랫동안 열려있으면 문제가 생긴다.
트랜잭션 A (시작 후 30분째 열려있음, ID=50)
그동안 발생한 모든 변경사항의 Undo Log:
트랜잭션 ID=51의 변경 → A가 참조할 수 있음 → 못 지움
트랜잭션 ID=52의 변경 → A가 참조할 수 있음 → 못 지움
...
트랜잭션 ID=9999의 변경 → A가 참조할 수 있음 → 못 지움
트랜잭션 A가 살아있는 한, A 시작 이후 발생한 모든 Undo Log를 삭제할 수 없다. 이 기간이 길수록 Undo Log가 무한정 쌓여 디스크를 점유한다. 심한 경우 DB 성능 저하나 디스크 풀로 이어질 수 있다.
@Transactional 범위를 최대한 좁게 잡아야 하는 이유 중 하나다. 트랜잭션 안에서 외부 API 호출, 파일 I/O, 오래 걸리는 연산 같은 작업을 포함시키면 트랜잭션이 불필요하게 오래 열려있게 된다.
마이크로서비스 환경에서는 서비스마다 독립된 DB를 갖는다. 주문 서비스는 주문 DB, 결제 서비스는 결제 DB, 배송 서비스는 배송 DB를 각각 따로 사용한다. 이 환경에서 하나의 트랜잭션으로 세 DB를 묶는 것은 사실상 불가능하다.
SAGA 패턴은 이 문제를 각 서비스가 자기 트랜잭션을 독립적으로 커밋하되, 실패하면 보상 트랜잭션(Compensating Transaction)을 실행해 이전 상태로 되돌리는 방식으로 해결한다.
1. 주문 서비스: 주문 생성 → commit
2. 결제 서비스: 결제 처리 → commit
3. 배송 서비스: 배송 요청 → 실패
보상 트랜잭션 역방향 실행:
2. 결제 서비스: 환불 처리 → commit
1. 주문 서비스: 주문 취소 → commit
보상 트랜잭션은 멱등성(idempotency)을 보장해야 한다. 네트워크 오류 등으로 보상 트랜잭션 자체가 실패할 수 있기 때문에, 동일한 보상 트랜잭션이 여러 번 실행되더라도 결과가 동일해야 한다. 예를 들어 환불 트랜잭션이 두 번 실행돼도 돈이 두 번 돌아오면 안 된다.
SAGA를 구현하는 방식이 두 가지다.
Choreography (안무 방식)
각 서비스가 자신의 작업이 끝나면 이벤트를 발행하고, 다음 서비스가 그 이벤트를 구독해 작업을 시작한다. 중앙에서 흐름을 제어하는 주체가 없다.
주문 서비스 → "주문 생성됨" 이벤트 발행
결제 서비스 → 이벤트 수신, 결제 처리 후 "결제 완료됨" 이벤트 발행
배송 서비스 → 이벤트 수신, 배송 처리
서비스 간 결합도가 낮다는 장점이 있지만, 전체 흐름이 여러 서비스에 흩어져 있어 어느 단계에서 실패했는지 파악하기가 어렵다.
Orchestration (지휘 방식)
중앙 오케스트레이터가 각 서비스에게 순서대로 무엇을 할지 지시한다.
오케스트레이터 → 주문 서비스에 "주문 생성해"
오케스트레이터 → 결제 서비스에 "결제 처리해"
오케스트레이터 → 배송 서비스에 "배송 시작해" → 실패
오케스트레이터 → 결제 서비스에 "환불해" (보상 트랜잭션 지시)
오케스트레이터 → 주문 서비스에 "주문 취소해" (보상 트랜잭션 지시)
전체 흐름이 오케스트레이터 한 곳에서 관리되어 어느 단계에서 실패했는지 명확하게 파악할 수 있다. 실패 추적과 재시도 관리가 쉬워 실무에서 선호한다.