예외 종류별 처리 표준의 원칙 2는 영속성 예외(DB에 저장하고 읽는 과정에서 나는 예외)를 Repository 구현체의 try/catch에서 번역하게 한다. 예시 구현체는 save 하나에서 신규와 수정을 함께 처리하고, 전체를 감싼 catch가 DataIntegrityViolationException(Spring이 DB 제약 위반을 감싸 돌려주는 예외)과 OptimisticLockingFailureException을 ErrorCodeException으로 바꾼다. 여기서 Repository 구현체는 Repository(DB에 저장하고 꺼내는 창구) 인터페이스를 내가 직접 구현한 클래스다. 그 아래에는 이런 문장이 붙어 있다.
위처럼 영속 Entity를 직접 고치면 JPA가 version을 검사하므로 이 catch가 제 역할을 한다.
여기서 영속 Entity는 영속성 컨텍스트가 지금 트랜잭션에서 관리하고 있는 Entity다 — DB에서 막 조회해 아직 들고 있는 객체라는 뜻이다. JPA는 자바 메서드 호출을 SQL(DB에 무엇을 하라고 보내는 말)로 바꿔 DB에 보내 주는 규칙이다. version을 검사한다는 것은 낙관적 락이 저장 시점에, 자신이 읽었던 버전 번호가 지금도 그대로인지 확인한다는 뜻이다.
그런데 수정 경로는 Domain 영속화 표준 원칙 1대로 findById → applyTo → 더티체킹이다. DB에서 기존 Entity를 찾아오고(findById), 거기에 바뀐 값을 옮겨 적은(applyTo) 뒤, 바뀐 것을 JPA가 알아서 알아채 DB에 반영하게 두는 순서다(코드는 아래 「설계」에서 한 줄씩 본다). UPDATE는 save가 아니라 커밋 때 나간다. 그때는 구현체의 try를 이미 빠져나온 뒤다. 커밋은 save가 아니라 save를 부른 Service 메서드가 끝날 때 일어나기 때문이다(「설계」의 트랜잭션 경계 참고). 이 저장소의 이슈 #161을 조사하던 에이전트가 이렇게 추론했고, 실측은 없었다. 선행 실험은 Spring이 만들어 주는 Repository 구현체를 그대로 썼고, 그 구현체는 Spring이 이미 예외를 번역해 준다. 그래서 「try/catch로 직접 번역하는 자체 구현체」는 재지 않고 남겼는데, 이 실험이 그것을 처음 잰다.
주장 — 구현체의 catch는 커밋 시점에 터지는 수정 경로 예외를 번역하지 못한다
Repository 구현체가 try/catch 로 영속성 예외를 번역해도, 수정 경로(영속 Entity + 더티체킹)의
낙관락·제약 위반 예외는 커밋 시점에 구현체 밖에서 터져 번역되지 않는다.
근거 종류는 1(기술 사실)이다.
반증 조건 — 한 환경이라도 수정 경로에서 번역된 예외가 호출자에 닿으면 반증이다
(가)·(나)·(다)·(라)가 각각 무엇을 가리키는지는 바로 아래 「설계」에서 정의한다 — 차례로 수정·낙관락 충돌, 수정·유니크 위반, 신규·유니크 대조군, (가)·(나)의 flush 변형이다. 두 환경은 Spring Boot 3.5.16과 4.1.1이다. 판정 세 가지(refuted 반증 · supported 지지 · inconclusive 판단 불가)의 뜻은 판정에 있고, 제목의 「지지」가 supported다.
- refuted — 두 환경 중 하나라도 (가) 또는 (나)에서 구현체의 catch가 실행되어
ErrorCodeException이 호출자에 도달한다 - supported — 두 환경 모두 (가)와 (나)에서 catch가 실행되지 않고, 번역 안 된 예외가 호출자에 도달한다
- inconclusive — 그 밖. 특히 대조군 (다)에서 catch가 실행되지 않거나, (가)(나)에서 충돌이 재현되지 않는다
(라)는 기록만 하고 판정에 쓰지 않는다.
설계 — 표준 예시 모양의 구현체로 네 경우를 돌린다
구현체는 표준 예시를 그대로 옮겼다. 표준이 허용하지 않는 모양을 쓰면 실험이 아니라 다른 코드를 재게 된다. 달라진 것은 catch마다 「실행됐다」는 표식을 남기는 줄뿐이다.
@Repository
public class UserJpaRepository implements UserRepository {
public UserDomain save(UserDomain user) {
try {
if (user.getId() == null) {
return mapper.toDomain(jpaEntityRepository.save(mapper.toEntity(user)));
}
UserEntity entity = jpaEntityRepository.findById(user.getId()).orElseThrow(...);
mapper.applyTo(entity, user);
return mapper.toDomain(entity);
} catch (DataIntegrityViolationException e) {
throw ErrorCodeException.of(ErrorCode.DUPLICATE_EMAIL, e);
} catch (OptimisticLockingFailureException e) {
throw ErrorCodeException.of(ErrorCode.USER_CONCURRENT_UPDATE, e);
}
}
}
한 줄씩 따라가기. @Repository는 이 클래스를 Spring이 만들어 관리하는 객체(빈)로 등록하고, 그 역할이 DB 접근이라고 표시하는 애너테이션이다(Repository 참고). 코드에 선언이 안 보이는 mapper와 jpaEntityRepository는 이 클래스의 필드이고, 표준 예시처럼 선언과 생성자는 줄였다. 실험 코드에서는 생성자 매개변수로 받아 Spring이 넣어 준다(의존성 주입).
mapper는UserDomain(순수한 도메인 객체)과UserEntity(JPA가 DB 테이블과 연결해 관리하는 객체)를 서로 바꿔 주는 역할만 하는 객체다 —toEntity는UserDomain을UserEntity로,toDomain은 그 반대로 바꾼다jpaEntityRepository는 Spring Data JPA의JpaRepository를 이어받은 인터페이스(UserJpaEntityRepository extends JpaRepository<UserEntity, Long>)다. 구현 코드는 내가 짜지 않고 Spring이 만들어 넣으며,save·findById·flush는 그 인터페이스가 이미 갖고 있는 메서드다id가 없으면(신규)toEntity로 바꾼 뒤jpaEntityRepository.save로 DB에 저장하고, 결과를 다시toDomain으로 돌려준다id가 있으면(수정)jpaEntityRepository.findById(user.getId())로 기존UserEntity를 찾는다. 이 메서드는 값이 없을 수도 있다는 뜻의Optional에 담아 돌려주므로.orElseThrow(...)로 “없으면 예외를 던지고, 있으면 그 값을 꺼내라”고 이어 붙인다....자리에는 던질 예외를 만드는 람다가 들어가고, 실험 코드에서는() -> ErrorCodeException.of(ErrorCode.USER_NOT_FOUND)다- 찾은
entity에mapper.applyTo(entity, user)로UserDomain의 값을 옮겨 적는다. 이entity는 영속성 컨텍스트가 관리하는 상태라, 메서드를 빠져나간 뒤 트랜잭션이 커밋될 때 그 변경이 더티체킹으로 감지되어 UPDATE가 나간다 - 두
catch는 저장 중에 난 Spring 예외를 받아ErrorCodeException으로 바꿔 다시 던진다. 이 실험이 지켜보는 것은 이 두catch가 실행되느냐다
설계의 전제가 셋 더 있다. 첫째, UserDomain은 version을 나르지 않고 @Version은 Entity에만 있다. 표준이 정한 모양이 그렇다. 그래서 낙관적 락이 비교하는 version은 Domain이 들고 온 값이 아니라 영속성 컨텍스트가 들고 있는 Entity의 값이다. 이것이 (가)의 모양을 정한다 — 바깥 트랜잭션이 먼저 행을 읽어 Entity를 들고 있어야, 남이 고친 뒤에도 바깥은 옛 version을 쥔 채 저장하러 간다. 둘째, 트랜잭션 경계(트랜잭션이 열리고 커밋되는 자리)는 Service의 @Transactional 메서드다. Service 메서드가 끝나야 커밋이 일어나고, Service는 예외를 잡지 않는다. 셋째, 호출자는 Service 밖의 main이다. 예외가 어디까지 올라왔는지는 main에서 본다.
돌린 경우는 넷이다. 경우마다 다른 행을 써서 서로 섞이지 않게 했다.
(가) 수정·낙관락 바깥이 읽음 → REQUIRES_NEW 가 같은 경로로 고쳐 먼저 커밋 → 바깥이 고쳐 커밋
(나) 수정·유니크 한 행의 email 을 다른 행의 email 로 바꿔 커밋
(다) 신규·유니크 있는 email 로 새로 저장. IDENTITY 라 save 에서 바로 INSERT — 대조군
(라) (가)(나) 변형 구현체 try 안에서 applyTo 뒤에 jpaEntityRepository.flush() 까지 부른다
네 경우에 쓰인 말을 짚어 둔다. (가)의 바깥은 Service의 @Transactional 메서드가 연 바깥 트랜잭션이다. 이 트랜잭션이 행을 읽은 뒤, 끝나기 전에 다른 트랜잭션 하나를 끼워 넣는다. REQUIRES_NEW는 @Transactional(propagation = REQUIRES_NEW)를 줄인 말로, 부르는 쪽의 트랜잭션과 별개로 새 트랜잭션을 열어 따로 커밋하라는 뜻이다 — (가)에서 끼워 넣은 안쪽이 바깥보다 먼저 커밋되게 만드는 장치다. 같은 경로로는 안쪽도 같은 구현체의 save를 수정 경로(findById → applyTo)로 탄다는 뜻이다. 코드로는 바깥 Service 메서드 rename이 findById로 읽은 다음, 넘겨받은 Runnable을 실행하고, 그 안에서 REQUIRES_NEW가 붙은 Service 메서드 renameNow를 부른다. renameNow가 끝나며 먼저 커밋되고, 그 뒤 rename이 save를 부르고 끝나며 커밋하려 한다. (나)의 users 테이블은 email 컬럼에 유니크 제약이 걸려 있어서, 다른 행과 같은 email로 바꾸면 위반이 된다. 유니크는 유니크 제약을 줄인 말로, 한 컬럼에 같은 값이 두 번 들어오지 못하게 막는 DB 규칙이다. IDENTITY는 PK 값을 DB가 저장 시점에 자동으로 매기는 방식이라(@Id·@GeneratedValue 참고), 행을 실제로 넣어 봐야 id를 알 수 있다(Hibernate 사용자 가이드의 IDENTITY 설명). 그래서 커밋까지 미루지 못하고 save를 부르자마자 INSERT가 나가 (다)가 대조군 역할을 할 수 있다. (라)의 flush()는 모아 둔 변경을 커밋 전에 미리 DB로 내보내는 호출로, 구현체의 try 블록 안에서 직접 불러 catch가 그 결과를 받을 기회를 만든다.
경우마다 구현체의 어느 catch가 실행됐는지, 구현체 try가 정상 반환까지 갔는지, 호출자가 받은 예외와 cause 체인(예외가 감싸 품고 있는 원인 예외를 끝까지 따라간 줄), 그 예외가 ErrorCodeException인지, 끝난 뒤 DB 값을 기록했다. DB는 표준이 고정한 MariaDB 11.8이고, 실행마다 전용 컨테이너와 도커 네트워크(컨테이너끼리 서로 통신할 수 있게 묶어 두는 가상의 통신망이다)를 새로 만들고 끝나면 지웠다. 환경은 Spring Boot 3.5.16(Hibernate 6.6.53)과 4.1.1(Hibernate 7.4.5)이다.
결과 — 수정 경로 둘 다 catch가 돌지 않았고, 대조군만 번역됐다
impl_catch try 반환 호출자가 받은 예외 번역
Boot 3.5.16 (가) none yes CannotAcquireLockException <- ... SQLException(1020) no
(나) none yes DataIntegrityViolationException <- ... (1062) no
(다) DIVE no ErrorCodeException(DUPLICATE_EMAIL) yes
Boot 4.1.1 (가) none yes JpaSystemException <- SnapshotIsolationException(1020) no
(나) none yes DataIntegrityViolationException <- ... (1062) no
(다) DIVE no ErrorCodeException(DUPLICATE_EMAIL) yes
표를 읽는 법 — impl_catch는 구현체의 catch 중 무엇이 실행됐는지(DIVE는 DataIntegrityViolationException을 잡는 catch의 줄임, none이면 아무 것도 안 잡힘), try 반환은 구현체의 try 블록이 끝까지 정상적으로 끝났는지, 호출자가 받은 예외는 main까지 올라온 예외와 그 원인(<-로 이어진 cause) 체인, 번역은 그 예외가 ErrorCodeException으로 바뀌었는지다. CannotAcquireLockException과 JpaSystemException은 둘 다 Spring이 DB 예외를 Spring 자신의 예외 타입으로 감쌀 때 붙이는 이름이다. 체인 맨 끝의 SQLException은 DB가 돌려준 오류를 자바가 처음 받은 예외이고, 가운데 ...는 Hibernate가 한 번 감싼 예외를 줄인 것이다(원본 기록에서 Boot 3.5.16 (가)는 LockAcquisitionException, (나)는 두 버전 모두 ConstraintViolationException). 괄호 안 숫자(1020·1062)는 MariaDB가 돌려주는 오류 번호다. 1062는 유니크 제약에 걸린 중복 값이고, 1020은 바로 다음 절에서 푼다.
판정은 supported다. (가)와 (나)에서 구현체 try는 정상 반환까지 갔고, catch는 하나도 실행되지 않았다. 예외는 그 뒤, 커밋 때 터져 번역되지 않은 채 호출자에 닿았다. 대조군 (다)는 save에서 바로 INSERT가 나가 catch가 실행되고 ErrorCodeException이 도달했으므로, 번역 코드 자체는 동작하는 실험이다. 모든 경우 트랜잭션은 롤백됐다. 번역 여부와 상관없이 롤백된 것은, (가)(나)처럼 커밋 도중 예외가 나면 Spring의 트랜잭션 관리자(@Transactional 뒤에서 트랜잭션을 열고 커밋 · 롤백하는 Spring 객체)가 그 트랜잭션을 되돌린 뒤 예외를 다시 던지고, (다)처럼 메서드 안에서 예외가 빠져나가면 @Transactional이 커밋하지 않고 되돌리기 때문이다. @Transactional은 기본으로 RuntimeException과 Error에만 되돌리는데, ErrorCodeException은 RuntimeException을 이어받았으니 여기에 든다. 롤백 여부는 끝난 뒤 DB에서 행을 다시 읽어 바깥 트랜잭션의 변경이 남지 않았는지로 확인했다. 두 Boot 버전에서 (가)의 예외 클래스만 달랐다.
예상과 달랐던 점 — 낙관락 충돌은 version 검사가 아니라 DB가 먼저 거절했다
(가)는 OptimisticLockingFailureException을 예상했다. 실제로는 MariaDB가 오류 1020(「Record has changed since last read」)으로 UPDATE를 먼저 거절했다. MariaDB 11.8은 innodb_snapshot_isolation=1이 기본이라, REPEATABLE READ에서 스냅숏 이후 남이 고친 행을 고치려 하면 DB가 막는다. Hibernate가 「갱신된 행 0개」를 보고 version 충돌을 판단할 기회가 오지 않는다.
이 두 문장을 풀면 이렇다. innodb_snapshot_isolation은 MariaDB의 저장 엔진 InnoDB에 걸리는 DB 서버 쪽 설정값이고, 이 실험은 SELECT @@innodb_snapshot_isolation으로 읽어 1인 것을 기록했다. 스냅숏 아래에서 트랜잭션은 자기가 처음 본 모습만 본다. 그 상태로 남이 이미 고친 행을 고치면, 보지도 못한 남의 변경을 덮어쓰게 된다. 이 설정이 켜져 있으면 DB가 그 UPDATE를 받지 않고 1020으로 돌려보낸다. 반대로 Hibernate의 낙관적 락은 UPDATE 문의 WHERE에 「version이 내가 읽은 값과 같을 때만」이라는 조건을 붙여 보내고, 바뀐 행이 0개로 돌아오면 남이 먼저 고쳤다고 판단해 예외를 던진다(Hibernate 문서의 optimistic lock 설명). 이번에는 DB가 UPDATE 자체를 거절해서, 「바뀐 행 0개」라는 대답이 Hibernate에 돌아오지 않았다.
그래서 Spring이 받는 예외도 낙관락 계열이 아니었다. Boot 3.5.16은 CannotAcquireLockException(PessimisticLockingFailureException 계열 — 동시에 못 바꾸게 미리 잠그는 비관적 락 쪽 실패에 Spring이 붙이는 이름), Boot 4.1.1은 Spring이 Hibernate 7의 SnapshotIsolationException을 알맞은 Spring 예외로 번역하지 못해 JpaSystemException(Spring이 더 맞는 이름을 찾지 못했을 때 붙이는 넓은 이름)이다. 여기서 「번역」은 구현체의 catch가 아니라 Spring이 커밋 과정에서 Hibernate 예외를 자기 예외로 바꿔 주는 일이다. 구현체의 catch는 그렇게 바뀐 Spring 예외를 받아 한 번 더 바꾸는 자리다. 같은 충돌이 Boot 버전에 따라 다른 계열로 나온다.
낙관락 계열이 아니라는 이 차이가 (라)에서 드러났다. (라)는 판정에 쓰지 않아 위 표에 넣지 않았다. 두 Boot 버전 모두 결과가 같았다 — ra_ga는 catch가 돌지 않고 (가)와 같은 예외(3.5.16 CannotAcquireLockException, 4.1.1 JpaSystemException)가 왔고, ra_na는 DIVE catch가 돌아 ErrorCodeException이 왔다. (라)는 (가)·(나)를 flush만 당겨서 다시 돌린 변형이라, 그 결과를 각각 ra_ga((라)의 (가), 즉 flush·낙관락 변형)와 ra_na((라)의 (나), 즉 flush·유니크 변형)로 이름 붙였다. flush를 try 안으로 당기자 유니크 위반(ra_na)은 catch가 잡아 ErrorCodeException이 됐다. 그런데 낙관락 충돌(ra_ga)은 try 안에서 터졌는데도 catch가 돌지 않았다 — catch (OptimisticLockingFailureException)에 맞는 타입이 아니기 때문이다. catch는 적힌 클래스와 그 자식 클래스만 잡는다(상속). Spring 소스에서 CannotAcquireLockException은 PessimisticLockingFailureException의 자식이고, OptimisticLockingFailureException과는 같은 부모(ConcurrencyFailureException)를 둔 형제다. JpaSystemException은 아예 다른 갈래(UncategorizedDataAccessException)다. 둘 다 OptimisticLockingFailureException의 자식이 아니라 그냥 지나간다. 표준 예시의 두 번째 catch는 이 DB 설정에서 시점을 당겨도 쓸모가 없다.
이 결과가 표준에 남기는 것 — flush를 당기는 안과 낙관락 catch 타입을 다시 정할지 연다
실험이 판정한 것은 근거 문장이다. 「영속 Entity를 직접 고치면 이 catch가 제 역할을 한다」는 수정 경로에서는 성립하지 않았다. 두 catch 모두 시점 때문에 돌지 않는다. 규칙을 어떻게 고칠지는 이 실험이 정하지 않는다. 아래는 제안이다. 「안 B」는 표준 글에서 이 안에 붙은 이름이고, 안 A · 안 C와 함께 어떻게 정해졌는지는 예외 종류별 처리 표준 원칙 2-2에 있다.
- 안 B — 수정 경로에서 구현체
try안에flush()를 둔다. (라)에서 유니크 위반은 이것만으로 번역됐다. 대가는 커밋 전에 UPDATE가 나가 같은 트랜잭션의 쓰기 묶음(JDBC batch — 여러 INSERT·UPDATE를 모았다가 한 번에 DB로 보내 왕복 횟수를 줄이는 방식)이 끊기는 것이고, 이것은 재지 않았다 - 낙관락 catch 타입을 DB 설정과 함께 정한다. MariaDB 11.8 기본값에서는
OptimisticLockingFailureException이 오지 않는다.innodb_snapshot_isolation을 끌지, 1020을 따로 번역할지는 새 안건이다. Boot 4.1.1에서는 그 예외가JpaSystemException으로 뭉개져 타입으로 가를 수도 없다. 이 이름은 Spring이 더 맞는 이름을 못 찾은 예외에 두루 붙이므로,catch (JpaSystemException)을 쓰면 낙관락 충돌이 아닌 다른 실패까지 함께 잡힌다 - 번역을 트랜잭션 경계 밖(Service 호출 바깥)으로 옮기는 안도 후보다. 이 실험에서 번역 안 된 예외가 도달한 곳이 거기다. 커밋은 Service의
@Transactional메서드가 끝날 때 일어나므로, Service를 부르는 쪽(이 실험의main)은 커밋 때 난 예외까지 받는다. 재지는 않았다
정리
- 수정 경로(
findById→applyTo→ 더티체킹)의 예외는 구현체try가 정상 반환한 뒤 커밋 때 터진다. 표준 예시 모양의 catch는 두 Boot 버전 모두에서 실행되지 않았다 - 유니크 위반은
try안에서flush()를 부르면 번역된다. 신규 경로(IDENTITY)는 원래 번역된다 - MariaDB 11.8 기본값에서 낙관락 충돌은 오류 1020으로 오고,
OptimisticLockingFailureException이 아니다. Boot 3.5.16은CannotAcquireLockException, 4.1.1은JpaSystemException이다 - 모든 경우 트랜잭션은 롤백됐다. 번역이 안 돼도 데이터는 남지 않는다
- 재지 않은 것 —
innodb_snapshot_isolation=0에서 Hibernate의 version 검사가 먼저 걸리는 경우, 다른 DB, 안 B의 성능 대가, 반복 실행(1회)
자신만의 철학을 만들어가는 중입니다.
댓글남기기