본문 바로가기
학습기록

데이터 동시성 제어의 목적과 유형(Lock과 트랜잭션 격리수준)

by seongju.lee 2023. 3. 13.

동시성 제어

데이터베이스는 공유를 목적으로 하기 때문에 가능한 많은 트랜잭션을 동시에 수행시켜야 한다.

하지만, 동시에 수행함으로써 같은 데이터를 공유한다면 데이터의 일관성이 훼손될 수 있다.

그래서 동시에 다양한 트랜잭션의 데이터 접근을 제어하기 위한 DBMS 기능이 바로 동시성 제어인 것이다.

 

각 트랜잭션이 동시에 같은 데이터에 접근하는 경우는 아래 세 가지가 있을 것이다.

T1(작업종류) T2(작업종류) 발생 가능한 문제
읽기 읽기 없음
읽기 쓰기 - Dirty Read(잘못된 값 읽기)
- Non-Repeatable Read
   (반복 불가능 읽기)

- Phantom Read(유령데이터 읽기)
쓰기 쓰기 갱신손실
  • 첫 번째 상황은 데이터 변경의 여지가 없으므로 아무 문제도 발생하지 않는다.
  • 두 번째 상황은 하나의 트랜잭션(T1)이 데이터를 변경하므로, 읽는 과정(T2)에서 여러 문제가 발생할 수 있다.
  • 세 번째 상황은 두 트랜잭션 모두 데이터를 갱신하려고 달려든다. 망하기 딱 좋은 상황이다.

각 상황에 따라 발생 가능한 문제를 정리해 봤다.

각 문제들이 어떤 것을 의미하는지를 구체적으로 살펴보자.

 

살펴보는 순서는 세 번째 상황 -> 두 번째 상황 순으로 살펴볼 것이다. (첫 번째 상황은 아무 문제도 발생하지 않으니 생략!)

 

 

갱신손실 문제

서로 다른 두 트랜잭션(T1, T2)이 같은 데이터에 접근하여 갱신하는 작업을 수행하는 경우에 발생한다.

즉, 두 트랜잭션이 모두 정상실행이 되더라도 한 트랜잭션이 갱신한 값이 사라질 수 있는 문제를 의미한다.

 

갱신손실 문제를 해결하기 위해선 단순히 T2 -> T1 또는 T1 -> T2와 같이 순서대로 실행해도 되지만,
그렇게 되면 빠른 응답성을 제공하지 못하는 문제가 발생한다.

 

갱신손실을 해결하기 위한 방법

1. 락(Pessimistic Lock, 비관적 락)

한 트랜잭션이 데이터에 접근하여 작업을 수행할 때, 데이터를 잠그는(락을 거는) 방법이다.
여기서 말하는 "락""비관적 락(Pessimistic Locking)"으로 동시성 문제가 발생하는 것을 애초에 막기 위한 것이다.

T1이 락을 걸어놓은 상태라면 T2는 락을 얻지 못하고 대기한다.

 

비관적 락의 유형

락에는 두 가지 유형이 있다.
- 데이터 읽기 작업에서 사용하는 공유락(Shared Lock: s-lock)
- 데이터 쓰기 작업을 할 때 사용하는 베타락(Exclusive Lock: x-lock)


S-lock과 X-lock의 특징은 아래와 같다.

- 공유락(Shared Lock)은 트랜잭션이 읽기를 할 때 사용한다.
- 베타락(Exclusive Lock)은 읽고 쓰기를 할 때 사용한다.

   -  s-lock을 걸어놓으면 다른 트랜잭션의 읽기 작업은 허용하고, 쓰기 작업은 허용하지 않는다.
       (읽는 동안 데이터 변경을 막기 위해)

   -  x-lock을 걸어놓으면 다른 트랜잭션의 읽기/쓰기 구분 없이 접근을 허용하지 않는다.

   -  허용받지 못한 락은 대기 상태가 된다.

 

근데, 앞서 갱신손실 문제를 정의할 때 아래와 같은 이야기를 했다.

