[스프링부트] @ExceptionHandler를 통한 예외처리

김기연·2022년 3월 4일
20

스프링부트

목록 보기
2/3

@ExecptionHandler

@ExceptionHandler 사용법

@ExceptionHandlerController계층에서 발생하는 에러를 잡아서 메서드로 처리해주는 기능이다.
Service, Repository에서 발생하는 에러는 제외한다.

간단한 예시부터 살펴보자.

@Controller
public class SimpleController {

    // ...

    @ExceptionHandler
    public ResponseEntity<String> handle(IOException ex) {
        // ...
    }
}

이렇게 @Controller로 선언된 클래스 안에서 @ExceptionHandler 어노테이션으로 메서드 안에서 발생할 수 있는 에러를 처리할 수 있다.

여러개의 Exception 처리

@ExceptionHandlervalue 값으로 어떤 Exception을 처리할 것인지 넘겨줄 수 있는데,
value를 설정하지 않으면 모든 Exception을 잡게 되기 때문에 Exception을 구체적으로 적어주는 것이 좋다고 한다.

여러 개의 Exception이 발생할 수 있는 코드가 있다고 하자.

@Controller
public class SimpleController {

    // ...

    @ExceptionHandler({FileSystemException.class, RemoteException.class})
    public ResponseEntity<String> handle(Exception ex) {
        // ...
    }
}

메서드의 인자로 Exception ex를 받고 있고 @ExceptionHandlervalue값으로 특정 Exception들을 설정해주고 있다.
여러개의 Exception을 잡아야한다면, @ExceptionHandler({IOException.class})처럼 포괄적인게 아닌
@ExceptionHandler({FileSystemException.class, RemoteException.class})로 구체적으로 명시해주는 것을 권장한다고 한다.

We generally recommend that you be as specific as possible in the argument signature, reducing the potential for mismatches between root and cause exception types. Consider breaking a multi-matching method into individual @ExceptionHandler methods, each matching a single specific exception type through its signature.
출처 - 스프링 공식 문서




@ControllerAdvice

@ControllerAdvice에서 @ExceptionHandler 사용

@ControllerAdvice@Controllerhandler에서 발생하는 에러들을 모두 잡아준다.
@ControllerAdvice안에서 @ExceptionHandler를 사용하여 에러를 잡을 수 있다.

@ControllerAdvice
public class ExceptionHandlers {

    @ExceptionHandler(FileNotFoundException.class)
    public ResponseEntity handleFileException() {
        return new ResponseEntity(HttpStatus.BAD_REQUEST);
    }


}

범위 설정

@ControllerAdvice는 모든 에러를 잡아주기 때문에 일부 에러만 처리하고 싶을 경우에는 따로 설정을 해주면 된다.

  1. 어노테이션
  2. basePackages (+basepackagesClasses)
  3. assignableTypes
// 1.
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

// 2.
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// 3.
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}
  • basePackages: 탐색 패키지 지정, org.example.controllers 패키지, 하위 패키지까지 모두 탐색
  • basePackagesClasses: 탐색 클래스 지정, 클래스의 맨 위에 있는 package부터 시작

주의 사항
어노테이션, 베이스패키지 등 설정자들은 runtime시 수행되기 때문에 너무 많은 설정자들을 사용하면 성능이 떨어질 수 있다!

Use selectors such as annotations, basePackageClasses, and basePackages (or its alias value) to define a more narrow subset of targeted controllers.

If multiple selectors are declared, boolean OR logic is applied, meaning selected controllers should match at least one selector. Note that selector checks are performed at runtime, so adding many selectors may negatively impact performance and add complexity.
출처 - 스프링 공식 문서



@RestControllerAdvice

@RestControllerAdvice@ControllerAdvice@ResponseBody을 가지고 있다.
@Controller처럼 작동하며 @ResponseBody를 통해 객체를 리턴할 수 있다.

@RestControllerAdvice 인터페이스

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

@ControllerAdvice vs @RestControllerAdvice

@ControllerAdvice@Componenet 어노테이션을 가지고 있어 컴포넌트 스캔을 통해 스프링 빈으로 등록된다.
@RestControllerAdvice@Controlleradvice@ResponseBody 어노테이션으로 이루어져있고 HTML 뷰 보다는 Response body로 값을 리턴할 수 있다.

@ControllerAdvice is meta-annotated with @Component and therefore can be registered as a Spring bean through component scanning. @RestControllerAdvice is meta-annotated with @ControllerAdvice and @ResponseBody, and that means @ExceptionHandler methods will have their return value rendered via response body message conversion, rather than via HTML views.
출처 - 스프링 공식 문서



프로젝트 리팩토링

