트랜잭션(Transaction)

Jeongmin Yeo (Ethan)·2021년 8월 4일
6
post-thumbnail

트랜잭션이 무엇인지 알아보고 트랜잭션을 동시에 처리할 때 생기는 이슈와 이를 해결하는 방법에 대해서 정리합니다.

정리할 내용은 다음과 같습니다.

  • 트랜잭션(Transaction)이란?
  • 트랜잭션 장애와 회복
  • 트랜잭션과 Concurrency
  • 트랜잭션 격리(Isolation) 와 대표적인 Concurrency 문제들

References


트랜잭션(Transaction) 이란?

트랜잭션은 하나의 작업을 수행하는 데 필요한 데이터베이스의 연산을 모아놓은 것으로 데이터베이스 작업의 단위라고 생각하면 된다.

또 데이터베이스가 장애가 났을 때 트랜잭션 단위로 데이터를 복구시킨다.

일반적으로 데이터베이스의 연산은 SQL 문으로 이루어져 있으므로 트랜잭션을 SQL 문의 집합이라고 생각해도 좋다.

트랜잭션에서 중요한 건 트랜잭션 단위로 모두 성공하거나 모두 실패해야 데이터베이스의 일관성을 유지할 수 있다는 것이다.

트랜잭션의 일부만 성공하거나 일부만 실패하면 데이터베이스는 일관된 상태를 유지할 수 없다.

데이터베이스의 일관된 상태를 유지하기 위한 트랜잭션의 특징은 다음과 같다.

트랜잭션의 특징

  • Atomic

    • 트랜잭션의 연산은 모두 정상적으로 실패하거나 모두 실패해야 한다.
  • Consistent

    • 트랜잭션이 성공한 후에 데이터베이스의 제약조건을 포함한 일관성이 지켜져야 한다.
  • Isolation

    • 현재 진행중인 트랜잭션이 있다면 다른 트랜잭션이 이 트랜잭션에 접근할 수 없다. 각 트랜잭션은 독립적으로 수행되어야 한다.
  • Durability

    • 트랜잭션이 성공되었다면 데이터베이스는 이 결과를 영구적으로 반영해야 한다. 하나라도 손실이 있으면 안된다.

트랜잭션의 연산

트랜잭션의 수행과 관련해서 주로 사용하는 연산으로는 작업을 완료했다는 뜻인 commit 연산과 작업을 취소하는 의미인 rollback 연산이 있다.

트랜잭션의 상태

트랜잭션은 총 다섯 가지의 상태가 있다.

처음 트랜잭션이 시작하면 트랜잭션이 활성 상태가 되고 트랜잭션의 마지막 연산이 끝났다면 부분 완료 상태가 된다.

그 후 commit 을 실행하면 트래잭션은 완료 상태가 되고 트랜잭션의 완료 상태나 부분 완료 상태에서 여러 원인으로 인해 실패가 되면 트랜잭션은 실패 상태가 된다.

실패 상태에서 rollback 연산을 실행하면 철회 상태가 된다.


트랜잭션의 장애와 회복

트랜잭션의 Atomic 한 특성과 Durability 의 특성을 모두 반영해주기 위해서 데이터베이스는 회복 기능을 제공해준다.

회복(recovery) 이란 말은 장애가 발생했을 때 데이터베이스를 장애가 발생하기 전의 상태로 복구시키는 걸 말한다.

일단 먼저 데이터베이스에서 일어날 수 있는 장애의 유형을 알아보자.

  • 트랜잭션 장애

    • 트랜잭션 수행 중 오류가 발생해서 정상적으로 실행할 수 없는 상태를 말한다.
  • 시스템 장애

    • 하드웨어의 결함으로 인한 장애나, 교착 상태로 인한 장애가 발생하는 경우를 말한다.
  • 미디어 장애

    • 디스크 장치에 장애가 난 경우를 말하며 디스크가 손상된 상태를 말한다.

데이터베이스가 장애가 났을 때 장애가 발생하기 전의 상태로 복구시키는 건 데이터베이스 시스템에 있는 Recovery Manager 가 담당한다.

Recovery Manager 는 장애 발생을 탐지하고, 장애가 탐지되면 데이터베이스 복구 연산을 제공한다.

회복에서 중요한 점은 장애가 일어난 시점에서는 데이터베이스를 이용하는게 불가능하므로 빠르게 회복하는게 중요하다는 것이다.

데이터베이스 회복의 핵심 원리는 데이터 중복을 이용해서 이루어진다.

데이터를 별도의 장소에 보관해놓고 장애로 문제가 발생했을 때 복사본을 이용해 원래의 상태로 데이터를 복원하는 방법이다.

주로 덤프(dump) 또는 로그(log) 의 방식을 이용해 데이터를 복사해두었다가 회복시킬 때 복사본을 이용함으로써 이뤄진다.