갱신손실 문제를 해결하기 위해선 단순히 T2 -> T1 또는 T1 -> T2와 같이 순서대로 실행해도 되지만,
그렇게 되면 빠른 응답성을 제공하지 못하는 문제가 발생한다.

빠른 응답성을 제공하지 못한다는 것은 달리 말해서 동시성이 낮아진다는 의미이다.

이 의미를 이해하고 비관적 락의 단위에 대해 가볍게 짚고 넘어가 보자.

 

비관적 락의 범위

Lock의 범위로는 하나의 필드값, 하나의 레코드, 하나의 릴레이션, 하나의 데이터베이스 등이 될 수 있다.

그리고, Lock의 범위에 따라 아래와 같은 성질을 가진다.

  • Lock의 단위가 클수록 동시성(병행성) 수준은 낮아지고, 동시성 제어 기법은 간단해진다.
  • Lock의 단위가 작을수록 동시성(병행성) 수준은 높아지고, 관리는 복잡해진다.
  • 즉, lock이 작을수록 동시성은 올라가지만 그만큼 lock에 대한 오버헤드가 증가한다.

 

비관적 락의 장점

  • 충돌이 자주 발생하는 환경에서 롤백의 횟수를 줄일 수 있으므로 성능상 유리하다.
  • 일관성이 보장된다.
    (사실 그냥 Lock을 사용하면 트랜잭션이 락을 걸고 해지하는 시점에 일관성이 위반될 수 있다.
    그래서, 2단계 Locking을 사용하여 락을 획득하는 단계(확장단계), 락을 해지하는 단계(수축단계)로 나뉘어서 일관성을 보장하기도 한다.)

 

비관적 락의 단점

  • 각 트랜잭션이 각각 자신의 데이터에 대해 락을 획득하고 서로 상대방의 데이터에 락을 요청하게 되면 데드락 현상이 발생하게 된다.
    • 데드락이 발생하면?
      • 데드락이 발생했다는 것은 대기 그래프를 통해 알 수 있다.
        트랜잭션을 노드로 표시하고, 대기 상태(락 요청)를 화살표로 표시해 보면 대기 그래프 내에 사이클이 존재하게 될 것이다.
        그렇게 되면 하나의 트랜잭션을 중지시킴으로써 나머지 트랜잭션을 정상적으로 수행되게끔 한다.
      • 여기서, 중지한 트랜잭션은 트랜잭션 성질에 의해 원래 상태로 롤백되게 된다.
        어떤 트랜잭션이 롤백되는지는 벤더사마다 다른 기준을 가지지만 innoDB의 경우 연산된 행 수에 대해 결정된다고 한다.

 

2. 락(Optimistic Lock, 낙관적 락)

트랜잭션이 충돌되지 않을 것이라 생각하여 낙관적으로 처리하는 방법이라고 할 수 있다.

DB 레벨에서의 Lock이 아닌 Application 레벨에서 처리하는 Lock이다.

(여기서는 Spring Boot JPA 기준)

 

낙관적 락의 메커니즘은 아래와 같다.

1. 갱신하고자 하는 데이터에 "버전"을 둔다.
2. 그리고, 엔티티가 변경될 때마다 "버전"을 하나씩 증가한다.
3. 엔티티를 갱신하고자 할 때,
   엔티티를 조회한 시점에서의 버전과 엔티티를 수정하는 시점에서의 버전이 일치하면 변경한다.
   일치하지 않다면, Application Level에서 예외가 발생한다.

여기서 말하는 버전은 단순히 정수가 될 수도 있고, 수정시간이 될 수도 있다.

 

단순한 예제를 통해 살펴보자.

e.g)

T1: 트랜잭션1
T2: 트랜잭션2
v(n): 버전번호 n


1. T1이 특정 데이터를 조회한다. -> 데이터 버전: v1
2. T2가 동일한 데이터를 조회한다. -> 데이터 버전: v1
3. T1이 데이터 수정 후, 커밋 -> 데이터 버전: v2로 변경
4. T2가 데이터 수정 후, 커밋 -> "조회 시점 버전: v1", "커밋 시점 버전: v2" => 불일치 발생 (예외)

 

