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


두 사람이 같은 회원을 동시에 고치면 한 사람의 수정이 사라질 수 있다

회원 정보는 DB(데이터베이스)에 표 모양으로 저장된다. 표의 가로 한 줄을 행이라고 부르고, 회원 하나가 행 하나다. 이 실험의 회원 표에는 이메일·이름 칸과 함께 version이라는 숫자 칸이 있다. 회원 정보가 고쳐질 때마다 이 숫자가 1씩 올라간다.

이제 장면이다.

  • A가 회원 정보를 읽었다. 이름은 u2, version은 0이다. 실험 코드는 이 회원 행을 이름 그대로 u2라고 부르고, 이 글도 그렇게 부른다. 행의 id(DB가 붙인 일련번호)와는 다른 것이다
  • A가 이름을 고치는 사이에 B가 같은 회원의 이름을 inner로 바꾸고 저장을 끝냈다. version은 1이 됐다
  • A가 이름을 outer로 바꿔 저장한다

A가 본 것은 B가 고치기 전의 회원이다. 그대로 저장되면 B가 한 일이 말없이 사라진다.

이를 막는 방법이 낙관락이다. 저장할 때 「내가 읽었을 때 version이 0이었는데 지금도 0인가」를 확인하고, 다르면 저장을 거절한다. A는 0을 봤는데 지금은 1이니 거절된다. 거절되면 프로그램은 예외를 던진다. 이 글에서 A 쪽 작업을 바깥, 끼어든 B 쪽 작업을 안쪽(inner)이라고 부른다. 실험 코드가 그렇게 부르기 때문이다.

이 실험이 다루는 범위를 먼저 못 박는다. 위 장면의 A는 「읽기 → 고치기 → 저장」을 서버 안의 한 트랜잭션(뒤에서 설명할, 한 번에 처리되는 작업 묶음) 안에서 한다. B는 그 사이에 끼어들어 먼저 저장을 끝낸다. 낙관락이 이 충돌을 잡을 수 있는 것은, A의 트랜잭션이 처음 읽은 version 0을 저장할 때까지 기억하고 있기 때문이다. 이 실험이 재는 것은 이 경우뿐이다.

사람이 화면에서 회원 정보를 보고, 한참 뒤에 저장 버튼을 누르는 경우는 다르다. 보는 것과 저장하는 것이 서버에 따로 보내는 두 요청이라, 저장할 때는 새 트랜잭션이 DB에서 회원을 다시 읽는다. 우리 표준에서는 Service(요청을 받아 업무 처리를 맡는 클래스, 뒤에서 설명한다)가 다루는 회원 객체(UserDomain, 뒤에서 설명한다)에 version이 없다. 그래서 「화면이 볼 때 번호는 0이었다」를 저장 요청에 실어 Service까지 전하는 길이 표준에 없다. 이 경우는 이 실험에서 재지 않았다. HTTP 요청에 「내가 본 버전」을 실어 보내 이런 충돌을 막을지(If-Match)는 이 저장소의 열린 안건 #123(If-Match로 수정 충돌을 막을지)이 다룬다.

이 블로그의 예외 종류별 처리 표준은 그 예외를 잡아 「동시에 고쳐졌다」는 뜻의 우리 쪽 오류 코드 USER_CONCURRENT_UPDATE로 바꾸게 한다. 그런데 앞선 실험에서 그 잡는 코드가 한 번도 실행되지 않았다. 자바 프로그램 쪽에서 번호를 확인하기도 전에 DB(MariaDB 11.8)가 먼저 저장을 막았고, 그때 나온 예외가 잡는 코드가 기다리던 종류가 아니었다.

DB가 먼저 막은 이유는 MariaDB 11.8에 기본으로 켜져 있는 설정 하나였다. 이 문제는 이 저장소의 안건 목록(GitHub 이슈) #169로 열려 있고, 거기 「그 설정을 끄면 기다리던 예외가 나오는지 — 재지 않았다」가 남아 있었다. 이 글이 그것을 잰다.


먼저 알아야 할 말이 이 실험에 여럿 나온다

아직 이 블로그에 따로 설명한 개념 글이 없어서 여기서 적는다. 실험을 따라가는 데 필요한 만큼만 적었다.

DB 쪽 말

  • SQL과 UPDATE — DB에게 일을 시키는 말이 SQL이다. 이미 있는 행을 고치는 명령이 UPDATE다
  • MariaDB와 InnoDB — MariaDB는 이 글에서 쓰는 DB 제품이고 11.8은 버전이다. InnoDB는 MariaDB가 표를 저장하는 기본 방식의 이름이다. 설정 이름이 innodb_로 시작하면 그 방식에 딸린 설정이다
  • 트랜잭션, 커밋, 롤백 — 여러 DB 작업을 「전부 되거나 전부 안 되거나」로 묶은 단위가 트랜잭션이다. 끝에 커밋하면 DB에 확정되고, 도중에 예외가 나면 롤백되어 없던 일이 된다
  • 격리 — 동시에 도는 트랜잭션끼리 서로가 고치던 것을 얼마나 보게 할지 정하는 규칙이다. 서로 떼어 놓는다는 뜻에서 격리라고 부른다
  • REPEATABLE READ와 스냅숏 — MariaDB의 기본 격리 규칙이다. 트랜잭션이 행을 보통으로 읽을 때(그냥 SELECT로 조회할 때)는 자기가 읽기 시작한 순간의 DB 모습(스냅숏)을 끝까지 본다. 그 사이 다른 트랜잭션이 행을 고치고 커밋해도 내 트랜잭션에게는 옛 모습이 보인다. UPDATE처럼 고치려고 읽을 때는 다르다. 이것은 「소스로 따라가면 2」 절에서 소스로 본다. 보통 읽기의 예로 MariaDB 쿼리 실행 흐름 글에 잔액 100원이 계속 100원으로 보이는 예가 있다
  • innodb_snapshot_isolation(스냅숏 격리) — MariaDB 11.8에서 기본으로 켜진(1) 설정이다. 켜져 있으면, 내 스냅숏 이후에 남이 고치고 커밋한 행을 내가 고치려 할 때 DB가 오류 번호 1020(「Record has changed since last read」, 마지막으로 읽은 뒤 행이 바뀌었다)으로 거절한다. 0으로 끌 수 있다

