[데이터 중심 애플리케이션 설계] 07. 트랜잭션

예니·2023년 2월 4일
0
post-thumbnail
  • 트랜잭션은 애플리케이션에서 몇 개의 읽기와 쓰기를 하나의 논리적 단위로 묶는 방법이다.
  • 모든 애플리케이션에서 트랜잭션이 필요하지는 않다. 어떤 안정성은 트랜잭션 없이도 보장될 수 있다.

애매모호한 트랜잭션의 개념

ACID의 의미

원자성, 일관성, 격리성, 지속성

원자성

  • 원자적이란 더 작은 부분으로 쪼갤 수 없는 무언가를 가리킨다.
  • 시스템은 연산을 실행하기 전이나 실행한 후의 상태에만 있을 수 있고, 그 중간 상태에는 머물 수 없다.
  • 트랜잭션이 어보트됐다면 애플리케이션에서 이 트랜잭션이 어떤 것도 변경하지 않았음을 알 수 있으므로 안전하게 재시도할 수 있다.

일관성

  • 항상 진실이어야 하는, 데이터에 관한 어떤 선언(불변식)이 있다는 것이다. 트랜잭션이 이런 불변식이 유효한 데이터베이스에서 시작하고 트랜잭션에서 실행된 모든 쓰기가 유효성을 보존한다면 불변식이 항상 만족된다고 확신할 수 있다.
  • 일관성의 아이디어는 애플리케이션의 불변식 개념에 의존하고, 일관성을 유지하도록 트랜잭션을 올바르게 정의하는 것은 애플리케이션의 책임이다.
  • 원자성, 격리성, 지속성은 데이터베이스의 속성이지만, 일관성은 애플리케이션의 속성이다.

격리성

  • 격리성은 동시에 실행되는 트랜잭션은 서로 격리된다는 것을 의미한다.
  • 직렬성은 커밋 순차적으로 트랜잭션을 처리하는 것인데, 성능 손해가 있어서 대중적 데이터베이스에서는 사용하지 않는다.

지속성

  • 지속성은 트랜잭션이 성공적으로 커밋됐다면 하드웨어 결함이 발생하거나 데이터베이스가 죽더라도 트랜잭션에서 기록한 모든 데이터는 손실되지 않는다는 보장이다.
  • 완벽한 지속성은 존재하지 않는다.

단일 객체 연산과 다중 객체 연산

  • 다중 객체 트랜잭션은 흔히 데이터의 여러 조각이 동기화된 상태로 유지돼야 할 때 필요하다.
  • 다중 객체 트랜잭션은 어떤 읽기 연산과 쓰기 연산이 동일한 트랜잭션에 속하는지 알아낼 수단이 있어야 한다. 관계형 데이터베이스에서는 특정 연결 내에서 BEGIN TRANSACTION 문과 COMMIT 문 사이의 모든 것은 같은 트랜잭션에 속하는 것으로 여긴다. 비관계형 데이터베이스에는 이런 식으로 연산을 묶는 방법이 없는 경우가 많다. 종종 부분적으로 갱신된 상태가 될 수 있다.

단일 객체 쓰기

  • 원자성과 격리성은 단일 객체를 변경하는 경우에도 적용된다.
  • 원자성은 장애 복구용 로그를 써서 구현할 수 있고, 격리성은 각 객체에 잠금을 사용해 구현할 수 있다.
  • 단일 객체 연산은 여러 클라이언트에서 동시에 같은 객체에 쓰려고 할 때 갱신 손실을 방지하므로 유용하다.

다중 객체 트랜잭션의 필요성

다중 객체 트랜잭션이 필요한 경우

  • 관계형 데이터 모델에서 외래 키를 갖는 경우
  • 문서 데이터 모델에서 비정규화된 정보를 갱신할 때 한 번에 여러 문서를 갱신해야 한다.
  • 보조 색인이 있는 데이터베이스는 값을 변경할 때마다 색인도 갱신돼야 한다.

