HomeAbout
[DataBase CS] 엔진 수준의 Lock, MVCC, 트랜잭션 격리 수준 (Transaction Isolation Level)
Algorithm & CS & OS
[DataBase CS] 엔진 수준의 Lock, MVCC, 트랜잭션 격리 수준 (Transaction Isolation Level)
NASA1515
NASA1515
July 03, 2024
8 min

목차

01
1️⃣ 트랜잭션의 기본 개념
02
2️⃣ 데이터베이스 락(Lock)의 이해
03
3️⃣ 트랜잭션 격리 수준 (Transaction Isolation Level)

트랜잭션 격리 수준 (Transaction Isolation Level) : 데이터베이스 성능과 일관성의 균형.


제 개인적인 견해로 비추어 보았을 때, 데이터베이스 관리는 성능과 데이터 일관성 사이의 끊임없는 균형 잡기의 반복이라고 생각합니다.
이 균형의 핵심 포인트에는 트랜잭션 격리 수준 (Transaction Isolation Level)이 존재합니다, 실제 업무에서의 경험을 토대로 배운 개념과 저의 여정을 공유하고자 합니다.

개인적 경험

처음, 그리고 여러 프로젝트에서 파이프라인 로직 개발 과정 속에서 Source Database의 Transaction 부하 문제에 직면했던 경험이 여럿 차례 있습니다.
그때마다 다른 DBA 분들이 해당 DataBase의 isolation level 설정을 변경/적용하는 것을 단순히 따라 하기만 했지, 왜 그렇게 하는지에 대한 근본적인 이해가 없었습니다.
언젠가는 제가 해야 할 일이 될 거라고 생각을 해서, 미리 근본적인 개념을 습득해야 할 것 같아서, 정리의 필요성을 느꼈습니다.


1️⃣ 트랜잭션의 기본 개념

트랜잭션(Transaction)은 데이터베이스의 데이터를 조작하는 작업의 단위(unit of work)입니다.
쉽게 이해하기 위해 예를 들어보자면, 은행 송금 과정은 다음과 같은 트랜잭션으로 구성됩니다:

  1. (1)의 계좌에서 (2)의 계좌로 돈을 송금
  2. (2)의 계좌에서 (1)의 입금을 확인

이 두 과정 중 하나라도 실패하면, 전체 데이터 변경이 취소되어야 합니다.

➕ Transaction의 ACID 원칙 : 은행 송금 예시

트랜잭션은 일반적으로 ACID 원칙을 따라야 합니다. 다만, 그냥은 이해하기 어려우니, ACID 원칙을 위의 은행 송금 예시를 통해 설명해보겠습니다.

  1. Atomicity (원자성): 송금 과정에서 A 계좌에서 돈을 인출하고 B 계좌로 입금하는 두 단계가 있습니다.
    원자성은 두 단계가 모두 성공하거나 모두 실패해야 함을 의미합니다. 예를 들어, A 계좌에서 돈은 인출됬지만 B 계좌로 입금되지 않은 상태로 트랜잭션이 끝나면 안 됩니다.

  2. Consistency (일관성): 송금 전후로 은행의 총 잔액은 변하지 않아야 합니다.
    A 계좌에서 100만원이 빠져나가면 B 계좌에는 정확히 100만원이 들어와야 합니다. 또한, 계좌 잔액이 마이너스가 되는 등의 규칙 위반이 있어서는 안 됩니다.

  3. Isolation (격리성): 여러 송금 트랜잭션이 동시에 실행될 때, 각 트랜잭션은 다른 트랜잭션의 중간 상태를 볼 수 없어야 합니다.
    예를 들어, A에서 B로의 송금과 C에서 A로의 송금이 동시에 일어날 때, 두 트랜잭션은 서로의 중간 결과에 영향을 받지 않고 독립적으로 실행되어야 합니다.

  4. Durability (지속성): 송금이 성공적으로 완료되면, 시스템 장애가 발생하더라도 그 결과는 영구적으로 보존되어야 합니다.
    예를 들어, A에서 B로 송금이 완료된 직후 은행 시스템에 장애가 발생해도, 재시작 후에도 B의 계좌에는 송금된 금액이 그대로 남아있어야 합니다.

