이전 글 [ https://techforme.tistory.com/60 ]
이전 글에서는 MVCC 에 대해서 아주 간략하게 알아보았다. MVCC 의 구조, 자세한 동작 방식을 더 알아보면 좋겠지만 "동시성 제어" 라는 매커니즘에 한정해서 하나의 예시만을 들었다. (나중에 제대로 공부하기로 하고...)
이번 글에서는 Lost Update 가 발생하는 상황에서 MVCC 와 Lock 이 각각 어떻게 문제를 해결하는 지 알아보고 각각의 장단점과 함께 "낙관적 락" 과 "비관적 락" 의 개념을 알아 본다.
1. Lost Update
1) Lost Update 란?
Lost Update 란 말 그대로 Update 쿼리가 유실되는 상황을 말합니다. Lost Update 가 일어나는 간단한 상황을 제시하겠습니다.
초기값 : X = 50, Y = 50
Tx 1 : X 가 Y 에 50 을 이체
Tx 2 : X 에 30 을 입금
정상 결과 : X = 30, Y = 100
위 Transaction 이 동시에 요청해온 경우, 아래와 같은 시나리오를 떠올릴 수 있습니다.
Tx 1 : --> Read X = 50 --> Write X = 0 --> Write Y = 100 --> Commit ---------------------------|
Tx 2 : --> Read X = 50 -------------- Wait for lock -----------------> Write X = 80 -- Commit --|
결과 : X = 80, Y = 100
왜 이런 결과가 나타나는 것일까요? X 에는 총 두 번의 Update 가 수행 됩니다. Tx 1 이 먼저 수행된다고 가정하면, Tx 1 에 의해 우선 X 가 (50 -> 0) 로 Update 되고 이후 Tx 2 에 의해 30 을 더하는 Update 를 수행하게 됩니다. 그러나 위 시나리오를 보면 Tx 2 의 업데이트 수행시 Tx 1 의 업데이트 내역이 Tx 2 의 업데이트에 의해 덧씌워지는 형태로 유실되게 됩니다.
문제의 원인을 파악해 보자면 Tx 2 에서 초기에 읽은 X 의 값이 중간에 변경 되었음에도 Tx 2 의 update 작업에 이를 그대로 사용한다는 것입니다. 이처럼 뒤 늦게 수행된 Update 가 앞선 Update 를 덧씌워 버리는 경우 이를 Lost Update 라고 합니다.
2) Lost Update 해결
Lost Update 를 해결하기 위해서는 두가지 방법을 고려할 수 있습니다.
첫번째로 Tx 1 이 X 값을 Read 를 하는 경우, 다른 Transaction 의 접근을 막는 Lock 을 걸어 Tx 1 의 Update 를 덧씌워버리지 못하게 하는 것 입니다. 이는 이전 글에서 다룬 "Lock" 방법이죠. 지난 글에서는 각각 Read Lock / Write Lock 이라고 이야기 했는데, 각각 공유락, Shared Lock / 배타락, Exclusive Lock 이라고 부릅니다. 참고로 Tx 1 이 처음 X 값을 Read 하는 이유는 X 에 값을 Update 하기 위함이므로 Exclusive Lock(Write Lock) 을 거는 것입니다.
두번째로, Lock 을 걸지 않고 연산을 진행하는 대신 Commit 시 Lost Update 가 일어날 것을 대비하여 변경 대상의 이력을 확인하는 방법이 있습니다. 이때 Tx 2 는 마지막 Commit 작업을 제외하고는 일반적인 MVCC 상황처럼 동작합니다. Lock 방식에서 Tx 2 는 X 의 값을 Read 하지 못하고 기다리지만, 이때는 Tx 1 의 값을 Read 합니다.(nonblock read) 그리고 나서, Write 단계에 와서 Tx 1 이 작업을 끝마치기를 기다립니다. (MVCC 에서도 write - write 경우는 상호 배타적으로 동작합니다.) 만약 Tx 1 이 Rollback 된다면 Tx 2 가 Commit 을 하고, Tx 1 이 Commit 된다면 Tx 2 를 Rollback 시킵니다. 이렇게 되면 하나의 Transaction 은 반드시 Rollback 되겠지만 데이터의 정합성은 유지될 것입니다. Transaction 을 Rollback 하는 것이 데이터의 정합성이 깨지는 것 보다는 낫습니다. 실패한 Transaction 은 다시 시도하면 됩니다.
상기 두가지 방법은 각자 장단점이 있습니다. PostgreSQL 은 기본적으로 두번째 방법을 채택하며, MVCC 를 통해 데이터 베이스레벨에서 이를 구현합니다. MySQL 의 경우에는 기본적으로 첫번째 방법을 사용합니다. 이를 특별히 Locking Read 라고 부릅니다.
2. 낙관적 락, 비관적 락
사실 첫번째 방법과 두번째 방법은 따로 지칭하는 용어가 있습니다. 첫번째 방법은 동시성 상황을 고려하여 미리 Lock 을 거는 강수를 두기 때문에 동시성에 대해 다소 비관적인 태도으로 갖는다는 뜻에서 비관적 락(Pessimistic Lock) 이라 부릅니다. 두번째 방법은 동시성 상황을 고려하지 않고, 일단 nonlocking 으로 읽기를 진행하고 추후 충돌을 확인하는 다소 낙관적 태도로 동시성을 제어한다는 뜻에서 낙관적 락(Optimistic Lock) 이라 부릅니다. (낙관적 락의 경우 데이터에 Version 컬럼을 추가하여 어플리케이션 레벨에서 구현해야하는 경우도 있습니다.)
아래 표는 각 방식의 장단점 입니다.
장점 | 단점 | |
비관적 락 | Lost Update 차단 | 락의 경합으로 성능이 저하됨 데드락이 발생할 수 있음 |
낙관적 락 | 동시성 향상(= 성능 향상) | Lost Update 발생 가능 (동시 commit 시) 충돌시 처리 로직 필요 |
3. 결론
Lost Update 문제를 해결하기 위한 낙관적 락과 비관적 락에 대해서 알아보았습니다. 각각은 구현 매커니즘과 장단점이 상이하기 때문에 상황에 따라 적절히 전략을 선택하여야 할 것입니다.
동시성제어 관련한 더 다양한 문제 상황과 격리 매커니즘이 존재하는데, 이번의 포스팅은 여기까지만 하고 나중에 보충하겠습니다.
조금 더 그럴 듯한 내용을 적고 싶어서 공식 문서도 많이 찾아보았는데, 아직 배움의 깊이가 얕은 것 같다.
DB 별 특징(MySQL, PostgreSQL) - 미완, 추후 이어서 작성 예정
아래 내용은, DB 별로 MVCC 를 적용하는 양상이 상이하기 때문에 가장 널리 쓰이는 RDB인 MySQL 과 PostgreSQL 의 Manual 을 읽으며 각 Isolation Level 별로 특징적인 부분을 정리한 것 입니다. 오류가 있을 수 있습니다. 아래는 Transaction 의 Isolation Level 별 작동 방식 Manual 입니다. 정확한 정보는 아래 링크를 읽기 바랍니다.
- MySQL 8.0 Manual : [ https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-transaction-model.html ]
- PostgreSQL 16 Manual : [ https://www.postgresql.org/docs/current/transaction-iso.html#TRANSACTION-ISO ]
1) Read Uncommited
DB | 특징 |
MySQL | nonlocking 방식으로 수행되며 다른 트랜잭션의 Commit 되지 않은 변경사항을 읽을 수 있습니다. Dirty-Read 를 허용합니다. |
PostgreSQL | Read Committed Level 와 비슷하게 동작합니다. (Dirty Read 를 허용하지 않음) |
더 읽기(Reference) - 추후 이어서 작성 예정
MySQL 8.0 메뉴얼의 "Consistent Read"
consistent read
A read operation that uses snapshot information to present query results based on a point in time, regardless of changes performed by other transactions running at the same time. If queried data has been changed by another transaction, the original data is reconstructed based on the contents of the undo log. This technique avoids some of the locking issues that can reduce concurrency by forcing transactions to wait for other transactions to finish. With REPEATABLE READ isolation level, the snapshot is based on the time when the first read operation is performed. With READ COMMITTED isolation level, the snapshot is reset to the time of each consistent read operation. Consistent read is the default mode in which InnoDB processes SELECT statements in READ COMMITTED and REPEATABLE READ isolation levels. Because a consistent read does not set any locks on the tables it accesses, other sessions are free to modify those tables while a consistent read is being performed on the table.
[https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_consistent_read]
PostgreSQL 16 메뉴얼의 "Repeatable Read Isolation Level"
13.2.2. Repeatable Read Isolation Level
The Repeatable Read isolation level only sees data committed before the transaction began; it never sees either uncommitted data or changes committed by concurrent transactions during the transaction's execution. (However, each query does see the effects of previous updates executed within its own transaction, even though they are not yet committed.)
[https://www.postgresql.org/docs/current/transaction-iso.html#XACT-REPEATABLE-READ]
UPDATE, DELETE, MERGE, SELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the transaction start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the repeatable read transaction will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the repeatable read transaction can proceed with updating the originally found row. But if the first updater commits (and actually updated or deleted the row, not just locked it) then the repeatable read transaction will be rolled back with the message
PostgreSQL's Read Uncommitted mode behaves like Read Committed. This is because it is the only sensible way to map the standard isolation levels to PostgreSQL's multiversion concurrency control architecture.