트랜잭션이란?
트랜잭션은 작업의 완전성을 보장해 준다.
즉, 트랜잭션은 논리적인 작업 셋을 모두 완벽하게 처리하거나, 처리하지 못할 경우에는 원 상태로 복구하여 작업의 일부만 적용되는 현상(Partial update)이 발생하지 않도록 해준다. 잠금(Lock)과 비슷한 개념이라고 생각할 수 있지만, 잠금은 동시성을 제어하기 위한 기능이고 트랜잭션은 데이터의 정합성을 보장하기 위한 기능이다.
또한, 트랜잭션은 여러 개의 변경 작업을 수행하는 쿼리가 조합됐을 때만 의미 있는 개념이 아니다. 트랜잭션은 하나의 논리적인 작업 셋에 쿼리의 수와 상관없이 원자성을 보장하는 것이다.
주의사항
트랜잭션 범위를는 최소화해하는 것이 좋다. 이유가 무엇일까? 데이터베이스 커넥션은 개수가 제한적이어서 각 단위 프로그램이 커넥션을 소유하는 시간이 길어질수록 사용 가능한 여유 커넥션의 개수는 줄어들게 된다. 때문에 트랜잭션 범위를 크게 잡게 된다면, 각 단위 프로그램에서 커넥션을 가져가기 위해 기다려야 하는 상황이 발생할 수도 있다.
마찬가지 이유로, 네트워크를 통해 원격 서버와 통신하는 작업이 있는 경우, 트랜잭션 내에서 제거하는 것이 좋다. 네트워크 통신은 트랜잭션과는 상관없이 지연이 발생할 수 있는 작업이다. 때문에 네트워크 통신이 트랜잭션 내에 존재할 경우, 데이터베이스의 트랜잭션 락이 길어질 수 있으며 이는 데이터베이스에 부담을 줄 수 있다.

하지만 김영한 선생님의 답변에 의하면, 트랜잭션 범위에 원격 서버와 통신하는 과정을 포함시키는 여부는 상황에 따라 달라진다고 한다. 결국, 각 상황에 따른 트레이드 오프를 잘 고려하여 설계하는 것이 정답인 것 같다.
트랜잭션 격리 수준
트랜잭션 격리 수준이란, 하나의 트랜잭션 내에서 또는 여러 트랜잭션 간의 작업 내용을 어떻게 공유하고 차단할 것인지를 결정하는 레벨이다. 즉, 쉽게 말해 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지를 결정하는 것이다.
격리 수준은 크게 "READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE" 이렇게 4가지로 나뉜다. 순서대로 뒤로 갈수록 각 트랜잭션 간의 데이터 격리 정도가 높아지며, 동시 처리 성능도 떨어진다.
일반적인 온라인 서비스 용도의 데이터베이스는 READ COMMITTED와 REPEATABLE READ 중 하나를 사용한다.
READ UNCOMMITTED
READ UNCOMMITTED 격리 수준에서는 말 그대로, 커밋 되지 않은 데이터도 읽을 수 있다.

트랜잭션 A가 이름이 "Lisa"인 새로운 사원을 INSERT한다. 트랜잭션 B가 id가 500인 사원을 조회하고자 할 때, 트랜잭션 A가 커밋되지 않음에도 접근할 수 있다. 만약, 트랜잭션 A가 쿼리 수행 도중 문제가 발생한다면 어떻게 될까? 트랜잭션 A가 처리 도중 알 수 없는 문제로 롤백한다고 하더라도, 트랜잭션 B는 여전히 조회된 "Lisa"라는 데이터가 정상적인 데이터라 생각하고 계속 로직을 처리할 것이다.
이처럼 어떤 트랜잭션에서 처리한 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있는 현상을 Dirty Read 현상이라고 한다. 이런 Dirty Read 문제를 유발하는 READ UNCOMMITTED는 RDBMS 표준에서는 트랜잭션 격리 수준으로 인정하지 않을 정도로 정합성에 문제가 많은 격리 수준이다. 때문에 최소 READ COMMITTED 이상의 격리 수준을 사용할 것을 권장한다.
READ COMMITTED

