Isolation Level
cs 면접에서 단골 질문이 isolation level입니다. 이 글에서는 격리에 대해서 자세히 설명드리겠습니다.
트랜잭션의 격리 수준(Isolation Level)이란, 여러 트랜잭션이 동시에 처리될 때 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할지 여부를 결정하는 설정입니다.
격리 수준은 고립도가 높은 순서대로 SERIALIZABLE, REPEATABLE READ, READ COMMITTED, READ UNCOMMITTED가 있으며, 고립도가 높을수록 데이터의 일관성은 보장되지만 동시에 성능 저하가 발생할 수 있습니다.
- SERIALIZABLE
- REPEATABLE READ
- READ COMMITTED
- READ UNCOMMITTED
참고로, 아래에 설명드릴 예제 상황들은 모두 자동 커밋(AUTO COMMIT)이 false인 상태에서만 발생할 수 있는 현상들입니다.
✅ 왜 자동커밋이 false여야 할까?
AUTO-COMMIT을 자동으로 해놓으면 각 SQL 문이 바로 커밋됩니다.
SELECT, UPDATE, INSERT, DELETE 등 모든 명령이 자체적으로 트랜잭션을 시작하고 끝내기 때문에 트랜잭션은 Rollback이 불가능 합니다.
우리가 Spring의 JPA를 배울 때 알 듯이 트랜잭션은 원자성을 보장하여서 모두 커밋 되거나 모두 안 되서 롤백이 돼야합니다.
그렇지만 자동 커밋을 True로 하면 트랜잭션 단위(여러 개의 sql)에서 원자성을 지킬 수 없습니다.
한 트랜잭션 안에서 한 sql은 커밋되고 하나는 커밋이 안 될 수 있기 때문이죠.
SERIALIZABLE
SERIALIZABLE은 가장 엄격한 트랜잭션 격리 수준입니다.
이름 그대로, 트랜잭션들이 마치 순차적으로(serial) 실행되는 것처럼 처리되기 때문에, 여러 트랜잭션이 동일한 레코드에 동시에 접근하는 일이 차단됩니다.
그 결과, 데이터의 부정합이나 예기치 않은 읽기 문제는 발생하지 않습니다.
하지만 이러한 완전한 고립성 보장을 위해, 트랜잭션 간에 병렬 실행이 제한되므로, 동시 처리 성능은 크게 떨어질 수 있습니다.
따라서 안정성이 최우선인 특수한 상황이 아닌 이상, 일반적인 애플리케이션에서는 이 수준을 사용하지 않는 것이 바람직합니다.
MySQL에서는 SELECT ... FOR UPDATE나 SELECT ... FOR SHARE와 같이 명시적으로 잠금을 요청한 SELECT문의 경우, 각각 쓰기 잠금(Exclusive Lock) 또는 읽기 잠금(Shared Lock)을 걸게 됩니다.
하지만 보통의 SELECT 문은 아무런 잠금 없이 일관된 데이터를 읽는 "Non-locking Consistent Read(비잠금 일관 읽기)" 방식으로 동작합니다.
이 방식은 성능을 위해 잠금을 생략하면서도 MVCC 기반으로 트랜잭션 시작 시점의 스냅샷 데이터를 읽기 때문에, 일관성은 유지됩니다. (MVCC는 아래에서 설명하겠습니다. 몰라도 넘어가 주세요.)
그러나 SERIALIZABLE 격리 수준에서는 이러한 일반 SELECT조차도 내부적으로 공유 잠금을 걸게 됩니다.
구체적으로는 Next-Key Lock(레코드 + 갭에 대한 잠금)을 통해, 다른 트랜잭션에서 해당 범위에 삽입·수정·삭제 작업을 할 수 없도록 제한합니다.
결과적으로 SERIALIZABLE 수준은 가장 안전한 격리 수준이지만, 트랜잭션 간 충돌과 잠금 경합이 빈번해져 성능 저하가 크기 때문에, 일반적인 트랜잭션 처리에서는 신중하게 사용하시는 것이 좋습니다.
REPEATABLE READ
MySQL(InnoDB)의 기본 격리 수준입니다.
한 트랜잭션 내에서 여러 번 SELECT를 수행하더라도, 항상 같은 결과를 보장하는 것이 핵심입니다.
즉, 트랜잭션 도중 다른 트랜잭션이 데이터를 수정하거나 삽입하더라도, 현재 트랜잭션에서는 그 변화가 보이지 않습니다.
✅MVCC란?
일반적인 RDBMS에서는 레코드가 변경되기 전의 데이터를 언두(undo) 공간에 백업해둡니다.
따라서 변경 전과 변경 후의 데이터가 모두 존재하게 되며, 동일한 레코드에 여러 버전의 데이터가 존재하는 이 방식을 MVCC라고 불립니다.
MVCC를 통해 트랜잭션이 롤백되었을 때 데이터를 복원할 수 있을 뿐만 아니라, 트랜잭션 간에 접근 가능한 데이터를 세밀하게 제어할 수 있습니다.
각각의 트랜잭션은 순차적으로 증가하는 고유한 트랜잭션 번호를 가지며, 백업된 레코드에는 해당 데이터를 백업한 트랜잭션 번호도 함께 저장됩니다.
이후 해당 데이터가 더 이상 필요하지 않다고 판단되면, 백그라운드 쓰레드가 주기적으로 삭제를 수행합니다.
REPEATABLE READ는 MVCC를 활용하여 하나의 트랜잭션 안에서 동일한 SELECT 문에 대해 항상 동일한 결과를 보장합니다. 하지만 트랜잭션 중간에 새로운 레코드가 추가되는 경우, 예상하지 못한 결과가 발생할 수 있습니다.
그래서 MVCC의 역할은?
1. 트랜잭션 롤백 시 데이터 복원 가능
2. 트랜잭션 간 데이터 접근을 세밀하게 제어 가능
이렇게 MVCC를 이용해서 Mysql은 동일성을 보장해주는데 어떤 상황에서 팬텀리드가 발생하는 걸까요??
문제 발생 상황
단순 SELECT만 사용하면 문제가 전혀 발생하지 않습니다. mysql은 트랜잭션 번호를 기준으로 자신보다 먼저 시작된 트랜잭션에서 커밋된 데이터만 조회합니다.
따라서 이후에 실행된 트랜잭션의 변경 사항은 무시하고, 언두 로그를 참고하여 이전 버전의 데이터를 읽게 됩니다.
결과적으로 사용자 A가 데이터를 수정하고 커밋했더라도, 사용자 B는 동일한 SELECT 문을 실행했을 때 항상 이전과 같은 데이터를 조회하게 됩니다.
그렇지만 문제는 바로 SELECT FOR ... UPDATE입니다.
SELECT FOR UPDATE는 잠금 있는 읽기(locking read)입니다.
즉, 읽으면서 쓰기 잠금(Exclusive Lock)을 걸기 때문에 MVCC가 적용되지 않고 실제 테이블의 최신 데이터를 직접 조회하게 됩니다.
이때 팬텀리드가 발생합니다.
Transaction B 시작
START TRANSACTION;
SELECT * FROM member WHERE id >= 50 FOR UPDATE;
- 트랜잭션 B는 id >= 50 조건으로 SELECT FOR UPDATE 실행
- 현재 테이블에는 id = 50인 데이터만 존재 → 이 레코드에 베타적 잠금(쓰기 잠금)이 걸림
- 주의: 일반적인 DBMS에서는 갭 락이 없기 때문에, id > 50 구간은 잠금되지 않음
Transaction A가 새로운 레코드 추가
START TRANSACTION;
INSERT INTO member (id, name) VALUES (51, '홍길순');
COMMIT;
- 트랜잭션 A는 아무런 제약 없이 id = 51인 데이터를 삽입함
- COMMIT되었기 때문에, 이후의 쿼리에서는 id = 51이 보이게 됨
Transaction B에서 같은 조건으로 다시 SELECT
SELECT * FROM member WHERE id >= 50 FOR UPDATE;
- 이전에 id = 50 하나만 조회됐던 쿼리에서, 이제는 id = 51도 함께 조회됨
- 즉, 같은 트랜잭션 안인데도 SELECT 결과가 달라짐 → 팬텀 리드 발생
요약
- SELECT FOR UPDATE는 MVCC가 아닌 실제 테이블 데이터를 직접 읽음
- 언두 로그를 활용하지 않기 때문에, MVCC처럼 이전 스냅샷을 참조하지 않음
- 일반적인 RDBMS에서는 갭 락이 없어서, 해당 범위 외의 레코드 삽입을 막지 못함
- 그 결과, 같은 SELECT 쿼리임에도 조회 결과가 달라지는 팬텀 리드가 발생함
MySQL(InnoDB)에서는 왜 괜찮은가?
MySQL에서는 SELECT FOR UPDATE를 실행하면:
- 조건에 일치하는 레코드(id=50)에 레코드 잠금
- 조건 범위에 포함될 수 있는 갭(id > 50)에도 갭 락 적용
- 즉, 트랜잭션 A가 id=51 INSERT 시도 시 → 대기하거나 타임아웃
→ SELECT FOR UPDATE 사용 시, 팬텀 리드 방지 가능
그렇지만 같은 트랜잭션에서 처음에 일반 SELECT를 사용하고, 이후에 SELECT FOR UPDATE를 사용하는 경우에는 여전히 팬텀리드가 발생합니다.
SELECT(트랜잭션 A) -> insert(트랜잭션 B) -> SELECT FOR UPDATE(트랜잭션 A)
SELECT는 언두 로그를 보고 락을 잠그지 않기 때문에 그 사이에 다른 트랜잭션 B에서 Insert를 하는 경우, 이후에 트랜잭션 A에서 FOR UDPATE를 실행했을 때 팬텀리드가 발생합니다.
READ COMMITTED
READ COMMITTED는 대부분의 상용 DBMS에서 기본으로 사용하는 격리 수준이며, MySQL에서도 선택적으로 설정할 수 있습니다. 이 격리 수준에서는 "커밋된 데이터만 읽을 수 있다"는 원칙을 따릅니다.
동작 방식
- 트랜잭션 내에서 실행되는 모든 SELECT는 그 시점에 커밋된 최신 데이터를 읽음
- 즉, 같은 SELECT를 여러 번 수행하면 읽는 시점마다 결과가 달라질 수 있음
특징
- Dirty Read는 발생하지 않음 (커밋되지 않은 데이터는 읽지 않음)
- Non-repeatable Read는 발생함 (같은 쿼리를 두 번 실행할 때 결과가 달라질 수 있음)
- 팬텀 리드도 발생할 수 있음 (조건에 맞는 새로운 행이 생기는 경우)
→ 같은 트랜잭션 B 안에서 두 번 SELECT 했는데 결과가 다름 → Non-repeatable Read 발생
READ UNCOMMITTED
READ UNCOMMITTED는 가장 낮은 수준의 격리이며, 다른 트랜잭션에서 아직 커밋하지 않은 데이터도 읽을 수 있습니다.
동작 방식
- SELECT는 언제든 다른 트랜잭션의 미완료 데이터(Dirty Data)를 읽을 수 있음
특징
- Dirty Read 발생 가능
- Non-repeatable Read, 팬텀 리드 모두 발생 가능
- 성능은 가장 좋지만 데이터 정합성 보장이 거의 없음 → 일반적으로 실무에서는 거의 사용하지 않음
예시
→ 트랜잭션 B가 트랜잭션 A의 커밋되지 않은 데이터를 읽음 → Dirty Read 발생
정리
격리 수준 | Dirty Read | Non-repeatable Read | Phantom Read |
---|---|---|---|
READ UNCOMMITTED | 발생함 | 발생함 | 발생함 |
READ COMMITTED | 발생하지 않음 | 발생함 | 발생함 |
REPEATABLE READ | 발생하지 않음 | 발생하지 않음 | (MySQL에서는 방지됨) |
SERIALIZABLE | 발생하지 않음 | 발생하지 않음 | 발생하지 않음 |
'CS' 카테고리의 다른 글
[CS] JAVA의 JIT 컴파일러와 Warm Up (1) | 2025.03.12 |
---|---|
[CS] CS 기술면접 (2) (1) | 2025.03.10 |
[기술면접] CS 기술면접 질문 (2) | 2024.10.06 |