➕ 왜 트랜잭션 격리 수준을 이해해야 할까요?

위의 예처럼, 각 행동마다 ACID 원칙을 적용함으로써, 은행 송금과 같은 중요한 프로세스를 가진 로직의 안정성과 신뢰성을 보장할 수 있습니다.

  1. 성능 최적화: 적절한 격리 수준 설정으로 데이터베이스 성능을 크게 향상시킬 수 있습니다.
  2. 데이터 일관성 보장: 비즈니스 요구사항에 맞는 데이터 정확성을 유지할 수 있습니다.
  3. 문제 해결 능력 향상: 데이터 불일치나 성능 저하의 근본 원인을 파악하고 해결할 수 있습니다.

➕ 실제로 트랜잭션 격리 수준의 필요성은 얼마나 될까?

위의 개념으로 이해하면, 무조건 적인 장점 뿐인 것 같지만, ACID 원칙을 완벽히 준수하게되면 동시성 처리 성능이 크게 저하될 수 있습니다.
이에 데이터베이스 시스템은 격리 수준(Isolation Level)이라는 개념을 도입하여 일관성과 동시성 사이의 균형을 조절합니다.
Isolation Level을 조정함으로써 ACID 원칙 중 일부를 완화하여 동시성을 높일 수 있습니다.
이때 Consistent Read를 통해 DB의 Snapshot을 활용하여 특정 시점의 데이터를 읽을 수 있게 됩니다.
각 isolation level에 대해서 정리하기 전, DB 엔진 단에서의 ACID와 동시성 보장을 위해 locking 전략을 사용하는데 그 부분부터 이해하고 넘어가도록 하겠습니다.



2️⃣ 데이터베이스 락(Lock)의 이해

격리 수준을 이해하기 위해서는 먼저 데이터베이스 락에 대한 이해가 필요합니다.
데이터베이스 락은 동시성 제어를 위한 핵심 메커니즘입니다.
여러 트랜잭션이 동시에 데이터에 접근할 때 데이터의 일관성과 무결성을 보장하기 위해 사용됩니다.

이번엔 잠금 수준을 모두 설명하기 보다는 큰 맥락에서의 정리만 하고 넘어가면 좋을 것 같아, 간단하게 정리해보도록 하겠습니다.
잠금 범위는 큰 맥락에서 Exclusive Lock(x Lock)Shared Lock(s Lock)로 구분됩니다.
가장 기본적인 수준의 Lock인데 보통 Table의 Row Level ~ Record 단위에서 걸리는 Lock 입니다.

  • Shared lock(S lock)Data read에 대한 lock 입니다.
    일반적으로 사용하는 SELECT 쿼리는 lock을 사용하지 않는데, SELECT ... FOR SHARE 등 특정 SELECT 쿼리는 read 작업을 수행할 때 각 row에 S lock을 걸게 됩니다.

  • Exclusive lock(X lock)write에 대한 lock 입니다.
    SELECT ... FOR UPDATEUPDATE, DELETE등의 수정과 관련된 쿼리를 날릴 때 row에 걸리는 lock 입니다.

한눈에 정리하면 아래와 같이 정리가 됩니다.

1. 공유 락(Shared Lock, S Lock)

  • 목적: 데이터 읽기 작업에 사용
  • 특징: 여러 트랜잭션이 동시에 획득 가능
  • 예시:
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
  • Shared lock(S lock)Data read에 대한 lock 입니다.
    일반적으로 사용하는 SELECT 쿼리는 lock을 사용하지 않는데, SELECT ... FOR SHARE 등 특정 SELECT 쿼리는 read 작업을 수행할 때 각 row에 S lock을 걸게 됩니다.

