트랜잭션 격리, 왜 재고 차감 로직에서 다시 고민하게 됐나?
결론부터 쓰면, 격리 수준은 "읽기·쓰기 비율"만으로 고르는 게 아니라 "여러 행의 관계를 한 트랜잭션에서 얼마나 보존해야 하는가"로 고른다. 단건 조회가 대부분이면 기본값에서 시작해도 되고, 재고·예약·잔액처럼 동시에 두 트랜잭션이 같은 행을 건드릴 수 있는 로직이면 Repeatable Read나 Serializable, 혹은 제약 조건·명시적 잠금까지 같이 검토해야 한다.
동시 주문 2건이 같은 재고 행을 동시에 차감하는 케이스를 겪은 뒤로, "기본 격리 수준이면 되겠지"라는 가정을 다시 점검하게 됐다. 결과부터 말하면 이 서비스는 Read Committed로 두고 애플리케이션 레벨 락과 유니크 제약을 추가하는 쪽으로 갔다. 그 과정을 적어본다.
핵심 정리
- PostgreSQL 17 기본 격리 수준은 Read Committed이며, 단순 CRUD에는 기본값으로 시작하는 게 합리적이다. 출처
- Read Uncommitted는 PostgreSQL 내부적으로 Read Committed처럼 동작하므로, "더 빠른 비커밋 읽기"를 기대하고 설정하면 안 된다.
- Repeatable Read는 트랜잭션 시작 시점 스냅샷을 유지해 phantom read까지 막지만, 긴 트랜잭션은 오래된 스냅샷 비용을 만든다.
- Serializable은 직렬 실행과 동일한 결과를 보장하는 대신 serialization failure를 전제로 재시도 로직을 필수로 설계해야 한다.
- "격리 수준을 올리면 무조건 느려진다"는 식의 고정 비율 단정은 근거가 없고, 실제 비용은 충돌률·트랜잭션 길이·읽기 집합 크기에 좌우된다.
Read Committed는 실제로 뭘 보여주고 뭘 숨기나?
Read Committed는 각 SELECT가 "그 쿼리가 시작하는 순간" 커밋된 데이터만 본다. 즉 같은 트랜잭션 안에서 쿼리 A를 실행하고, 그 사이 다른 트랜잭션이 커밋을 하고, 다시 쿼리 B를 실행하면 B는 A가 보지 못한 변경을 볼 수 있다. 출처
이게 왜 함정이 되냐면, "트랜잭션 하나 = 일관된 스냅샷 하나"라고 착각하기 쉽기 때문이다. 예를 들어 대시보드에서 총 재고 수량을 구하는 쿼리 3개를 한 트랜잭션에 묶어도, 각 쿼리는 서로 다른 시점의 커밋 상태를 볼 수 있다. 단건 조회나 짧은 트랜잭션에서는 이 차이가 체감되지 않지만, 여러 집계 쿼리를 묶어서 "하나의 보고서"를 만드는 로직이면 숫자가 서로 안 맞는 원인이 될 수 있다. 이런 경우가 바로 다음 절에서 다룰 Repeatable Read 후보다.
Repeatable Read의 스냅샷은 왜 공짜가 아닌가?
Repeatable Read는 트랜잭션이 시작되는 시점의 스냅샷을 트랜잭션이 끝날 때까지 유지한다. 트랜잭션 안의 후속 쿼리들은 동시에 커밋된 변경을 보지 않고, PostgreSQL 문서상 phantom read도 허용하지 않는다. 출처
대신 이 스냅샷을 오래 들고 있는 트랜잭션은 MVCC 정리(vacuum) 지연을 유발할 수 있다. 보고서 생성이나 대량 배치처럼 실행 시간이 긴 작업에 Repeatable Read를 걸면, "격리 수준이 더 안전해졌다"는 이득과 "오래된 스냅샷이 쌓여 테이블 블로트가 늘어날 수 있다"는 비용이 동시에 붙는다. 그래서 이 격리 수준을 쓸 때는 격리 수준 자체보다 "이 트랜잭션이 몇 초, 몇 분을 차지하는가"를 먼저 측정해야 한다. 실행 시간 측정 없이 격리 수준만 올리는 건 절반만 한 판단이다.
Serializable은 왜 "느린 게 아니라 재시도가 필요한 것"인가?
Serializable은 모든 커밋 결과가 어떤 직렬 실행 순서와 동일하도록 보장하는 가장 엄격한 수준이다. PostgreSQL은 이를 위해 충돌을 감지하면 serialization failure를 발생시키고, 그 트랜잭션은 커밋되지 않는다. 출처
여기서 흔히 놓치는 지점은, Serializable이 "느려지는 게 아니라 일부 트랜잭션이 실패하고 재시도되는 구조"라는 점이다. 즉 애플리케이션은 serialization failure를 받았을 때 전체 트랜잭션을 처음부터 다시 실행하는 경로를 반드시 갖고 있어야 한다. 이 경로가 없으면 Serializable은 단순히 "가끔 에러 뱉는 DB"가 된다. 그리고 "더 강한 일관성 = 무조건 더 느린 고정 비율"이라는 단정은 할 수 없다. 실제 비용은 충돌률, 트랜잭션 길이, 읽기 집합 크기에 따라 달라지고, 충돌이 거의 없는 워크로드라면 Serializable과 Read Committed의 체감 차이가 크지 않을 수도 있다. 반대로 충돌이 잦으면 재시도 비율이 올라가 실질 처리량이 떨어질 수 있다. 이 부분은 직접 운영 로그로 재시도 성공률을 측정하기 전엔 단정할 수 없는 영역이다.
Read Uncommitted를 설정하면 정말 더 빨라지나?
아니다. PostgreSQL에서 Read Uncommitted는 독립적인 더 낮은 격리 수준이 아니라, 요청해도 내부적으로 Read Committed처럼 동작한다. 출처
다른 RDBMS 경험이 있는 개발자가 "Read Uncommitted로 설정하면 dirty read를 감수하는 대신 더 빠른 집계가 가능하지 않을까"라고 기대하는 경우를 봤는데, PostgreSQL에서는 이 기대가 성립하지 않는다. dirty read 자체가 허용되지 않으므로, 설정값만 바꾸고 "이제 더 빠르겠지"라고 넘어가면 실제로는 아무 변화도 없는 설정을 추가한 셈이 된다. 이건 또한 PostgreSQL과 MySQL을 오갈 때 자주 걸리는 지점이기도 하다. MySQL InnoDB는 Repeatable Read가 기본으로 알려져 있어서, 마이그레이션 시 ORM 기본 격리 수준과 잠금 동작, 재시도 정책을 함께 점검해야 한다. 출처
격리 수준은 언제 올리고 언제 기본값에 머물러야 하나?
판단 기준은 "이 트랜잭션이 몇 개의 행 관계를 동시에 보존해야 하는가"다. 읽기 위주의 짧은 단건 조회는 Read Committed로 시작하고, 실제 쿼리 실행계획을 EXPLAIN (ANALYZE, BUFFERS)로 확인하는 쪽이 먼저다. 출처
여러 읽기 결과가 하나의 시점을 봐야 하는 보고서·집계 로직이면 Repeatable Read를 검토하되, 트랜잭션 실행 시간을 같이 측정한다. 재고 차감·중복 예약 방지·잔액 이체처럼 직렬 실행과 동일한 결과가 필요한 쓰기 트랜잭션은 Serializable이나 명시적 잠금, 유니크 제약 조건을 조합하고, serialization failure 재시도를 선택사항이 아니라 필수 경로로 설계한다.
반대로 피해야 할 패턴은 "일관성이 불안하니 일단 Serializable로 올리고 본다"는 식의 접근이다. 트랜잭션 길이가 길고 충돌이 잦은 워크로드에 Serializable을 덮으면 재시도 비율이 올라갈 수 있고, 그 재시도 로직이 허술하면 사용자에게 실패가 그대로 노출된다. 2026년 기준으로도 이 선택은 여전히 "충돌률을 먼저 재고 결정"하는 문제로 남아 있다.
쓰기 비율이 높은 테이블이라면 격리 수준과 별개로 인덱스 비용도 같이 본다. INSERT, UPDATE, DELETE마다 관련 인덱스가 함께 갱신되므로, 사용되지 않는 복합·부분 인덱스를 쌓아두면 쓰기 처리량과 저장 공간에 불리해질 수 있다. 복합 인덱스 (a, b, c)는 a, a+b, a+b+c 조건에 유리하지만 선두 컬럼을 건너뛴 조건에서는 활용 방식이 달라지므로, "컬럼을 많이 넣을수록 좋다"가 아니라 실제 쿼리 패턴을 기준으로 삼아야 한다.
정리: 격리 수준 선택, 무엇을 기준으로 남겨야 하나?
- 단순 조회·짧은 트랜잭션: Read Committed로 시작하고 실행계획부터 확인한다.
- 여러 쿼리가 하나의 시점을 봐야 함: Repeatable Read 후보, 단 트랜잭션 실행 시간을 같이 측정한다.
- 재고·예약·잔액처럼 직렬 결과가 필요함: Serializable + 재시도 경로, 또는 제약 조건·명시적 잠금 조합.
- 쓰기 비율이 높은 테이블: 격리 수준과 별개로 인덱스 사용률·쓰기 증폭을 같이 점검한다.
- 수치 비교는 버전, 하드웨어, 동시 사용자 수, 격리 수준, 재시도 정책을 모두 공개한 조건에서만 의미가 있다.