본문 바로가기
DB

DB 트랜잭션 격리 수준(Isolation Level)과 MVCC 메커니즘의 이해

by 정권이 내 2026. 3. 4.

🧐 왜 트랜잭션과 동시성 제어를 알아야 할까?

서비스는 단 한 명의 사용자만 이용하는 것이 아니라, 수백, 수천 명의 사용자가 동시에 접속하여 데이터를 조회하고 수정합니다. 만약 선착순 콘서트 티켓 예매 시스템이나 금융 송금 시스템에서 동시성 제어가 제대로 이루어지지 않는다면 어떤 일이 발생할까요?

두 명의 사용자가 동시에 마지막 남은 한 장의 티켓을 예매하려고 할 때, 시스템이 이를 적절히 통제하지 못하면 두 명 모두에게 예매 완료 처리가 될 수 있습니다. 혹은 통장에 잔고가 1만 원뿐인데, 동시에 두 기기에서 1만 원씩 송금을 요청했을 때 잔고가 마이너스가 되거나 돈이 복사되는 치명적인 버그가 발생할 수도 있습니다.

이러한 장애를 막기 위해 RDBMS 는 트랜잭션(Transaction) 이라는 작업의 논리적 단위를 제공하며, 데이터의 안전한 처리를 보장하는 ACID(원자성, 일관성, 고립성, 지속성) 원칙을 준수하도록 설계되어 있습니다.

하지만 완벽한 고립성을 추구하면 데이터베이스는 한 번에 하나의 작업만 순차적으로 처리해야 하므로 성능이 기하급수적으로 떨어집니다. 따라서 성능과 데이터 정합성 사이의 적절한 타협점을 찾는 것이 필수적이며, 이를 위해 우리는 트랜잭션 격리 수준, 락(Lock), 그리고 MVCC와 같은 동시성 제어 메커니즘을 깊이 이해하고 실무에 적용해야 합니다.

🤦‍♂️ 동시성 제어 시행착오

이론을 본격적으로 다루기 전에, 실무에서 주니어 개발자들이 흔히 겪는 동시성 관련 실수들을 먼저 살펴보겠습니다.

1. 애플리케이션 메모리에서의 검증을 맹신

가장 흔한 실수는 데이터베이스가 아닌 애플리케이션 코드 레벨에서만 조건문을 통해 동시성을 제어하려는 시도입니다.

예를 들어 재고를 확인하고 차감하는 로직을 작성할 때, 현재 재고를 조회한 후 if문을 사용해 재고가 0보다 큰지 확인하고 업데이트하는 코드를 작성합니다. 로컬 환경에서 혼자 테스트할 때는 완벽하게 동작하지만, 운영 환경에서 다수의 스레드가 동시에 요청을 보내면 Race Condition 이 발생합니다.

두 스레드가 동시에 재고 조회를 수행하여 둘 다 재고가 남아있다고 판단하고 차감을 진행해버리기 때문에, 결국 데이터베이스에는 마이너스 재고가 기록됩니다. 데이터베이스의 락이나 원자적 연산을 활용하지 않은 순수한 애플리케이션 레벨의 로직은 동시성 문제에 매우 취약합니다.

2. 성능을 고려하지 않은 무분별한 비관적 락 사용

동시성 문제를 겪고 난 후, 데이터를 보호하겠다는 목적으로 모든 수정 로직에 비관적 락을 걸어버리는 실수도 잦습니다.

비관적 락은 확실하게 데이터를 보호해주지만, 트랜잭션이 끝날 때까지 다른 모든 접근을 대기 상태로 만듭니다.

트래픽이 몰리는 이벤트 페이지나 메인 화면에서 이 방식을 사용하면, 수많은 요청이 데이터베이스 락을 기다리며 줄을 서게 되고 결과적으로 커넥션 풀이 고갈되어 전체 시스템이 마비되는 장애로 이어질 수 있습니다.

3. 트랜잭션 격리 수준에 대한 무관심

