제목 끝의 「지지」는 판정이다. 증명 글은 실험 결과가 주장을 받쳐 주면 지지(supported), 주장을 뒤집으면 반증(refuted), 어느 쪽인지 가를 수 없으면 판정 불가(inconclusive)라고 적는다. 이 글은 지지다.

이 글은 MariaDB 스냅숏 격리 실험을 DB만 바꿔 다시 돌린 것이다. 장면, 실험 코드, 경우 이름((가)·(마)·(바)·(사)), 용어가 그 글과 같다. 겹치는 설명은 그 글의 절로 링크하고 여기서는 짧게만 적는다.


앞선 실험은 MariaDB에서만 쟀고, 표준 DB는 PostgreSQL로 바뀌었다

장면부터 다시 적는다. A가 회원 u2를 읽는다(version 0). 그사이 B가 같은 회원의 이름을 inner로 바꾸고 커밋한다(version 1). A가 이름을 outer로 바꿔 저장한다. 그대로 저장되면 B의 수정이 말없이 사라진다. 이것이 잃어버린 수정이고, 낙관락은 「읽었을 때의 version이 지금도 0인가」를 저장할 때 확인해 막는다. 자세한 장면은 앞선 글의 첫 절에 있다.

예외 종류별 처리 표준은 그 충돌에서 나는 OptimisticLockingFailureException(이하 OLFE)을 Repository 구현체의 catch로 잡아 USER_CONCURRENT_UPDATE(409)로 바꾸게 한다. 앞선 실험은 MariaDB 11.8에서 그 catch가 기본 설정으로는 돌지 않고, 스냅숏 격리 설정을 꺼야 돈다는 것을 쟀다.

그런데 표준을 실제로 적용하는 프로젝트는 PostgreSQL이었다. 그래서 질문이 바뀌었다 — PostgreSQL에서는 이 catch가 기본 설정 그대로 도는가, 그리고 그것이 무엇에 달려 있는가. 실험 폴더의 claim.md가 돌리기 전에 「같은 구현체 모양이 PostgreSQL에서도 catch에 닿는지, 그리고 그것이 격리 수준에 달렸는지는 재지 않았다」고 적어 두었다. 이 글이 그것을 잰다.


먼저 알아야 할 말 — PostgreSQL의 두 격리 수준

트랜잭션, 커밋, 롤백, @Transactional, @Version, flush(), Repository 구현체, 예외 번역은 앞선 글의 용어 절과 같다. 여기서 새로 필요한 말은 격리 수준 둘이다. 격리 수준은 동시에 도는 트랜잭션끼리 서로의 변경을 얼마나 보게 할지 정하는 규칙이다.

  • READ COMMITTED — PostgreSQL의 기본값이다(PostgreSQL 17 문서 13.2.1절 「Read Committed is the default isolation level in PostgreSQL」). 같은 절에 따르면 이 수준에서는 문장(쿼리)마다 그 문장이 시작될 때까지 커밋된 DB 모습을 새로 본다. 그래서 UPDATE도 시작할 때 이미 커밋된 최신 행을 보고 WHERE 조건을 맞춘다
  • REPEATABLE READ — 트랜잭션의 첫 문장(쿼리)이 시작될 때 찍은 DB 모습(스냅숏)을 트랜잭션이 끝날 때까지 본다(같은 문서 13.2.2절). 같은 절은, 이 수준의 트랜잭션이 고치려는 행을 그 뒤에 다른 트랜잭션이 고쳐 커밋했으면 「ERROR: could not serialize access due to concurrent update」로 트랜잭션을 롤백한다고 적는다
  • SQLState 40001 — DB가 오류를 알릴 때 붙이는 다섯 글자 표준 번호다. 위 거절이 실험에서 40001로 찍혔다. 앞선 실험의 MariaDB에서 오류 번호 1020이 하던 역할 — DB가 JPA보다 먼저 거절했다는 신호 — 을 여기서는 40001이 한다

READ COMMITTED의 동작을 위 장면에 대 보면 이렇다. A의 UPDATE는 「id가 u2이고 version이 0인 행」을 고치라고 보낸다. B는 그보다 먼저 커밋했으므로 DB는 B가 고친 최신 행(version 1)에 이 조건을 맞춰 보고, 맞지 않으니 고치지 않는다. 고친 행이 0개가 되고, JPA가 그것을 보고 낙관락 충돌로 판단한다. 이 판단의 소스는 앞선 글의 「소스로 따라가면 1」에 있다.


주장 — PostgreSQL 기본 READ COMMITTED에서는 표준 모양의 catch가 충돌을 번역한다

PostgreSQL 17 에서 격리 수준을 기본값 READ COMMITTED 로 두면,
예외 종류별 처리 표준 원칙 2·2-2 모양(수정 경로에서 try 안에 flush())의 Repository 구현체에서
catch (OptimisticLockingFailureException) 이 수정 경로 낙관락 충돌에 실행되어
USER_CONCURRENT_UPDATE 로 번역된다.
REPEATABLE READ 로 올리면 DB 가 먼저 거절(SQLState 40001)해 그 catch 에 닿지 않는다.

근거 종류는 1(기술 사실)이다. 받치는 표준은 예외 종류별 처리 표준 하나다. 판정은 앞 문장으로만 가른다. 뒷문장(REPEATABLE READ에서는 닿지 않는다)은 claim.md가 「판정에 쓰지 않고 원본 출력으로 읽는다」고 미리 적어 두었다. rr에서 쓰는 것은 (가)flush에서 「예외가 났나」(충돌이 재현됐나) 하나뿐이고, 그것도 판정 불가를 가르는 데만 쓴다(아래). rr에서 「catch에 닿았나」는 판정에 쓰지 않는다.


반증 조건 — READ COMMITTED의 (가)flush로 가르고, 대조군이 먼저 돌아야 반증한다

반증 조건은 돌리기 전에 claim.md에 적었다. 무리는 둘이다 — rc(READ COMMITTED, PostgreSQL 기본값 그대로)와 rr(REPEATABLE READ로 올림). 각각 Spring Boot 3.5.16과 4.1.1에서 돌린다.

  • 지지 — Boot 두 버전 모두 rc의 (가)flush에서 구현체의 OLFE catch가 실행되고, 호출한 쪽(Probe)이 ErrorCodeException(USER_CONCURRENT_UPDATE)을 받는다
  • 반증 — 둘 중 하나라도 그렇지 않다. 예외가 아예 안 나는 것도 반증이다. 단, 대조군 (사)가 네 조합(무리 2 × Boot 2) 중 하나에서라도 OLFE catch에 닿았을 때만 반증이다. 대조군은 결과가 이미 알려진 장면(동시 수정 없이 낡은 번호를 저장하면 catch에 닿는다)을 같이 돌려 실험 도구가 제대로 도는지 보는 경우다. 대조군마저 못 닿으면 (가)flush의 실패가 규칙 탓인지 실험 도구 탓인지 가를 수 없다(앞선 글의 반증 조건 절)
  • 판정 불가 — 앱이나 DB가 뜨지 않았다, 네 조합 중 기록이 빠졌다, 트랜잭션 안에서 물은 격리 수준이 의도한 값과 다르다, rr의 (가)flush에서 예외가 아예 나지 않았다(충돌이 재현되지 않았다). 그리고 지지가 아닌데 대조군도 어디서도 catch에 닿지 못했을 때다

판정은 이 순서로 본다 — 먼저 판정 불가 조건 넷, 그다음 지지, 지지가 아닐 때만 대조군을 보고 반증 또는 판정 불가를 가른다(result.md의 판정 규칙).

rr의 (가)flush가 판정 불가 조건에 들어간 이유는 앞선 실험의 iso=1(스냅숏 격리를 켠 MariaDB)과 같다. 두 무리는 같은 하네스로 같은 순서 코드(REQUIRES_NEW로 안쪽이 먼저 커밋하도록 고정)를 돌리고, 다른 것은 격리 수준뿐이다. REPEATABLE READ에서는 읽은 뒤 남이 고쳐 커밋한 행을 고치면 DB가 거절한다(위 「먼저 알아야 할 말」의 13.2.2절). 그래서 rr에서 예외가 났다면 그 순서 코드가 끼어들기를 실제로 만들었다는 뜻이고, 같은 코드를 도는 rc에서도 그 순서가 만들어졌다고 본다. 반대로 더 엄격한 수준에서조차 아무 예외가 없다면 끼어들기가 만들어지지 않은 것이라, rc에서 예외가 나지 않았을 때 그것이 충돌이 없어서인지 가를 수 없다. 이 연결은 같은 코드를 쓴다는 데 기댄 것이라 완전한 보증은 아니다. 반복은 1회다.


설계 — 하네스는 MariaDB 실험을 그대로 옮기고 DB와 격리 수준만 바꿨다

앞선 실험의 실험 프로그램(app/)을 새 폴더 proofs/postgres-read-committed-optimistic-lock/로 복사했다. 옛 폴더는 고치지 않았다. 바꾼 것은 셋이다.

  • DB — postgres:17 이미지와 PostgreSQL JDBC 드라이버. MariaDB 전용 설정(sessionVariables, innodb_*)은 걷어냈다
  • 표 만들기 — users(id, email UNIQUE, name, version)과 plains(id, name). id는 GENERATED BY DEFAULT AS IDENTITY(행을 넣을 때 DB가 번호를 붙인다)다
  • 격리 수준 축 — 아래 표
무리 서버 접속
rc PostgreSQL 기본값 그대로 아무것도 걸지 않는다
rr 같은 서버, 그대로 커넥션 풀(HikariCP)이 접속마다 REPEATABLE READ를 건다

커넥션 풀은 DB 접속을 미리 열어 두고 돌려쓰는 도구다. rr은 Spring Boot 설정 spring.datasource.hikari.transaction-isolation(실험에서는 같은 뜻의 환경 변수 SPRING_DATASOURCE_HIKARI_TRANSACTION_ISOLATION)에 TRANSACTION_REPEATABLE_READ를 넣어 걸었다. 서버 설정을 바꾸지 않은 것은, DB 컨테이너 하나로 두 무리를 돌리면서 서버를 rc와 똑같이 「손대지 않음」으로 두기 위해서다. @Transactional(isolation = ...)으로 거는 방법은 이 실험에서 돌리지 않았다.

설정이 정말 걸렸는지는 Service의 @Transactional 메서드 안에서 SHOW transaction_isolation을 실행해 확인했다. 실제 경우들이 도는 트랜잭션과 같은 길이다.

나머지 — 구현체 코드, (가)를 만드는 순서(REQUIRES_NEW로 안쪽이 먼저 커밋), (가)noflush·(마)·(바)·(사)의 뜻 — 는 앞선 실험과 같다. 본체 구현체, (가)의 순서, (마)·(바)·(사)를 보면 된다. 한 줄로 줄이면 이렇다.

  • (가)flush — 판정 본체. 표준 원칙 2-2 모양(try 안에서 flush())
  • (가)noflush — 같은 장면을 flush() 없는 옛 모양으로
  • (마) — @Version이 없는 표 plains에서 같은 끼어들기
  • (바) — 바깥이 다른 행(u3)을 먼저 읽어 스냅숏을 찍어 둔 뒤, 한 번도 읽지 않은 행(u4)을 SQL로 직접 고친다(그 행은 그사이 안쪽이 고쳐 커밋했다)
  • (사) — 대조군. 동시 수정 없이 낡은 version을 든 객체를 저장한다

bash run.sh 하나로 처음부터 다시 돌릴 수 있다. 실행마다 전용 PostgreSQL 컨테이너를 새로 띄우고 끝나면 지운다.


결과 — READ COMMITTED에서는 두 버전 모두 catch가 실행되어 번역됐다

판정은 지지다. 실행 환경은 PostgreSQL 17.10, Boot 3.5.16(Hibernate 6.6.53)과 4.1.1(Hibernate 7.4.5)이었다. 트랜잭션 안에서 물은 격리 수준은 rc가 read_committed, rr이 repeatable_read로 의도와 같았다. result.md의 원본에서 (가)flush와 대조군만 옮기면 이렇다.

무리  Boot    경우       catch  Probe가 받은 예외와 원인 사슬                                          번역  롤백
rc    3.5.16  (가)flush  OLFE   ErrorCodeException <- ObjectOptimisticLockingFailureException
                                  <- StaleObjectStateException                                       yes   yes
rc    4.1.1   (가)flush  OLFE   ErrorCodeException <- ObjectOptimisticLockingFailureException
                                  <- StaleObjectStateException <- StaleStateException                yes   yes
rr    3.5.16  (가)flush  none   CannotAcquireLockException <- LockAcquisitionException
                                  <- PSQLException(40001)                                            no    yes
rr    4.1.1   (가)flush  none   CannotAcquireLockException <- LockAcquisitionException
                                  <- PSQLException(40001)                                            no    yes
rc·rr 둘 다   (사)대조군  OLFE   ErrorCodeException <- ObjectOptimisticLockingFailureException
                                  <- StaleObjectStateException                                       yes   yes

표 읽는 법은 앞선 글의 결과 절과 같다. A <- B는 A 안에 원인으로 B가 들었다는 뜻이고, PSQLException은 PostgreSQL 드라이버가 던지는 DB 오류, 괄호는 SQLState다.

  • rc에서는 DB가 끼어들지 않았다. 사슬에 40001이 없고 Hibernate의 낙관락 예외 — StaleObjectStateException(「낡은 객체를 고치려 했다」), Boot 4.1.1에서는 그 안의 StaleStateException까지 — 만 있다. 앞 절에서 본 대로 최신 행에 version = 0 조건이 맞지 않아 고친 행이 0개가 됐고, JPA가 그것을 낙관락 충돌로 판단했다. flush()가 try 안에 있어서 catch가 잡았다. 사슬 모양은 앞선 실험의 iso=0(스냅숏 격리를 끈 MariaDB)과 같다
  • rr에서는 DB가 먼저 거절했다. 가장 안쪽 원인이 40001이고, 맨 겉 예외는 CannotAcquireLockException이다. OLFE가 아니라서 catch를 지나쳤다. 앞선 실험의 iso=1(스냅숏 격리를 켠 MariaDB)과 같은 자리에서 막힌 것이다
  • 대조군 (사)는 네 조합 전부에서 OLFE catch에 닿았다. 실험 코드는 정상이다

소스로 따라가면 — REPEATABLE READ의 40001은 CannotAcquireLockException으로 온다

rr의 사슬 CannotAcquireLockException <- LockAcquisitionException <- PSQLException(40001)이 어디서 만들어지는지 이 저장소의 open-source/ 소스로 따라갔다. 들어 있는 버전은 Hibernate 7.4.0-SNAPSHOT, Spring Framework 7.1.0-SNAPSHOT이라 실험 버전과 다르다. 그래서 아래는 그 버전에서의 동작이고, 실험 결과의 사슬과 맞는지를 같이 적는다.

  1. Hibernate가 40001을 LockAcquisitionException으로 바꾼다. hibernate-core의 exception/internal/SQLStateConversionDelegate.java:51-52가 SQLState "40001"이면 new LockAcquisitionException(...)을 돌려준다. 이름은 「잠금을 얻지 못했다」지만, 실제로 받은 것은 「동시에 고쳐져서 직렬화할 수 없다」는 거절이다
  2. Spring이 그것을 CannotAcquireLockException으로 바꾼다. spring-orm의 jpa/hibernate/HibernateExceptionTranslator.java:161-162가 LockAcquisitionException이면 new CannotAcquireLockException(...)을 만든다. 이 클래스는 spring-tx의 dao/CannotAcquireLockException.java:31에서 PessimisticLockingFailureException(비관락 실패)의 자식이고, 그 부모는 ConcurrencyFailureException이다. OLFE도 ConcurrencyFailureException의 자식이라, 둘은 부모 쪽(ConcurrencyFailureException)에서만 만난다. 그래서 OLFE catch에 걸리지 않는다. 이 관계의 설명은 앞선 글의 「소스로 따라가면 3」에 있다

결과 사슬과 이 두 단계가 맞는다. 다만 읽은 소스는 Hibernate 7.4.0-SNAPSHOT이다. Boot 3.5.16이 쓴 Hibernate 6.6.53은 소스가 없어 같은 줄이 있는지 확인하지 못했다.

이 타입은 40001에만 오지 않는다. 같은 Hibernate 소스의 dialect/PostgreSQLDialect.java:1097-1099는 SQLState 40P01(교착 상태, 두 트랜잭션이 서로의 잠금을 기다려 둘 다 못 나아가는 것)도 LockAcquisitionException으로 바꾼다. 그러면 2단계를 거쳐 같은 CannotAcquireLockException이 된다. 맨 겉 타입만 보고는 「동시에 고쳐졌다」와 「교착 상태」를 가를 수 없다는 뜻이다. 40P01을 실제로 일으켜 재지는 않았다.

Spring에는 DB 오류 번호를 Spring 예외로 바꾸는 다른 표도 있다. spring-jdbc의 jdbc/support/sql-error-codes.xml:243-268이 PostgreSQL 항목이고, 거기서는 40001을 cannotSerializeTransactionCodes(262-264줄), 55P03(잠금을 얻지 못함)을 cannotAcquireLockCodes(259-261줄), 40P01을 deadlockLoserCodes(265-267줄)에 둔다. 이 실험의 사슬에는 이 표가 고르는 이름이 나오지 않았다 — Hibernate가 먼저 이름을 붙인 1·2단계 길을 탔다. 두 길이 어떤 조건에서 갈리는지는 따라가지 않았다.


예상과 달랐던 점 — READ COMMITTED에서는 version이 없는 행이 처음부터 무방비였다

판정과 별개로, 나머지 경우에서 표준을 정할 때 알아야 할 것이 나왔다. Boot 두 버전의 결과가 같아서 한 번만 적는다. 다른 곳은 하나, Boot 4.1.1의 rc (가)noflush 사슬 끝에 StaleStateException이 하나 더 붙는 것뿐이다.

무리  경우           catch  Probe가 받은 예외                                    번역  롤백  끝난 뒤 DB
rc    (가)noflush    none   ObjectOptimisticLockingFailureException              no    yes   name:inner
rc    (마)version없음 none   없음                                                 no    no    name:outer
rc    (바)안읽은행    none   없음                                                 no    no    name:outer
rr    (가)noflush    none   CannotAcquireLockException <- ... <- PSQLException(40001)  no    yes   name:inner
rr    (마)version없음 none   CannotAcquireLockException <- ... <- PSQLException(40001)  no    yes   name:inner
rr    (바)안읽은행    none   CannotAcquireLockException <- ... <- PSQLException(40001)  no    yes   name:inner

...는 LockAcquisitionException이다. 「끝난 뒤 DB」가 outer면 바깥이 쓴 값이 남은 것이고, inner면 먼저 저장한 안쪽 값이 지켜진 것이다.

  • rc의 (마)는 잃어버린 수정이다. @Version이 없는 표에서 바깥이 먼저 읽고, 안쪽이 inner로 고쳐 커밋한 뒤, 바깥이 outer로 덮어썼다. 예외도 롤백도 없었다. MariaDB에서는 기본 설정(스냅숏 격리 켬)이 이것을 1020으로 막아 줬고 끄기로 한 순간 구멍이 됐다. PostgreSQL의 기본값 READ COMMITTED에서는 처음부터 막는 것이 없다. 무엇을 끄지 않아도 @Version이 없으면 덮어써진다
  • rc의 (바)도 덮어썼다. 바깥이 한 번도 읽지 않은 행이라 「옛 모습을 보고 고쳤다」는 위험은 없다. 막을 필요가 없었을 수도 있는 저장이다. 앞선 실험의 끈 상태와 같다
  • (가)noflush는 rc에서도 번역되지 않았다. OLFE가 나긴 했지만 커밋 때 나서 catch를 이미 지난 뒤였다. 표준 원칙 2-2의 flush()는 PostgreSQL에서도 필요하다
  • rr은 (마)·(바)까지 전부 40001로 막았다. MariaDB의 스냅숏 격리를 켰을 때와 같은 모양이다. (바)가 막힌 것은, 바깥이 u3을 읽을 때 찍어 둔 스냅숏 뒤에 안쪽이 u4를 고쳐 커밋했기 때문이다 — 읽지 않은 행이라도 「스냅숏 뒤에 남이 고친 행」이면 REPEATABLE READ가 거절한다. (마)의 데이터는 지켜지고, (바)는 막을 필요가 없었을 수도 있는 저장까지 막은 것이다. 그 대가로 (가)도 OLFE catch에 닿지 않는다

하나 더 있다. rr에서 서버 기본값을 물은 값(SHOW default_transaction_isolation)도 repeatable_read로 찍혔다. 서버 설정은 손대지 않았으므로, 이 질문이 커넥션 풀이 그 접속에 건 값을 보여 준 것으로 본다. 드라이버 소스로 확인하지는 않았다. 판정에 쓰는 값은 트랜잭션 안에서 물은 값이고, 그것은 의도와 같았다.


이 결과가 예외 종류 표준에 남기는 것 — 격리 수준을 올리지 않는다

이 실험이 정한 것은 사실 둘이다. READ COMMITTED에서는 표준 모양의 catch가 그대로 돈다. REPEATABLE READ로 올리면 DB가 먼저 거절해 catch에 닿지 않는다. 격리 수준을 무엇으로 둘지는 이 실험이 정하지 않는다.

두 사실을 이으면 이렇다. 격리를 REPEATABLE READ로 올리면 (마)는 DB가 막아 주지만, (가)의 충돌이 CannotAcquireLockException으로 와 catch에 닿지 못한다. 이 타입을 catch에 그대로 넣으면 낙관락 충돌만 잡히지 않는다 — 위 「소스로 따라가면」에서 본 대로 같은 타입이 교착 상태(40P01)에도 온다(소스로만 확인). 사슬 안쪽에 SQLState 40001이 찍혀 있으니 안쪽 번호를 보면 가를 수는 있다. 다만 그러려면 표준의 타입 기반 번역을 벗어나 예외 사슬을 열어야 하고, 이 실험은 그 길을 재지 않았다.

그 결정은 같은 날(2026-10-20) 했다. 표준 DB를 MariaDB에서 PostgreSQL로 바꾸면서, 예외 종류 표준의 원칙 2-3을 「MariaDB의 스냅숏 격리는 커넥션에서 끈다」에서 「트랜잭션 격리 수준은 READ COMMITTED에 두고 올리지 않는다」로 바꿨다. 위 「예상과 달랐던 점」의 (마)는 Domain 영속화 표준의 「수정 경로가 있는 Entity에는 @Version을 둔다」가 막는다.


이 실험이 재지 않은 것

  • 반복은 1회였다
  • @Transactional(isolation = ...)으로 격리 수준을 올리는 방법은 돌리지 않았다. rr은 커넥션 풀로만 걸었다. 같은 결과가 나올 것으로 보지만 재지 않았다
  • SERIALIZABLE(REPEATABLE READ보다 더 엄격한 수준)은 돌리지 않았다
  • 40P01(교착 상태)을 일으켜 CannotAcquireLockException으로 오는지는 재지 않았다. 소스로만 봤다
  • 여러 요청이 정말 같은 순간에 몰려드는 상황은 재지 않았다. 끼어드는 순서를 코드로 정해 한 번씩 돌렸다
  • 화면이 옛 번호를 들고 다른 요청으로 저장하는 경우는 범위 밖이다. 앞선 글과 같고, 안건 #123이 다룬다

정리

  • PostgreSQL 17 기본값 READ COMMITTED에서는 표준 모양 구현체의 OLFE catch가 실행되어 USER_CONCURRENT_UPDATE로 번역된다. Boot 3.5.16과 4.1.1 모두 같았다. 무엇도 끄거나 켤 필요가 없다
  • REPEATABLE READ로 올리면 DB가 40001로 먼저 거절해 catch에 닿지 않는다. 그 예외는 CannotAcquireLockException으로 오고, 같은 타입이 교착 상태(40P01)에도 온다(소스로만 확인)
  • READ COMMITTED에서는 @Version이 없는 표의 동시 수정이 처음부터 조용히 덮어써진다((마)). MariaDB처럼 DB가 대신 막아 주는 기본값이 없다
  • try 안의 flush()는 PostgreSQL에서도 필요하다((가)noflush)
  • 예외 종류 표준은 격리 수준을 READ COMMITTED에 두고 올리지 않기로 했다(2026-10-20)

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기