트랜잭션 격리 수준과 Write Skew: DB 동시성 이상 현상 정리

· 약 12분 읽기 · 조회 1 #트랜잭션#격리 수준#동시성#PostgreSQL#MySQL#Spring Boot

격리 수준은 "동시에 실행된 트랜잭션이 서로를 얼마나 못 보게 할 것인가"를 정하는 설정이다. 이름은 DB마다 같아도 실제로 막아 주는 현상은 다르고, 특히 Lost Update와 Write Skew는 SQL 표준의 격리 수준 정의에 아예 나오지 않아 테스트에서 놓치기 쉽다. 이 글은 다섯 가지 동시성 이상 현상, PostgreSQL·MySQL·Oracle의 구현 차이, 그리고 잠금·제약·버전·SERIALIZABLE 재시도로 막는 방법을 정리한 것이다.

유리 칸막이를 사이에 둔 두 직원이 같은 장부 사본을 보며 동시에 기입하는 은행 사무실

한눈에 보기

이상 현상 무엇이 일어나나 표준에서 막히는 수준 대표 대책
Dirty Read 커밋 안 된 남의 변경을 읽는다 READ COMMITTED 기본값만 지켜도 막힌다
Non-repeatable Read 같은 행을 두 번 읽었는데 값이 바뀌어 있다 REPEATABLE READ 스냅샷 읽기
Phantom 같은 조건으로 두 번 조회했는데 행 수가 바뀐다 SERIALIZABLE 범위 잠금, 스냅샷
Lost Update 둘이 읽고 계산해서 쓰면 한쪽 변경이 사라진다 표준에 없음 원자적 UPDATE, FOR UPDATE, 버전
Write Skew 각자 다른 행을 고쳤는데 합치면 규칙이 깨진다 표준에 없음 읽은 행 잠금, 제약, SERIALIZABLE

위 세 줄은 SQL-92 표준이 격리 수준을 정의할 때 쓴 현상이다. 아래 두 줄은 1995년 논문 "A Critique of ANSI SQL Isolation Levels"(Berenson 외)가 표준의 빈틈으로 지적한 현상이다. 운영 장애는 대개 아래 두 줄에서 난다.

SQL 표준의 네 단계와 세 가지 현상

표준은 네 단계를 "어떤 현상을 허용하지 않는가"로만 정의한다.

  • READ UNCOMMITTED: 아무것도 막지 않는다.
  • READ COMMITTED: Dirty Read를 막는다.
  • REPEATABLE READ: Non-repeatable Read까지 막는다.
  • SERIALIZABLE: Phantom까지 막고, 결과가 어떤 순서로 하나씩 실행한 것과 같아야 한다.

표준은 "어떻게" 막는지 정하지 않았다. 그래서 어떤 DB는 잠금으로, 어떤 DB는 스냅샷(MVCC)으로 구현했고, 같은 REPEATABLE READ라도 막는 범위가 다르다. 격리 수준 이름만 보고 동작을 짐작하면 안 되는 이유다.

Lost Update: 읽고 계산해서 쓰면 한쪽이 사라진다

재고 5개인 상품을 두 요청이 동시에 하나씩 산다. 둘 다 5를 읽고, 애플리케이션에서 4를 계산해 저장한다. 두 개가 팔렸는데 재고는 4다.

-- 위험: 애플리케이션이 계산한 값을 쓴다
SELECT stock FROM item WHERE id = 10;        -- A, B 모두 5
UPDATE item SET stock = 4 WHERE id = 10;     -- A, B 모두 4를 쓴다

-- 안전: DB가 최신 값으로 계산하고 조건까지 검사한다
UPDATE item SET stock = stock - 1 WHERE id = 10 AND stock >= 1;
-- 영향받은 행 수가 0이면 재고 부족으로 처리

원자적 UPDATE는 행 잠금을 잡은 뒤 최신 값을 기준으로 계산하므로 READ COMMITTED에서도 안전하다. JPA처럼 엔티티를 읽어 필드를 바꾸는 코드는 첫 번째 패턴과 같으므로 @Version(낙관적 락)이나 SELECT ... FOR UPDATE가 필요하다.

Write Skew: 각자 맞았는데 합치면 규칙이 깨진다

병원 규칙은 "한 근무조에 당직 의사가 최소 1명"이다. 당직자 김과 이가 동시에 휴무를 신청한다. 두 트랜잭션은 같은 스냅샷에서 당직자 2명을 확인하고, "나 하나 빠져도 1명 남는다"고 판단해 각자 자기 행만 바꾼다.

두 트랜잭션이 같은 스냅샷에서 당직자 2명을 확인하고 각자 자기 행만 휴무로 바꿔 커밋해 당직이 0명이 되는 시퀀스