위와 같은 메커니즘으로 동작하고, 이 버전을 관리하기 위해 엔티티 필드에 버전관리를 위한 필드를 추가한다.

(spring boot 예시)

public class Order {

    private Long id;
    ...생략...
    
    @Version  // JPA에서 @Version Annotation을 사용하여 버전관리를 할 수 있다.
    private Integer version; // 데이터(엔티티)가 수정될 때, 이 version 필드가 1씩 증가한다.
}

 

 

트랜잭션 고립(격리) 수준

위에서 살펴본 내용은 두 트랜잭션이 하나의 자원에 대해서 쓰기 작업을 할 때, 발생 가능한 갱신손실 문제에 대한 것이었다.

지금 살펴볼 내용은 하나의 트랜잭션은 쓰기 / 다른 하나의 트랜잭션은 읽기 작업을 할 때, 발생 가능한 문제들과 해결방법이다.

 

두 트랜잭션(T1, T2)이 있는데 T1은 읽기 작업, T2는 쓰기 작업을을 수행한다고 가정해 보자.

이 경우에는 T2에 대해서는 아무 지장이 없고,
문제가 발생하는 시점은 T1이 데이터를 읽는 과정에서 T2가 데이터를 변경할 때이다.

 

당연히 비관적 락을 사용하여, T1이 공유 락을 걸고, T2에 대해 베타락을 허용하지 않으면 문제가 전혀 발생하지 않는다.

하지만, Locking은 근본적으로 공유 자원인 데이터베이스에 동시성을 떨어뜨리기 때문에 성능상 매우 비효율적이다.

 

그렇기 때문에 이렇게 서로 다른 트랜잭션이 (읽기, 쓰기) 같은 경우에는 데이터 접근에 대한 동시성을 높이기 위해서 좀 더 완화된 방법을 사용하는데, 그 방법이 바로 트랜잭션 격리 수준에 따라 읽기/쓰기에 대한 각 트랜잭션 간의 격리 수준을 결정하는 것이다.

 

 

그렇다면 트랜잭션 격리 수준을 알아보기 전에 (읽기/쓰기)라는 각 트랜잭션이 동시에 데이터에 접근할 때,
(읽기) 작업을 수행하는 트랜잭션에는 어떤 문제가 생기는지에 대해 알아보자.

 

읽기 T1, 쓰기 T2가 동시에 동일한 데이터를 접근할 때, 발생 가능한 문제

T1: 읽기 작업을 수행하는 트랜잭션
T2: 쓰기 작업을 수행하는 트랜잭션

 

  1. Dirty Read
    T2가 쓰기 작업을 수행하는 중간에 T1이 데이터를 읽는 과정에서 발생하는 문제

    T2가 데이터를 변경한 후에 T1이 데이터를 읽었다고 하자.
    근데 여기서 T2가 어떠한 이유로 ROLLBACK을 하게 되면 T1은 무효화된 데이터를 읽은 꼴이 된다.
  2. Non-Repeatable Read
    기본적으로 T1 내에서 동일한 데이터에 대해 읽기 작업을 두 번 이상 하게 될 경우 발생하는 문제로써,
    T1이 처음 읽은 데이터와 T2가 수정한 후 T1이 두 번째로 읽은 데이터가 불일치하는 문제
    즉, T1이 읽기 작업을 다시 반복할 경우 이전의 결과가 반복되지 않는 현상을 말한다.

  3. Phantom Read
    기본적으로 하나의 트랜잭션인 T1 내에서 동일한 데이터를 두 번 이상 읽을 경우 발생한다.
    T1이 첫 번째로 데이터를 읽고, T2가 데이터를 INSERT 한 후, T1이 동일한 쿼리를 요청할 때,
    이전에 없던 데이터를 읽게 되는데 이전에 없던 데이터를 바로 phantom row라고 한다.
    이 문제는 트랜잭션의 고립성 원칙을 위반했기 때문에 문제가 된다.
    트랜잭션 내에서 읽은 데이터가 트랜잭션 도중에 변경되었기 때문이다.

 