두 방식에 대해서 간단하게 알아보면 다음과 같다.

  • 덤프(dump)

    • 주기적으로 데이터베이스 전체를 다른 저장 장치에 복사하는 방법을 말한다.
  • 로그(log)

    • 데이터베이스 변경 연산이 실행될 때마다 데이터를 변경하기 이전 값과 변경된 이후의 값을 별도의 파일에 계속해서 기록하는 방식이다. append-only 방식을 이용하면 O(1) 방식으로 기록할 수 있으므로 성능상의 크게 이슈가 되진 않는다.

장애가 발생했을 때 덤프나 로그의 방법으로 중복 데이터를 이용해 데이터베이스를 복구하는 가장 기본적인 방법은 redo 나 undo 연산을 통해서 이뤄진다.

두 연산은 다음과 같다.

  • redo(재실행)

    • redo 를 이용한 복구 방법으로는 가장 최근 데이터베이스 복사본을 가져온 다음에 로그에 저장된 트랜잭션 내역들을 이용해 복사본 이후 시점에 실행된 모든 변경 연산을 재실행해서 장애가 발생하기 이전의 데이터베이스 상태로 복구하는 걸 말한다.
  • undo(취소)

    • 로그를 이용해 지금까지 실행된 변경 연산을 취소함으로써 데이터베이스를 원래의 상태로 복구하는 걸 말한다. 주로 변경된 내용이 신뢰성을 잃은 경우에 undo 를 이용해 취소한다.

redo 연산과 undo 연산은 모두 로그를 이용해서 실행한다. 데이터베이스 관리 시스템이 로그를 기록하는 방법을 좀 더 자세히 살펴보면 다음과 같다.

로그는 데이터베이스 변경의 연산을 기록하는 것으로 변경하기 이전의 값과 변경한 이후의 값을 기록한다.

로그를 저장한 파일을 로그 파일이라고 하며 로그 파일은 레코드 단위로 저장한다.

레코드는 다음과 같이 네 종류로 분류되며 레코드도 데이터베이스의 트랜잭션 수행을 기준으로 기록된다.

레코드의 종류는 다음과 같다.

  • <T, start>

    • 트랜잭션 T 가 시작됐음을 기록한다.
  • <T, X, old-value, new-value>

    • 트랜잭션 T 가 데이터 X 를 이전 값(old-value) 에서 새로운 값(new-value) 로 변경하는 연산이 실행되었음을 기록한다.
  • <T, commit>

    • 트랜잭션 T 가 성공적으로 데이터베이스에 반영되었음을 기록한다.
  • <T, abort>

    • 트랜잭션 T 가 철회되었음을 기록한다.

실제 예시로 계좌 잔액이 10,000 원인 A 가 계좌 잔액이 0 원인 B 에게 5,000 원을 보내는 트랜잭션의 로그 파일은 다음과 같다.

1: <T, start>
2: <T, X, 10000, 5000>
3: <T, Y, 0, 5000>
4: <T, commit> 

데이터베이스 회복 기법

이제 데이터베이스의 회복에 관련된 배경 지식을 알았으니까 구체적인 회복 기법을 알아보자.

데이터베이스 회복 기법은 로그 회복 기법, 검사 시점 회복 기법, 미디어 회복 기법 이렇게 있다.

데이터베이스 회복 기법 중 일반적으로 가장 많이 사용되는 로그 회복 기법을 먼저 알아보자.

로그 회복 기법

로그를 이용한 회복 기법은 데이터를 변경한 연산 결과를 데이터베이스에 반영하는 시점에 따라 즉시 갱신 회복 기법과 지연 갱신 회복 기법으로 나뉜다.

  • 즉시 갱신 회복 기법(Immediate Update)

    • 이 기법은 트랜잭션 수행 후에 데이터를 변경한 연산의 결과를 즉시 데이터베이스에 반영하는 걸 말한다. 그리고 장애 발생에 대비하기 위해서 로그 파일을 기록해둔다. 즉 수행 과정은 로그 파일을 기록하면서 데이터베이스에 반영한다.

    • 이 기법으로 회복을 할 때는 로그 파일 내용에 기반해서 회복한다. 로그 파일에 <T, commit> 이 없다면 데이터베이스에 반영된 걸 취소해야 하므로 undo 연산을 수행하고 <T, commit> 이 존재하지만 장애가 나서 데이터베이스 반영에 실패한 경우에는 redo 연산을 통해 재실행을 통해서 회복한다.

    • 즉시 데이터베이스에 반영한다는 것은 바로 Disk 에 엑세스 하는 데이터베이스 같은 경우에는 성능이 안나올것 같아서 인메모리 데이터 스토어 같은 곳에 쓸일 듯하다.

  • 지연 갱신 회복 기법(Deferred Update)

    • 지연 갱신 회복 기법에서는 트랜잭션이 수행되는 동안 데이터베이스에 바로 반영하지 않고 로그 파일에 모아서 반영을 한다.

    • 그러므로 어느 파일까지 데이터베이스에 반영했는지 표시될 것이고 이 로그 파일에 commit 이 없다면 반영하지 않으면 된다. 그러므로 undo 연산은 필요가 없다.