READ COMMITTED은, 커밋이 완료된 데이터만 접근할 수 있는 격리 수준으로 Dirty Read 현상은 발생하지 않는다. 트랜잭션 A가 이름을 Lisa에서 KGY로 변경을 하게 되면, 새로운 값인 KGY는 테이블에 즉시 기록되고 이전 값인 Lisa는 언두 영역으로 백업된다. 이어서 트랜잭션 B가 조회를 하게 되면, 트랜잭션 A는 아직 커밋이 되지 않았기 때문에 언두 로그에 존재하는 값인 Lisa를 반환하게 된다. 즉, READ COMMITTED 격리 수준에서는 어떤 트랜잭션에서 변경한 내용이 커밋되기 전까지는 다른 트랜잭션에서 그러한 변경 내역을 조회할 수 없다.
하지만, 해당 격리 수준에서도 NON-REPEATABLE READ라는 부정합 문제가 존재한다.

트랜잭션 B가 트랜잭션을 시작하고 SELECT 문을 통해 데이터를 조회하면 "Lisa"라는 결과를 반환한다. 하지만 트랜잭션 A가 해당 데이터를 수정하고 COMMIT한 후에 다시 똑같은 쿼리로 조회하면 다른 결과가 조회된다. 이는 하나의 트랜잭션 내에서 똑같은 SELECT 쿼리를 실행했을 때 항상 같은 결과를 가져와야 한다는 "REPEATABLE READ" 정합성에 어긋나는 것이다. 이를 NON-REPEATABLE READ 문제라고 한다. 별로 중요하지 않은 문제처럼 보일 수 있지만, 이런 문제로 데이터의 정합성이 깨지고 그로 인해 애플리케이션에 버그가 발생하면 찾아내기 쉽지 않다.
REPEATABLE READ
REPEATABLE READ는 MySQL의 InnoDB 스토리지 엔진에서 기본으로 사용되는 격리 수준이다. 해당 격리 수준에서는 NON-REPEATABLE READ 부정합이 발생하지 않는다. InnoDB 스토리지 엔진은 트랜잭션이 ROLLBACK될 가능성에 대비해 변경되기 전 레코드를 언두(Undo) 공간에 백업해두고 실제 레코드 값을 변경한다. 이러한 변경 방식을 MVCC(Multi Version Concurrency Control) 이라고 한다. (MVCC에 대한 자세한 내용은 다음 아티클에서 자세히 다뤄보고자 한다. -> 4/2 포스팅 완료: 동시성 제어와 MVCC)
REPEATABLE READ는 이 MVCC를 위해 언두 영역에 백업된 이전 데이터를 이용해 동일 트랜잭션 내에서는 동일한 결과를 보여줄 수 있게 보장한다. READ COMMITTED도 MVCC를 이용해 커밋되기 전 데이터를 보여주긴 하지만 차이는 언두 영역에 백업된 레코드의 여러 버전 가운데 몇 번째 이전 버전까지 찾아 들어가야 하느냐 에 있다.
모든 InnoDB의 트랜잭션은 고유한 트랜잭션 번호(순차적으로 증가)를 가지며, 언두 영역에 백업된 모든 레코드에는 변경을 발생시킨 트랜잭션의 번호가 포함되어 있다. 그리고 언두 영역에 백업된 데이터는 InnoDB 스토리지 엔진이 불필요하다고 판단하는 시점에 주기적으로 삭제한다. REPEATABLE READ 격리 수준에서는 MVCC를 보장하기 위해 실행 중인 트랜잭션 가운데 가장 오래된 트랜잭션 번호보다 트랜잭션 번호가 작은 언두 영역의 데이터는 삭제할 수 없다.

테이블의 초기 레코드는 트랜잭션 번호가 6인 트랜잭션에 의해 커밋된 상태라 가정하자. 트랜잭션A가 사원의 이름에 대한 변경을 수행했지만 트랜잭션 B는 항상 같은 값을 가져온다. 그 이유는 트랜잭션 B의 트랜잭션 번호는 10번이고 때문에 트랜잭션 B 안에서 실행되는 모든 SELECT 쿼리는 자신의 트랜잭션 번호보다 작은 트랜잭션 번호에서 변경한 것만 보이게 된다. 즉, 트랜잭션B의 트랜잭션 번호보다 큰 트랜잭션A가 변경한 데이터는 접근할 수 없게 된다.