자신이 사용하는 데이터베이스의 기본 격리 수준이 무엇인지 모른 채 개발하는 경우도 많습니다.

예를 들어 MySQL의 InnoDB 엔진은 Repeatable Read를 기본으로 사용하지만, Oracle은 Read Committed를 기본으로 사용합니다. 이를 인지하지 못하고 통계 데이터를 집계하는 트랜잭션을 길게 유지할 경우, 조회할 때마다 데이터의 건수가 달라지는 Phantom Read 현상을 겪고 데이터 정합성을 의심하게 됩니다.

데이터베이스마다 구현 방식과 기본 설정이 다르므로, 이를 정확히 파악하고 목적에 맞는 격리 수준을 설정하는 것이 중요합니다.

💡 핵심 : 트랜잭션 격리 수준부터 락(Lock)까지

이제 동시성 제어의 핵심 개념들을 하나씩 자세히 보겠습니다.

🛡️ 트랜잭션 격리 수준(Isolation Level)과 발생 가능한 현상들

트랜잭션 격리 수준은 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 설정입니다. 격리 수준은 크게 4가지로 나뉘며, 수준이 엄격해질수록 데이터 정합성은 높아지지만 동시 처리 성능은 떨어집니다.

  • Read Uncommitted (커밋되지 않은 읽기): 한 트랜잭션의 변경 내용이 커밋이나 롤백과 상관없이 다른 트랜잭션에 바로 보여집니다.
  • Read Committed (커밋된 읽기): 어떤 트랜잭션에서 데이터를 변경했더라도 커밋이 완료된 데이터만 다른 트랜잭션에서 조회할 수 있습니다.
  • Repeatable Read (반복 가능한 읽기): 트랜잭션이 시작되기 전에 커밋된 내용에 대해서만 조회할 수 있으며, 트랜잭션 내에서 같은 쿼리를 두 번 이상 수행할 때 항상 동일한 결과를 보장합니다.
  • Serializable (직렬화 가능): 가장 엄격한 격리 수준으로, 읽기 작업에도 공유 락을 획득해야 하며 여러 트랜잭션이 동시에 같은 데이터에 접근 불가능 합니다.

격리 수준에 따라 다음과 같은 세 가지 주요 데이터 불일치 현상이 발생할 수 있습니다.

  • Dirty Read (더티 리드): Read Uncommitted 수준에서 발생합니다. 트랜잭션 A가 데이터를 수정하고 아직 커밋하지 않았는데, 트랜잭션 B가 그 수정된 데이터를 읽어가는 현상입니다. 만약 트랜잭션 A가 롤백된다면 트랜잭션 B는 존재하지 않는 잘못된 데이터를 가지고 작업을 수행하게 됩니다.
  • Non-Repeatable Read (반복 불가능한 읽기): Read Committed 수준에서 발생합니다. 트랜잭션 A가 같은 쿼리를 두 번 실행하는 동안, 트랜잭션 B가 그 데이터를 수정하고 커밋해버리는 상황입니다. 트랜잭션 A의 첫 번째 조회 결과와 두 번째 조회 결과가 달라지는 현상을 말합니다.
  • Phantom Read (팬텀 리드): Repeatable Read 수준 이하에서 발생할 수 있습니다. 트랜잭션 A가 특정 조건으로 데이터를 여러 번 조회하는 동안, 트랜잭션 B가 그 조건에 부합하는 새로운 데이터를 삽입(Insert)하거나 삭제(Delete)하는 경우입니다. 트랜잭션 A 입장에서는 유령(Phantom)처럼 이전에 없던 데이터가 나타나거나 있던 데이터가 사라지는 현상을 겪게 됩니다. (단, MySQL InnoDB는 MVCC 덕분에 Repeatable Read에서도 팬텀 리드가 거의 발생하지 않습니다.)

⏱️ 락 없는 동시성 제어, MVCC의 동작 원리

