이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Isolation Level은 어떤 이상 현상을 막기 위한 개념인가?
- dirty read, non-repeatable read, phantom read는 실제 API에서 어떻게 보이는가?
- PostgreSQL과 MySQL의 기본 격리 수준 차이를 왜 알아야 하는가?
개요
Isolation Level은 동시에 실행되는 트랜잭션이 서로의 변경을 얼마나 보거나 막을지 정하는 규칙이다. 격리 수준은 정합성과 동시성 사이의 균형이다. 무조건 높이면 안전해 보이지만 대기, 충돌, 재시도 비용이 커질 수 있다.
백엔드 개발자는 격리 수준을 이론으로만 외우지 말고, 사용자 API에서 어떤 이상 현상이 생기는지 연결해야 한다.
원리
대표 이상 현상은 다음이다.
- Dirty read: 커밋되지 않은 다른 트랜잭션의 변경을 읽는다.
- Non-repeatable read: 같은 트랜잭션에서 같은 행을 두 번 읽었는데 값이 달라진다.
- Phantom read: 같은 조건으로 다시 조회했을 때 행 집합이 달라진다.
격리 수준은 일반적으로 다음 순서로 강해진다.
Read Uncommitted
Read Committed
Repeatable Read
Serializable하지만 DB 엔진마다 구현과 기본값이 다르다. PostgreSQL의 Read Uncommitted는 사실상 Read Committed처럼 동작하고, MySQL InnoDB의 기본은 Repeatable Read다. 이름만 같아도 세부 동작이 다를 수 있다.
Spring에서 @Transactional(isolation = Isolation.REPEATABLE_READ)를 지정해도 실제 의미는 연결된 DB가 제공하는 격리 수준에 의존한다. 같은 애너테이션이라도 PostgreSQL과 MySQL InnoDB에서 phantom, gap lock, serialization failure 양상이 다를 수 있으므로, 팀 문서에는 “우리 DB의 기본 격리 수준과 예외 케이스”를 적어두는 것이 좋다.
Dirty Read
Dirty read는 커밋되지 않은 값을 읽는 현상이다.
T1: UPDATE accounts SET balance = 0 WHERE id = 1; -- 아직 commit 안 함
T2: SELECT balance FROM accounts WHERE id = 1; -- 0을 읽음
T1: ROLLBACK;T2는 존재하지 않게 된 값을 읽은 셈이다. 많은 현대 DB 기본 설정에서는 dirty read를 허용하지 않는다. 그래도 개념을 알아야 격리 수준의 출발점을 이해할 수 있다.
Non-repeatable Read
같은 트랜잭션 안에서 같은 행을 두 번 읽었는데 값이 달라질 수 있다.
T1: SELECT status FROM orders WHERE id = 100; -- CREATED
T2: UPDATE orders SET status = 'PAID' WHERE id = 100; COMMIT;
T1: SELECT status FROM orders WHERE id = 100; -- PAIDRead Committed에서는 이런 일이 가능할 수 있다. 관리자 화면에서 하나의 트랜잭션 안에서 여러 번 상태를 확인하는 로직이라면 혼란이 생길 수 있다.
반대로 일반적인 CRUD API에서는 매 statement마다 최신 커밋 데이터를 보는 Read Committed가 자연스러운 경우도 많다. 격리 수준 선택은 “가장 강한 것”이 아니라 “이 유스케이스에서 다시 읽을 때 값이 바뀌면 실제 문제가 되는가”로 판단한다.
Phantom Read
조건에 맞는 행 집합이 달라지는 현상이다.
T1: SELECT count(*) FROM coupon_issues WHERE coupon_id = 10; -- 99
T2: INSERT INTO coupon_issues(coupon_id, member_id) VALUES (10, 200); COMMIT;
T1: SELECT count(*) FROM coupon_issues WHERE coupon_id = 10; -- 100쿠폰 재고를 count(*)로 확인하고 insert하는 방식은 phantom과 race condition에 취약하다. 재고는 조건부 update, lock, Unique 제약, 별도 재고 모델로 설계해야 한다.
예를 들어 선착순 쿠폰은 다음처럼 남은 수량 row를 원자적으로 줄이는 방식이 더 단순할 수 있다.
UPDATE coupons
SET remaining_count = remaining_count - 1
WHERE id = :couponId
AND remaining_count > 0;영향 row 수가 1이면 성공, 0이면 소진으로 해석한다. 이후 발급 이력에는 (coupon_id, member_id) Unique 제약을 둬 중복 발급을 막는다.
실전 판단
격리 수준을 높이기 전에 다음을 묻는다.
- 막고 싶은 이상 현상은 무엇인가?
- DB 제약이나 원자적 update로 해결할 수 있는가?
- 충돌 시 재시도 가능한가?
- 성능과 lock 대기 비용은 감당 가능한가?
- 해당 DB의 격리 수준 구현은 무엇인가?
Serializable은 강력하지만 serialization failure 재시도 설계가 필요할 수 있다. 격리 수준을 올리는 것만으로 애플리케이션 책임이 사라지지는 않는다.
실전 팁
- DB 기본 격리 수준을 확인하고 문서화한다.
- 재고, 포인트, 중복 발급은 격리 수준보다 원자적 조건 update와 Unique 제약을 먼저 검토한다.
- Repeatable Read라는 이름만 보고 phantom이 완전히 사라진다고 일반화하지 않는다.
- 강한 격리 수준을 쓰는 코드는 재시도 전략을 함께 둔다.
- 테스트에서는 동시 요청 시나리오를 직접 만든다.
- Spring isolation 설정은 DB별 실제 구현과 함께 문서화한다.
- 수량 제한은
count 후 insert보다 조건부 update, Unique 제약, 재시도 가능한 실패 처리를 먼저 검토한다. - Serializable을 쓰면 실패 가능성이 사라지는 것이 아니라 serialization failure를 처리해야 할 수 있음을 로그와 테스트로 확인한다.
위험 신호!
- 격리 수준을 모르고
@Transactional만 붙인다. count(*) 후 insert로 수량 제한을 구현한다.- Serializable로 올리면 재시도가 필요 없다고 생각한다.
- PostgreSQL과 MySQL의 기본 동작 차이를 무시한다.
- 동시성 버그를 단일 스레드 테스트로만 검증한다.
@Transactional(isolation=...)만 보고 실제 DB의 gap lock, MVCC, retry 오류를 확인하지 않는다.- 선착순/재고 기능을 count 결과만 믿고 구현한다.
확인 질문
- Isolation Level은 무엇을 조절하는가?
- 동시에 실행되는 트랜잭션이 서로의 변경을 얼마나 볼 수 있고 어떤 이상 현상을 허용할지를 조절한다.
- Phantom read가 재고 차감 로직에서 위험한 이유는 무엇인가?
- 조건에 맞는 행 수를 확인한 뒤 다른 트랜잭션이 새 행을 추가하면 수량 제한 판단이 깨질 수 있기 때문이다.
- 격리 수준을 높일 때 함께 설계해야 하는 것은 무엇인가?
- lock 대기, 성능 비용, serialization failure나 deadlock 재시도, 멱등성을 함께 설계해야 한다.
- 선착순 쿠폰을
count(*) 후 insert로 구현하면 위험한 이유는 무엇인가?- 동시에 여러 트랜잭션이 같은 count를 보고 insert할 수 있어 수량 제한이 깨질 수 있으므로, 조건부 update와 Unique 제약 같은 DB 원자성을 함께 써야 하기 때문이다.