두 사람이 동시에 같은 계좌의 잔액을 확인하고 출금한다면, 잔액이 마이너스가 될 수 있을까요?

동시에 여러 트랜잭션이 같은 데이터를 읽고 수정하면 예상치 못한 문제가 발생합니다. 격리 수준(Isolation Level) 은 이런 동시성 문제를 얼마나 엄격하게 방지할지를 결정합니다.

개념 정의

트랜잭션 격리 수준 은 동시에 실행되는 트랜잭션 간에 데이터를 어떻게 보여줄지를 정의하는 규칙입니다. 격리 수준이 높을수록 데이터 일관성은 보장되지만, 동시 처리 성능은 낮아집니다.

읽기 이상 현상 3가지

Dirty Read

커밋되지 않은 데이터를 다른 트랜잭션이 읽는 현상입니다.

PLAINTEXT
트랜잭션 A                    트랜잭션 B
UPDATE account SET balance = 0
WHERE id = 1;
                              SELECT balance FROM account
                              WHERE id = 1;
                              → balance = 0 (커밋 전 데이터!)
ROLLBACK;
                              -- B는 잘못된 데이터로 작업을 진행함

Non-Repeatable Read

같은 행을 두 번 읽었을 때 값이 달라지는 현상입니다.

PLAINTEXT
트랜잭션 A                    트랜잭션 B
SELECT balance FROM account
WHERE id = 1;
→ balance = 1000
                              UPDATE account SET balance = 500
                              WHERE id = 1;
                              COMMIT;
SELECT balance FROM account
WHERE id = 1;
→ balance = 500 (값이 바뀜!)

Phantom Read

같은 조건으로 조회했는데 행의 수가 달라지는 현상입니다.

PLAINTEXT
트랜잭션 A                    트랜잭션 B
SELECT COUNT(*) FROM member
WHERE age > 20;
→ 5명
                              INSERT INTO member VALUES ('심', 25);
                              COMMIT;
SELECT COUNT(*) FROM member
WHERE age > 20;
→ 6명 (유령 행 등장!)

4단계 격리 수준

격리 수준Dirty ReadNon-Repeatable ReadPhantom Read
READ_UNCOMMITTEDOOO
READ_COMMITTEDXOO
REPEATABLE_READXXO (MySQL에서는 X)
SERIALIZABLEXXX

READ_UNCOMMITTED

가장 낮은 격리 수준입니다. 커밋되지 않은 데이터도 읽을 수 있습니다. 실무에서는 거의 사용하지 않습니다.

READ_COMMITTED

커밋된 데이터만 읽을 수 있습니다. PostgreSQL, Oracle의 기본값 입니다.

PLAINTEXT
트랜잭션 A                    트랜잭션 B
UPDATE account SET balance = 0;
                              SELECT balance → 원래 값 (Dirty Read 방지)
COMMIT;
                              SELECT balance → 0 (커밋 후 읽기 가능)

REPEATABLE_READ

트랜잭션 시작 시점의 스냅샷을 읽습니다. MySQL InnoDB의 기본값 입니다.

PLAINTEXT
트랜잭션 A                    트랜잭션 B
BEGIN;
SELECT balance → 1000;
                              UPDATE SET balance = 500;
                              COMMIT;
SELECT balance → 1000;       -- 여전히 처음 값 (Non-Repeatable Read 방지)
COMMIT;

MySQL InnoDB는 REPEATABLE_READ에서도 갭 락(Gap Lock) 을 사용하여 Phantom Read까지 방지합니다.

SERIALIZABLE

가장 높은 격리 수준입니다. 모든 읽기에 공유 락을 걸어 트랜잭션이 순차적으로 실행되는 것처럼 동작합니다.

Spring에서 격리 수준 설정

@Transactional로 설정

JAVA
@Transactional(isolation = Isolation.READ_COMMITTED)
public void transferMoney(Long fromId, Long toId, int amount) {
    Account from = accountRepository.findById(fromId).orElseThrow();
    Account to = accountRepository.findById(toId).orElseThrow();
    from.withdraw(amount);
    to.deposit(amount);
}

Isolation 열거형