검사 시점 회복 기법

로그만을 이용해서 데이터베이스를 회복한다고 하면 너무나 실행할 연산이 많아서 오래 걸린다. 그리고 redo 연산을 실행할 필요가 없는데도 실행을 해야하는 경우도 있다.

이러한 비효율성을 해결하기 위해서 제안된 방법이 검사 시점 회복 기법이다.

검사 시점 회복 기법은 로그 회복 기법과 같이 사용하되 일정 시간 간격으로 검사 시점(check point) 를 만들어둔다.

그리고 장애가 나면 가장 최근 시점으로 돌아가서 로그 회복 기법을 수행한다.

검사 시점은 <checkpint L> 과 같이 기록되는데 L 은 현재 수행중인 트랜잭션 리스트를 말한다.

그러므로 L 시점 이후의 로그를 찾아서 복구하면 된다.

미디어 회복 기법

데이터베이스는 비휘발성 저장 장치인 디스크에 데이터를 반영한다.

디스크는 메모리보다 장애가 드물게 발생하지만 디스크 헤더와 같은 장애가 발생할 수 있다.

미디어 회복 기법은 이런 디스크에 장애가 발생한 경우 회복 기법을 말하는 것이다.

미디어 회복 기법은 데이터베이스의 내용을 일정 주기마다 다른 안전한 저장 장치에 복사해두는 덤프를 이용한다.

디스크 장애가 발생나면 이런 덤프를 이용해서 가장 최근에 저장 내용을 가져오고 로그를 이용해서 redo 연산을 실행한다.


트랜잭션과 Concurrency

데이터베이스는 여러 사용자가 동시에 데이터베이스를 공유할 수 있도록 동시에 수행되는 Concurrency 기능을 제공한다.

트랜잭션들은 차례로 번갈아 수행하는 인터리빙(Interleaving) 방식으로 진행되는데 이 경우에 서로 다른 데이터에 접근해서 처리하는 경우에는 문제가 없지만 동시에 같은 데이터에 접근을 하게 되는 경우라면 예상치 못한 결과가 일어날 수 있기 때문에 주의해야 한다.

데이터베이스 개발자들은 오랫동안 트랜잭션 격리(Isolation) 을 제공함으로써 애플리케이션 개발자들에게 이런 동시성 문제를 해결해주려고 했다.

일단 트랜잭션의 격리와 발생할 수 있는 Concurrency 문제들에 대해서 알아보고 이를 해결할 수 있는 방법들(Concurrency Control)에 대해서 알아보자.


트랜잭션 격리(Isolation) 와 대표적인 Concurrency 문제들

데이터베이스는 동시성 문제를 해결하기 위해서 여러가지 격리 수준을 제공해주고 그 중 최상위 격리 수준인 Serialization 은 데이터베이스 트랜잭션이 순차적으로 실행하도록 만들어 준다. (완전 이렇게 엄격하지는 않고 다른 방식을 통해서 Serialization 을 보장해준다.)

하지만 이런 직렬성은 성능 상의 이슈가 있어서 다소 완화된 격리 수준도 함께 데이터베이스는 제공해준다.

이런 완화된 격리수준으로 인해 발생할 수 있는 Concurrency 이슈들에 대해서 이해하고 각 격리 수준이 이들을 어디까지 어떻게 해결할 수 있는지 알아두는게 중요하다.

가장 기본적인 트랜잭션 격리부터 가장 엄격한 트랜잭션 격리에 대해서 알아보자.

Read Committed

가장 기본적인 트랜잭션 격리 수준으로 다음과 같이 두 가지를 보장해준다.

  1. 데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다. (Dirty Read 방지)

  2. 데이터베이스에서 쓸 때 커밋된 데이터만 덮어쓰게 된다. (Dirty Write 방지)

Dirty Read 방지

트랜잭션이 커밋되지 않은 데이터를 읽는 걸 Dirty Read 라고 한다. Read Committed 수준의 격리는 트랜잭션이 값을 읽을 때 커밋된 데이터를 기준으로만 값을 읽어온다. 현재 진행중인 다른 트랜잭션이 변경한 값은 읽어오지 않는다.

Dirty Read 를 방지하면 해결할 수 있는 문제가 많다.

  • 트랜잭션이 Dirty Read 를 허용한다면 일부는 갱신된 값을 일부는 갱신되지 않은 값을 읽어서 이로인해 혼란을 가져다 줄 수 있다.

  • 트랜잭션이 Dirty Read 를 허용한다면 트랜잭션이 어보트(abort) 나게 되는 상황에서 다른 트랜잭션이 abort 되는 트랜잭션의 값을 기반으로 업데이트 했다면 굉장히 혼란스러운 상황이 발생할 수 있다.

Dirty Write 방지