오류와 어보트 처리

  • 어보트된 트랜잭션을 재시도하는 것은 간단하고 효과적이지만 몇몇 문제가 있다.
    • 트랜잭션이 사실 성공했지만, 클라이언트에게 커밋 성공을 알리는 도중 네트워크 이슈가 생긴 경우, 재시도하면 트랜잭션이 두 번 실행된다.
    • 오류가 과부하 때문이라면 재시도는 문제를 악화시킨다. 재시도 횟수를 제한하거나 지수적 백오프를 사용할 수 있다.
    • 일시적인 오류일 때만 재시도가 효과있다.
    • 트랜잭션이 데이터베이스 외부에 부수 효과가 있다면 어보트될 때도 부수 효과가 있을 수 있다.

완화된 격리 수준

  • 동시성 문제는 트랜잭션이 다른 트랜잭션에서 동시에 변경한 데이터를 읽거나 두 트랜잭션이 동시에 같은 데이터를 변경하려고 할 때만 나타난다.
  • 동시성은 추론하기 어렵다. 특히 다른 어떤 코드 조각에서 데이터베이스에 접근하는지 확실히 알지 못할 수 있는 커다란 애플리케이션에서 그렇다.
  • 트랜잭션 격리는 어떤 동시성 이슈로부터는 보호해주지만 모든 이슈로부터 보호해주지는 않는, 완화된 격리 수준을 제공하는 경우가 흔하다.
  • 동시성 문제의 종류를 잘 이해하고 방지하는 방법을 배워, 사용 가능한 도구를 서서 신뢰성 있고 올바르게 동작하는 애플리케이션을 만들어야 한다.

커밋 후 읽기

가장 기본적인 수준의 트랜잭션 격리이다.

더티 읽기, 더티 쓰기가 없다.

더티 읽기 방지

다른 트랜잭션에서 커밋되지 않은 데이터를 볼 수 있는 것을 더티 읽기라고 한다.

다른 트랜잭션에서 커밋된 후에야 새로운 값을 볼 수 있도록 해서 더티 읽기를 방지한다.

더티 쓰기 방지

먼저 쓴 내용이 아직 커밋되지 않은 트랜잭션에서 쓴 것이고 나중에 실행된 쓰기 작업이 커밋되지 않은 값을 덮어쓰는 것을 더티 쓰기라고 한다.

이를 방지하기 위해 먼저 쓴 트랜잭션이 커밋되거나 어보트될 때까지 두번째 쓰기를 지연시키는 방법을 사용한다.

커밋 후 읽기 구현

  • 더티 쓰기 방지 구현 데이터베이스의 로우 수준 잠금을 사용한다.
  • 더티 읽기 방지 구현 쓰여진 모든 객체에 대해 데이터베이스는 과거에 커밋된 값과 현재 쓰기 잠금을 갖고 있는 트랜잭션에서 쓴 새로운 값을 모두 기억한다. 해당 트랜잭션이 실행 중인 동안 그 객체를 읽는 다른 트랜잭션들은 과거의 값을 읽게 된다. 새 값이 커밋돼야만 다른 트랜잭션들이 새 값을 읽을 수 있게 된다.

스냅숏 격리와 반복 읽기

  • 커밋 후 읽기 격리 수준을 사용해도 동시성 버그가 생길 수 있다. 비반복 읽기(non repeatable read, read skew)
  • 일시적인 비일관성이지만 백업, 분석 질의, 무결성 확인에서는 아주 일시적인 비일관성도 감내할 수 없다.
  • 스냅숏 격리는 이런 문제의 가장 흔한 해결책이다. 각 트랜잭션은 데이터베이스의 일관된 스냅숏으로부터 읽는다. 트랜잭션은 시작할 때 데이터베이스에 커밋된 상태였던 모든 데이터를 본다. 데이터가 나중에 다른 트랜잭션에 의해 바뀌더라도 각 트랜잭션은 특정한 시점의 과거 데이터를 볼 뿐이다.
  • 스냅숏 격리는 백업이나 분석처럼 실행하는 데 오래 걸리며 읽기만 하는 질의에 요긴하다.

