티스토리 뷰
동시성 제어 문제
먼저 동시성 제어가 제대로 이루어지지 않았을 때 발생할 수 있는 문제들을 알아보자.
Lost Update - 갱신 손실
하나의 트랜잭션에서 갱신한 데이터 내용을 다른 트랜잭션이 덮어씀으로써 갱신이 무효화 되는 문제를 의미한다.
초기 값이 `x = 1`데이터에 대해 두 트랜잭션이 동시에 실행된다고 생각해보자.
- 트랜잭션 A : `x + 10`
- 트랜잭션 B : `x + 20`
- 트랜잭션 A가 먼저 실행되어 x = x + 10 연산을 통해 값을 `11`로 갱신한다.
- 트랜잭션 A보다 조금 늦게 시작한 트랜잭션 B가 x = x + 20 연산을 통해 값을 `21`으로 갱신한다.
- 결과적으로 x의 값은 21이되며 트랜잭션 A의 갱신은 손실된다.
두 트랜잭션이 직렬로 실행되었다면 `x` 값은 31이 되어야 한다. 하지만 트랜잭션 A가 `x`의 값을 갱신하고 커밋하기 전에 트랜잭션 B가 `x`의 값을 읽기 때문에 그 값을 1로 읽게 되어 1에 20을 더한 21로 갱신하게 된다. 결과적으로 `x`의 값이 31이 아닌 21로 갱신되는 것이다.
이렇게 트랜잭션 A에서 갱신한 데이터를 트랜잭션 B에서 덮어씀으로써 트랜잭션 A의 갱신이 손실되는 것이 갱신 손실 문제이다.
Inconsistency - 모순성
예시를 먼저 보자. 초기 값이 각각 `x = 1000, y = 2000`인 데이터에 대해 다음의 두 트랜잭션이 실행되는 상황을 생각해보자.
- 트랜잭션 A : `x + 500` 이 후 `y + 500`
- 트랜잭션 B : x * 3 이후 y * 3
그리고 트랜잭션 A의 `x + 500` 연산 이후 트랜잭션 B의 `x * 3` 연산과 `y * 3` 연산이 수행된 다음에 마지막으로 트랜잭션 A의 `y + 500` 연산이 이루어진다.
- 트랜잭션 A read(x) : 초기 x의 값 1000을 읽는다.
- 트랜잭션 A write(x) : x = x + 500 연산 후 저장한다. → x = 1500
- 트랜잭션 B read(x, y) : 저장되어 있는 x와 y 값을 읽는다. → x = 1500, y = 2000
- 트랜잭션 B write(x, y) : x와 y에 각각 3을 곱한 후 저장한다. → x = 4500, y = 6000
- 트랜잭션 A read(y) : 연산을 하기 위해 저장되어 있는 y 값을 읽는다. → y = 6000
- 트랜잭션 A write(y) : y = y + 500 연산 후 저장한다. → y = 6500
동시성 제어가 잘 이루어졌으며 트랜잭션 A → 트랜잭션 B 순서로 수행되었다고 가정한다면 `[x, y]`의 결과 값은 `[4500, 7500]`이 되어야 한다. 하지만 동시성 제어가 잘 이루어지지 않아 트랜잭션 B가 3, 4번 과정을 통해 트랜잭션 A의 수행 중간에 값을 변경하였고 이 결과를 트랜잭션 A가 읽게 되어 잘못된 결과인 `[4500, 6500]`이 되었다.
다시 트랜잭션 A의 입장에서 생각해보면 x 데이터는 트랜잭션을 실행하기 이전의 값인 1000을 정상적으로 읽었다. 하지만 y 데이터는 중간에 트랜잭션 B가 3, 4번 과정을 통해 바꾼 6000이라는 값을 읽게 되었다. 이렇게 한 트랜잭션 안에서 어떤 값은 갱신 전의 값을, 다른 값은 다른 트랜잭션에 의해 갱신된 값을 읽어 데이터가 불일치하는 문제가 모순성이다.
Cascading Rollback - 연쇄 복귀(롤백)
하나의 트랜잭션이 롤백될 때, 해당 트랜잭션이 변경한 데이터를 참조한 다른 트랜잭션들도 함께 연쇄적으로 롤백되거나 롤백하기 어려운 상태가 되는 문제이다.
초기 데이터 `x = 1000`에 대해 다음의 두 트랜잭션이 실행되는 상황을 가정해보자.
- 트랜잭션 A : `x + 500`
- 트랜잭션 B : `x * 3`
- 트랜잭션 A : x의 초기 값이 1000을 읽고 500을 더한다. → x = 1500
- 트랜잭션 B : 트랜잭션 A에 의해 바뀐 값 1500을 읽고 3을 곱한다. → x = 4500
- 트랜잭션 A : 바뀐 값을 저장하는데 실패해 롤백한다.
3번 과정에서 일어날 수 있는 문제는 2가지이다.
- 트랜잭션 A가 롤백됨에 따라 동일한 데이터를 참조한 트랜잭션 B 또한 롤백되어야 한다.
- 만약 트랜잭션 B가 커밋이 완료된 상태라면 트랜잭션 A는 롤백되지 못한다.
1번 문제는 두 트랜잭션이 동시에 같은 데이터를 참조했기 때문에 데이터 무결성을 유지하기 위해 한 트랜잭션이 롤백됨에 따라 같은 데이터를 참조한 모든 트랜잭션 또한 롤백이 되어야 한다. 이렇게 롤백이 연쇄적으로 발생하면서 데이터베이스의 성능과 일관성에 영향을 미칠 수 있게 된다.
2번 문제는 롤백과 커밋 간의 충돌이다. 이미 트랜잭션 B에 의해 갱신된 x 데이터가 커밋되었기 때문에 이후에 트랜잭션 A가 롤백하기 어려운 상태가 된다. 트랜잭션 A가 롤백되더라도 이미 트랜잭션 B에 의해 갱신된 데이터가 데이터베이스에 반영된 상태로 일관성이 깨질 수 있다.
이렇게 하나의 트랜잭션이 롤백될 때, 해당 트랜잭션이 변경한 데이터를 참조한 다른 트랜잭션들도 함께 연쇄적으로 롤백되거나 롤백하기 어려운 상태가 되는 문제가 연쇄 롤백이다.
동시성 제어 기법
데이터베이스에서 동시성을 제어하는 기법으로 대표적인 도구가 Lock이다. Lock은 이전 글에서 이미 보았기 때문에 생략한다.
2PL - 2 Phase Locking
2PL 기법은 이름에서도 알 수 있듯 Lock을 사용한 기법이다. 기존의 Lock 기법은 락을 걸고 해제하는 시점에 제한을 두지 않으면 두 개의 트랜잭션이 동시에 실행될 때 데이터의 일관성이 깨질 수 있는 문제가 발생할 수 있다.
2PL은 트랜잭션이 데이터에 접근할 때 두 가지 단계를 따른다.
Growing Phase - 성장 단계
성장 단계는 트랜잭션이 필요한 데이터에 대한 락을 획득하는 단계이다. 이 때 트랜잭션은 필요한 만큼의 락을 획득할 수는 있지만 락을 해제할 순 없다.
Shringking Phase - 수축 단계
수축 단계는 트랜잭션이 모든 락을 해제하는 단계이다. 더이상 트랜잭션이 락을 획득할 수 없으며 이미 획득한 락만 해제할 수 있다.
2PL은 성장 단계에서만 락을 획득하고 수축 단계에서만 락을 해제하는 2단계를 통해 갱신 손실, 모순성, 연쇄 롤백의 다양한 동시성 문제를 예방할 수 있다.
하지만 여전히 데드락이 발생할 수 있는 가능성이 존재한다. 때문에 데드락 탐지 및 해결 방법이 필요하다.
Pessimistic Concurrency Control - 비관적 동시성 제어
비관적 - Pessimistic : 앞으로의 일이 잘 안될 것이라고 보는
비관적 동시성 제어는 트랜잭션이 데이터에 접근할 때 충돌이 발생할 가능성이 높다고 가정을 가지고 작업을 수행한다. 때문에 이러한 충돌을 방지하기 위해 락을 적극적으로 사용한다.
락을 사용해 충돌을 미리 차단하기 때문에
- 데이터의 정확성과 일관성을 높일 수 있어 변경이 자주 일어나는 데이터에 적합하다.
하지만 락이 많이 걸리면
- 다른 트랜잭션은 대기해야 하므로 성능이 저하될 수 있으며
- 두 트랜잭션이 서로의 자원을 획득하기 위해 무한 대기 상태에 빠지는 데드락이 발생할 수 있다.
Optimistic Concurrency Control - 낙관적 동시성 제어
낙관적 - Optimistic : 앞날의 일이 잘될 것이라고 믿는
낙관적 동시성 제어는 낙관적이라는 말 그대로 트랜잭션이 다른 트랜잭션과 충돌하지 않을 것이라는 가정을 가지고 작업을 수행한다. 즉, 트랜잭션이 수행하는 동안 그 어떠한 검사도 하지 않고 트랜잭션 종료 시 일괄적으로 충돌이 발생하는지 검사하는 방식이다.
낙관적 동시성 제어 기법에서 트랜잭션은 크게 3단계로 진행된다.
Read Phase - 읽기 단계
- 트랜잭션은 필요한 데이터를 읽고, 변경 사항을 메모리에 저장한다. 이 때, 변경사항은 실제 데이터베이스에 기록되는 것이 아닌 사본에 갱신된다.
Validation Phase - 검사 단계
- 트랜잭션이 종료되기 전에 다른 트랜잭션과의 충돌 여부를 검사한다. 충돌 여부는 주로 2가지 방식으로 검사한다.
- 버전 번호 검사 : 데이터에 버전 번호를 부여하고, 트랜잭션이 읽은 시점과 커밋 시점의 버전을 비교해 서로 다른 버전이라면 데이터가 수정된 것으로 충돌로 판단해 롤백된다.
- 타임스탬프 검사 : 트랜잭션이 특정 레코드를 수정하기 전에 해당 레코드의 타임스탬프를 읽고 기억한 다음 커밋 시점에 타임스탬프가 다른 트랜잭션에 의해 변경되었다면 충돌로 간주되어 롤백된다.
Write Phase - 쓰기 단계
- 충돌이 없으면 변경 사항을 커밋하며 충돌이 발견되면 트랜잭션은 롤백된다.
낙관적 동시성 제어 기법은 락을 사용하지 않아 여러 트랜잭션이 동시에 접근할 수 있어
- 동시 접근이 많은 환경에서 성능을 높일 수 있으며 데드락이 발생하지 않는다.
하지만
- 충돌이 발생하면 롤백이 필요하며,
- 빈번하게 충돌이 발생하면 오히려 충돌 처리 비용이 증가해 성능이 저하될 수 있다.
- 락을 사용하지 않기 때문에 충돌 발생이 잦으면 일관성 문제가 생길 가능성이 높다.
때문에 대부분의 트랜잭션이 읽기 작업에 집중되는 경우에 더 효율적인 방식이다.
비관적 동시성 제어 vs 낙관적 동시성 제어
비교 항목 | 비관적 동시성 제어 | 낙관적 동시성 제어 |
충돌 가정 | 충돌이 빈번하게 발생할 것으로 가정 | 충돌이 드물게 발생할 것으로 가정 |
충돌 방지 방법 | 락을 통해 충돌을 방지 | 충돌이 발생한 경우 롤백 처리 |
성능 | 락으로 인해 성능이 다소 저하될 수 있음 | 락이 없어 성능이 높음 (단, 충돌 발생 시 성능 저하) |
데드락 | 데드락 발생 가능성 존재 | 데드락 없음 |
적합한 환경 | 변경 작업이 잦고 충돌 가능성이 높은 환경 | 읽기 작업이 많고 충돌이 드문 환경 |
Timestamp Ordering - 타임스탬프 기반 동시성 제어
이 기법은 각 트랜잭션에 타임스탬프를 부여하여 트랜잭션의 실행 순서를 결정한다. 트랜잭션의 시작 시점에 타임스탬프를 할당하고, 데이터에 접근할 때마다 타임스탬프를 비교하여 충돌을 해결한다. 이 타임스탬프는 트랜잭션마다 고유하게 할당되며 작을수록 더 이전에 시작된 트랜잭션으로 간주한다.
충돌을 방지하기 위해 각 데이터마다 두 가지의 타임스탬프를 유지한다.
- `RTS(Read Timestamp, 읽기 타임스탬프)` : 해당 데이터를 마지막으로 읽은 트랜잭션의 타임스탬프
- `WTS(Write Timestamp, 쓰기 타임스탬프)` : 해당 데이터를 마지막으로 쓴 트랜잭션의 타임스탬프
작업 시에 데이터가 유지하는 타임스탬프와 트랜잭션의 타임스탬프를 비교해 트랜잭션을 롤백하거나 재시도한다.
타임스탬프 기반 동시성 제어는 트랜잭션이 데이터에 접근할 때 Read Rule(읽기 규칙)과 Write Rule(쓰기 규칙)을 적용하여 일관성을 유지한다.
Read Rule : 트랜잭션 T가 데이터 X를 읽을 때, 다음의 조건을 만족해야 한다.
- `T의 타임스탬프 > X의 WTS` : 트랜잭션 T가 X를 읽을 때 X가 T보다 나중에 다른 트랜잭션에 의해 쓰여지지 않았어야 한다.
- 반대로 `T의 타임스탬프 < X의 WTS`라면 T는 이미 오래된 데이터를 읽으려고 하는 것이기 때문에 롤백된다.
- 조건에 만족해 T가 X의 값을 성공적으로 읽는다면 X의 RTS는 T의 타임스탬프로 갱신된다.
Write Rule : 트랜잭션 T가 데이터 X에 쓰기 작업을 수행할 때, 다음의 조건을 만족해야 한다.
- `(T의 타임스탬프 > X의 RTS) && (T의 타임스탬프 > X의 WTS)` : 트랜잭션 T가 X에 쓰려면 T보다 나중에 시작된 트랜잭션이 X를 읽거나 쓰지 않았어야 한다.
- 만약 `T의 타임스탬프 < X의 RTS` 인 경우, T는 다른 트랜잭션이 이미 읽은 X에 쓰려는 상황이므로 롤백된다.
- `T의 타임스탬프 < X의 WTS`인 경우, T보다 나중에 시작된 트랜잭션이 X에 쓴 값을 T가 새로운 값으로 덮어쓰려는 상황이므로 롤백된다.
- 조건에 만족해 T가 X에 쓰기 작업을 성공적으로 수행한다면 X의 WTS는 T의 타임스탬프로 갱신된다.
타임스탬프 기반의 동시성 제어는
- 락을 사용하지 않기 때문에 데드락이 발생하지 않는다.
- 또한 타임스탬프에 기반한 순서로 트랜잭션을 처리하기 때문에 트랜잭션이 직렬성(serializability)을 만족해 데이터베이스의 일관성이 보장된다.
- 타임스탬프 비교를 통해 충돌을 즉각적으로 감지하고 롤백함으로써 갱신 손실의 문제를 방지할 수 있다.
하지만 쓰기 작업이 빈번하게 충돌하는 환경에서는
- 충돌할 때마다 롤백을 해야하므로 성능이 저하될 수 있으며 많은 트랜잭션이 동시에 발생하는 경우 많은 오버헤드를 유발한다.
- 또한, 각 데이터 항목마다 RTS와 WTS를 관리해야 하므로 메모리와 저장 공간이 더 많이 필요하며 작업을 수행할 때 매번 비교해야하는 오버헤드가 증가한다.
💡 낙관적 동시성 제어 vs 타임스탬프 기반 동시성 제어
낙관적 동시성 제어의 검사 방법 중 타임스탬프를 사용한 검사 방법이 존재한다. 이 방법은 타임스탬프 기반 동시성 제어의 타임스탬프를 비교하는 방식과 비슷하다.
두 방식의 차이점은 검증 시점의 차이이다. 낙관적 동시성 제어는 트랜잭션이 작업을 마친 후 커밋하기 전에 충돌 여부를 검사한다. 반대로 타임스탬프 기반 동시성 제어는 트랜잭션이 실행 중일 때 읽기나 쓰기 작업을 할 때마다 타임스탬프 규칙을 적용해 충돌여부를 바로바로 확인한다.
MVCC(Multi-Version Concurrency Control) - 다중 버전 동시성 제어
MVCC는 데이터베이스에서 데이터 항목에 대해 여러 버전을 유지하여 동시성을 관리하는 기법이다. 주로 PostgreSQL, MySQL의 InnoDB 등에서 사용된다.
MVCC 기법에서 각 트랜잭션은 시작될 때 고유한 타임스탬프나 트랜잭션 ID가 부여된다. 이를 사용해 자신의 시작 시점보다 이전의 최신 버전을 참조한다. 이를 통해 읽기 작업은 락 없이도 일관된 데이터를 볼 수 있고 쓰기 작업과 충돌 없이 동시에 수행이 가능하다.
각 트랜잭션이 자신의 시작 시점에 맞는 버전을 읽기 위해서는 각 버전이 어딘가에는 유지가 되어야 한다. 대표적으로 많이 사용하는 MySQL InnoDB는 데이터 변경이 일어날 때 이전 버전을 UNDO 로그에 보관해 공간 효율성을 높인다. 트랜잭션이 시작된 시점에 맞는 버전을 UNDO 로그를 통해 재구성해 읽게 된다.
- 먼저 실행된 `T-A`가 ID가 1인 데이터의 값을 6으로 변경한다.
- UNDO 로그에 변경 사항이 저장된다.
- `T-A`가 종료되기 전에 `T-B`가 시작해 ID가 1인 데이터를 읽는다.
- 데이터베이스 엔진이 UNDO 로그를 참조해 T-B가 읽는 버전에 맞게 재구성한 결과 5를 반환한다.
일반적으로 UNDO 로그는 트랜잭션이 유지될 때까지만 살아있지만 MVCC를 사용하는 InnoDB는 트랜잭션이 종료되더라도 UNDO 로그의 데이터를 다른 트랜잭션이 읽고 있을 수도 있기 때문에 UNDO 로그를 즉시 삭제하지 않고 일정 기간 보관한다.
MVCC 기법은 읽기 작업에 락을 걸지 않으므로
- 여러 트랜잭션이 동시에 접근해도 높은 동시성 성능이 유지되고
- 데드락이 발생하지 않는다.
하지만 여러 버전의 데이터를 저장해야하기 때문에
- 스토리지 사용량이 증가할 수 있으며
- 불필요한 데이터 버전을 제거하기 위한 가비지 컬렉션 오버헤드가 발생한다.
- 이렇게 다양한 버전과 타임스탬프 관리 및 가비지 컬렉션 등의 작업으로 시스템 복잡도가 높아 질 수 있다.
추가로 MVCC는 주로 읽기 작업에 락을 사용하지 않도록 하여 성능을 높이는 데 강점이 있으나 이 자체로 갱신 손실의 문제를 완벽히 해결해주지는 못한다. 때문에 갱신 손실의 문제를 해결하기 위해서는 추가적인 동시성 제어 기법이나 트랜잭션 격리 수준 설정 등이 필요하다.
참고
'CS > Database' 카테고리의 다른 글
Transaction (2) | 2024.10.25 |
---|---|
Database Lock (2) | 2024.10.24 |