트랜잭션의 동시성 문제를 알아보자

정훈희·2024년 7월 13일
10
post-thumbnail
post-custom-banner

0️⃣ 배경

데이터 중심 애플리케이션 설계 7장(트랜잭션)을 읽던 중 여러 트랜잭션이 동시에 진행될 때 발생할 수 있는 문제들에 대해 접했다.

사실 모든 트랜잭션을 동기적으로 실행하는 Serializable(직렬성) 격리 수준을 적용하면 모든 문제를 해결할 수 있지만, 큰 성능 문제가 있다. 그렇기 때문에 각 상황에 맞는 적절한 해결법을 적용시키는 것이 중요하다.

그래서, 이번에는 어떤 상황에서 어떤 동시성 문제가 발생할 수 있는지, 어떻게 해결할 수 있는지 정리해보았다.

1️⃣ 더티 읽기 문제(Dirty Read)

더티 읽기 문제는 아직 커밋되지 않은 트랜잭션에서 쓴 데이터를 읽을 수 있는 문제이다.

문제 상황

image

사용자 1이 사용자 2에게 이메일을 보내면 사용자 2의 메일함에 Insert하고, 읽지 않은 이메일 수를 +1 한다.

이 과정에서, 커밋되지 않은 값을 확인할 수 있으면 아래와 같은 문제가 발생하게 된다.

  • 1번 작업 직후에 사용자 2가 메일함을 확인하면 새로운 메일이 왔지만, 읽지 않은 이메일 수는 0인 문제가 발생한다.

적절한 해결 방법

이를 막기 위해서 읽기 잠금을 걸 수 있겠지만, 읽기 작업을 진행하는 여러 트랜잭션이 하나의 쓰기 트랜잭션으로 인해 지연되는 문제가 발생한다.

그래서, 대부분의 DB는 최근에 커밋된 값과 실행중인 트랜잭션이 쓴 값을 모두 기억하고, 다른 트랜잭션이 읽을 때는 최근에 커밋된 값을 읽도록 하여 더티 읽기 문제를 해결한다.

위 문제 상황에서는, 메일함 Insert 작업은 커밋되지 않았으므로 보이지 않으므로 이상 현상이 나타나지 않을 것이다.

이러한 해결 방법이 적용된 격리 수준으로는 Read Committed가 있다.

2️⃣ 더티 쓰기 문제(Dirty Write)

더티 쓰기 문제는 아직 커밋되지 않은 트랜잭션에서 쓴 데이터에 쓰기 작업을 진행할 수 있는 문제이다.

문제 상황

image

중고차를 구매하기 위해선 중고차의 구매자와 수신자를 갱신해야한다.

이 과정에서 커밋되지 않은 값에 쓰기 작업이 가능하면 아래와 같이 문제가 발생하게 된다.

  1. 먼저 엘리스가 구매자를 갱신한다. → 구매자 = 엘리스
  2. 밥이 구매자를 갱신한다. → 구매자 = 밥
  3. 밥이 수신자를 갱신하고 커밋한다 → 구매자 = 밥, 수신자 = 밥
  4. 엘리스가 수신자를 갱신하고 커밋한다 → 구매자 = 밥, 수신자 = 엘리스

즉, 구매는 밥이하고, 수신을 엘리스가 하게된다.

적절한 해결 방법

이를 해결하기 위한 가장 흔한 방법은 Row 수준 잠금을 이용하는 것이다.

트랜잭션에서 객체를 변경하고 싶다면 해당 객체에 대한 잠금을 획득하고, 트랜잭션이 끝날 때(커밋 or 어보트) 잠금을 해제한다.

위 문제 상황에서는, 밥의 구매자 갱신 작업이 엘리스의 구매 트랜잭션이 끝난 뒤에 이루어지므로 구매자와 수신자가 다른 이상 현상을 겪지 않을 것이다.

이러한 해결 방법이 적용된 격리 수준으로는 더티 읽기와 마찬가지로 Read Committed가 있다.

3️⃣ 읽기 스큐(read skew)

비반복 읽기(Non Repeatable Read)라고도 하는 읽기 스큐는 한 트랜잭션에서 여러번의 읽기 작업을 수행했을 때 일관성이 깨진 데이터를 읽게되는 문제이다.

문제 상황

image

