트랜잭션을 공부하다 보면, 트랜잭션에 대한 격리 수준을 접하게 되는데 굉장히 당황스러웠던 기억이 난다.
당장 구글링을 통해 트랜잭션 격리 수준에 대해 검색해 보면, 마치 사다리타기 그림과 같은 도식을 흔하게 볼 수 있는데, 현실 세계에서 발생하는 보다 풍부한 예시가 없어 아쉬웠던 차에
멧돼지책에서 여러 좋은 예시들을 찾아보게 되어 해당 내용을 중심으로 포스팅을 해보고자 한다!
트랜잭션은 왜 쓸까?
트랜잭션의 정의를 찾아보면 '데이터베이스에서 하나의 그룹으로 처리되어야 하는 명령문들을 모아 놓은 논리적인 작업 단위' 이다.
쉽게 말하면, DB 상태를 변경하는 작업의 단위 라고 이해하면 된다.
트랜잭션의 가장 큰 장점은 데이터의 정합성을 보장할 수 있다는 점이다.
트랜잭션의 특성을 보장하기 위해 널리 알려진 ACID라는 특성이 존재하는데, 트랜잭션에 성공한 경우에 커밋하여 정합성을 보장하고, 트랜잭션에 실패한 경우에도 안전하게 이전 상태로 되돌릴 수 있다.
완화된 트랜잭션 격리
언뜻 보면 트랜잭션이라는 매커니즘은 완벽한 정합성을 보장해 줄 것 같지만, 그렇지 않다.
오늘날의 애플리케이션은 복잡성이 높기 때문에 여러 개의 트랜잭션이 동시에 발생하는 일이 굉장히 흔하다.
특히 어떤 두 트랜잭션에 대해 동일한 데이터에 접근하여 읽기, 혹은 쓰기 작업을 하게 된다면 동시성 문제가 발생하게 된다.
일반적으로 동시성이라는 것은 기본적으로 타이밍이 따라 주어야 하므로, 테스트로 발견하기 어렵다. DB의 관점에서는 데이터들이 예측 불가능한 수준으로 언제라도 바뀔 수 있다는 사실을 내포하고 있는 것과 같다.
따라서 DB는 트랜잭션 격리를 제공하여 동시성 문제를 감추고자 했다. 격리성을 제공한다면, 동시성이 없는 것 처럼 행동할 수 있기 때문이다. 하지만, 현실적으로 높은 수준의 격리성을 제공한다는 것은 곧 동시 접근에 대한 성능 비용을 포기한다는 것과 같다.
그렇기에 요즘의 애플리케이션은 현실적으로 성능과 격리성 사이에서 타협한 형태인 '완화된 격리성'을 사용하는 경우가 대다수이다.
커밋 후 읽기
가장 기본적인 수준의 트랜잭션 격리이다. 커밋 후 읽기 레벨에서는 딱 두가지의 성질을 보장하면 된다.
- 데이터베이스에서 읽을 때, 커밋된 데이터만 볼 수 있다. (더티 읽기 없음)
- 데이터베이스에서 쓸 때, 커밋된 데이터만 덮어쓰게 된다.(더티 쓰기 없음)
더티 읽기(Dirty Read)
트랜잭션에서 DB에 데이터를 썼지만, 아직 커밋 혹은 Abort가 되지 않은 상태의 데이터가 있다. 이러한 데이터를 볼 수 있다면, 더티 읽기라고 한다.
더티 읽기를 방지한다는 것은, 아래와 같은 상황에서 이점이 있다.
1. 트랜잭션에서 여러 객체를 갱신할 때, 더티 읽기가 있다면 일부는 갱신된 값을, 일부는 갱신되지 않은 값을 읽게 되어 혼란이 가중된다.
2. 만약 롤백될 데이터를 볼 수 있다면, 사용자는 실제로 DB에 절대 커밋되지 않을 데이터를 보게 된다.
더티 쓰기(Dirty Write)
동일 객체에 대해, 다른 트랜잭션에서 동시에 이를 쓰기 작업으로 갱신하는 상황이 있다.
만약 먼저 쓴 내용이 커밋되지 않은 상태로, 나중에 쓴 내용이 커밋된다면
앞선 내용은 커밋 기록이 남지 않은 채 고스란히 유실되게 된다.
이를 방지하기 위해서는 먼저 쓴 트랜잭션이 커밋 혹은 어보트 될 때 까지 이어지는 쓰기 작업을 지연시켜야 한다.
즉 쓰기 잠금(베타락)이 필요한 것이다.
커밋 후 읽기는 어떻게 구현될까?
앞서 언급한 더티 쓰기를 방지하기 위한 쓰기 잠금의 경우, DB의 row 수준에서의 쓰기 잠금이다.
한 트랜잭션에서 쓰기 작업에 대해 베타락을 보유한 경우 다른 트랜잭션은 락을 보유한 트랜잭션이
커밋 혹은 어보트된 후에 락을 획득해 진행할 수 있다.
그렇다면 더티 읽기는 어떻게 막을 수 있을까? 읽기 잠금을 사용하면 될까?
생각해봤지만 무작정 읽기에 대한 락을 사용하는 경우 긴 쓰기 트랜잭션이 진행된다고 가정했을 때,
그 동안 전혀 읽기 작업을 수행할 수 없고, 따라서 응답성이 현저하게 떨어지게 된다.
따라서 쓰기 락을 가진 트랜잭션이 실행되는 동안에는, 해당 데이터를 읽는 다른 트랜잭션의 경우 과거의 값을 읽게 된다.
이후, 해당 작업이 커밋되어야만 갱신된 값을 읽을 수 있다.
이러한 매커니즘을 적용할 경우 읽기 작업에 대해서 무조건적인 락을 보유하지 않아도 되므로, 앞서 언급한 문제를 해결할 수 있다는 점에서 큰 의의가 있다.
스냅숏 격리
하지만 커밋 후 읽기를 보장한다고 해서, 모든 동시성 문제를 해결할 수 있는 것은 아니다.
책에서 소개한 한 예시가 굉장히 좋은 것 같아 같이 살펴보면 좋을 것 같다.
앨리스 은행에 1,000 달러의 저축이 있고 이를 두 계좌에 각각 500달러씩 나눠서 저축해 두었다. 이후 한 계좌 A에서 B로 100달러를 전송하는 트랜잭션을 수행했다. 이때 앨리스가 운이 없어, 트랜잭션이 처리되고 있는 순간에 계좌 잔고를 보게 되면 한 계좌는 입금되기 전 상태(잔고 : $500)를 보고 다른 계좌는 출금이 된 직후(잔고 : $400)를 볼 수도 있다. 즉, 현재 계좌 총액이 $900 처럼 보이는 것이다.
이러한 현상을 non-repeatable read라고 한다. 트랜잭션이 완전 끝난 시점에는 B의 잔고가 정상적으로 $600으로 되어 있을 것이다.
커밋 후 읽기 레벨에서는 해당 현상은 충분히 용인될 수 있다. 출금 트랜잭션은 이미 커밋되었으므로, 실제 커밋된 데이터만 읽었기 때문이다.
스냅숏 격리를 통해 이러한 문제를 해결할 수 있다.
스냅숏 격리란, 각 트랜잭션이 DB의 일관된 스냅숏으로부터 읽는다는 뜻이다. 트랜잭션이 특정 시점에 고정된 DB의 일관된 스냅숏을 본다면, 쿼리가 실행 중인 동시에 데이터가 변경되는 현상을 방지할 수 있다.
스냅숏 격리는 어떻게 구현될까?
커밋 후 읽기 격리와 같이 쓰기 잠금을 가진다. 하지만, 읽을 때에는 아무런 락을 사용하지 않는다.
책에서 해당 내용에 대해서 다음과 같이 말해주는데, 해당 문장이 제일 와닿았던 표현이었다.
'읽는 쪽에서 쓰는 쪽을 결코 차단하지 않고, 쓰는 쪽에서 읽는 쪽을 결코 차단하지 않는다.'
커밋 후 읽기는 query마다 독립적인 스냅숏을 사용하지만, 스냅숏 격리는 전체 트랜잭션에 대해서 동일한 스냅숏을 사용한다고 보면 된다.
스냅숏 격리를 보장하기 위해, 트랜잭션에 대해 고유한 ID값을 부여하게 된다. 이 값은 unique하며, DB의 auto-increment와 같이 계속 증가하는 값을 지닌다.
이러한 ID값을 기준으로, 어떤 트랜잭션을 시작할 때, 해당 시점에 진행 중인(커밋, 어보트 되지 않은) 트랜잭션을 제외하고
현재 ID보다 더 뒤에 위치한 트랜잭션이 쓴 데이터는 커밋 여부에 관계없이 무시할 수 있게 된다.
다시 말해, 어떤 트랜잭션이 읽은 데이터를 다른 트랜잭션이 수정하더라도 항상 동일한 결과를 반환할 것을 보장할 수 있다는 것이 핵심이다.
따라서 이를 Repeatable Read 격리 수준이라고 부르며, 이는 오늘날의 MySQL의 디폴트 격리 수준으로 채택되어 있다.
References
- 데이터 중심 애플리케이션 설계 132p ~ 142p
'CS > Database' 카테고리의 다른 글
Replication - 복제 지연 문제에 대해(일관성과 성능 이야기) (0) | 2024.06.17 |
---|---|
Replication - 리더 기반 복제에 대해 (0) | 2024.06.10 |
[MYSQL] float, double 저장 시, 소수점이 깨지는 문제 (0) | 2023.09.05 |