두 트랜잭션이 동일한 데이터에 동시에 갱신하려고 하면 어떻게 될까? 일반적으로 나중에 쓴 트랜잭션이 결과를 덮어쓰게 될 확률이 높다.

보통 이 문제를 해결하기 위해서는 한 트랜잭션이 다른 트랜잭션이 커밋되거나 어보트(abort) 될 때까지 기다려야한다.

Dirty Write 를 방지하면 해결할 수 있는 문제가 많다.

  • 여러 객체를 갱신하는 트랜잭션의 경우 Dirty Write 는 문제가 생길 수 있다. 예를 들어 중고차를 사는 트랜잭션이 있고 이 트랜잭션 안에는 두번의 갱신이 있다고 가정해보자. 하나는 구매자를 정하는 업데이트가 있고 다른 하나는 송장을 보내는 업데이트가 있다. 이를 둘이 동시에 물건을 산다고 하면 물건의 소유자가 송장을 못받는 경우가 생길 수 있다.

    • 엘리스와 밥이 동시에 차를 사는 진행과정을 살펴보면 다음과 같다.

      • 엘리스가 차의 소유자가 되고 -> 밥이 차의 소유자가 되고 -> 판매 송장이 밥의 주소로 등록되고 -> 판매 송장이 엘리스의 송장으로 등록되고 결국 소유자는 밥이 되고 판매 송장은 엘리스로 된다.

그러나 Read Committed 은 Lost Update 같은 문제는 막지 못한다. 예를 들면 한 물건의 소유자를 정하는 트랜잭션의 동시성 문제는 막지 못한다.

물건의 구매를 하는 트랜잭션이 동시에 진행된다고 하면 둘 다 소유자가 없는 즉 커밋된 데이터를 읽어올 것이지만 소유자는 후에 커밋된 트랜잭션으로 결정되기 때문이다.

Repeatable Read

Read Committed 정도의 격리도 되게 많은 문제를 해결해 주지만 다음과 같은 Read Skew 문제를 해결해 주지 않는다.

엘리스는 계좌 두 개를 가지고 있고 각각 500 달러씩 있다고 가정해보자. 여기서 2번 계좌에서 1번 계좌로 100 달러를 보내는 송금 트랜잭션을 실행시킨다.

이 경우에 다음과 같이 일이 처리된다면 Read Skew 현상이 일어난다.

1. 엘리스가 계좌 1 조회 (500 달러)

2. 송금 트랜잭션 실행 후 커밋 

3. 엘리스가 계좌 2 조회 (400 달러)

이 경우에 엘리스가 처음에 1번 계좌를 읽었을 때 500 달러가 있다고 확인 한 후 2번 계좌에서 400 달러가 있는 걸 엘리스가 만난다면 100 달러가 갑자기 사라진 것 처럼 보일 수 있다.

이렇게 Read Committed 단계에서는 일관성 없는 결과를 만나는 Read Skew 현상을 만날 수 있다.

커밋된 결과로 인해 오래된 버전과 새로운 버전의 데이터를 모두 만나는 경우가 생길 수 있다.

Reapeatable Read 는 Snapshot 을 이용해 이런 문제를 해결한다.

각 트랜잭션은 데이터베이스의 일관된 Snapshot 으로 부터 데이터를 읽어온다. 이로인해 즉 다른 트랜잭션이 커밋되더라도 이전 트랜잭션은 일관적으로 과거 데이터를 보도록 한다.

이런 Reapeatable Read 는 로킹(locking) 을 통해서 해결하지 않고 데이터베이스 객체마다 커밋된 여러 버전을 가지도록 해서 이를 해결한다.

이 기법은 다중 버전 동시성 제어(multi-version concurrency control) 이라고도 한다.

PorstgreSQL 에서는 트랜잭션 ID 를 통해서 이런 MVCC (다중 버전 동시성 제어) 를 구현했다.

트랜잭션이 시작하면 계속해서 값이 1씩 증가하는 트랜잭션 고유의 ID 를 할당 받는다.

그리고 트랜잭션이 데이터를 쓸 때마다 데이터에 쓰기를 실행한 트랜잭션의 ID 가 함께 붙는다.

테이블의 각 로우에는 그 로우를 테이블에 삽입한 트랜잭션의 ID 를 갖는 created_by 필드가 있게되고 또 각 로우에 처음에는 비어있는 delete_by 필드도 있다.

트랜잭션이 로우를 삭제하면 바로 삭제하는게 아니라 delete_by 에 해당 트랜잭션의 ID 가 박히게 되고 지워졌다고 표시된다. 이후에 더이상 그 트랜잭션에 접근하지 않을 때 해당 데이터를 지운다.

이렇게 갱신은 내부에서 삭제와 생성으로 변환된다. 잔고가 500 달러가 있는 1번 계좌에서 100 달러가 나가는 트랜잭션이 실행된다고 하면 계좌 테이블에는 500 달러가 있는 계좌와 400 달러가 있는 계좌 2개가 존재하게 된다.

