안녕하세요! 10배 더 뛰어난 개발자가 되는 법, 10x 개발자 입니다.
오늘은 기술 면접 단골 질문 이면서 실제 서비스를 개발하려면 모든 개발자가 꼭 알아야 하는 DB 트랜잭션의 격리 수준 에 대해서 그림과 예시로 정말 쉽게 설명해드리고자 합니다!
하나의 글 에 모든 트랜잭션 격리 수준을 다루기는 양이 너무 많아서 밑처럼 1, 2편 으로 나누어 글을 써보고자 합니다.
- 1편: 커밋 후 읽기(Read Committed), 반복 읽기 (Repeatable Read)
- 2편: 갱신 손실과 팬텀, 직렬화(Serializable) 읽기
이 글에선 트랜잭션 격리 수준 중 커밋 후 읽기(Read Committed) 와 반복 읽기(Repeatable Read) 에 대해서 설명 드리겠습니다!
트랜잭션 격리 수준은 왜 필요할까요?
사용자 1, 2가 동일한 계좌에 100원씩 동시에 송금하는 상황을 가정 해 보겠습니다. 이때 트랜잭션 격리 수준이 존재하지 않으면 어떻게 될까요?
위의 사진처럼 사용자 1, 2의 송금 트랜잭션이 동시에 실행된다면, 두명이 100원씩 총 200원을 송금 했음에도 불구하고 계좌의 잔고는 1000원에서 100원 만 증가한 1100원 이 되어있을 수 있습니다. 100원이 증발 한 셈이죠!
우리가 기대한 결과는 위처럼 사용자 1, 2의 송금 트랜잭션이 순차적으로 실행되서 잔고가 1000원 에서 1200원 이 되는 것 이였죠.
위처럼 여러 트랜잭션이 같은 데이터를 동시에 읽고 쓸 때 에는 수 많은 동시성 문제가 발생할 수 있습니다.
트랜잭션 격리 수준은 이러한 동시성 문제를 해결하기 위해 도입 되었으며, “여러 트랜잭션이 동시에 실행됐더라도 그 결과가 트랜잭션이 순차적으로 실행됐을때의 결과와 동일함을 보장한다” 를 의미합니다.
커밋 후 읽기(Read Committed) 격리 수준
가장 기본적인 격리 수준인 커밋 후 읽기 는 아래의 두가지를 보장합니다.
1. DB에서 읽을 때 커밋된 데이터 만 읽는다 (Dirty read가 없음)
2. DB에 쓸 때 커밋된 데이터 만 덮어쓴다 (Dirty write가 없음)
Dirty read를 왜 막아야 할까요?
Ditry read 방지 란 위의 사진처럼 “커밋된 데이터 만 읽는다” 를 의미합니다.
만약 Ditry read를 막지 않는다면 어떤 문제가 발생할 수 있을까요?
메신저 앱 을 예시로 들어보면, 안 읽은 메시지 들이 있고 안 읽은 메시지의 수를 빠르게 표시(안 읽은 메시지 목록을 전부 확인하지 않고) 해주기 위한 카운트가 따로 있음을 알 수 있습니다.
메시지 A가 도착했을때 트랜잭션 으로 “안 읽은 메시지 에 A 를 추가하고, 안 읽은 메시지 수 를 1 증가 시킨다” 를 처리한다고 하면, 위의 사진처럼 중간에 다른 트랜잭션 이 안 읽은 메시지 목록과 수 를 읽으면 “안 읽은 메시지는 A 가 존재하는데, 안 읽은 메시지 수 는 0개” 인 잘못된 결과를 반환 받을 수 있습니다.
이러한 문제의 원인은 아직 커밋 되지 않은 값을 다른 트랜잭션이 읽었기(Dirty read) 때문이죠! 따라서 Dirty read가 없음을 보장해주는 커밋 후 읽기 격리 수준을 사용하면 이러한 문제를 해결할 수 있습니다.
Dirty write를 왜 막아야 할까요?
“커밋되지 않은 데이터도 덮어 쓸 수 있음” 을 의미하는 Ditry write 를 막지 않는다면 어떤 문제가 발생 할 수 있을까요?
어떤 쇼핑몰 에서 사용자 1 이 물건 구매 시 “구매자: 사용자 1, 이메일 수신자: 사용자 1(이메일 발송 배치잡에서 사용)” 트랜잭션 처리가 있다고 예시를 들어보겠습니다.
이때 Dirty write 를 막지 않는다면 위의 사진처럼 Alice, Bob 이 동시에 물건을 구매할 때 “구매자: Bob, 이메일 수신자: Alice” 처럼 구매자와 이메일 수신자가 동일하지 않은 경우가 발생할 수 있습니다.
이러한 문제의 원인은 아직 커밋 되지 않은 값을 다른 트랜잭션이 덮어썼기(Dirty write) 때문이죠! 따라서 Dirty write가 없음을 보장해주는 커밋 후 읽기 격리 수준을 사용하면 이러한 문제를 해결할 수 있습니다.
커밋 후 읽기(Read Committed) 는 만능일까?
커밋 후 읽기 트랜잭션 격리 수준 만 사용하더라도 Dirty read 와 Dirty write 를 모두 막아주므로 “트랜잭션의 미완료 결과 를 읽는걸 방지하고, 동시에 실행되는 쓰기가 섞이는 것을 막아준다” 라고 할 수 있습니다.
실제로 커밋 후 읽기 격리 수준 만 사용해도 많은 동시성 문제를 DB 단에서 해결 할 수 있습니다.
하지만, 커밋 후 읽기 격리 수준으로도 해결할 수 없는 동시성 문제 들이 아직 많이 존재합니다.
위의 사진처럼 “커밋 후 읽기 격리 수준을 사용 중이며, 사용자 1이 계좌 1 에서 계좌 2 로 500원을 송금하는 상황” 을 예시로 들어 보겠습니다.
이때 커밋 후 읽기 격리 수준을 사용 하고 있으므로 사용자 2 는 이미 커밋된 값 만 읽고 덮어 쓸 수 있습니다.
하지만, 위의 사진에서 사용자 2 는 이미 커밋이 완료된 데이터 만 읽었음에도 불구하고, “계좌 1 잔고: 1000원, 계좌 2 잔고: 1500원” 으로 계좌 잔고의 총 합이 2500원 이라는 잘못된 결과를 반환 받았습니다.
정상적인 결과로는 “계좌 1 잔고: 500원, 계좌 2 잔고: 1500원” 으로 잔고의 총 합 2000원 이 그대로 유지되어야 하죠.
이러한 문제를 비반복 읽기 (non-repeatable read) 나 읽기 스큐 (read skew) 라고 합니다.
사용자 2가 조금 뒤 새로고침 하여 다시 계좌들의 잔고를 읽게 되면 사용자 1이 커밋 완료한 “계좌 1 잔고: 500원, 계좌 2 잔고: 1500원” 인 정상적인 결과를 반환 받게 되죠. 반복하여 읽었을 때 다른 결과가 나오므로 “비반복 읽기” 라고 합니다.
커밋 후 읽기 격리 수준만 사용한다면 이러한 비반복 읽기 문제를 해결할 수 없습니다.
위의 예시의 경우 몇 초 후 사용자 2가 새로고침을 하면 다시 정상적인 결과를 반환 받을 가능성이 높으므로 지속적인 문제는 아니며 서비스에 따라서 잠깐 동안의 비정상 응답을 허용할 수 도 있지만, 특정 상황에서는 이러한 일시적인 비정상 응답의 반환조차 허용할 수 없는 상황 도 있습니다.
- 데이터 베이스 백업
데이터 베이스 백업을 하려면 전체 복사본을 만들어야 해서 시간이 오래 걸릴 수 있으며 백업 도중에도 데이터 베이스에 write가 실행될 수 있다. 이때 커밋 후 읽기 처럼 일부는 이전 버전의 데이터를, 일부는 새로운 버전의 데이터를 백업 프로세스에게 반환하면 데이터 베이스 백업에 일관성이 깨진 데이터들이 저장될 수 있다. - 시간이 오래 걸리는 쿼리
데이터 베이스의 전체 데이터를 기반으로 통계 분석을 하거나 데이터가 로직상 올바른지 확인하는 무결성 검사 쿼리를 실행하면 시간이 오래 걸릴 수 있다. 이때 커밋 후 읽기 격리 수준을 사용하게 되면 일부는 이전 버전의 데이터를 보게되면서 비정상 적인 결과를 반환 할 수 있다.
이러한 비반복 읽기 문제들을 어떻게 해결 할 수 있을까요? 바로 반복 읽기 (Repeatable Read) 격리 수준을 사용하면 됩니다.
반복 읽기(Repeatable Read) 격리 수준
다시 문제로 돌아가 보겠습니다. 위의 사진에서 사용자 2 는 사용자 1 이 커밋한 데이터만 읽었지만, 여전히 잘못된 응답을 반환 받고 있습니다.
이 문제를 해결하기 위해선 “스냅숏 격리” 라는 개념을 사용할 수 있습니다
“스냅숏 격리” 를 사용하는 반복 읽기
“스냅숏 격리” 란 데이터 베이스가 각 데이터 마다 여러 버전의 값 들을 유지하고, 각 트랜잭션 마다 순서인 txid (트랜잭션 id) 를 부여하여 트랜잭션 별로 읽을 수 있는 데이터를 특정 시점 의 데이터로 고정(스냅숏 을 찍어서) 하여 이후의 트랜잭션들에서 가해진 변경으로 부터 데이터를 보호하는 것 을 의미합니다.
스냅숏 격리를 사용하는 반복 읽기 격리 수준을 적용하게 되면, 위의 사진처럼 사용자 2는 txid가 12 이므로 이후 실행된 사용자 1의 txid 13이 업데이트한 계좌 잔고를 볼 수 없고 txid 12 실행 시점까지의 데이터만 “스냅숏” 형태로 변하지 않게 고정하여 바라볼 수 있습니다.
따라서 위의 사용자 2 는 사용자 1 이 계좌 잔고를 업데이트 했음에도 스냅숏 격리로 이전 버전의 데이터 들을 일관성 있게 바라볼 수 있는 것이죠.
데이터 베이스가 각 데이터 마다 여러 버전의 값 들을 유지하므로 스냅숏 격리는 다중 버전 동시 제어 (multi version concurrency control, MVCC) 라고도 불립니다.
스냅숏 격리 에서의 데이터 버전을 읽는 가시성 규칙
스냅숏 격리 에선 각 트랜잭션의 아이디인 txid를 기반으로 데이터의 어느 버전 까지를 읽을 수 있을지를 결정하여 일관된 데이터의 스냅숏을 제공합니다.
이러한 스냅숏 격리에서 데이터 버전을 읽는 가시성 규칙은 다음과 같습니다.
1. 트랜잭션 아이디(txid) 가 더 큰 (현재 트랜잭션 이후에 시작한) 트랜잭션이 쓰거나 커밋한 데이터는 현재 트랜잭션에서 볼 수 없다
2. 어보트 된 트랜잭션이 쓰거나 커밋한 데이터는 모두 현재 트랜잭션에서 무시된다
3. DB는 각 트랜잭션을 시작할 때 그 시점에서 진행 중인 (커밋이나 어보트가 아직 완료되지 않은) 모든 트랜잭션의 목록을 만든다.
이 트랜잭션 들이 쓰거나 커밋한 데이터는 모두 현재 트랜잭션에서 무시된다.
즉, 현재 트랜잭션에게 무슨 일이 있어도 일관되고 고정된 데이터 들을 읽을 수 있게 보장하는 게 스냅숏 격리가 하는 일 인거죠!
쉽게 말해서, 스냅숏 격리는 아래의 두 조건을 모두 만족하는 데이터만 볼 수 있게 보장 해줍니다.
1. 읽기를 실행하는 트랜잭션이 시작한 시점에 읽으려는 데이터 가 커밋 된 상태였다
2. 읽으려는 데이터가 삭제된 것으로 표시되지 않거나, 삭제되었다고 표시는 되었지만 읽기를 실행한 트랜잭션이 시작한 시점에 삭제 표시를 한 트랜잭션이 커밋 되지 않았다
스냅숏 격리의 성능상 이점
이러한 스냅숏 격리를 구현하는 데에는 여러 버전의 데이터 유지 가 필요할 뿐 읽기, 쓰기 작업이 서로를 차단(잠금) 하지 않습니다.
- 읽는 쪽 에서 쓰는 쪽을 차단(잠금) 하지 않음
(이전 버전의 일관된 데이터를 바라봄) - 쓰는 쪽에서도 읽는 쪽을 차단(잠금) 하지 않음
(이전 버전의 데이터를 수정하는 대신 새로운 버전의 데이터를 추가)
따라서 데이터 베이스는 읽기, 쓰기 작업 사이에 서로 차단하는 부분이 없으므로 잠금 경쟁 없이 읽기, 쓰기 작업을 처리할 수 있으며 오래 걸리는 읽기 작업 (데이터 베이스 백업, 통계 분석 쿼리) 도 일관된 버전의 스냅숏을 제공하여 올바르게 처리할 수 있도록 보장합니다.
결론
1. 커밋 후 읽기 격리 수준은 Dirty read, Dirty write 방지를 보장한다
2. 커밋 후 읽기 로도 해결하지 못하는 비반복 읽기 문제는 스냅숏 격리 를 통한 반복 읽기 격리 수준 으로 해결 할 수 있다
트랜잭션의 격리 수준 인 커밋 후 읽기 와 반복 읽기 가 각각 와 필요한지, 어떤 문제를 해결하는지 에 대해서 예시를 통해 구체적으로 살펴보았습니다.
하지만, 커밋 후 읽기 와 반복 읽기 격리 수준 만으로 세상의 모든 동시성 문제를 해결할 수 있을까요? 아직도 생각 치 못한 문제들이 남아있지 않을까요?
어떤 문제들이 남아있고, 어떤 방법으로 해결할 수 있는지 는 2편 에서 이어서 설명 드리겠습니다. 다음 글을 기대해주세요! 감사합니다.