개발일기장

Part2. 07장 트랜잭션 본문

책 정리/데이터 중심 애플리케이션 설계

Part2. 07장 트랜잭션

게슬 2021. 10. 5. 12:05
728x90

데이터 시스템은 여러 가지 문제가 생길 수 있다.

1. SW, HW는 언제라도 실패할 수 있다.

2. application은 언제라도 죽을 수 있다.

3. 네트워크가 끊기면 애플리케이션과 데이터베이스의 연결이 갑자기 끊기거나 데이터베이스 노드 사이의 통신이 안 될 수 있다.

등등.

 

시스템이 신뢰성을 지니려면 이런 결함을 처리해서 전체 시스템의 치명적인 장애로 이어지는 것을 막아야 한다. 내결함성을 갖춘 시스템을 구현하려면 할 일이 많다. 잘못될 수 있는 모든 것에 대해 신중하게 생각해야 하며 테스트를 여러 번 해서 해결책이 실제로 동작하는지 확인해야 한다.

트랜잭션은 이런 문제를 단순화하는 메커니즘, 몇 개의 읽기와 쓰기를 하나의 논리적 단위로 묶는 방법이다. 전체가 성공(commit)하거나 실패(abort, rollback)한다. 트랜잭션을 쓰면 애플리케이션에서 오류 처리를 하기가 훨씬 단순해진다. 부분적인 실패를 걱정할 필요가 없기 때문이다. 데이터베이스에서 대신 이런 일을 도맡아 주기 때문이다.

 

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

대부분의 관계형 데이터베이스와 일부 비관계형 데이터베이스는 IBM 시스템 R에서 소개된 스타일을 따른다. 다른 모든 기술적 설계 선택과 마찬가지로 트랜잭션은 이점과 한계가 있다. 이 트레이드오프를 이해하기 위해 정상적인 운영 상황과 다양한 극단적인 환경에서 트랜잭션이 제공하는 보장의 세부 사항을 알아야 한다.

 

ACID의 의미

트랜잭션이 제공하는 안전성 보장은 흔히 원자성, 일관성, 격리성, 지속성을 의미하는 약어인 ACID로 알려져 있다. 현실에서는 데이터베이스마다 구현이 제각각 이다. 격리성의 의미 주변에는 모호함이 많이 있다.

이 표준을 따르지 않는 시스템은 때로 BASE라고 불린다 기본적으로 가용성을 제공하고(Basically Available), 유연한 상태를 가지며(soft state), 최종적 일관성(Eventual consistency)를 지닌다는 뜻이다.

원자성

더 작은 부분으로 쪼갤 수 없는 무언가를 가리킨다. 클라이언트의 작업 중 일부만 처리된 후 결함이 생기면 무슨 일이 생기는지 설명한다. 여러 쓰기 작업이 하나의 원자적인 트랜잭션으로 묶여 있는데 결함 때문에 완료될 수 없다면 어보트 되고 데이터베이스는 이 트랜잭션에서 지금까지 실행한 쓰기를 무시하거나 취소해야한다.

일관성

항상 진실이어야 하는(모순이 없는), 데이터에 관한 어떤 선언이 있다는 것이다. 애플리케이션의 불변식 개념에 의존하고, 일관성을 유지하도록 트랜잭션을 올바르게 정의하는 것은 애플리케이션의 책임이다.

격리성

동시에 여러 클라이언트에서 데이터베이스에 접속한다. 클라이언트들이 동일한 데이터베이스 레코드에 접근하면 동시성 문제(경쟁 조건)에 맞닥뜨리게 된다. 격리성은 동시에 실행되는 트랜잭션은 서로 격리된다는 것을 의미한다. 트랜잭션은 다른 트랜잭션을 방해할 수 없다. 직렬성이라는 용어로 공식화한다. 그러나 직렬성 격리는 성능 손해를 동반하므로 현실에서는 거의 사용되지 않는다. 오라클에는 직렬성보다 보장이 약한 스냅숏 격리를 구현한다.

지속성

데이터베이스 시스템의 목적은 데이터를 잃어버릴 염려가 없는 안전한 저장소를 제공하는 것이다. 보통 디스크에 저장된 데이터 구조가 오염됐을 때 복구할 수 있게 해주는, 쓰기 전 로그나 비슷한 수단을 동반한다. 복지속성을 보장하려면 데이터베이스는 트랜잭션이 성공적으로 커밋됐다고 보고하기 전에 쓰기나 복제가 완료될 때까지 기다려야 한다. 완벽한 지속성은 존재하지 않는다. 디스크에 쓰기, 원격 장비에 복제하기, 백업 등을 포함해 위험을 줄이려는 기법이 여러가지 있을 뿐이다.

 

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

한 번에 여러 객체를 변경할 수 있다고 가정한다. 다중 객체 트랜잭션은 흔히 데이터의 여러 조각이 동기화된 상태로 유지돼야 할 때 필요하다. 다중 객체 트랜잭션은 어떤 읽기 연산과 쓰기 연산이 동일한 트랜잭션에 속하는지 알아낼 수단이 있어야 한다. TCP연결의 경우 begin transaction문과 commit문 사이의 모든 것은 같은 트랜잭션에 속하는 것으로 여겨진다.

단일 객체 쓰기

원자성은 장애 복구용 로그를 써서 구현할 수 있고, 격리성은 각 객체에 잠금을 사용해 구현할 수 있다.

Read-modify-write, compare-and-set같은 연산은 다중 객체 연산.

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

많은 분산 데이터스토어는 다중 객체 트랜잭션 지원을 포기했다. 여러 파티션에 걸쳐서 구현하기가 어렵고 매우 높은 가용성과 성능이 필요한 곳에서는 방해가 되는 시나리오도 있기 때문이다.

오류와 어보트(abort)처리

트랜잭션의 핵심 기능은 오류가 생기면 abort되고 안전하게 재시도할 수 있다는 것이다. ACID 위반할 위험이 있으면 트랜잭션이 절반 정도 완료된 상태에 머물게 하는 대신 트랜잭션을 완전히 폐기한다. 그러나 리더 없는 복제를 사용하는 데이터스토어는 best effort원칙을 기반으로 훨씬 더 많은 일을 한다. 따라서 오류 복구는 애플리케이션에게 책임이 있다. 오류는 필연적으로 발생하지만 많은 개발자들은 오류 처리의 복잡한 내용은 신경 쓰지 않고 낙관적인 상황만 생각하려고 한다.

Abort된 트랜잭션을 재시도 하는 것은 간단하고 효과적인 오류 처리 메커니즘이지만 완벽하지는 않다.

1. 트랜잭션은 성공했지만 서버가 클라이언트에게 커밋 성공을 알리지 못했을 경우 -> 애플리케이션에 중복 제거 메커니즘이 있어야 한다.

2. 오류가 과부하 때문이라면 트랜잭션 재시도는 문제를 악화시킬 수 있다. -> 재시도 횟수를 제한, 과부화와 관련된 오류를 다른 오류와 별도로 처리하는 방법

3. 일시적인 오류는 아무 소용이 없다.

4. 트랜잭션이 DB 외부에도 부수 효과가 있다면 2단계 커밋이 도움될 수 있다.

5. 클라이언트 프로세스가 재시도 중에 죽어버리면 DB에 쓰려고 했던 데이터가 모두 손실된다.

 

완화된 격리 수준

두 트랜잭션이 동일한 데이터에 접근하지 않으면 서로 의존하지 않으므로 안전하게 병렬 실행될 수 있다. 동시성 문제는 서로 다른 트랜잭션이 동시에 변경한 데이터를 읽거나 두 트랜잭션이 동시에 같은 데이터를 변경하려고 할 때만 나타난다. 타이밍에 운이 없을 때만 촉발되기 때문에 테스트로 발견하기 어렵다. 추론하기도 매우 어렵다.

