제 개인적인 견해로 비추어 보았을 때, 데이터베이스 관리는 성능과 데이터 일관성 사이의 끊임없는 균형 잡기의 반복이라고 생각합니다.
이 균형의 핵심 포인트에는 트랜잭션 격리 수준 (Transaction Isolation Level)
이 존재합니다, 실제 업무에서의 경험을 토대로 배운 개념과 저의 여정을 공유하고자 합니다.
처음, 그리고 여러 프로젝트에서 파이프라인 로직 개발 과정 속에서 Source Database의 Transaction 부하 문제에 직면했던 경험이 여럿 차례 있습니다.
그때마다 다른 DBA 분들이 해당 DataBase의 isolation level
설정을 변경/적용하는 것을 단순히 따라 하기만 했지, 왜 그렇게 하는지에 대한 근본적인 이해가 없었습니다.
언젠가는 제가 해야 할 일이 될 거라고 생각을 해서, 미리 근본적인 개념을 습득해야 할 것 같아서, 정리의 필요성을 느꼈습니다.
트랜잭션(Transaction)
은 데이터베이스의 데이터를 조작하는 작업의 단위(unit of work)
입니다.
쉽게 이해하기 위해 예를 들어보자면, 은행 송금 과정은 다음과 같은 트랜잭션으로 구성됩니다:
이 두 과정 중 하나라도 실패하면, 전체 데이터 변경이 취소되어야 합니다.
트랜잭션은 일반적으로 ACID 원칙을 따라야 합니다. 다만, 그냥은 이해하기 어려우니, ACID 원칙을 위의 은행 송금 예시를 통해 설명해보겠습니다.
Atomicity (원자성):
송금 과정에서 A 계좌에서 돈을 인출하고 B 계좌로 입금하는 두 단계가 있습니다.
원자성은 두 단계가 모두 성공하거나 모두 실패해야 함을 의미합니다.
예를 들어, A 계좌에서 돈은 인출됬지만 B 계좌로 입금되지 않은 상태로 트랜잭션이 끝나면 안 됩니다.
Consistency (일관성):
송금 전후로 은행의 총 잔액은 변하지 않아야 합니다.
A 계좌에서 100만원이 빠져나가면 B 계좌에는 정확히 100만원이 들어와야 합니다. 또한, 계좌 잔액이 마이너스가 되는 등의 규칙 위반이 있어서는 안 됩니다.
Isolation (격리성):
여러 송금 트랜잭션이 동시에 실행될 때, 각 트랜잭션은 다른 트랜잭션의 중간 상태를 볼 수 없어야 합니다.
예를 들어, A에서 B로의 송금과 C에서 A로의 송금이 동시에 일어날 때, 두 트랜잭션은 서로의 중간 결과에 영향을 받지 않고 독립적
으로 실행되어야 합니다.
Durability (지속성):
송금이 성공적으로 완료되면, 시스템 장애가 발생하더라도 그 결과는 영구적으로 보존
되어야 합니다.
예를 들어, A에서 B로 송금이 완료된 직후 은행 시스템에 장애가 발생해도, 재시작 후에도 B의 계좌에는 송금된 금액이 그대로 남아있어야 합니다.
위의 예처럼, 각 행동마다 ACID 원칙을 적용함으로써, 은행 송금과 같은 중요한 프로세스를 가진 로직의 안정성과 신뢰성을 보장할 수 있습니다.
위의 개념으로 이해하면, 무조건 적인 장점 뿐인 것 같지만, ACID 원칙을 완벽히 준수하게되면 동시성 처리 성능
이 크게 저하될 수 있습니다.
이에 데이터베이스 시스템은 격리 수준(Isolation Level)
이라는 개념을 도입하여 일관성과 동시성 사이의 균형을 조절합니다.
Isolation Level
을 조정함으로써 ACID 원칙 중 일부를 완화하여 동시성을 높일 수 있습니다.
이때 Consistent Read
를 통해 DB의 Snapshot
을 활용하여 특정 시점의 데이터를 읽을 수 있게 됩니다.
각 isolation level에 대해서 정리하기 전, DB 엔진 단에서의 ACID와 동시성 보장을 위해 locking 전략을 사용하는데 그 부분부터 이해하고 넘어가도록 하겠습니다.
격리 수준을 이해하기 위해서는 먼저 데이터베이스 락에 대한 이해가 필요합니다.
데이터베이스 락은 동시성 제어를 위한 핵심 메커니즘
입니다.
여러 트랜잭션이 동시에 데이터에 접근할 때 데이터의 일관성과 무결성을 보장하기 위해 사용됩니다.
이번엔 잠금 수준을 모두 설명하기 보다는 큰 맥락에서의 정리만 하고 넘어가면 좋을 것 같아, 간단하게 정리해보도록 하겠습니다.
잠금 범위는 큰 맥락에서 Exclusive Lock(x Lock)
과 Shared Lock(s Lock)
로 구분됩니다.
가장 기본적인 수준의 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 입니다.한눈에 정리하면 아래와 같이 정리가 됩니다.
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
Shared lock(S lock)
은 Data read
에 대한 lock 입니다.SELECT ... FOR SHARE
등 특정 SELECT 쿼리는 read 작업을 수행할 때 각 row에 S lock을 걸게 됩니다.SELECT * FROM users WHERE id = 1 FOR UPDATE;
Exclusive lock(X lock)
은 write
에 대한 lock 입니다.SELECT ... FOR UPDATE
나 UPDATE, DELETE
등의 수정과 관련된 쿼리를 날릴 때 row에 걸리는 lock 입니다.위에 설명한 두 가지 종류의 lock에는 아래와 같은 규칙들이 존재합니다.
S Lock 간 호환성
: 여러 Transaction이 동시에 한 행에 S lock을 걸 수 있습니다. 즉, 여러 Transaction이 동시에 하나의 Row를 읽는 것을 보장합니다. 예: 여러 사용자가 동시에 같은 제품 정보를 조회할 수 있음
S Lock과 X Lock의 비호환성
: 이미 S lock이 걸려있는 행은 다른 Transaction에서 X lock을 걸 수 없습니다.예: 한 사용자가 제품 정보를 조회 중일 때 다른 사용자가 해당 제품 정보를 수정할 수 없음
X Lock의 배타성
: X lock이 걸려있는 행에는 다른 Transaction에서 S,X lock 둘다 걸 수 없습니다.요약하자면, Data를 Read는 여러 Transaction에서 수행이 가능하나, 수정과 관련된 Transaction이 동작 중 이라면 어떤 쿼리도 접근이 불가능합니다.
START TRANSACTION; SELECT stock FROM products WHERE id = 1 LOCK IN SHARE MODE; -- 다른 트랜잭션도 동시에 재고 확인 가능 COMMIT;
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;
참고로 획득한 락을 해제할 수 있는 방법은 COMMIT
과 ROLLBACK
밖에 없습니다.
이러한 방식의 일반적인 Locking으로 동시성을 제어하는 메커니즘은 구현이 간단한 반면에 아래와 같은 문제점을 가지고 있습니다.
위에서 Locking 매커니즘
으로 발생하는 문제점들을 해결하기 위해 동시 접근을 허용하는 데이터베이스에서 동시성을 제어하기 위해 사용하는 방법
입니다.
MVCC
는 원본의 데이터와 변경중인 데이터를 동시에 유지하는 방식
으로, 원본 데이터에 대한 Snapshot을 백업하여 보관하는 로직으로 동작합니다.
하나의 원본 데이터에서 동일한 트랜잭션 요청들을 Locking
해 동시성을 제어하는 형태에서, 특정 시점 기반의 추가적인 Snapshot
데이터 저장하고 활용해서
과거, 현재
두 가지 버전의 데이터가 모두 존재하는 환경에서, 새로운 사용자가 데이터에 접근하면 데이터베이스의 Snapshot을 읽는 형태로 작동합니다.
만약 변경이 취소되면 원본 Snapshot을 기준으로 데이터를 복구하고, 변경이 완료되면 최종적으로 디스크에 반영하며 또 새로운 Snapshot
을 생성하는 방식으로 동작합니다.
결론적으로 MVCC
는 Snapshot
사용해 동시성을 지원하고, 기존의 데이터를 덮어 씌우는 방식
에서 -> 기존 데이터 기준으로 데이터 비교
를 통해 변경된 내용을 기록합니다.
최종적으로는 하나의 원본 데이터에서 파생된 여러 시점의 데이터가 존재하게 되고, 사용자는 최종적으로 모든 트랜잭션이 반영된 마지막 버전의 데이터를 읽게 됩니다.
이러한 구조를 지닌 MVCC의 특징은 부연 설명과 함께 아래와 같이 정리가 가능합니다.
MVCC
에서는 데이터를 읽기 시작할 때, 누가 그 데이터를 삭제하거나 수정해도 영향을 받지 않고 데이터를 사용할 수 있습니다, 대신 사용하지 않는 데이터가 계속 쌓이게 되므로 더 이상 사용하지 않는 잉여 데이터를 정리하는 시스템이 필요하겠죠. 또한, MVCC에서는 하나의 데이터에서 파생된 여러 버전의 데이터를 허용하기에, 데이터 버전 충돌이 일어날 수 있습니다. 때문에 애플리케이션 영역에서 충돌 여부를 확인하는 등의 문제를 해결해야 합니다.
간단하게 요약하자면 아래 3가지로 요약 가능합니다.
우리가 제일 흔하게 사용하는 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
을 정리 해보겠습니다.
- 가장 낮은 격리 수준 입니다.
- 커밋되지 않은 데이터도 읽을 수 있습니다.
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;
위와 같은 경우를 Dirty Read
라고 하는데, (2) Transaction은 실제로 DB에 한번도 Commit 되지 않은 (존재한 적 없는) 데이터를 읽은 것입니다.
commit이 완료된 데이터만 확인되는 수준
의 isolation을 보장합니다.
간단하게, read operation(데이터를 읽는)
동작이 완료될 때 마다 그 순간의 DB의snapshot
을 다시 뜨고 저장합니다.
그 덕분에 데이터를 조회하는 모든 쿼리 결과가commit 된 이후의 일관된 데이터 결과
를 읽을 수 있습니다.
저의 경우에는 왜? 이런 당연한 상황에 대한 Level이 있는거지?? 라는 의구심이 들었었는데.
(Uncommited Read)
을 통해서 설명이 가능합니다. 이유를 요약하자면, 실제 DB에는 아직 Commit 되지 않은 경우의 쿼리도 적용된 상태이기 때문입니다.
따라서 1단계 Level에서는 여러 Read 동작 과정 중에 Commit 된 데이터만을 읽어오기 위해서 Commit 되지 않은 데이터들을 솎아 내는 과정이 필요하게 되고,
이 과정에서 Consistent read
를 수행해야합니다. 간단하게 요약하면 아래와 같이 정리가 가능하겠네요.
- 커밋된 데이터만 읽을 수 있음
- 매 SELECT 마다 스냅샷을 새로 생성
- Non-Repeatable Read 문제 발생 가능
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;
id = 1
에 해당하는 row를 확인.id = 1
에 해당하는 값을 Update
반복해서 여러번
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;
balance > 1000
조건의 조회 쿼리 실행Insert
구문 실행 -> 데이터 추가됨
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을 걸게 됩니다. 2번 UPDATE 쿼리
를 실행하려고 하면 해당 row에 X lock
을 걸려는 시도를 하게 됩니다. deadlock
으로 인해 모두 timeout 실패로 처리될 것이고. Update는 반영되지 않은 채 money 값은 1로 남게됩니다.여기서 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 UNCOMMITTED
: 데이터 부정합 문제가 심각하게 발생할 수 있어 실제 사용은 권장되지 않습니다.READ COMMITTED
: 일반적인 웹 애플리케이션에서 많이 사용됩니다.REPEATABLE READ
: 데이터 일관성이 중요한 금융 거래 등에서 사용됩니다.SERIALIZABLE
: 매우 중요한 데이터를 다루는 경우에만 제한적으로 사용 (동시성이 상당히 떨어집니다.)- 즉, 실제 개발 환경에서는
READ COMMITTED
또는REPEATABLE READ
을 사용 합니다. (Mysql, Oraclel의 기본 격리 레벨이 저 둘 중 하나 입니다.)
- 가령 예를 들면 읽기 작업은
REPEATABLE READ
로, 삽입/수정/삭제할 때는SERIALIZABLE
로 isolation level을 지정하는 방식으로요.