병행 수행 시 문제
병행 수행(concurrency)은 여러 사용자가 데이터베이스를 동시에 공유할 수 있도록 여러 트랜잭션을 한꺼번에 실행하는 것을 의미하며, 각 트랜잭션은 여러 트랜잭션이 차례로 번걸아 수행되는 인터리빙(interleaving) 방식으로 수행된다. 이러한 환경에서 병행 제어(concurrency control)는 서로 다른 트랜잭션이 동일한 데이터에 접근하여 연산을 수행하더라도 충돌 없이 올바른 결과를 얻을 수 있도록 트랜잭션 실행을 관리하는 작업을 말하며, 동시성 제어라고도 한다.
만약 병행 제어가 제대로 이루어지지 않으면 여러 가지 문제가 발생할 수 있는데, 그중 하나가 갱신 분실(lost update)이다. 갱신 분실은 두 개 이상의 트랜잭션이 같은 데이터를 동시에 읽고 수정할 때 한쪽의 변경 내용이 다른 쪽의 변경 내용으로 덮여 사라지는 현상을 뜻한다. 예를 들어 계좌에서 30원을 인출하는 트랜잭션과 20원을 인출하는 트랜잭션이 동시에 실행된다고 가정해 보자. 정상적으로 처리된다면 두 트랜잭션이 모두 반영되어 총 50원이 인출되어야 하지만, 30원을 인출하는 도중에 20원을 인출하고 다시 30원을 인출한 결과위에 20원을 인출한 결과가 덮어쓰여져 최종적으로는 20원만 인출된 것처럼 보이는 상황이 발생할 수 있다. 이러한 문제를 방지하려면 트랜잭션이 동시에 수행되더라도 순차적으로 처리된 것처럼 동작하도록 제어해야 한다.
모순성(inconsistency)은 하나의 트랜잭션이 여러 개의 데이터 변경 연산을 수행할 때, 일관성 없는 상태의 데이터베이스에서 값을 읽어 계산함으로써 잘못된 결과를 초래하는 문제를 말한다. 예를 들어 카드 포인트 A와 B에 각각 100원씩 적립하는 트랜잭션과 전체 포인트의 절반을 사용하는 트랜잭션이 있다고 하자. 올바른 순차적 수행 순서는 A와 B에 먼저 100원을 적립한 다음, 두 카드 합산 포인트의 절반을 차감하는 것이다. 그런데 병행 수행 상황에서 먼저 A에 100원을 적립한 뒤 전체 포인트의 절반을 사용하고, 그 후에 B에 100원을 적립하는 방식으로 진행되면 문제가 발생한다. 즉 A와 B의 포인트 처리 순서가 엇갈려서 B에 적립된 100원이 절반 사용 당시 반영되지 않아 전체 사용 금액이 실제보다 적게 차감되는 결과가 나온다. 이렇게 여러 트랜잭션이 동시에 실행되면서 일관성 있는 상태를 읽지 못해 발생하는 오류를 방지하려면, 갱신 분실 문제를 해결하듯 각 트랜잭션이 순차적으로 수행된 것처럼 작동하도록 제어해야 한다.
연쇄 복귀(cascading rollback)는 하나의 트랜잭션이 완료되기 전 장애가 발생하여 롤백(rollback)을 수행해야 할 때, 해당 트랜잭션이 변경한 데이터를 참조하여 작업을 수행한 다른 트랜잭션들도 함께 연쇄적으로 롤백해야 하는 상황을 말한다. 예를 들어 계좌 잔액이 0원이라고 가정해 보자. 입금/출금 트랜잭션은 이 계좌에 먼저 1,000원을 입금한 다음 이어서 4,000원을 출금하는 트랜잭션이고, 동시에 이체 트랜잭션은 같은 계좌에서 1,000원을 다른 계좌로 이체하는 작업을 수행한다고 하며 이 두 트랜잭션이 동시에 수행된다고 하자. 이때 먼저 입금/출금 트랜잭션이 수행을 시작하여 1,000원을 입금하고, 다시 다른 트랜잭션이 수행되어 1,000원을 이체해 가면 계좌 잔액은 0원이 되고 이체 트랜잭션은 수행이 완료되어 커밋(commit)된다. 다시 입금 출금 트랜잭션을 수행하기 위해 4,000원을 출금하려하면 잔액이 0원이므로 출금이 불가능해져 롤백해야하는 단계로 진입한다. 그런데 입금/출금 트랜잭션이 원래의 상태로 되돌아가려면 입금한 1,000원을 취소해야 하지만, 이체 트랜잭션은 이미 커밋된 상태이기 때문에 이체 트랜잭션은 롤백이 불가능하고, 따라서 데이터 일관성을 유지할 수 없게 된다.
트랜잭션 스케줄 (Transaction Schedule)
트랜잭션 스케줄이란 트랜잭션에 포함된 연산들이 실제로 수행되는 순서를 의미하며, 크게 직렬 스케줄(serial schedule), 비직렬 스케줄(nonserial schedule), 직렬 가능 스케줄(serializable schedule)로 구분된다.
직렬 스케줄은 각 트랜잭션의 연산을 서로 인터리빙 방식을 사용하지 않고 하나의 트랜잭션이 모든 연산을 끝낸 뒤 다음 트랜잭션이 실행되는 방식이다. 이 경우 트랜잭션들은 서로 간섭을 받지 않고 독립적으로 수행되므로 데이터베이스의 일관성이 항상 유지되어 모순 없는 결과를 보장한다. 비록 다양한 직렬 스케줄이 만들어질 수 있고 스케줄마다 최종 결과가 달라질 수 있지만, 직렬 스케줄로 처리된 결과는 모두 올바르다. 다만 각각의 트랜잭션이 완전히 순차적으로 실행되기에 실제로는 병행 수행(concurrency)이 이루어지지 않는다.
비직렬 스케줄은 트랜잭션을 인터리빙 방식으로 병행 수행하는 방식이다. 이 경우 하나의 트랜잭션이 모든 연산을 마치기 전에 다른 트랜잭션의 연산이 끼어들어 실행될 수 있다. 그러나 비직렬 스케줄로 트랜잭션을 병행 수행하면 앞서 설명한 갱신 분실(lost update), 모순성(inconsistency), 연쇄 복귀(cascading rollback) 등의 문제가 발생할 가능성이 높아 결과의 정확성을 보장하기 어렵다. 실제로 서로 다른 연산 순서를 가진 어떤 비직렬 스케줄은 잘못된 결과를 초래한다.
직렬 가능 스케줄은 직렬 스케줄에 따라 수행한 것과 같이 정확한 결과를 생성하는 비직렬 스케줄이다. 인터리빙 방식으로 병행 수행하면서도 정확한 결과를 얻을 수 있다. 단 비직렬 스케줄이 직렬 가능 스케줄인지 판단하는 것은 간단한 작업이 아니므로 직렬 가능성을 보장하는 병행 제어 기법을 사용하는 것이 일반적이다.
로킹 (Locking)
병행 수행(concurrency) 환경에서도 직렬 가능성(serializability)을 보장하기 위해서는 모든 트랜잭션이 준수하면 반드시 직렬 가능 스케줄이 되는 규약을 정의하고, 각 트랜잭션이 이 규약을 따르도록 하는 방식이 있어야 앞서 언급한 여러 문제 발생 가능성을 막을 수 있다. 이를 위한 대표적인 방법이 로킹(locking) 기법이다. 로킹 기법은 한 트랜잭션이 특정 데이터에 대해 연산을 수행하는 동안 다른 트랜잭션이 해당 데이터에 접근하지 못하도록 상호 배제(mutual exclusion)를 보장한다. 상호 배제를 구현하기 위해 락(lock) 연산과 언락(unlock) 연산을 활용한다. 락(lock)은 트랜잭션이 데이터에 대한 독점권을 요청하는 연산이고, 언락(unlock)은 트랜잭션이 데이터에 대한 독점권을 반환하는 연산이다.
기본 로킹 규약(lock-based protocal)은 다음과 같다.
- 트랜잭션은 데이터에 접근하기 위해 먼저 락(lock) 연산을 실행해 독점권을 획득해야 한다.
- 다른 트랜잭션에 의해 이미 락(lock) 연산이 실행된 데이터에는 다시 락(lock) 연산을 실행할 수 없다.
- 독점권을 획득한 데이터에 대한 모든 연산의 수행이 끝나면 트랜잭션은 언락(unlock) 연산을 실행하여 독점권을 반납해야 한다.
로킹 단위는 데이터베이스 전체부터 릴레이션, 튜플, 속성까지 다양하게 설정할 수 있다. 락(lock) 단위가 커질수록 관리가 쉬워지지만 병행성이 떨어지고, 반대로 락 단위가 작을수록 세밀한 제어가 가능해져 병행성은 높아지지만 관리가 복잡해진다.
기본 로킹 규약의 효율성을 높이기 위해서는 읽기(read) 작업과 쓰기(write) 작업을 구분하여 허용 조건을 다르게 설정할 수 있다. 이때 사용하는 것이 공용 락(shared lock)과 전용 락(exclusive lock)이다. 공용 락(shared lock)을 획득한 트랜잭션은 해당 데이터에 대해 읽기(read) 연산만 수행할 수 있으며, 다른 트랜잭션도 동시에 같은 데이터에 대해 공용 락(shared lock)을 걸어 읽기만 수행할 수 있다. 그러나 쓰기(write) 연산은 불가능하다. 반면 전용 락(exclusive lock)을 획득한 트랜잭션은 해당 데이터에 대해 읽기(read)와 쓰기(write) 연산을 모두 수행할 수 있지만, 어떤 다른 트랜잭션도 공용 락(shared lock)이든 전용 락(exclusive lock)이든 그 데이터에 대해 락 연산을 실행할 수 없다. 이와 같이 공용 락과 전용 락을 적절히 사용하면, 여러 트랜잭션이 동시에 읽기 작업을 수행하도록 허용하면서도 쓰기 작업 시에는 상호 배제를 보장해 효율적인 병행 제어가 가능해진다.
그러나 기본 로킹 규약만으로는 완전한 직렬 가능성(serializability)을 보장하기 어렵다. 이를 해결하기 위해 2단계 로킹 규약(2PLP, Two-Phase Locking Protocol)이 도입된다. 2단계 로킹 규약에서는 트랜잭션의 락(lock) 연산과 언락(unlock) 연산을 두 단계로 구분하여 실행해야 한다. 먼저 트랜잭션이 시작되면 확장 단계(growing phase)에 들어가며, 이 단계에서는 오직 락(lock) 연산만 수행할 수 있다. 트랜잭션이 더 이상 락을 획득할 필요가 없다고 판단되면 첫 번째 언락(unlock) 연산을 실행함으로써 축소 단계(shrinking phase)에 들어간다. 축소 단계에서는 오직 언락(unlock) 연산만 수행할 수 있고, 새로운 락을 획득할 수는 없다. 따라서 트랜잭션은 첫 번째 언락 연산이 실행되기 전에 필요한 모든 락 연산을 완료해야 한다. 이러한 두 단계 구조를 통해, 트랜잭션 간 충돌을 방지하고 인터리빙된 실행에서도 직렬 가능성을 보장할 수 있다.
로킹 기법을 이용할 때는 교착 상태(deadlock)에 빠지지 않도록 주의해야 한다. 교착 상태란 두 개 이상의 트랜잭션이 서로 상대방이 독점하고 있는 데이터에 대해 언락(unlock) 연산이 실행되기를 기다리면서, 각자의 처리가 더 이상 진행되지 않는 상황을 말한다. 따라서 시스템 설계 시 교착 상태가 발생하지 않도록 예방책을 마련하거나, 교착 상태가 발생했을 때 즉시 이를 탐지하여 적절한 조치를 취할 수 있도록 해야 한다.
'Computer Science and Engineering > Database' 카테고리의 다른 글
[DB] 장애(failure) 및 회복(recovery) (0) | 2025.06.04 |
---|---|
[DB] 트랜잭션(transaction) (0) | 2025.06.04 |
[DB] 정규화(normalization) 및 정규형(NF, normal form) 단계 (0) | 2025.06.02 |
[DB] 이상 현상(anomaly) 및 함수적 종속성(FD, functional dependency) (0) | 2025.06.02 |
[DB] 인덱스(index) (0) | 2025.06.02 |