트랜잭션은 하나의 논리적 기능을 수행하기 위한 작업의 단위이다. 하나의 논리적 기능에 관련된 여러 작업들을 하나로 묶어서 처리하여 기능의 완전성을 보장한다.
RDBMS(Relational Database Management System)에서는 ACID 특징을 갖고있다.
트랜잭션에 묶인 작업들이 실패하면 모두 실패하고, 성공하면 모두 성공하도록 보장한다. 예를 들어서 주문이라는 하나의 논리적 기능이 있고 아래 그림과 같이 ‘결제 → 주문 → 배달 할당’ 순으로 처리된다고 가정해보자.
만약 중간에 주문 작업이 실패하면 어떻게 될까? 데이터베이스는 롤백을 수행해서 주문 이전에 성공한 결제 작업을 트랜잭션 시작 전으로 초기화 한다. 따라서 결과적으로 데이터베이스에는 변경된 내용이 없도록 만들어준다. 이처럼 트랜잭션은 하나의 논리적 기능에 대해서 관련된 작업들을 묶어서 원자성을 보장해준다.
반면에 트랜잭션 내 작업이 모두 성공할 경우 커밋을 통해서 데이터베이스에 반영할 수 있다. 커밋이 수행 됐다는 것은 ‘하나의 트랜잭션이 성공적으로 완료 되었다’와 동일한 의미로 볼 수 있다.
트랜잭션이 수행된 후 데이터베이스가 항상 일관된 상태를 유지하도록 보장한다.
예를 들어서 A계좌와 B계좌에 각각 100만원의 금액이 들어있고, A계좌에서 B계좌로 100만원을 이체하는 상황에서의 트랜잭션을 살펴보자.
이처럼 트랜잭션 전에 A계좌 100만원, B계좌 100만원으로 총 200만원의 금액이 있었고, 트랜잭션 후에도 마찬가지로 B계좌에 200만원 금액이 들어있어 총 200만원의 금액을 보유하고 있어 트랜잭션 전과 후의 데이터의 일관성이 지켜졌다는 것을 확인할 수 있다. 이렇게 보면 간단해보이지만 격리 수준이 낮아서 부정합 문제가 발생하거나 동시성 문제로 인해서 데이터의 일관성이 깨질 수 있다.
동시에 여러 트랜잭션이 수행될 때, 각 트랜잭션은 다른 트랜잭션의 영향을 받지 않도록 격리된다. 이 특성에 깊게 관련된 격리 수준에 대한 내용은 아래에서 자세하게 다뤄보겠다.
성공적으로 수행된 트랜잭션은 영원히 반영되어야 함을 의미한다.
격리 수준은 동시에 여러 트랜잭션이 수행될 때 트랜잭션 간의 간섭을 얼마나 허용할 지에 대한 설정이다. Read Uncommitted, Read Committed, Repeatable Read, Serializable 총 4단계로 이루어져 있다. 격리 수준에 따라서 발생할 수 있는 3가지의 부정합 문제인 Dirty Read, Non-Repeatable Read, Phantom Read에 대해서도 같이 자세하게 다뤄보겠다.
Read Uncommitted 격리 수준은 다른 트랜잭션에서 변경한 내용을 커밋 이전에도 읽을 수 있도록 허용해주는 격리 수준이다. 이에 대한 예시를 아래에서 살펴보겠다.
Read Uncommitted 격리 수준에서는 커밋 전이나 롤백 전의 데이터를 조회할 수 있다. 2번 과정에서 트랜잭션 B가 계좌1의 금액을 조회 했는데, 이는 트랜잭션 A에서 롤백을 수행하기 전의 계좌 금액이다.
이후에 트랜잭션 A에서 롤백이 수행되어 실제 계좌1의 금액은 100만원이 되었지만, 트랜잭션 B에서는 여전히 계좌1의 금액이 50만원인 채로 이후의 작업이 수행되게 된다. 이 때문에 의도하지 않은 동작이 발생하고 프로덕션 환경에서 이슈를 발생시킬 수 있다.
이렇게 커밋 이전에 변경된 데이터가 조회되는 현상을 Dirty Read라고 부르고, Read Uncommitted 격리 수준은 Dirtry Read를 허용한다. 위 예시와 같이 Dirty Read는 의도하지 않은 동작으로 이슈를 발생 시킬 수 있기 때문에 최소한 Read Committed 격리 수준을 사용하는 것이 권장된다.
Read Committed 격리 수준은 오라클 DBMS와 PostgreSQL 기본으로 사용되는 격리 수준이다. 이 격리 수준에서는 다른 트랜잭션에서 변경한 내용에 대해서 커밋 이후에만 읽을 수 있도록 제한한다. 때문에 Read Uncommitted 격리 수준에서 발생하던 Dirty Read 현상을 방지할 수 있다. 이어서 해당 격리 수준에 대한 예시에 대해서도 살펴보겠다.
Read Committed 격리 수준에서는 트랜잭션이 데이터를 변경하면 커밋 후에만 다른 트랜잭션에서 변경된 내용으로 조회할 수 있다. 이는 데이터를 변경하기 전에 언두 영역에 이전 레코드를 백업해두는 방식으로 동작한다.
트랜잭션 A가 1번 회원의 이름을 ‘뉴회원1’로 변경하면 실제로 변경되기 전에 현재 레코드를 언두 영역에 백업해두고, 트랜잭션 B에서는 트랜잭션 A에서 커밋하기 전에는 언두 영역에서 백업된 레코드를 조회하기 때문에 1번 회원을 조회해도 여전히 이름의 값이 ‘회원1’로 조회된다. 이처럼 Read Committed 격리 수준을 사용하면 Dirty Read 문제를 해결할 수 있다.
하지만, Read Committed 격리 수준에서도 Non-Repeatable Read 부정합 문제가 발생한다.
1번 과정에서 트랜잭션 B가 ‘뉴회원1’로 처음 조회할 때는 결과가 없었지만, 트랜잭션 A에서 데이터를 변경하고 커밋한 후에 4번 과정에서 트랜잭션 B가 동일한 쿼리로 다시 조회할 때 1건의 결과가 반환되는 것을 볼 수 있다. 이처럼 동일한 트랜잭션에서 동일한 쿼리를 실행하더라도 다른 결과가 반환되는 현상을 Non-Repeatable Read 라고 한다.
Repeatable Read 격리 수준은 MySQL의 InnoDB 스토리지 엔진에서 기본으로 사용되는 격리 수준이다. 자세히 알아보기 전에 MVCC에 대해서 간단하게 알아보자.
MVCC는 하나의 레코드에 대해서 여러 개의 버전이 동시에 관리해주는 동시성 제어 방식이다. InnoDB 스토리지 엔진 기준으로 이 기능은 언두 로그를 이용해서 구현되었다. 위에서 살펴본 Dirty Read를 해결하기 위해서 사용한 방법과 동일하다. 실제로 Read Committed도 MVCC를 이용해서 커밋 되기전의 데이터를 보여주는게 맞다.
MVCC 기능을 구현하기 위해서 언두 영역에 이전 레코드를 백업하는데, Repeatable Read는 같은 트랜잭션 내에서 다시 조회할 때 언두 영역에 백업된 이전 레코드를 조회해서 동일한 결과를 보여줄 수 있도록 보장한다. 이를 통해서 Non-Repeatable Read 부정합 문제를 해결한다.
다음 그림을 통해서 어떻게 Non-Repeatable Read 문제를 해결했는지 자세하게 살펴보자.
6번 트랜잭션에 의해서 999번과 1000번 회원이 INSERT 됐음을 가정하고 처리 과정을 살펴보자.
4번 과정과 같이 Repeatable Read 격리 수준에서는 트랜잭션이 수행되는 동안, 같은 쿼리로 조회할 경우에는 동일한 결과를 반환 받게된다. 이를 통해서 Non-Repeatable Read 부정합 문제를 해결할 수 있다.
지겹게도 Repeatable Read 격리 수준에서도 다음과 같은 상황에서 부정합 문제가 발생할 수 있다.
SELECT … FOR UPDATE
쿼리로 1000번 이상의 회원을 조회 → 결과 1건 (회원2)SELECT … FOR UPDATE
쿼리로 1000번 이상의 회원을 다시 조회 → 결과 2건 (회원2, 회원3)SELECT … FOR UPDATE
쿼리는 SELECT 하는 레코드에 쓰기 잠금을 걸어야 하는데 언두 레코드에는 잠금을 걸 수 없다. 그래서 조회되는 레코드는 현재 레코드의 값을 가져오게 되어서, Repeatable Read 격리 수준이지만 반환되는 결과가 다를 수 있다. 이처럼 다른 트랙잭션에서 수행한 변경 작업에 의해 레코드가 보였다 안 보였다 하는 현상을 Phantom Read 라고 한다.
가장 엄격한 격리 수준이다. Serializable 격리 수준은 기본적으로 읽기 작업도 공유 작업을 획득해야 한다. 동시에 다른 트랜잭션에서도 해당 레코드를 변경할 수 없다. 한 트랜잭션에서 읽고 쓰는 레코드를 다른 트랜잭션에서 절대 접근할 수 없기 때문에 Phantom Read 현상이 발생하지 않지만, 동시 처리 성능이 다른 격리 수준보다 떨어진다.
MySQL InnoDB 스토리지 엔진에서는 갭 락(Gap Lock)과 넥스트 키 락 (Next Key Lock) 덕분에 Repeatable Read 격리 수준에서도 Phantom Read가 발생하지 않는다. 따라서, Serializable 격리 수준을 사용할 필요가 없다. 다만 SELECT … FOR UPDATE
나 SELECT … LOCK IN SHARE MODE
쿼리에서는 Phantom Read 현상이 발생할 수 있다.
위에서 말한 갭 락과 넥스트 키 락에 대해서 간단하게 살펴보겠다.
갭 락은 레코드 자체가 아니라 레코드와 인접한 레코드 사이의 간격만을 잠근다. 위의 그림에서는 회원2와 회원3 레코드의 간격을 잠근 것이다. 갭 락의 역할은 레코드와 레코드 사이의 간격에 새로운 레코드가 생성(INSERT)되는 것을 방지한다. 갭 락은 그 자체로 사용되기 보다도 아래에서 설명할 넥스트 키 락의 일부로써 사용된다.
넥스트 키 락은 레코드 락과 갭 락을 합쳐 놓은 형태의 잠금이다. 위 그림에서는 회원1-회원2 사이와 회원3-회원4 사이를 갭 락으로 잠구고, 회원2와 회원3을 레코드 락으로 잠군 것이다. 이처럼 InnoDB 스토리지 엔진은 넥스트 키 락을 통해서 Repeatable Read 격리 수준에서도 Phantom Read 현상을 방지할 수 있다.
낙관적 락과 비관적 락 글에 이어서 RDBMS의 ACID 특징과 트랜잭션 격리 수준에 대해서 살펴봤다. 만약 격리 수준의 이해없이 데이터베이스와 함께 애플리케이션 로직을 개발하면 부정합 문제들로 인해서 예측이 어려운 이슈가 발생할 수 있다. 이 경우 빠르게 대처하기 어렵기 때문에 고객의 신뢰도 하락, 비즈니스 손실로 이어질 수 있으므로 미리 격리 수준과 부정합 문제를 이해하고 있는게 중요할 것 같다.