광고
~ / posts / developer
smith@lab:~/posts$ cat developer/transaction-isolation-level-selection-retry-checklist.md

트랜잭션 격리 수준 고르는 법, MySQL·PostgreSQL 판단표와 재시도 비용

격리 수준을 바꿀지 고민된다면 순서부터 바꿔 보세요. 결론부터 말하면 대부분의 서비스는 기본값을 그대로 두고, 동시성 버그는 쿼리 모양(원자적 UPDATE·잠금 읽기·유니크 제약)으로 먼저 막고, 여러 행에 걸친 규칙만 SERIALIZABLE 과 재시도로 지키는 게 실무 기본값이에요. 격리 수준을 전역으로 올리면 버그 하나를 막는 대가로 모든 트랜잭션이 락 대기나 재시도 비용을 내요.

함정은 두 DB 의 기본값과 동작이 다르다는 점이에요. PostgreSQL 은 READ COMMITTED(RC), MySQL InnoDB 는 REPEATABLE READ(RR)가 기본이에요. 같은 RR 이라도 PostgreSQL 은 동시 갱신에 오류를 내고, InnoDB 는 기다렸다가 최신 행에 덮어써요. 이 글은 내부 구조 설명 대신 언제 무엇을 고르나, 얼마나 드나, 장애가 나면 어디부터 보나를 다뤄요. 근거는 2026-11-04 에 연 PostgreSQL 트랜잭션 격리 문서와 MySQL 8.4 InnoDB 격리 수준 문서예요.

광고

먼저 지금 격리 수준부터 확인해요

ORM·커넥션 풀이 접속할 때 세션 값을 바꾸기도 해서, 설정 파일보다 애플리케이션이 실제로 쓰는 커넥션에서 물어보는 게 정확해요.

-- MySQL
SELECT @@GLOBAL.transaction_isolation, @@SESSION.transaction_isolation;

-- PostgreSQL
SHOW default_transaction_isolation;   -- 새 트랜잭션의 기본값
SHOW transaction_isolation;           -- 지금 트랜잭션의 값

MySQL SET TRANSACTION 문서에 따르면 SESSION·GLOBAL 없이 쓴 SET TRANSACTION ISOLATION LEVEL ... 은 다음 트랜잭션 하나에만 적용되고, 트랜잭션 안에서는 쓸 수 없어요(오류 1568). SET SESSION 은 그 커넥션의 이후 트랜잭션 전부에 남아요. 풀에서 빌린 커넥션에 SET SESSION 을 하고 돌려주면, 다음에 그 커넥션을 빌린 전혀 다른 요청이 높은 격리 수준으로 돌아요. 특정 작업만 올리려면 트랜잭션 단위(BEGIN ISOLATION LEVEL ..., 프레임워크의 트랜잭션 속성)로 지정하는 편이 안전해요.

상황별 격리 수준 판단표

아래 표는 "격리 수준을 몇으로 하나"보다 "어떤 수단으로 막나"를 먼저 적었어요. 대부분의 칸이 기본값 유지인 이유는, 격리 수준을 올려도 그 문제가 안 풀리거나 더 싼 수단이 있기 때문이에요.

상황흔한 버그먼저 쓸 수단격리 수준
목록·상세 화면 조회없음그대로기본값(RC 또는 RR)
재고·포인트·잔액 차감갱신 손실, 음수 재고SET v = v - 1 WHERE v >= 1 원자적 UPDATE, 영향 행 수 확인기본값
읽고 계산해서 저장(복잡한 로직)갱신 손실SELECT ... FOR UPDATE 또는 버전 컬럼 낙관적 잠금기본값
없으면 넣기(가입·쿠폰 발급)중복 행유니크 제약 + 중복 키 오류 처리기본값
여러 행에 걸친 규칙(당직 최소 1명, 예약 겹침, 합계 한도)쓰기 스큐PG: SERIALIZABLE + 재시도 / MySQL: 규칙 대상 행을 잠금 읽기, 또는 제약·잠글 대표 행 하나PG SERIALIZABLE
리포트·정산의 여러 쿼리가 같은 시점쿼리마다 숫자가 다름읽기 전용 트랜잭션 하나로 묶기RR(읽기 전용, 짧게)
대량 배치 UPDATE락 대기·데드락작은 묶음으로 나눠 커밋, 같은 순서로 접근MySQL 은 RC 검토

