Technology/MySQL

트랜잭션 격리수준 Repeatable Read 가 필요한 이유

ikjo 2024. 3. 23. 04:23

Non-Repeatable Read 는 언제 문제가 되는가?

MySQL 의 InnoDB 스토리지 엔진에서 기본으로 사용되는 트랜잭션 격리수준은 Repeatable Read 이다. 이때 Repeatable Read 에서는 다른 트랜잭션 격리수준인 Read Uncommitted 나 Read Committed 에서와 달리 Non-Repeatable Read 현상이 발생하지 않는다. 여기서 Non-Repeatable Read 란 하나의 트랜잭션에서 같은 값을 두 번 select 했을 때 각각 다른 값이 읽히는 현상이다. 참고로 ANSI SQL 1992 에서는 Non-Repeatable Read 현상을 아래와 같이 서술하고있다.

 

https://www.contrib.andrew.cmu.edu/~shadow/sql/sql1992.txt

 

하나의 예시로서 아래와 같은 시나리오들이 존재할 수 있다. 참고로 트랜잭션 A 의 경우 격리수준이 Read Committed 라고 가정했다.

 

 

위 예시는 많은 자료에서 예시로 사용되고 있는 Non-Repeatable Read 발생 시나리오이기도 하다.

 

그런데, 여기서 의문이 생길 수 있다. "애초에 한 트랜잭션에서 동일한 select 쿼리를 두 번 이상 할 일이 있을까?" 사실, 처음 DB 로부터 읽은 레코드 값을 따로 저장하고 있다면 굳이 동일한 select 쿼리를 한 번 더 할 필요가 있을까 싶다는 생각이 들었다.

 

위 시나리오들은 Non-Repeatable Read 현상을 쉽게 나타내기 위해 만든 것이겠지만 내게 있어 심각한 문제로 느껴지는 시나리오는 아니었다. 심지어 오라클 DBMS 에서도 기본 트랜잭션 격리수준을 Non-Repeatable 현상을 허용하는 Read Committed 로 설정하고있는 만큼 이러한 Non-Repeatable Read 현상은 도대체 언제 문제가 되는가 싶었다. 오히려 가장 최신화된(가장 마지막으로 커밋된) 데이터를 읽을 수 있다는 점에서 이러한 Non-Repeatable Read 현상은 있어도 되지않을까 싶었다.

 

 

트랜잭션 격리수준 Repeatable Read, 굳이 필요한가?

Non-Repeatable Read 현상이 문제가 되지 않는다면, 좀 더 구체적으로는 한 트랜잭션 내에서 동일한 select 쿼리를 두 번 이상 할 일이 없다면, 굳이 트랜잭션 격리수준을 Repeatable Read 로 설정함으로써 한 트랜잭션 내에서 여러번 select 쿼리를 사용해도 동일한 결과 값을 가져오도록 강제할 필요가 있을까 하는 생각이 들었다. 더욱이 MySQL InnoDB 스토리지 엔진의 기본 트랜잭션 격리수준으로 Repeatable Read 을 설정할 이유가 있었을까 싶었다.

 

물론, 당장 내 짧은 경험 상으로 문제로 여겨지지 않는다고 해서 트랜잭션 격리수준 Repeatable Read 이 필요없다고 주장할 수는 없다. 😅 더욱이 Repeatable Read 에서는 Non-Repeatable Read 현상이 발생하지 않는다곤 하지만 꼭 이것만을 위해 존재하리라는 보장도 없다. 이에 Repeatable Read 이 필요한 이유에 대해 좀 더 제대로 알기 위해 MySQL docs 를 살펴보기로 했다. 사실, Repeatable Read 가 MySQL 에 국한된 것은 아니지만 본 글의 카테고리가 MySQL 인 만큼 MySQL 을 기준으로 내용을 다루고자 한다.

 

 

트랜잭션 격리수준 Repeatable Read 파헤쳐 보기

아래 내용은 MySQL docs 에서 트랜젹션 격리수준 Repeatable Read 에 대해 정의한 내용이다.

 

https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html

 

첫 번째 조회(first read)에 의해 설정되는 스냅샷(snapshot)

여기서 첫 줄에 주목할만한 구절이 있다. "Consistent reads within the same transaction read the snapshot established by the first read." 해석해보자면 동일한 트랜잭션 내에서의 일관된 조회는 첫 번째 조회에 의해 설정된 스냅샷을 읽는다고 한다. 이때, 여기서 말하는 스냅샷이란 무엇을 의미하는걸까?

 