네 가지의 트랜잭션 격리(고립) 수준과 각 격리 수준 명령어

다시 한번 설명하지만, 위에서 언급한 세 가지 문제를 해결하기 위해서는 Locking을 사용하면 끝이다.

하지만, Locking 자체가 공유자원에 동시에 접근하는 데이터베이스의 특성을 고려하면 동시성이 매우 떨어지기 때문에 효율적이지 못하다.

그래서 완화된 방법으로 고안된 것이 바로 트랜잭션의 각 격리 수준을 나누고 그 격리 수준에 따라 위 세 가지 문제를 해결하는 것이다.

 

이제 설명할 읽기/쓰기에 대한 네 가지 격리 수준에 따라 위 세 가지 문제를 모두 해결할 수도, 일부만 해결할 수도 있게 된다.

  1. READ UNCOMMITTED(Level=0)
    고립 수준이 가장 낮은 단계이다.

    트랜잭션은 자신이 접근하는 데이터에 아무런 s-lock을 걸지 않는다. (x-lock은 갱신손실 문제 때문에 무조건 걸어야 한다.)
    또한 다른 트랜잭션에 공유락과 베타락이 걸린 데이터를 대기하지 않고 읽는다.
    심지어 다른 트랜잭션이 작업 중인(COMMIT 하지 않은) 데이터도 읽을 수 있기 때문에, dirtry read가 발생할 수 있다.

    즉, 읽고자 하는 데이터(테이블)에 대해 아무런 락을 설정하지 않은 것과 같다.
    • 발생 가능한 현상
      • dirty read, non-repeatable read, phantom read
    • Lock
      • Select문: s-lock을 걸지 않음
      • 데이터 갱신: x-lock을 건다.


  2. READ COMMITTED(Level=1)
    dirty read를 피하기 위한 단계의 명령어이다.
    트랜잭션 자신이 데이터를 읽는 동안은 s-lock을 건다. 하지만, 트랜잭션이 끝나기 전에 해지가 가능하므로 non-repeatable read 현상이 발생할 수 있다.

    즉, 한 트랜잭션에 select문이 두 개 있을 때, select문이 진행되는 동안에는 공유락을 걸지만, 다음 select문이 수행되기 이전에 다른 트랜잭션 접근으로 인해 데이터 일관성이 깨질 수 있다.
    • 발생 가능한 현상
      • non-repeatable read, phantom read
    • Lock
      • Select문: s-lock을 걸긴 걸지만, select문이 끝나면 바로 해지
      • 데이터 갱신: x-lock을 건다.


  3. REPEATABLE READ(Level=2)
    트랜잭션 자신이 접근한 데이터에 대해 s-lock 또는 x-lock을 트랜잭션이 종료될 때까지 유지하는 단계의 명령어이다.
    얼핏 보면 트랜잭션이 완전히 격리되는 수준인 것처럼 보이지만 다른 트랜잭션으로부터의 INSERT문을 허용한다.

    즉, 트랜잭션의 생명주기동안 lock을 유지하지만 insert문은 허용하기 때문에 phantom read문제가 발생 가능하다.
    • 발생 가능한 문제
      • phantom read
    • Lock
      • Select문: s-lock을 걸고, 트랜잭션 끝까지 유지한다.(non-repeatable read 문제 해결)
      • 데이터 갱신: x-lock을 건다.


  4. SERIALIZABLE(Level=3)
    격리 수준이 가장 높은 단계로 트랜잭션이 완전히 독립적으로 수행된다.
    인덱스에 s-lock을 설정함으로써 다른 트랜잭션의 INSERT문이 금지된다.

    완전히 독립적으로 수행되기에 동시성 문제는 해결하지만 Lcoking과 똑같은 효과이므로 동시성이 매우 떨어진다.
    • 발생 가능한 문제
      • 없음
    • Lock
      • Select문: s-lock을 걸고, 트랜잭션 끝까지 유지한다.
      • 데이터 갱신: x-lock을 건다.