예약 겹침처럼 "범위"가 문제인 규칙은 PostgreSQL 이라면 배제 제약(EXCLUDE)으로 DB 가 직접 막게 할 수도 있어요. 규칙을 제약으로 옮길 수 있으면 격리 수준도 재시도 코드도 필요 없어져서 가장 싸요.

읽기만 하나, 한 행을 읽고 고치나, 없으면 넣는 로직인가, 여러 행에 걸친 규칙이 있나, 여러 쿼리가 한 시점을 봐야 하나를 차례로 물어 기본값 유지·원자적 UPDATE·유니크 제약·SERIALIZABLE 재시도·읽기 전용 RR 중 하나를 고르는 순서도
위에서부터 답하면 필요한 수단이 정해져요

애그리거트 단위로 버전 컬럼을 두는 낙관적 잠금은 DDD 애그리거트 경계 판단표 글과 JPA 애그리거트 루트 버전 강제 증가 글에서 충돌률까지 계산했어요. 여러 서버가 DB 밖의 자원을 두고 다툰다면 격리 수준이 닿지 않으니 레디스 분산 락 판단표를 보세요.

같은 이름, 다른 동작 — 엔진별로 꼭 알아 둘 차이

판단표를 엔진에 옮길 때 가장 많이 틀리는 곳 세 가지예요.

1. 갱신 손실은 InnoDB RR 에서도 남아요. 값을 애플리케이션으로 읽어 와서 계산한 뒤 SET v = 계산값 으로 쓰면, PostgreSQL RR 은 뒤늦은 쪽에 직렬화 실패를 내지만 InnoDB RR 은 기다렸다가 최신 행에 그대로 덮어써요. "MySQL 은 RR 이 기본이니 안전하다"는 판단이 가장 위험해요. InnoDB 에서는 처음 읽을 때 FOR UPDATE 를 걸거나 계산을 SQL 안으로 옮겨야 해요.

2. MySQL 을 RC 로 내리면 바이너리 로그 형식이 바뀌어요. 문서에 따르면 RC 에서는 행 기반 바이너리 로그만 지원되고, binlog_format=MIXED 이면 자동으로 행 기반으로 기록해요. 대신 RC 에서는 잠금 읽기·UPDATE·DELETE 가 간격(gap)을 잠그지 않아서(외래 키·중복 키 검사 제외) 경합과 데드락이 줄고, 팬텀은 다시 생겨요.

3. PostgreSQL SERIALIZABLE 은 복제본에서 못 써요. PostgreSQL 핫 스탠바이 문서는 핫 스탠바이에서 SERIALIZABLE 을 지정하면 오류가 난다고 적어요. 읽기 부하를 복제본으로 돌리는 구조라면, 직렬 가능성이 필요한 읽기는 주 서버에서 해야 해요.

항목PostgreSQL 18MySQL 8.4 InnoDB
기본값READ COMMITTEDREPEATABLE READ
RR 에서 동시 갱신직렬화 실패(40001), 트랜잭션 재시도기다린 뒤 최신 커밋 버전에 적용
SERIALIZABLE 방식읽기를 막지 않고 위험하면 사후 실패일반 SELECT 를 FOR SHARE 로 바꿔 락 대기(autocommit 꺼졌을 때)
재시도가 필요한 오류40001, 40P01(데드락)1213(데드락, SQLSTATE 40001), 1205(락 대기 초과)
락 대기 기본 한도무제한(lock_timeout 0)innodb_lock_wait_timeout 50초

재시도 비용 — 실패율별 기대 시도 수

SERIALIZABLE(PostgreSQL)이나 PostgreSQL RR 을 쓰면 재시도는 선택이 아니에요. 문서도 이 수준의 애플리케이션은 직렬화 실패에 대비해 재시도해야 하고, 실패하면 트랜잭션 전체를 처음부터 다시 돌리라고 적어요. 그러면 재시도가 얼마나 드는지 계산해 볼 수 있어요.

