예외 종류별 처리 표준의 원칙 2는 영속성 예외를 Repository 구현체의 try/catch에서 번역하게 한다. 예시 구현체는 save 하나에서 신규와 수정을 함께 처리하고, 전체를 감싼 catch가 DataIntegrityViolationException과 OptimisticLockingFailureException을 ErrorCodeException으로 바꾼다. 그 아래에는 이런 문장이 붙어 있다.
위처럼 영속 Entity를 직접 고치면 JPA가 version을 검사하므로 이 catch가 제 역할을 한다.
그런데 수정 경로는 Domain 영속화 표준 원칙 1대로 findById → applyTo → 더티체킹이다. UPDATE는 save가 아니라 커밋 때 나간다. 그때는 구현체의 try를 이미 빠져나온 뒤다. 이슈 #161을 조사하던 에이전트가 이렇게 추론했고, 실측은 없었다. 선행 실험이 재지 않고 남긴 「번역하는 자체 구현체」도 이 실험이 처음 잰다.
주장 — 구현체의 catch는 커밋 시점에 터지는 수정 경로 예외를 번역하지 못한다
Repository 구현체가 try/catch 로 영속성 예외를 번역해도, 수정 경로(영속 Entity + 더티체킹)의
낙관락·제약 위반 예외는 커밋 시점에 구현체 밖에서 터져 번역되지 않는다.
근거 종류는 1(기술 사실)이다.
반증 조건 — 한 환경이라도 수정 경로에서 번역된 예외가 호출자에 닿으면 반증이다
- 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);
}
}
}
UserDomain은 version을 나르지 않고 @Version은 Entity에만 있다. 트랜잭션 경계는 Service의 @Transactional 메서드이고, Service는 예외를 잡지 않는다. 호출자는 Service 밖의 main이다.
돌린 경우는 넷이다. 경우마다 다른 행을 써서 서로 섞이지 않게 했다.
(가) 수정·낙관락 바깥이 읽음 → REQUIRES_NEW 가 같은 경로로 고쳐 먼저 커밋 → 바깥이 고쳐 커밋
(나) 수정·유니크 한 행의 email 을 다른 행의 email 로 바꿔 커밋
(다) 신규·유니크 있는 email 로 새로 저장. IDENTITY 라 save 에서 바로 INSERT — 대조군
(라) (가)(나) 변형 구현체 try 안에서 applyTo 뒤에 jpaEntityRepository.flush() 까지 부른다
경우마다 구현체의 어느 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
판정은 supported다. (가)와 (나)에서 구현체 try는 정상 반환까지 갔고, catch는 하나도 실행되지 않았다. 예외는 그 뒤, 커밋 때 터져 번역되지 않은 채 호출자에 닿았다. 대조군 (다)는 save에서 바로 INSERT가 나가 catch가 실행되고 ErrorCodeException이 도달했으므로, 번역 코드 자체는 동작하는 실험이다. 모든 경우 트랜잭션은 롤백됐다. 두 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 충돌을 판단할 기회가 오지 않는다.
그래서 Spring이 받는 예외도 낙관락 계열이 아니었다. Boot 3.5.16은 CannotAcquireLockException(PessimisticLockingFailureException 계열), Boot 4.1.1은 Hibernate 7의 SnapshotIsolationException을 번역하지 못해 JpaSystemException이다. 같은 충돌이 Boot 버전에 따라 다른 계열로 나온다.
이 차이가 (라)에서 드러났다. flush를 try 안으로 당기자 유니크 위반(ra_na)은 catch가 잡아 ErrorCodeException이 됐다. 그런데 낙관락 충돌(ra_ga)은 try 안에서 터졌는데도 catch가 돌지 않았다 — catch (OptimisticLockingFailureException)에 맞는 타입이 아니기 때문이다. 표준 예시의 두 번째 catch는 이 DB 설정에서 시점을 당겨도 쓸모가 없다.
이 결과가 표준에 남기는 것 — flush를 당기는 안과 낙관락 catch 타입을 다시 정할지 연다
실험이 판정한 것은 근거 문장이다. 「영속 Entity를 직접 고치면 이 catch가 제 역할을 한다」는 수정 경로에서는 성립하지 않았다. 두 catch 모두 시점 때문에 돌지 않는다. 규칙을 어떻게 고칠지는 이 실험이 정하지 않는다. 아래는 제안이다.
- 안 B — 수정 경로에서 구현체
try안에flush()를 둔다. (라)에서 유니크 위반은 이것만으로 번역됐다. 대가는 커밋 전에 UPDATE가 나가 같은 트랜잭션의 쓰기 묶음(JDBC batch)이 끊기는 것이고, 이것은 재지 않았다 - 낙관락 catch 타입을 DB 설정과 함께 정한다. MariaDB 11.8 기본값에서는
OptimisticLockingFailureException이 오지 않는다.innodb_snapshot_isolation을 끌지, 1020을 따로 번역할지는 새 안건이다. Boot 4.1.1에서는 그 예외가JpaSystemException으로 뭉개져 타입으로 가를 수도 없다 - 번역을 트랜잭션 경계 밖(Service 호출 바깥)으로 옮기는 안도 후보다. 이 실험에서 번역 안 된 예외가 도달한 곳이 거기다. 재지는 않았다
정리
- 수정 경로(
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회)
자신만의 철학을 만들어가는 중입니다.
댓글남기기