스냅숏 격리 구현

  • 스냅숏 격리의 핵심 원리는 읽는 쪽에서 쓰는 쪽을 결코 차단하지 않고 쓰는 쪽에서 읽는 쪽을 결코 차단하지 않는다. 데이터베이스는 잠금 경쟁 없이 쓰기 작업이 일상적으로 처리되는 것과 동시에 일관성 있는 스냅숏에 대해 오래 실행되는 읽기 작업을 처리할 수 있다.
  • 스냅숏 격리를 구현하기 위해 데이터베이스는 객체마다 커밋된 버전 여러 개를 유지할 수 있어야 한다. 데이터베이스가 객체의 여러 버전을 함께 유지하므로 이 기법을 다중 버전 동시성 제어(MVCC)라고 한다.
  • 트랜잭션이 시작하면 계속 증가하는 고유한 트랜잭션 ID를 할당받는다. 트랜잭션이 이 데이터베이스에 데이터를 쓸 때마다 쓰기를 실행한 트랜잭션의 ID가 함께 붙는다. 테이블의 각 로우에는 트랜잭션 ID를 갖는 created_by, deleted_by 필드가 있다. 처음에 deleted_by는 비워져있고, 로우를 삭제하면 이 필드가 채워짐으로써 지워졌다고 표시한다. 나중에 아무 트랜잭션도 사용하지 않을 때 해당 로우를 삭제하여 사용량을 줄인다.

일관된 스냅숏을 보는 가시성 규칙

  • 동작 방식
    • 각 트랜잭션을 시작할 때 그 시점에 진행 중인 트랜잭션들이 쓴 데이터는 모두 무시된다.
    • 어보트된 트랜잭션이 쓴 데이터는 모두 무시된다.
    • 트랜잭션 ID가 더 큰 트랜잭션이 쓴 데이터는 모두 무시된다.
  • 아래 두 조건이 모두 참이면 객체를 볼 수 있다.
    • 읽기를 실행하는 트랜잭션이 시작한 시점에 읽기 대상 객체를 생성한 트랜잭션이 이미 커밋된 상태였다.
    • 읽기 대상 객체가 삭제된 것으로 표시되지 않았다.

반복 읽기와 혼란스러운 이름

스냅숏 격리를 오라클에서는 직렬성, PSQL, MySQL에서는 반복 읽기라고 한다.

갱신 손실 방지

  • 동시에 실행되는 쓰기 트랜잭션 사이에 발생할 수 있는 충돌 중 가장 널리 알려진 것은 갱신 손실이다.
  • 갱신 손실 문제는 애플리케이션이 데이터베이스에서 값을 읽고 변경한 후 변경된 값을 다시 쓸 때 발생할 수 있다.(read-modify-write 주기) 만약 두 트랜잭션이 이 작업을 동시에 하면 두 번째 쓰기 작업이 첫 번째 변경을 포함하지 않으므로 변경 중 하나는 손실된다.

다음과 같은 해결책이 있다.

원자적 쓰기 연산

  • 여러 데이터베이스에서 원자적 갱신 연산을 제공한다. 이 연산은 애플리케이션 코드에서 read-modify-write 주기를 구현할 필요를 없애준다.
  • 원자적 연산은 보통 객체를 읽을 때 그 객체에 독점적인 잠금을 획득해서 구현한다. 그래서 갱신이 적용될 때까지 다른 트랜잭션에서 그 객체를 읽지 못하게 한다. 이 기법을 커서 안정성이라고도 한다.

명시적인 잠금

  • 애플리케이션에서 갱신할 객체를 명시적으로 잠근다.
  • 코드에 필요한 잠금을 추가하는 것을 잊어버려 경쟁 조건을 유발하기 쉽다.

갱신 손실 자동 감지

  • 병렬 실행을 허용하고 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 어보트시키고 read-modify-write 주기를 재시도하도록 강제하는 방법이다.
  • 잠금이나 원자적 연산을 쓰는 것을 잊어버려 버그를 유발할 순 있지만, 자동으로 갱신 손실이 감지되어 오류가 덜 발생하게 해준다.

Compare-and-set

  • 이 연산의 목적은 값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용함으로써 갱신 손실을 회피하는 것이다.
  • 현재 값이 이전에 읽은 값과 일치하지 않으면 갱신은 반영되지 않고, read-modify-write 주기를 재시도해야 한다.

