데이터베이스의 동시성 제어 기법 정리
‘데이터베이스의 동시성 제어에 대해서 정리해봤습니다.’
데이터베이스의 ‘MVCC’와 ‘lock’은 동시성을 제어하는 기법입니다.
이 기법에 대한 이해를 돕기 위해, 기본적인 개념과 함께 실제 작동 방식을 살펴보겠습니다.
ACID
본격적으로 알아보기 이전에 트랜잭션의 ACID에 대해서 정리하는게, 동시성 제어 기법에 대해서 이해하는데 도움이 될 것 같습니다.
트랜잭션은 데이터베이스의 데이터를 조작하는 작업의 단위이며, 이론적으로 ACID 원칙을 보장해야 한다고 합니다.
ACID는 각각 Atomicity(원자성), Consistency(일관성), Isolation(격리성), Durability(영구성)를 의미하는데요.
은행의 송금(보내는 사람의 계좌에서 돈을 차감하고 받는 사람의 계좌에 돈을 추가)을 예시로 ACID에 대해서 설명하면 아래와 같습니다.
- Atomicity: transaction의 작업이 부분적으로 성공하는 일이 없도록 보장하는 성질입니다.
- 보내는 사람의 계좌에서 돈을 차감만하고, 받는 사람의 계좌에 돈을 추가하지 않는 일은 없어야합니다.
- Consistency: transaction이 끝날 때 DB의 여러 제약 조건에 맞는 상태를 보장하는 성질입니다.
- 송금하는 사람의 계좌 잔고가 0보다 작아지면 안 됩니다.
- Isolation: transaction이 진행되는 중간 상태의 데이터를 다른 transaction이 볼 수 없도록 보장하는 성질입니다.
- 송금하는 사람의 계좌에서 돈은 빠져나갔는데 받는 사람의 계좌에 돈이 아직 들어가지 않은 DB 상황을 다른 transaction이 읽으면 안됩니다.
- Durability: transaction이 성공했을 경우 해당 결과가 영구적으로 적용됨을 보장하는 성질입니다.
- 한 번 송금이 성공하면 은행 시스템에 장애가 발생하더라도 송금이 성공한 상태로 복구할 수 있어야 합니다.
하지만 실제로 많은 DBMS에서 Isolation(격리성) 원칙은 완화되어 적용되기도 합니다.
트랜잭션간의 격리성을 증가시킬수록 동시성의 성능이 떨어지기 때문입니다.
lock-based 동시성 제어
현대의 대부분 데이터베이스는 ‘MVCC’와 ‘lock based’ 동시성 제어 기법을 같이 사용하여 고립 레벨을 컨트롤 합니다.
하지만 예전에는 ‘lock based’ 기법만이 주로 사용되었다고 합니다.
‘lock based’ 기법에서 사용되는 ‘row-level lock’에는 ‘shared lock’과 ‘exclusive lock’ 두 종류가 있습니다
Shared lock은 읽기 작업에 사용되며, 다른 트랜잭션이 같은 레코드에 대해 shared lock을 요청할 때 트랜잭션을 차단하지 않습니다.
반면, exclusive lock은 쓰기 작업에 사용되며, 다른 트랜잭션이 해당 레코드에 대해 어떤 lock도 획득할 수 없게 됩니다.
이러한 lock 기법은 동시성을 제한하여 성능 저하를 초래할 수 있지만, 데이터의 일관성을 보장하는 데 중요한 역할을 합니다.
아래 표와 같이, 여러개의 트랜잭션이 같은 데이터에 접근할 때, 전부 읽기만 하는 경우를 제외하고는, 동시에 접근하는것이 불가능합니다.
T2 - read | T2 - write | |
---|---|---|
T1 - read | Non Block | Block |
T1 - write | Block | Block |
위와 같이 ‘lock based’ 기법만을 사용해서 동시성을 컨트롤하는 경우, 데이터 일관성을 유지하기 위해 트랜잭션 간의 직접적인 데이터 충돌은 방지할 수 있습니다.
하지만 ‘lost update’와 같은 이상현상은 막을 수 없으며, 이를 위해 2PL(Two Phase Locking)와 같은 기법이 사용됩니다.
또한 트랜잭션 간 자원 경합이 있을 때, 대기나 데드락과 같은 문제가 발생할 수 있습니다.
2PL은 락을 획득하는 확장 단계와 락을 해제하는 수축 단계로 구성되며, 이 프로토콜을 사용하면 트랜잭션이 일정한 순서로 락을 얻고 해제함으로써 교착 상태를 예방하고 데이터의 일관성을 유지할 수 있습니다.“
MVCC의 등장
Lock-based 동시성 제어는 고립 수준을 보장하지만, 높은 경합 상황에서 성능 저하가 발생할 수 있습니다.
이를 위해서 등장한게 ‘MVCC(Multi-Version Concurrency Control)’입니다. 참고로 대부분의 현대적인 DBMS는, lock 기법과 MVCC를 결합해 사용합니다.
MVCC는 주로 READ COMMITTED와 REPEATABLE READ 고립 수준에서 사용되며, 이 기법을 통해 성능을 유지하면서도 일관성을 보장합니다.
MVCC는 트랜잭션이 데이터를 수정할 때, 기존 데이터를 덮어쓰지 않고 새로운 버전을 만드는것을 의미합니다.
새로운 버전의 데이터는 Undo 파일을 사용하여 데이터의 변경 이력을 저장하며, 이는 추가적인 저장 공간을 필요로 합니다.
다른 트랜잭션이 변경 중인 데이터를 읽을 때, Undo 파일에 저장된 이전 버전의 데이터를 읽게 됩니다.
이로 인해서 두개의 트랜잭션이 서로 같은 데이터에 쓰기 작업을 수행하는게 아니라면, 트랜잭션을 블락하지 않아 성능이 더 잘 나옵니다.
기본적으로 새로운 버전은 해당 트랜잭션에서만 볼 수 있으며, 고립 레벨이 READ UNCOMMITTED가 아니라면 다른 트랜잭션은 이 새로운 버전을 보지 못합니다.
기본적으로 MVCC를 사용할 때의 동시성은 아래 표와 같이 향상됩니다.
T2 - read | T2 - write | |
---|---|---|
T1 - read | Non Block | Non Block |
T1 - write | Non Block | Block |
고립레벨
아래는 MySQL에서 사용되는 각 고립 수준(Isolation Level)의 정의와 각 수준에서 발생할 수 있는 문제를 정리한 표입니다.
고립 수준(Isolation Level) | 정의 | 발생할 수 있는 문제 |
---|---|---|
READ UNCOMMITTED | 트랜잭션이 아직 커밋되지 않은 데이터를 읽을 수 있습니다. | - Dirty Read: 커밋되지 않은 데이터를 읽음 - Non-repeatable Read: 동일한 쿼리에서 다른 결과를 얻을 수 있음 - Phantom Read: 트랜잭션 동안 레코드가 추가되거나 삭제되는 현상 |
READ COMMITTED | 트랜잭션이 커밋된 데이터만 읽을 수 있습니다. | - Non-repeatable Read: 동일한 트랜잭션에서 동일한 쿼리에서 다른 결과를 얻을 수 있음 - Phantom Read: 트랜잭션 동안 레코드가 추가되거나 삭제되는 현상 |
REPEATABLE READ | 트랜잭션이 처음 데이터를 읽을 때의 스냅샷을 기준으로 데이터를 읽습니다. | - Phantom Read: 트랜잭션 동안 새로운 레코드가 삽입되거나 삭제되는 현상 |
SERIALIZABLE | 트랜잭션이 순차적으로 실행되도록 강제하여, 데이터의 직렬화를 보장합니다. | - 성능 저하: 모든 트랜잭션이 직렬화되어 처리되므로 동시성 처리량이 감소 |
- Dirty Read: 트랜잭션이 다른 트랜잭션이 커밋되지 않은 데이터를 읽어오는 경우를 말합니다. 이로 인해 잘못된 데이터를 읽을 가능성이 있습니다.
- Non-repeatable Read: 동일한 트랜잭션 내에서 동일한 쿼리를 여러 번 실행할 때, 다른 결과를 얻는 상황을 의미합니다. 이는 다른 트랜잭션이 데이터를 수정했기 때문입니다.
- Phantom Read: 트랜잭션 동안 데이터가 추가되거나 삭제되어 동일한 쿼리를 실행할 때 결과가 달라지는 상황을 말합니다. 주로
INSERT
또는DELETE
연산에서 발생합니다.
참고로 MySQL의 innoDB의 경우, 기본적으로 ‘repeatable read’ 격리 수준을 사용합니다.
Lost Update
Lost Update는 두 트랜잭션이 동시에 동일한 데이터를 수정할 때 발생하는 문제입니다.
이 문제를 이해하기 위해, 아래와 같은 시나리오를 살펴보겠습니다.
시나리오 설명
두 트랜잭션 A와 B가 있으며, 둘 다 ‘read committed’ 격리 수준을 사용한다고 가정합니다.
x의 초기값은 50, y의 초기값은 10입니다. A 트랜잭션은 x에서 y로 40을 이체하고, B 트랜잭션은 x에 10을 입금합니다.
트랜잭션 실행 과정
- A 트랜잭션은 x의 값을 50으로 읽습니다.
- A는 x에 대해 write lock을 설정하고 x의 값을 10으로 변경합니다.
- B 트랜잭션은 x의 값을 50으로 읽습니다.
- B는 x에 대해 write lock을 시도하지만, A에 의해 이미 lock이 설정되어 대기합니다.
- A는 y의 값을 10으로 읽습니다.
- A는 y에 대한 write lock을 설정하고 y의 값을 50으로 변경합니다.
- A는 커밋을 하여 모든 lock을 해제합니다. 데이터베이스에는 x = 10, y = 50으로 기록됩니다.
- B는 이제 x에 대한 lock을 획득합니다.
- B는 x의 값을 60으로 변경합니다.
- B는 커밋을 하여 모든 lock을 해제합니다. 최종적으로 x = 60이 데이터베이스에 기록됩니다.
결과적으로, A 트랜잭션에 의한 x값의 변경은 사라지고, 최종적으로 x는 60, y는 50이 됩니다.
일반적으로 lock based 동시성 제어에서는 2단계 잠금 프로토콜(2PL)을 적용하여 이런 문제를 방지합니다.
MVCC 환경에서는 이 문제를 어떻게 해결하는지 살펴보겠습니다.
PostgreSQL의 Repeatable Read를 통한 해결 방법
PostgreSQL에서는 Lost Update 문제를 해결하기 위해 트랜잭션의 격리 수준을 ‘repeatable read’로 설정할 수 있습니다.
PostgreSQL에서 격리 수준이 ‘repeatable read’인 경우, 같은 데이터에 대해 먼저 업데이트한 트랜잭션이 커밋되면 이후 트랜잭션은 롤백됩니다.
따라서, 위 예시에서 A가 먼저 커밋했기 때문에, B 트랜잭션은 9번 과정에서 롤백되어 Lost Update 문제가 해결됩니다.
하지만 실제 운영 환경에서는 여러 가지 순서로 트랜잭션이 실행될 수 있으므로, A와 B 모두 ‘repeatable read’ 격리 수준을 적용하는 것이 안전합니다.
MySQL의 Repeatable Read를 통한 해결 방법
MySQL은 PostgreSQL과 달리 ‘repeatable read’ 격리 수준에서 자동으로 후행 트랜잭션을 롤백하는 기능이 없습니다.
따라서 MySQL에서는 ‘locking read’(비관적 락)를 사용해야 합니다.
이는 개발자가 직접 구현해야 하는 쿼리로, MySQL이 관리해주는것이 아닙니다.
예를 들어, select balance from account where id = ‘x’ for update 같은 쿼리를 사용하면, 읽기 동작 중에도 x에 대한 쓰기 lock을 취득할 수 있습니다.
이미 다른 트랜잭션에 의해 lock이 점유되어 있다면, 해당 값이 해제될 때까지 대기합니다.
Locking read를 사용하면, ‘repeatable read’ 격리 수준에서도 가장 최근에 커밋된 데이터를 읽습니다.
“x의 초기값이 50, y의 초기값이 10인 상황에서 A 트랜잭션이 x에서 y로 40을 이체하고, B 트랜잭션이 x에 10을 입금하는” 상황에서 A와 B 트랜잭션이 ‘locking read’를 사용할 때의 동작을 살펴보겠습니다.
- A 트랜잭션은 lockingRead(x)를 통해 x의 값을 50으로 읽고, 동시에 x에 대한 쓰기락을 획득합니다.
- A 트랜잭션은 write(x = 10)를 통해 x의 값을 10으로 업데이트합니다.
- B 트랜잭션은 lockingRead(x)를 통해 x의 값을 읽으려 하지만, A가 이미 x에 쓰기락을 가지고 있어 대기합니다.
- A 트랜잭션은 lockingRead(y)를 통해 y의 값을 10으로 읽고, y에 대한 쓰기락을 획득합니다.
- A 트랜잭션은 write(y = 50)를 통해 y의 값을 50으로 업데이트합니다.
- A 트랜잭션은 커밋하면, 자동으로 모든 락이 해제되고, x = 10, y = 50이 데이터베이스에 기록됩니다.
- 이제 B 트랜잭션이 대기하던 x에 대한 쓰기락을 획득하며, locking read를 통해 x의 값을 읽습니다. 이때, 최근에 커밋된 값인 10으로 x의 값을 읽습니다.
- B는 write(x = 20)을 통해 x의 값을 20으로 업데이트합니다.
- B가 커밋하면, x = 20이 데이터베이스에 기록됩니다.
Locking Read는 두 가지 형태가 있습니다.
- SELECT … FOR UPDATE
- exclusive lock을 얻어옵니다.
- SELECT … FOR SHARE
- share lock을 얻어옵니다.
비관적락이 고립 수준 SERIALIZABLE과 유사한가? 라고 생각할 수 있지만 아래와 같은 차이점이 존재합니다.
- SERIALIZABLE 고립 수준은 전체 데이터베이스의 모든 트랜잭션이 마치 직렬로 실행되는 것처럼 처리되기 때문에, 비관적 락과 달리 Phantom Read와 같은 문제도 완벽하게 방지합니다.
- 비관적 락은 특정 레코드나 행에 대해서만 적용되기 때문에, 전체 트랜잭션을 SERIALIZABLE 수준으로 만들지 않습니다.
MySQL InnoDB의 Gap Lock을 통한 Phantom Read 방지
MySQL의 InnoDB 스토리지 엔진은 REPEATABLE READ 격리 수준에서 Phantom Read 문제를 방지하기 위해 Gap Lock이라는 메커니즘을 사용합니다.
Phantom Read는 트랜잭션이 실행되는 동안 다른 트랜잭션이 새로운 행을 삽입하거나 기존 행을 삭제할 때 발생하는 현상으로, 동일한 쿼리를 반복 실행했을 때 결과가 달라지는 문제가 생길 수 있습니다.
Gap Lock이란?
Gap Lock은 특정 행 자체뿐만 아니라, 행 사이의 빈 공간(갭)에도 락을 거는 방식입니다.
이 락은 트랜잭션이 특정 범위의 데이터를 읽거나 수정할 때 발생하며, 트랜잭션이 완료되기 전까지 다른 트랜잭션이 이 범위 내에 새로운 행을 삽입하거나 삭제하지 못하도록 방지합니다.