사용자 1과 2는 모두 500$가 있는 상황에서 사용자 2가 1에게 100$를 송금하는 트랜잭션이 진행중이다.

만약, 엘리스가 트랜잭션 커밋 이전과, 이후로 잔고를 조회하면 일관성이 깨진 데이터를 볼 수 있다.

  • 커밋 이전 사용자 1의 잔고는 500$, 커밋 이후 사용자 2의 잔고는 400$ → 100$가 증발??

적절한 해결 방법

읽기 스큐는 지속적인 문제는 아니다. 만약 송금 트랜잭션 이후에 두 잔고를 다시 조회한다면 정상적으로 보일 것이다.

그러나, 일시적인 일관성 파괴가 치명적인 상황에서는 일관성을 감내할 수 없다.

  • 예를 들면, 백업과 같은 상황이 치명적일 수 있다. 만약 일관성이 파괴된 순간의 데이터가 백업된다면 큰 문제가 발생할 수 있다.

이런 문제의 가장 흔한 해결책으로는 스냅숏 격리가 있다.

스냅숏 격리

스냅숏 격리는 DB가 객체 마다 여러 버전의 데이터를 유지하는 MVCC 기법을 사용하여 구현된다.

image

그림과 같이, 트랜잭션에는 계속 증가하는 ID가 할당된다. 트랜잭션에서 읽기 작업이 진행된다면, 현재 트랜잭션이 시작한 시점에 커밋된 데이터를 읽는다. 다른 트랜잭션이 쓰기 작업 후 커밋된다 해도 같은 시점의 데이터를 읽으므로 영향이 없다.

즉, 트랜잭션이 시작되는 시점에 스냅숏을 생성하고, 읽기 & 쓰기 작업을 해당 스냅숏에 진행한다.

위 문제 상황에서는 엘리스가 트랜잭션 시작 시점의 스냅숏에 읽기를 진행하므로 두 번의 조회에서 500$를 읽어올 수 있어서 일관성 있는 데이터를 조회할 수 있다.

이러한 해결 방법이 적용된 격리 수준으로는 오라클에서는 직렬성, PostgreSQL과 MySQL에서는 Repeatable Read가 있다.

4️⃣ 갱신 손실

갱신 손실은 값을 읽고 변경한 후 변경한 값을 다시 쓰는 갱신 작업이 동시에 일어났을 때 다른 갱신 작업이 손실될 수 있는 문제이다.

문제 상황

image

사용자 1과 2가 카운터를 증가시키는 트랜잭션을 동시에 진행한다.

이때, 사용자 1과 2는 같은 값(42)을 읽었고, 각자 1씩 증가시키고 커밋하였다.

두 번의 증가 작업이 일어났으므로 44가 되어야 정상이지만, 두 번째 갱신 작업이 첫 번째 갱신 작업을 덮어 씌우면서 갱신 손실 문제가 발생했다.

이러한 문제는 아래와 같은 다양한 상황에서 발생할 수 있다.

  • 계좌 잔고 갱신 작업(계좌 잔고를 읽고, 변경한 뒤 다시 쓰는 갱신 작업)
  • 문서의 일부분 변경(문서를 읽고, 일부를 변경한 뒤 다시 쓰는 갱신 작업)

적절한 해결 방법

갱신 손실은 이렇게 자주 발생하는 문제라서 다양한 해결책이 개발되었다.

  1. 원자적 쓰기 연산

    여러 DB에서 원자적 갱신 연산을 제공한다. 이러한 원자적 연산은 그 객체에 독점적인(Exclusive) 잠금을 획득해서 구현한다. 그래서 갱신 적용 전까지 다른 트랜잭션에서 그 객체를 읽지 못하게 한다. 혹은 모든 원자적 연산을 단일 스레드에서 실행되도록 강제하여 구현할 수도 있다.

  2. 명시적인 잠금

    애플리케이션에서 갱신할 객체를 명시적으로 잠궈서 갱신 손실을 해결할 수 있다. 동시에 같은 객체를 읽으려 할 때 첫 번째 갱신이 완료될 때 까지 다음 작업들이 기다린다.

  3. 갱신 손실 자동 감지

    원자적 연산과 잠금은 결국 순차적으로 실행되게 함으로써 갱신 손실을 방지하는 방법이다. 이에 대한 대안으로는 병렬 실행을 허용하고, 갱신 손실을 발견하면 해당 트랜잭션을 어보트하고 재시도하도록 하는 방법이 있다.

    해당 방법의 이점은 스냅숏 격리와 결합해 효율적으로 수행될 수 있다는 것이다. 실제로 PostgreSQL의 Repeatable Read와 오라클의 직렬성 격리수준은 갱신 손실 자동 감지를 제공하지만, MySQL(InnoDB)의 Repeatable Read는 제공하지 않는다.

