안녕하세요. 이번시간에는 데이터베이스의 LOCK에 대해 알아보겠습니다.
(이 내용은 지난시간에 보았던 트랜잭션(Transaction)과 격리레벨 과 이어지는 내용입니다.)
많은 사용자들이 존재하는 서비스 운영을 하다보면 하나의 자원에 대해 여러 스레드가 접근하여 경쟁조건 (Race Condition) 이 발생되어 의도하지 않은 결과를 얻을 수 있습니다.
이러한 결과를 방지하기 위해 데이터베이스로 접근하는 Connection
에 대해 LOCK
을 설정하여 여러 Connection
이 경합하여 발생할 수 있는 여러 오류 상황들을 방지하는 것을 LOCK
을 통해 해결할 수 있습니다.
공유락(Shared Lock)은 읽기락이라고도 합니다. 공유 락이 걸려있는 경우 공유 락은 허용하지만 배타 락은 대기를 하게 됩니다. 공유 락은 SELECT
문 뒤에 FOR SHARE
명령어가 붙은 것을 의미합니다.
여기서 FOR SHARE
가 없는 일반 SELECT
문은 Non Blocking Consistent Read
로 동작하게 됩니다. 이 말은 대기가 없는 Read 로 동작한다는 의미와 같습니다. (아래에서 이 내용에 대해 자세히 알아봅니다.)
이것이 가능한 이유는 지난시간에 보았던 트랜잭션 격리레벨에서 Undo Log 를 통해 원본 데이터를 변경하였을 경우 Commit 이 되기 전의 데이터를 계속 관리를 하고 있기 때문입니다.
Non-Blocking Consistent Read 는 트랜잭션 격리 수준이 REPEATABLE READ 또는 READ COMMITTED 인 경우에 적용됩니다. 이 모드에서는 SELECT
쿼리가 데이터의 스냅샷을 읽어오며, 다른 트랜잭션이 데이터를 변경 중이어도 읽기 작업이 차단되지 않습니다. 이를 가능하게 하는 핵심 기술은 다중 버전 동시성 제어(MVCC, Multi-Version Concurrency Control)입니다.
MVCC는 데이터베이스 시스템에서 일관된 읽기 작업을 보장하면서 동시에 읽기 및 쓰기 작업이 차단되지 않도록 하는 기술입니다. InnoDB에서 MVCC는 다음과 같이 동작합니다
일관된 읽기란, SELECT 쿼리가 실행될 때 해당 트랜잭션이 보는 데이터는 트랜잭션이 시작된 시점의 데이터 스냅샷이라는 의미입니다. 이를 통해 다음과 같은 이점이 있습니다:
정리해보면, 데이터의 스냅샷을 통해 일관된 읽기를 할 수 있게 되고 이게 가능한 이유는 InnoDB에서는 MVCC 를 사용하기 때문입니다.
데이터 스냅샷을 통해 일관된 읽기를 할 수 있으므로 여러 스레드가 SELECT 쿼리를 동시에 요청하여도 차단하지 않고도 일관된 데이터를 보내주므로 대기 없이 Read가 가능하게 됩니다. 이러한 그러므로 잠금이 필요하지 않은 SELECT 쿼리같은 경우 대기 없이 빠른 응답을 줄 수 있게 됩니다.
배타락(Exclusive Lock)은 쓰기락이라고도 불립니다. 쓰기락의 경우 LOCK이 걸린 이후에 대한 모든 LOCK 요청에 대해서는 항상 대기가 발생하게 됩니다.
이렇게 배타락의 경우 항상 대기가 발생하는 이유는 데이터의 일관성과 무결성을 유지하기 위함입니다. 그리고 배타락은 트랜잭션 특징 중 Isolation 독립성과 관련이 있습니다. 배타락을 통해 다른 트랜잭션으로부터 독립적으로 데이터를 제어하기 위함이기 때문입니다.
아래와 같은 경우를 생각해보았습니다.
계좌 1의 잔고는 10만원이며 계좌 1에서 계좌 2로 10만원을 이체하는 상황을 가정해 봅시다.
이 과정에서 계좌 1의 데이터를 안전하게 업데이트하기 위해 배타 락을 걸어야 합니다. 이는 오직 1개의 트랜잭션만이 계좌 1의 데이터에 접근하고 수정할 수 있도록 보장합니다.
다른 트랜잭션은 이제 계좌 1의 데이터에 접근할 수 있습니다.
이렇게 하면 데이터의 일관성과 무결성이 보장됩니다.
이 예시에서 보듯이, 배타 락이 없으면 데이터 일관성이 깨지고, 심각한 오류가 발생할 수 있습니다. 따라서, 배타 락은 데이터의 일관성과 무결성을 보장하기 위해 반드시 필요합니다.
데드락 Dead Lock 이란 ?
데드락은 서로 다른 2개의 트랜잭션이 서로가 가지고 있는 LOCK을 획득하려고 할때 교착상태 데드락 에 빠진 상황을 의미합니다.
아래의 자원이 주어졌다고 가정합니다.
Book 데이터가 2개가 있고 트랜잭션 1, 2가 있습니다.
아래에서 시간순서으로 진행상황을 보겠습니다.
문제는 3, 4번에서 발생합니다.
트랜잭션 1은 Commit을 하기 위해 Book2에 락을 얻어야 하는데 이 락을 트랜잭션 2에서 가지고 있습니다. 하지만 트랜잭션 2는 커밋을 하기 위해 반대로 Book1의 락을 얻어야 하는데 이 락을 트랜잭션 1이 가지고 있습니다.
즉 하나의 트랜잭션이 2개 이상의 락을 얻으려고 하면서 서로의 락을 얻기위해 서로가 대기상태에 빠져있는 상황입니다.
이러한 상황을 데드락이라고 합니다.
데드락 상황은 빈번하게 일어날 수 있으므로 미리 방지를 하는 것이 중요합니다.
해결방법은 크게
이 있습니다.
예방기법은 각 트랜잭션이 실행되기 전에 필요한 데이터를 모두 LOCK을 걸어주는 것입니다. 하지만 이는 데이터가 많이 필요한 상황에서는 병행성을 보장하지 못하므로 많이 쓰이지는 못합니다.
회피기법은 자원을 할당할 때 시간 스탬프 TimeStamp 를 사용하여 교착상태 데드락이 발생하지 않도록 회피하는 방법입니다.
TimeStamp 란 '1970-01-01:00:00:01' UTC 부터 시작하는 초
추가적으로 밑에서도 보겠지만, 데드락을 해결하는 방법에 대해 고민을 해보며 동시성 이슈를 해결하기 위한 방법인 PESSMISTIC LOCK 과 OPTIMISTIC LOCK 과 함께 엮어서 고민해보는 것도 좋은 방법 중 하나일 것 같습니다. 왜냐하면 결국 LOCK을 획득하고 해지하는 과정에서 여러 문제들이 발생할 수 있기 때문입니다.
하지만 그래프 기반 사이클 탐색의 경우 그 비용이 크므로 보통 1번을 많이 사용합니다.
해결하는 과정을 위에서 보았던 데드락 상황을 가정하고 보겠습니다.
Wait - Die, Wound - Wait 방식으로 해결
이는 TimeStamp를 기반을 트랜잭션을 대기, 선점, 종료하는 방식
트랜잭션 1 = 오래된 트랜잭션
트랜잭션 2 = 최신의 트랜잭션
위 상황에서 Book1의 데이터는 트랜잭션 1이 Book2의 데이터는 트랜잭션 2가 LOCK을 가지고 있습니다. 이 상황에서 트랜잭션 1이 트랜잭션 2가 LOCK을 가지고 있는 Book2의 데이터에 대해 접근을 시도할때 Wait - Die 기법에 따르면 트랜잭션 1이 오래되었으므로 기다립니다. 그리고 트랜잭션 2가 트랜잭션 1이 LOCK을 가지고 있는 Book1의 데이터에 LOCK을 획득하려고 할 때, 트랜잭션 2는 최신의 트랜잭션이므로 포기하고 나중에 다시 요청합니다.
이 동안 트랜잭션1은 기다리다가 트랜잭션 2가 롤백하게 되면 Book2에 대한 접근을 완료할 수 있게 되어 Book2에 대해 업데이트를 완료하고 Commit하게 됩니다.
이렇게 되면 데드락이 발생하지 않게 됩니다. 트랜잭션 2가 Book1에 대한 LOCK을 포기했기 때문입니다.
즉, 다른 트랜잭션이 데이터를 점유하고 있을 때, 오래된 트랜잭션은 기다리고(Wait), 최신 트랜잭션은 포기하고 나중에 다시 시도하는(Die)하는 방식입니다.
트랜잭션 1 = 오래된 트랜잭션
트랜잭션 2 = 최신의 트랜잭션
동일한 위 상황에서 Book1의 데이터는 트랜잭션 1이 Book2의 데이터는 트랜잭션 2가 LOCK을 가지고 있습니다. 이 상황에서 트랜잭션 1이 트랜잭션 2가 LOCK을 가지고 있는 Book2의 데이터에 대해 접근을 시도할때 Wound-Wait 기법에 따르면 트랜잭션 1이 오래되었으므로 트랜잭션 2가 가지고 있는 LOCK을 선점(Wound)하여 LOCK을 포기하게 만듭니다. 트랜잭션 2는 Roll Back 하고 나중에 다시 시도하게 됩니다.
이 동안 트랜잭션 1은 Book2에 대한 접근을 완료할 수 있게 되어 Book2에 대한 업데이트를 완료하고 Commit하게 됩니다. 이후 트랜잭션 2는 다시 시작하여 완료할 수 있습니다.
이렇게 되면 데드락이 발생하지 않게 됩니다. 트랜잭션 1은 트랜잭션 2보다 먼저 시작되었으므로 모든 데이터 접근에 대해 선점권을 가지므로 대기할 일이 없게되어 업데이트를 완료하고 Commit하게 됩니다. 오래된 트랜잭션이 새로운 트랜잭션 자원을 요청할 때 새로운 트랜잭션은 자원을 포기(Wound)하고, 새로운 트랜잭션이 오래된 트랜잭션의 자원을 요청할 때는 기다리는(Wait) 방식입니다.
즉, 다른 트랜잭션이 데이터를 점유하고 있을 때, 오래된 트랜잭션이 선점(Wound) 하고 최신 트랜잭션은 기다리는(Wait) 방식입니다.
데이터베이스에서는 여러 상황에서 예기치 못한 상황이 발생할 수 있습니다. 그 중 대표적으로 동시에 데이터를 업데이트를 시도할때 상황이 발생할 수 있습니다. 이를 어떻게 해결할 수 있을까요 ?
크게 2가지가 있습니다.
테이블의 Row에 접근을 시도할 때 LOCK이 걸려있는지 판단하여 LOCK이 걸려있지 않는 경우에만 수정이 가능하며, 수정 시 LOCK을 걸어 다른 트랜잭션이 데이터를 수정할 수 없도록 하는 것입니다. PESSMISTIC LOCK
테이블의 Row에 Version 개념을 적용하며 트랜잭션들의 동시 접근을 허용합니다. 하지만 Commit시점에 Version 정보를 통해 다른 트랜잭션이 Version을 업데이트 하였다면 이 트랜잭션은 Commit이 실패하게 하여 다른 트랜잭션이 동시에 데이터를 수정할 수 없도록 하는 것입니다. OPTIMISTIC LOCK
PESSMISTIC LOCK 은 비관적 락이라고도 불립니다. 이는 데이터가 동시에 수정될 것을 미리 비관적(좋지 않게) 으로 생각하여, 트랜잭션이 데이터를 접근할 때 락을 걸어 다른 트랜잭션들이 해당 데이터에 접근하지 못하게 하는 방법입니다. 비관적 락은 Repetable Read 혹은 Serializble 정도의 격리성 수준을 제공합니다. (격리성 관련하여서는 이전 트랜잭션(Transaction)과 격리레벨 포스트를 참고 부탁드립니다.)
비관적 락은 트랜잭션이 시작될 때 해당 트랜잭션에서 데이터를 읽거나 수정할 때 공유 락(Shared Lock) 또는 배타 락(Exclusive Lock)을 걸고 시작하는 방법을 의미합니다.
이렇게 되면 다른 트랜잭션들이 데이터를 Update하기 위해서는 LOCK 을 얻어야 하므로 LOCK 을 얻기 전까지 대기하게 됩니다.
그래서 비관적 락을 사용하면 트랜잭션들이 순차적으로 실행된다고 생각할 수 있습니다.
위 사진의 상황에서는 트랜잭션 1, 트랜잭션 2가 있으며 Id = 2 인 데이터에 대해 접근을 시도합니다.
OPTIMISTIC LOCK 은 낙관적 락이라고도 불립니다. 이 방법은 트랜잭션이 데이터를 수정한 뒤 Version 정보를 사용하여 Commit 시점 에 해당하는 데이터의 Version 을 확인함으로써 트랜잭션이 데이터를 수정할 수 있는지를 판단하는 방법입니다.
낙관적 락은 데이터베이스가 아닌 Application 에서 LOCK을 핸들링 하는 방법입니다.
위 사진의 상황에서는 트랜잭션 1, 트랜잭션 2가 있으며 Id = 2 인 데이터에 대해 접근을 시도합니다.
OPTIMISTIC LOCK의 경우 Version을 통해 데이터를 관리하며, Application에서 실패한 트랜잭션에 대해서는 추가적으로 조치하여 다시 시도(Retry) 하거나 Roll Back 하는 등의 처리를 해주어야 합니다.
낙관적 락은 주로 다중 사용자가 동시에 데이터를 수정하는 빈도가 낮은 환경에서 유용합니다. 이는 데이터에 대한 접근 충돌이 드물다고 가정하며, 충돌이 발생할 경우 이를 애플리케이션에서 처리하는 방식입니다. 이러한 접근 방식은 락을 사용하는 동안 데이터베이스 리소스를 줄여주기 때문에 성능 이점이 있을 수 있습니다.
참고자료