본문 바로가기

데이터

데이터 중심 어플리케이션 설계 7장-트랜잭션

트랜잭션이 필요한지 어떻게 알 수 있을까?

이 질문에 답변하기 위해서는 아래 2가지를 이해하는 것이 중요하다

  • 안전성 보장에는 어떠한 것들이 있는지?
  • 어떠한 비용이 발생하는지?

이번 7장에서는 아래의 내용을 볼 것이다.

  • 커밋 후 읽기(read committed), 스냅숏 격리(snapshot isolation), 직렬성(serializability) 같은 격리 수준 구현 방법
  • 동시성 제어 종류와 경쟁조건

ACID의 의미

  • 원자성(Atomicity) : 되거나 안되거나
  • 일관성(Consistency) 
    • 일관성을 유지하도록 트랜잭션을 올바르게 정의하는 것은 애플리케이션 책임
    • 여러가지 의미로 사용됨
      • (5장)에서 복제 일관성
      • (6장)일관성 해싱
      • (9장)CAP 정리에서
      • ACID 관점에서는 데이터베이스가 "good state"에 있어야 한다는 것의 애플리케이션 특화된 개념
  • 격리성(Isolation) : 동시에 실행되는 트랜잭션은 서로 격리된다
    • 트랜잭션이 순차적으로 실행됐을 때와 동일하도록 보장

  • 지속성(Durability) : 트랜잭션에서 기록한 모든 데이터는 손실되지 않도록 보장

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

상황 : 이메일이 너무 많아 지면서 읽지 않은 이메일 카운팅을 위해 별도의 테이블 생성해 놓은 경우(일종의 비정규화)

아래와 같은 상황이 발생할 수 있다.

이메일 insert후 update가 완료되지 않은 상태에서 dirty read가 발생하지 않도록 막는다 (격리성)
이메일은 insert가 되고 카운팅 update가 되지 않는다면 모두 롤백되어야 한다 (원자성)

클라이언트에서 동일한 트랜잭션 내에서 동작하는지를 알려줘야 한다

  • 관계형 DB : BEGIN TRANSACTION, COMMIT문 사이가 동일 트랜잭션이라고 여김
  • 비관계형 DB : 연산을 묶는 방법이 없는 경우가 많음

단일 객체 쓰기(Single-object writes)

  • 예시
    • 20KB JSON 문서를 DB에 저장한다면?
  • 예상 문제
    • 10KB만 전송되고 중간에 네트워크 연결 끊긴다면?
    • disk에 기존 값을 덮어쓰다가 전원이 나간다면?
    • 다른 클라이언트가 쓰는 도중에 읽고 업데이트한다면?
  • 예상 해결 방법
    • 장애 복구성 로그(crash recovery log)를 이용해 원자성 보장
    • 각 객체에 잠금을 사용해 격리성 보장
    • compare-and-set 연산

오류와 어보트 처리 ( Handling erros and aborts )

  • 트랜잭션의 핵심 기능은 오류가 생기면 abort되고 안전하게 재시도하는 것이다.
  • 어보트된 트랜잭션을 재시도하는 것은 간단하지만 완벽하지는 않다.
  • 하지만 이 철학을 따르지 않는 리더없는복제(6장)을 사용하는 데이터스토어도 있다 ( DynamoDB )
    • 오류 복구는 애플리케이션에게 책임이 있음

완화된 격리 수준

  • 직렬성 격리는 동시성 문제를 해결해주지만 현실에서는 이로 인해 희생되는 성능 비용으로 인해 완화된 격리수준을 사용한다
  • 맹목적으로 도구에 의존하기보다는 동시성 문제의 종류를 잘 이해하고 방지하는 방법을 배워야 한다

1. 커밋 후 읽기 ( Read Commited )

가장 기본적인 트랜잭션 격리는 커밋후읽기이다. 이는 아래 두가지를 보장해준다

  • No Dirty Reads : 커밋된 데이터만을 보도록 한다
  • No Dirty Writes : 커밋된 데이터만 엎어칠수 있도록 해준다