자바 쪽 말

  • 어노테이션 — @로 시작하는 표시다. 클래스나 메서드, 필드에 붙이는 이름표이고, Spring이나 JPA가 이 이름표를 보고 정해진 일을 대신 해 준다
  • Spring Boot — 자바로 서버 프로그램을 만들 때 쓰는 도구 모음이다. 이 실험은 3.5.16과 4.1.1 두 버전에서 돌렸다. 버전마다 안에 든 도구가 달라서 같은 일에 다른 예외가 나올 수 있다
  • @Transactional — 메서드에 붙이면 그 메서드 전체가 트랜잭션 하나가 된다. 커밋은 그 메서드가 끝날 때 일어난다
  • JPA(Hibernate)와 Entity — JPA는 「자바 객체와 DB 행을 이렇게 잇는다」를 정한 규칙 문서이고, 그 규칙대로 실제로 일하는 프로그램이 Hibernate다. 이 글에서 「JPA가 ~한다」는 「JPA 규칙대로 Hibernate가 ~한다」는 뜻이다. DB 행 하나를 담는 자바 객체를 Entity라고 부른다
  • @Version — Entity의 숫자 필드에 붙이면 JPA가 낙관락을 맡는다. 행을 고칠 때마다 version을 1 올리고, 고치기 전에 번호가 달라졌는지 확인한다. 앞 장면의 version 칸이 이것이다
  • 영속성 컨텍스트와 변경 감지 — JPA는 트랜잭션 안에서 DB에서 꺼낸 Entity를 기억해 둔다. 이 기억 공간이 영속성 컨텍스트다. 같은 트랜잭션에서 같은 행을 다시 꺼내면 DB에 묻지 않고 기억해 둔 그 객체를 그대로 돌려준다(Domain 영속화 표준 원칙 2). 그리고 그 객체의 필드를 고치기만 하면 JPA가 바뀐 것을 알아채 UPDATE를 만든다. 고친 객체를 넣으라고 Spring의 저장 도구(jpaEntityRepository.save)를 따로 부르지 않아도 된다(같은 글 원칙 1). 뒤에 나올 구현체의 save(UserDomain)은 우리가 만든 메서드 이름일 뿐, 이 저장 도구의 save와는 다른 메서드다
  • flush() — JPA는 그렇게 만든 UPDATE를 바로 보내지 않고 모아 두었다가 커밋할 때 보낸다. flush()는 「지금 바로 보내라」는 명령이다
  • Service, Repository, 구현체 — 우리 표준에서 Service는 「회원 이름 바꾸기」 같은 업무 한 건을 처리하는 클래스이고, @Transactional이 여기 붙는다. Repository는 DB에 넣고 꺼내는 메서드의 이름만 적은 인터페이스다. 그 메서드들의 실제 코드를 채운 클래스가 Repository 구현체다
  • 예외 번역과 ErrorCodeException — 도구가 던진 예외를 catch로 잡아, 우리가 정한 예외 ErrorCodeException에 오류 코드(USER_CONCURRENT_UPDATE 같은 이름)를 넣어 다시 던지는 것을 번역이라고 부른다. 오류 코드는 ErrorCode라는 enum(정해진 상수 몇 개만 가질 수 있는 자바 타입)에 모아 두었고, ErrorCode.USER_CONCURRENT_UPDATE처럼 쓰면 그 enum의 상수 하나를 가리킨다. ErrorCodeException.of(오류 코드)와 ErrorCodeException.of(오류 코드, 원인)은 ErrorCodeException 객체를 만들어 돌려주는 static 메서드다. new 대신 이 메서드로 예외를 만든다. 잡은 원래 예외는 새 예외 안에 원인으로 넣어 둔다. 예외 안에 원인이 들고, 그 원인 안에 또 원인이 드는 줄을 원인 사슬이라고 한다
  • OptimisticLockingFailureException(OLFE) — 낙관락 확인에서 번호가 달랐을 때 Spring이 던지는 예외다. 이름이 길어 이 글에서는 OLFE라고 줄여 쓴다
  • Probe와 「호출한 쪽」 — 실험 프로그램에서 맨 바깥에 있는 클래스가 Probe다. Service 메서드를 부르고, 무슨 예외를 받았는지 기록한다. 이 글에서 「호출한 쪽이 받은 예외」는 Probe가 받은 예외다

표준은 이 예외를 Repository 구현체의 try 안에서 번역하게 한다

예외 종류별 처리 표준의 원칙 2는 「영속성 예외는 Repository 구현체에서 번역한다」다. 영속성 예외는 DB에 저장하다 나는 예외를 말한다. 표준 글은 이 원칙에 이유 둘을 적었다.

하나는 그냥 두면 사용자에게 틀린 응답이 간다는 것이다. 예를 들어 DB 표에 「이메일은 겹치면 안 된다」는 제약(unique 제약)이 걸려 있는데 이를 어기면 Spring이 DataIntegrityViolationException을 던진다. 이것을 그냥 두면 이렇게 된다.

사용자가 이미 있는 이메일로 가입 요청을 보낸다
  → 서버가 DB에 저장하다 DataIntegrityViolationException 이 난다
  → 아무도 그 예외를 알아보지 못해 @ExceptionHandler(Exception.class) 까지 떨어진다
  → 사용자 화면에는 「서버 내부 오류(500)」가 돌아간다

먼저 응답 번호부터 적는다. 사용자의 화면이 서버에 일을 부탁하는 것을 요청, 서버가 돌려주는 답을 응답이라 하고, 응답에는 결과를 나타내는 세 자리 상태 번호가 붙는다. 200번대는 성공, 400번대는 요청한 쪽 잘못, 500번대는 서버 쪽 잘못이다. 500은 「서버 내부 오류」, 409는 「요청이 지금 상태와 부딪힌다」다.

Exception.class는 「Exception 클래스 자체」를 가리키는 자바 표기이고, Exception은 거의 모든 예외의 조상 클래스다. 그래서 @ExceptionHandler(Exception.class)는 「어떤 예외든 받는 처리기」라는 뜻이다. 이 처리기는 우리 표준의 서버가 예외 종류마다 「어떤 응답을 돌려줄지」 정해 둔 처리기 모음(@RestControllerAdvice) 안에서, 앞의 어느 처리기에도 안 걸린 예외를 전부 받는 마지막 처리기다. 이것은 500을 돌려준다. 사용자는 자기 잘못(이미 가입한 이메일)인데 서버가 고장 난 줄 안다. 표준 글은 이 경우를 409(「요청이 지금 상태와 부딪힌다」는 응답 번호)로 돌려줘야 맞다고 적었다.

다른 하나는 번역을 어디서 하느냐다. 표준 글은 「영속성 예외 타입도 Service로 새면 안 된다」고 적었다. DB 도구의 예외 이름은 DB 쪽 사정이라, 업무를 다루는 Service가 그 이름을 알게 하지 않는다는 뜻이다. 그래서 DB 바로 앞인 Repository 구현체가 예외를 잡아 중복 가입 오류로 바꾼다.

원칙 2-2는 여기에 하나를 더한다. 수정할 때는 구현체의 try 안에서 flush()까지 부르라는 것이다. 순서를 따라가면 이유가 보인다.

  1. Service 메서드가 Repository 구현체의 save를 부른다
  2. 구현체가 try 안에서 Entity를 고친다. UPDATE는 아직 모여 있기만 하다
  3. 구현체의 save가 끝나 try를 빠져나온다
  4. Service 메서드가 끝나며 커밋한다. 모아 둔 UPDATE가 이때 DB로 간다

충돌 예외는 4번에서 나는데, 그때는 3번에서 try를 이미 빠져나온 뒤라 catch가 잡을 수 없다. flush()를 2번 안에서 부르면 UPDATE가 try 안에서 나가고, 예외도 try 안에서 난다.


주장 — 스냅숏 격리를 끄면 표준 모양의 catch가 충돌을 번역한다

MariaDB 11.8 의 innodb_snapshot_isolation 을 0 으로 끄면,
예외 종류별 처리 표준 원칙 2·2-2 모양(수정 경로에서 try 안에 flush())의 Repository 구현체에서
catch (OptimisticLockingFailureException) 이 수정 경로 낙관락 충돌에 실행되어
USER_CONCURRENT_UPDATE 로 번역된다.

「수정 경로」는 이미 있는 회원을 고쳐 저장하는 길이다. 없던 회원을 새로 넣는 길과 구별해 부른다.

근거 종류는 1(기술 사실)이다. 표준을 증명하는 방식에서 주장을 네 종류로 나눴는데, 1번 기술 사실은 「같은 조건에서 직접 돌려 보면 확인되는 사실」이라 실험으로 증명한다.

받치는 표준은 이 실험 결과를 근거로 쓰게 될 표준 글이다. 실험 폴더의 claim.md가 돌리기 전에 이 둘을 적어 두었다. 하나는 방금 본 예외 종류별 처리 표준 원칙 2-2다. 다른 하나는 DB 시간 저장 표준이다. 시간 표준이 DB 버전과 얽힌 이유는 시각을 담는 TIMESTAMP 칸 때문이다. 이 칸에는 담을 수 있는 가장 늦은 시각이 있다. 11.5 미만은 그 끝이 2038년이라, 2038년 이후의 예약 시각이나 계약 종료일을 저장할 수 없다(그 표준 글에 이 이유가 적혀 있다). 그 끝이 2106년(2106-02-07T06:28:15Z — 날짜와 시각 사이의 T는 둘을 가르는 글자이고, 끝의 Z는 세계 표준시 UTC 기준이라는 뜻이다)으로 늘어난 것이 MariaDB 11.5부터다. 그런데 11.5는 짧게만 지원하고 다음 버전으로 넘어가는 버전(rolling release)이다. 그래서 그 표준은 원칙 2에서, 같은 기능을 갖고 오래 고쳐 주고 지원하는 버전(LTS)인 11.8을 최소 버전으로 고정했다. 그런데 스냅숏 격리가 기본으로 켜진 것은 11.6.2부터다(출처는 뒤의 「소스로 따라가면 2」 절 끝에서 본다). 11.8은 그보다 뒤라, 우리 프로젝트는 이 설정이 켜진 버전을 피할 수 없다. 버전을 내려 피할 수 없으니 남는 질문은 「설정을 끌 것인가」이고, 그것이 이 실험에 걸린다.