이제 진행하고 있는 개인 프로젝트에 적용해야한다!
프로젝트 주제는 "현재 하고 있는 전시회를 보여주고, 보고 싶은 전시회를 저장할 수 있는 어플"이다.

기존 코드

src/main/java/hyangyu/server/api/FavoriteApi.java

@PostMapping("/display/{displayId}")
    public ResponseEntity saveFavoriteDisplay(@PathVariable Long displayId) throws Exception {
        HttpHeaders httpHeaders = new HttpHeaders();

        //전시 검색
        Optional<Display> display = displayService.findOne(displayId);
        if(display.isEmpty()) {
            return new ResponseEntity(new ErrorDto(404, "잘못된 전시 id입니다."), HttpStatus.BAD_REQUEST);
        }

        //저장한 전시회 검색
        Optional<FavoriteDisplay> favoriteDisplay = favoriteDisplayService.saveFavoriteDisplay(user.getUserId(), displayId);
        if(favoriteDisplay.isPresent()) {
            return new ResponseEntity(new ErrorDto(404, "이미 저장한 전시회입니다."), HttpStatus.BAD_REQUEST);
        }

        ResponseDto responseDto = new ResponseDto(200, "내가 저장한 전시회에 추가되었습니다.");
        return new ResponseEntity<>(responseDto, httpHeaders, HttpStatus.OK);
    }

원래는 이렇게 컨트롤러 계층에서 Error Response를 하나하나 리턴해줬다🥲
리팩토링 해봅쉬다🔨

리팩토링 과정

1. ErrorCode 작성

src/main/java/hyangyu/server/constants/ErrorCode.java

package hyangyu.server.constants;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public enum ErrorCode {

    //400 BAD_REQUEST 잘못된 요청
    INVALID_PARAMETER(400, "파라미터 값을 확인해주세요."),

    //404 NOT_FOUND 잘못된 리소스 접근
    DISPLAY_NOT_FOUND(404, "존재하지 않는 전시회 ID 입니다."),
    FAIR_NOT_FOUND(404, "존재하지 않는 박람회 ID 박람회입니다."),
    FESTIVAL_NOT_FOUND(404, "존재하지 않는 페스티벌 ID 페스티벌입니다."),
    SAVED_DISPLAY_NOT_FOUND(404, "저장하지 않은 전시회입니다."),
    SAVED_FAIR_NOT_FOUND(404, "저장하지 않은 박람회입니다."),
    SAVED_FESTIVAL_NOT_FOUND(404, "저장하지 않은 페스티벌입니다."),

    //409 CONFLICT 중복된 리소스
    ALREADY_SAVED_DISPLAY(409, "이미 저장한 전시회입니다."),
    ALREADY_SAVED_FAIR(409, "이미 저장한 박람회입니다."),
    ALREADY_SAVED_FESTIVAL(409, "이미 저장한 페스티벌입니다."),

    //500 INTERNAL SERVER ERROR
    INTERNAL_SERVER_ERROR(500, "서버 에러입니다. 서버 팀에 연락주세요!");
    
    private final int status;
    private final String message;
}

Enum 클래스로 사용할 에러들을 적어준다.
status 값과 error message 만 프론트에 넘겨줄 예정으로 두 개만 작성하였다.
status 타입으로 HTTPStatus를 사용해보고 싶었는데
프로젝트 내에서int를 사용하고 있었기 때문에 그냥 그대로 사용하기로 했다...!

💡+번외)
SuccessCode도 따로 만드는게 좋을 것 같다!
나중에 HTTPStatus나 message 수정할 때 상수가 모여 있는 곳에 가면 수정하기가 더 편할 것 같다!!
하지만 아직 안만들었다☺️

2. CustomException 작성

src/main/java/hyangyu/server/exception/CustomException.java

package hyangyu.server.exception;

import hyangyu.server.constants.ErrorCode;
import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class CustomException extends RuntimeException {
    private final ErrorCode errorCode;
}

RuntimeException을 상속받는 CustomException 클래스를 생성한다.
위에 ErrorCode에서 작성한 404, 409 에러들은 따로 잡아주어야 하기 때문에 필요한 클래스이다.
Enum 타입인 ErrorCode를 필드로 추가하였다!

3. GlobalExceptionHandler 작성

src/main/java/hyangyu/server/exception/GlobalExceptionHandler.java

package hyangyu.server.exception;

