데이터베이스 동시성 제어(Concurrency Control)
동시성 제어란 하나의 DB에 여러개의 CRUD 접근에 있어서 (한 어플리케이션이 다중 연결성을 갖거나, 다중 어플리케이션이 붙는 경우 등) 충돌이 없도록, Data Integrity가 위배되지 않도록 다중 접근에 대해 동시성 제어 정책을 통해 관리하는 것을 말한다. 관리할 수 있는 방법으로는 개발 언어를 통하거나, 데이터베이스 설정을 통해서로 나뉜다.
동시성 제어가 필요한 이유
갱신손실: 두 트랜잭션이 같은 데이터를 동시에 업데이트하여 한 트랜잭션의 변경 내용이 손실되는 경우
읽기-쓰기 충돌: 한 트랜잭션이 아직 커밋되지 않은 데이터를 읽고, 이를 기반으로 작업을 수행하여 잘못된 결과를 초래할 수 있음
데이터베이스 동시성 제어의 핵심은 "Locking Mechaanism"이다. Locking 제어 메커니즘은 아래와 같이 2개의 카테고리로 나뉜다.
1. Pessimistic Lock: 비관적인 락
" 선락체크 -> 후작업 "
-> "분명히 충돌이 발생해, 돌다리도 두드리고 건너자!" (DB 내장)
카페에서 화장실을 가기 전, 키가 있는지 확인하고 없으면 자리에 앉아서 기다린다. 화장실 이용이 끝난 사람이 키를 두면 그제서야 키를 챙겨서 화장실에 간다.
- 데이터 충돌 가능성이 높다고 가정하고, 데이터에 접근하는 동안 다른 트랜잭션이 데이터를 수정하지 못하도록 잠금을 거는 방식이다.
- 관계형 데이터베이스에서 많이 사용하는 방식이다.
- 쓰기 작업이 많거나 충돌 가능성이 높은 작업에 효과적인다. ex) 은행 계좌 이체, 재고 관리 시스템 등
- 데이터베이스 빌트인 Lock: 데이터 일관성은 데이터베이스 내에서 알아서 하겠습니다.
- 단위: Table 단위 Lock(큰 잠금 단위) / Row 단위 Lock(작은 잠금 단위)
- 잠금 단위 증가: 동시성 수준이 낮아짐, 트랜잭션 제어가 간단해짐
- 잠금 단위 축소: 동시성 수준이 높아짐, 트랜잭션 제어가 복잡해짐
- 단위: Table 단위 Lock(큰 잠금 단위) / Row 단위 Lock(작은 잠금 단위)
- 결국 Pessimistic Lock란, 특정 데이터에 Lock을 가진 스레드만 접근이 가능하도록 제어하는 방법
- 동작방식
- 데이터를 읽을 때 잠금(Lock)을 설정한다
- 공유 잠금(Shared Lock, S-Lock): 데이터를 읽을 때 설정, 여러 트랜잭션이 동시에 읽기 가능.
- 배타 잠금(Exclusive Lock, X-Lock): 데이터를 읽거나 쓸 때 설정, 다른 트랜잭션은 접근 불가.
- 잠금이 해제되기 전까지 다른 트랜잭션이 데이터에 접근할 수 없다.
- 작업이 완료되면 잠금을 해제한다.
- 데이터를 읽을 때 잠금(Lock)을 설정한다
- 장점
- 충돌을 사전에 방지하므로 트랜잭션 실패 가능성이 낮다.
- 데이터 일관성과 무결성을 강력하게 보장한다.
- 단점
- 매 요청마다 Lock 설정으로 성능이 저하될 수 있다. -> 트랜잭션이 길어질 경우 데드락(교착상태)가 발생할 수 있다.
- 여러 사용자가 동시에 데이터를 수정하려고 할 때 병목 현상이 발생할 수 있다.
1-1. 데드락 발생
: Pessimistic Lock을 사용하면 데이터 무결성 보장 수준은 높으나, 동시성이 떨어지고, 데드락 발생 위험성이 있다.
- 데드락 발생: 1. ORM 내 ID 생성을 위한 접속시 Thread 부족 2. 같은 데이터 2개를 서로 먼저 업데이트하고 락 대기
- T1(트랜잭션 1)은 D1에 Lock을 걸어놓고 T2(트랜잭션 2)는 D2에 Lock을 걸어놓은 상태
- T1은 D1을 먼저 수행하고, D2를 수행할 계획이고
- T2는 D2를 먼저 수행하고, D1을 수행할 계획이다.
- T1입장: D1 수행 후 D2에 접근하려고 하지만, 이미 D2는 Lock이 걸려있는 상태로 대기를 해야한다.
- T2입장: D2 수행 후 D1에 접근하려고 하지만, 이미 D1는 Lock이 걸려있는 상태로 대기를 해야한다.
- 이때 서로 각자의 Lock이 걸려있어서 작업을 수행하지 못하는, 데드락이 발생한다.
2. Optimistic Lock: 낙관적인 락
" 선작업 -> 후체크 "
-> "에이~ 설마 충돌이 발생하겠어? 일단 출발!!!" (소프트웨어적, 누구나 접근 가능)
화장실 전자키 없이 일단 화장실에 가고, 문 앞에서 똑똑똑으로 확인하는 것. 가서 문이 잠겨있으면 앞에서 기다린다.
- 데이터 충돌 가능성이 낮다고 가정하고, 트랜잭션이 종료될 때 충돌 여부를 검증하는 방식이다.
- 데이터 충돌이 실제로 발생했을 경우, 트랜잭션을 다시 시도하거나 실패 처리를한다.
- 비관계형 데이터베이스에서 많이 사용하는 방식이다.
- 동작방식
- 데이터를 읽을 때 그때의 버전 정보나 타임스탬프를 함께 가져온다.
- 데이터를 수정할 때, 위에서 가져온 버전 정보나 타임스탬프를 현재의 데이터 정보와 비교한다.
- 버전이 일치하면 데이터를 업데이트하고, 버전이나 타임스탬프를 수정한다.
- 버전이 불일치하면 다른 트랜잭션이 데이터를 수정한 것이므로, 충돌로 간주하고 작업을 실패처리한다.
- 장점
- 충돌 가능성이 낮은 환경에서 유리하며, 잠금으로 인한 자원 점유가 없다.
- 읽기 작업이 많고, 쓰기 작업이 적은 경우 효과적이다.
- 단점
- 충돌이 빈번한 경우 성능이 떨어질 수 있다. -> 실패한 트랜잭션을 재시도해야하기 때문에
- 트랜잭션이 길어질 경우 충동 가능성도 증가한다.
- OptimisticLockException: 쓰기 버전 충돌이 발생하면 작업한 내용을 개발자가 직접 롤백해야한다.
- 좌측 트랜잭션 01번이 9시 0분에 0 값(8시 0분)을 읽어 +1 하여 1 값을 9시 1분에 저장했고
- 우측 트랜잭션 02번이 9시 0분에 0 값(8시 0분)을 읽어 +2 하여 2 값을 9시 2분에 저장하려던차
- 0 값(8시 0분)이 아닌 1 값(9시 1분)으로 되어있어 처음 버전 혹은 타임스탬프와 달라 에러
- Commit 시점에 처음 읽었던 해당 값의 시간 ≠ 쓰고자할때 해당 값의 시간 때문에 Exception 발생
3. 낙관적 동시성 제어의 대표적인 두가지 방법
두가지 방법 모두 낙관전 락의 구현 방식으로, 잠금을 최소화하여 교착상태는 발생하지 않는다. , 아래와 같이 차이점이 있다.
- 타임스탬프 기법
- 접근 방식: 타임 스탬프 기반 순서 관리
- 읽기-쓰기 동시성: 읽기와 쓰기가 충돌이 가능
- 저장 공간 요구량: 버전 관리 없음 -> 저장 공간 효율적
- 적합한 환경: 충돌 가능성이 낮은 환경, 트랜잭션의 순서를 엄격히 보장해야하는 시스템(PostgreSQL, MySQL에서 전체 DB 처리량 저하 방지를 위해서 사용)
- MVCC
- 접근 방식: 데이터의 여러 버전 관리
- 읽기-쓰기 동시성: 읽기와 쓰기를 분리하여 충돌 방지
- 저장 공간 요구량: 버전 관리 저장 -> 저장 공간 소모
- 적합한 환경: 읽기 작업이 많은 환경, 데이터베이스의 높은 동시성과 성능이 요구되는 시스템
3-1. Timestamp-based Concurrency Control
타임 스탬프 기법은 트랜잭션 순서를 보장하기 위해, 각 트랜잭션과 데이터 항목에 고유한 타임 스탬프를 할당하여서 동시성을 제어하는 방법이다.
- 트랜잭션 타임스탬프: 각 트랜잭션이 시작될 때 시스템은 트랜잭션에 고유한 타임스탬프를 할당하여서 동시성을 제어하는 방법
- 읽기 타임스탬프(Read TS): 데이터 항목이 가장 최근에 읽힌 시점을 기록한다.
- 쓰기 타임스탬프(Write TS): 데이터 항목이 가장 최근에 수정된 시점을 기록한다.
- 타임스탬프 비교 규칙
- 읽기 요청: 트랜잭션의 타임스탬프 >= 해당 데이터의 Write TS
- 쓰기 요청: 트랙잭션의 타임스탬프 >= 해당 데이터의 Write TS AND Read TS
- 그렇지 않으면 트랜잭션은 롤백된다.
3-2. Multiversion Concurrency Control (MVCC)
다중 버전 동시성 제어는 데이터베이스의 동시성을 보장하면서 데이터의 여러 버전을 유지하여, 읽기 작업과 쓰기 작업을 분리하는 동시성 제어 방법이다. 이는 데이터의 읽기와 쓰기를 서로 간섭하지 않는다.
- 쓰기 작업: 트랜잭션이 데이터를 수정하면 기존 데이터를 삭제하지 않고, 새로운 버전을 생성한다.
- 기존 버전은 유지되기 때문에, 다른 트랜잭션이 이 데이터를 읽을 수 있다.
- 새 버전은 해당 쓰기 작업을 완료한 후에만, 다른 트랜잭션에서 접근이 가능하다.
- 읽기 작업: 항상 트랜잭션이 시작될 때 존재했던 데이터의 특정 버전을 참조한다.
- 쓰기 작업이 새로운 버전을 생성해도, 기존 트랜잭션은 영향을 받지 않는다.
- 가비지 컬렉션: 더이상 참조되지 않는 오래된 데이터 버전은 삭제하여 저장소를 관리한다.