이렇게 데이터가 여러 버전이 있다면 어떤 걸 볼 수 있고 어떤 걸 볼 수 없을까?

Repeatable Read 에서는 다음과 같은 규칙을 따른다.

  • 데이터베이스는 각 트랜잭션을 실행할 때 그 시점에 진행중인 모든 트랜잭션들을 모아서 리스트로 만든다. 그리고 이 트랜잭션들이 쓴 데이터는 모두 읽는 것을 무시한다. 설령 나중에 커밋되더라도 무시한다.

  • 어보트된 트랜잭션이 쓴 데이터는 모두 무시한다.

  • 트랜잭션 ID 가 더 큰 트랜잭션(즉 현재 트랜잭션보다 후에 실행된 트랜잭션) 이 쓴 데이터도 무시한다.

  • 그 밖의 모든 데이터는 read 연산이므로 볼 수 있다.

즉 정리하면 결과적으로 다음과 같은 조건일 때 데이터를 볼 수 있다.

  • 읽기를 시작하는 트랜잭션이 시작될 때 이미 객체를 생성하는 트랜잭션이 커밋되어 있으면 이 객체를 볼 수 있다.

  • 읽기 대상 객체가 삭제된 것으로 표시되지 않았거나 표시됐지만 읽기를 시작한 트랜잭션 이후에 커밋되었다면 볼 수 있다.

Lost Update 방지

Repeatable Read 를 사용하면 Lost Update 문제를 막을 수 있다.

먼저 Lost Update 가 어떤 것인지 알아보자.

Lost Update 는 하나의 트랜잭션이 수행한 결과를 다른 트랜잭션이 덮어쓰는 경우를 말한다.

한 트랜잭션 T1 은 데이터 X 에 1000 의 값들 더하는 트랜잭션이고 다른 트랜잭션 T2 는 데이터 X 에 값을 50% 로 줄이는 트랜잭션이 있다고 가정해보자.

여기서 X 값은 3000 이라고 하겠다.

연산의 처리 과정은 다음과 같을 수 있다.

1. 처음 T1 이 시작하면서 X 의 값 3000을 읽어오고 1000을 더한다. 

2. 다음 트랜잭션 T2 가 들어오면서 같은 X 의 값 3000을 읽어오고 50% 를 곱한다. 

3. 이후에 T1 이 커밋을 하고 곧바로 T2 가 커밋을 한다.  

이런 과정을 거치면 데이터 X 의 값은 1500 으로 저장된다. T1 의 결과가 유실된 것이다.

Repeatable Read 는 이런 Lost Update 가 발생할 수 있는 트랜잭션의 경우를 병행적으로 실행하되 Lost Update 를 감지하면 abort 시키고 다시 트랜잭션을 재시도 시킨다.

이때 갱신할 객체를 명시적으로 잠근다.

객체가 잠기면 다른 트랜잭션은 읽기 요청이라도 이 잠금이 풀릴 때까지 기다려야 한다.

번외로 이런 명시적인 잠금을 그냥 바로 이용하고 싶다면 SELECT ... FOR UPDATE 구문을 이용하면 된다.

Lost Update 를 막는 방법은 여러가지가 있는데 이를 조금 더 소개하자면 그 객체에 독점적인(exclusive) 잠금을 획득해서 해결하는 방법도 있고 Compare-and-set 을 이용하는 방법도 있다.

이는 내가 읽은 값이 마지막으로 변경된 값이 내 예측과 같다면 변경하겠다 라는 의미로 맞지 않다면 실패하고 재시도 하는 방벙이다. 이 방법은 데이터베이스에서 지원을 안할수도 있으므로 지원하는지 체크를 해야한다.

Serialization

Write Skew 라는 동시성 문제가 있는데 이는 Repeatable Read 도 해결할 수 없다.

이를 알아보고 Serialization 은 어떻게 이걸 해결하는지 알아보자.

Write Skew

Write Skew 는 Lost Update 보다 더욱 미묘한 쓰기 문제인데 예시로 바로 보자.

의사들이 병원에서 교대로 서야하는 호출 대기를 관리하는 어플리케이션이 있다.

이 어플리케이션에서 대기해야 하는 의사는 최소한 한 명은 있어야 하며 여러명이 대기를 할 수도 있다.

즉 두 명의 의시가 대기해야 하는 상황이라면 한 명은 나갈 수 있다.

하지만 이 경우에 동시에 대기를 나가는 트랜잭션이 실행된다면 두명 다 나가고 대기하는 의사가 0 명이 될 수도 있다.

엘리스와 밥, 두 의사가 동시에 대기를 나가는 트랜잭션을 실행했다면 다음과 같이 진행될 수도 있다.

1. 엘리스 트랜잭션 시작 - 병원 대기자가 몇 명인지 조회 -> 2명 결과가 나옴