2. 배타적 락(Exclusive Lock, X Lock)

  • 목적: 데이터 쓰기 작업에 사용
  • 특징: 한 번에 하나의 트랜잭션만 획득 가능
  • 예시:
SELECT * FROM users WHERE id = 1 FOR UPDATE;
  • Exclusive lock(X lock)write에 대한 lock 입니다.
    SELECT ... FOR UPDATEUPDATE, DELETE등의 수정과 관련된 쿼리를 날릴 때 row에 걸리는 lock 입니다.

➕ 락의 잠금 범위

  • 글로벌 락(Global Lock): 전체 데이터베이스에 적용
  • 데이터베이스 락(Database Lock): 특정 데이터베이스에 적용
  • 테이블 락(Table Lock): 특정 테이블에 적용
  • 행 락(Row Lock): 특정 행(레코드)에 적용

➕ 여기서 궁금한 점!! S lock과 X lock에 규칙이 있을까요?

위에 설명한 두 가지 종류의 lock에는 아래와 같은 규칙들이 존재합니다.

    1. S Lock 간 호환성 : 여러 Transaction이 동시에 한 행에 S lock을 걸 수 있습니다. 즉, 여러 Transaction이 동시에 하나의 Row를 읽는 것을 보장합니다.
    • 예: 여러 사용자가 동시에 같은 제품 정보를 조회할 수 있음


    1. S Lock과 X Lock의 비호환성: 이미 S lock이 걸려있는 행은 다른 Transaction에서 X lock을 걸 수 없습니다.
      즉, 다른 Transaction이 읽고 있는 데이터를 수정하거나 삭제 할 수 없음을 보장합니다.
    • 예: 한 사용자가 제품 정보를 조회 중일 때 다른 사용자가 해당 제품 정보를 수정할 수 없음


    1. X Lock의 배타성 : X lock이 걸려있는 행에는 다른 Transaction에서 S,X lock 둘다 걸 수 없습니다.
      즉, 다른 Transaction이 수정하고 있는 행에는 다른 동작을 할 수 없음을 보장합니다.
    • 예: 한 사용자가 제품 가격을 수정 중일 때 다른 사용자는 해당 제품 정보를 조회하거나 수정할 수 없음

요약하자면, Data를 Read는 여러 Transaction에서 수행이 가능하나, 수정과 관련된 Transaction이 동작 중 이라면 어떤 쿼리도 접근이 불가능합니다.


➕ 실제 예시를 통해서 락에 대한 개념을 확고하게 잡아보겠습니다.

예시 시나리오: 제가 온라인 쇼핑몰의 재고 관리 데이터를 담당한다고 가정하겠습니다.


  • 현재 재고를 확인하는 경우 (S Lock)
START TRANSACTION;
SELECT stock FROM products WHERE id = 1 LOCK IN SHARE MODE;
-- 다른 트랜잭션도 동시에 재고 확인 가능
COMMIT;

  • 현재 재고를 업데이트 하는 경우 (X Lock)
START TRANSACTION;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
UPDATE products SET stock = stock - 1 WHERE id = 1;
-- 다른 트랜잭션은 이 행에 접근 불가
COMMIT;

  • 데드락이 발생하는 상황
-- 트랜잭션 A
START TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE id = 1;
UPDATE products SET stock = stock - 1 WHERE id = 2;

-- 트랜잭션 B (동시에 실행)
START TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE id = 2;
UPDATE products SET stock = stock - 1 WHERE id = 1;
  • 이 경우, 트랜잭션 A와 B가 서로 상대방이 잡고 있는 락을 기다리며 데드락 상태에 빠질 수 있습니다.


➕ Locking으로 동시성을 제어하는 메커니즘의 문제점