https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_snapshot

 

 

MySQL docs 에 따르면 스냅샷이란 "일관된 조회를 위해 특정 격리 수준에서 사용되는 것으로서 다른 트랜잭션에 의해 변경 사항이 커밋되더라도 동일하게 유지되는 특정 시간의 데이터 표현"이라고 한다. 여기서 특정 격리 수준이라 함은 Repeatable Read 도 당연히 포함된다는 것을 알 수 있다. 즉, Repeatable Read 에서 Non-Repeatable Read 현상이 발생하지 않는 이유(일관된 조회가 가능한 이유)는 이 스냅샷과 관련이 있는 것이다.

 

테스트를 통해 이 스냅샷에 대해 좀 더 알아보자.

 

아래와 같이 2개의 테이블이 있다고 가정해보았을 때,

 

아래와 같은 시나리오가 있다고 생각해보자. 이번에는 트랜잭션 A 의 격리수준이 Repeatable Read 라고 가정해보자.

 

 

 

가장 먼저 트랜잭션 A 는 team 테이블에서 id = 1 인 데이터를 조회하고 뒤 이어 트랜잭션 B 가 user 테이블에서 id = 1 인 데이터를 수정하고 커밋했다. 이후 트랜잭션 A 가 user 테이블에서 id = 1 인 데이터를 조회했는데, 놀랍게도 트랜잭션 B 가 수정(ikjo → mike)하고 커밋 처리를 했음에도 불구하고 더욱이 트랜잭션 A 가 한번도 조회하지 않았던 user 테이블의 원본 데이터(ikjo)이 조회가 된 것이다.

 

트랜잭션 격리수준이 Repeatable Read 라면 한 트랜잭션 내에서 동일한 select 를 2번 이상 시도해도 동일한 결과가 반환된다는 것은 알고있었지만 다른 테이블 데이터 역시 최초 조회(first read) 시점에서의 데이터가 마치 스냅샷(snapshot)처럼 유지되어있었던 것이다. MySQL 은 도대체 이를 어떻게 구현하고있는 것일까?

 

일관된 조회(consistent read)

이번에도 MySQL docs 를 살펴봤다. MySQL 에서 일관된 조회라 함은 다음과 같이 정의하고있다.

 

https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_snapshot

 

일관된 조회란 한 시점을 기준으로 쿼리 결과를 표시하는 스냅샷 정보를 사용하는 것이라고 한다. 앞서 살펴봤듯이 Repeatable Read 격리수준의 경우 first read 시점에서의 정보가 스냅샷이 되는 것이다. 그리고 중요한 구절이 있다. "조회된 데이터(queried data)가 다른 트랜잭션에 의해 변경되었다면 본래 데이터는 undo log 의 내용을 통해 재구성된다." 그렇다면 앞서 살펴봤던 것처럼 트랜잭션 A 가 한 번도 조회하지 않았던 user 테이블의 원본 데이터를 조회할 수 있었던 것은 트랜잭션 B 가 해당 데이터를 수정함으로 인해 원본 데이터가 undo log 에 들어갔고 이 undo log 로부터 해당 데이터를 가져왔다고 유추해볼 수 있다.

 

언두 로그(undo log)

언두 로그는 MySQL docs 에서 아래와 같이 정의하고있다.

 

https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_undo_log

 

언두 로그는 활성 트랜잭션에 의해 수정된 데이터의 복사본을 보관하는 저장 영역이라고 한다. 즉, 수정하기 전 원본 데이터가 저장되는 영역인 것이다. 또한 언두 로그는 트랜잭션이 롤백되면 트랜잭션 도중 변경된 데이터를 변경 전 데이터로 복구하는데에도 사용된다.

 

MySQL 은 이러한 언두 로그를 통해 잠금 없는 일관된 조회를 제공하는 것이었다. 트랜잭션 격리수준이 Repeatable Read 일 때 특정 테이블 데이터를 조회함에 있어 언두 로그 영역에 저장된 원본 데이터를 가져오는 것이다. 이때 원본 데이터의 기준은 첫 조회(first read)가 발생한 시점인 것이다.

 