2. 밥 트랜잭션 시작 - 병원 대기자가 몇 명인지 조회 -> 2명 결과가 나옴 

3. 엘리스 트랜잭션 재개 - 엘리스를 대기자에서 제외시키고 커밋

4. 밥 트랜잭션 재개 - 밥을 대기자에서 제외시키고 커밋

이런 현상을 Write Skew 라고 한다. 두 트랜잭션이 두 개의 다른 객체를 갱신하므로 Dirty Write 도 아니고 Lost Update 도 아니다.

이는 동일한 객체에 갱신이 일어나는게 아니므로 Repeatable Read 에서도 감지할 수 없다.

Write Skew 와 같은 문제를 이해하기 위해서 추가적인 예시를 보면 다음과 같다.

  • 회의실 예약 시스템

    • 동시에 같은 회의실을 중복 예약할 수 없는 어플리케이션이 있다.

    • 만약에 동시에 회의실 예약을 실행하면 둘 다 회의실 등록이 되는 Write Skew 가 발생할 수 있다. (이 문제는 테이블 구조를 어떻게 짜느냐에 따라서 Write Skew 가 될 수도 있지만 Lost Update 가 될 수도 있을 것 같다. Write Skew 가 되는 예시는 Bookings 라는 테이블에 회의실 사용하는 사람을 INSERT 하는 경우다.)

  • 사용자명 획득

    • 각 사용자가 유일한 사용자 명을 가져야 하는 웹사이트에서 두 명의 사용자가 동시에 같은 사용자 명으로 계정 생성을 시도할 때 Write Skew 문제가 발생할 수 있다.

    • 각 트랜잭션은 사용자 이름이 점유됐는지 확인하고 없다면 그 사용자로 계정을 생성하므로 두 개의 같은 이름을 가진 사용자가 등장하는게 가능하다. (하지만 이 경우 해결방법은 사용자 이름에 UNIQUE 라는 제약조건을 붙인다면 두번째 실행되는 트랜잭션은 실패할 것이다. 그러므로 UNIQUE 제약조건을 통해 해결하는게 가능하다.)

  • Double Spending 방지

    • 사용자가 돈이나 포인트를 지불하는 서비스에서는 사용자가 가지고 있는 잔고보다 더 작은지 확인해야한다. 지불 예정 항목을 사용자 잔고와 비교해서 지불 예정 항목의 합이 사용자 잔고보다 더 작다면 실행될 수 있도록 설계 되어 있다면 Write Skew 가 발생할 수 있다. 이 경우에 지불 트랜잭션이 동시에 실행된다면 계좌 잔고가 음수가 되는 문제가 생길 수 있다.

Serialization Concurrency Control

Serialization 은 이런 Write Skew 를 해결할 수 있다. 물론 Write Skew 를 해결할 수 있는 방법으로 Materializing Conflict 라는 방법도 있지만 이 방법도 오류가 많기 때문에 소개하진 않겠다.

Serialization 은 동시성 없이 한 번에 하나씩 실행될 때와 같도록 보장한다. (그렇다고 직접적으로 하나씩 실행하지는 않는다. 그러는 경우에 성능이 완전히 떨어지므로)

Serialization 을 설명하기 위해 로킹(locking) 기법을 먼저 설명하고 난 후 실제 기법인 2단계 잠금(two-phase locking) 을 설명하겠다.

로킹(locking) 기법의 개념

로킹 기법은 트랜잭션들이 병행 수행될 때 동일한 데이터에 동시에 접근하지 못하게 lock 과 unlock 을 통해서 제어하는 기법을 말한다.

로킹 기법의 원리는 한 트랜잭션이 먼저 데이터에 접근한다면 데이터에 대한 연산을 모두 마칠때까지 다른 트랜잭션에서 접근하지 못하도록 상호 배제(mutual exclusion) 을 이용해서 직렬성을 보장하는 것이다.

로킹 기법의 lock 연산은 트랜잭션이 사용할 데이터에 대해 독점권을 가지기 위해서 사용된다면 unlock 연산은 트랜잭션이 데이터에 대한 독점권을 반납하기 위해서 사용한다.

즉 데이터에 접근하고자 하면 먼저 lock 연산을 통해서 데이터에 대해 독점권을 얻은 다음에 다른 연산을 실행한 후 unlock 연산으로 인해 독점권을 푸는 식으로 진행한다.

lock 을 할 때 실행하는 데이터의 단위도 중요한데 lock 을 하는 단위를 너무 크게 잡으면 처리율이 떨어지기 때문에 성능이 나오지 않고 너무 작게 잡으면 성능은 올라가지만 복잡한 Concurrency Control 이 필요하다.