참고로 획득한 락을 해제할 수 있는 방법은 COMMITROLLBACK 밖에 없습니다.
이러한 방식의 일반적인 Locking으로 동시성을 제어하는 메커니즘은 구현이 간단한 반면에 아래와 같은 문제점을 가지고 있습니다.

  • 데이터 베이스에 접근하는 읽기 작업과 쓰기 작업이 서로 방해를 일으키기 때문에 동시성 문제가 발생합니다.
  • 결과론적으로는, 데이터 일관성에 문제가 생기는 경우도 있어 Lock을 더 오래 유지하거나 테이블 레벨의 Lock을 사용해야 하고 최종적으로는 동시성 저하가 발생합니다.


➕ MVCC(Multi-Version Concurrency Control)

위에서 Locking 매커니즘으로 발생하는 문제점들을 해결하기 위해 동시 접근을 허용하는 데이터베이스에서 동시성을 제어하기 위해 사용하는 방법 입니다.

MVCC원본의 데이터와 변경중인 데이터를 동시에 유지하는 방식으로, 원본 데이터에 대한 Snapshot을 백업하여 보관하는 로직으로 동작합니다.
하나의 원본 데이터에서 동일한 트랜잭션 요청들을 Locking해 동시성을 제어하는 형태에서, 특정 시점 기반의 추가적인 Snapshot 데이터 저장하고 활용해서
과거, 현재 두 가지 버전의 데이터가 모두 존재하는 환경에서, 새로운 사용자가 데이터에 접근하면 데이터베이스의 Snapshot을 읽는 형태로 작동합니다.
만약 변경이 취소되면 원본 Snapshot을 기준으로 데이터를 복구하고, 변경이 완료되면 최종적으로 디스크에 반영하며 또 새로운 Snapshot을 생성하는 방식으로 동작합니다.

결론적으로 MVCCSnapshot 사용해 동시성을 지원하고, 기존의 데이터를 덮어 씌우는 방식에서 -> 기존 데이터 기준으로 데이터 비교를 통해 변경된 내용을 기록합니다.
최종적으로는 하나의 원본 데이터에서 파생된 여러 시점의 데이터가 존재하게 되고, 사용자는 최종적으로 모든 트랜잭션이 반영된 마지막 버전의 데이터를 읽게 됩니다.
이러한 구조를 지닌 MVCC의 특징은 부연 설명과 함께 아래와 같이 정리가 가능합니다.
MVCC에서는 데이터를 읽기 시작할 때, 누가 그 데이터를 삭제하거나 수정해도 영향을 받지 않고 데이터를 사용할 수 있습니다, 대신 사용하지 않는 데이터가 계속 쌓이게 되므로 더 이상 사용하지 않는 잉여 데이터를 정리하는 시스템이 필요하겠죠. 또한, MVCC에서는 하나의 데이터에서 파생된 여러 버전의 데이터를 허용하기에, 데이터 버전 충돌이 일어날 수 있습니다. 때문에 애플리케이션 영역에서 충돌 여부를 확인하는 등의 문제를 해결해야 합니다.  

간단하게 요약하자면 아래 3가지로 요약 가능합니다.

  • 일반적인 lock 방식의 매커지늠을 사용하는 RDBMS보다 매우 빠르게 작동합니다.
  • 다만, 사용하지 않는 Snapshot 데이터가 지속적으로 쌓이게 되므로 잉여 데이터를 정리하는 시스템이 필요합니다.
  • 또한, 데이터 버전이 충돌하면 애플리케이션 영역에서 이러한 문제를 해결해야 합니다.


[MySQL의 MVCC(Multi-Version Concurrency Control)]

우리가 제일 흔하게 사용하는 MySQL이 InnoDB에서는 Undo Log를 활용해 MVCC를 지원합니다.
쉬운 이해를 위해서 아래 테이블을 실행하고, 데이터를 삽입하는 쿼리가 동작했다고 가정해보겠습니다.

# 테이블 생성
CREATE TABLE member (
    id INT NOT NULL,
    name VARCHAR(20) NOT NULL,
    job VARCHAR(50) NOT NULL,
    PRIMARY KEY(id),
    INDEX idx_job(job)
);