-- A와 B가 동시에 실행 (B는 name = '이')
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE shift_id = 7 AND on_call;   -- 2
UPDATE doctors SET on_call = false WHERE shift_id = 7 AND name = '김';
COMMIT;

두 UPDATE는 서로 다른 행을 고친다. 같은 행을 두고 다투지 않으니 행 잠금도, 스냅샷 격리의 "먼저 고친 쪽이 이긴다" 검사도 걸리지 않는다. 둘 다 정상 커밋되고 당직은 0명이 된다. 회의실 중복 예약, 한도 합계 초과, 유일해야 하는 사용자명 선점처럼 "다른 행들을 읽고 판단한 뒤 쓰는" 모든 규칙이 같은 구조다.

같은 이름, 다른 동작: DB별 격리 수준 구현

항목 PostgreSQL MySQL InnoDB Oracle
기본값 READ COMMITTED REPEATABLE READ READ COMMITTED
REPEATABLE READ의 실체 스냅샷 격리. 같은 행을 동시에 고치면 뒤쪽이 SQLSTATE 40001 일반 SELECT는 스냅샷, 잠금 읽기와 UPDATE는 최신 값 + 넥스트키 락 해당 수준 없음
Lost Update(읽고 계산해서 쓰기) REPEATABLE READ 이상에서 오류로 막는다 REPEATABLE READ에서도 막지 않는다 READ COMMITTED는 막지 않고, SERIALIZABLE은 ORA-08177로 막는다
Write Skew SERIALIZABLE(SSI)에서 40001로 막는다 SERIALIZABLE은 일반 SELECT를 공유 잠금 읽기로 바꿔 데드락으로 한쪽을 롤백한다 SERIALIZABLE도 스냅샷 기반이라 막지 못한다

눈여겨볼 점이 세 가지다.

  • MySQL의 기본값이 더 높다고 더 안전한 것은 아니다. InnoDB REPEATABLE READ는 UPDATE를 최신 값에 적용하므로 Lost Update를 오류 없이 통과시킨다.
  • Oracle의 SERIALIZABLE은 이름과 달리 스냅샷 격리다. Write Skew를 막으려면 잠금이나 제약을 직접 써야 한다.
  • PostgreSQL SERIALIZABLE은 SSI(Serializable Snapshot Isolation)다. 잠금으로 기다리게 하지 않고 위험한 읽기·쓰기 의존을 추적해 한쪽을 40001로 실패시킨다. 실제 충돌이 없어도 실패할 수 있어 재시도가 필수다.

공공 SI에서 쓰는 CUBRID·Tibero도 기본 격리 수준과 구현 방식이 제품·버전마다 다르니 제품 문서로 확인한다.

막는 방법: 잠금, 제약, 버전

1) 판단에 쓴 행을 잠근다. 읽은 행을 FOR UPDATE로 잠그면 두 번째 트랜잭션은 첫 번째가 커밋할 때까지 기다린다. PostgreSQL은 집계 함수와 FOR UPDATE를 함께 쓸 수 없으므로 행을 가져와 애플리케이션에서 센다.

SELECT id FROM doctors WHERE shift_id = 7 AND on_call FOR UPDATE;  -- 당직자 행을 모두 잠근다
-- 2명 이상일 때만
UPDATE doctors SET on_call = false WHERE id = :me;

2) 아직 없는 행이 규칙에 걸리면 대표 행을 잠근다. 회의실 예약처럼 "겹치는 예약이 없을 것"을 검사하면 잠글 행 자체가 없다. 이때는 SELECT ... FROM shifts WHERE id = 7 FOR UPDATE처럼 상위 행 하나를 잠가 같은 대상의 변경을 한 줄로 세운다. DB 락과 Redis 락의 선택은 분산 락 구현 전략: Redis·DB·ZooKeeper 비교에서 다뤘다.

3) 규칙을 DB 제약으로 옮긴다. 제약은 격리 수준과 상관없이 항상 검사된다. 유일성은 유니크 제약으로, 시간 겹침은 PostgreSQL의 배제 제약으로 막는다.

CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE room_booking
  ADD CONSTRAINT no_overlap EXCLUDE USING gist (room_id WITH =, during WITH &&);

4) 상위 엔티티의 버전을 올린다. @Version은 같은 행의 Lost Update만 잡는다. 다른 행을 고치는 Write Skew를 잡으려면 판단 기준이 되는 상위 엔티티(근무조)의 버전을 강제로 올려 충돌 지점을 만든다.

