본문 바로가기

database

[DB] 트랜잭션(3) - 격리 수준과 동시성 문제, 그 해결

[DB] 트랜잭션(1) - ACID와 격리 수준(isolation level)
[DB] 트랜잭션(2) - repeatable read 격리 수준에서의 phantom read 현상
[DB] 트랜잭션(3) - 격리 수준과 동시성 문제, 그 해결

 

이전 글에서 트랜잭션과 격리성을 살펴보았다. 트랜잭션은 작업의 완전성을 보장하기 위한 개념이다. 격리성은 각 트랜잭션이 다른 트랜잭션에 영향을 받지 않게 하려는(즉, 동시성 문제를 해결하려는) 개념이다. 가장 엄격한 격리성은 직렬로 모든 트랜잭션을 처리하는 것이다.

그런데 가장 엄격한 격리성은 동시성 문제를 해결하기 보다는 동시성 문제를 아예 회피하는 전략이다. 이는 동시성의 단점을 배제하고자 그 장점을 모두 버린 해결책이다. 이는 성능 문제를 야기했다.

격리 수준을 완화하여 세분화한 이유도 동시성 이슈와 성능 이슈 사이의 trade-off를 어떻게 조정할지에 대한 아이디어에서 나온 것이다. '완화된 격리 수준'에서는 성능상의 이점을 최대한 살리고 동시성 이슈는 최소화하고자 한다. 따라서 모든 동시성 이슈를 완전히 해결하지는 못한다.

 

완화된 격리 수준에서의 동시성 이슈

이제 완화된 격리 수준에서의 동시성 이슈가 무엇이 있을지 알아보자. 격리수준은 MySQL에서의 repeatable read를 기준으로 삼았다. 사례는 데이터 중심 애플리케이션 설계 책을 참고하였다.

아래에서 보여줄 동시성 이슈는 read - modify - write의 시점 차이에서 비롯된 문제다. 동시에 여러 클라이언트가 트랜잭션을 시작해서 조회(read)하고 그 값을 바탕으로 수정(modify)한 뒤 최종적으로 저장소에 기록(write)하는 시점의 간극이 동시성 이슈를 유발한다. 

자바로 이 문제를 테스트해보면 다음과 같다. ConcurrentHashMap은 thread-safe 하다고 생각되지만, key-value를 저장할 때 read - write의 시점 차이로 인해 데이터의 일관성이 깨질 수도 있다. 이를 위해 읽기와 쓰기 사이에 'longCalculation()' 이라는 버퍼를 두었다. 잘못된 값이 들어가는 것을 확인하기 위해 comparedMap은 putIfAbsent() 메서드를 호출해 락을 획득하는 연산으로 정상적인 값을 넣었다.

public class ReadModifyWriteTest {

    private static Map<Integer, Integer> map = new ConcurrentHashMap<>();
    private static Map<Integer, Integer> comparedMap = new ConcurrentHashMap<>();

    @Test
    void read_modify_write() throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                int key = ThreadLocalRandom.current().nextInt(0, 100);
                int value = ThreadLocalRandom.current().nextInt(0, 100);
                
                comparedMap.putIfAbsent(key, value);
                if (!map.containsKey(key)) {
                    longCalculation();  // read와 write의 시점 차이로 인해 데이터의 일관성이 깨진다.
                    map.put(key, value);
                }