이런 상황에서 대표적으로 lock 은 크게 두 가지로 나눠서 사용이 된다.

  • Shared Lock

    • 트랜잭션이 데이터에 대해 Shared Lock 을 사용하고 있다면 다른 트랜잭션에서 read 연산은 가능하지만 write 연산은 수행할 수 없다. 그러므로 읽기 연산만 수행하는 경우에 성능을 떨어뜨리지 않는다.
  • Exclusive Lock

    • 트랜잭션이 데이터에 대해 Exclusiv Lock 을 사용하고 있다면 다른 트랜잭션은 read 연산도 write 연산도 불가능하다. Exclusive Lock 을 반납할때까지 기다려야 한다.

로킹은 이렇게 사용이 가능하지만 이 정도로만 사용해서는 문제를 해결할 수 없다.

이 로킹을 이용해 이전 트랜잭션 동시성 문제를 해결할 수 있는지 예제를 통해서 살펴보자.

한 트랜잭션 T1 은 데이터 X 와 데이터 Y 값에 각각 1000 씩 더하는 트랜잭션이고 다른 트랜잭션 T2 는 데이터 X 와 데이터 Y 의 값에 각각 50% 씩 감소시키는 트랜잭션이다.

기본 로킹을 이용한 동시성 처리는 다음과 같을 수 있다.

  1. 트랜잭션 T1 의 데이터 X 처리
lock(x);
read(x);
x = x + 1000;
write(x);
unlock(x); 
  1. 트랜잭션 T2 의 처리
lock(x);
read(x);
x = x * 0.5;
write(x);
unlock(x);

lock(y); 
read(y);
y = y * 0.5; 
write(y); 
unlock(y);
  1. 트랜잭션 T1 이 이어서 처리
lock(y);
read(y);
y = y + 1000;
write(y);
unlock(y);

로킹을 이용했지만 여전히 데이터의 모순성이 발생했다.

이런 로킹의 문제를 조금 더 보완하기 위해서 2단계 잠금(two-phase locking) 이라는 기법이 있다.

2단계 locking 같은 경우에는 다음과 같이 단계가 두 개가 있다.

  • 확장 단계

    • 트랜잭션이 lock 연산만 수행할 수 있고 unlock 연산은 실행할 수 없다.
  • 축소 단계

    • 트랜잭션이 unlock 연산만 수행할 수 있고 lock 연산은 수행할 수 없다.

2단계 잠금을 이용한 트랜잭션이 수행되면 처음에는 확장 단계로 들어가서 lock 연산만 수행하는게 가능하다.

그러다가 unlock 연산을 실행해 축소 단계로 들어가서 연산을 마무리 한다.

unlock 연산을 하면서 lock 연산을 하는 것도 불가능하고 lock 연산을 하다가 unlock 연산을 하고 다시 lock 연산 하는것도 불가능하다.

2단계 잠금은 다음과 같은 룰을 따른다고 생각하면 된다.

  • 트랜잭션이 객체를 읽기 원한다면 먼저 Shared Lock 을 얻어야 한다. 동시에 여러 트랜잭션이 Shared Lock 을 얻는 건 허용되지만 만약 그 객체에 Exclusive Lock 을 획득한 트랜잭션이 있다면 그 트랜잭션이 완료될 때까지 기다려야 한다.

  • 트랜잭션이 객체에 쓰기를 원한다면 먼저 Exclusive Lock 을 얻어야 한다. 다른 어떠한 트랜잭션도 동시에 Exclusive Lock 을 얻을 수 없다.

  • Exclusive Lock 을 얻은 트랜잭션이 먼저 있다면 읽기 요청이라도 Exclusive Lock 을 얻은 트랜잭션이 완료될 때까지 기다려야 한다.

  • 트랜잭션이 읽다가 쓰는 경우라면 Shared Lock 에서 Exclusive Lock 으로 업그레이드 해야한다.

  • 트랜잭션이 잠금을 획득한 후라면 트랜잭션이 종료될 때까지 모든 잠금을 갖고 있어야 한다. 그래서 이 방법이 2단계 잠금이다. 잠금을 모두 가지고 있다가 이후에 모든 잠금을 해제하므로 2단계 잠금이다.

2단계 잠금 규약을 이용하면 위의 문제를 어떻게 해결할 수 있는지 위의 예제를 그대로 가져와서 실행해보자.

  1. 트랜잭션 T1 의 처리
lock(x);
read(x);
x = x + 1000;
write(x);
lock(y);
unlock(x);
  1. 트랜잭션 T2 의 처리
lock(x);
read(x);
x = x * 0.5;
write(x); 
  1. 트랜잭션 T1 의 재개
read(y);
y = y + 1000;
write(y);
unlock(y); 
  1. 트랜잭션 T2 의 재개
lock(y);
unlock(x); 
read(y);
y = y * 0.5;
write(y);
unlock(y); 

이렇게 2단계 로킹 규약을 이용하면 트랜잭션을 직렬적으로 실행하는게 가능하다. 하지만 교착 상태(deadlock) 이 발생할 수 있다.

다른 트랜잭션에서 트랜잭션 A 가 x 에 대한 lock 을 가지고 있고 트랜잭션 B 가 y 에 대한 lock 을 가지고 있다면 둘은 영원히 기다리게 되는 교착 상태가 발생할 수 있다.