정확히 하면, 이 실험은 시간 표준의 규칙 하나를 증명하지는 않는다. 시간 표준이 받는 것은 「11.8로 고정하면 이 설정이 켜진 채로 따라온다, 끄면 무엇을 얻고 무엇을 잃는다」는 그 선택의 결과다. 그래도 claim.md가 시간 표준을 받치는 표준에 넣은 것은 맞다고 나는 본다. 시간 표준이 이미 DB 설정 하나(시간대)를 정해 두었으니, 이 설정을 끄기로 하면 그 옆에 함께 적게 될 것이다. 어디에 둘지는 이슈 #169에서 정한다.


반증 조건 — 끈 상태에서 catch가 안 돌면 반증이고, 대조군이 먼저 돌아야 판정한다

반증 조건은 「결과가 이렇게 나오면 주장이 틀린 것으로 친다」를 돌리기 전에 적어 둔 것이다. 결과를 보고 기준을 정하면 어떤 결과든 주장에 맞게 읽을 수 있어서다.

여기서 대조군이 나온다. 대조군은 결과가 이미 알려진 장면을 일부러 같이 돌려, 실험 도구가 제대로 동작하는지 확인하는 경우다. 이 실험의 대조군은 뒤의 「설계 — (마)와 (바)는…」 절에 나오는 (사)다.

판정 대상은 설정 두 가지 — 스냅숏 격리를 켠 상태(iso=1)와 끈 상태(iso=0) — 에서 돌린 (가)flush 경우뿐이다. 대조군 (사)는 판정 대상이 아니라 판정의 전제 조건이다. (사)가 제대로 동작해야 (가)flush 결과를 읽을 수 있다는 뜻이고, 아래 반증·판정 불가 조건에 그래서 (사)가 들어간다. (가)flush 경우는 「설계 — (가)는…」 절에서, iso 설정은 「설계 — 스냅숏 격리는…」 절에서 설명한다.

  • 지지 — Spring Boot 두 버전 모두 iso=0의 (가)flush에서 구현체의 OLFE catch가 실행되고, Probe가 ErrorCodeException(USER_CONCURRENT_UPDATE)을 받는다
  • 반증 — 두 버전 중 하나라도 iso=0의 (가)flush에서 그렇지 않다. 예외가 아예 안 나는 것(끄면 충돌이 조용히 덮어써진다)도 반증이다. 단, 대조군 (사)가 한 번이라도 OLFE catch에 닿았을 때만 반증이다
  • 판정 불가 — DB나 실험 프로그램이 실행되지 않는다(안 뜬다), 설정이 실제로 안 걸렸다, iso=1에서 충돌이 재현되지 않았다, 또는 대조군도 catch에 한 번도 닿지 못했다

iso=1에서 충돌이 재현되지 않으면 판정 불가인 이유는 이렇다. 켠 상태는 앞선 실험에서 이미 1020이 나는 것을 본 장면이다. 같은 장면에서 그것조차 안 나면 끼어들기 순서가 실제로 만들어지지 않은 것이고, 그 상태로 끈 상태 결과를 읽으면 충돌이 없어서 조용했던 것인지 끄는 덕분인지 가를 수 없다.

대조군 조건도 같은 이유다. 대조군이 catch에 한 번도 못 닿으면, 「끈 상태에서 안 됐다」가 설정 탓인지 실험 코드가 깨진 탓인지 가를 수 없다. 그래서 대조군이 먼저 동작해야 반증도 할 수 있게 했다. 반복은 1회다.


설계 — 본체는 표준 예시 모양의 구현체를 그대로 옮겼다

구현체는 표준 원칙 2-2의 예시를 그대로 옮겼다. 표준에 없는 모양을 쓰면 표준이 아니라 다른 코드를 재게 된다. 더한 것은 「어느 줄까지 실행됐나」를 남기는 Marks.event(...) 줄뿐이다.

public UserDomain save(UserDomain user) {
    try {
        if (user.getId() == null) {
            UserDomain saved = mapper.toDomain(jpaEntityRepository.save(mapper.toEntity(user)));
            Marks.event("try-returned");
            return saved;
        }
        UserEntity entity = jpaEntityRepository.findById(user.getId())
                .orElseThrow(() -> ErrorCodeException.of(ErrorCode.USER_NOT_FOUND));
        mapper.applyTo(entity, user);
        jpaEntityRepository.flush();
        Marks.event("try-returned");
        return mapper.toDomain(entity);

    } catch (DataIntegrityViolationException e) {
        Marks.event("catch:DIVE");
        throw ErrorCodeException.of(ErrorCode.DUPLICATE_EMAIL, e);
    } catch (OptimisticLockingFailureException e) {
        Marks.event("catch:OLFE");
        throw ErrorCodeException.of(ErrorCode.USER_CONCURRENT_UPDATE, e);
    }
}

한 줄씩 따라가면 이렇다.

  • save(UserDomain user) — 회원 하나를 저장하는 메서드다. 회원을 담는 객체가 둘이다. UserDomain은 업무 규칙(이름 바꾸기 같은 것)을 담는 평범한 자바 객체로, Service가 다룬다. UserEntity는 DB 행 하나를 그대로 담는 Entity로, Repository 구현체 안에서만 쓴다. 우리 표준이 두 층을 이렇게 갈라 두었다(Domain 영속화 표준). version은 UserEntity에만 있고 UserDomain은 들고 있지 않다. 그 표준이 「@Version을 Domain에 넣지 않아도 낙관락이 동작한다」를 노린 설계라서다. 이 점이 중요하다 — 낙관락의 번호는 Service 쪽 객체가 아니라 구현체 안의 UserEntity가 들고 있다
  • jpaEntityRepository와 mapper — 이 클래스가 가진 도우미 둘이다. jpaEntityRepository는 Spring이 만들어 주는 기본 저장 도구로 save(넣기)·findById(id로 꺼내기)·flush를 이미 갖고 있다. mapper는 UserDomain과 UserEntity를 서로 바꿔 준다. toEntity는 Domain을 Entity로, toDomain은 그 반대다
  • if (user.getId() == null) — id가 없으면 새 회원이다. Entity로 바꿔 넣고, 넣은 결과를 Domain으로 바꿔 돌려준다. 이 실험의 관심은 이쪽이 아니다
  • findById(user.getId()) — id가 있으면 수정이다. 해당 행의 UserEntity를 꺼낸다. 이 메서드는 「찾았을 수도 못 찾았을 수도 있는 값」을 담은 상자(Optional)를 돌려준다
  • .orElseThrow(() -> ...) — 상자가 비었으면 예외를 던지고, 차 있으면 안의 UserEntity를 꺼낸다. () -> ...는 「필요할 때만 실행할 코드 조각」이다. 비었을 때만 ErrorCodeException.of(ErrorCode.USER_NOT_FOUND)가 실행되어 예외 객체를 만든다
  • mapper.applyTo(entity, user) — user에 담긴 새 이름을 꺼낸 entity의 필드에 옮겨 적는다. 필드를 고쳤으니 변경 감지가 이것을 UPDATE로 만들어 모아 둔다
  • jpaEntityRepository.flush() — 모아 둔 UPDATE를 지금 DB로 보낸다. 충돌이 있으면 이 줄에서 예외가 나야 아래 catch가 잡을 수 있다
  • Marks.event("try-returned") — 여기까지 왔으면 try가 예외 없이 끝났다는 표식이다
  • catch (DataIntegrityViolationException e) — 이메일 unique 제약 위반이면 DUPLICATE_EMAIL로 바꿔 던진다. e는 잡은 원래 예외이고 새 예외의 원인으로 들어간다. 기록에는 DIVE로 적었다
  • catch (OptimisticLockingFailureException e) — 낙관락 충돌이면 USER_CONCURRENT_UPDATE로 바꿔 던진다. 이 줄이 실행되는지 보는 것이 이 실험이다

설계 — (가)는 바깥이 읽은 뒤 안쪽이 먼저 커밋하는 순서를 코드로 만든다