                // 같은 key가 map에 동시에 put되어 갱신 손실 발생
                int originalValue = comparedMap.get(key);
                if (map.get(key) != originalValue) {
                    System.out.println("unexpected key-value: " + key + " - "  + value);
                }
            }).start();
        }
        Thread.sleep(3000L);
    }

    private void longCalculation() {
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

원래는 putIfAbsent()만으로도 충분한 연산이다. map.putIfAbsent(key, value)를 넣어 comparedMap과 비교하면 더 이상 'unexpected key-value'가 출력되지 않는다.

map.putIfAbsent(key, value);
//int originalValue = map.compute(key, (k, v) -> v == null ? value : v);
//if (!map.containsKey(key)) {
//    longCalculation();
//    map.put(key, value);
//}

 

실생활에서의 동시성 문제

실생활에서는 조금 더 미묘한 문제가 발생할 수 있다.

1. 호출 대기 문제

- 병원의 호출 대기 시스템을 만들어 일정 시간대에 의사가 최소 한 명은 대기하도록 한다. 즉 대기 중인 의사가 없으면 안된다. 그런데 대기해야 하는 의사 2명이 동시에 근무에서 빠지려고 할 때 문제가 발생할 수 있다. read 하는 시점에는 2명이 대기하고 있지만, 두 트랜잭션이 같은 객체를 읽어서 각 객체의 일부를 갱신하면 결국 호출 중인 의사는 0명이 된다. 이를 쓰기 스큐(write skew)라 한다.

currently_on_call = (
    select count(*) from doctors
     where on_call = true
       and shift_id = 1
)

if (currently_on_call > 1):
    update doctors set on_call = false
     where name = 'tx1 or tx2'
       and shift_id = 1
       
# shift_id = 1인 시간대에 대기 중인 의사가 0명이 된다.

 

2. 회의실 예약 문제

- 같은 회의실에서 시간대가 겹치는 예약을 없게 만들고자 한다. 동일하게 쓰기 스큐가 발생한다.

currently_on_booking = (
    select count(*) from bookings
     where room_id = 1
       and start_time >= '2023-05-09 12:00'
       and end_time <= '2023-05-09 13:00'
)

if (currently_on_booking is empty):
    insert into bookings (room_id, start_time, end_time, user_id)
    values (1, '{start-time}', '{end-time}', userA or userB)
 
 # 동일 시간대에 다른 사용자가 같은 회의실을 예약하게 된다.

 

3. 이중 사용(double-spending) 방지 문제

- 돈이나 포인트가 사용자가 갖고 있는 금액보다 더 많이 지불되지 않게 해야 한다. 아래 사례를 보면 잔고가 1,000원이라고 할 때, 두 트랜잭션이 500원씩을 인출하면 잔고가 0원이 되어야 하지만 최종적으로 500원이 된다. 심지어 잔고가 1,000원 보다 작다면 결과값이 마이너스가 될 수도 있다. 중간에 인출한 금액이 누락되는 '갱신손실(lost update)'이 발생하였다.

balance = (
    select balance
      from accounts
     where account_id = 1
)

if (balance >= withdrawal):
    update accounts
       set balance = (balance - withdrawal)

 

동시성 문제의 해결

1. 명시적 잠금

select count(*)
  from doctors
 where shift_id = 1
for update;

update doctors
   set on_call = false
 where shift_id = 1
   and name = 'tx1 or tx2';
 
commit;

exclusive lock을 획득해 읽기와 쓰기 모두에서 잠그는 방식이다. 호출 대기 문제에서 shift_id = 1 등으로 PK가 정해져 있다면 해당 PK를 잠금으로써 동시성 문제를 해결할 수 있다. 하지만 이와 같은 해결 방식은 현재 존재하지 않는 데이터에 대해서는 잠금을 획득하지 못한다. 현재 존재하지 않고(팬텀) 미래에 존재할 데이터에 대한 문제가 있는 것이다.

 

2. 충돌 구체화(matarializing conflict)

insert into reservations (id, date, start_time, end_time)
values (1, 20230509, '12:00', '13:00')
#...

# select ... for update

팬텀의 문제가 잠글 수 있는 객체가 없는 것이라면 인위적으로 DB에 잠금 객체를 추가한다. 예를 들어 예약 문제에서 특정 시간 범위(1시간) 동안 사용되는 특정 회의실에 해당하는 조합을 미리 record로 만들어 놓는 것이다.

다만 충돌 구체화는 알아내기 어렵고 오류가 발생하기 쉽다. 동시성 제어 매커니즘이 애플리케이션 데이터 모델로 새어 나오는 것도 보기 좋지 않다. 이런 이유로 충돌 구체화는 다른 대안이 불가능할 때 최후의 수단으로 고려한다.

 

3. 원자적 쓰기 연산

create table accounts (
	account_id INT PRIMARY KEY,
    balance INT CHECK (balance >= 0)
);

insert into accounts
values (1, 100);

update accounts
set balance = balance - 101
where account_id = 1;

특정 계좌의 잔고를 update 할 때 기존 잔고의 값을 기준으로 수정하였다. 추가하여 check 조건을 넣어 잔고가 음수가 될 수 없도록 만들었다.

하지만 원자적 쓰기 연산은 여러 객체가 관련되어 있을 때는 도움이 되지 않을 수 있다.

 

4. 갱신 손실 자동 감지

read - modify - write 주기가 순차적으로 실행되도록 강제할 수 있다. oracle의 직렬성, postgresql 반복 읽기, SQL 서버의 스냅숏 격리는 갱신손실 발생 시 자동으로 abort 한다.

 

5. compare-and-set

값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용하여 갱신 손실을 회피한다. 이전에 읽은 값과 일치하지 않으면 갱신 반영되지 않고 read-modify-write 주기를 재시도해야 한다.

read committed 에서는 안전할 수 있으나 old snapshot을 읽을 수 있는 격리 수준(repeatable read 같은)에서는 안전하지 않다.