JAVA
public enum Isolation {
    DEFAULT(-1),           // DB 기본 격리 수준 사용
    READ_UNCOMMITTED(1),
    READ_COMMITTED(2),
    REPEATABLE_READ(4),
    SERIALIZABLE(8);
}

Isolation.DEFAULT가 기본값이며, 이는 DB의 기본 격리 수준을 따른다는 의미입니다.

DB별 기본 격리 수준

DB기본 격리 수준
MySQL (InnoDB)REPEATABLE_READ
PostgreSQLREAD_COMMITTED
OracleREAD_COMMITTED
SQL ServerREAD_COMMITTED
H2READ_COMMITTED

MySQL과 PostgreSQL의 차이

같은 격리 수준이라도 DB마다 구현이 다릅니다.

  • MySQL: MVCC + 갭 락으로 REPEATABLE_READ에서도 Phantom Read를 방지
  • PostgreSQL: MVCC 기반이며, REPEATABLE_READ에서 쓰기 충돌 시 에러 발생 (Serialization Failure)

실무 고려사항

격리 수준을 변경해야 하는 경우

대부분의 경우 DB 기본 격리 수준(DEFAULT)으로 충분합니다. 격리 수준을 변경해야 하는 상황은 드뭅니다.

JAVA
// 정산 로직 — 정확한 합계가 필요
@Transactional(isolation = Isolation.SERIALIZABLE)
public BigDecimal calculateDailyRevenue(LocalDate date) {
    return orderRepository.sumRevenueByDate(date);
}

격리 수준 변경 대신 고려할 대안

  • ** 비관적 락 **: @Lock(LockModeType.PESSIMISTIC_WRITE)
  • ** 낙관적 락 **: @Version 필드를 활용한 버전 관리
  • ** 분산 락 **: Redis 기반 분산 락
JAVA
// 비관적 락으로 동시성 문제 해결
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Account findByIdForUpdate(@Param("id") Long id);

공부하다 보니, 격리 수준을 높이는 것보다 적절한 락 전략을 사용하는 것이 실무에서 더 일반적인 해결 방법이었습니다.

주의할 점

1. 격리 수준을 높이면 데드락 위험도 함께 올라간다

SERIALIZABLE이나 REPEATABLE_READ에서는 락의 범위가 넓어지기 때문에, 두 트랜잭션이 서로의 락을 기다리는 데드락이 발생할 확률이 높아집니다. 격리 수준을 올리기 전에 반드시 데드락 시나리오를 점검해야 합니다.

2. Spring의 Isolation.DEFAULT는 DB마다 다르게 동작한다

@Transactional(isolation = Isolation.DEFAULT)는 DB의 기본 격리 수준을 따릅니다. 개발 환경에서 H2(READ_COMMITTED)로 테스트하고 운영에서 MySQL(REPEATABLE_READ)로 배포하면, 동일한 코드가 서로 다른 격리 수준에서 동작하여 예상치 못한 차이가 발생할 수 있습니다.

3. @Transactional에서 격리 수준을 지정해도 JDBC 드라이버나 커넥션 풀 설정에 의해 무시될 수 있다

일부 커넥션 풀(HikariCP 등)은 커넥션 반환 시 격리 수준을 원래대로 되돌리지 않는 경우가 있습니다. 또한 DB에 따라 특정 격리 수준을 지원하지 않으면 가장 가까운 수준으로 자동 변환되므로, 의도한 격리 수준이 실제로 적용되는지 로그나 DB 세션 상태로 확인해야 합니다.

정리

  • ** 격리 수준 **은 동시 트랜잭션 간 데이터 가시성을 결정합니다.
  • Dirty Read, Non-Repeatable Read, Phantom Read 세 가지 이상 현상이 있습니다.
  • MySQL의 기본값은 REPEATABLE_READ, PostgreSQL/Oracle은 READ_COMMITTED 입니다.
  • Spring에서는 @Transactional(isolation = ...)으로 메서드별 격리 수준을 설정합니다.
  • 대부분의 경우 DB 기본 격리 수준 + 적절한 락 전략 으로 동시성 문제를 해결합니다.
댓글 로딩 중...