제육's 휘발성 코딩
article thumbnail
반응형

@RestControllerAdvice + @ExceptionHandler 란?

Spring에서 예외 처리를 할 때 Global 영역에서 관리해 주는 기능으로 @ControllerAdvice, @RestControllerAdvice를 사용합니다. 

먼저 각 어노테이션 별로 간단하게 짚고 넘어가겠습니다. 

 

 

@ControllerAdvice vs @RestControllerAdvice

ControllerAdvice 어노테이션 정보

ControllerAdvice 어노테이션을 살펴보면, 대상을 지정하지 않은 모든 컨트롤러에 @ExceptionHandler와 @InitBinder의 기능을 적용해 준다고 합니다. 

 

 

// 패키지 하위 모두 적용
@ControllerAdvice("org.bot.controllers") 
public class Advice1{} 

 // 특정 클래스 지정
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class Advice2{}
  • 예를 들면 다음과 같이 사용하면 지정한 패키지 또는 클래스에 해당하는 모든 클래스에 @ExceptionHandler와 @InitBinder를 제공해 줄 수 있습니다. 

 

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {
  • @RestControllerAdvice의 경우 @ControllerAdvice에 @ResponseBody의 기능을 더해준 어노테이션입니다.

 

 

@ExceptionHandler, @InitBinder

그렇다면 @ExceptionHandler와 @InitBinder는 뭘까?? 

먼저, HandlerExceptionResolver에 대해 알아야 합니다. 보통 클라이언트의 잘못된 요청이 오면 Http Status가 4XX로 반환됩니다.

하지만 간혹 500 서버 에러가 발생하는 경우가 있을 것입니다. 이 경우는 Dispatcher Servlet이 인터셉터를 통해 preHandle 작업 이후 handler adapter에 접근하고, 컨트롤러에 접근하면서 예외를 발견하였지만 예외 정보를 전달하지 않고 WAS까지 에러가 없다고 넘어간 경우입니다.

 

클라이언트의 요청이 잘못된 것인데 서버의 문제라고 반환하면 억울하겠죠? 

그래서 사용되는 것이 HandlerExceptionResolver으로 인터셉터의 postHandle을 실행하기 전에 예외가 있는 지를 판단합니다. 

예외가 처리 가능한 경우 예외를 처리해 주고 처리할 수 없는 예외라면 실패 후 서블릿 밖으로 반환해 주는 역할을 합니다. (null 반환)

 


HandlerExceptionResolver 가 인터셉터의 postHandle 전에 예외를 처리해 준다고 해도 postHande은 호출되지 않습니다.

 

 

@ExceptionHandler는 HandlerExceptionResolver를 제공해 주며 다른 ExceptionResolver 중에 우선순위를 가장 높게 만들어줍니다.

@InitBinder는 컨트롤러에서 WebDataBinder를 사용해 커맨드 객체를 바인딩 또는 검증 설정을 변경하는 용도로 사용됩니다. 

 

커스텀 예외처리 예제 

이제 글로벌 예외처리를 개발할 준비가 다 되었으니 간단한 예제로 테스트를 진행해 볼게요.

 

공통 에러코드 만들기 

@AllArgsConstructor
@Getter
public enum BotErrorCode {
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "User not found"),
    DUPLICATED_USER_NAME(HttpStatus.CONFLICT, "User name is duplicated"),
    INVALID_PERMISSION(HttpStatus.UNAUTHORIZED, "Permission is invalid"),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"),
    ;

    private HttpStatus httpStatus;
    private String message;
}
  • Enum을 활용해서 HttpStatus 상태코드와 메시지를 보여주도록 만들게요.

 

 

import lombok.Getter;

@Getter
public class BotApplicationException extends RuntimeException {

    private BotErrorCode botErrorCode;
    private String message;

    public BotApplicationException(BotErrorCode botErrorCode) {
        this.botErrorCode = botErrorCode;
        this.message = botErrorCode.getMessage();
    }
}
  • 앞에서 만든 에러코드를 RuntimeException을 상속받은 예외 클래스에 적용시켜두었습니다.

 

 

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class GlobalRestControllerAdvice {

    @ExceptionHandler(BotApplicationException.class)
    public ResponseEntity<?> application(BotApplicationException e) {
        log.error("Error occurs {}", e.toString());
        return ResponseEntity.status(e.getBotErrorCode().getHttpStatus()).body(e.getBotErrorCode().name());
    }

}
  • @RestControllerAdvice와 @ExceptionHandler 어노테이션을 사용해서 글로벌 영역에 컨트롤러 예외 처리를 지정해 두었어요.

 

 

@GetMapping("/user")
public String main(@RequestParam("id") String id)
{
    if (id.equals("invalid-user")) {
        throw new BotApplicationException(BotErrorCode.INVALID_PERMISSION);
    }
    if (id.equals("anonymous-user")) {
        throw new BotApplicationException(BotErrorCode.USER_NOT_FOUND);
    }
    if (id.equals("duplicate-user")) {
        throw new BotApplicationException(BotErrorCode.DUPLICATED_USER_NAME);
    }
    return "/bot";
}
  • 컨트롤러는 간단하게 @GetMapping 하나로만 처리해 두도록 할게요. 쿼리 스트링으로 넘어온 값들을 비교해서 커스텀해서 만든 예외별로 처리되도록 구현하였어요.

 

 

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest
public class GlobalExeptionTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private BotService botService;

    @Test
    void 사용자_정보가_올바른_경우_isOK_반환한다() throws Exception {

        mockMvc.perform(get("/user").queryParam("id", "user"))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    void 사용자_정보가_없는_경우_isNotFound_에러를_반환한다() throws Exception {

        mockMvc.perform(get("/user").queryParam("id", "anonymous-user"))
                .andDo(print())
                .andExpect(status().isNotFound());
    }

    @Test
    void 허가되지_않은_사용자는_isUnauthorized_에러를_반환한다() throws Exception {

        mockMvc.perform(get("/user").queryParam("id", "invalid-user"))
            .andDo(print())
            .andExpect(status().isUnauthorized());
    }

    @Test
    void 중복된_사용자는_isConflict_에러를_반환한다() throws Exception {

        mockMvc.perform(get("/user").queryParam("id", "duplicate-user"))
                .andDo(print())
                .andExpect(status().isConflict());
    }
}
  • 컨트롤러에서 만든 예외 상황별로 테스트 코드를 작성해 보면 다음과 같이 구성할 수 있어요. 

 

Exception Test

 

테스트가 정상 통과하는 것을 확인해 볼 수 있습니다. 

 

HandlerExceptionResolver에 대해 더 내용을 확인해보고 싶다면 스프링 API 예외 처리 포스팅을 추천 드립니다.

 

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^