기본 콘텐츠로 건너뛰기

트랜잭션 격리 수준, 동시성과 정합성 사이에서 어떻게 선택할까

트랜잭션 격리 수준, 동시성과 정합성 사이에서 어떻게 선택할까

빠른 답

  • 트랜잭션 격리 수준은 동시에 실행되는 트랜잭션이 서로의 변경을 어디까지 볼 수 있는지 정하는 설정이다.
  • READ COMMITTED는 Dirty Read를 막지만, 같은 트랜잭션 안에서 같은 행을 다시 읽었을 때 값이 달라질 수 있다.
  • REPEATABLE READSERIALIZABLE은 더 강한 일관성을 제공하지만, DBMS의 MVCC, 락, 인덱스 상태에 따라 성능 영향이 달라진다.
  • 격리 수준은 이름만으로 고르기보다 쿼리 패턴, 트랜잭션 길이, 인덱스, 락 대기 상황을 함께 보고 결정해야 한다.

한눈에 비교

읽기 기준
READ UNCOMMITTED 는 커밋 전 변경까지 읽을 수 있고, READ COMMITTED 부터는 커밋된 데이터만 읽는다.
반복 조회
READ COMMITTED 에서는 같은 행을 다시 읽을 때 값이 바뀔 수 있고, REPEATABLE READ 는 같은 트랜잭션 안의 반복 읽기를 더 강하게 보장한다.
범위 조회
Phantom Read는 같은 조건으로 다시 조회했을 때 행의 개수나 집합이 달라지는 현상이며, DBMS의 MVCC와 범위 락 구현에 따라 관찰 결과가 달라진다.
동시성 비용
격리 수준이 높아질수록 일관성은 강해질 수 있지만 락 대기, 충돌, 재시도 비용이 늘 수 있다.
선택 기준
단순 조회인지, 결제·재고·계좌처럼 경쟁 상태를 막아야 하는 쓰기인지에 따라 격리 수준과 잠금 전략을 함께 봐야 한다.

왜 격리 수준은 이름만 외우면 헷갈릴까

트랜잭션 격리 수준은 동시에 여러 트랜잭션이 실행될 때 한 트랜잭션이 다른 트랜잭션의 변경을 어떻게 관찰할지 정한다. 낮은 격리 수준은 동시 처리에는 유리할 수 있지만 아직 확정되지 않은 값이나 중간 상태를 읽을 위험이 커진다. 높은 격리 수준은 더 강한 일관성을 제공하지만 대기와 충돌 비용이 늘 수 있다.

다만 “격리 수준이 높으면 무조건 느리다”처럼 단순하게 볼 수는 없다. 같은 REPEATABLE READ라도 MySQL InnoDB, PostgreSQL, SQL Server가 스냅샷, 락, 범위 잠금을 처리하는 방식은 다르다. 일반 SELECT인지, SELECT ... FOR UPDATE 같은 잠금 읽기인지에 따라서도 결과가 달라진다.

데이터베이스 실행 관점에서는 쿼리와 인덱스가 함께 중요하다. 같은 범위 조회라도 적절한 인덱스가 있으면 필요한 범위를 좁게 읽고 잠글 수 있다. 반대로 인덱스가 없으면 더 많은 행을 스캔하면서 트랜잭션 시간이 길어지고, 그만큼 다른 트랜잭션이 기다릴 가능성도 커진다.

선택 기준 매트릭스

읽기 중심 목록 조회
READ COMMITTED 를 우선 검토한다. 커밋된 최신 데이터를 읽고 긴 트랜잭션을 피하면 불필요한 대기를 줄이기 쉽다.
같은 트랜잭션 안에서 기준 값을 반복 검증하는 상황: REPEATABLE READ 를 검토한다. 계산 중 같은 행의 값이 흔들리면 안 되는 경우에 유리하다.
재고 차감, 계좌 이체, 선착순 처리
격리 수준만 올리기보다 조건부 UPDATE , 유니크 제약, SELECT ... FOR UPDATE 를 함께 사용한다.
범위 안에 새 행이 끼어들면 안 되는 상황
SERIALIZABLE 또는 DBMS별 범위 락 동작을 확인한다. 인덱스가 없으면 잠금 범위와 대기 시간이 커질 수 있다.
대량 배치와 온라인 트래픽이 섞이는 조건
트랜잭션을 짧게 나누고 실행 계획을 확인한다. 높은 격리 수준보다 긴 실행 시간이 더 큰 병목이 되는 경우가 많다.
운영 기준을 정하는 조건
DBMS 기본 격리 수준, MVCC 동작, 락 대기 지표, 재시도 정책을 함께 보고 선택한다.

세 가지 읽기 이상 현상

Dirty Read는 다른 트랜잭션이 아직 커밋하지 않은 변경을 읽는 현상이다. 예를 들어 주문 상태가 PAID로 바뀌었지만 이후 롤백될 수 있는데, 다른 트랜잭션이 그 중간 값을 읽고 배송 처리를 시작하면 실제로 확정되지 않은 상태를 기준으로 후속 작업이 진행된다.

Non-Repeatable Read는 같은 트랜잭션 안에서 같은 행을 두 번 읽었는데 값이 달라지는 현상이다. READ COMMITTED에서는 각 문장이 실행되는 시점에 커밋된 데이터를 보기 때문에, 첫 조회 이후 다른 트랜잭션이 커밋하면 두 번째 조회 결과가 바뀔 수 있다.

Phantom Read는 같은 범위 조건으로 다시 조회했을 때 결과 집합에 새 행이 나타나거나 사라지는 현상이다. 단일 행의 값보다 “조건을 만족하는 행의 존재 여부와 개수”가 업무 규칙에 중요할 때 문제가 된다. 예를 들어 “대기 주문이 10개 이하일 때만 새 주문을 받는다”는 규칙은 범위 안의 새 행 삽입에 민감하다.

예제 테이블과 인덱스

아래 예제는 주문 테이블에서 상태와 생성 시각을 기준으로 조회하는 상황을 가정한다. statuscreated_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: rangekey: 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;
}

markPaidIfPendingUPDATE 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

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...