Shift shift = em.find(Shift.class, shiftId, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
// 커밋 때 shift.version이 올라가므로 동시에 커밋한 쪽은 OptimisticLockException

SERIALIZABLE과 재시도를 Spring에서 쓰는 법

규칙이 많고 잠글 대상을 일일이 고르기 어렵다면 PostgreSQL SERIALIZABLE에 맡기고 실패를 재시도한다. 재시도는 트랜잭션 전체를 다시 시작해야 하므로 @Transactional 메서드 안이 아니라 바깥에서 감싼다.

@Service
public class OnCallService {
    private static final int MAX_ATTEMPTS = 3;
    private final TransactionTemplate tx;
    private final DoctorRepository doctors;

    public OnCallService(PlatformTransactionManager tm, DoctorRepository doctors) {
        this.tx = new TransactionTemplate(tm);
        this.tx.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE);
        this.doctors = doctors;
    }

    public void requestOff(long shiftId, long doctorId) {
        for (int attempt = 1; ; attempt++) {
            try {
                tx.executeWithoutResult(status -> {
                    if (doctors.countByShiftIdAndOnCallTrue(shiftId) < 2) {
                        throw new IllegalStateException("당직자는 최소 1명이어야 합니다");
                    }
                    doctors.markOff(doctorId);
                });
                return;
            } catch (ConcurrencyFailureException e) {   // 직렬화 실패, 데드락 패자, 낙관적 락 충돌
                if (attempt == MAX_ATTEMPTS) throw e;
            }
        }
    }
}
  • ConcurrencyFailureException은 Spring이 40001, 데드락, 낙관적 락 실패를 번역한 예외들의 상위 타입이다. 드라이버·JPA 구현에 따라 구체 타입이 달라지므로 상위 타입으로 잡는다.
  • 재시도할 트랜잭션 안에서 메일 발송이나 외부 API를 부르면 재시도 횟수만큼 실행된다. 외부 호출은 커밋 뒤로 뺀다. 경계 설계는 Spring Boot에서 트랜잭션 경계를 잘못 설계하면 발생하는 문제에, 같은 요청이 두 번 들어오는 문제는 분산 환경에서 멱등 API와 중복 요청 방지 설계에 정리했다.
  • @Transactional(isolation = Isolation.SERIALIZABLE)도 쓸 수 있지만, 이미 열린 트랜잭션에 REQUIRED로 합류하면 안쪽 격리 수준은 무시된다.

자주 하는 실수

격리 수준을 올리면 다 해결된다고 믿는다. MySQL REPEATABLE READ와 Oracle SERIALIZABLE은 각각 Lost Update와 Write Skew를 통과시킨다. 어떤 현상을 막고 싶은지 먼저 정해야 한다.

SERIALIZABLE을 켜고 재시도를 넣지 않는다. PostgreSQL SSI는 실패로 충돌을 알린다. 재시도가 없으면 동시 요청이 몰릴 때 사용자에게 500 오류가 그대로 보인다.

@Version이 Write Skew도 막는다고 생각한다. 버전은 같은 행의 충돌만 잡는다. 다른 행을 고치는 규칙은 상위 엔티티 버전이나 잠금이 필요하다.

검사는 애플리케이션에서, 저장은 DB에서 따로 한다. "중복 확인 후 INSERT"는 두 요청이 동시에 확인을 통과하면 둘 다 저장된다. 유니크 제약이 최종 방어선이다.

테스트를 순차 실행으로만 돌린다. 이상 현상은 두 트랜잭션이 겹칠 때만 나온다. CountDownLatch로 두 스레드를 같은 지점에서 출발시키는 동시성 테스트가 있어야 재현된다.

점검표

  • 사용하는 DB의 기본 격리 수준과 각 수준의 실제 구현을 문서로 확인했다
  • "읽고 계산해서 쓰는" 코드는 원자적 UPDATE, FOR UPDATE, @Version 중 하나로 보호한다
  • 여러 행을 읽고 판단하는 업무 규칙 목록을 만들고 Write Skew 가능성을 검토했다
  • 유일성·겹침 규칙은 유니크 제약이나 배제 제약으로 DB에 걸었다
  • 아직 없는 행이 규칙에 걸리는 경우 잠글 대표 행을 정했다
  • SERIALIZABLE을 쓰는 트랜잭션에는 최대 횟수가 정해진 재시도가 있다
  • 재시도 대상 트랜잭션 안에 외부 API 호출이나 메시지 발행이 없다
  • 격리 수준 지정이 바깥 트랜잭션 합류로 무시되지 않는지 확인했다
  • 두 스레드를 동시에 출발시키는 동시성 테스트로 이상 현상이 막히는 것을 확인했다

격리 수준은 이름이 아니라 "이 DB에서 이 수준이 실제로 무엇을 막는가"로 판단하고, 표준이 다루지 않는 Lost Update와 Write Skew는 잠금·제약·버전·재시도 중 하나로 직접 막아야 한다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기