![[MySQL] MySQL 동시성 처리(2) - 트랜잭션의 고립성 보장을 위한 격리 수준과 MVCC](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtiBRM%2Fbtr5NyqGTms%2FdrDh1sX0T0HrhinI5hCHZK%2Fimg.png)
목표
MySQL의 동시성 처리를 위한 격리 수준과 MVCC에 대해서 알아보겠습니다.
개요
이전 포스팅에서 DB의 Lock에 대해서 알아보았습니다.
[MySQL] MySQL 동시성 처리(1) - LOCK
목표 MySQL의 동시성 처리를 위한 Lock에 대해서 알아보겠습니다. 개요 최근 프로젝트를 진행하면서 배포를 앞에 두고 가장 큰 관심사는 동시성 처리입니다. 사용자가 늘어날수록 동시 요청이 많
chanos.tistory.com
이어서 트랜잭션의 고립성 보장을 위한 격리 수준과 MVCC에 대해서 알아보도록 하겠습니다.
트랜잭션 격리 수준
트랜잭션은 작업 단위를 뜻합니다. DB의 값을 업데이트하거나 조회하는 등의 작업의 범위를 지정하는 것입니다.
이렇게 작업 단위를 지정하는 이유는 트랜잭션이 ACID를 보장한다는 특성을 갖고 있기 때문입니다.
ACID란 다음과 같은 4가지 성질을 의미합니다.
Atomicity (원자성) | 트랜잭션 내에서 실행한 작업들은 하나의 작업인 것처럼 모두 성공하거나 실패해야 한다. |
Consistency (일관성) | 모든 트랜잭션은 일관성 있는 DB 상태를 유지해야 한다. (무결성 제약 조건, 외래키 제약 조건 등) |
Isolation (고립성) | 동시에 실행되는 트랜잭션들이 서로 영향을 미치지 않아야 한다. |
Durability (지속성) | 트랜잭션이 끝나면 결과가 영원히 반영되고, 기록되어야 한다. |
롤백, 트리거, 로그 등을 통해 DB는 ACID를 보장하고 있습니다. 이를 통해 데이터 정합성을 보장할 수 있습니다.
여기서 눈여겨봐야 할 점은 Isolation(고립성)입니다.
트랜잭션을 하나씩 처리하면 고립성을 완벽히 보장할 수 있지만, 성능이 크게 떨어지게 됩니다.
이에, ANSI 표준은 격리 수준을 4단계로 나누었습니다.
READ UNCOMMITTED(커밋되지 않은 데이터 읽기)READ COMMITTED(커밋된 데이터 읽기)REPEATABLE READ(반복된 데이터 읽기)SERIALIZABLE(직렬화 가능)
READ UNCOMMITTED부터 SERIALIZABLE까지 격리 수준이 높아지는 순서로 나열한 것입니다.
격리 수준이 낮을수록 동시성은 증가하지만, 다양한 문제가 발생할 수 있습니다.
격리 수준에 따라 발생할 수 있는 문제는 다음과 같습니다.
DIRTY READNON-REPEATABLE READPHANTOM READ
격리 수준이 낮을 수록 많은 문제가 발생하는데, 이를 표로 정리하면 다음과 같습니다.
격리 수준\문제 | DIRTY READ | NON-REPEATABLE READ | PHANTOM READ |
READ UNCOMMITTED | O | O | O |
READ COMMITTED | O | O | |
REPEATABLE READ | O | ||
SERIALIZABLE |
격리 수준 별 구체적인 내용을 확인하면서 동시성을 처리하는 방법과 문제가 발생하는 이유를 확인해 보겠습니다.
0.READ UNCOMMITTED
가장 낮은 격리 수준으로, 커밋하지 않은 데이터를 읽을 수 있습니다.
만약, 트랜잭션 A가 데이터를 추가하고 트랜잭션 B가 이를 조회한다고 가정해 보겠습니다.
트랜잭션 A를 처리하는 중 에러가 발생해 롤백해 버린다면, 트랜잭션 B는 올바르지 않은 데이터를 가져가는 것입니다.
위와 같이, 남자가 AGE를 30으로 수정하다 에러가 발생해서 롤백했습니다.
이 와중에 여자가 데이터를 조회하게 되면 테이블에 반영되어 있는 데이터를 그대로 가져옵니다.
이렇게 작업이 완료되지 않은 데이터(커밋이 되지 않은)를 조회하는 것을 DRITY READ라고 합니다.
에러가 발생해서 롤백되는 순간 데이터 정합성에 심각한 문제가 발생하는 것을 확인할 수 있습니다.
DIRTY READ가 허용되는 단계는 READ UNCOMMITED 단계뿐입니다.
1.READ COMMITED
DIRTY READ를 해결하기 위해, 격리 수준을 커밋된 데이터만 읽을 수 있도록 올린 단계입니다.
위 그림과 같은 상황에서 남자가 AGE를 30으로 수정해도, 커밋이 되지 않았기 때문에 여자는 0건의 데이터가 조회됩니다.
하지만, 여자의 트랜잭션이 진행되고 있을 때, 남자의 수정 쿼리가 커밋이 된다면 똑같은 쿼리를 사용하면 데이터를 얻을 수 있을 겁니다.
이렇게 한 트랜잭션 내에서 동일한 조회 쿼리를 사용했을 때, 같은 결과를 보장하지 않는 NON-REPEATABLE READ 문제가 발생합니다.
위 그림과 같이, 여자는 한 트랜잭션 내에서 동일한 쿼리를 사용했으나 다른 결과를 얻는 문제가 발생합니다.
이는 남자의 트랜잭션이 커밋되면서 수정된 데이터를 조회했기 때문입니다.
이렇게 DIRTY READ는 허용하지 않지만, NON-REPEATABLE READ는 허용하는 격리 수준이 READ COMMITTED입니다.
2.REPEATABLE READ
한 트랜잭션 내에서 조회한 데이터는 반복해서 조회해도 동일한 결과를 얻을 수 있는 격리 수준입니다.
이것이 가능한 이유는 UNDO 영역이 존재하기 때문입니다. 데이터를 수정하기 전, 테이블의 스냅샷을 UNDO 영역에 저장합니다.
트랜잭션 번호를 기준으로 번호가 더 낮은 트랜잭션이 들어오면, UNDO 영역에서 조회하게 됩니다.
따라서, 실제 테이블이 변경되어도 기존과 동일한 결과를 얻을 수 있는 것입니다.
위는 REPEATABLE READ 단계에서 NON_REPEATABLE READ를 방지하는 것을 나타낸 그림입니다.
트랜잭션 번호 5번을 가진 남자가 수정 쿼리를 전달하면, 기존 데이터를 UNDO 영역에 스냅샷을 저장합니다.
이후, 트랜잭션 번호 3번을 가진 여자가 AGE가 25 초과인 데이터를 조회하면, UNDO 영역에서 조회하기 때문에 0건의 데이터를 받습니다.
남자가 트랜잭션을 커밋하더라도, 여자는 여전히 UNDO영역에서 조회하기 때문에 동일한 결과를 얻을 수 있습니다.
UNDO를 활용한 REPEATABLE READ에서도 여전히 문제가 발생하는데, 바로 PHANTOM READ입니다.
다음 그림을 통해 예시를 확인해 보겠습니다.
여자는 SELECT ... FOR UPDATE를 사용해 데이터를 조회합니다. 이때, 베타 락(Exclusive Lock, X_LOCK)이 걸리게 됩니다.
해당 ROW에 다른 트랜잭션이 접근할 수 없는 상태에서, 남자가 새로운 데이터를 추가합니다.
이후, 여자가 다시 데이터를 조회하면 이전에 없던 데이터를 추가로 조회하는 것을 확인할 수 있습니다.
마치 유령처럼 이전에 없던 데이터가 생기거나, 있던 데이터가 없어지는 것을 PHANTOM READ라고 합니다.
범위를 조회하는 경우나 집계를 하는 등의 상황에서 자주 발생합니다.
여기서 드는 의문은 왜 UNDO 영역에서 조회하지 않고 테이블에서 데이터를 가져오는가?입니다.
이유는 UNDO 영역에 락을 걸 수 없기 때문입니다. UNDO 영역에 배타 락을 걸 수 없으니, 기존 데이터에 락을 걸고 조회합니다.
테이블 자체에 락이 걸려있지 않으니, 남자는 데이터를 추가할 수 있습니다. 그래서 나중에 여자가 조회하면 새로운 데이터가 조회되는 문제가 발생합니다.
3. SERIALIZABLE
SERIALIZABLE은 제일 높은 격리 수준입니다.
트랜잭션에서 사용하는(읽고 쓰는) 데이터를 다른 트랜잭션에서 접근하지 못하게 막아 문제가 발생하지 않습니다.
접근을 못하니 읽고, 쓰는 것이 아예 불가능합니다.
따라서, DIRTY READ, NON-REPEATABLE READ, PHANTOM READ 모두 발생하지 않습니다.
이름 그대로 아예 직렬로 처리하는 것입니다. 당연히, 동시성 처리에 대한 성능이 급격히 떨어지게 됩니다.
MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)
제가 프로젝트를 진행하면서 주로 사용하는 DB는 MySQL이고, InnoDB 엔진을 사용하고 있습니다.
해당 엔진은 격리 수준 2단계인 REPEATABLE READ를 기본으로 사용합니다.
하지만, REPEATABLE READ에서 발생할 수 있는 PHANTOM READ는 발생하지 않습니다.
이게 가능한 이유는, MVCC라는 방법을 사용하기 때문입니다.
MVCC는 동시 접근을 허용하는 데이터베이스에서 동시성을 처리하기 위한 방법입니다.
MVCC의 동작 원리
- 트랜잭션 A가 5번 행의 데이터를 수정한다.
- 수정 전, 5번 행 데이터의 스냅샷을 생성 → 데이터를
UNDO 로그에 저장 - 커밋 여부와 상관없이 InnoDB 버퍼 풀에 수정된 데이터를 반영한다.
- 이때, 백그라운드 쓰레드를 통해 디스크에 있는 데이터도 수정된다.
- 트랜잭션 B가 5번 행의 데이터를 조회한다.
- 트랜잭션 B는 UNDO 로그에 있는 데이터를 조회한다. (수정 전 데이터를 조회한다.)
- 만약, 트랜잭션 B도 데이터를 수정하려고 하면,
새로운 UNDO 로그를 생성해서 데이터를 수정한다.
위와 같이, 두 개의 트랜잭션이 각각 독립된 버전에서 처리되어 다중 버전 동시성 제어라고 불립니다.
4번을 보면 백그라운드 쓰레드를 통해 버퍼 풀에 데이터를 반영한다고 되어 있습니다.
데이터를 수정하면 저장되는 버퍼 풀은 데이터와 인덱스 정보를 캐싱해 놓는 공간입니다.
디스크에 매 번 조회하거나, 데이터가 바뀔 때마다 디스크에 저장해 잦은 I/O가 발생하게 되면, 성능이 저하됩니다.
이를 방지하기 위해 데이터를 캐싱해 놓고 빠르게 조회하거나, 여러 개를 모아서 한 번에 디스크에 반영해(쓰기 지연) 입력 성능을 높입니다.
MVCC의 장점
동시성을 제어하기 위한 대표적인 방법으로는 락(LOCK)이 있습니다.
락을 사용한다는 것은, 동일한 공간에서 작업을 한다는 뜻이고 이는 동시성 문제가 발생할 수 있어 다음과 같은 문제가 발생할 수 있습니다.
배타 락처럼 다른 락과 공존할 수 없는 락이 걸려있으면 뒤에 온 트랜잭션은 블로킹(대기) 상태에 빠지게 됩니다.
또한, 잘못 사용하면 교착 상태(데드락)에 빠져 성능이 급격하게 떨어지는 상황이 발생할 수 있습니다.
반면, MVCC는 트랜잭션이 독립된 버전으로 관리되기 때문에 락을 사용하지 않아도 된다는 장점이 있습니다.
락을 걸고 푸는 등의 오버헤드도 줄일 수 있고, 교착 상태나 블로킹이 발생하지 않습니다.
즉, 일반적인 RDBMS보다 빠르게 동작하고, 동시성 성능을 높게 가져갈 수 있다는 장점이 있습니다.
MVCC를 사용할 때 고려해야 하는 것
그렇다고 해서 MVCC가 만능인 것은 아닙니다. 데이터에 접근할 때마다 UNDO로그에 스냅샷을 생성하는데요. 이는 관리가 필요합니다.
UNDO로그는 격리 수준에 따라서 값을 읽는 용도로 사용되기도 하지만, 롤백할 때 활용되는 로그입니다.
반대로 말하면, 격리 수준이 READ UNCOMMITED거나, 문제없이 커밋된다면 UNDO 로그는 필요 없다는 뜻입니다.
필요 없는 로그가 계속 쌓이게 된다면, 저장 공간이 불필요하게 쌓이게 됩니다.
만약 100만 건의 데이터를 삭제하는 쿼리를 요청했다고 가정해 보겠습니다.
버퍼 풀과 디스크는 100만 건의 데이터가 삭제되었지만, UNDO 로그에는 100만 건의 데이터가 저장되어 있는 것입니다.
따라서, MySQL에서 불필요한 UNDO 로그를 정리해 주는 시스템이 존재합니다.
또한, 동작 원리에서 확인했던 것처럼 두 트랜잭션이 동시에 한 레코드를 수정했을 때 발생하는 충돌을 어떻게 처리해야 할까요?
수정 내역은 UNDO로그에 기록되어 있어, 누가 먼저 수정했는지에 대한 여부를 알 수 있습니다.
따라서, 늦게 수정한 트랜잭션은 롤백 처리가 될 것입니다. 이러한 예외 상황은 애플리케이션 레이어에서 처리해줘야 할 필요가 있습니다.
정리
동시성 처리를 위한 트랜잭션의 격리 수준과 MVCC에 대해서 알아봤습니다.
특히, ACID의 고립성을 중심으로 알아봤는데요. InnoDB에서 UNDO 로그를 중심으로 동작하는 것을 알 수 있었습니다.
트랜잭션이 동작하고 있을 때, 다른 트랜잭션이 데이터를 조회한다고 해서 무조건 UNDO 로그를 조회하는 것은 아닙니다.
이는 고립 수준에 따라, READ UNCOMMITED면 바로 버퍼 풀을 조회하고, 상위 레벨인 경우 UNDO 로그를 조회하는 것입니다.
락을 비롯해서 격리 수준, UNDO 로그 등 동시성 제어를 위해 다양한 장치가 마련되어 있음을 알 수 있습니다.
이 외에도, 지속성(Durablity)을 위해 활용되는 REDO 로그 등 스토리지 엔진의 구성 요소가 있습니다.
LOCK과 MVCC를 정리하면서 느낀 점은, 트랜잭션을 최대한 짧게 가져가는 것이 좋다는 것입니다.
락의 경우 트랜잭션이 길어질수록, 동시성 성능이 떨어지게 됩니다.
MVCC의 경우 트랜잭션이 길어지면 UNDO 로그에 쌓이는 양이 많아지게 됩니다.
이는, 저장 공간을 많이 차지함과 동시에 필요한 데이터를 찾는데 많은 시간이 필요하게 된다는 점입니다. 동시에 충돌 가능성도 높아집니다.
트랜잭션을 짧게 가져가기 위해, 트랜잭션이 진행되는 로직 사이에 외부 네트워크를 호출하는 것을 지양해야 할 것 같습니다.
또한, JPA를 사용하는 경우 BatchSize를 지정해서 적당량의 데이터만 반영할 수 있도록 하는 것도 방법인 것 같습니다.
Reference
Real MySQL
'개발 > MySQL' 카테고리의 다른 글
[MySQL] MySQL 동시성 처리(1) - LOCK (0) | 2023.03.14 |
---|