# 데이터 삽입
INSERT INTO member(id, name, area) VALUES (1, "nasa1515", "DE");




그럼 데이터는 아래와 같이 메모리와 디스크에 모두 동일하게 저장됩니다.


UPDATE member SET job = "developer" WHERE id = 1;

그리고 다음과 같은 UPDATE 명령을 날리는 경우.

UPDATE 문이 실행된 결과는 위의 그림과 같습니다.
먼저 COMMIT 실행 여부와 무관하게 InnoDB 버퍼에는 Update 된 값으로 갱신됩니다.
이후 Undo 로그 에는 변경 전의 과거 값들만 복사됩니다, 그리고 InnoDB 버퍼의 내용은 back 쓰레드를 통해 디스크에 기록되는데.
디스크에도 반영되었는지 여부는 시점과, 아래 설명할 격리 수준에 따라 다를 수 있어서 ?로 표시합니다.

이제, 현재의 환경에서 commit or rollback이 호출되지 않은 상태에서 다른 사용자가 아래 쿼리로 데이터를 조회하면 어떤 데이터가 반환될까요?

SELECT * FROM member WHERE id = 1;

그 결과는 아래 설명해야 할 트랜잭션의 격리 수준(Isolation Level)에 따라 달라지게 됩니다. 만약 commit 되지 않은 내용도 조회하도록 해주는 READ_UNCOMMITTED 이라면 Buffer의 데이터를 읽어서 반환하고, 만약 READ_COMMITED 이나 그 이상 격리 수준(REPEATABLE_READ, SERIALIZABLE)이라면 변경 이전의 Undo 로그 영역의 데이터를 반환하게 됩니다. 이 환경에서, Undo Log 영역의 데이터commit 혹은 rollback 을 호출하여 InnoDB buffer도 이전의 데이터로 복구되고, 더 이상 undo 영역의 데이터를 필요로 하는 트랜잭션이 없을 때 삭제됩니다.

이제, 트랜잭션 격리 수준(Transaction Is 때 아직 commit 되지 않은 데이터를 읽어올 수 있습니다.. 예를 들어, 다음과 상황 일어날 수 있습니다.

예시:

sqlolation Level)을 이해하기 위해, Lock 부터 시작해서, DataBase의 동시성에 대한 개념을 모두 이해했습니다.
DataBase에서의 사용자 및 관리자들의 행동 (Transaction)으로 인해 해당 데이터를 어떤 식으로 반영하는지에 대한 로직까지..
이제 이어서 자세한 Isolation Level을 정리 해보겠습니다.


3️⃣ 트랜잭션 격리 수준 (Transaction Isolation Level)


🟢 0. READ UNCOMMITTED (읽기 미확정)

  • 가장 낮은 격리 수준 입니다.
  • 커밋되지 않은 데이터도 읽을 수 있습니다.
  • Dirty Read 문제 발생 가능 합니다.
