개발놀이터

Spring에서 예외를 관리하는 방법 (@ControllerAdvice, @ExceptionHandler) 본문

Spring/Spring

Spring에서 예외를 관리하는 방법 (@ControllerAdvice, @ExceptionHandler)

마늘냄새폴폴 2022. 8. 4. 06:13

https://coding-review.tistory.com/148

 

CheckedException, UncheckedException

자바의 예외는 크게 3가지로 나눌 수 있다. 체크 예외 (CheckedException) 언체크 예외 (UncheckedException) 에러 (Error) 자바에서 에러, 예외와 관련된 클래스들의 계층구조는 위와 같다. Throwable 클래스를..

coding-review.tistory.com

CheckedException, UncheckedException에 대해 공부하다가 김영한님의 강의 "데이터베이스 접근기술 1편"을 다시 보면서 "체크 예외를 쓰기 보다는 런타임 예외를 쓰고 런타임 예외에 대한 로그를 남기고 개발자가 해당 오류를 빠르게 인지하는 것이 필요하다. 서블릿 필터, 스프링의 인터셉터, 스프링의 ControllerAdvice를 사용하면 이런 부분을 깔끔하게 공통으로 해결할 수 있다." 라는 문장을 발견했다.

 

서블릿의 필터, 스프링의 인터셉터 모두 사용해봤지만 스프링의 ControllerAdvice는 한번도 써본 적이 없다. 이 기회에 한번 써보고 포스팅을 해보려고 한다.

 

예외 처리 과정

프로그래밍에서 예외 처리는 아주 중요하면서도 아주 어렵다. 과하다할만큼 상세하고 다양하게 예외를 잡아 처리해준다면, 클라이언트도 그렇고 서버도 그렇고 더 안정적인 프로그램이 될 수 있게 도와준다.

 

예외를 처리하는 경우와 방법은 다양하다.

  • 메서드 내에서 예외 상황을 예측해서 처리하는 try-catch문을 이용하는 방법
  • 요구사항에 의한 예외 처리 (ex. validation > 특정 값이 0~255 범위가 아니라면 유효하지 않은 값으로 판단하고 예외처리)
  • 스프링 시큐리티에서 인터셉터로 잡아서 UnauthorizedException 같은 예외 처리

기타 여러 예외 처리들을 적용하다 보면 코드가 엄청나게 복잡해진다. if문으로 잡던 try-catch문으로 잡던 상위 메서드로 예외처리를 위임하던 코드는 복잡해진다.

 

그렇게 되면 유지보수 하기가 아주 어려워진다. 비즈니스 로직에 집중하기 어렵고, 비즈니스 로직과 관련된 코드보다 예외를 처리하기 위한 코드가 더 많아지는 경우도 생긴다. 

 

이러한 문제를 조금이라도 개선하기 위해 @ControllerAdvice, @ExceptionHandler를 사용한다고 보면 이해하기가 쉬워진다.

 

 

@ExceptionHandler

@ExceptionHandler 같은 경우는 @Controller, @RestController가 적용된 Bean 내에서 발생하는 예외를 잡아서 하나의 메서드에서 처리해주는 기능을 한다.

 

@RestController
public class MyRestController {
    ...
    ...
    @ExceptionHandler(NullPointerException.class)
    public Object nullex(Exception e) {
        System.err.println(e.getClass());
        return "myService";
    }
}

위와 같이 적용하기만 하면 된다. @ExceptionHandler라는 어노테이션을 쓰고 인자로 캐치하고 싶은 예외 클래스를 등록해주면 끝난다.

 

cf) @ExceptionHandler({ Exception1.class, Exception2.class }) 이런식으로 두 개 이상 등록도 가능하다.

 

위의 예제에서 처럼 하면 MyRestController에 해당하는 Bean 내에서 NullPointerException이 발생한다면 @ExceptionHandler가 적용된 메서드가 호출될 것이다.

 

주의사항 / 알아둘 것

  • Controller, RestController에만 적용 가능하다. (@Service같은 빈에서는 안됨)
  • 리턴 타입은 자유롭게 해도 된다. (Controller 내부에 있는 메서드들은 여러 타입의 response를 할 것이다. 해당 타입과 전혀 다른 리턴 타입이어도 상관없다.)
  • @ExceptionHandler를 등록한 Controller엣만 적용된다. 다른 Controller에서 NullPointerException이 발생하더라도 예외를 처리할 수 없다. 
  • 메서드의 파라미터로 Exception을 받아왔는데 이것 또한 자유롭게 받아와도 된다.

 

@ControllerAdvice

@ExceptionHandler가 하나의 클래스에 대한 것이라면, @ControllerAdvice는 모든 @Controller 즉, 전역에서 발생할 수 있는 예외를 처리해주는 어노테이션이다.

@ControllerAdvice
public class MyAdvice {
    @ExceptionHandler(CustomException.class)
    public String custom() {
        return "hello custom";
    }
}