데이터베이스는 오랫동안 트랜잭션 격리를 제공함으로써 애플리케이션 개발자들에게 동시성 문제를 감춘다. 직렬성 격리는 DB가 여러 트랜잭션들이 직렬적으로 실행되는 것과 동일한 결과가 나오도록 보장한다는 것을 의미한다.

직렬성 격리는 성능 비용이 있고 많은 데이터베이스들은 그 비용을 원하지 않는다. 그래서 완화된 격리 수준을 사용하는 시스템들이 흔하다. 맹목적으로 도구에 의존하기 보다는 존재하는 동시성 문제의 종류를 잘 이해하고 방지하는 방법이 필요하다.

 

커밋 후 읽기(read committed)

1. 데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다(dirty read가 없다).

2. 데이터베이스에 쓸 때 커밋된 데이터만 덮어쓰게 된다(dirty write가 없다).

여러 데이터베이스에서는 기본 설정이다. 로우 수준 잠금을 사용해 dirty 쓰기를 방지한다. 트랜잭션에서 특정 객체를 변경하고 싶다면 먼저 해당 객체에 대한 lock을 획득해야 한다. 작업이 끝나면 lock을 반납한다.

dirty 읽기의 경우 쓰여진 모든 객체에 대해 데이터베이스는 과거에 커밋된 값과 현재 쓰기 잠금을 갖고 있는 트랜잭션에서 쓴 새로운 값을 모두 기억한다. 해당 트랙잭션이 실행 중인 동안 그 객체를 읽는 다른 트랜잭션들은 과거의 값을 읽게 된다.

 

스냅숏 격리와 반복 읽기

각 트랙잭션은 데이터베이스의 일관된 스냅숏으로부터 읽는다. 즉 트랜잭션은 시작할 때 데이터베이스에 커밋된 상태였던 모든 데이터를 본다. 나중에 다른 트랜잭션에 의해 바뀌더라도 각 트랜잭션은 특정한 시점의 과거 데이터를 볼 뿐이다. 시간이 오래 걸리고 읽기만 하는 질의에 요긴하다. 질의가 실행 중일 때 동시에 대상 데이터가 변경된다면 그 질의의 의미에 대해 추론하기가 매우 어렵기 때문이다.

읽는 쪽에서 쓰는 쪽을 결코 차단하지 않고 쓰는 쪽에서 읽는 쪽을 결코 차단하지 않는다는 것이 핵심이다. 따라서 데이터베이스는 잠금 경쟁 없이 쓰기 작업이 일상적으로 처리되는 것과 동시에 일관성 있는 스냅숏에 대해 오래 실행되는 읽기 작업을 처리할 수 있다.

데이터베이스는 객체마다 커밋된 버전 여러 개를 유지할 수 있어야 한다. 이 기법은 다중 버전 동시성 제어(multi-version concurrency control, MVCC)라고 한다.

읽기 격리만 제공할 필요가 있다면 객체마다 버전 두 개씩만 유지하면 충분하다.

트랜잭션이 시작하면 계속 증가하는 고유한 트랜잭션 ID를 할당 받는다. 트랜잭션이 데이터베이스에 데이터를 쓸 때마다 쓰기를 실행한 트랜잭션의 ID가 함께 붙는다.

 

갱신 손실 방지

원자적 쓰기 연산 read-modify-write를 구현할 필요를 없애 준다. 동시성 한전하다.

Ex) update counter set value = value+1 where key = ‘foo’; 원자적 연산은 보통 객체를 읽을 때 그 객체에 독점적인(exclusive)잠금을 획득해서 구현한다. 갱신이 적용될 때 까지 다른 트렌젝션에서 그 객체를 읽지 못하게 한다. 커서 안정성이라고 부른다.

명시적인 잠금

Ex) select for update구문

Compare-and-set

