4 minute read

다음은 두 트랜잭션의 동시 쓰기 경합 상황이다.

  • 의사들의 당직 근무를 관리하는 애플리케이션이 있다.
  • 병원에는 여러 의사들 중 단 한 명의 의사는 당직 근무를 해야 한다.
  • 또한, 의사들은 당직 근무를 거부할 수도 있다.

앨리스와 밥은 당직 근무 의사다. 두 명 모두 건강이 좋지 않아 당직 근무 요청을 거부한 상황이다. 우연히도, 두 명이 모두 동시에 버튼을 누르게 되었다.

애플리케이션은 먼저 두 의사가 당직 대기 중인지 확인하고, 한 명의 의사가 당직을 하지 않아도 됨을 판단한다. 데이터베이스가 스냅샷 격리를 사용하고 있으므로 두 의사 모두 당직 중이지 않음을 반환한다. 앨리스는 자신의 기록을 업데이트하여 당직을 하지 않으며, 밥도 마찬가지로 당직을 하지 않도록 업데이트한다. 두 트랜잭션이 모두 커밋되고 이제 당직 중인 의사가 없다. 최소 한 명의 의사를 대기시켜야 한다는 요구 사항이 위반된다.

쓰기 왜곡 Write skew

위와 같이 두 트랜잭션이 동시에 쓰기를 하여 애플리케이션의 요구 사항을 위반하는 것을 쓰기 왜곡이라고 한다.

두 트랜잭션이 서로 다른 두 개의 객체(각각 앨리스와 밥의 당직 근무 레코드)를 업데이트하기 때문에 더티 쓰기도 아니고 업데이트 손실도 아니다. 여기서 충돌이 발생했다는 것은 분명하지 않지만, 두 트랜잭션이 차례로 실행되었다면 두 번째 의사가 호출을 해제하지 못했을 것이다.

쓰기 왜곡과 갱신 손실의 차이점

쓰기 왜곡은 서로 다른 트랜잭션이 서로 다른 개체를 업데이트할때 발생한다.

반면, 갱신 손실은 서로 다른 트랜잭션이 동일한 오브젝트를 업데이트하는 경우에 발생한다. 갱신 손실을 방지하는 방법에는 여러 가지가 있다는 것을 확인했습니다.

쓰기 왜곡 해결의 어려움

  • 여러 개체가 관련되어 있기 때문에 원자적 단일 개체 작업은 도움이 되지 않는다.
  • 일부 스냅샷 격리 구현에서 볼 수 있는 갱신 손실의 자동 감지 기능도 도움이 되지 않는다. 쓰기 왜곡은 PostgreSQL의 반복 읽기, MySQL/InnoDB의 반복 읽기, Oracle의 직렬화 가능 또는 SQL Server의 스냅샷 격리 수준에서 자동으로 감지되지 않는다. 쓰기 왜곡을 자동적으로 방지하려면 직렬화 가능 격리가 필요하다.
  • 적어도 한 명의 의사가 대기 중이어야 한다는 제약을 지정하려면 여러 개체를 포함하는 제약 조건이 필요하다. 대부분의 데이터베이스에는 이러한 제약 조건에 대한 기본 지원 기능이 없지만, 트리거 또는 구체화된 뷰를 사용할 수 있다.

직렬화 가능한 격리 수준을 사용할 수 없는 경우, 차선책은 트랜잭션이 의존하는 행을 명시적으로 잠그는 것이다.

BEGIN TRANSACTION;
SELECT * FROM doctors
WHERE on_call = true
AND shift_id = 1234 FOR UPDATE;
UPDATE doctors
SET on_call = false WHERE name = 'Alice' AND shift_id = 1234;
COMMIT;

쓰기 왜곡 예시

1. 회의실 예약 시스템

회의실 예약 시스템의 조건은 이렇다.

하나의 회의실을 동시에 예약할 수 없다. 시스템이 회의실을 예약하기 전에 예약하려는 시간 대에 예약이 되어 있는지를 확인한다. 다음의 문장을 통해 구동할 수 있다.

BEGIN TRANSACTION;
-- 정오부터 한 시까지 예약을 확인함
SELECT COUNT(*) FROM bookings WHERE room_id = 123 AND
end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';
-- 만약 이전 쿼리가 0을 반환할 경우
INSERT INTO bookings
(room_id, start_time, end_time, user_id)
VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);
COMMIT;

