트랜잭션 격리 수준, 동시성과 정합성 사이에서 어떻게 선택할까
빠른 답
- 트랜잭션 격리 수준은 동시에 실행되는 트랜잭션이 서로의 변경을 어디까지 볼 수 있는지 정하는 설정이다.
READ COMMITTED는 Dirty Read를 막지만, 같은 트랜잭션 안에서 같은 행을 다시 읽었을 때 값이 달라질 수 있다.REPEATABLE READ와SERIALIZABLE은 더 강한 일관성을 제공하지만, DBMS의 MVCC, 락, 인덱스 상태에 따라 성능 영향이 달라진다.- 격리 수준은 이름만으로 고르기보다 쿼리 패턴, 트랜잭션 길이, 인덱스, 락 대기 상황을 함께 보고 결정해야 한다.
목차
한눈에 비교
왜 격리 수준은 이름만 외우면 헷갈릴까
트랜잭션 격리 수준은 동시에 여러 트랜잭션이 실행될 때 한 트랜잭션이 다른 트랜잭션의 변경을 어떻게 관찰할지 정한다. 낮은 격리 수준은 동시 처리에는 유리할 수 있지만 아직 확정되지 않은 값이나 중간 상태를 읽을 위험이 커진다. 높은 격리 수준은 더 강한 일관성을 제공하지만 대기와 충돌 비용이 늘 수 있다.
다만 “격리 수준이 높으면 무조건 느리다”처럼 단순하게 볼 수는 없다. 같은 REPEATABLE READ라도 MySQL InnoDB, PostgreSQL, SQL Server가 스냅샷, 락, 범위 잠금을 처리하는 방식은 다르다. 일반 SELECT인지, SELECT ... FOR UPDATE 같은 잠금 읽기인지에 따라서도 결과가 달라진다.
데이터베이스 실행 관점에서는 쿼리와 인덱스가 함께 중요하다. 같은 범위 조회라도 적절한 인덱스가 있으면 필요한 범위를 좁게 읽고 잠글 수 있다. 반대로 인덱스가 없으면 더 많은 행을 스캔하면서 트랜잭션 시간이 길어지고, 그만큼 다른 트랜잭션이 기다릴 가능성도 커진다.
선택 기준 매트릭스
세 가지 읽기 이상 현상
Dirty Read는 다른 트랜잭션이 아직 커밋하지 않은 변경을 읽는 현상이다. 예를 들어 주문 상태가 PAID로 바뀌었지만 이후 롤백될 수 있는데, 다른 트랜잭션이 그 중간 값을 읽고 배송 처리를 시작하면 실제로 확정되지 않은 상태를 기준으로 후속 작업이 진행된다.
Non-Repeatable Read는 같은 트랜잭션 안에서 같은 행을 두 번 읽었는데 값이 달라지는 현상이다. READ COMMITTED에서는 각 문장이 실행되는 시점에 커밋된 데이터를 보기 때문에, 첫 조회 이후 다른 트랜잭션이 커밋하면 두 번째 조회 결과가 바뀔 수 있다.
Phantom Read는 같은 범위 조건으로 다시 조회했을 때 결과 집합에 새 행이 나타나거나 사라지는 현상이다. 단일 행의 값보다 “조건을 만족하는 행의 존재 여부와 개수”가 업무 규칙에 중요할 때 문제가 된다. 예를 들어 “대기 주문이 10개 이하일 때만 새 주문을 받는다”는 규칙은 범위 안의 새 행 삽입에 민감하다.
예제 테이블과 인덱스
아래 예제는 주문 테이블에서 상태와 생성 시각을 기준으로 조회하는 상황을 가정한다. status와 created_at을 함께 쓰는 인덱스는 조회 성능뿐 아니라 잠금 읽기에서 접근 범위를 줄이는 데도 영향을 준다.
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
amount INT NOT NULL,
created_at DATETIME NOT NULL,
INDEX idx_orders_status_created (status, created_at)
);
INSERT INTO orders VALUES
(1, 10, 'PENDING', 30000, '2026-04-13 10:00:00'),
(2, 11, 'PENDING', 15000, '2026-04-13 10:01:00'),
(3, 12, 'PAID', 42000, '2026-04-13 10:02:00');
status = 'PENDING' 조건과 created_at 범위 조건이 자주 함께 쓰인다면 위와 같은 복합 인덱스가 도움이 된다. 인덱스가 없으면 데이터베이스가 더 많은 행을 읽고 조건을 비교해야 하며, 트랜잭션이 길어질수록 락 대기와 커넥션 점유 비용도 함께 커질 수 있다.
트랜잭션 재현 SQL
MySQL InnoDB 기준으로 현재 세션의 격리 수준은 다음처럼 확인하고 바꿀 수 있다. 서비스 전체 기본값을 바꾸기 전에 세션 단위로 작은 재현 SQL을 만들어 보는 편이 결과를 해석하기 쉽다.
SELECT @@transaction_isolation;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT amount FROM orders WHERE id = 1;
Non-Repeatable Read는 두 세션을 나누어 확인할 수 있다. 세션 A가 같은 행을 두 번 읽는 동안 세션 B가 중간에 값을 바꾸고 커밋한다.
-- Session A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT amount FROM orders WHERE id = 1;
-- 결과: 30000
-- Session B
START TRANSACTION;
UPDATE orders SET amount = 35000 WHERE id = 1;
COMMIT;
-- Session A
SELECT amount FROM orders WHERE id = 1;
-- 결과: 35000
COMMIT;
이 결과는 READ COMMITTED가 잘못 동작했다는 뜻이 아니다. 각 조회 문장이 실행되는 시점에 이미 커밋된 값을 읽었기 때문에 두 번째 조회에서 35000이 보인다. 같은 흐름을 REPEATABLE READ의 일반 SELECT로 실행하면 DBMS 구현에 따라 트랜잭션 시작 시점의 스냅샷을 계속 보게 되어 같은 값이 유지될 수 있다.
실행 계획과 결과 해석
격리 수준을 조정했는데 쿼리가 느려졌다면 설정만 보기보다 실행 계획을 함께 봐야 한다. 아래 조회는 대기 주문을 생성 시각 순서로 가져오는 예시다.
EXPLAIN
SELECT id, user_id, amount
FROM orders
WHERE status = 'PENDING'
AND created_at >= '2026-04-13 10:00:00'
ORDER BY created_at
LIMIT 10;
인덱스를 잘 사용하면 출력은 대략 다음과 비슷하게 읽힌다.
id: 1
select_type: SIMPLE
table: orders
type: range
possible_keys: idx_orders_status_created
key: idx_orders_status_created
rows: 2
Extra: Using index condition
type: range와 key: idx_orders_status_created는 조건에 맞는 인덱스 범위를 사용한다는 뜻이다. 반대로 type: ALL, key: NULL, rows가 큰 값으로 나온다면 전체 스캔에 가까운 실행일 수 있다. 이런 쿼리가 FOR UPDATE나 높은 격리 수준과 결합되면, 격리 수준 자체보다 인덱스 부재가 더 큰 대기 원인이 될 수 있다.
락 대기와 운영 출력 예시
락 대기는 실제 출력으로 확인하는 편이 분명하다. 세션 A가 특정 주문을 잠근 뒤 커밋하지 않은 상태에서 세션 B가 같은 행을 수정하려고 하면 세션 B는 대기하거나 타임아웃이 발생할 수 있다.
-- Session A
START TRANSACTION;
SELECT *
FROM orders
WHERE id = 1
FOR UPDATE;
-- Session B
UPDATE orders
SET status = 'PAID'
WHERE id = 1;
세션 B가 제한 시간 안에 필요한 락을 얻지 못하면 다음과 같은 오류가 나타날 수 있다.
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
SHOW PROCESSLIST 예시
Id: 83
User: app
Command: Query
Time: 12
State: updating
Info: UPDATE orders SET status = 'PAID' WHERE id = 1
Id: 79
User: app
Command: Sleep
Time: 45
State:
Info: NULL
이 출력은 데이터베이스 전체가 멈췄다는 뜻이 아니라 세션 B가 필요한 락을 기다리고 있다는 뜻이다. Sleep 상태의 커넥션도 트랜잭션을 열어 둔 채 반환되지 않았다면 락을 붙잡고 있을 수 있다. 이때는 애플리케이션의 트랜잭션 경계, 예외 처리, 커넥션 반환, 외부 API 호출 위치를 함께 확인해야 한다.
애플리케이션 코드에서의 보완
경쟁이 있는 쓰기 작업은 격리 수준만으로 해결하기보다 업무 조건을 SQL에 직접 포함하는 편이 해석하기 쉽다. 재고 차감은 대표적인 예다.
UPDATE products
SET stock = stock - 1
WHERE id = 100
AND stock > 0;
영향받은 행 수가 1이면 차감 성공, 0이면 재고 부족으로 해석할 수 있다. 이 방식은 “읽고 판단한 뒤 업데이트”하는 흐름보다 경쟁 상태를 줄이는 데 유리하다.
Spring 애플리케이션에서는 트랜잭션별로 격리 수준을 명시할 수 있다. 다만 모든 메서드에 높은 격리 수준을 붙이기보다, 읽기 전용 조회와 경쟁이 있는 쓰기를 나누어 보는 편이 운영상 부담을 줄인다.
@Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
public OrderSummary readOrder(long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("order not found"));
return OrderSummary.from(order);
}
@Transactional
public boolean pay(long orderId) {
int updated = orderRepository.markPaidIfPending(orderId);
return updated == 1;
}
markPaidIfPending이 UPDATE orders SET status = 'PAID' WHERE id = ? AND status = 'PENDING'처럼 조건부 갱신을 사용하면 동시에 두 요청이 들어와도 한쪽만 성공하도록 만들 수 있다. 격리 수준은 배경 조건이고, 실제 정합성은 제약 조건, 원자적 갱신, 인덱스, 짧은 트랜잭션 경계가 함께 만든다.
흔한 오해
READ COMMITTED는 다른 트랜잭션의 접근을 모두 막는 설정이 아니다. 핵심 동작은 커밋된 데이터만 읽는다는 데 있다. 쓰기 충돌이나 잠금은 별도의 락 정책, 쿼리 형태, 인덱스 상태와 함께 판단해야 한다.
SERIALIZABLE도 항상 테이블 전체를 잠근다고 설명하기는 어렵다. 더 강한 격리 수준이 더 많은 대기와 충돌을 만들 수는 있지만 실제 잠금 범위는 DBMS, 실행 계획, 조건절, 인덱스에 따라 달라진다.
마지막으로, DBMS별 기본 격리 수준과 MVCC 구현 차이를 가볍게 넘기면 마이그레이션 때 같은 SQL의 결과를 다르게 해석할 수 있다. MySQL에 익숙한 팀이 PostgreSQL로 이동하거나 반대 방향으로 이동할 때는 반복 조회, 범위 조회, 잠금 읽기를 작은 SQL로 재현해 보는 과정이 필요하다.
원문 참고
https://www.maeil-mail.kr/question/61
댓글
댓글 쓰기