개인적으로 공부한 내용을 기반으로 작성하였습니다.
잘못된 내용을 반견하신 경우, 댓글로 알려주시면 감사하겠습니다.
이전 글의 트랜잭션 ACID 속성으로부터 파생된 내용입니다.
트랜잭션 격리성
트랜잭션은 종종 동시에 실행됩니다. 여러 트랜잭션이 동시에 수행되더라도 각각의 트랜잭션은 다른 트랜잭션의 수행에 영향을 받지 않고 격리되어 수행되어야 합니다. 즉, 한 트랜잭션의 작업이 다른 트랜잭션에 영향을 주지 않고 서로 격리되어 실행되어야 한다는 의미입니다. 이러한 Isolation 성질을 보장하지 않으면 데이터의 일관성과 무결성이 깨질 수 있습니다.
예를 들어, 각각 50원씩 들어있는 계좌 X, Y가 있을 때, X에서 Y로 30원을 입금하는 트랜잭션 1과 Y에서 X로 30원을 입금하는 트랜잭션 2가 격리성을 준수하지 않으면 다음과 같은 문제가 발생할 수 있습니다. 문제가 발생하는 Schedule은 아래와 같습니다. (Schedule은 트랜잭션들의 실행 순서를 뜻합니다.)
시간 | 트랜잭션 1 (X에서 Y로 입금) | 트랜잭션 2 (Y에서 X로 입금) |
1 | Start Transaction | |
2 | read(X) => 50 | Start Transaction |
3 | read(Y) => 50 | read(X) => 50 |
4 | write(X = 20) (50 - 30 = 20 이므로) | read(Y) => 50 |
5 | weite(Y = 80) (50 + 30 = 80 이므로) | write(Y = 20) (50 - 30 = 20 이므로) |
6 | Commit | weite(X = 80) (50 + 30 = 80 이므로) |
7 | Commit |
만약 X에서 Y로 30원을 입금하는 동작과 Y에서 X로 30원을 입금하는 동작이 정상적으로 완료되었다면, 당연히 X와 Y의 잔액은 동작하기 전과 동일하게 각각 50원으로 유지되어야 합니다. 그러나 위의 예시에서는 X 계좌에 80원이, Y 계좌에 20원이 남아있게 됩니다. 이처럼 트랜잭션의 격리성을 준수하지 않는다면 데이터의 일관성과 무결성이 깨질 수 있습니다.
트랜잭션이 격리성을 준수하지 않으면 저러한 문제가 발생하니 격리성을 준수하도록 만들어야겠습니다. 그런데 어떻게 해야 격리성을 준수하도록 만들 수 있는 걸까요? 격리성을 준수하기 위해서는 어떻게 해야 하는 걸까요?
순차적으로 실행하면 됩니다. 트랜잭션 1이 끝날 때(Terminated state)까지 기다린 뒤 트랜잭션 2를 수행하면 됩니다. 혹은 그 반대로 해도 되고요. 어쨌든 하나의 트랜잭션이 끝날 때까지 기다린 뒤 그다음 트랜잭션을 수행하면 격리성을 준수할 수 있습니다.
시간 | 트랜잭션 1 (X에서 Y로 입금) | 트랜잭션 2 (Y에서 X로 입금) |
1 | Start Transaction | |
2 | read(X) => 50 | |
3 | read(Y) => 50 | |
4 | write(X = 20) | |
5 | weite(Y = 80) | |
6 | Commit | |
7 | Start Transaction | |
8 | read(X) => 20 | |
9 | read(Y) => 80 | |
10 | write(Y = 50) | |
11 | write(Y = 50) | |
12 | Commit |
문제가 해결되었습니다. 트랜잭션이 다른 트랜잭션의 수행에 영향을 받지 않고 격리되어 수행되므로, 트랜잭션 1과 트랜잭션 2를 수백 수천수만 번 실행해도 더 이상 이상한 결과를 만들지 않을 것입니다. 모든 트랜잭션을 위와 같이 순차적으로 실행하도록 만들면 아무 문제가 없을까요?
모든 트랜잭션을 순차적으로 실행하는 방법의 단점
안타깝지만, 모든 트랜잭션을 순차적으로 실행하는 것은 좋지 않습니다. 그것은 아주 큰 문제를 갖고 있습니다. 바로 속도입니다. 위 트랜잭션 1과 트랜잭션 2의 예시에서 순차적으로 실행하지 않았을 때는 7초 만에 모든 작업이 완료되었지만 순차적으로 실행했을 때는 12초가 걸렸습니다. 전체 실행 시간이 거의 두 배가 된 것입니다.
이처럼 격리성을 엄격하게 준수하면 DB의 전체 처리량이 하락합니다. DB 엔진은 이 문제를 해결하기 위해 다양한 격리 수준을 제공합니다. 사용자에게 필요에 따라 적절한 격리 수준을 선택할 수 있도록 하여, 가능한 전체 처리량이 감소하지 않으면서도 격리성을 유지하도록 하는 것입니다.
다만, 격리 수준을 다양하게 제공한다는 말은 매우 엄격한 수준의 격리를 제공하기도 하고 그것보다는 조금 관대한 수준의 격리를 제공하기도 한다는 말입니다. 다시 말해, DB 엔진이 제공하는 몇몇 격리 수준은 격리성을 일부 타협합니다. 따라서 격리 수준에 따라 몇 가지 이상 현상이 발생할 수 있습니다. 사용자는 이러한 trade-off(엄격한 격리 수준은 아무런 이상 현상이 발생하지 않지만, 처리량이 떨어지고, 관대한 격리 수준은 처리량이 높지만 이상 현상이 발생할 수 있음)를 잘 이해한 뒤 적절한 격리 수준을 선택해야 합니다.
이상 현상과 격리 수준
격리 수준은 이상 현상을 기준으로 만들어진 것이기 때문에 이상 현상을 먼저 알아보겠습니다.
이상 현상
데이터베이스에서 발생하는 이상 현상에는 다음과 같은 것들이 있습니다.
Dirty read
아직 커밋되지 않은 트랜잭션에 의해 수정되거나 삭제된 데이터를 다른 트랜잭션이 읽는 현상입니다. 해당 트랜잭션이 롤백 되면, 다른 트랜잭션에서 읽은 데이터는 올바르지 않게 되는 문제가 발생할 수 있습니다.
예를 들어 아래와 같이 트랜잭션 A가 입금을 하려다 다시 취소했을 때 트랜잭션 B가 A의 변경 내용을 본다면 존재하지 않은 데이터를 읽는 경우가 생길 수 있는데, 이를 Dirty read라 합니다.
시간 | 트랜잭션 1 | 트랜잭션 2 |
1 | Start Transaction | |
2 | read(x) => 50 | Start Transaction |
3 | wirte(x = 0) | read(x) => 0 ?? (트랜잭션이 변경한 0이란 데이터는 abort 될 것 이므로 사실상 존재하는 값이 아닙니다.) |
4 | abort | commit |
Non-repeatable read
한 트랜잭션 내에서 같은 데이터의 값을 여러 번 조회할 때 그 값이 다르게 나오는 현상을 non-repeatable 현상이라고 합니다.
id가 1인 row를 두 번 읽는 트랜잭션 1과 id가 1인 row의 nickname을 변경하는 트랜잭션 2가 있다고 가정했을 때 때에 따라 아래와 같은 Non-repeatable read가 발생할 수 있습니다. 트랜잭션 1의 경우 동일한 작업을 했는데 다른 조회 값이 나오게 됩니다.
시간 | 트랜잭션 1 | 트랜잭션 2 |
1 | Start Transaction | |
2 | read(x) => (id = 1, nickname = "hs") | Start Transaction |
3 | write(x = (id = 1, nickname = "ww")) | |
4 | read(x) => (id = 1, nickname = "ww") | commit |
commit |
Phantom read
한 트랜잭션 내에서 같은 데이터를 두 번 읽었는데, 없던 데이터가 생기는 경우를 phantom read라고 합니다.
id가 2 이하인 row를 두 번 읽는 트랜잭션 1과 id가 2인 row를 추가하는 트랜잭션 2가 있다고 가정했을 때 때에 따라 아래와 같은 Phantom read가 발생할 수 있습니다. 트랜잭션 1의 경우 동일한 작업을 했는데 조회되는 row의 양이 달라집니다.
시간 | 트랜잭션 1 | 트랜잭션 2 |
1 | Start Transaction | |
2 | read(x) => (id = 1, nickname = "hs") | Start Transaction |
3 | write(x = (id = 2, nickname = "ww")) | |
4 | read(x) => (id = 1, nickname = "hs", id = 2, nickname = "ww") | commit |
commit |
격리 수준
ANSI/ISO SQL 표준에 의해 정의된 격리 수준은 네 가지, serializable(직렬화 가능), Repeatable Read(반복 가능한 읽기), Read Committed(커밋된 읽기), Read Uncommitted(커밋되지 않은 읽기)로 분류됩니다.
Isolation level | Dirty read | Non-repeatable read | Phantom read |
Serializable | X | X | X |
Repeatable read | X | X | O |
Read committed | X | O | O |
Read uncommitted | O | O | O |
Serializable
가장 엄격한 격리 수준으로, 모든 트랜잭션을 순차적으로 실행한 것과 동일한 결과를 보장합니다. dirty read, non-repetable read, phantom read가 발생하지 않습니다. 일관성과 격리성은 최고 수준으로 보장되지만, 동시 처리량은 감소할 수 있습니다.
Repeatable read
한 트랜잭션이 읽은 데이터는 해당 트랜잭션이 종료될 때까지 변경되지 않음을 보장합니다. 해당 격리 수준의 경우 dirty read와 mon-repeatable read가 발생하지 않지만, phantom read가 발생할 수 있습니다.
Read committed
커밋된 데이터만 읽을 수 있습니다. 해당 격리 수준의 경우 Dirty read가 발생하지 않지만, non-repeatable read, phantom read가 발생할 수 있습니다.
Read uncommitted
아직 커밋되지 않은 다른 트랜잭션의 변경 사항도 읽을 수 있습니다. 해당 격리 수준의 경우 dirty read, Non-repeatable read, phantom read가 발생할 수 있습니다.
동시성 제어(Concurrency Control)
동시성 제어란 동시에 실행되는 여러 개의 트랜잭션의 격리 수준에 따라 스케줄을 적절히 수정하여 데이터 일관성을 유지하는 트랜잭션의 실행을 제어하는 기법입니다.
동시성 제어 분류
동시성 제어는 아래와 같이 분류할 수 있습니다.
낙관적(Optimistic)
읽기/쓰기 작업을 차단하지 않고 트랜잭션이 종료될 때까지 격리 및 기타 무결성 규칙을 충족하는지 확인하는 것을 지연시킵니다. 커밋 시 원하는 규칙이 위반되는 경우(위 격리 레벨에 따른 제약을 위반하는 경우) 트랜잭션은 중단(abort)됩니다. 중단된 트랜잭션은 즉시 다시 시작되고 다시 실행되므로 끝까지 한 번만 실행하는 것과 비교하여 명백한 오버헤드가 발생합니다.
잠금을 관리하는 데 드는 비용과 다른 트랜잭션의 잠금이 해제될 때까지 기다리지 않고 트랜잭션을 완료할 수 있으므로 다른 동시성 제어 방법보다 높은 처리량을 얻을 수 있습니다. 그러나 데이터 리소스에 대한 경합이 빈번한 경우 트랜잭션을 반복적으로 다시 시작하는 비용으로 인해 성능이 크게 저하되므로 이러한 특성을 고려해야 합니다.
비관적
규칙을 위반할 가능성이 있는 경우 위반 가능성이 사라질 때까지 트랜잭션 작업을 차단합니다. 차단 작업은 일반적으로 성능 저하와 관련이 있습니다.
반낙관적
일부 부분에 있어서는 차단을 진행하는 비관적 전략을 채택하고 일부 부분에 있어서는 낙관적 전략을 채택합니다.
동시성 제어를 달성하는 여러 가지 방법들
동시성 제어를 달성하는 여러 가지 방법이 있습니다. 방법 대부분은 위에서 언급한 분류 안에서 구현될 수 있습니다. 이러한 방법들은 여러 변형이 있으며, 때에 따라 서로 다른 방법들을 적절히 섞어서 사용되기도 합니다. 아래는 많이 사용되는 동시성 제어 방법입니다.
잠금(Locking)
데이터에 할당된 잠금으로 데이터에 대한 액세스를 제어합니다. 다른 트랜잭션에 의해 잠긴 데이터 항목에 대한 트랜잭션의 액세스는 잠금이 해제될 때까지 차단될 수 있습니다.
트랜잭션은 특정 데이터에 Read/Write 작업을 수행하기 위해 공유 잠금 혹은 독점 잠금을 취득해야 합니다. 이때 이미 해당 데이터에 대해 Lock이 걸려 있다면 Lock 취득에 실패할 수도 있습니다. Lock 취득에 실패하면 해당 데이터를 작업하고 있는 트랜잭션이 Lock을 반환할 때까지 대기합니다.
독점 잠금(Exclusive Lock)
독점 잠금(Exclusive Lock)은 데이터에 변경을 가하는 명령들에게 주어지는 락으로 Write Lock으로 불리며 X로 표기합니다. 베타 락은 다른 트랜잭션의 해당 자원에 대한 조회/수정을 모두 막습니다. 이는 공유 락이 해당 데이터에 대한 다른 트랜잭션의 조회를 허용하는 것과 상반됩니다. 베타 락은 critical section에 대한 mutex와 비슷합니다.
공유 잠금(Shared Lock)
공유 잠금(Shared Lock)이란 데이터를 변경하지 않는 읽기 명령에 대해 주어지는 잠금으로 Read Lock이라고도 불리며 보통 Shared의 앞 글자를 따서 S로 표기합니다. 여러 사용자가 동시에 데이터를 읽어도 데이터 일관성에는 아무런 영향을 주지 않기 때문에 공유 잠금끼리는 동시에 접근이 가능합니다. 하지만 공유 잠금이 설정된 데이터에 독점 잠금을 얻을 순 없습니다. 독점 잠금을 얻기 위해서는 공유 잠금이 해제되어야 합니다.
독점 잠금과 공유 잠긍의 관계는 아래 표로 나타낼 수 있습니다.
잠금 단위(Lock Granularity)
한 트랜잭션에서 데이터베이스 개체에 대한 잠금을 보유하는 경우 다른 응용 트랜잭션이 해당 개체에 액세스하지 못할 수 있습니다. 잠겨 있어서 액세스할 수 없는 데이터의 양을 최소화하기 위해 잠금 단위가 존재합니다. 이러한 잠금 단위는 작게는 레코드의 필드 값, 하나의 레코드, 물리적 입출력 단위가 되는 디스크 블록이 될 수도 있으며, 크게는 테이블이나 데이터베이스까지 하나의 잠금 단위가 될 수 있습니다.
세부적으로 잠글수록 동시성은 향상되지만, 구현이 복잡해지고 잠금 오버헤드가 커지는 단점이 있습니다.
잠금의 한계
잠금은 두 가지 한계가 있습니다. 하나는 직렬 가능한 스케줄을 보장하지는 않는다는 것이고 하나는 교착 상태가 발생할 수 있다는 것입니다. 직렬 가능한 스케줄(serializable schedule)이란 트랜잭션을 순차적으로 실행(serial)한 것과 동일한 결과를 만드는 스케줄을 뜻합니다.
직렬 가능한 스케줄을 보장하기 위해서는 2단계 잠금 규약(tow-phase locking, 2PL)을 사용해야 합니다. 2PL 프로토콜에 따라 잠금은 두 단계로 적용 및 제거됩니다.
1. 확장 단계 - 잠금을 획득할 수만 있고 해제할 수는 없습니다.
2. 축소 단계 - 잠금을 해제할 수만 있고 획득할 수는 없습니다.
위 규칙을 요약하자면 잠금이 해제된 후에는 잠금을 획득하지 말라는 것입니다. 이 규칙을 준수하는 스케줄은 serializable schedule입니다. 모든 serializable schedule이 2PL을 준수하는 것은 아니지만 2PL을 준수하는 스케줄은 반드시 serializable schedule입니다.
하지만 2PL은 캐스케이드 스케줄을 방지하지 못합니다. 이를 방지하기 위해 S2PL(Strict two-phase locking)을 사용합니다. S2PL은 2PL의 부분집합으로 축소 단계에서 공유 잠금(읽기 잠금)을 해제하고 모든 트랜잭션이 끝난 후에 독점 잠금(쓰기 잠금)을 해제하는 방법입니다. 혹은 SS2PL(String strict two-phase locking)을 사용해도 됩니다. SS2PL은 공유 잠금 독점 잠금을 모두 트랜잭션이 끝난 후에 해제하는 방법입니다. S2PL과 SS2PL은 모두 2PL의 부분집합입니다.
타임스탬프 오더링(Timestamp ordering, TO)
타임스탬프 오더링은 트랜잭션의 실행 순서를 타임스탬프를 기반으로 정하는 방식입니다. 각 트랜잭션에는 고유한 타임스탬프가 할당되고, 이 타임스탬프를 기준으로 트랜잭션의 실행 순서를 결정합니다.
serializable schedule을 보장하고, 교착 상태를 방지할 수 있으나 연쇄 복귀(Cascading Rollback)를 방지하지 못합니다.
다중 버전 동시성 제어(Multiversion concurrency control, MVCC)
Lock을 사용할 때 read-wirte와 write-read를 허용하지 않았던 것과 달리 MVCC는 Write-Write를 제외한 잠금의 호환을 모두 허용합니다. 이를 통해 동시성을 향상할 수 있습니다.
MVCC는 트랜잭션이 데이터를 읽을 때 해당 시점의 데이터 버전을 보여주는 방식을 사용합니다. 각 트랜잭션은 일정 시간대에 유효한 데이터 버전을 보게 됩니다.(UNDO 로그를 참고합니다.) 이를 통해 트랜잭션들이 서로의 작업에 영향을 주지 않으면서 동시에 데이터에 접근할 수 있습니다.
아래 영상을 보시면 쉽게 이해할 수 있습니다.
https://www.youtube.com/watch?v=wiVvVanI3p4
https://sewonzzang.tistory.com/76
https://velog.io/@cataiden/Isolation-Level-Lock-and-MVCC-in-RDBMS
https://en.wikipedia.org/wiki/Concurrency_control
https://velog.io/@ha0kim/%EC%9E%A0%EA%B8%88Locking-%EA%B8%B0%EB%B2%95
https://en.wikipedia.org/wiki/Two-phase_locking
'프로그래밍 기초' 카테고리의 다른 글
CORS란 (Origin, SOP, CORS, CSP) (0) | 2023.07.19 |
---|---|
실시간 통신 - WebSocket이란 (0) | 2023.07.18 |
데이터베이스 트랜잭션 (0) | 2023.06.21 |
마이크로서비스 아키텍처(MSA, Microservice Architecture) (0) | 2023.06.19 |
콘텐츠 보안 정책(Content Security Policy, CSP) (0) | 2023.06.03 |