원래 같았으면 잠금 비용을 지불해야 일관된 조회를 허용할 수 있었을 것이다. 예를 들어, 한 트랜잭션이 종료될 때까지 일관된 조회를 하기 위해 공유 잠금을 사용해 다른 트랜잭션에서 수정 작업 처리를 하지 못하도록 하거나 한 트랜잭션이 수정 작업하는 동안 다른 트랜잭션이 읽지 못하도록 레코드에 배타 잠금을 걸었을 것이다. 하지만 MySQL 은 언두 로그를 통해 이러한 잠금 비용을 줄인 것이다.

 

참고로, 트랜잭션 격리수준이 Repeatable Read 이더라도 공유 잠금이나 배타 잠금을 사용하여 조회하는 순간 일관된 조회가 불가능해진다. 이는 언두 로그에 잠금을 걸 수 없기 때문에 가장 마지막에 커밋된 데이터를 조회하는 것이다.

 

Repeatable Read 에서의 잠금 전략

아울러 Repeatable Read 은 나름대로의 잠금 전략을 가지고 있다. 앞서 살펴본 공식 문서 상 잠금을 수반한 Select 문, Update문, Delete문의 경우 잠금 전략이 unique 인덱스를 사용하여 검색하는지 또는 범위 검색하는지에 따라 달라진다고 한다.

 

unique 검색 조건 시에는 InnoDB 가 발견된 인덱스 레코드에만 잠금을 건다. 그리고 그외 검색 조건 시에는 InnoDB 가 갭 락(gap lock)이나 넥스트 키 락(next-key lock)을 활용하여 범위 검색된 인덱스에 잠금을 거는데 이를 통해 검색된 범위 사이에 다른 트랜잭션이 데이터를 삽입하는 것을 방지한다. 즉 MySQL 에서는 이러한 잠금 전략으로 Repeatable Read 격리수준에서도 Phantom Read 가 발생하지 않는 것이다.

 

 

결론

지금까지 Non-Repeatable Read 현상이 왜 문제가 될까라는 질문에서 시작해서 트랜잭션 격리수준 Repeatable Read 가 필요할까라는 의문을 가지고 MySQL 에서의 트랜잭션 격리수준 Repeatable Read 에 대해 파헤쳐보았다. 이를 통해 Repeatable Read 격리수준의 필요성에 대해 보다 공감할 수 있었다.

 

우선 MySQL 에서 제공하는 트랜잭션 격리수준 Repeatable Read 의 경우 기존에 내가 알고있었던 Non-Repeatable Read 현상을 방지하는 것을 넘어 잠금 없는 일관된 조회를 제공하고 있었다. 이는 한 트랜잭션 내에서 언두 로그를 기반으로 처음 조회(first read) 시점을 기점으로 다른 트랜잭션의 수정 작업과 상관없이 모든 테이블의 데이터를 잠금 없이 일관적으로 읽을 수 있게 하는 것이었다.

 

기존에 내가 생각했었던 Repeatable Read 라 함은 단순히 "한 트랜잭션 내에서 다른 트랜잭션의 수정 작업과 상관없이 동일한 select 쿼리를 두 번 이상 시도했을 때 결과가 같은 것"이었다. 하지만 이는 일부에 불과했다. 실제 MySQL 에서의 Repeatable Read 는 "한 트랜잭션 내에서 다른 트랜잭션의 수정 작업과 상관없이 조회 대상이 다른 select 쿼리여도 처음 조회 시점 기준의 원본 데이터를 잠금 없이 일관되게 조회할 수 있는 것"이다.

 

당초 "한 트랜잭션 내에서 시간을 두고 동일한 테이블의 동일한 레코드를 조회하는 것"에 대해선 의문이 들었으나, "한 트랜잭션 내에서 시간을 두고 다른 테이블의 레코드를 처음 조회 시점을 기준으로 일관되게 조회하는 것"에 대해선 의문이 생기지 않는다. 이는 엄연히 다른 개념이며 잠재적으로 충분히 일어날 수 있는 시나리오라 생각하기 때문이다.

 

아울러 자신만의 잠금 전략을 가지는 것도 Repeatable Read 의 매력이었다. 범위 검색의 경우 갭 락이나 넥스트 키 락을 통해 Phantom Read 를 방지한다. 이러한 점도 Repeatable Read 격리수준의 필요성에 대해 공감할 수 있었던 요인이었다.