지난 글에서는 트랜잭션의 ACID 특징과 여러 트랜잭션이 병행적으로 들어왔을 때 이를 실행하는 스케줄의 직렬 가능성에 대해 정리하였다.
이번 글에서는 트랜잭션의 실행 스케줄이 직렬가능하지 않을 때, ACID 특징을 지키면서 모든 트랜잭션이 직렬 가능하게 동작하도록 하는 동시성 제어 방법들을 알아본다. (동시성 제어라고 했지만, 동시성과 병행성이 다르다는 말이 여기에서 등장한다. 병행적으로 오는 트랜잭션은 '동시에' 도착할 수도 있지만 살짝 얽혀서 얼기설기 들어올 수도 있다.)
동시성 제어에는 크게 엄격한 2PL, Tompson Read & Write, Timestamp 방법이 있다.
Strict 2 Phase Locking
먼저 직렬가능하지 않은 문제가 발생하는 원인은 같은 데이터에 여러 트랜잭션이 동시에 접근하기 때문에 발생한다.
그렇다면 먼저 접근한 트랜잭션이 이 데이터에 다른 트랜잭션이 접근하지 못하도록 lock을 걸어두는 아이디어를 떠올릴 수 있다.
(운영체제에서 임계 구역에 여러 프로세스가 들어오지 못하도록 lock을 걸어두는 것과 비슷하다.)
엄격한 2 Phase Locking (줄여서 2PL) 은 다음의 2가지 규칙을 가진다.
1. 트랜잭션이 DBMS의 객체를 읽고자 하면 공용 장금, 쓰고자 한다면 전용 잠금을 요구한다.
2. 트랜잭션이 소유한 모든 락은 트랜잭션이 완료될 때 해제된다.
먼저 공용 잠금 (shared lock) 은 read_lock 이라고도 부르며, 읽기 전용 lock 으로, 다른 트랜잭션이 전용 잠금을 얻지 않았다면 자유롭게 얻을 수 있는 락이다. (즉, 한 트랜잭션이 read_lock을 걸어둔 상태에서는 다른 트랜잭션도 자유롭게 read_lock을 얻을 수 있다. 다만 이때 write_lock은 얻을 수 없다.)
반면 전용 잠금 (exclusive lock) 은 write_lock 이라고도 부르며, 쓰기 전용 lock 으로, 이 lock을 갖고 있다면 커밋이나 롤백을 하기 전까지 다른 트랜잭션들은 read_lock 또는 write_lock 을 얻을 수 없다.
이 알고리즘은 2단계, 즉, 락을 걸고 (1) - 동작 수행 - 락을 해제(2) 하는 과정으로 동작하기 때문에 2 Phase Locking 이라고 부른다.
이때 앞에 '엄격한' 이라는 말은, 한번 락을 얻은 뒤에는 트랜잭션의 커밋/롤백이 없는 한 다른 트랜잭션이 락을 뺏을 수 없음을 의마한다.
예를 들어 위와 같은 스케줄을 살펴보자.
T2는 x의 값을 읽어 100을 더하고 다시 그 값을 x에 쓰려고 한다.
따라서 이 연산을 하기 전에 먼저 write_lock(x) 를 통해 쓰기 락을 요청한다.
아무도 x에 대해 락을 획득한 적이 없으므로, T2는 성공적으로 lock을 얻고, 읽고 쓰기 작업을 수행할 수 있다.
이때 T1이 조금 늦게 시작하여 x에 대해 쓰기 락을 요청한다.
이 시점에는 T2가 락을 획득하여 사용하고 있는 상황이므로, T2가 커밋 또는 롤백을 통해 락을 풀기 전까지 락을 얻을 수 없으므로 OS를 이용하여 스케줄링시켜 대기한다. 그리고 T2가 commit을 하고 unlock을 하면, 이때 T1이 락을 획득하여 정상적으로 원하는 동작을 수행한다.
만약 롤백을 하는 상황이라면, 위 그림에서 commit 대신 rollback을 넣고 unlock을 한 뒤에 T1에서 접근하면 된다.
또한 만약 Lock이 없다면 T1은 T2가 값을 쓰기 전에 조회하고, T1이 조회한 이후에 값을 쓰면서 phantom read가 발생할 수 있다.
지난 글에서 봤던 x y z의 합을ㅇ 구하는 예시에도 2PL을 적용해보자.
먼저 T2에서 트랜잭션을 시작했는데, T1에서 먼저 write_lock을 걸었고, 이후에 T2에서 read_lock을 시도하면, x에 대해 write_lock이 먼저 걸려있으므로 read_lock(x)는 락을 획득하지 못해 대기한다.
따라서 x, z 값이 변화한 뒤에 read_lock(x) 가 락을 획득하면서 정상적으로 값을 구하므로, 90 + 50 + 35 = 175 로 x, y, z 의 값이 올바르게 들어간 상태에서 175를 구하게 된다.
Timestamp
타임스탬프를 사용한 방법은 락을 사용하지 않고 동시성을 제어하는 방법이다.
먼저 자체 타임 시스템이나 카운터 값을 이용하여 트랜잭션이 시작하는 시간을 타임스탬프로 기록한다.
타임스탬프를 사용한 동시성제어는 만약 Tᵢ < Tⱼ 순으로 타임스탬프가 찍혔을 때, 그 내부 액션이 j < i 순으로 발생하려고 하면 동작을 거부하는 방법으로 동시성을 제어한다.
위 예시에서는 T1이 T2보다 먼저 시작했으나, T2에서 먼저 x에 대해 접근하여 값을 읽었다면 T1에서 write(x) 동작을 수행하는 것을 막는다.
만약 T1의 write(X)를 막지 않으면 T2가 읽은 x의 값이 과거의 값이 되어버리는 팬텀 리드 문제가 발생하며, 이는 트랜잭션의 ACID 중 Isoloation 성질을 위반한다.
따라서 이 경우에는 T2 트랜잭션이 끝날 때까지 기다렸다가 T2 트랜잭션이 커밋/롤백되고 나면 그 다음에 T1 트랜잭션을 취소하고 다시 시작한다. (그러면 T1은 더 큰 타임스탬프 값을 가질 것이다.)
Tompson Read & Write
(검색을 했는데 관련 정보가 안나온다. 책에는 Thomas Read & Write Rule이 나오는데, 이는 타임스탬프와 관련된 내용이었으며, 비슷한 내용은 낙관적 동시성제어에서 찾아볼 수 있었다. 기존의 2PL에 의한 동시성 제어를 가리켜 '비관적 락' 이라고 한다.)
이 방식은 동시성 제어를 위해 트랜잭션의 동작을 수행할 때 다음의 3단계로 나누어서 수행한다.
1. read - 2. 유효성 검증 - 3. write
유효성 검증은 내가 어떤 값을 쓰려고 할 때, 이 값을 쓰기 전에 이 쓰기 동작이 직렬가능한지 검증하는 과정을 거친다.
만약 직렬가능하지 않다면 (=충돌이 있다면) 쓰기를 하지 않고, 트랜잭션을 철회하고 다시 시작한다.
만약 직렬가능하다면 write를 수행한다.
(책에서는 validation에 실패하면 트랜잭션을 철회하고 다시 사작한다고 하는데, 수업중에는 validation에 실패하면 다시 validation을 수행한다고 하셨다. 그런데 수업 중에 '나중에 시작한 트랜잭션이 vaildation을 하기 이전이라면 먼저 시작한 트랜잭션이 write를 하더라도 상관없다 = read를 write하는 동안 해도 괜찮다'고 하셨는데, vaildation을 반복하는 경우, 먼저 시작한 트랜잭션이 Write를 한 값이 나중에 시작한 트랜잭션이 읽은 값이라면 팬텀 리드 문제가 발생할 것이고 validation이 계속해서 실패하므로 나중에 시작한 트랜잭션은 무한 루프를 도는 것과 같아진다. 따라서 책에 나와있는대로 트랜잭션을 철회하고 다시 시작하는 것으로 정리하였다.
=> 표현을 생략하신 것 같다. 검증에 실패하면 다시 읽고 다시 검증한다라고 구체적으로 말씀해주셨다. 사실상 트랜잭션을 다시 시작하는 것과 같다.)
유효성 검증 과정을 구체적으로 살펴보면 아래 3가지 중 하나가 만족하는지 검사하는 것과 같다.
T1 이 T2보다 먼저 시작한 트랜잭션이라고 할 때
1. T2가 시작하기 전에 T1이 read - validation - write 를 모두 마친 경우 (이 자체로 직렬가능하다)
2. T2가 쓰기 단계에 들어가기 전에 T1이 세 단계를 모두 마쳤고, T1은 T2가 읽은 데이터에 대해 쓰기를 하지 않는 경우
3. T2가 읽기 단계를 마치기 전에 T1이 읽기 단계를 마쳤고, T1이 T2가 읽거나 쓰는 객체에 쓰기를 하지 않는 경우
각각을 그림으로 그리면 아래와 같다.
이 방법은 잠금을 사용하지 않으므로, 잠금과 관련된 동작을 수행하는 오버헤드가 발생하지 않는 점에서 이점이 있다.
장애 복구
어떤 시스템을 운영하다보면 장애가 발생할 수 있다.
이때 장애 상황 이전의 상황으로 복구를 하기 위해서는 초반에 정리했던 WAL (Write Ahead Log) 프로토콜이 필요하다.
이 프로토콜은 Read, Write 와 같은 동작을 '수행하기 전에' 일단 로그를 남기고 수행하자는 규칙이다.
그러면 로그를 남기기 위해 위와 같이 구성하는 모습을 생각할 수 있다.
DBMS가 스케줄의 액션을 실행될 때마다 로그 버퍼와 데이터 버퍼를 두고 그 곳에 임시 로그와 데이터를 두었다가, 복구용 디스크에 로그와 데이터를 써둔다.
이 로그와 데이터는 충돌(크래시)이 발생했을 때, redo 또는 undo를 하는 과정에서 사용된다.
그런데 로그도 매번 발생할 때마다 디스크에 바로 쓰는 것이 부담스럽기 때문에 버퍼에 모아뒀다가 쓰는데, 이때 체크포인트라는 것을 활용한다. (장애가 발생하면 메모리 내용이 날아갈 수 있으니 버퍼에 저장된 로그는 아직 복구에 활용할 수 없다.)
위 그림의 영구 저장용 디스크는 이전 체크포인트의 내용이 아카이빙되어 저장되는 공간이다.
체크포인트는 DBMS의 특정 시점의 상태에 대한 스냅샷과 같다.
체크포인트는 구현에 따라 다음 2가지 선택지가 있으며, 이를 조합하여 4가지로 구현할 수 있다.
1. 주기적으로 체크포인트 생성 vs 트랜잭션 커밋이 발생할 때마다 체크포인트 생성
2. 체크포인트 생성 중 트랜잭션을 받지 않음 vs 트랜잭션을 받음
(다른 트랜잭션을 멈추고, 신규 트랜잭션도 받지 않는다)
지금은 체크포인트를 주기적으로 생성했다고 가정하고 다음 예시를 생각해보자.
체크포인트 시점까지는 모든 로그가 로그 파일로서 디스크에 쓰여있고, 체크포인트 이후에는 로그가 로그 버퍼에만 존재한다.(?)
체크포인트 이후에 존재하는 커밋은 데이터 버퍼에 대해 커밋을 하며, 아직 데이터 파일로는 저장하지 않은 상태이다.
(WAL이므로 체크포인트 이후 로그 파일에는 있고, 데이터 파일에는 없다고 말씀하셨는데, 체크 포인트 이후에도 디스크에 쓰기는 쓰되, 버퍼를 두고 쓴다는 것일까?)
체크포인트 이전에 존재하는 커밋에 대해서는 모두 데이터 파일로 저장이 되어있다.
그러나 체크포인트 이후 시점에 대해 커밋이 되었다면, 그 트랜잭션은 아직 파일로 쓰이지 않은 상태이다.
만약 이 상황에서 다음 체크포인트가 생성되기 전에 장애가 발생했다면 다음과 같이 동작한다.
1. 장애가 발생하기 이전에 커밋한 경우, 체크포인트 이후의 동작을 redo 하여 다시 커밋한다.
2. 장애가 발생하기 이전에 커밋하지 않은 경우, 체크포인트 이후의 동작을 undo 하여 되돌린다.
(책에서는 장애가 발생하면, 로그를 분석한 뒤, 먼저 장애 시점까지 모든 동작을 redo 하고, 그 중에서 장애시점까지 커밋되지 않은 동작들을 모두 undo 한다고 표현했다. 이 경우, undo 되는 트랜잭션들은 체크포인트 이전에 시작을 했더라도 모두 사라지는 게 맞는 것 같다. 체크포인트는 commit 결과물이 디스크에 실제로 반영이 되는 기준이라고 이해했다.
그런데 교수님은 체크포인트 시점까지 undo하고 다시 한다고 표현하셔서 좀 헷갈린다.... 뭘 어떻게 다시 할까)
그러면 위 예시에서 1번, 2번 트랜잭션은 undo 될 것이고, 3번 4번 트랜잭션은 redo 될 것이다.
이때 redo와 undo는 각각 redo log, undo log 라는 별도의 로그 파일로 저장되는데,
커밋이 되기 전에는 undo 상태의 log 로 저장하고, 커밋이 되면 redo 상태의 log로 저장한다.
undo log는 이전에 했던 동작들을 기억하고, 하나하나 뒤로 가면서 취소하는데 사용되지만,
redo log는 이전의 데이터를 기억하지 않고, redo 로그를 보면서 데이터 파일이 없다면 만들고, 있다면 그 상태를 redo 과정을 보며 바꾼다. (redo는 체크를 해서 로그와 일치하는 상태이면 동작을 실제로 수행할 필요는 없다.)
redo 가 걸려있는 트랜잭션은 우선적으로 먼저 복구되고, undo 가 걸려있는 트랜잭션은 원복한 다음에 수행된다.
GPT의 설명을 덧붙이면, undo 로그는 데이터를 변경할 때마다 변경되기 이전의 값을 기록한다.
redo 로그는 데이터를 변경할 때마다 변경 내용을 기록한다.
예를 들어 계좌에 100원이 있고, 이 값을 200원으로 변경하였다고 해보자.
만약 변경한 결과를 커밋하고나서 장애가 발생했다면, 디스크를 확인해서 100원이라면 redo 로그를 보고 200원으로 고쳐주면 된다.
만약 커밋 이전에 장애 또는 롤백을 수행했다면, 잔액을 200원에서 100원으로 되돌린다고 한다.
분산 데이터베이스에서의 장애
분산 데이터베이스 상에서 데이터를 주고받을 때도 여러가지 장애가 발생할 수 있다.
해킹을 당해서 의도적으로 응답을 보내지 않을 수도 있고, 네트워크의 문제가 발생했을 수도 있고, 충돌이 발생했을 수도 있다.
하지만 분산 상황에서는 상대방의 상태를 알 수 있는 방법이 없다.
먼저 분산 DB 상태에서 발생할 수 있는 문제 상황은 크게 3가지가 있다.
1. Crash Failure : 충돌이 발생해서 DB가 다운되어 보내지 못한 경우
2. Ommission Failure : 네트워크 문제로 데이터가 누락되어 보내지 못한 경우
3. Byzantine Failure : 악의적으로 메세지를 누락, 지연 시키는 경우
Crash Failure, Ommission Failure 의 경우, 선량한 Failure 라는 뜻으로 Benign Failure 라고도 한다.
분산 상태에서는 어떤 장애가 발생했는지, 어떤 종류의 failure가 발생했는지 아는 것이 매우 힘들다.
분산 상태에서 장애 감지는 네트워크 상황의 변화 때문에 시간만으로 확인하기 힘들기 때문이다.
그 예시로 인공위성 시계를 사용하여 시간을 측정하는 과정을 살펴볼 수 있다.
어떤 절대적인 인공위성 시계가 있고, 컴퓨턴느 인공위성 시계에게 현재 시각을 질의한다고 해보자.
이때 컴퓨터가 보내는 질의는 빛보다 빠르게 갈 수 없으므로, x 만큼의 시간을 거쳐 인공위성에 도착한다.
인공위성은 약간의 처리 시간을 거쳐 T3 시점에 '현재 시각은 이거다' 라는 응답을 보낸다.
이 응답은 역시 T4 시간대에 도착할 것이다.
하지만 컴퓨터는 T4에 받은 시각을 정말 T4의 시간으로 생각할 수 없다.
그림에서 보는 것처럼 T3에서 보낸 시각 데이터가 도착할 때까지 어느 정도의 시간이 걸려서 오차가 발생하기 때문이다.
따라서 순수하게 데이터가 왕복하는 시간을 오차로 계산하기 위해, 인공위성은 T3-T2 값을 함께 보내준다.
그리고 컴퓨터는 이 값을 토대로 (T4 - T1 ) - (T3 - T2) 를 계산할 수 있다.
이 값은 순수하게 데이터가 한번 왕복하는데 걸린 시간이 된다.
따라서 데이터를 전송하는 시간과 수신하는데 걸리는 시간이 같다는 전제 하에, 이 값을 2로 나눈다.
그러면 T3에서 측정한 시간을 나에게 보내는데 걸린 시간을 구할 수 있다.
따라서 T4에서는 T3에서 보내준 시간 + (전송 시간) 을 현재 자신의 시간으로 보정해서 인식한다.
하지만 위 전제는 분명히 틀렸을 수도 있기 때문에, 분산 시스템에서는 절대 시계에 다른 컴퓨터의 시계를 맞추는 것이 어렵다.
만약 분산 시스템이 아니라면 중앙의 하나의 시계를 두고 거기에 맞춰 모든 시스템을 굴리면 되지만, 분산 시스템에서는 그것이 쉽지 않다.
'CS > 기초데이터베이스' 카테고리의 다른 글
[데이터베이스] 27. 보안 기초 (0) | 2024.12.09 |
---|---|
[데이터베이스] 26. 정규화 (1NF ~ 5NF) (0) | 2024.12.08 |
[데이터베이스] 24. 트랜잭션 & 직렬 가능성 (0) | 2024.12.07 |
[데이터베이스] 23. Postgresql Explain (0) | 2024.12.05 |
[데이터베이스] 22. 외부 정렬 (0) | 2024.11.29 |