충돌 해소와 복제

  • 복제가 적용된 데이터베이스에서 갱신 손실을 막는 것은 추가 단계가 필요하다.
  • 잠금과 compare-and-set 연산은 데이터의 최신 복사본이 하나만 있다고 가정한다. 그래서 복제가 적용된 데이터베이스에서는 이 방식을 사용할 수 없다.
  • 쓰기가 동시에 실행될 때 한 값에 대해 여러 개의 충돌된 버전을 허용하고 사후에 특별한 구조를 사용해 충돌을 해소하고 병합하는 방식을 사용할 수 있다.

쓰기 스큐와 팬텀

쓰기 스큐를 특징짓기

쓰기 스큐는 두 트랜잭션이 같은 객체들을 읽어서 그중 일부를 갱신할 때 나타날 수 있다. 다른 트랜잭션이 하나의 동일한 객체를 갱신하는 특별한 경우에 더티 쓰기나 갱신 손실 이상 현상을 겪게 된다.

쓰기 스큐를 유발하는 팬텀

  • 어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 효과를 팬텀이라고 한다.
  • 스냅숏 격리는 읽기 전용 질의에서는 팬텀을 회피하지만, 읽기 쓰기 트랜잭션에서는 팬텀이 쓰기 스큐의 까다로운 경우를 유발할 수 있다.

충돌 구체화

  • 팬텀을 데이터베이스에 존재하는 구체적인 로우 집합에 대한 잠금 충돌로 변환하는 것을 충돌 구체화라고 한다. (materializing conflict)
  • 충돌 구체화는 알아내기 어렵고 오류가 발생하기 쉽다. 동시성 제어 메커니즘이 애플리케이션 데이터 모델로 새어 나오는 것도 안좋다. 그러므로 충돌 구체화는 다른 대안이 불가능할 때 최후의 수단으로 사용해야 하고, 대부분의 경우 직렬성 격리 수준이 낫다.

직렬성

  • 가장 강력한 격리 수준
  • 여러 트랜잭션이 병렬로 실행되더라도 최종 결과는 동시성 없이 한 번에 하나씩 직렬로 실행될 때와 같도록 보장한다.
  • 데이터베이스가 발생할 수 있는 모든 경쟁 조건을 막아준다.

단일 노드 데이터베이스 맥락에서 직렬성을 구현하는 선택지들을 살펴볼 것이다.

실제적인 직렬 실행

  • 한 번에 트랜잭션 하나씩만 직렬로 단일 스레드에서 실행한다.
  • 잠금을 코디네이션하는 오버헤드를 피할 수 있어 성능이 더 나을 때도 있다. 하지만 처리량은 CPU 코어 하나의 처리량으로 제한된다.
  • 단일 스레드를 최대한 활용하려면 트랜잭션이 전통적인 형태와는 다르게 구조화돼야 한다.

트랜잭션을 스토어드 프로시저 안에 캡슐화하기

단일 스레드에서 트랜잭션을 순차적으로 처리하는 시스템들은 상호작용하는 다중 구문 트랜잭션을 허용하지 않는다. 대신 애플리케이션은 트랜잭션 코드 전체를 스토어드 프로시저 형태로 데이터베이스에 미리 제출해야 한다.

파티셔닝

  • 여러 CPU 코어와 여러 노드로 확장하기 위해 데이터를 파티셔닝할 수도 있다.
  • 각 트랜잭션이 단일 파티션 내에서만 데이터를 읽고 쓰도록 데이터셋을 파티셔닝할 수 있다면 각 파티션은 다른 파티션과 독립적으로 실행되는 자신만의 트랜잭션 처리 스레드를 가질 수 있다.
  • 하지만 여러 파티션에 접근해야하는 트랜잭션이 있다면 추가적인 코디네이션 오버헤드 때문에 엄청나게 느려진다.

2단계 잠금(2PL)

  • 2PL에서 쓰기 트랜잭션은 다른 쓰기 트랜잭션뿐만 아니라 읽기 트랜잭션도 진행하지 못하게 막고, 그 역도 성립한다.
  • 읽는 쪽은 쓰는 쪽을 막지 않고, 쓰는 쪽도 읽는 쪽을 막지 않는 스냅숏 격리와 2PL은 다르다.
  • 2PL은 직렬성을 제공하므로 갱신 손실과 쓰기 스큐를 포함한 모든 경쟁 조건으로부터 보호해준다.

2단계 잠금 구현

읽는 쪽과 쓰는 쪽을 막는 것은 데이터베이스의 각 객체에 잠금을 사용해 구현한다. 잠금은 공유 모드와 독점 모드로 사용될 수 있다.

  • 읽기에는 공유 모드 잠금을 사용한다. 공유 모드 잠금끼리는 동시에 잠금을 획득할 수 있지만, 독점 모드 잠금이 걸려있으면 기다려야 한다.
  • 쓰기에는 독점 모드 잠금을 사용한다. 독점 모드에는 다른 어떤 트랜잭션도 동시에 잠금을 획득할 수 없다.

잠금이 많이 사용되므로 교착 상태가 발생할 수 있다. 데이터베이스는 교착 상태를 자동으로 감지하여 트랜잭션 중 하나를 어보트시켜 다른 트랜잭션들이 진행할 수 있게 한다.

2단계 잠금의 성능

동시성이 줄어들고, 잠금을 획득하고 해제하는 오버헤드 때문에 2단계 잠금은 성능이 좋지 않다.

서술 잠금(predicate lock)

  • 서술 잠금은 공유/독점 잠금과 비슷하게 동작하지만 특정 객체에 속하지 않고 어떤 검색 조건에 부합하는 모든 객체에 속한다.
  • 서술 잠금은 데이터베이스에 아직 존재하지 않지만 미래에 추가될 수 있는 객체(팬텀)에도 적용할 수 있다.
  • 2단계 잠금이 서술 잠금을 포함하면 모든 형태의 쓰기 스큐와 다른 경쟁 조건을 막을 수 있어 격리 수준이 직렬성 격리가 된다.

색인 범위 잠금

  • 서술 잠금은 잘 동작하지 않는다. 진행 중인 트랜잭션들이 획득한 잠금이 많으면 조건에 부합하는 잠금을 확인하는 데 오래 걸린다.
  • 데이터베이스는 실제로는 색인 범위 잠금(index-range locking)(다음 키 잠금(next-key locking))을 구현한다.
  • 색인 범위 잠금은 서술 잠금보다 정밀하지 않지만 오버헤드가 훨씬 낮다.

직렬성 스냅숏 격리(SSI, serializable snapshot isolation)

등장한지 얼마 안된, 아주 유망한 알고리즘이다.

비관적 동시성 제어 대 낙관적 동시성 제어

  • 2단계 잠금, 직렬 실행은 비관적 동시성 제어 메커니즘이다.
  • 직렬성 스냅숏 격리는 낙관적 동시성 제어 기법이다.
  • 낙관적 동시성 제어는 경쟁이 심하면 어보트시켜야 할 트랜잭션의 비율이 높아지므로 성능이 떨어진다. 하지만 예비 용량이 충분하고 트랜잭션 사이의 경쟁이 너무 심하지 않으면 낙관적 동시성 제어 기법은 비관적 동시성 제어보다 성능이 좋다.
  • SSI는 스냅숏 격리 위에 쓰기 작업 사이의 직렬성 충돌을 감지하고 어보트시킬 트랜잭션을 결정하는 알고리즘을 추가한다.

뒤처진 전제에 기반한 결정

트랜잭션에서 실행하는 질의와 쓰기 사이에는 인과적 의존성이 있을지도 모른다. 직렬성 격리를 제공하려면 데이터베이스는 트랜잭션이 뒤처진 전제를 기반으로 동작하는 상황을 감지하고 그런 상황에서는 트랜잭션을 어보트시켜야 한다.

  • 감지하는 방법
    • 오래된 MVCC 객체 버전을 읽었는지 감지하기
    • 과거의 읽기에 영향을 미치는 쓰기 감지하기

직렬성 스냅숏 격리의 성능

  • 2단계 잠금과 비교할 때 직렬성 스냅숏 격리의 큰 이점은 트랜잭션이 다른 트랜잭션들이 잡고 있는 잠금을 기다리느라 차단될 필요가 없다는 것이다.
  • 순차 실행과 비교할 때 직렬성 스냅숏 격리는 단일 CPU 코어의 처리량에 제한되지 않는다.
  • 어보트 비율은 SSI의 전체 성능에 큰 영향을 미친다.

0개의 댓글