import hyangyu.server.dto.ErrorDto;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import static hyangyu.server.constants.ErrorCode.INTERNAL_SERVER_ERROR;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({ CustomException.class })
    protected ResponseEntity handleCustomException(CustomException ex) {
        return new ResponseEntity(new ErrorDto(ex.getErrorCode().getStatus(), ex.getErrorCode().getMessage()), HttpStatus.valueOf(ex.getErrorCode().getStatus()));
    }

    @ExceptionHandler({ Exception.class })
    protected ResponseEntity handleServerException(Exception ex) {
        return new ResponseEntity(new ErrorDto(INTERNAL_SERVER_ERROR.getStatus(), INTERNAL_SERVER_ERROR.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

@ExceptionHandler를 사용하는 곳이다. 클래스 위에 @RestControllerAdvice 어노테이션을 달아준다.

@ExceptionHandlervalue 값으로 어떤 Exception을 잡을지 정해준다.
아까 만들었던 CustomException.class를 넣어주었고,
설정했던 ErrorCode의 status와 message만 ResponseEntity에 담아서 리턴하도록 하였다.
(ErrorDto에는 똑같이 status와 message 필드만 있다.)

주의 사항
ExceptionHandler가 붙은 함수는 꼭 protected / private 처리를 해준다!!!
외부에서 함수를 부르게 되면 그대로 에러 객체를 리턴한다😱

💡+번외)
Exception.class, CustomException.class 외에도 다양한 클래스를 사용할 수 있다.

  • MissingServletRequestParameterException.class: request parameter가 없을 때 에러를 리턴한다.
  • MissingRequestHeaderException.class: request header가 없을 때 에러를 리턴한다.
  • MethodArgumentNotValidException.class: request body의 데이터가 유효하지 않을 때 에러를 리턴한다.
  • NoHandlerFoundException.class: 404 error를 리턴한다.

CustomException으로 다 설정해줄 필요 없이,
스프링에서 제공하는 클래스들을 사용하면 어떤 api에서든
받아야할 데이터가 오지 않았을 때 공통으로 사용할 수 있다!

ExceptionHandler로 위의 Exception 클래스들을 잡으면 된다ㅎㅎ
이 부분은 아직 코드를 작성하지 않아서 번외로 작성!

출처 - 스프링 공식 문서

4. @Controller, @Service 수정

가고 싶은 전시회를 저장하는 기능 이다!

src/main/java/hyangyu/server/api/FavoriteApi.java

@PostMapping("/display/{displayId}")
    public ResponseEntity saveFavoriteDisplay(@PathVariable Long displayId) throws Exception {
        HttpHeaders httpHeaders = new HttpHeaders();
        
        User user = user.getMyUserWithAuthorities();
        
        SaveFavoriteDisplayServiceRequestDto requestDto = new SaveFavoriteDisplayServiceRequestDto(user, displayId);
        favoriteDisplayService.saveFavoriteDisplay(requestDto);
        
        ResponseDto responseDto = new ResponseDto(200, "내가 저장한 전시회에 추가되었습니다.");
        return new ResponseEntity<>(responseDto, httpHeaders, HttpStatus.OK);
    }

Controller에서 User와 프론트로부터 받은 displayId를 DTO에 담아서 Service 계층으로 넘겨준다.
(SuccessCode 같은 경우 아직 작성하지 않아서 status와 message를 직접 작성해서 넘겨주고 있다..!)


src/main/java/hyangyu/server/service/FavoriteDisplayService.java

@Transactional(readOnly = false)
    public Optional<FavoriteDisplay> saveFavoriteDisplay(SaveFavoriteDisplayServiceRequestDto request) {
    	//1.
    	Optional<Display> display = displayRepository.findOne(request.getDisplayId());
        if(display.isEmpty()) {
            throw new CustomException(DISPLAY_NOT_FOUND);
        }
        
        FavoriteDisplay favoriteDisplay = new FavoriteDisplay(request.getUser(), display);
        //2.
        Optional<FavoriteDisplay> result = Optional.ofNullable(favoriteDisplayRepository.findOne(favoriteDisplay.getFavoriteDisplayId()));
        if (result.isEmpty()) {
            favoriteDisplayRepository.saveFavoriteDisplay(favoriteDisplay);
        }
        else {
        //3.
        	throw new CustomExcewption(ALREADY_SAVED_DISPLAY)
        }
        
        return result;
    }
  1. displayId로 존재하는 전시인지 확인하고, 존재하지 않는다면 ErrorCode에서 만든 DISPLAY_NOT_FOUND 에러를 CustomException으로 잡아줍니다.

  2. Repository에서 이미 저장한 전시회인지 확인합니다. FavoriteDisplayId에는 UserDisplay ID가 담겨 있습니다.

  3. 데이터가 있다면(이미 저장한 전시회라면), ALREADY_SAVED_DISPLAY를 리턴합니다.


아직 수정해야할 코드가 정말 많이 남아있지만.. 그래도 지저분했던 컨트롤러가 많이 단순해졌다!
서비스 계층에 로직이 추가되어서 훨씬 깔끔해졌고, ExceptionHandler를 통해 에러를 전역적으로 처리할 수 있게 됐다🙈!!

참고

0개의 댓글