스냅샷 격리를 통해 서로 다른 두명이 동일한 회의실을 예약하는 것을 방지할 수 있을까? 스케줄링 충돌을 해결하려면 직렬화 가능한 격리를 사용할 수 밖에 없다.

2. 멀티플레이어 게임

두 플레이어가 동일한 하나의 사물을 움직이지 못하게 하려면 잠금을 사용하면 된다. 하지만 잠금은 두 플레이어가 다른 사물을 똑같은 보드의 위치에 놓는 것은 막지 못한다.

이러한 문제를 방지하려면, 유일한 제약을 사용해야 한다.

3. 사용자 이름 발급하기

하나의 웹사이트에서 각각의 사용자는 유일한 이름을 갖는다. 동시에 두 명의 사용자가 같은 이름을 발급한다면 쓰기 왜곡이 발생한다. 스냅샷 격리 수준에서는 이러한 문제를 막지 못한다. 다행히도, 유일 제약조건은 두 번째 트랜잭션이 사용자 이름을 갖지 못하게 할 수 있다.

3. 이중 지불 방지하기

여기 하나의 서비스가 있다. 이 서비스는 사용자가 자신이 가진 포인트 이상으로 소비하지 못하게 해야 한다. 사용자의 계좌에 임시로 지출할 물건들을 삽입하고 계좌의 모든 물건들의 합이 양수인지 확인한다. 두 개의 지출할 아이템이 동시에 삽입된다면 잔액이 음수가 될 수 있다.

팬텀은 쓰기 왜곡을 유발한다,

다음 상황들은 모두 동일한 패턴을 가지고 있다.

  1. SELECT쿼리는 조건에 맞는 행들을 검색한다.
  2. 첫 번째 쿼리 결과에 따라서, 애플리케이션 코드는 로직을 진행할지 결정한다.
  3. 애플리케이션이 계속 로직을 진행한다면, 데이터베이스에 수정, 삭제 혹은 삽입 연산을 하고 트랜잭션을 커밋한다. 3번째 쓰기 변화에 따라 2번째의 전제조건이 달라진다. 다시 말해, SELECT 쿼리를 1번부터 수행하고 쓰기가 커밋된 이후에 다시 수행하면 결과가 달라진다.

위 동작의 순서는 다르게 수행할 수 있다. 예를 들어, 쓰기를 먼저 한뒤, 조회 쿼리를 날려서 쿼리 결과에 따라 커밋을 할지 취소할지 결정한다.

SELECT FOR UPDATE 구문을 사용하여 잠금을 시행하면 쓰기 왜곡을 막을 수 있을까? 만약 첫 번째 단계가 어떠한 행도 반환받지 못하면 SELECT FOR UPDATE 는 잠금을 수행할 수 없다,

이처럼 한 트랜잭션의 쓰기 결과가 다른 트랜잭션의 검색 결과에 영향을 미치는 것을 팬텀(phantom)이라고 한다.

스냅샷 격리는 읽기-전용 쿼리에서는 팬텀을 방지할 수 있다. 하지만, 위와 같은 읽기-쓰기 트랜잭션은 쓰기 왜곡을 일으킨다.

충돌 구체화 Materializing conflicts

팬텀의 문제점은 우리가 어떠한 객체에도 잠금을 걸 수 없다는 데에 있다. 대신, 잠금 객체를 인위적으로 입력하면 어떨까?

예를 들어, 회의실을 예약하는 시스템에서, 각 행이 특정 회의실에 대한 특정 시간(예를 들어, 15분)을 저장한다고 가정하자. 우리는 모든 종류의 회의실 x 사용시간 조합의 행들을 만들어 낼 수 있다.

이제 방을 예약하려는 트랜잭션은 원하는 회의실과 사용시간에 대한 행의 잠금을 걸 수 있다. 트랜잭션이 잠금을 획득하고 나면, 시간대와 회의실이 겹치는 예약을 확인할 수 있다.

우리가 잠금에 사용할 테이블은 정보 제공을 위한 목적이 아니라, 순수히 잠금을 사용하기 위해 사용한다.

이러한 접근방법을 충돌 구체화 (materializing conflicts)라고 한다. 충돌 구체화는 에러를 일으키기 쉬우며, 애플리케이션 데이터 모델에 동시성 제어 메커니즘을 드러낼 수 밖에 없다.. 따라서, 충돌 구체화는 모든 다른 대안이 불가능 할때 사용해야할 최후의 방안으로 남겨둬야 한다. 직렬화 가능한 격리 수준이 대부분의 이러한 상황에 적합하다.