트랜잭션 격리 수준과 데드락 줄이기

2026. 10. 22. 13:00·IT
728x90
반응형

동시에 들어온 요청 두 개가 같은 데이터를 건드리면 무슨 일이 일어나는가. 재고가 1개 남았는데 두 명이 동시에 주문하면 어떻게 되는가. 이 질문의 답은 트랜잭션 격리 수준에 달려 있다. 이 글은 격리 수준이 실제로 무엇을 막아주고 무엇은 안 막아주는지, 그리고 데드락을 줄이는 방법을 정리한다.

격리 수준이 막는 세 가지 현상

SQL 표준은 동시성에서 생길 수 있는 문제를 세 가지로 정의한다.

더티 리드(Dirty Read). 아직 커밋되지 않은 다른 트랜잭션의 변경을 읽는 것. 그 트랜잭션이 롤백되면 존재한 적 없는 값을 읽은 셈이 된다.

반복 불가능 읽기(Non-repeatable Read). 같은 행을 두 번 읽었는데 값이 다른 것. 사이에 다른 트랜잭션이 커밋했기 때문이다.

팬텀 리드(Phantom Read). 같은 조건으로 두 번 조회했는데 행 개수가 다른 것. 사이에 조건에 맞는 행이 추가됐기 때문이다.

격리 수준은 이 셋을 어디까지 막을지를 정한다.

격리 수준 더티 리드 반복 불가능 읽기 팬텀 리드
READ UNCOMMITTED 발생 발생 발생
READ COMMITTED 방지 발생 발생
REPEATABLE READ 방지 방지 발생(표준 기준)
SERIALIZABLE 방지 방지 방지

실무에서 알아둘 점은 기본값이 데이터베이스마다 다르다는 것이다. PostgreSQL과 오라클은 READ COMMITTED가 기본이고, MySQL(InnoDB)은 REPEATABLE READ가 기본이다. 같은 코드가 데이터베이스에 따라 다르게 동작할 수 있다는 뜻이다.

PostgreSQL은 REPEATABLE READ에서 팬텀 리드도 막는다. 표준보다 강하게 구현돼 있다. 이런 차이 때문에 "이론상 이렇다"보다 쓰는 데이터베이스의 문서를 확인하는 것이 맞다.

격리 수준으로는 안 막히는 문제

여기가 핵심이다. 재고 차감 같은 작업은 격리 수준을 올려도 그것만으로는 안전하지 않다.

-- 위험한 패턴
SELECT stock FROM products WHERE id = 1;   -- 애플리케이션이 1 을 읽음
-- 애플리케이션에서 1 >= 1 판단
UPDATE products SET stock = 0 WHERE id = 1;

두 트랜잭션이 동시에 SELECT하면 둘 다 1을 읽는다. 둘 다 "재고 있음"으로 판단하고 둘 다 주문을 만든다. 읽고 판단하고 쓰는 사이에 남이 끼어드는 것, 이걸 잃어버린 갱신(lost update)이라고 한다.

해법은 두 가지다.

비관적 잠금. 읽을 때 아예 잠근다.

SELECT stock FROM products WHERE id = 1 FOR UPDATE;

다른 트랜잭션은 이 행을 읽으려다 대기한다. 확실하지만 동시성이 떨어진다.

낙관적 잠금. 버전 컬럼을 두고 갱신할 때 확인한다.

UPDATE products SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5 AND stock >= 1;
-- 영향 받은 행이 0이면 다른 사람이 먼저 바꾼 것 → 재시도

충돌이 드물면 낙관적 잠금이 효율적이다. 충돌이 잦으면 재시도가 반복되므로 비관적 잠금이 낫다.

가장 단순한 방법은 계산을 데이터베이스에서 하는 것이다.

UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock >= 1;

읽고 판단하는 단계가 없으므로 끼어들 틈이 없다. 가능하면 이 형태를 먼저 고려한다.

데드락은 순서가 엇갈릴 때 생긴다

데드락은 두 트랜잭션이 서로가 가진 잠금을 기다릴 때 생긴다.

A는 100을 잡고 200을 기다리고, B는 200을 잡고 100을 기다린다. 영원히 풀리지 않는다. 데이터베이스가 이를 감지해 한쪽을 강제로 롤백시킨다.

줄이는 방법은 몇 가지가 정해져 있다.

잠금 순서를 통일한다. 가장 효과가 크다. 여러 행을 건드릴 때 항상 ID 오름차순으로 접근하면, 위 그림의 교차가 생기지 않는다. 계좌 이체라면 작은 번호부터 잠근다.

트랜잭션을 짧게 유지한다. 트랜잭션 안에서 외부 API를 호출하거나 파일을 쓰지 않는다. 잠금을 오래 들고 있을수록 충돌 확률이 올라간다. 트랜잭션을 열기 전에 필요한 데이터를 모아두는 편이 낫다.

건드리는 행을 줄인다. 인덱스가 없어서 넓은 범위를 잠그는 경우가 있다. MySQL은 인덱스가 없으면 갭 잠금 범위가 넓어진다. 인덱스 설계가 동시성에도 영향을 준다.

재시도를 준비한다. 데드락은 완전히 없앨 수 없다. 데드락 예외를 잡아 짧은 대기 후 재시도하는 코드를 넣어두면, 사용자에게는 아무 일도 없었던 것처럼 보인다.

격리 수준을 올리는 것이 답은 아니다

SERIALIZABLE로 올리면 모든 문제가 사라질 것 같지만, 대가가 있다.