값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용함으로써 갱신손실을 회피하는 것이다.

Ex update wiki_pages set content = ‘new content’ where id = 1234 and content = ‘old content’;

내용이 바뀌어서 더는 ‘old content’와 일치하지 않으면 이 갱신은 적용되지 않는다.

충돌 해소와 복제

복제가 적용된 데이터베이스에서 흔히 쓰는 방법은 쓰기가 동시에 실행될 때 한 값에 대해 여러 개의 충돌된 버전(형제 sibling)을 생성하고 사후에 애플리케이션 코드나 특별한 데이터 구조를 사용해 충돌을 해소하고 이 버전들을 병합한다. 반면 최종 쓰기 승리(last write win LWW) 충돌 해소방법은 갱신 손실이 발생하기 쉽다.

 

쓰기 스큐와 팬텀

데이터 오염을 피하려면 경쟁 조건을 방지해야 한다. 데이터베이스에서 자동으로 해주든지 잠금이나 원자적 쓰기 연산 같은 수동 안전 장치를 사용해야 한다.

쓰기 스큐를 특징짓기

두 트랜잭션이 두 개의 다른 객체를 갱신하므로 dirty 쓰기도 갱신 손실도 아니다. 문제가 생길 것 같으면 for update를 사용해서 반환하는 모든 로우를 잠그자.

예시) 회의실 예약 시스템, 다중 플레이어게임, 사용자명 획득, 이중 사용 방지 등등

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

 

직렬성

격리수준은 이해하기 어렵고 데이터베이스마다 그 구현에 일관성이 없다.

애플리케이션 코드를 보고 특정한 격리 수준에서 해당 코드를 실행하는 게 한전한지 알기 어렵다.

경쟁 조건을 감지하는 데 도움이 되는 좋은 도구가 없다.

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

실제적인 직렬 실행

한 번에 트랜잭션 하나씩만 직렬로 단일 스레드에서 실행하면 된다. 잠금을 코디네이션하는 오버헤드를 피할 수 있기 때문에 동시성을 지원하는 시스템보다 성능이 나을 때도 있다.

 

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

불행하게도 사람은 결정하는 것도 반응하는 것도 매우 느리다. 데이터베이스 트랜잭션이 사용자의 입력을 기다려야 한다면 데이터베이스는 대부분 유휴 상태지만 잠재적으로 매우 많은 동시 실행 트랜잭션을 지원해야 한다. 거의 모든 OLTP 애플리케이션은 트랜잭션 내에서 대화식으로 사용자 응답을 대기하는 것을 회피함으로써 트랜잭션을 짧게 유지한다. 웹의 경우 이것은 트랜잭션이 동일한 HTTP 요청 내에서 커밋된다는 뜻이다.

애플리케이션이 질의를 실행하고 그 결과를 읽고, 결과에 따라 다른 질의를 실행할 수 도 있고 이런 식이다. 애플리케이션 코드와 데이터베이스 서버 사이에서 질의와 결과를 주고받는다.

이렇게 하는 방법은 너무 많은 트랜잭션을 생성하므로 단일 스레드에서 트랜잭션을 순차적으로 처리하는 시스템들은 상호작용하는 다중 구문 트랜잭션을 허용하지 않는다. 대신 트랜잭션 코드 전체를 스토어드 프로시저 형태로 데이터베이스에 미리 제출해야 한다. 필요한 데이터는 모두 메모리에 있고 스토어드 프로시저는 네트워크나 디스크 I/O 대기 없이 매우 빨리 실행된다고 가정한다.

스토어드 프로시저 장단점

데이터가 메모리에 저장된다면 모든 트랜잭션을 단일 스레드에서 실행하는 게 현실성이 있다. I/O 대기가 필요없고 다른 동시성 제어 메커니즘의 오버헤드를 회피하므로 단일 스레드로 상당히 좋은 처리량을 얻을 수 있다.

파티셔닝