시도마다 실패할 확률을 p 라 하고 시도끼리 독립이라고 가정하면, 성공할 때까지 기대 시도 수는 1 ÷ (1 − p) 예요. 최대 4번(첫 시도 + 재시도 3번)까지 하면 끝내 실패할 확률은 p⁴ 이에요.

직렬화 실패율별 성공까지 기대 시도 수
00.250.50.7511.21.51% · 기대 시도 수 1회11%5% · 기대 시도 수 1.1회1.15%10% · 기대 시도 수 1.1회1.110%20% · 기대 시도 수 1.2회1.220%30% · 기대 시도 수 1.4회1.430%

최대 4번 시도면 최종 실패 확률은 p⁴ — 10%면 0.01%, 20%면 0.16%, 30%면 0.81%

단위: 회 · 자료: 시도마다 독립인 실패 확률 p 에서 1÷(1−p) 로 직접 계산
표로 보기
구분기대 시도 수
1%1
5%1.1
10%1.1
20%1.2
30%1.4

숫자로 보면 실패율 20% 도 평균 1.25번에 끝나서 생각보다 싸요. 비싼 건 두 경우예요.

같은 행에 몰리는 경합. 독립 가정이 깨져요. 동시에 c 개 트랜잭션이 같은 행을 읽고 고치면 한 번에 하나만 성공하고 나머지는 실패해 다시 겨룬다고 단순화해 볼게요. 모두 끝날 때까지 총 시도는 c(c+1)/2, 성공 하나당 평균 (c+1)/2 번이에요.

동시에 5개면 총 15번(성공당 3번), 10개면 55번(5.5번), 20개면 210번(10.5번)이에요.

조회수·좋아요 카운터처럼 한 행에 쓰기가 몰리는 곳을 높은 격리 수준의 "읽고 계산해서 쓰기"로 처리하면 재시도가 경합의 제곱으로 늘어요. 이런 곳은 격리 수준을 올리지 말고 SET cnt = cnt + 1 로 DB 안에서 더하세요. 그러면 행 락을 차례로 기다릴 뿐 버려지는 시도가 없어요.

재시도 안에 외부 부작용이 있는 경우. 결제 API 호출, 메일 발송, 메시지 발행이 트랜잭션 안에 있으면 재시도마다 다시 나가요. 부작용은 커밋 뒤로 빼거나(아웃박스 테이블), 멱등 키를 붙여야 재시도가 안전해요.

오래 열린 트랜잭션의 비용 — 붙잡힌 옛 버전 계산

MVCC 에서는 열려 있는 가장 오래된 스냅샷이 필요로 하는 옛 버전을 지울 수 없어요. MySQL 퍼지 설정 문서는 쓰기가 많거나 오래 도는 트랜잭션이 있으면 읽기 전용이어도 History list length 가 늘어난다고 적고, PostgreSQL 클라이언트 설정 문서도 열린 트랜잭션이 최근 죽은 튜플의 VACUUM 을 막아 테이블이 부푼다고 적어요.

얼마나 쌓이는지 예시로 계산해 볼게요. 초당 500행을 UPDATE 하는 테이블이 있고, 옛 버전 하나가 헤더 포함 약 200바이트라고 가정해요. 그동안 지우지 못하는 버전 수는 단순히 초당 갱신 행 수 × 트랜잭션이 열려 있던 시간이에요.

트랜잭션이 열려 있던 시간별 붙잡힌 옛 버전 용량 (초당 500행 갱신)
1분1분 · 6MB6MB10분10분 · 60MB60MB1시간1시간 · 360MB360MB8시간8시간 · 2,880MB2,880MB

8시간이면 옛 버전 1,440만 개 — 퇴근 전 BEGIN 만 하고 놔둔 세션 하나로 생기는 양이에요

단위: MB · 자료: 500행/초 × 시간 × 버전당 200바이트 가정으로 직접 계산 (예시)
표로 보기
구분붙잡힌 용량
1분6
10분60
1시간360
8시간2,880

