개발을 하다 보면 예외 상황을 처리해야 할 일이 정말 자주 생긴다.
메서드 하나에 여러 개의 예외 처리가 섞여 있으면 가독성은 떨어지고,
호출할 때마다 상태 코드를 설정하고
에러 메시지를 작성해야 하는 번거로움이 생긴다.
이런 문제들을 겪으며 예외 처리를
일관성 있게 관리할 수 있는 구조가 필요하다고 느꼈다.
![]()
이와 같이 예외 처리가 하드코딩되어 있고,
HttpStatus 코드와 에러 메시지를 매번 메서드 내부에서 직접 작성하다 보니,
코드의 가독성이 떨어지고,
예외 상황이 반복될수록 중복과 일관성 부족 문제가 생긴다.
Spring MVC는 예외 처리를 위한 여러 가지 보완적인 접근 방식을 제공합니다. 하지만 Spring MVC를 가르칠 때면, 학생들이 이 방법들에 대해 혼란스러워하거나 편하게 느끼지 못하는 경우가 많습니다. 오늘은 사용할 수 있는 다양한 예외 처리 옵션들을 소개하려고 합니다. 가능하다면 컨트롤러 메서드 안에서 예외를 직접 처리하지 않는 것이 목표입니다. 예외 처리는 여러 모듈에 공통적으로 반복되는 기능이므로, 전담된 별도의 코드에서 처리하는 것이 더 적절합니다. 스프링은 세 가지 예외 처리 방식을 제공합니다. <예외별 처리>, <컨트롤러별 처리>, <전역 처리>입니다.
정리하면,
예외 처리는 비즈니스 로직과는 별개의 공통 기능이기 때문에
컨트롤러 안에서 직접 처리하지 않고,
전담된 별도의 코드를 통해 일관되게 관리하는 것이 스프링의 방향이다.
이런식으로 다 작성이 되어있다.
이 세 가지 방식은 모두 비즈니스 로직과 분리된 전담 예외 처리 코드,
즉 스프링이 말하는 dedicated code(전용 코드)에 해당한다.
보통 이러한 예외 처리 구조들을 통틀어 예외 핸들러 또는 예외 처리 핸들러라고 부르며,
특히 전역 처리 클래스는 ExceptionHandler, GlobalExceptionHandler 같은 이름으로 자주 사용된다고 한다.
지금 과제 중인 내코드로 직접 한번 테스트해보겠다.
일단 커스텀 예외를 만들어서 사용해보겠다.
@Override
public PlanResponse updatePlan(PlanRequest request) {
if (request.getContents().isEmpty()) {
throw new EmptyContentException();
}
int row = planItRepository.update(request);
if (row == 0) {
throw new PlanNotFoundException();
}
return planRepositoryImpl.get(request.getScheduleId())
.map(plan -> new PlanResponse(
plan.getScheduleId(),
plan.getUserId(),
plan.getTitle(),
plan.getContents()))
.orElseThrow(() -> new PlanNotFoundException("존재하지 않는 일정입니다."));
}
이런식으로 EmptyContentEx, PlanNotFoundEx 를 "하나 하나" 지정해서 예외를 처리했다.
이게 예외 별로 처리하는 방식이다.
위 코드와 달리 하나로 묶어서 예외를 처리한다.
이게 컨트롤러 별 처리 방식이다.
기존 컨트롤러 내부에 작성되어 있던 @ExceptionHandler 메서드는 삭제하고,
전역 예외 처리 전용 클래스를 새로 만들어 @RestControllerAdvice로 선언한다.
이 클래스 내부에서 발생 가능한 예외들을 각각 처리해주면,
모든 컨트롤러에서 발생한 예외를 일관된 방식으로 전역 처리할 수 있다.
쉽게 정리하면,
@ExceptionHandler는 컨트롤러 단위, @ControllerAdvice는 전역 단위 예외 처리이며,
HandlerExceptionResolver는 상태 코드만 처리하고 응답 본문은 제어할 수 없다.
즉 상황에 따라서 적절하게 사용하는게 중요하다.
된다.
실제로 내가 위에 썼던
컨트롤러 별 처리 와 전역 처리를 동시에 썼는데,
컨트롤러 별 처리가 먼저 동작해서 예외를 잡았다.
즉 우선 순위가 있는 것이다.
컨트롤러 별 처리 [ 예외 발생2222: ~~~ ]
전역 예외처리 [ 메세지만 표출 ]
결과
찾아보니 우선순위는 다음과 같다.
예외 별 처리 > 컨트롤러 별 처리 > 전역 처리
즉, 예외 별 처리가 가장 빠르다.
예외를 메서드마다 직접 처리하기보다는,
컨트롤러 단위 또는 전역 예외 처리 방식으로 분리하면
가독성, 유지보수성, 일관성 측면에서 더 유리하다.
@ExceptionHandler와 @ControllerAdvice는 함께 사용 가능하며,
컨트롤러 내부의 핸들러가 우선 실행되고,
없을 경우에만 전역 핸들러가 동작한다.
예외가 여러 개일 경우,
예외별로 각각 핸들러 메서드를 나누면 세밀하게 대응할 수 있고,
하나의 메서드로 묶으면 간단하게 처리할 수 있다.
제목을 이렇게 지은이유?
"throw가 아니라 flow가 필요하다."
"이제는 무작정 예외를 던지지 말고, 흐름 있게 잘 설계해서 처리하자"는 의미이다.
결과 객체 vs 예외 처리 어떤걸 반환하는 것이 좋을까
다른 모듈에 요청을 보내고 결과를 기대할 때, 정상 경로가 아닐 경우(non-happy path)를 처리하는 방식은 보통 두 가지가 있는 것 같다. - 예외를 던진다. - 결과 객체(Result object)를 반환한다. (값과 오류를 함께 담는 형태) 나는 개인적으로 첫 번째 방식이 더 좋아 보인다. 코드가 더 깔끔하고 읽기 쉬워지기 때문이다. 정상적인 결과를 기대한다면, 그 기대에서 벗어나는 상황만 예외로 처리하면 된다. 하지만 결과가 어떨지 전혀 모르는 경우엔 어떨까? 예를 들어 복권 당첨 여부를 확인하는 모듈을 호출한다고 하자. 당첨이 정상 경로(happy path)일 수도 있지만, 대부분은 꽝일 것이다. (@Ben Cottrell의 댓글에 따르면 “당첨되지 않음”도 시스템 입장에선 정상 경로다. 유저에겐 아닐지 몰라도) 그렇다면 복권 검증 모듈에서 결과 객체를 반환하게 하고, 처리 자체에 실패했을 때만 예외를 던지는 게 더 낫지 않을까? 또 다른 예로, 사용자 로그인 기능을 생각해보자. 자격 증명이 틀렸을 때 예외를 던지는 게 맞을까? 아니면 LoginResult 객체 같은 걸 만들어서 성공/실패 상태를 명시적으로 전달하는 게 더 적절할까?
반환값과 에러는 구분해야 한다 반환값(return value)은 어떤 연산에서 나올 수 있는 정상적인 여러 결과 중 하나다. 반면, 에러(error)는 예상하지 못한 상황이며, 이를 호출자에게 반드시 알려야 한다. 모듈은 에러가 발생했음을 특별한 반환값으로 알릴 수도 있고, 애초에 그런 상황이 발생하지 않을 것으로 예상했다면 예외(exception)를 던지기도 한다. 에러가 드물고 예외적인 상황이어야 한다는 점에서 우리는 이것을 "예외"라고 부른다. 예를 들어, 복권 티켓을 검증하는 모듈이 있다고 하자. 이때 결과는 다음 중 하나일 수 있다: - 당신은 당첨되었습니다. - 당신은 당첨되지 않았습니다. - 오류가 발생했습니다 (예: 티켓이 유효하지 않음) 이 중 세 번째 경우는, 반환값이 "당첨"도 "미당첨"도 아니게 된다. 왜냐하면 티켓이 유효하지 않은 경우에는 정상적인 판단 자체가 불가능하기 때문이다. 추가 설명 누군가는 이렇게 말할 수도 있다. "티켓이 유효하지 않은 건 자주 있는 일인데, 그걸 에러로 처리해야 할까?" 그렇게 본다면, 결과는 아래처럼 정리될 수 있다. - 당신은 당첨되었습니다. - 당신은 당첨되지 않았습니다. - 티켓이 유효하지 않습니다. - 오류가 발생했습니다 (예: 복권 서버에 연결할 수 없음) 결국 어떤 상황을 "정상적인 처리 대상"으로 보고, 어떤 상황을 "예상치 못한 예외 상황"으로 볼지는 설계자가 어떤 범위까지 지원하려고 하느냐에 따라 달라진다. 예외 상황은 대부분 "호출자에게 알리고 종료"하는 방식으로 처리하며, 그 외의 추가 로직은 구현하지 않는 것이 일반적이다.