커밋 후 읽기 구현

  • 로우 수준 잠금 ( row-level locks )
    • 특정 객체(로우,문서)를 변경하고 싶은 경우 해당 객체에 대한 잠금 획득
    • 하지만, 읽기만 실행하는 여러 트랜잭션들이 오랫동안 실행되는 쓰기 트랜잭션 하나가 완료될 때까지 기다려야 하면 성능적인 이슈가 발생한다
  • 과거값, 실행중인 값 기억하기

반복 읽기와 스냅숏 격리( Repeatable Read And Snapshot Isolation )

 

1->2 계좌로 전송을 하는 도중에 1,2계좌를 조회하면 총합 1000이 아닌 900이 보이는 경우가 발생한다 ( Read Skew, Non repeatable read )

  • 해결 방법
    • 다중 버전 동시성 제어 ( Multi-version Concurrency control, MVVC )
      • 커밋 후 읽기 격리만 제공할 필요가 있다면 객체마다 두가지 버전씩만 유지해도 충분
      • 나중에는 어떤 트랜잭션도 삭제된 데이터(deleted by=13)에 접근하지 않는다면 DB의 가비지 컬렉션 프로세스가 지워졌다고 표신된 로우들을 삭제해서 사용량을 줄임

 

갱신 손실 방지 ( Preventing Lost Updates )

이때 까지 Read Commited, Non-Repeatable Read 는 쓰기와 읽기가 동시에 진행될 때 읽기 트랜잭션이 무엇을 볼 수 있는지에 대한 보장과 관련된 것이였다.

이번엔 동시에 쓰기가 이루어졌을 때 생기는 문제에 대해 알아보자.

  • read-modify-write 주기에서 발생
  • 예제
    • 동시에 카운트 증가
    • 계좌 잔고 갱신
    • 복잡한 JSON 문서와 같은 값을 지역적으로 변경

원자적 쓰기 연산

exclusive 잠금을 획득해서 구현한다.

  • 관계형 DB : UPDATE counters SET value = value +1 WHERE key = 'foo' ;
  • 몽고DB : JSON 문서 일부 지역적으로 변경해도 원자성 보장
  • 레디스 : 우선순위큐 같은 데이터 구조를 변경하는 원자적 연산 제공

갱신 손실 자동 감지

병렬 실행을 허용하고 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 abort 시킴

 

쓰기 스큐와 팬텀 ( Write Skew and Phantoms )

  • 예시 : 의사들이 교대로 비상 대기조를 관리하는 애플리케이션
    • Alice, Bob이 동시에 호출대기 빠지기 버튼을 클릭
    • 최소 1명은 대기해야 하는데 둘다 현재 인원 2명인 상태에서 빠지기 버튼을 누름
    • 최종적으로 0명이 대기하는 상황 발생

  • 해결 방법
    • 직렬성 격리 수준을 사용할 수 없다면 트랜잭션이 의존하는 로우를 명시적으로 잠그는 것이 차선책.
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;
  • 추가적인 쓰기 스큐의 예
    • 사용자명 획득 : A,B 사용자가 동시에 C라는 닉네임으로 변경한 경우
    • double-spending 방지 : 가지고 있는 돈보다 더 많이 지불하게 되는 경우

Phantom

  • 위의 의사 어플리케이션 예시처럼 어떤 쓰기 트랜잭션이 다른 트랜잭션의 검색 질의 결과를 바꾸는 효과를 Phantom 이라고 한다.
  • 사용자명 획득 예시처럼 잠금(SELECT FOR UPDATE)할 로우가 없다면?
    • 충돌 구체화 : 인위적으로 DB에 잠금 객체를 추가 ( 충돌 구체화 - materializing conflict )
    • 하지만 이 방법은 알아내기도 어렵고 동시성 제어 매커니즘이 애플리케이션 모델로 나오는거 좋지 않음