용량만이 아니라 스캔이 지우지 못한 버전을 건너뛰느라 느려져요. 흔한 원인은 세 가지예요.

PostgreSQL 은 idle_in_transaction_session_timeout, transaction_timeout 으로 이런 세션을 끊을 수 있어요. 둘 다 기본값은 0(끔)이에요. 서버 전체에 걸면 정상 배치까지 끊길 수 있으니, 애플리케이션 계정에 역할 단위로 거는 방식을 많이 써요.

광고
광고

락 대기·데드락·40001 이 늘 때 확인 순서

알림이 울리면 아래 순서로 보세요. 대부분은 1·2단계에서 원인이 나와요.

MySQL InnoDB 와 PostgreSQL 각각에 대해 오래 열린 트랜잭션, 막는 세션, 데드락 기록, 옛 버전 쌓임, 갭 락이나 술어 락 승격을 차례로 확인하는 5단계 점검 카드
1단계 오래 열린 트랜잭션에서 원인이 가장 자주 나와요

1. 오래 열린 트랜잭션. MySQL 은 information_schema.innodb_trx 의 trx_started, PostgreSQL 은 pg_stat_activity 의 xact_start 를 오래된 순으로 봐요.

-- PostgreSQL: 5분 넘게 열린 트랜잭션
SELECT pid, state, now() - xact_start AS 열린시간, left(query, 60)
FROM pg_stat_activity
WHERE xact_start < now() - interval '5 minutes'
ORDER BY xact_start;

2. 누가 누구를 막고 있나. MySQL 은 performance_schema.data_lock_waits 와 data_locks 를, PostgreSQL 은 pg_blocking_pids(pid) 로 막는 쪽을 찾아요. 막는 쪽이 1단계의 오래된 트랜잭션인 경우가 많아요.

3. 데드락 기록. MySQL 데드락 대응 문서대로 SHOW ENGINE INNODB STATUS 의 LATEST DETECTED DEADLOCK 절에서 가장 최근 데드락을 보고, 자주 나면 innodb_print_all_deadlocks 를 잠시 켜서 전부 에러 로그에 남겨요. 문서의 처방은 트랜잭션을 작고 짧게, 여러 테이블·행을 고칠 때 늘 같은 순서로, 잠금 읽기를 쓴다면 RC 같은 낮은 수준을 검토하라는 거예요. PostgreSQL 은 pg_stat_database.deadlocks 로 횟수를 보고 서버 로그에서 내용을 봐요. 데드락 검사는 deadlock_timeout(기본 1초)만큼 기다린 뒤에 돌아요.

4. 옛 버전 쌓임. MySQL 은 INNODB STATUS 의 History list length(보통 수천 이하), PostgreSQL 은 pg_stat_activity.backend_xmin 이 오래된 세션과 pg_stat_user_tables.n_dead_tup 을 봐요.

5. 락 범위가 넓어진 이유. InnoDB RR 에서 WHERE 조건 컬럼에 인덱스가 없으면 훑는 범위 전체에 next-key 락이 걸려 엉뚱한 INSERT 까지 막혀요. 실행 계획부터 확인하세요. PostgreSQL SERIALIZABLE 은 순차 스캔이 테이블 단위 술어 락을 잡고, 술어 락 테이블 메모리가 모자라면 락을 묶어 올리면서 직렬화 실패가 늘어날 수 있다고 문서가 적어요. 인덱스 스캔이 되게 하거나 max_pred_locks_per_transaction 등을 늘려요.

격리 수준을 바꾸기 전에 따질 것