교착 상태는 다음과 같이 4가지가 모두 충족되어야 일어날 수 있으므로 이 중 하나를 해결하면 된다.

  • 상호 배제 (Mutual Exclusion)

    • 상호 배제의 정의는 다른 두 스레드에서 동시에 사용할 수 없는 자원 조건을 말한다.

    • 이를 해결하는 방법으로는 동시에 사용해도 괜찮은 자원을 이용할 수 있는지 체크한다. 예로 자바에서는 Atomic Variable 이 있다.

  • 잠금 & 대기 (Lock & Wait)

    • 잠금 & 대기의 정의는 일단 한 스레드가 자원을 점유하면 나머지 필요한 자원을 모두 얻어서 처리할 수 있을 때까지 대기한다.
    • 이를 해결하는 방법으로는 각 자원을 점유하기 전에 어느 하나라도 얻을 수 없다면 자원을 모두 반납하도록 한다.
  • 선점 불가 (No Preemption)

    • 선점 불가의 정의는 한 스레드가 다른 스레드의 자원을 빼앗지 못하는 것을 말한다.

    • 이를 해결하는 방법으로 자원을 가지고 있는 스레드에게 자원을 풀어달라는 요청을 하면 된다.

  • 순환 대기 (Circuit Wait)

    • 순환 대기는 두 스레드가 상대방이 가진 자원이 반납되긴를 기다리는 상황을 말한다.

    • 이를 해결하는 방법으로는 모든 자원을 순서대로 스레드에게 할당할 수 있다면 순환 대기는 불가능하게 된다.

Predicate Lock

앞에서 동시성 문제 중 하나인 Phantom 문제, 즉 한 트랜잭션이 다른 트랜잭션의 검색 결과를 바꿔버리는 문제에 대해서 설명하지 않았다.

Phantom 문제는 Repeatable Read 에서 해결할 수 없는 문제로 이전에 조회했던 결과와 다르게 내가 삽입하지도 삭제하지도 않았던 데이터로 인해 조회 결과가 바뀌는 문제를 말한다.

Serialization 에서는 Predicate Lock 으로 Phantom 문제를 막는다.

Predicate Lock 은 Shared Lock 과 Exclusive Lock 과는 다르게 검색 조건에 부합하는 모든 객체를 말한다.

회의실 예약을 기준으로 설명하자면 빈 회의실 검색 날짜를 2021.08.04 ~ 2021.08.10 으로 검색을 한다면 이 기간에 있는 모든 객체에 락이 걸린다.

Predicate Lock 이 제한하는 방법은 다음과 같다.

  • 트랜잭션 T1 이 검색 조건을 가지고 SELECT 질의를 한다면 질의에 대한 Shared Predicate Lock 을 얻어야 한다. 이 상황에서 다른 트랜잭션 T2 가 그 조건에 부합하는 Exclusive Lock 을 얻어 있다면 그 트랜잭션이 Lock 을 풀때까지 쿼리를 기다려야 한다.

  • 트랜잭션 T1 이 어떤 객체를 삽입, 갱신, 삭제를 원한다면 그 조건에 해당하는 Shared Predicate Lock 을 누군가 가지고 있는지 확인해야한다. Predicate Lock 을 다른 트랜잭션 T2 가 가지고 있다면 그 트랜잭션이 커밋될때까지 기다려야 한다.

Serializable Snapshot Isolation

2단계 잠금은 동시성 제어를 해결할 수 있지만 성능이 그렇게 좋지 않다.

이로인해 직렬성 격리와 성능 두 마리를 모두 가지고 갈 방법으로 SSI(Serializble Snapshot Isolation) 라는 알고리즘이 등장했다.

완전한 직렬성을 제공해주지만 Snapshot 격리보다 약간의 성능상의 단점만 있는 알고리즘이다.

SSI 는 Postgresql 9.1 버전 이후부터 Serialization 격리 수준으로 제공된다.

2단계 잠금이 비관적(Pessimistic) 방법이라면 SSI 는 낙관적(Optimistic) 방법이다.

이런 낙관적 방법이란 말은 동시성 문제가 생길 수 있을때 다른 트랜잭션을 막기 보다는 병행하는 대신에 원하는 상황이 아니라면 실패하고 재시도 하도록 하는 방법이다.

이런 낙관적 방법은 경쟁이 심한 경우에 실패하는 트랜잭션이 많아질 수 있는 상황에선 성능이 안나온다는 단점이 있다.

그러나 트랜잭션 사이의 경쟁이 그렇게 심하지 않는다면 낙관적 락의 방법은 비관적 락의 방법보다 성능이 좋다.

profile
좋은 습관을 가지고 싶은 평범한 개발자입니다.

1개의 댓글

comment-user-thumbnail
2021년 9월 5일

ㄸㄷ

답글 달기