두 사람이 동시에 고치는 장면을 프로그램 하나에서 순서대로 재현한다. (가)는 이렇게 흘러간다.

  1. 바깥 트랜잭션(@Transactional이 붙은 Service 메서드)이 u2 회원을 읽는다. 이 순간 바깥의 스냅숏이 찍히고, 읽은 UserEntity(version 0)가 영속성 컨텍스트에 기억된다
  2. 안쪽 트랜잭션이 같은 회원의 이름을 inner로 바꾸고 먼저 커밋한다. DB의 version은 1이 된다. 안쪽은 @Transactional(propagation = REQUIRES_NEW)로 만들었다. 바깥 안에서 불러도 따로 새 트랜잭션을 열어 따로 커밋하라는 뜻이다
  3. 바깥이 이름을 outer로 바꿔 위 구현체의 save로 저장한다. 구현체 안의 findById는 같은 트랜잭션이라 DB에 다시 묻지 않고, 1번에서 기억해 둔 version 0짜리 객체를 그대로 돌려준다. 그래서 JPA는 「0일 때 읽은 것을 고친다」고 알고 있다
  4. flush()가 UPDATE를 보낸다

4번에서 무슨 일이 날지는 설정에 달렸다. 켠 상태면 바깥의 스냅숏 이후 안쪽이 고친 행이라 DB가 1020으로 거절한다. 끈 상태면 DB는 거절하지 않고, 번호가 달라진 것을 JPA가 알아챌 차례가 온다. JPA가 번호 충돌을 어떻게 알아채는지, DB가 1020을 어디서 내는지는 결과 뒤의 「소스로 따라가면」 세 절에서 실제 소스 코드로 따라간다.

(가)noflush는 같은 장면을 flush() 줄이 없는 옛 구현체로 돌린 것이다. 표준 원칙 2의 예시 코드는 원래 flush() 없이 적혀 있었다. 앞선 실험에서 그 모양의 catch가 커밋 때 터진 예외를 잡지 못한다는 것이 드러났고, 그 뒤에 원칙 2-2(「try 안에서 flush()까지 부른다」)가 더해졌다. (가)noflush는 그 원칙 2-2 이전의 모양이다. 스냅숏 격리를 끈 상태에서도 flush()가 여전히 필요한지 보려고 같이 돌렸다.


설계 — (마)와 (바)는 낙관락이 없는 자리를, (사)는 번호만 틀린 자리를 본다

(가) 말고 세 경우를 더 돌렸다. 경우마다 다른 회원 행을 써서 서로 영향을 주지 않게 했다.

이름에 (나)(다)(라)가 없는 것은 앞선 실험이 그 이름을 이미 썼기 때문이다. 이 실험은 거기서 (가)만 다시 쓰고, 새로 만든 경우에 (마)부터 이름을 붙였다. 정리하면 경우는 다섯이다 — (가)flush, (가)noflush, (마), (바), (사).

(마) version 없음 — version 칸도 @Version도 없는 표 plains에서 (가)와 같은 순서로 끼어든다. 저장 코드는 이렇다.

public void rename(Long id, String name) {
    try {
        PlainEntity entity = jpaEntityRepository.findById(id)
                .orElseThrow(() -> ErrorCodeException.of(ErrorCode.USER_NOT_FOUND));
        entity.changeName(name);
        jpaEntityRepository.flush();
        Marks.event("try-returned");
    } catch (DataIntegrityViolationException e) { ... }
      catch (OptimisticLockingFailureException e) { ... }
}

행을 꺼내(findById) 이름 필드를 고치고(changeName) flush()로 보낸다. 모양은 본체와 같다. catch 두 개도 본체와 같아서 줄였다. 다른 점은 Entity에 @Version이 없어 JPA가 번호를 확인하지 않는다는 것뿐이다.

(바) 안 읽은 행 — 바깥 트랜잭션이 다른 회원 u3만 읽고, 한 번도 읽지 않은 u4를 JPA의 번호 확인을 거치지 않는 SQL로 직접 고친다. u4는 그 사이 안쪽이 고쳐 커밋했다. 이 SQL은 앞의 본체 코드에서 본 Spring 저장 도구 jpaEntityRepository에 메서드 하나를 더 적어 보낸다. 그 저장 도구는 UserJpaEntityRepository라는 인터페이스(interface UserJpaEntityRepository extends JpaRepository<UserEntity, Long>)이고 — < > 안의 두 타입은 차례로 「저장할 Entity 타입」(UserEntity)과 「그 Entity의 id 타입」(Long)이다 — 우리는 메서드 이름과 SQL만 적는다. 실제로 동작하는 구현은 Spring Data(Spring의 저장 도구 묶음)가 프로그램이 뜰 때 만들어 준다. u3를 먼저 읽는 것은 바깥의 스냅숏을 찍기 위해서다(실험 코드의 설명이 그렇다).

왜 굳이 「안 읽은 행」인가. 1020이 「내가 읽었던 행이 바뀌었다」일 때만 나는지, 아니면 「내 스냅숏 이후에 바뀐 행」이면 읽은 적이 없어도 나는지를 가르려는 것이다. 앞쪽이면 1020은 낙관락과 비슷한 신호이고, 뒤쪽이면 낙관락과 상관없이 DB가 거는 더 넓은 검사다. 보낸 SQL은 이 한 줄이다.

@Query(value = "UPDATE users SET name = :name WHERE id = :id", nativeQuery = true)
int renameByNativeUpdate(@Param("name") String name, @Param("id") Long id);

한 줄씩 보면 이렇다.

  • UPDATE users SET name = :name WHERE id = :id — SQL이다. 「users 표에서, id 칸이 :id인 행을 찾아(WHERE), name 칸을 :name으로 바꿔라(SET)」는 뜻이다. version 조건이 없다
  • @Query(..., nativeQuery = true) — 이 메서드를 부르면 JPA가 SQL을 만들지 말고 적어 둔 SQL을 그대로(native) DB에 보내라는 뜻이다
  • @Param("name") String name — 메서드 인자 name을 SQL의 :name 자리에 넣으라는 표시다. id도 같다
  • int 반환값 — DB가 실제로 고친 행의 개수다

JPA의 번호 확인이 끼어들 자리가 없다.

(마)와 (바)는 낙관락이 아예 없는 자리에서 DB가 무엇을 하는지 보려고 넣었다.

(사) 낡은 version — 대조군 — 동시 수정이 없다. 먼저 u5를 한 번 고쳐 커밋해 DB의 version을 1로 올려 둔다. 그다음 version 0을 적은 Entity를 직접 만들어 저장한다.

public UserDomain saveStale(Long id, String email, String name, long staleVersion) {
    try {
        UserEntity stale = UserEntity.detached(id, email, name, staleVersion);
        UserDomain saved = mapper.toDomain(jpaEntityRepository.save(stale));
        jpaEntityRepository.flush();
        Marks.event("try-returned");
        return saved;
    } catch (DataIntegrityViolationException e) {
        Marks.event("catch:DIVE");
        throw ErrorCodeException.of(ErrorCode.DUPLICATE_EMAIL, e);
    } catch (OptimisticLockingFailureException e) {
        Marks.event("catch:OLFE");
        throw ErrorCodeException.of(ErrorCode.USER_CONCURRENT_UPDATE, e);
    }
}

실제 코드(UserStaleVersionJpaRepository) 그대로다. 저장과 flush()가 본체와 같은 모양의 try 안에 있고, catch 두 개도 본체와 같다.

  • UserEntity.detached(...) — DB에서 꺼내지 않고 코드에서 새로 만든 Entity다. id는 u5의 것이고 version 칸에 낡은 값 0(staleVersion)을 넣었다. 사용자의 화면(앱)이 오래전에 받아 둔 옛 번호를 들고 서버에 저장 요청을 보낸 모양을 흉내 낸 것이다. 다만 우리 표준 구조에서는 이 길이 생기지 않는다. 글 처음의 「이 실험이 다루는 범위」에서 말했듯, 화면이 옛 번호를 들고 오는 경우(요청이 둘로 나뉜 경우)는 이 실험이 재는 범위 밖이고 안건 #123의 몫이다. UserDomain에 version이 없으니 화면이 옛 번호를 받아 올 곳도, 서버에 들고 올 곳도 없다. UserEntity.detached(...)는 이 실험에만 있는 메서드이고, 대조군을 만들려고 일부러 번호를 손으로 넣은 것이다
  • jpaEntityRepository.save(stale) — 이미 id가 있는 Entity를 save하면 JPA는 merge로 처리한다. merge는 같은 id의 행을 DB에서 꺼낸 객체를 준비하고, 들고 온 객체의 값을 그 위에 덮어 쓰는 방식이다. 우리 표준이 수정 경로에서 쓰지 말라고 한 방법이다(Domain 영속화 표준 원칙 1). 여기서는 번호 확인만 일으키려고 일부러 썼다
  • 번호 확인은 이 save(merge)에서 바로 일어난다. DB에서 꺼낸 객체의 번호는 1, 들고 온 번호는 0이라 여기서 거절되고, 다음 줄 flush()까지 가지 않는다. 스냅숏과 상관없는 충돌이다. 이 확인을 소스의 어디서 하는지는 「소스로 따라가면 1」 절 끝에서 본다

(사)가 OLFE catch에 닿으면 「이 실험 코드에서 OLFE catch는 실행될 수 있다」가 보증된다.


설계 — 스냅숏 격리는 서버에서 끄는 것과 접속에서 끄는 것 두 가지로 끈다

먼저 실험을 돌리는 방법부터 적는다. 실험 전체는 실험 폴더 proofs/mariadb-snapshot-isolation-optimistic-lock/에 있다. 그 안의 run.sh는 명령을 순서대로 적은 파일이고, bash는 그런 파일을 실행하는 프로그램이다. bash run.sh 하나로 처음부터 다시 돌릴 수 있다. run.sh가 끝나며 원본 기록을 같은 폴더의 result.md에 쓴다. 사람이 손으로 고치지 않는 파일이다.

실험 프로그램은 DB 서버에 접속해서 일한다. 설정은 서버 전체에 거는 값이 있고, 접속 하나에만 거는 값이 있다. 접속에 건 값이 있으면 그 접속에서는 그 값이 쓰인다.

[실험 프로그램] ──접속(여기에 거는 값: 접속 설정)──> [MariaDB 서버(여기에 거는 값: 서버 설정)]

그래서 설정을 세 가지로 돌렸다.

이름 서버 설정 접속 설정
iso=1 켬(11.8 기본값) 그대로
iso=0 끔 그대로
iso=0s 켬 이 접속에서만 끔(sessionVariables)

iso=0s는 「서버 설정을 못 바꾸는 곳에서 접속 설정만으로 끌 수 있는가」를 보려고 넣었다. 판정에는 쓰지 않는다.

접속 설정은 프로그램이 DB에 접속할 때 쓰는 주소(JDBC URL) 끝에 붙인다. 이 실험의 run.sh가 iso=0s에서 쓴 주소는 이렇다.

jdbc:mariadb://db:3306/proof?sessionVariables=innodb_snapshot_isolation=0

jdbc:mariadb://db:3306/proof까지가 「db라는 컴퓨터의 3306번 문으로 들어가 proof라는 DB를 쓴다」는 뜻이다. ? 뒤의 sessionVariables=...가 「접속하자마자 이 접속에서만 innodb_snapshot_isolation을 0으로 바꿔라」는 부탁이다. Spring Boot에서는 이 주소를 설정값 spring.datasource.url에 넣는다(실험에서는 환경 변수 SPRING_DATASOURCE_URL로 넘겼다. 환경 변수는 프로그램을 띄울 때 바깥에서 건네주는 이름=값 쌍이고, Spring Boot는 이 이름의 환경 변수를 spring.datasource.url 설정값으로 읽는다. 실험이 실제로 이렇게 접속했다).

이 세 설정을 Spring Boot 3.5.16(Hibernate 6.6.53)과 4.1.1(Hibernate 7.4.5)에서 각각 돌렸다. 실행할 때마다 전용 MariaDB를 도커(프로그램을 격리된 상자 안에서 잠깐 띄우는 도구)로 새로 띄우고 끝나면 지웠다. 다른 DB에는 붙지 않는다.


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

판정은 지지다. result.md의 원본 기록에서 판정에 쓰는 줄과 대조군만 옮기면 이렇다.

설정   Boot    경우      catch  Probe가 받은 예외와 원인 사슬                                               번역  롤백
iso=1  3.5.16  (가)flush none   CannotAcquireLockException <- LockAcquisitionException <- SQLException(1020)  no    yes
iso=1  4.1.1   (가)flush none   JpaSystemException <- SnapshotIsolationException <- SQLException(1020)        no    yes
iso=0  3.5.16  (가)flush OLFE   ErrorCodeException <- ObjectOptimisticLockingFailureException
                                  <- StaleObjectStateException                                                yes   yes
iso=0  4.1.1   (가)flush OLFE   ErrorCodeException <- ObjectOptimisticLockingFailureException
                                  <- StaleObjectStateException <- StaleStateException                         yes   yes
iso=1  3.5.16  (사)대조군 OLFE   ErrorCodeException <- ObjectOptimisticLockingFailureException
                                  <- StaleObjectStateException                                                yes   yes
iso=1  4.1.1   (사)대조군 OLFE   ErrorCodeException <- ObjectOptimisticLockingFailureException
                                  <- StaleObjectStateException                                                yes   yes

표를 읽는 법은 이렇다.

  • catch 칸 — 구현체의 어느 catch가 실행됐는지다. none은 아무것도 실행되지 않았다는 뜻이다
  • A <- B — A라는 예외 안에 원인으로 B가 들어 있다는 뜻이다. 오른쪽 끝이 가장 안쪽 원인이다
  • SQLException(1020) — SQLException은 자바가 DB 오류를 알릴 때 쓰는 기본 예외이고, 괄호는 MariaDB 오류 번호다
  • 번역 칸 — Probe가 받은 것이 ErrorCodeException(USER_CONCURRENT_UPDATE)이면 yes다
  • 롤백 칸 — yes면 바깥이 쓰려던 outer가 DB에 남지 않았다는 뜻이다

켠 상태(iso=1)는 앞선 실험과 같았다. 가장 안쪽 원인이 1020이다 — DB가 먼저 거절했다. 그 위의 이름(CannotAcquireLockException, JpaSystemException)은 Spring 버전마다 다르지만 둘 다 OLFE가 아니다. 그래서 OLFE를 기다리는 catch를 그냥 지나쳤다.

끈 상태(iso=0)에서는 1020이 사슬에 없다. 대신 StaleObjectStateException이 들어 있다. Hibernate가 던지는 「낡은 객체를 고치려 했다」는 뜻의 예외이고, 이것을 Spring이 ObjectOptimisticLockingFailureException으로 감쌌다. 즉 DB가 아니라 JPA의 번호 확인이 충돌을 잡았다. 이 예외가 어떻게 OLFE catch에 걸리는지는 뒤의 소스 절에서 본다. flush()가 try 안에 있었으므로 catch가 잡아 USER_CONCURRENT_UPDATE로 바꿨다.

대조군 (사)는 여섯 조합(설정 3 × Boot 2) 전부에서 OLFE catch에 닿았다. 실험 코드는 정상이다.

설정이 실제로 걸렸는지도 확인했다. 실행마다 처음에 DB에게 지금 값을 직접 물어 찍었고, iso=0에서는 서버 값과 접속 값이 모두 0, iso=1에서는 둘 다 1이었다.

result.md 맨 위에는 결과 전체를 한 줄로 몰아 적은 「요약:」 줄이 있다. 이 줄은 경우 다섯 × 설정 세 가지 × Spring Boot 두 버전 = 서른 개를 전부 담고 있어 길다. 판정은 위 네 줄로 났고, 나머지 줄은 「예상과 달랐던 점」 절에서 쓴다.


소스로 따라가면 1 — JPA는 「고친 행이 0개」로 낙관락 충돌을 안다

여기서부터 세 절은 결과가 왜 그렇게 나왔는지를 실제 소스 코드로 따라간다. 이 저장소의 open-source/ 폴더에 Hibernate·Spring·MariaDB 소스가 들어 있다. 다만 들어 있는 버전이 실험에서 쓴 버전과 같지 않다.

  • Hibernate는 7.4.0-SNAPSHOT이다(실험은 6.6.53·7.4.5). SNAPSHOT은 아직 정식으로 내지 않은 개발 중인 버전이라는 뜻이다. 이 글의 DB 「스냅숏」(트랜잭션이 본 DB의 순간 모습)과는 이름만 비슷하고 관계없다
  • Spring Framework는 7.1.0-SNAPSHOT이다. Spring Boot는 Spring Framework를 비롯한 여러 도구를 골라 묶은 꾸러미이고, 아래에서 볼 예외 변환 코드는 그중 Spring Framework에 들어 있다. 실험의 Boot 3.5.16·4.1.1에 묶인 Spring Framework는 이보다 앞 버전이다
  • MariaDB는 13.0.1이다(실험은 11.8.9)

버전이 다르므로 아래는 그 버전 소스에서의 동작이고, 실험 결과와 맞는지를 함께 적는다.

소스 위치는 파일 경로:줄 번호로 적는다. 예를 들어 UpdateCoordinatorStandard.java:549는 그 파일의 549번째 줄이다.

Hibernate가 @Version이 붙은 Entity의 UPDATE를 만들 때, WHERE에 id만이 아니라 읽었을 때의 version도 조건으로 건다. hibernate-core(Hibernate 소스 중 핵심 기능이 든 폴더)의 persister/entity/mutation/UpdateCoordinatorStandard.java:549 주석 「restrict the old-version」 아래에서 옛 번호(oldVersion)를 조건 자리(ParameterUsage.RESTRICT)에 넣는다. SQL로 쓰면 대략 이런 모양이다.

UPDATE users SET name = 'outer', version = 1 WHERE id = (u2 행의 id) AND version = 0

「u2 행의 id이고 version이 0인 행을 찾아 고쳐라」는 뜻이다. 괄호 자리에는 실제로 그 행의 id 숫자가 들어간다. DB의 version이 이미 1이면 그런 행이 없으니 고친 행이 0개다.

Hibernate는 UPDATE가 끝나면 고친 행 수를 확인한다. jdbc/Expectations.java:89-95가 기대한 개수(1)보다 적으면 StaleStateException을 던지고, engine/jdbc/mutation/internal/ModelMutationHelper.java:65-72가 그것을 받아 고친 행이 0개면 StaleObjectStateException으로 한 번 더 감싼다. Boot 4.1.1 결과의 사슬이 StaleObjectStateException <- StaleStateException인 것과 같은 모양이다. Boot 3.5.16(Hibernate 6.6)의 사슬에는 StaleStateException이 따로 안 보였는데, 6.6 소스는 여기 없어 확인하지 못했다.

대조군 (사)는 다른 자리에서 걸린다. merge를 처리하는 event/internal/DefaultMergeEventListener.java:486-491이 DB에서 꺼낸 객체와 들고 온 객체의 version이 다르면(isVersionChanged) 그 자리에서 StaleObjectStateException을 던진다. UPDATE를 보내기 전이라 「고친 행 수」 단계를 거치지 않는다. (사)의 원인 사슬에 StaleStateException 없이 StaleObjectStateException만 있는 것이 이 길과 맞는다.


소스로 따라가면 2 — 켠 상태에서는 MariaDB가 행을 잠그기 전에 1020을 낸다

UPDATE는 고칠 행을 찾으면 먼저 그 행을 잠근다. 잠근다는 것은 다른 트랜잭션이 동시에 그 행을 고치지 못하게 붙잡아 두는 것이다. MariaDB에서 그 일을 하는 함수가(C 언어의 「함수」는 클래스에 속하지 않고 혼자 있는 메서드라고 보면 된다) storage/innobase/lock/lock0lock.cc:6483의 lock_clust_rec_read_check_and_lock이다. 그 안의 6531-6541줄이 핵심이다.

if (heap_no > PAGE_HEAP_NO_SUPREMUM && gap_mode != LOCK_GAP
    && trx->snapshot_isolation
    && trx->read_view.is_open()) {
    trx_id_t trx_id= trx_read_trx_id(rec + row_trx_id_offset(rec, index));
    if (!trx_sys.is_registered(trx, trx_id)
        && !trx->read_view.changes_visible(trx_id)
        && /* 여기 한 줄은 이 글과 무관한 복제 기능 조건이라 줄였다 */ ...) {
        return DB_RECORD_CHANGED;
    }
}

C 언어지만 자바와 거의 같게 읽힌다. trx->snapshot_isolation의 ->는 자바의 .처럼 「trx가 가진 snapshot_isolation 값」이라는 뜻이다. trx는 지금 이 UPDATE를 보내는 트랜잭션이다. heap_no, gap_mode가 든 첫 줄은 「진짜 행 하나를 잠그는 경우」라는 조건이라 넘어가도 된다.

  • trx->snapshot_isolation — 이 트랜잭션에 스냅숏 격리가 켜져 있고
  • trx->read_view.is_open() — 이미 스냅숏이 찍혀 있고(MariaDB 소스는 스냅숏을 read view라고 부른다)
  • trx_id_t trx_id= trx_read_trx_id(rec + row_trx_id_offset(rec, index)); — 이 행을 마지막으로 고친 트랜잭션의 번호를 읽어서. trx_id_t는 트랜잭션 번호를 담는 타입 이름이다(자바의 long 같은 숫자 타입). rec는 지금 잠그려는 행의 데이터가 메모리에서 시작하는 위치이고, index는 그 행이 들어 있는 표의 정보다. rec + 숫자는 「행 데이터의 시작 위치에서 그 숫자만큼 바이트를 건너뛴 곳」이라는 뜻이다. 그러니 이 줄은 「행에서 트랜잭션 번호 칸이 있는 곳으로 건너가 그 번호를 읽는다」는 뜻이다. MariaDB는 트랜잭션마다 번호를 붙이고, 행마다 「이 행을 마지막으로 고친 트랜잭션 번호」를 숨은 칸 DB_TRX_ID에 함께 저장한다. row_trx_id_offset은 그 칸이 행의 어디에 있는지 찾는 함수다(include/row0row.h:343-347, 칸의 종류는 include/data0type.h:155의 「transaction id: 6 bytes」)
  • !trx_sys.is_registered(trx, trx_id) — trx_sys는 MariaDB 안에 하나만 있는, 모든 트랜잭션을 관리하는 객체다(include/trx0sys.h:853 class trx_sys_t, :1329 extern trx_sys_t trx_sys). 이 조건은 그 트랜잭션이 지금 진행 중이 아닌지, 즉 이미 커밋을 끝냈는지를 본다. is_registered는 「진행 중인 트랜잭션 목록에 이 번호가 있는가」를 묻는다(include/trx0sys.h:1187-1190). 아직 진행 중인 트랜잭션이 고친 행은 이 검사가 아니라 잠금 대기로 처리된다
  • !trx->read_view.changes_visible(trx_id) — 그 트랜잭션이 고친 것이 내 스냅숏에는 안 보이면, 즉 내 스냅숏 이후에 고쳐졌으면
  • return DB_RECORD_CHANGED — 잠그지 않고 「행이 바뀌었다」를 돌려준다

이 값은 storage/innobase/handler/ha_innodb.cc:2093-2098에서 트랜잭션 전체를 롤백하고 HA_ERR_RECORD_CHANGED로 바뀐다. 그것이 sql/handler.cc:5111-5116에서 오류 ER_CHECKREAD가 된다. sql/share/errmsg-utf8.txt는 오류 항목을 차례로 적은 파일이다. 5줄의 start-error-number 1000이 「첫 항목이 1000번」이라는 뜻이고, 항목마다 번호가 1씩 오른다. 7줄의 첫 항목 ER_HASHCHK(1000번)부터 세면 509줄의 ER_CHECKREAD는 21번째 항목이라 1020번이다. 영어 문구는 「Record has changed since last read in table」(513줄)이다. 결과의 1020이 여기서 나왔다.

이 검사는 「내가 읽었던 행인가」를 보지 않는다. 행을 마지막으로 고친 트랜잭션이 내 스냅숏 이후냐만 본다. 뒤 「예상과 달랐던 점」 절의 표에서 볼 (바) 결과 — 켠 상태에서 한 번도 안 읽은 u4도 1020으로 막혔다 — 와 맞는다.

끈 상태에서는 이 if를 건너뛰고 그대로 잠근다. 여기서 앞의 용어 절 「보통으로 읽을 때는 스냅숏을 본다」와 부딪히는 것처럼 보이는데, 읽는 방식이 두 가지라서 부딪히지 않는다.

  • 보통 읽기(잠그지 않는 읽기) — SELECT로 조회할 때다. 행이 스냅숏 이후에 바뀌었으면 필요할 때 옛 모습을 꺼내 보여 준다. row/row0sel.cc:1084-1086이 잠그지 않는 쪽에 「필요하면 그 행의 이전 버전을 꺼낸다」(non-locking consistent read: if necessary, fetch a previous version)는 주석을 달아 두었다
  • 잠그며 읽기(locking read) — 고치기 전에 그 행을 잠그면서 읽는 것이다. 이 실험에서는 UPDATE가 고칠 행을 찾을 때가 이것이다. 같은 파일 1059-1066줄처럼 잠그는 쪽은 옛 모습을 꺼내는 단계 없이 그 행에 바로 잠금 함수를 부른다. 그래서 스냅숏 속 옛 모습이 아니라 지금 DB에 확정된 최신 행을 잠그고 고친다

row/row0sel.cc:5273-5283에도 주석이 하나 있다. 이 주석이 말하는 읽기는 UPDATE처럼 고치려고 읽는 경우다. 앞의 용어 절에서 말한 「보통으로 읽을 때는 스냅숏을 본다」와는 다른 경우라 부딪히지 않는다. 주석은 이 절의 두 이야기를 한자리에서 말한다. 「스냅숏 격리가 켜져 있고 read view가 열려 있었다면 (고치려고 잠그는) 단계에서 DB_RECORD_CHANGED가 먼저 났을 것」(앞의 if)이고, 「REPEATABLE READ에서는 (고치려고 읽을 때) 잠그며 읽기를 한다」(바로 위 두 번째 목록)다. 켠 상태와 끈 상태가 이 잠그는 자리에서 갈린다는 뜻이다. 최신 행의 version은 이미 1이라 Hibernate의 version = 0 조건이 맞지 않고, 고친 행이 0개가 되어 앞 절의 길로 간다. (마)·(바)는 version 조건이 없으니 최신 행을 그대로 덮어썼을 것이다. 실제로 그랬는지는 뒤 「예상과 달랐던 점」 절의 표에서 본다(끈 상태에서 끝난 뒤 이름이 outer였다).

다만 위 1059-1086줄은 MariaDB 안의 한 읽기 함수에서 본 것이고, 이 실험의 UPDATE가 정확히 그 함수를 지나는지까지는 따라가지 않았다. 「잠그며 읽기는 최신 행을 고친다」는 이 주석들과, 뒤 「예상과 달랐던 점」 절에서 볼 실험 결과((마)·(바)에서 outer가 남았다)를 이어 붙인 것이다.

Hibernate 쪽에도 같은 이야기가 있다. Hibernate에는 DB 제품마다 다른 점(SQL 모양, 오류 번호의 뜻 등)을 모아 둔 클래스가 있고 이를 Dialect(사투리)라고 부른다. MariaDB용인 hibernate-core의 dialect/MariaDBDialect.java:364-368에 「@@innodb_snapshot_isolation이 켜져 있으면(@@는 MariaDB에서 설정값 이름 앞에 붙여 「이 설정의 값」을 가리키는 표시다)(11.6.2부터 기본) 지금 read view에 없는 행을 잠그려 할 때 DB_RECORD_CHANGED가 난다」는 주석과 함께, 오류 번호 1020을 SnapshotIsolationException으로 바꾸는 줄이 있다. Boot 4.1.1 결과의 SnapshotIsolationException이 이것이다.


소스로 따라가면 3 — Spring은 Hibernate의 예외를 OLFE의 자식으로 바꿔 catch에 걸리게 한다

flush()에서 Hibernate 예외가 나면, Spring이 그것을 Spring의 예외로 바꿔 다시 던진다. 바꾸는 표가 spring-orm의 jpa/hibernate/HibernateExceptionTranslator.java다. spring-orm은 Spring Framework 소스 안에서 Hibernate 같은 DB 도구와 Spring을 잇는 코드가 든 폴더다.

if (exToCheck instanceof StaleObjectStateException hibEx) {          // 203줄
    return new ObjectOptimisticLockingFailureException(hibEx.getEntityName(), hibEx.getIdentifier(), ex.getMessage(), ex);
}
if (exToCheck instanceof StaleStateException) {                       // 206줄
    return new ObjectOptimisticLockingFailureException(ex.getMessage(), ex);
}
  • ex와 exToCheck — 이 메서드가 받는 두 예외다(같은 파일 134줄 convertHibernateAccessException(HibernateException ex, HibernateException exToCheck)). 처음에는 둘이 같은 예외이고(131줄), 맞는 종류를 못 찾으면, exToCheck의 원인이 Hibernate 예외일 때만 exToCheck를 그 원인으로 바꿔 다시 확인한다(219-221줄). 그것도 아니면 223줄에서 JpaSystemException을 만든다. ex는 새 예외에 원인으로 넣는 원래 예외다
  • exToCheck instanceof StaleObjectStateException hibEx — 「exToCheck가 StaleObjectStateException(또는 그 자식)인가」를 묻고, 맞으면 그 예외를 hibEx라는 이름의 변수로 바로 쓸 수 있게 해 주는 자바 문법이다(자바 16부터)
  • new ObjectOptimisticLockingFailureException(hibEx.getEntityName(), hibEx.getIdentifier(), ex.getMessage(), ex) — 새 예외를 만든다. 네 인자는 차례로 충돌한 Entity 이름, 그 행의 id, 메시지, 원인으로 넣을 원래 예외다
  • 정리하면 앞 절의 낡은 객체 예외 둘은 모두 ObjectOptimisticLockingFailureException이 된다

그리고 spring-orm의 ObjectOptimisticLockingFailureException.java:31이 class ObjectOptimisticLockingFailureException extends OptimisticLockingFailureException이다. OLFE를 물려받은 자식 클래스라는 뜻이다. 자바의 catch (OptimisticLockingFailureException e)는 그 클래스와 자식 클래스를 모두 잡는다. 그래서 끈 상태의 충돌이 구현체의 OLFE catch에 걸렸다.

켠 상태는 다르다. 같은 파일 161줄이 Hibernate의 LockAcquisitionException을 CannotAcquireLockException으로 바꾼다. 이것은 spring-tx(Spring Framework 안에서 트랜잭션과, DB 도구와 상관없이 쓰는 공통 DB 예외들이 든 폴더)의 CannotAcquireLockException.java:31에서 PessimisticLockingFailureException의 자식이다. Pessimistic은 비관이라는 뜻이다. 낙관락이 「일단 고치고 저장할 때 번호로 확인」하는 방식이라면, 비관락은 「고치기 전에 먼저 잠가 남이 못 고치게」 하는 방식이다. Spring은 잠금을 얻지 못한 실패를 이쪽 무리로 분류한다. OLFE와는 부모 쪽(ConcurrencyFailureException)에서만 만나는 형제 관계라 OLFE catch에 걸리지 않는다. Boot 3.5.16 결과가 이 모양이다. 다만 Hibernate 6.6이 1020을 LockAcquisitionException으로 바꾸는 줄은 그 버전 소스가 없어 확인하지 못했다.

SnapshotIsolationException은 이 표 어디에도 없다. 남은 길은 둘이다.

  • 같은 파일 135-141줄 — 이 메서드는 표를 보기 전에, 예외가 JDBCException(DB가 돌려준 SQL 오류를 담은 Hibernate 예외)이고 Spring에 따로 SQL 오류 번역기가 설정돼 있으면 그 번역기에 먼저 맡긴다. SnapshotIsolationException은 JDBCException의 자식이다(hibernate-core의 exception/SnapshotIsolationException.java:21). 이 실험에서는 이 길로 이름이 정해지지 않았다. 번역기가 설정돼 있지 않았는지, 설정돼 있었지만 답을 못 냈는지는 확인하지 않았다
  • 같은 파일 219-223줄 — 원인이 Hibernate 예외면 원인으로 다시 확인하고(219-221줄), 아니면 JpaSystemException을 만든다(223줄). SnapshotIsolationException의 원인은 Hibernate 예외가 아니라 SQLException이라(결과 사슬 SnapshotIsolationException <- SQLException) 223줄로 간다

이 실험의 Boot 4.1.1은 이 두 번째 길로 JpaSystemException이 됐다. 이 이름은 「Spring이 더 맞는 이름을 찾지 못했다」는 뜻일 뿐 무슨 일이 났는지는 알려 주지 않는다. 그래서 이 글은 JpaSystemException을 넓은 이름이라고 부른다.

하나 더 있다. 이름이 비슷한 예외가 둘이라 헷갈리기 쉽다. OptimisticLockException은 JPA 규칙 문서가 정한 예외이고(jakarta.persistence 소속), OLFE(OptimisticLockingFailureException)는 Spring이 정한 예외다. Hibernate 쪽 internal/ExceptionConverterImpl.java:97-98에는 SnapshotIsolationException을 JPA의 OptimisticLockException으로 바꾸는 길이 따로 있다. 그 길을 탔다면, JPA 예외를 Spring 예외로 바꾸는 Spring 쪽 도구 spring-orm의 jpa/EntityManagerFactoryUtils.java:486-487이 그것을 JpaOptimisticLockingFailureException으로 바꿨을 것이다. 이 클래스는 ObjectOptimisticLockingFailureException의 자식이라(JpaOptimisticLockingFailureException.java:32) OLFE catch에 걸린다. 실험 결과는 그렇지 않았다. 두 변환 길(Hibernate의 ExceptionConverterImpl을 거치는 길과, Spring의 HibernateExceptionTranslator가 바로 받는 길)이 어떤 조건에서 갈리는지, 이 실험에서 왜 뒤의 길만 탔는지는 따라가지 않아 모른다.


예상과 달랐던 점 — 끄면 version이 없는 행은 충돌 없이 덮어써졌다

판정과 별개로, 끈 상태의 나머지 경우에서 표준을 정할 때 알아야 할 것이 나왔다.

설정   Boot    경우           catch  Probe가 받은 예외                                          번역  롤백  끝난 뒤 DB
iso=0  3.5.16  (마)version없음  none   없음                                                     no    no    name:outer
iso=0  3.5.16  (바)안읽은행     none   없음                                                     no    no    name:outer
iso=1  3.5.16  (마)version없음  none   CannotAcquireLockException <- ... <- SQLException(1020)  no    yes   name:inner
iso=1  3.5.16  (바)안읽은행     none   CannotAcquireLockException <- ... <- SQLException(1020)  no    yes   name:inner

표는 Boot 3.5.16만 실었다. 켠 상태 두 줄의 ...는 결과 표와 같은 LockAcquisitionException이다. 마지막 칸 「끝난 뒤 DB」는 실험이 끝나고 그 행의 이름을 DB에서 다시 읽은 값이다. outer면 바깥이 쓴 값이 남은 것이고, inner면 먼저 저장한 안쪽의 값이 지켜진 것이다. Boot 4.1.1도 catch·번역·롤백·끝난 뒤 DB 칸은 같았다. 원인 사슬만 다르다 — 켠 상태 두 줄이 JpaSystemException <- SnapshotIsolationException <- SQLException(1020)이다(LockAcquisitionException은 없다).

켠 상태에서는 @Version이 없는 표도, JPA를 거치지 않은 SQL도 DB가 1020으로 막았다. 끄자 두 경우 모두 예외가 나지 않았고, 끝난 뒤 DB의 이름은 outer였다. 그런데 두 경우는 뜻이 다르다.

  • (마)는 진짜 잃어버린 수정이다. 바깥은 plains 행을 먼저 읽고(findName), 그 사이 안쪽이 inner로 고쳐 커밋한 뒤, 바깥이 outer로 덮어썼다. 바깥은 안쪽이 고치기 전 모습을 본 채로 저장했고, 안쪽이 먼저 저장한 inner가 말없이 사라졌다 — 글 처음의 장면이 그대로 일어났다
  • (바)는 다르다. 바깥은 u4를 한 번도 읽지 않았다. 옛 모습을 보고 덮어쓴 것이 아니라, 읽지 않은 행에 나중에 저장한 쪽의 값이 남은 것이다. 여기서는 오히려 켠 상태의 1020이 막을 필요가 없었을 수도 있는 저장까지 막았다고 볼 수 있다. 바깥이 그 행을 본 적이 없으니 「옛 모습을 보고 고쳤다」는 위험이 없기 때문이다

그러니 끄는 것은 공짜가 아니다. 끄면 낙관락 catch는 살아나지만, 그 대신 @Version이 없는 표에서 읽고 고치는 곳((마))은 보호막을 잃는다. 켠 상태의 1020은 「catch가 모르는 예외」였지만, (마)에서는 동시에 「@Version이 없어도 잃어버린 수정을 막아 주는 장치」이기도 했다. 반대로 (바)처럼 읽지 않은 행을 고치는 곳에서는, 켜 두면 막히지 않아도 될 저장까지 실패할 수 있다.

두 가지가 더 있다.

  • flush()가 없는 옛 구현체((가)noflush)는 끈 상태에서도 번역되지 않았다. ObjectOptimisticLockingFailureException이 나긴 했지만 커밋 때 나서 catch를 이미 지난 뒤였다. 번역되지 않은 채 Probe까지 그대로 올라갔다. 원칙 2-2의 flush()는 끈 상태에서도 필요하다
  • 접속 설정만으로 끈 것(iso=0s)도 iso=0과 모든 경우에서 같은 결과였다. 서버 설정을 못 바꾸는 곳에서도 접속 설정으로 끌 수 있다. 다만 판정에 쓰지 않은 설정이라 참고로만 적는다

이 결과가 이슈 #169에 남기는 것 — 끌 수 있다는 사실과 끄는 대가가 함께 확인됐다

이 실험이 정한 것은 사실 하나다. 스냅숏 격리를 끄면 표준 모양의 catch가 낙관락 충돌을 번역한다. 끌지 말지는 이 실험이 정하지 않는다. 그 결정은 이슈 #169에서 한다.

결정할 때 올려놓을 것은 이렇다.

  • 끄는 쪽 — 표준 예시 코드가 적힌 그대로 동작한다. 대신 @Version이 없는 표에서 먼저 읽고 고치는 곳((마))에서 잃어버린 수정이 조용히 일어난다
  • 켜 두는 쪽 — @Version이 없는 표에서도 DB가 잃어버린 수정을 막았다((마)). 대신 읽지 않은 행을 고치는 저장까지 1020으로 막을 수 있고((바)), 1020을 따로 번역해야 한다. 그런데 Boot 4.1.1에서는 그 예외가 JpaSystemException으로 온다. 앞의 「소스로 따라가면 3」 절에서 보았듯 Spring이 맞는 이름을 찾지 못한 예외에 마지막으로 붙이는 이름이라, 이 맨 겉 예외 이름만 보고는 1020인지 알 수 없다. 원인 사슬을 열어 봐야 한다

재지 않은 것도 적어 둔다. 반복은 1회였다. MariaDB 11.8 말고 다른 DB는 재지 않았다. 여러 요청이 정말로 같은 순간에 몰려 들어오는 상황도 재지 않았다 — 이 실험은 끼어드는 순서를 코드로 정해 한 번씩 돌렸다. flush()를 저장마다 부르는 비용도 재지 않았다. 그 비용이 무엇인지는 예외 종류별 처리 표준 원칙 2-2에 적혀 있다 — 원래는 한 트랜잭션에서 바뀐 것을 모아 커밋 때 한 번에 보내는데, 저장마다 flush()하면 그 모아 보내는 이득을 버린다.


정리

  • innodb_snapshot_isolation을 끄면 표준 모양 구현체의 OLFE catch가 실행되어 USER_CONCURRENT_UPDATE로 번역된다. Boot 3.5.16과 4.1.1 모두 같았다
  • 켠 상태(11.8 기본값)에서는 DB가 오류 1020으로 먼저 거절해 catch에 닿지 않는다. 앞선 실험과 같은 결과다
  • 끄면 @Version이 없는 표에서 읽고 고친 수정은 충돌 없이 덮어써진다((마) — 잃어버린 수정). 켠 상태에서는 1020으로 막혔다. 바깥이 읽지 않은 행을 고친 (바)도 켠 상태에서는 막혔는데, 이것은 막을 필요가 없었던 저장일 수 있다
  • 끈 상태에서도 flush()가 try 안에 없으면 번역되지 않는다
  • 서버 설정을 못 바꾸면 접속 설정(sessionVariables)으로도 끌 수 있었다
  • 끌지 말지는 이슈 #169가 정한다

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

댓글남기기