전역 기본값을 바꾸는 건 스키마 변경만큼 영향이 커요. 바꾸기 전에 이 다섯 가지를 확인하세요.

  1. 재시도 코드가 모든 쓰기 경로에 있나. PostgreSQL 을 RR·SERIALIZABLE 로 올리면 지금까지 안 나던 40001 이 모든 트랜잭션에서 날 수 있어요. 재시도가 없는 경로는 오류로 사용자에게 그대로 보여요.
  2. 트랜잭션 안에 외부 부작용이 있나. 있으면 재시도가 중복 결제·중복 발송이 돼요.
  3. 복제·백업 도구와 맞나. MySQL RC 는 행 기반 바이너리 로그가 전제고, PostgreSQL SERIALIZABLE 은 핫 스탠바이에서 못 써요.
  4. 읽기 전용 리포트가 길게 도나. 리포트 하나가 몇 시간짜리 스냅샷을 잡으면 위 계산처럼 옛 버전이 쌓여요. PostgreSQL 에서 직렬 가능한 리포트가 필요하면 SERIALIZABLE READ ONLY DEFERRABLE 이 시작 때만 잠깐 기다리고 이후에는 실패 걱정 없이 읽어요(문서가 SERIALIZABLE 이 RR 과 달리 막히는 유일한 경우라고 적어요).
  5. 동시 부하로 재 봤나. 단일 요청 테스트로는 차이가 안 보여요. 재시도율·락 대기 시간을 바꾸기 전후로 비교하세요.
광고
광고

자주 묻는 질문

MySQL 기본값 RR 을 RC 로 내려도 되나요?

많은 서비스가 그렇게 운영해요. 간격 락이 사라져 데드락과 락 대기가 줄어드는 대신 잠금 읽기에서 팬텀이 다시 생기고, 바이너리 로그가 행 기반이어야 해요. 갱신 손실·중복 방지를 원자적 UPDATE 와 유니크 제약으로 막고 있다면 RC 로 내려도 정합성 위험은 크게 늘지 않아요. 그 전에 "범위를 SELECT 로 확인하고 넣는" 코드가 있는지 찾아보세요.

PostgreSQL 에서 SERIALIZABLE 을 전역으로 켜도 되나요?

문서는 SERIALIZABLE 을 일관되게 쓰면 개별 트랜잭션만 맞으면 전체가 맞는다고 보장할 수 있어 개발이 단순해진다고 적어요. 다만 모든 쓰기 경로에 재시도가 있어야 하고, 순차 스캔이 많으면 실패율이 올라가요. 보통은 규칙이 걸린 트랜잭션에만 지정하고, 재시도율을 본 뒤 넓혀요.

40001 과 데드락 오류를 같은 방법으로 재시도해도 되나요?

네. 둘 다 트랜잭션 전체를 처음부터 다시 돌리면 돼요. PostgreSQL 은 40001(직렬화 실패)과 40P01(데드락), MySQL 은 1213(데드락)이 대상이에요. MySQL 1205(락 대기 초과)는 기본 설정에서 문장만 롤백되니, 롤백을 명시하고 전체를 재시도하는 쪽으로 맞추는 게 단순해요.

낙관적 잠금(버전 컬럼)을 쓰면 격리 수준은 신경 안 써도 되나요?

같은 행(애그리거트)을 동시에 고치는 갱신 손실은 버전 컬럼으로 막혀요. 하지만 서로 다른 행을 고치면서 생기는 쓰기 스큐(합계 한도·최소 인원)는 버전 컬럼이 못 막아요. 그 규칙에 걸린 행들을 한 애그리거트로 묶거나, 대표 행 하나를 잠그거나, SERIALIZABLE 을 써야 해요.

읽기 전용 트랜잭션도 짧아야 하나요?

네. 아무것도 쓰지 않아도 열려 있는 스냅샷이 옛 버전 정리를 막아요. 위 예시처럼 초당 500행을 고치는 테이블이면 1시간에 360MB 쯤 쌓여요. 리포트는 끝나는 즉시 커밋하고, 콘솔·BI 도구는 autocommit 을 켜 두세요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 트랜잭션 격리 수준과 MVCC 원리 — 팬텀 리드의 정체에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#트랜잭션 격리 수준 선택#READ COMMITTED vs REPEATABLE READ#SERIALIZABLE 재시도#40001 serialization failure#InnoDB 데드락 확인#idle in transaction#갱신 손실 lost update
← 이전 글LLM 접두사 캐시 라우팅 고르기, 복제본 수별 적중률과 GPU 비용다음 글 →타입스크립트 구조적 타이핑 실무 판단표, 브랜드 타입과 초과 속성 구멍
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →