실제로 업무 중에 파이프라인 로직을 개발하며, 데이터 수집 과정에서 Source DB의 transaction 부하 문제가 빈번하게 발생했었는데,
그럴 때 마다, 나는 회사의 다른 DBA가 isolation level의 패턴을 설정을 변경하는 것을 단순히 따라하기만 했지, 근본적으로 왜 그렇게 해야하는지 몰랐다.
그래서 매번 업무를 할 때마다 찝찝함을 남기게 하는 요소가 됐었는데, 이번 기회에 transaction의 isolation level에 대한 근본적인 내용을 정리했다.
트랜잭션(Transaction) : 데이터베이스의 데이터를 조작하는 작업의 단위(unit of work)
입니다.
여럿 블로그에서도 은행의 송금을 예로 많이 설명하는데, 아래 과정을 트랙잭션이라고 합니다. (두 과정중에 하나라도 실패한다면, 아예 데이터의 반영이 되지 않아야 하니까요)
일반적으로 이론상 트랜잭션 = ACID
원칙을 보장해야한다 라고 이해하고 있는데, 조금 더 파고 들어서 ACID의 개념을 송금 시스템을 예시로 정리해보겠습니다.
따라서 DB 엔진에서는 Isolation Level
라고 하는 ACID를 어느정도 포기하면서 동시성을 늘리고자 하는 개념을 제공하고 있습니다.
위에서 언급한 Isolation
원칙을 덜 지키는 Level을 사용하면 다른 문제가 발생할 가능성은 높아지지만, 동시에 더 높은 동시성 처리 성능을 얻을 수 있게 됩니다.
이때, Consistent Read
로 DB의 Snapshot
기능을 통해서 현재 값이 아닌 특정 시점의 값을 읽을 수 있기 때문에, Level에 따라 위에서 설명한 각기 다른 성질을 보장하게 됩니다.
각 isolation level에 대해서 정리하기 전, DB 엔진 단에서의 ACID와 동시성 보장을 위해 locking 전략을 사용하는데 그 부분부터 이해하고 넘어가도록 하겠습니다.
우선 DB의 잠금 범위는 Global Lock
, DataBase
, Table
, Record(Row-level)
등으로 나눌 수 있습니다.
Transaction 포스트에서 잠금 수준을 모두 설명하기 보다는 큰 맥락에서의 정리만 하고 넘어가면 좋을 것 같아, 간단하게 정리해보도록 하겠습니다.
가장 기본적인 수준의 Lock인데 보통 Table의 Row Level ~ Record 단위에서 걸리는 Lock 입니다.
Shared lock(S lock)
은 Data read
에 대한 lock 입니다.SELECT ... FOR SHARE
등 특정 SELECT 쿼리는 read 작업을 수행할 때 각 row에 S lock을 걸게 됩니다.Exclusive lock(X lock)
은 write
에 대한 lock 입니다.SELECT ... FOR UPDATE
나 UPDATE, DELETE
등의 수정과 관련된 쿼리를 날릴 때 row에 걸리는 lock 입니다.보통 두 가지 종류의 lock에는 아래와 같은 규칙들이 존재합니다.
요약하자면, Data를 Read는 여러 Transaction에서 수행이 가능하나, 수정과 관련된 Transaction이 동작중인라면 어떤 쿼리도 접근이 불가능합니다.
READ UNCOMMITTED은 SELECT 쿼리를 실행할 때 아직 commit 되지 않은 데이터를 읽어올 수 있습니다.
. 예를 들어, 다음과 상황 일어날 수 있습니다.
위와 같은 경우를 Dirty Read
라고 하는데, (2) Transaction은 실제로 DB에 한번도 Commit 되지 않은 (존재한 적 없는) 데이터를 읽은 것입니다.
READ COMMITTED은 commit이 완료된 데이터만 확인되는 수준
의 isolation을 보장하는 level 입니다.
동작을 간단하게 설명하면, read operation 동작이 완료되었을 때 마다 DB의 snapshot을 다시 뜨고 저장합니다.
그 덕분에 모든 쿼리 결과가 commit 된 이후의 데이터 결과를 읽을 수 있습니다.
왜? 이런 당연한 상황에 대한 Level이 있는거지?? 라는 의구심이 들거나, Select 쿼리마다 Snapshot을 다시 뜨고 저장을 왜 하는거야?? 라고 생각이 드는데.
이 경우는 위의 (Uncommited Read)
로 설명이 가능합니다. 즉, 실제 DB에는 아직 Commit 되지 않은 경우의 쿼리도 적용된 상태이기 때문입니다.
따라서 1단계 Level에서는 여러 Read 동작 과정 중에 Commit 된 데이터만을 읽어오기 위해서 Commit 되지 않은 데이터들을 솎아 내는 과정이 필요하게 되고,
이 과정에서 Consistent read
를 수행해야합니다. 간단하게 요약하면 아래와 같이 정리가 가능하겠네요.
o
반복해서 여러번 read operation을 수행해도, 항상 동일 값을 읽는 것을 보장하는 level 입니다.
처음으로 read(SELECT) operation
시점의 Transaction ID를 기록
해놓고, 그 이후 read operation 마다 해당 시점을 기준의 데이터를 읽어오는 consistent read
를 수행합니다.
결과적으론 다른 transaction의 결과가 commit 되더라도 새롭게 commit 된 데이터는 조회할 수 없습니다. (처음 read 기준의 snapshot을 뜨고 이후는 뜨지 않기 때문)
이 특성을 간단하게 요약하면 아래와 같이 정리가 가능할 것 같습니다.
Shared Lock
이 발생합니다. 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;
(출처 : Ditto 님 블로그)
우선, 위의 쿼리의 동작을 하나씩 나열해서 설명하면 다음과 같습니다.
(A-1)번
SELECT 쿼리가 SELECT … FOR SHARE로 변경되면서 id = 1
인 row에 S lock이 걸리게됩니다. (B-1)
번의 SELECT 쿼리도 역시 FOR SHARE로 변경되면서 id = 1인 row에 S lock을 걸게 됩니다. 2번 UPDATE 쿼리
를 실행하려고 하면 해당 row에 X lock
을 걸려는 시도를 하게 됩니다. 여기서 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
의 예시로 이해해보겠습니다.
(1) SELECT COUNT(name) FROM table WHERE name = 'nasa1515'; (2) DELETE FROM table WHERE name = 'nasa1515'; (3) COMMIT;
(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 걸리지 않습니다.Data Value
의 삽입이 가능하게 됩니다. (A-2)
에서는 분명히 초기에 조회했던 nasa1515
User는 0명이었지만, (B-1)
에서 새로 삽입된 3개의 Row Data가 삭제되게 됩니다.만약 위처럼 consistent read
에서는 확인할 수 없는 데이터에 UPDATE
와 DELETE
의 영향을 받은 경우, 해당 시점 이후로는 해당 row가 transaction에서 보이기 시작합니다.
Isolation Level
에 대한 내용을 정리하면 다음과 같습니다.READ COMMITTED
또는 REPEATABLE READ
를 사용이 효율적입니다. (보통 Mysql, Oraclel의 기본 격리 레벨이 둘입니다.) REPEATABLE READ
로, row를 삽입/수정/삭제할 때는 SERIALIZABLE
로 isolation level을 지정하는 방식으로요.