위 그림에는 언두 로그에 하나의 백업 데이터만 있는 것으로 표현했지만 하나의 레코드에 대해 백업은 여러 개 존재할 수 있다. 때문에 트랜잭션이 시작되고, 장기간 종료되지 않으면 언두 영역에 백업된 데이터도 무한정 커질 수도 있다. 이렇게 언두 영역에 백업된 레코드가 많아지면 MySQL 서버의 처리 성능이 떨어질 수 있다.
REPEATABLE READ 격리 수준에서도 PHANTOM READ라는 부정합 문제가 발생할 수 있다. (일반적인 SELECT 쿼리로는 MVCC 동작으로 인해 팬텀 리드가 방지되기 때문에, 본 예시에서는 SELECT ... FOR UPDATE를 사용하여 직접 레코드에 락을 걸음으로써 인위적으로 팬텀 리드를 발생시켰다.)
위는 트랜잭션 B에서 같은 쿼리를 실행했는데도, 다른 결과를 반환한다. 왜 이런 현상이 발생할까? SELECT ... 뒤에 FOR UPDATE가 붙은 쿼리는 SELECT하는 레코드에 쓰기 잠금(X-Lock)을 걸어야 한다. 하지만, 언두 레코드에는 잠금을 걸 수 없어, 언두 영역의 변경 전 데이터를 가져오는 것이 아니라 현재 레코드의 값을 가져오게 된다.
이처럼 다른 트랜잭션에서 수행한 변경 작업에 의해 레코드가 보였다 안 보였다 하는 현상을 PHANTOM READ(유령 읽기) 문제라고 한다.
SERIALIZABLE
가장 단순한 격리 수준이면서 동시에 가장 엄격한 격리 수준이다. 그만큼 동시 처리 성능도 다른 트랜잭션 격리 수준보다 떨어진다.
MySQL의 InnoDB엔진에서는 기본적으로 순수한 SELECT 작업에 대해서는 아무런 레코드 잠금을 설정하지 않고 실행한다. "Non-locking consistent read(잠금이 필요 없는 일관된 읽기)"라는 말이 이를 의미하는 것이다.
하지만 트랜잭션 격리 수준이 SERIALIZABLE로 설정되면 읽기 작업도 읽기 잠금(S-Lock)을 획득해야만 하며, 때문에 다른 트랜잭션은 그러한 레코드에 대해 변경을 하지 못하게 된다. 즉, 한 트랜잭션에서 읽고 쓰는 레코드를 다른 트랜잭션에서 접근할 수 없다는 것이다.
정리
각 격리 수준에 따라 발생하는 부정합 문제를 정리하면 아래 표와 같다.

MySQL의 기본 격리 수준은 REAPEATABLE READ 이지만, InnoDB 에서는 독특한 특성 때문에 해당 격리 수준에서도 PHANTOM READ 문제가 발생하지 않는다. 이에 대한 이유도 다음 아티클에서 자세히 다뤄보고자 한다.
SNAPSHOT ISOLATION
위의 4가지 격리수준은 ANSI/ISO standard SQL 92에서 정의한 격리 수준이다. 하지만, 이 논문에 대해 상업적인 DBMS에서 사용되는 방법을 반영해서 격리 수준을 구분하지 않았다며 비판을 하며 추가적으로 소개한 격리 수준이 있는데 바로 SNAPSHOT ISOLATION이다.
SNAPSHOT 격리는 MVCC를 기반으로 동작하며 트랜잭션이 실행될 때 특정 시점의 데이터 스냅샷(복사본)을 읽도록 보장하는 격리 수준이다. 트랜잭션이 시작될 때 특정 시점의 데이터를 기준으로 스냅샷을 생성하고 해당 트랜잭션이 실행되는 동안, 해당 시점의 데이터를 사용하며 다른 트랜잭션에서 변경한 최신 데이터는 보이지 않는다는 것이 특징이다.

스냅샷 격리 하에서는 트랜잭션 A가 보는 데이터는 “자신이 시작할 때의 상태” 이므로, 트랜잭션 B가 값을 5000으로 변경해도 A 입장에서는 바뀐 내용을 볼 수 없다. 즉, 트랜잭션 A는 시작할 때 찍힌 ‘스냅샷’을 계속 참조 때문에 트랜잭션 A의 두 번째 SELECT에서도 똑같이 결과가 없음으로 반환되게 된다.
대표적으로 PostgreSQL의 기본 격리 수준은 REPEATABLE READ이라고 명시되어 있지만, SNAPSHOT ISOLATION 방법으로 동작한다. 즉, 같은 이름의 격리 수준이더라도 동작 방식은 다를 수 있기 때문에 사용하는 RDBMS의 트랜잭션 격리수준이 어떻게 동작하는지 잘 파악하여 적절한 격리 수준을 사용할 수 있도록 유의하자.
'데이터베이스' 카테고리의 다른 글
동시성 제어에 대해 알아보자 (0) | 2025.04.02 |
---|