모든 트랜잭션을 순차적으로 실행하면 동시성 제어는 훨씬 간단해지지만 데이터베이스의 트랜잭션 처리량이 단일 장비에 있는 단일 CPU 코어의 속도로 제한된다. 읽기 전용 트랜잭션은 스냅숏 격리를 사용해 다른 곳에서 실행될 수 있지만 쓰기 처리량이 높은 애플리케이션에게는 단일 스레드 트랜잭션 처리자가 심각한 병목이 될 수 있다.

각 트랜잭션이 단일 파티션 내에서만 데이터를 읽고 쓰도록 데이터셋을 파티셔닝 할 수 있다면 각 파티션은 다른 파티션과 독립적으로 실행되는 자신만의 트랜잭션 처리 스레드를 가질 수 있다. 이 경우 CPI 코어 개수에 맞춰 선형적으로 확장할 수 있다.

직렬 실행 요약

1. 모든 트랜잭션은 작고 빨라야 한다.

2. 활성화된 데이터셋이 메모리에 적재될 수 있는 경우로 사용이 제한된다.

3. 쓰기 처리량이 단일 CPU 코어에서 처리할 수 있을 정도로 충분히 낮아야 한다.

4. 여러 파티션에 걸친 트랜잭션도 쓸 수 있지만 이것을 사용할 수 있는 정도에는 제한이 있다.

 

2단계 잠금(2PL)

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

잠금은 공유 모드나 독점 모드로 사용될 수 있다. 읽기를 위해서는 공유모드로 잠금, 쓰기를 원한다면 독점 모드로 잠금을 획득해야 한다. 잠금을 획득한 후에는 트랜잭션이 종료될 때까지 잠금을 갖고 있어야 한다.

트랜잭션AB의 잠금을 해제하기를 기다리고, BA의 잠금을 해제하기를 기다리는 상황을 교착 상태라 한다. 데이터베이스는 교착 상태를 자동으로 감지하고 트랜잭션 중 하나를 abort시켜서 다른 트랜잭션들이 진행할 수 있게 한다. Abort된 트랜잭션은 애플리케이션에서 재시도해야 한다.

성능이 큰 약점이다. 트랜잭션 처리량과 질의 응답 시간이 크게 나빠진다. 잠금을 획득하고 해제하는 오버헤드와 동시성이 줄어드는 문제 발생한다. 한 트랜잭션이 다른 트랜잭션이 끝나기를 기다릴 때 얼마나 오래 기다려야 하는지에 대한 제한이 없다. 2PL 직렬성 격리에서는 교착 상태가 훨씬 더 자주 발생한다.

 

서술 잠금

직렬성 격리를 쓰는 데이터베이스는 한 트랜잭션이 다른 트랜잭션의 검색 질의 결과를 바꿔버리는 문제를 막아야한다. 서술 잠금은 공유/독점 잠금과 비슷하게 동작하지만 특정 객체에 속하지 않고 어떤 검색 조건에 부합하는 모든 객체에 속한다.

색인 범위 잠금

서술 잠금은 잘 동작하지 않는다. 진행 중인 트랜잭션들이 획득한 잠금이 많으면 조건에 부합하는 잠금을 확인하는데 시간이 오래 걸린다. 2PL을 지원하는 대부분의 데이터베이스는 실제로는 색인 범위 잠금을 구현한다. 어떤 방법을 쓰든지 간략화 한 검색 조건이 색인 중 하나에 붙는다. 삽입, 갱신, 삭제하길 원한다면 색인의 같은 부분을 갱신해야 한다. 그 과정에서 공유 잠금을 반견하고 잠금이 해제될 때까지 기다리게 된다. 쓰기 팬텀과 쓰기 스큐로부터 보호해주는 효과를 낳는다. 범위 잠금을 잡을 수 있는 적합한 색인이 없다면 데이터베이스는 테이블 전체에 공유 잠금을 잡는 것으로 대체할 수 있다.

 

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

