Introduce
결제 시스템을 구축하면서 동시성 문제를 해결하기 위해 비관적 락, 낙관적 락, 분산락과 같은 다양한 락킹 기법들을 검토하게 되었다. 하지만 이를 효과적으로 적용하기 위해서는 데이터베이스에서 제공하는 기본적인 락킹 기법을 먼저 이해하는것이 우선이라는 생각이다. 따라서 이번 글에서는 데이터베이스의 Lock의 종류, 발생할 수 있는 이상 현상, 해결 기법 들을 다루고자 한다.
Concurrency
📌 동시성
Concurrency(동시성) 이란 여러개의 작업(트랜잭션, 요청, 스레드 등)이 동시에 실행되는 상태를 의미한다. 즉, 하나의 시스템에서 여러 개의 프로세스 또는 스레드가 동일한 자원을 공유하며 실행되는 모든 상황을 의미한다.
즉, 데이터베이스에서 동시성 제어란 여러 개의 트랜잭션이 동시에 실행될 때, 데이터의 정합성을 유지하면서 처리하는 방법이다. 데이터의 일관성과 무결성을 보장하는것이 핵심이다.
📌 문제 상황 (Lost Update)
동시성으로 인해 발생할 수 있는 문제 상황 중 한가지이다.
데이터베이스에 X의 초기값이 10으로 저장되어 있을때, Tx 1 은 X의 값을 20으로 바꾸는 작업이고, TX 2 는 X의 값을 90으로 바꾸는 작업이다. 이때 만약 2개의 트랜잭션이 동시에 실행 되었을 때 X의 값은 어떻게 될까?
Tx 1 과 Tx 2 가 동시에 실행될 경우, 둘다 초기값 X = 10을 읽고, 각자의 연산을 수행하게 된다.
Tx 1이 먼저 실행된다면 최종값이 90일 것이고, Tx 2가 먼저 실행된다면 최종값은 20일 것이다.
즉, Tx 2 가 Tx 1의 변경사항을 반영하지 못하고, 덮어 쓰게 되거나, Tx 1 이 Tx 2의 변경사항을 덮어 쓰게 된다. 한 트랜잭션의 작업은 반드시 무시되므로 데이터 정합성이 깨지게 된다.
Lock
Lock이란 트랜잭션의 직렬성(Serializablity)을 보장하여 여러 트랜잭션이 동시에 동일한 데이터에 접근할 때 데이터의 정합성을 보장하고, 동시성 문제를 방지하기 위해 사용되는 동기화 메커니즘이다.
📌 shared-lock (read-lock)
데이터에 대해서 읽기(read)만 가능한 락으로 해당 데이터를 읽는 동안 변경되지 않도록 보장해준다. 여러 트랜잭션이 동시에 같은 데이터를 read 하는것을 허용한다. 따라서 tx 1이 read-lock을 가지고 있더라도, 다른 트랜잭션이 read-lock을 같이 획득할 수 있다.
📌 exclusive lock (write-lock)
데이터를 수정할 때 사용되며, 해당 데이터에 대한 모든 다른 접근을 차단하는 락이다. 이름이 단순히 write-lock 이라서 write(insert, update, delete) 작업을 할 때만 사용될 것 같지만 실제로는 read, write 모두 가능하다. 만약 tx 1이 X에 대해서 write-lock을 가지고 있다면 다른 트랜잭션들은 모두 X에 대한 어떠한 lock도 가질 수 없다.
📌 호환성
read-lock 은 서로 배타성이 없어서 동시에 여러 트랜잭션이 read-lock을 획득할 수 있다.
하지만 write-lock은 배타성을 지니기 때문에 특정 데이터에 대해서 한 트랜잭션이 write-lock을 획득했다면, 다른 트랜잭션들은 해당 데이터에 대해서 read-lock, write-lock 모두 획득하지 못한다.
read-lock | write-lock | |
read-lock | O | X |
write-lock | X | X |
📌 동시성 해결
해당 시나리오는 Tx 1과 Tx 2가 동시에 write-lock 을 요청하였을때, 데이터베이스 내부적으로 Tx 1이 Lock을 획득한 경우를 가정한 시나리오이다.
먼저 write-lock을 획득한 Tx 1은 x = 20 이라는 쓰기 연산을 실행한 뒤 lock을 반납한다. 이때 공유 자원인 X는 20이다.
Tx 2는 그동안 Blocking 상태로 대기하다가 Tx 1이 lock을 반납하면 write-lock을 획득하게 되고, x = 90 쓰기 연산을 실행한 뒤, 락을 반납하게 된다. 그러므로 최종적으로 X는 90이 되면서, 락을 사용하여 데이터 정합성이 보장된 것을 확인할 수 있다.
Locks alone do not ensure serializability of transactions
Lock을 사용하여 동시성 문제를 해결한 것 같지만.. 사실 Lock 만으로는 Serial Schedule과 같은 결과를 보장하지는 않는다.
#1, #2는 serial schedule로 Tx 1 -> Tx 2 이거나 Tx 2 -> Tx 1 일때 X와 Y의 값을 의미한다. 그렇다면 어떠한 경우라도 #1, #2의 값이 결과로 보장받아야 할 것이다.
📌 Nonserializable
해당 시나리오로는 #1과, #2의 결과가 아닌 x = 300, y = 300 으로 아예 새로운 값이 나왔다. 이러한 스케줄을 Nonserializable한 스케줄이라고 한다,
또한 분명 lock을 사용했음에도 불구하고, 데이터의 정합성을 보장할 수 없는 것이다.
그렇다면 Nonserializable 해진 이유가 뭘까?
문제가 생긴 이유는 이 부분 때문이다.
만약 Tx2가 먼저 시작했다면, serializable 하기 위해서는 Tx 1은 Tx 2가 커밋한 후의 y를 읽어야 한다. 하지만 Tx 1이 먼저 read lock을 획득해서 커밋 이전의 y를 읽어버렸다. 그렇기에 Tx 2는 먼저 트랜잭션을 시작했음에도 불구하고, write_lock(y)이 block 되어 버려서 Tx 1이 read_lock을 반납할때까지 작업을 진행하지 못하기에 Nonserializable 해졌다.
📌 serializable
Tx 2에서 x 에 대한 lock을 반납(unlock(x)) 하기 전에, y에 대한 락을 획득 (write_lock(y)) 하도록 변경하여 serializable schedule로 만들었다. 최종 결과도 X = 400, Y = 300으로 #2와 결과가 일치하는 것을 확인할 수 있다.
또한 Tx 1이 먼저 실행될 수도 있기 때문에 똑같이 lock을 반납하기 전에 lock을 획득하는 식으로 순서를 바꾸어 주었다.
이처럼 lock을 반납하기 이전에 lock을 획득하는 식으로 시나리오를 변경해주니 데이터 정합성이 보장된다는 것을 알 수 있다.
serializable 해진 schedule 에서 lock과 관련된 operation만 모아보면 다음과 같다. 결과적으로 lock을 unlock 보다 먼저 실행 해주면 되는것이다.
이러한 protocol을 2PL Protocol이라고 한다.
2PL Protocol
2PL Protocol은 two-phase locking 의 줄임말이다. 그렇다면 왜 2 phase 일까?
이렇게 2개의 단계로 구분할 수 있다. 파란색 박스를 Expanding Phase(growing phase)라 하고, 빨간색 박스를 Shrinking phase(contraction phase) 라고 한다.
Expanding Phase은 lock을 획득하기만 하고, 반환하지는 않는 phase이다.
Shrinking Phase은 lock을 반환만 하고, 획득하지는 않는 phase이다.
즉, 트랜잭션에서 모든 locking operation이 최초의 unlock operation 보다 먼저 수행되도록 하는 것이 2PL protocol이다.
그리고 이 2PL Protocl은 serializability를 보장한다.
📌 DeadLock
Tx 1은 x 쓰기 락을 획득한 뒤 y 락을 반납할려고 하고, Tx 2는 y 쓰기 락을 획득한 뒤 x 락을 반납하고자 한다. 결국 서로가 서로를 기다려 영원히 대기하는 deadlock 상태가 되어 더 이상 트랜잭션이 진행될 수 없는 상태가 된다. 이를 해결하기 위해서는 운영체제의 deadlock 해결법과 유사한데 본글은 deadlock에 대해서 알아보는것이 아니니 이러한 문제가 있다는것을 인지만 하고 넘어가자.
Types of 2PL
2PL 에도 다양한 종류가 있다.
Tx 1이라는 트랜잭션이 있고, 해당 작업을 수행한다고 했을때 2PL Protocol로 phase를 나눠보면 이렇게 된다. 이 트랜잭션의 시나리오로 다양한 2PL에 대해서 알아보자.
📌 Conservative 2PL
트랜잭션이 시작되기 이전에 실행에 필요한 모든 lock을 미리 획득한다. 만약 트랜잭션이 필요한 모든 lock을 획득하지 못하면 아예 트랜잭션이 실행되지 않는다. 따라서 필요한 lock을 처음에 획득하기 때문에, 실행 중에 추가적인 lock을 요청하는 일이 없다. 따라서 다른 트랜잭션과의 lock 경쟁이 발생할 가능성이 없어진다.
하지만 모든 락을 획득해야지만 트랜잭션이 시작되기 때문에 트랜잭션 자체가 시작되기 어려워질 수 있어 실용적인 방식은 아니다.
📌 Strict 2PL (S2PL)
트랜잭션이 종료될 때 까지 모든 write_lock을 유지하는 2PL 방식으로 strict schedule을 보장한다.
strict schedule: 어떤 데이터에 대해 write 하는 트랜잭션이 있다면 그 트랜잭션이 commit/rollback 되어 확정 될때까지 다른 트랜잭션은 그 데이터에 대해 read/write 모두 하지 않는 스케줄
쓰기 연산의 락을 트랜잭션이 끝날 때까지 유지하여 Dirty Read를 방지할 수 있으며 트랜잭션이 commit이 되어야만 다른 트랜잭션에서 데이터를 읽을 수 있으므로 rollback 되더라도 다른 트랜잭션은 영향을 받지 않는다. 따라서 Recoverability를 보장하는 2PL 방식이다.
📌 Strong Strict 2PL (SS2PL or rigorous 2PL)
S2PL에서 좀 더 엄격한 방식으로 strict schedule와 recoverability를 보장한다. read-lock과 write-lock 모두 commit/rollback 될 때 반환하며 한번에 lock을 반납하면 되므로 동시성을 구현하기 쉽다는 특징이 있다.
Limitations of 2PL
read-lock | write-lock | |
read-lock | O | X |
write-lock | X | X |
2PL 방식은 read-read를 제외 하고는 한쪽이 block되므로 대기 시간이 길고, 전체 처리량이 좋지 않다는 구조적인 한계가 있다.
write-write 는 같은 데이터에 대해서 모두 수정하는 작업이므로 block 하지 않으면 문제가 발생할 가능성이 매우 높기에 어쩔 수 없다 할지라도 read-write가 서로 배타적으로 동작 하는 것을 막을 수 있지 않을까? 라는 의문이 있다.
이러한 고민속에서 나온게 MVCC 라고 한다. MVCC에 대해서는 CS의 개념이 강하기 때문에 다음 시간에 알아보기로 하자.
Reference
'Computer Science > Database' 카테고리의 다른 글
[DB] 트랜잭션과 동시성 제어 (0) | 2025.03.11 |
---|