PostgreSQL의 SERIALIZABLE은 직렬화 이상을 감지하면 트랜잭션을 실패시킨다. 즉 애플리케이션이 재시도를 반드시 구현해야 한다. 이걸 안 하면 사용자에게 오류가 그대로 노출된다.

MySQL의 SERIALIZABLE은 모든 SELECT에 공유 잠금을 걸어서 동시성이 크게 떨어진다. 읽기가 많은 서비스라면 체감이 크다.

그래서 일반적인 권장은 기본 격리 수준을 쓰되, 정확성이 필요한 지점에만 명시적으로 잠금을 거는 것이다. 전체를 조이는 대신 필요한 곳만 조인다. 재고 차감, 잔액 변경, 좌석 예약처럼 지점이 명확한 경우가 대부분이다.

한 가지 더. 이런 문제는 테스트로 잡기 어렵다. 순차적으로 도는 테스트에서는 동시성 버그가 안 나온다. 재고 하나를 두고 동시 요청 100개를 보내는 테스트를 한 번이라도 짜두면, 구현이 맞는지 확인할 수 있다. 운영에서 발견하는 것보다 훨씬 싸다.

참고 자료

  • PostgreSQL: Transaction Isolation — 격리 수준별 동작과 직렬화 실패 처리
  • MySQL: InnoDB Locking and Transaction Model — 잠금 종류, 갭 잠금, 데드락 감지
  • PostgreSQL: Explicit Locking — FOR UPDATE 등 명시적 잠금
728x90
반응형

'IT' 카테고리의 다른 글

환경 변수와 시크릿 관리, 어디까지 해야 하나  (0) 2026.10.24
메시지 큐를 언제 써야 하고 언제 쓰면 안 되나  (0) 2026.10.23
API 버저닝, 언제 올리고 언제 안 올리나  (0) 2026.10.21
타임아웃과 재시도, 서킷 브레이커까지  (0) 2026.10.20
헬스체크와 무중단 배포, 프로브 제대로 쓰기  (0) 2026.10.19
'IT' 카테고리의 다른 글
  • 환경 변수와 시크릿 관리, 어디까지 해야 하나
  • 메시지 큐를 언제 써야 하고 언제 쓰면 안 되나
  • API 버저닝, 언제 올리고 언제 안 올리나
  • 타임아웃과 재시도, 서킷 브레이커까지
밍글링글링
밍글링글링
mingling - 밍글링, 밍글밍글링. 코드와 어우러지다. IT/ 프로그래밍/소스
    반응형
    250x250
  • 밍글링글링
    mingling
    밍글링글링
  • 전체
    오늘
    어제
    • 밍글링글링 (463)
      • Flutter (2)
      • 일상생활 (8)
        • 리뷰 (1)
        • 생활정보 (4)
        • 맛집 (0)
        • 여행 (0)
        • 모든정보 (3)
      • JAVA (126)
        • 개념 (6)
        • 예제 (115)
        • Exception (2)
      • C (1)
        • C (1)
        • C++ (0)
        • C# (0)
      • JS (29)
        • JavaScript (18)
        • JQuery (5)
        • AJax (0)
        • NODE.JS (6)
        • Angular.JS 2.0 (0)
      • WEB (87)
        • HTML (6)
        • CSS (61)
        • JSP (20)
        • JSTL (0)
      • FrameWork (8)
        • Spring (8)
        • BootStrap (0)
        • MyBATIS (0)
        • JUnit (0)
      • 외부 라이브러리 (5)
      • 공유 소스 관리 (5)
        • Git (5)
        • SVN (0)
      • 빅데이터 프로그래밍 (37)
        • Python (37)
        • R Programming (0)
      • DB (7)
        • ORACLE (0)
        • MySql (6)
      • Development Tools (7)
        • StarUML (0)
        • eXERD (0)
        • Eclipse (4)
      • SKILL (6)
        • Migration (0)
        • Security (6)
      • MicroSoft (0)
        • Excel (0)
        • Word (0)
      • Android (0)
      • Server (21)
        • Ubuntu (5)
        • Linux (15)
      • IOS (0)
      • XML (0)
      • 미디어 (0)
      • 공지사항 (3)
      • NETWORK (1)
      • 게임 (4)
        • 피파 (1)
        • 리니지M (0)
        • 배틀그라운드 (1)
        • 듀랑고 (2)
      • 세상 이슈 (6)
      • 일렉트론 (0)
      • 대회 소식 (4)
      • 업무 (2)
      • Express, Vue (6)
      • docker (11)
      • svelte (3)
      • 블록체인 (1)
      • IT (66)
      • Rust (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 미디어로그
    • 위치로그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    vue cli
    제미나이
    java casting
    spring java
    자바 클래스
    오류
    gitlab 설치
    러스트
    Rust lang
    mysql db
    nginx
    ubuntu
    nginx ssl 설정
    nginx ssl 적용
    css tb
    에디터 팁
    vscode
    css perspective
    lang rust
    Extension Bisect
    자바 객체 지향
    리눅스 설치
    proxy pass
    svelte
    브라우저 자동화
    티스토리 자동화
    jsp parameter
    클론코딩
    자바 for문
    React Compiler
    AI 코딩 에이전트
    css table
    docker
    vue 설치
    AI 반도체
    자바 배열
    Java Array
    Node
    프런트엔드
    ssl 인증서 발급
    css float
    자바 exception
    css transition
    servlet class
    SSL 인증서
    rust linux
    자바 생성자
    VS Code 팁
    jsp include
    API 설계
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
밍글링글링
트랜잭션 격리 수준과 데드락 줄이기
상단으로

티스토리툴바