복제가 적용된 상황이라면?

여러 노드에 데이터의 복사본이 있어서 데이터가 다른 노드들에서 동시에 변경될 수 있으므로 갱신 손실을 방지하려면 어떻게 해야할까?

가장 흔한 방법은 쓰기가 동시에 실행될 때 한 값에 대해 여러개의 충돌된 버전(sibling)을 생성하는 것을 허용하고 사후에 어플리케이션 코드나 특별한 데이터 구조를 사용해 충돌을 해소하고 이 버전들을 병합하는 것이다.

또한, 원자적 연산은 복제 상황에서도 잘 작동한다.

5️⃣ 쓰기 스큐와 팬텀(Write Skew, Phantom)

이정도나 했는데도 아직도 문제 상황이 남아있다.. 이 부분은 구체적인 예시를 살펴보며 설명하겠다.

문제 상황 - 1 (서로 다른 객체 갱신)

image

병원에는 최소 1명이 근무하고 있어야 한다. 만약, 1명 보다 많은 사람이 근무 중이라면 쉬러갈 수 있다.

위 그림에서는 엘리스와 밥이 병원에서 근무 중이다. 만약 엘리스와 밥이 동시에 트랜잭션을 시작하면 두 트랜잭션 다 근무 중인 인원을 2명으로 읽고, 자기 자신을 휴식 상태로 만든다.

이러한 이상 현상을 쓰기 스큐라고 한다. 두 트랜잭션이 두 개의 다른 객체를 갱신하므로 더티 쓰기도, 갱신 손실도 아니다.

문제 상황 - 2 (삽입)

두 명이 같은 시간에 회의실 예약을 진행하려고 한다. 이를 위해선 예약하려는 시간에 회의 예약 내역이 있는지 확인하고, 없다면 예약 데이터를 삽입한다.

스냅숏 격리는 이렇게 충돌되는 삽입 연산을 막을 수 없다.

쓰기 스큐를 유발하는 팬텀

팬텀은 어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 쿼리 결과를 바꾸는 효과를 말한다.

쓰기 스큐를 유발하는 팬텀은 비슷한 패턴을 따른다.

  1. SELECT 쿼리를 통해 요구사항을 만족하는지 확인한다.
    • ex) 최소 두 명의 의사가 근무 중이다, 해당 시간에 예약 기록이 없다
  2. 요구사항을 만족하면 DB에 쓰고 트랜잭션을 커밋한다. 이 쓰기의 효과로 1단계의 결과가 바뀐다.
    • ex) 근무 중인 의사가 1명 줄었다, 해당 시간에 예약 기록이 생겼다
    • 만약 쓰기의 대상이 명확하다면 잠금을 통해 해결할 수 있다. 하지만, 다르거나 명확하지 않다면 해결이 어려워진다.
      • ex1) 의사 본인의 상태를 업데이트 → 동시에 실행되는 트랜잭션이 같은 Row를 업데이트하지 않음
      • ex2) 예약 기록을 삽입 → 수정이 아닌 삽입 작업이므로 잠금의 대상이 명확하지 않음

적절한 해결 방법

만약, 잠글 객체가 없는 것이라면 잠글 객체를 만드는 충돌 구체화 방식을 통해서 해결할 수 있다.

회의 예약 예시의 경우, 예약 가능한 모든 시간에 객체를 생성해서 해당 객체에 잠금을 거는 방식으로 해결이 가능하다. 다만, 대부분의 경우는 직렬성 격리 수준이 훨씬 더 선호된다.

6️⃣ 결론

생각 보다도 데이터의 일관성을 지키는데는 장애물이 많다는 것을 새삼 깨달았다…

이래서 다들 트랜잭션과 격리 수준이 중요하다고 하고, 면접에서도 단골 질문으로 등장하는구나 싶다..!

profile
DB를 사랑하는 백엔드 개발자입니다. 열심히 공부하고 열심히 기록합니다.
post-custom-banner

0개의 댓글