DataIntegrityViolationException 예외 유실 문제

seongwop·2026년 5월 26일

Spring

목록 보기
20/20

Unique 제약조건 위반 예외인 DataIntegrityViolationException가 발생하는 문제가 있었는데 GlobalExceptionHandler에서 해당 예외를 처리하고 있음에도 불구하고 예외가 잡히지 않는 문제가 발생했었다.

그래서 처음에는 GlobalExceptionHandler가 처리하지 못하는 범위에서 예외가 발생했나 싶어서 GlobalExceptionHandler의 처리 범위를 찾아보았다.

GlobalExceptionHandler의 처리 범위

GlobalExceptionHandler는 스프링 MVC의 DispatcherServlet 내부에서 동작하는 HandlerExceptionResolver의 구현체(구체적으로는 ExceptionHandlerExceptionResolver)에 의해 구동된다. 즉, 웹 요청이 DispatcherServlet에 진입한 이후부터 응답이 나가기 전까지의 범위 내에서 발생하는 예외만 처리할 수 있다.

구분처리 가능처리 불가
웹 레이어Controller 내부 에러 / Interceptor (preHandle, postHandle) / Argument Resolver · Message Converter 바인딩 에러Filter 계층 에러 (Spring Security 인증·인가 등) / 서블릿 컨테이너 자체 에러 (404 등)
비즈니스 레이어Service · Repository에서 throw되어 Controller까지 전파된 런타임 예외컨트롤러 응답 반환 후 WAS 레이어로 전파된 이후 단계의 에러

DataIntegrityViolationException 예외 유실 문제

결과는 GlobalExceptionHandler의 처리 범위때문에 잡히지 않았던 것이 아니였다.

@Transactional이 서비스 계층 메서드에 적용되어 있다면, 트랜잭션 커밋과 영속성 컨텍스트의 flush는 서비스 메서드가 종료되고 제어권이 컨트롤러로 돌아가기 직전, 즉 프록시 레이어(서비스 호출부)에서 일어난다. 따라서 발생 시점 자체는 컨트롤러 메서드 호출 흐름 내부이므로 물리적인 범위로만 보면 GlobalExceptionHandler가 인지할 수 있는 영역이다.

그럼에도 불구하고 @ExceptionHandler(DataIntegrityViolationException.class)가 이를 잡지 못하는 진짜 원인은 범위의 문제가 아니라 예외가 발생하는 시점과 그에 따른 스프링의 예외 변환(Exception Translation) 매커니즘 때문이었다.

  • 트랜잭션 커밋에 의존하는 경우 (기본 동작)
  1. 서비스 메서드가 정상 종료되면 스프링의 트랜잭션 인터셉터가 tx.commit()을 시도한다.
  2. 이때 영속성 컨텍스트가 자동으로 flush를 수행하며 DB에 쿼리를 보내고, Unique 제약조건 위반 에러를 반환받는다.
  3. [핵심 원인] 예외가 터진 시점은 개발자가 작성한 서비스 메서드 내부가 아니라, 스프링 프록시의 '트랜잭션 커밋 서브시스템' 내부다.
  4. 스프링의 트랜잭션 매니저(JpaTransactionManager)는 커밋 프로세스 자체에서 예외가 발생하면, 내부 원인이 무엇이든 간에 시스템 전체의 문제로 인지하여 TransactionSystemException으로 통째로 감싸서(Wrapping) 상위로 던진다.
  5. 결과적으로 핸들러에 도달했을 때는 이미 DataIntegrityViolationException이 아닌 TransactionSystemException으로 껍데기가 바뀐 상태이기 때문에 매칭에 실패한다.
  • 명시적으로 flush()를 호출하는 경우
  1. em.flush()나 repository.saveAndFlush()를 호출하면 트랜잭션이 끝나기 전, 즉 서비스 메서드가 실행되고 있는 도중에 DB로 쿼리를 보낸다.
  2. DB가 Unique 제약조건 위반 에러를 반환하면, 이 예외는 여전히 Repository와 Service 메서드 실행 스택 안에서 머물고 있다.
  3. 이때 스프링의 예외 변환기(PersistenceExceptionTranslationPostProcessor)가 해당 네이티브 예외를 가로채 자사 데이터 액세스 예외인 DataIntegrityViolationException으로 변환한다.
  4. 변환된 예외가 프록시에 감싸이지 않고 그대로 상위로 던져지므로 원래 의도했던 타입으로 매칭하여 캐치할 수 있다.

결론적으로 DataIntegrityViolationException 예외 유실 문제는 GlobalExceptionHandler의 처리 범위때문이 아닌 예외 발생 시점에 의한 문제였다.

트랜잭션 커밋은 서비스 메서드가 종료된 후의 AOP 프록시의 커밋 시점이기 때문에 TransactionSystemException으로 발생한 것이었다.

다만 이 문제에 대한 해결법으로서 flush()를 명시적으로 호출하고 DataIntegrityViolationException로 catch 할 지, GlobalExceptionHandler에서 TransactionSystemException를 catch 할 지는 선택의 문제인 것 같다.

0개의 댓글