READ UNCOMMITTED은 `SELECT 쿼리를 실행.
-- Transaction A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = nasa1515;
-- 이 시점에서 Transaction B가 조회

-- Transaction B
SELECT balance FROM accounts WHERE id = nasa1515;
-- Transaction A가 롤백되어도 변경된 값을 읽게 됨 (Dirty Read)

-- Transaction A
ROLLBACK;
  • Transaction (A) 에서 nasa1515 row를 삽입.
  • READ UNCOMMITTED transaction (B)가 nasa1515 row를 읽어서 가져간다.
  • Transaction (A)가 rollback되서 nasa1515 row가 사라진다.

위와 같은 경우를 Dirty Read라고 하는데, (2) Transaction은 실제로 DB에 한번도 Commit 되지 않은 (존재한 적 없는) 데이터를 읽은 것입니다.


🟢 1. READ COMMITTED (읽기 확정)

commit이 완료된 데이터만 확인되는 수준의 isolation을 보장합니다.
간단하게, read operation (데이터를 읽는) 동작이 완료될 때 마다 그 순간의 DB의 snapshot을 다시 뜨고 저장합니다.
그 덕분에 데이터를 조회하는 모든 쿼리 결과가 commit 된 이후의 일관된 데이터 결과를 읽을 수 있습니다.

저의 경우에는 왜? 이런 당연한 상황에 대한 Level이 있는거지?? 라는 의구심이 들었었는데.

  • (Uncommited Read)을 통해서 설명이 가능합니다.

이유를 요약하자면, 실제 DB에는 아직 Commit 되지 않은 경우의 쿼리도 적용된 상태이기 때문입니다.
따라서 1단계 Level에서는 여러 Read 동작 과정 중에 Commit 된 데이터만을 읽어오기 위해서 Commit 되지 않은 데이터들을 솎아 내는 과정이 필요하게 되고,
이 과정에서 Consistent read를 수행해야합니다. 간단하게 요약하면 아래와 같이 정리가 가능하겠네요.

  • 커밋된 데이터만 읽을 수 있음
  • 매 SELECT 마다 스냅샷을 새로 생성
  • Non-Repeatable Read 문제 발생 가능

특징:

  • 한 Transaction에서 Read 할 때 마다 Snapshot 스냅샷을 새로 생성, 때문에 커밋 후의 데이터 결과만을 읽을 수 있습니다.
  • Unrepeatable Read: 한 트랜잭션의 SELECT 결과가 동일하지 않을 수 있음, 두번의 Select에서 그 사이의 다른 트랜잭션의 Commit으로 데이터 변화 있다면 결과가 다를 수 있습니다.

예시:

-- Transaction A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 1000원 조회
-- 이 시점에서 Transaction B가 업데이트 및 커밋
SELECT balance FROM accounts WHERE id = 1; -- 900원 조회 (Non-Repeatable Read)
COMMIT;

-- Transaction B
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
  • Transaction (A) 에서 id = 1에 해당하는 row를 확인.
  • Transaction (B) 에서 동일 시점에서 id = 1에 해당하는 값을 Update
  • Transaction (A) 에서 다시 값을 조회하면, commit이 완료된 값으로 반환.


🟢 2. Repeatable Read (반복 읽기)

반복해서 여러번 read operation을 수행해도, 항상 동일 값을 읽는 것을 보장하는 level 입니다.

처음으로 read(SELECT) operation 시점의 Transaction ID를 기록해놓고, 이후 read operation 마다 해당 시점을 기준의 데이터를 읽어오는 consistent read를 수행합니다.
결과적으론 다른 transaction의 결과가 commit 되더라도 새롭게 commit 된 데이터는 조회할 수 없습니다. (처음 read 기준의 snapshot을 뜨고 이후는 뜨지 않기 때문입니다.)
이 특성을 간단하게 요약하면 아래와 같이 정리가 가능할 것 같습니다.

  • Transaction의 처음 Read Operation 기준으로 Snapsht을 뜨고 그 뒤의 Snapshot은 뜨지 않는다. (일관성 보장)
  • 일관성을 보장하지만, 다른 Transaction의 결과를 무시하기 때문에, 현재의 데이터가 아닌 과거의 데이터 결과를 반환할 가능성이 높습니다. (Phantom Read)
  • 일관성을 보장하기 위해서, 한 Transaction에서 SELECT를 실행 시 대상 Record 단에 Shared Lock이 발생합니다.

예시:

-- Transaction A
BEGIN;
SELECT COUNT(*) FROM accounts WHERE balance > 1000; -- 2개 조회
-- 이 시점에서 Transaction B가 새 레코드 삽입 및 커밋
SELECT COUNT(*) FROM accounts WHERE balance > 1000; -- 여전히 2개 조회
UPDATE accounts SET balance = balance + 100 WHERE balance > 1000;
-- 실제로는 3개의 레코드가 업데이트됨 (Phantom Read)
COMMIT;

-- Transaction B
INSERT INTO accounts (id, balance) VALUES (3, 1500);
COMMIT;
  • Transaction (A) 에서 balance > 1000 조건의 조회 쿼리 실행
  • Transaction (B) 에서 조회 시점에, Insert 구문 실행 -> 데이터 추가됨
  • Transaction (A) 에서 업데이트 전, 동일 쿼리 실행 -> 동일한 값 조회.
  • Transaction (A) 에서 데이터 업데이트 -> Transaction (B)에서 추가한 값까지 Update 적용.


🟢 3. Serializable (직렬화 가능)

SERIALIZABLE transaction은 REPEATABLE READ와 동작이 동일 합니다.
다만, SELECT 쿼리가 전부 SELECT ... FOR SHARE로 자동 변환 됩니다.

즉, 여러 Transaction이 동일한 Record에 접근할 수 없게되고, 어떤 데이터의 부정합 문제가 발생하지 않습니다.
다만 Transaction이 Serializable하게 처리되야 하므로 동시성 처리 성능이 떨어지게 됩니다.
아까 위에서도 설명했듯이, 이 경우 Repeatable Read에서 발생하는 몇가지 문제들을 방지할 수 있는데, 아래의 예로 설명을 하겠습니다.

1 (A-1) SELECT state FROM account WHERE id = 1;
2 (B-1) SELECT state FROM account WHERE id = 1;
3 (B-2) UPDATE account SET state = ‘rich’, money = money * 1000 WHERE id = 1;
4 (B-3) COMMIT;
5 (A-2) UPDATE account SET state = ‘rich’, money = money * 1000 WHERE id = 1;
6 (A-3) COMMIT;

우선, 위의 쿼리의 동작을 하나씩 나열해서 설명하면 다음과 같습니다.

  • (A-1)번 SELECT 쿼리가 SELECT … FOR SHARE로 변경되면서 id = 1 인 row에 S lock이 걸리게됩니다.
  • 이후 동일하게 (B-1)번의 SELECT 쿼리도 역시 FOR SHARE로 변경되면서 id = 1인 row에 S lock을 걸게 됩니다.
  • 이 상황에서 transaction A와 B가 각각 2번 UPDATE 쿼리를 실행하려고 하면 해당 row에 X lock을 걸려는 시도를 하게 됩니다.
  • 하지만 이미 해당 row에는 S lock이 걸려있는 상태입니다.
  • 따라서 Transaction 들은 deadlock 상황에 걸리게 됩니다.
  • 결과적으로는 두개의 transaction은 deadlock으로 인해 모두 timeout 실패로 처리될 것이고. Update는 반영되지 않은 채 money 값은 1로 남게됩니다.

➕ Deadlock(데드락)

여기서 Deadlock을 설명하고 넘어가면.
위처럼 A라는 Transaction으로 lock이 걸려있는 Row에, 동시에 B Transaction에서 쓰기를 시도하면 발생하는 현상입니다.
버그가 아니고, 단지 동시성을 보장하기 위해 발생되는 현상일 뿐입니다.
보통의 경우 DeadLock을 방지하기 위해서 Read-Commit 방식의 Level을 사용하는 것으로 권장합니다. 위와 같은 예로 알아봤듯이, SERIALIZABLE Level에서는 데이터의 일관성을 안전하게 보장하지만, 굉장히 간단하게 DEAD LOCK에 걸리게 됩니다. (조심해야 합니다.)
참고로, 위에서 설명한 4가지의 isolation Level은 모두 Auto Commit 기능이 False인 전제에서만 발생합니다.


➕ 그렇다면 Serializable을 사용하지 않았을 때 Update, Delete는 데이터 부정합을 보장하나?

여기서 중요하게 기억해야 하는 부분은, Select 쿼리와는 다르게, Update, Delete 쿼리는 Consistent read의 적용이 불가능하다는 것입니다.
어떤 의미일까요? 만약 같은 Where 조건문으로 데이터를 가져온다고 하더라도, Select 로 읽어온 Row Data를 수정하기 위해 Update쿼리를 실행시키게 되면
우리가 읽어온 Row Data와 실제로 Update가 적용 되는 Row는 서로 상이할 수 있다는 것이죠.

위에선 이런 설명은 없었다고 생각할 수 있지만, 조금 더 자세히 REPEATABLE READ의 예시로 이해해보겠습니다.

  • A Transaction - READ COMMITTED
(1) SELECT COUNT(name) FROM table WHERE name = 'nasa1515';
(2) DELETE FROM table WHERE name = 'nasa1515';
(3) COMMIT;

  • B Transaction - READ COMMITTED
(Transaction B - READ COMMITTED)
(1) INSERT INTO table(name, value) VALUES('nasa1515', 15), ('nasa1515', 16), ('nasa1515', 17);
(2) COMMIT;

(A) 쿼리는 간단하게 where 절로 특정 Name의 Count를 집계한 이후 해당 데이터들을 삭제하는 쿼리입니다.
(B) 쿼리는 새로운 데이터를 넣는 간단한 쿼리입니다.
위의 (A,B) Transaction이 아래와 같이 실행된다는 가정에서 설명을 이어 나가겠습니다.

(A-1) SELECT COUNT(name) FROM table WHERE name = 'nasa1515';
(B-1) INSERT INTO table(name, value) VALUES('nasa1515', 15), ('nasa1515', 16), ('nasa1515', 17);
(B-2) COMMIT;
(A-2) DELETE FROM table WHERE name = 'nasa1515';
(A-3) COMMIT;
  • (A-1)에서 테이블 데이터가 없다면 값은 0으로 나올것입니다.
    이때 실행했던 Select 쿼리는 Serializable와는 다른 Level 이기에 Non-locking으로 동작, Row에 Lock 걸리지 않습니다.
  • 때문에 Transaction B는 자유롭게 동일한 조건에 Data Value의 삽입이 가능하게 됩니다.
  • 따라서 가장 (A-2)에서는 분명히 초기에 조회했던 nasa1515 User는 0명이었지만, (B-1)에서 새로 삽입된 3개의 Row Data가 삭제되게 됩니다.

만약 위처럼 consistent read에서는 확인할 수 없는 데이터에 UPDATEDELETE의 영향을 받은 경우.
해당 시점 이후로는 해당 row가 transaction에서 보이기 시작합니다.


➕ 결과론적으로는 Isolation Level에 대한 내용을 정리하면 다음과 같습니다.

  • READ UNCOMMITTED: 데이터 부정합 문제가 심각하게 발생할 수 있어 실제 사용은 권장되지 않습니다.
  • READ COMMITTED: 일반적인 웹 애플리케이션에서 많이 사용됩니다.
  • REPEATABLE READ: 데이터 일관성이 중요한 금융 거래 등에서 사용됩니다.
  • SERIALIZABLE: 매우 중요한 데이터를 다루는 경우에만 제한적으로 사용 (동시성이 상당히 떨어집니다.)
  • 즉, 실제 개발 환경에서는 READ COMMITTED 또는 REPEATABLE READ을 사용 합니다. (Mysql, Oraclel의 기본 격리 레벨이 저 둘 중 하나 입니다.)
    • 가령 예를 들면 읽기 작업은 REPEATABLE READ로, 삽입/수정/삭제할 때는 SERIALIZABLE로 isolation level을 지정하는 방식으로요.


Reference

  • https://mangkyu.tistory.com/53
  • http://www.gurubee.net/lecture/2398

Tags

#Data#DataBase#CS
NASA1515

NASA1515

Data Engineer

Hello I'M Wonseok aka NASA1515

Expertise

Public Cloud
k8s/Docker
Python

Social Media

instagramwebsitelinkedingithub

Related Posts

[DataBase CS] 데이터 정규화와 Star Schema, Snow Flake 모델링
[DataBase CS] 데이터 정규화와 Star Schema, Snow Flake 모델링
2024-07-02
4 min

Topics

CloudDevelop

Social Media