현대의 RDBMS(MySQL InnoDB, PostgreSQL 등)는 동시성을 극대화하기 위해 락에만 의존하지 않고 MVCC (Multi-Version Concurrency Control) 라는 기술을 사용합니다. MVCC의 핵심 아이디어는 레코드에 잠금을 걸지 않고, 데이터의 여러 버전(Multi-Version)을 유지하여 읽기 작업과 쓰기 작업이 서로를 블로킹하지 않도록 만드는 것입니다.

어떤 트랜잭션이 데이터를 변경하려고 하면, 데이터베이스는 기존의 원본 데이터를 덮어쓰기 전에 언두 로그(Undo Log) 공간에 복사해 둡니다.

그 후 실제 데이터 영역을 새로운 값으로 변경합니다. 이때 다른 트랜잭션이 해당 데이터를 조회하려고 접근하면, 데이터베이스는 현재 트랜잭션의 격리 수준과 고유 ID를 확인합니다. 만약 아직 커밋되지 않은 변경 사항을 읽으면 안 되는 격리 수준이라면, 데이터베이스는 실제 데이터 영역의 잠금을 기다리게 하는 대신 언두 로그에 저장된 과거 버전의 데이터를 반환합니다.

이러한 MVCC 메커니즘 덕분에 읽기 작업은 쓰기 작업을 기다릴 필요가 없고, 쓰기 작업 역시 읽기 작업을 기다릴 필요가 없습니다. 이는 대규모 트래픽 환경에서 데이터베이스가 높은 성능을 유지할 수 있는 가장 중요한 원동력입니다. 단, 변경 작업이 잦은 환경에서는 언두 로그가 기하급수적으로 커질 수 있으므로, 완료된 트랜잭션의 오래된 버전을 주기적으로 정리하는 작업(Purge)이 내부적으로 수행됩니다.

🔒 DB 내부의 락: 공유 락(Shared Lock), 배타 락(Exclusive Lock), 데드락

MVCC가 읽기 성능을 높여주지만, 데이터를 실제로 수정(Update, Delete)할 때는 데이터베이스 내부의 락(Lock) 메커니즘이 필요합니다.

1. 공유 락 (Shared Lock, S-Lock)

데이터를 읽을 때 사용하는 락입니다. 공유 락끼리는 서로 호환됩니다. 즉, 여러 트랜잭션이 동시에 같은 데이터에 공유 락을 걸고 읽을 수 있습니다. 하지만 공유 락이 걸린 데이터에 배타 락을 걸 수는 없습니다.

2. 배타 락 (Exclusive Lock, X-Lock)

데이터를 변경할 때 사용하는 락입니다. 배타 락은 이름 그대로 매우 배타적이어서, 어떤 트랜잭션이 배타 락을 획득하면 다른 트랜잭션은 공유 락이든 배타 락이든 어떤 락도 걸 수 없고 대기해야 합니다.

3. 데드락(Deadlock, 교착 상태)

락 메커니즘 때문에 발생하는 치명적인 문제입니다. 두 개 이상의 트랜잭션이 서로가 점유하고 있는 락을 해제하기만을 기다리며 무한 대기에 빠지는 상황을 말합니다.

예를 들어 트랜잭션 A가 1번 데이터에 배타 락을 걸고, 트랜잭션 B가 2번 데이터에 배타 락을 건 상태입니다. 이때 트랜잭션 A가 2번 데이터를 수정하려 하고, 동시에 트랜잭션 B가 1번 데이터를 수정하려 한다면 두 트랜잭션은 영원히 서로를 기다리게 됩니다.

데드락을 해결하고 예방하는 방안은 다음과 같습니다.

  • 트랜잭션 크기 최소화: 트랜잭션이 락을 점유하는 시간을 최소한으로 줄여 교착 상태가 발생할 확률을 낮춥니다.
  • 데이터 접근 순서 일치: 모든 트랜잭션에서 여러 데이터를 수정할 때 접근하는 순서를 동일하게 맞춥니다. (항상 1번 데이터 접근 후 2번 데이터 접근)
  • 타임아웃 설정: 락을 획득하기 위한 최대 대기 시간을 설정하여, 영원히 기다리지 않고 일정 시간 후 예외를 발생시켜 롤백하도록 처리합니다.