직렬성 스냅숏 격리라는 알고리즘이 유망하다. 완전한 직렬성을 제공하지만 스냅숏 격리에 비해 약간의 성능 손해만 있을 뿐이다.

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

2단계 잠금(2PL)은 비관적 동시성 제어 메커니즘이다. 다중 스레드 프로그래밍에서 자료구조 보호를 위해 사용되는 상호 배제와 비슷하다. 반대로 직렬성 스냅숏 격리(SSI)는 낙관적 동시성 제어 기법이다. 뭔가 위험한 상황이 발생할 가능성이 있을 때 트랜잭션을 막는 대신 모든 것이 괜찮아질 거라는 희망을 갖고 계속 진행한다는 뜻. 트랜잭션이 커밋 되기를 원할 때 데이터베이스는 나쁜 상황이 발생했는지 확인한다. 그렇다면 재시도.

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

SSI 성능

2단계 잠금과 비교할 때 직렬성 스냅숏 격리의 큰 이점은 트랜잭션이 다른 트랜잭션들이 잡고 있는 잠금을 기다리느라 차단될 필요가 없다는 것이다. 스냅숏 격리하 에서와 마찬가지로 쓰는 쪽은 읽는 쪽을 막지 않고 그 역도 성립한다. 이런 설계 원칙은 질의 지연 시간 예측이 쉽고 변동이 적게 만든다. 읽기 전용 질의는 어떤 잠금도 없이 일관된 스냅숏 위에서 실행될 수 있다.

단일 CPU코어의 처리량에 제한되지 않는다. 데이터가 여러 장비에 걸쳐서 파티셔닝돼 있더라도 트랜잭션은 직렬성 격리를 보장하면서 여러 파티션으로부터 읽고 쓸 수 있다.

 

정리

트랜잭션은 HW, SW결함이 존재하지 않는 것처럼 동작할 수 있게 도와주는 추상층이다. 많은 종류의 오류가 간단히 abort로 줄어들고 애플리케이션은 재시도만 하면 된다.

트랜잭션이 없으면 다양한 오류 시나리오에서 다양한 방법으로 데이터가 일관성이 깨질 수 있다.

커밋 후 읽기, 스냅숏 격리, 직렬성 격리가 있다.

 

Dirty read : 다른 클라이언트가 썼지만 아직 커밋되지 않은 데이터를 읽는다.

Dirty write : 다른 클라이언트가 썼지만 아직 커밋되지 않은 데이터를 덮어쓴다.

Read-write : 다른 시점에 데이터베이스의 다른 부분을 본다. -> 스냅숏 격리를 가장 흔히 사용한다. MVCC로 구현한다.

갱신 손실 : 두 클라이언트가 동시에 read-modify-write 주기를 실행한다. 덮어써서 데이터가 손실된다. 수동 잠금 (SELECT FOR UPDATE)가 필요하다.

쓰기 스큐 : 무언가를 읽은 값을 기반으로 결정을 내릴 때 쓰기를 실행하는 시점에서는 결정의 전제가 참이 아닐경우 직렬성 격리만 이런 이상 현상을 막을 수 있다.

팬텀 읽기 : 다른 클라이언트가 검색 결과에 영향을 주는 쓰기를 실행한다.

 

직렬성 트랜잭션을 구현하는 방법들

1. 트랜잭션을 단일 CPU코어에서 처리할 수 있을 정도로 트랜잭션이 낮으면 간단한 방법

2. 2단계 잠금 (2PL) 공유: 비공유 잠금을 가지고 작업을 할 때 잠금을 얻고 작업이 끝나면 반납한다.

3. 직렬성 스냅숏 격리(SSI) : 낙관적 방법을 사용해서 트랜잭션이 차단되지 않고 진행할 수 있게 한다. 커밋을 원할 떄 트랜잭션을 확인해서 실행이 직렬적이지 않다면 abort시킨다.

728x90
Comments