아래 모든 내용은 현재 스프링 부트 최신 버전인 3.4.1을 기반으로 작성되었고, 버전에 따라 달라질 수 있습니다.
목차
- 공통 Exception을 처리해야 하는 이유
- Controller 예외 처리
- @Repository 어노테이션
- 결론
공통 Exception을 처리해야 하는 이유
우리는 기본적으로 Spring 프레임워크를 사용하고 있고, 상당히 많은 예외 케이스나 구현에 대해 도움을 받고 있다.
API에서 사용자가 연락처를 포맷에 맞지 않게 입력한다거나, 잘못된 ID로 조회하려고 하는 등의 경우 사용자가 알 수 있도록 응답을 보내줘야 한다.
Exception을 제대로 처리해주지 않으면 API를 사용하는 개발자뿐만 아니라 사용자까지 원인을 모르고 알 수 없는 오류로만 보이는 문제가 있기 때문에, Exception을 신경 써서 처리해줘야 할 필요가 있다.
Controller 예외 처리
API를 호출할 때 단순히 개발자가 잘못 호출하거나, 유효성 검사가 실패하는 경우는 공통적으로 예외 처리를 해줄 필요가 있다.
단순 나열해 보면 아래 정도가 있겠다.
- 잘못된 API 경로 호출
- Request 누락
- Request 유효성 검사 실패
유효성 검사의 경우 직접 코드를 통해 유효성 검사를 하는 경우는 Exception을 직접 지정해 줄 수 있지만,
Spring Bean Validation을 통해 어노테이션으로 유효성 검사를 하는 경우 자체적인 Exception이 발생한다.
이 경우를 포함하여 각각의 경우에서 어떤 Exception이 발생하는지 알아보자.
잘못된 API 경로 호출
우리가 만들지 않은 API 경로로 호출하면 기본적으로 어떤 예외가 발생할까?
아무 EndPoint에다가 API 요청을 보내 보자.
로그 레벨을 debug로 두고 보면 NoResourceFoundException이 발생한 것을 확인할 수 있다.
이를 테스트 코드로 한 번 더 확인해 보자.
위와 같이 코드를 작성하고 테스트를 돌려 보면 성공하는 것을 알 수 있다.
RequestParam
다음으로 쿼리 파라미터에 대한 누락 검증과 유효성 검사를 수행해 보자.
테스트를 위해 아래와 같은 API를 작성해 보자.
누락 검증
@RequestParam의 required 기본값은 true이므로 존재하지 않으면 예외가 발생한다.
또한 @NotBlank 어노테이션을 통해 param에 빈 값이나 공백을 입력하면 예외가 발생하도록 Validation을 적용해 두었다.
먼저 파라미터 없이 API를 호출하면 어떤 예외가 발생하는지 알아보자.
MissingServletRequestParameterException이 발생하는 것을 확인했다.
이를 테스트 코드로 한 번 더 확인해 보자.
이때 테스트 코드에 있는 MissingRequestValueException은 무엇일까?
MissingRequestValueException은 MissingServletRequestParameterException의 상위 클래스로,
아래와 파란색 박스에 담긴 Exception들의 상위 클래스이다.
이를 통해 RequestParam 말고도 PathVariable, Cookie, Header 등의 누락을 한 번에 예외 처리하고 싶을 때 MissingRequestValueException을 다루면 되는 것을 확인할 수 있다.
유효성 검사
@Validated를 클래스 레벨에 달고, @RequestParam의 @NotBlank 어노테이션을 통해 Bean Validation을 정의해 두었다.
이제 파라미터에 공백 값을 담아 API를 호출하면 어떤 Exception이 발생하는지 확인해 보자.
ConstraintViolationException이 발생하는 것을 확인했다.
이를 테스트 코드로 한 번 더 확인해 보자.
PathVariable
다음으로 PathVariable에 대한 유효성 검사를 수행하기 위해 같은 클래스에 아래와 같은 API를 작성해 보자.
누락 검증
PathVariable은 누락하는 경우 경로 자체가 다르게 되어 NoResourceFoundException이 호출된다.
또한 발생하더라도 위에서 MissingRequestValueException을 상속받은 MissingPathVariableException이 존재하는 것도 확인했다.
유효성 검사
@Validated를 클래스 레벨에 달고, @PathVariable의 @Min 어노테이션을 통해 Bean Validation을 정의해 두었다.
이제 Path에 0을 담아 API를 호출하면 어떤 Exception이 발생하는지 확인해 보자.
@RequestParam과 동일하게 ConstraintViolationException이 발생하는 것을 확인했다.
이를 테스트 코드로 한 번 더 확인해 보자.
RequestPart
다음은 파일 첨부 시 주로 사용하는 RequestPart에 대한 누락 검증과 유효성 검사를 수행해 보자.
누락 검증
누락 테스트를 위해 아래와 같이 API를 작성했다.
이제 파일 없이 API를 호출해 보자.
MissingServletRequestPartException이 발생하는 것을 확인했다.
이를 테스트 코드로 한 번 더 확인해 보자.
해당 Exception은 MissingRequestValueException을 상속받은 Exception이 아니다.
유효성 검사
유효성 검사를 위해 아래와 같이 API를 작성했다.
@RequestPart(required = false)로 두고 @NotNull을 붙이는 건 불필요한 행동이지만,
Bean Validation을 편하게 테스트하기 위해 임의로 작성했다.
이제 동일하게 파일 없이 해당 API를 호출해 보자.
ConstraintViolationException이 발생하는 것을 확인했다.
이를 테스트 코드로 한 번 더 확인해 보자.
RequestBody
다음으로 RequestBody에 대한 유효성 검사를 수행하기 위해 같은 클래스에 아래와 같은 API를 작성해 보자.
Body에 대한 테스트를 위해 ExampleRequest 클래스가 함께 추가되어 있다.
누락 검증
이제 Body 없이 API를 호출하면 어떤 예외가 발생하는지 알아보자.
HttpMessageNotReadableException이 발생하는 것을 확인했다.
이를 테스트 코드로 한 번 더 확인해 보자.
참고로 유효한 Json 양식이 아니거나, 필드의 타입이 맞지 않아서 파싱이 실패한 경우 등은 모두 해당 HttpMessageNotReadableException이 발생한다.
유효성 검사
RequestBody 앞에 @Validated 어노테이션을 달고, value 필드에 @Min 어노테이션을 통해 Bean Validation을 정의해 두었다.
이제 Body의 value 필드에 0을 담아 API를 호출하면 어떤 Exception이 발생하는지 확인해 보자.
기존 Bean Validation과는 다르게 MethodArgumentNotValid이 발생하는 것을 확인했다.
이를 테스트 코드로 한 번 더 확인해 보자.
중간 정리
- 잘못된 경로 요청 대응을 위한 NoResourceFoundException
- 기본적인 요청 값 누락 대응을 위한 MissingRequestValueException
- RequestBody의 양식 오류 대응을 위한 HttpMethodNotReadableException
- Bean Validation 사용 시 ConstraintViolationException, MethodArgumentNotValidException
- RequestPart 사용 시 MissingServletRequestPartException
필드 누락이나 유효성 검사에 대한 Exception들은 모두 Exception 내부에서 어떤 필드에서 어떤 예외가 발생했는지 확인할 수 있다.
이를 통해 어떤 경우로 호출이 실패했는지 처리할 수 있다.
@Repository
다음은 우리가 영속성 계층을 정의할 때 흔히 사용하는 @Repository 어노테이션에 대한 이야기다.
먼저 아래와 같은 코드 2개를 비교해 보자.
해당 구조는 우리가 존재하는 Entity임을 보장할 때 흔히 사용하는 코딩 패턴이다.
Entity가 존재하지 않으면 IllegalStateException이 발생하는 것이 전부이다.
각각의 경우 Exception이 의도대로 잘 발생하는지 확인해 보자.
@Component의 경우 의도대로 Exception이 잘 발생했다.
다음은 @Repository이다.
어노테이션 제외하고 아무것도 다른 게 없는데, 놀랍게도 테스트 코드가 실패한다.
테스트 코드가 왜 실패했는지 StackTrace를 확인해 보자.
놀랍게도 선언하지 않은 InvalidDataAccessApiUsageException으로 예외가 변환되었다.
어떻게 이런 일이 발생하는 걸까?
예외를 다시 한 번 자세히 보자.
convertJpaAccessException, translateExceptionIfPossible 등등..
Exception을 어디선가 변환하고 있는 것 같아 보인다.
자세히 들어가 보자.
보아하니 대략 Hibernate Exception일 때와 아닐 때 정도의 if문 구분은 있지만,
결국 전부 Exception을 변환하는 코드임을 알 수 있다.
해당 메서드를 정의한 상위 인터페이스를 확인해 보자.
적혀있는 설명을 번역하면 아래와 같다.
persistence 프레임워크에 의해 생성된 주어진 런타임 예외를 가능하다면 Spring의 일반적인 DataAccessException 계층에서 해당 예외로 변환합니다.
요약하면 영속성 계층에서 예외가 발생하면 Spring Data의 예외로 변환된다는 이야기이다.
해당 메서드를 바탕으로 Exception의 변환이 이루어지고 있음을 추측할 수 있다.
위에서 테스트 코드의 예외를 통해 확인했듯 해당 메서드의 구현체는 HibernateJpaDialect이다.
아래는 해당 메서드의 구현체를 IntelliJ에서 확인한 결과이다.
현재 JPA의 구현 벤더가 Hibernate이기 때문에 기본적으로 해당 인터페이스의 구현은 HibernateJpaDialect에서 이루어지고 있다.
만약 추후 JPA의 벤더사가 달라진다면 구현체도 맞춰서 달라질 것이다.
JPA 의존성을 추가하면 자동으로 포함되는 spring-orm 라이브러리의 패키지를 보면,
위 2개의 구현체가 존재하는 것을 확인할 수 있다.
예외가 어디서 변환되는지는 파악했는데, @Repository가 존재하면 어떻게 자동으로 변환해 주는 걸까?
아래 클래스를 확인해 보자.
클래스의 이름에서 알 수 있듯 예외 변환을 설정해 주는 클래스이고,
해당 클래스 내에서 @Repository 어노테이션을 가진 클래스들에 대해 자동 변환을 수행한다.
아래는 해당 클래스의 주석을 번역한 내용이다.
Spring의 @Repository 어노테이션이 있는 모든 Bean에 Persistence Exception 변환을 자동으로 적용하고,
프록시에 해당하는 PersistenceExceptionTranslationAdvisor를 추가하는 Bean PostProcessor.
네이티브 리소스 예외를 Spring의 DataAccessException 계층으로 변환합니다.
PersistenceExceptionTranslator 인터페이스를 구현하는 빈을 자동으로 감지하고,
이후 후보 예외를 변환하라는 요청을 받습니다.
Spring의 모든 해당 리소스 팩토리는 PersistenceExceptionTranslator 인터페이스를 기본적으로 구현합니다.
따라서 자동 예외 번역을 활성화하는 데 필요한 것은 일반적으로 영향을 받는 모든 Bean을 @Repository 어노테이션으로 표시하고,
이 PostProcessor를 애플리케이션 컨텍스트에서 Bean으로 정의하는 것뿐입니다.
자 여기까지 @Repository의 Persistence Exception들이 자동 변환된다는 것도 알았다.
그런데 정작 중요한 우리가 테스트했던 IllegalStateException은 어디서 변환되는 걸까?
테스트 코드에서 가장 마지막에 있던 EntityManagerFactoryUtils를 보자.
드디어 우리의 Exception이 직접 변환되는 부분까지 찾았다.
해당 Util에서는 어떤 Java 예외들이 어떤 Spring 예외로 변환되는지도 대략적으로 확인할 수 있다.
그렇다면 대체 왜 변환하는 걸까?
- Spring의 DataAccessException으로 변환하여 일관된 트랜잭션과 예외 처리
- 각 벤더사의 예외에서 Spring의 예외로 추상화
위의 이유들로 영속성 계층에서는 Exception을 자체적으로 변환한다.
따라서 @Repository에서 표준 예외를 사용하는 것은 가급적 지양하는 것이 예상치 못한 동작을 예방할 수 있다.
우리가 사용하는 JpaRepository는 자체적으로 상속받은 모든 곳에서 @Repository가 붙은 것처럼 동작한다.
이러한 설정이 어디에서 되어있는지 등에 대한 내용은 주제에서 벗어난 것 같으니 생략하겠다.
그래서 실용적으로는 어떻게 하면 좋을까?
위 사진의 낙관적 락에서는 JpaOptimisticLockingFailureException이 보이지만,
실제로는 Hibernate에서 변환하여 ObjectOptimisticLockingFailureException이 발생한다.
해당 두 Exception의 상위 Exception인 OptimisticLockingFailureException을 다루면 낙관적 락에 대해서 편하게 다룰 수 있다.
따라서 실용적으로 사용할 수 있는 내용을 정리해 본다면,
- 영속성 계층에서 예외 처리 시 CustomException (or 영속성 계층에서 예외 처리 X)
- 낙관적 락을 사용하는 경우 OptimisticLockingFailureException
- Unique Key 제약 조건을 활용하는 경우 DataIntegrityViolationException
최종 결론
결과적으로 공통으로 처리할 만한 Exception들은 아래와 같다.
- Controller
- 잘못된 경로 요청 대응을 위한 NoResourceFoundException
- 기본적인 요청 값 누락 대응을 위한 MissingRequestValueException
- RequestBody의 양식 오류 대응을 위한 HttpMethodNotReadableException
- Bean Validation 사용 시 ConstraintViolationException, MethodArgumentNotValidException
- RequestPart 사용 시 MissingServletRequestPartException
- Repository
- 영속성 계층에서 예외 처리 시 CustomException (or 영속성 계층에서 예외 처리 X)
- 낙관적 락을 사용하는 경우 OptimisticLockingFailureException
- Unique Key 제약 조건을 활용하는 경우 DataIntegrityViolationException
마무리
모든 코드는 GitHub에서 확인하실 수 있습니다 :)
혹시라도 항해플러스 참여 생각 있으신 분들을 위해,
항해플러스 신청 시 아래 추천 코드를 입력하시면 할인 받으실 수 있습니다 :)
- B80DOG
'Java & Spring' 카테고리의 다른 글
[JAVA] assert 잘 사용하기 (with Spring Assert) (0) | 2024.12.11 |
---|---|
[Spring] Spring Boot 1.x에서 JUnit5 사용하기 (1) | 2024.10.11 |
[사내 세미나] Spring Batch 도입하기 (3) | 2024.09.04 |
[Spring] 레거시 프로젝트에 Testcontainers 도입하기 (2) | 2024.07.07 |
Spring Boot 무료로 배포하기 (Koyeb, GitHub) (0) | 2024.03.21 |