⚖️ 애플리케이션 레벨의 동시성 제어: 낙관적 락 vs 비관적 락

비즈니스 로직을 구현하다 보면 데이터베이스의 기본 락만으로는 부족한 경우가 생깁니다. 이때 개발자는 상황에 맞춰 낙관적 락이나 비관적 락 패턴을 선택하여 동시성을 제어해야 합니다.

1. 비관적 락 (Pessimistic Lock)

비관적 락은 트랜잭션 충돌이 발생할 것이라고 비관적으로 가정하고, 데이터를 읽는 시점부터 데이터베이스의 배타 락을 획득해버리는 방식입니다.

데이터베이스 수준에서 직접 락을 걸기 때문에 데이터 무결성은 완벽히 보장되지만, 다른 트랜잭션의 대기 시간이 길어져 성능 저하를 유발할 수 있습니다. 재고 차감이나 금융 결제와 같이 충돌 빈도가 높고 정합성이 절대적으로 중요한 곳에 사용합니다.

SELECT * FROM product WHERE id = 1 FOR UPDATE;

위 SQL 구문처럼 FOR UPDATE 키워드를 사용하면 조회와 동시에 해당 로우에 배타 락을 걸게 됩니다. Spring Data JPA 환경에서는 메서드에 @Lock 어노테이션을 사용하여 쉽게 구현할 수 있습니다.

public interface ProductRepository extends JpaRepository<Product, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdForUpdate(Long id);
}

2. 낙관적 락 (Optimistic Lock)

낙관적 락은 대부분의 트랜잭션이 충돌하지 않을 것이라고 낙관적으로 가정하는 방식입니다.

데이터베이스의 물리적인 락을 사용하지 않고, 애플리케이션 레벨에서 버전(Version) 관리를 통해 충돌을 감지합니다.

데이터를 조회할 때 버전 값을 함께 읽어오고, 데이터를 수정하여 저장할 때 내가 읽었던 버전과 현재 데이터베이스의 버전이 일치하는지 확인합니다. 일치하면 수정을 진행하며 버전을 증가시키고, 불일치하면 다른 누군가 먼저 수정했다는 뜻이므로 예외(OptimisticLockingFailureException)를 발생시킵니다.

물리적 락이 없으므로 성능이 우수하여, 충돌이 적은 일반적인 게시판 수정이나 사용자 정보 변경 등에 적합합니다.

Spring Boot JPA 환경에서는 엔티티 클래스에 버전 필드를 추가해주면 프레임워크가 알아서 낙관적 락을 처리해 줍니다.

@Entity
public class UserProfile {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String nickname;

    @Version
    private Long version;

    // getters and setters...
}

만약 업데이트 시점에 충돌이 발생하면 예외가 던져지므로, 개발자는 이를 캐치하여 사용자에게 다시 시도하라는 메시지를 보여주거나 별도의 재시도(Retry) 로직을 구현해야 합니다.

📝 오늘 내용 3줄 요약

  1. 트랜잭션 격리 수준은 데이터 정합성과 동시성 성능 간의 트레이드오프를 결정하며, 목적에 맞게 설정해야 더티 리드, 팬텀 리드 등의 문제를 막을 수 있습니다.
  2. 최신 RDBMS는 MVCC 구조를 채택하여 언두 영역을 활용함으로써, 읽기 작업과 쓰기 작업이 서로 락을 걸며 대기하지 않고 높은 성능을 낼 수 있도록 돕습니다.
  3. 실무 비즈니스 로직에 맞게 충돌이 잦고 정합성이 필수적이라면 비관적 락을, 충돌 빈도가 낮고 성능이 중요하다면 버전 기반의 낙관적 락을 선택해야 합니다.
반응형

댓글