위와 같이 새로운 클래스 파일을 만들어서 어노테이션을 붙이기만 하면 된다. 그 다음에 ExceptionHandler로 처리하고 싶은 예외를 잡아 처리하면 된다. 별도의 속성값 없이 사용하면 모든 패키지 전역에 있는 컨트롤러를 담당하게 된다.

 

@RestControllerAdvice와 @ControllerAdvice가 존재하는데 @RestControllerAdvice 어노테이션을 들여다보면 아래와 같이 되어있다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {
	//...
}

 

@ControllerAdvice와 동일한 역할 즉, 예외를 잡아 핸들링 할 수 있도록 하는 기능을 수행하면서 @ResponseBody를 통해 객체를 리턴할 수도 있다는 얘기이다.

 

ViewResolver를 통해서 예외 처리 페이지로 리다이렉트 시키려면 @ControllerAdvice만 써도 되고, API서버여서 에러 응답으로 객체를 리턴해야 한다면 @ResponseBody 어노테이션이 추가된 @RestControllerAdvice를 적용하면 되는 것이다.

 

@RestController에서 예외가 발생하던 @Controller에서 예외가 발생하던 @ControllerAdvice + @ExceptionHandler 조합으로 다 캐치할 수 있고, @ResponseBody의 필요 여부에 따라 적용하면 된다는 것이다. 

 

 

@RestControllerAdvice
@Slf4j
public class MemberControllerAdvice {

    @ExceptionHandler(NotFoundMember.class)
    public ResponseEntity notMemberFoundExceptionHandler(NotFoundMember exception) {
        Map body = Map.of("status", HttpStatus.BAD_REQUEST.value(),
                "error", HttpStatus.BAD_REQUEST.getReasonPhrase(),
                "message", "회원을 찾을 수 없습니다.");

        log.info("Exception Handler 작동중");

        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
    }
}

 

@ControllerAdvice
@Slf4j
public class MemberControllerAdvice {

    @ExceptionHandler(NotFoundMember.class)
    public String notMemberFoundExceptionHandler(NotFoundMember exception, Model model) {

        model.addAttribute("errorMessage", exception.getMessage());

        log.info("Exception Handler 작동중");

        return "error";
    }
}

위의 두개의 예제는 각각 @ControllerAdvice, @RestControllerAdvice를 사용해 만든 예제이다. 위의 예제의 상황은 회원가입 후 로그인을 할 때 회원 객체를 찾을 수 없는 경우에 발생한 예외를 잡아서 처리한 과정이다. 

 

@RestControllerAdvice는 API이기 때문에 ResponseEntity에 Map을 담아서 리턴한 것이고 @ControllerAdvice는 ViewResolver를 이용해 에러 페이지로 리다이렉트 한 것이다.

 

 

실무에선 어떻게?

일반적인 실무에서 어떻게 사용하는지는 사실 명확하지 않다. 무슨 얘기냐면 에러메시지로 나가는 포맷이 일정해야 한다는 이야기이다. 만약 로그인 모듈에서 발생한 예외에 응답하는 메세지는 에러코드랑 설명을 리턴해준다고 하고, 배송 모듈에서 발생한 예외는 에러코드랑 배송 번호를 리턴해준다고 하자.

 

그러면 @ControllerAdvice를 이용해서 통합으로 처리하려고 했지만 리턴 타입이 다르니까 통합해서 처리할 수 없다. 에러 인터페이스, 포맷이 다 같고 클라이언트 측에서도 이해하기 좋은 에러가 날라오는 것이다. 

 

그래서 @ExceptionHandler와 함께 @ResponseStatus(value = HttpStauts.UNAUTHORIZED) 이런것도 같이 집어 넣어서 HTTP 상태코드를 리턴하기도 한다.

 

다시 한번 정리하지만 에러 메시지가 잘 정의되어 있어야 한다는게 전제 조건이다.

 

→ 에러 관리하기

public enum LoginErrorCode {
        OperationNotAuthorized(6000,"Operation not authorized"),
        DuplicateIdFound(6001,"Duplicate Id"),
        //...
        UnrecognizedRole(6010,"Unrecognized Role");
        private int code;
        private String description;
        private LoginErrorCode(int code, String description) {
            this.code = code;
            this.description = description;
        }
        public int getCode() {
            return code;
        }
        public String getDescription() {
            return description;
        }
    }

 

보통 에러를 위와 같이 한 곳에 정리를 할 것이다. 저렇게 미리 정의해 놓고 실제 사용할 때는 이런식으로 불러와서 에러 객체를 만들어서 리턴할 것이다.

LoginErrorCode.OperationNotAuthorized.getCode();

그러면 1차적으로 에러 객체 관리는 위와 같은 방법으로 끝난다.

 

그 다음에 위에서 말한 @ControllerAdvice, @ExceptionHandler를 이용해서 에러를 처리하면 된다.

'Spring > Spring' 카테고리의 다른 글

스프링에서 동시성 문제 해결하기  (0) 2023.05.21
@Async 어노테이션  (1) 2023.05.16
트랜잭션과 @Transactional  (0) 2022.06.21
커넥션 풀 (Connection pool)  (0) 2022.06.21
@Target, @Retention  (0) 2022.01.24