[Spring] 스프링 부트 예외 처리 가이드

이 글은 [스프링 부트 3 백엔드 개발자 되기(자바 편)(2판)]에서 발췌했습니다.
골든래빗 출판사

스프링, 스프링 부트는 예외 처리를 쉽고 명확하게 처리할 수 있는 다양한 애너테이션을 지원합니다. 이번에는 스프링, 스프링 부트를 사용할 때 어떤 방식으로 예외 처리를 하는지 알아보겠습니다.

 

[Spring] 스프링 부트 값 검증 가이드

public Article findById(long id) {
  return blogRepository.findById(id)
         .orElseThrow(ArticleNotFoundException::new);
}

 

이 코드는 id를 입력받아 특정 블로그 글을 찾은 다음, 글이 없으면 IllegalArgumentException 예외와 함께 “not found ${id}”라는 에러 메시지를 보냅니다. 실제로 다음 포맷으로 에러 메시지를 보내줍니다.

 

{
  "timestamp": "2023-04-16T07:28:34.039+00:00", # 예외 발생 시간
  "status": 500, # HTTP 상태 코드
  "error": "Internal Server Error", # 예외 유형
  "path": "/api/articles/123" # 예외가 발생한 요청 경로
}

 

이 포맷은 스프링 부트에서 기본으로 제공하는 DefaultErrorAttributes입니다. 여기에 추가 정보를 담고 싶다면 ErrorAttributes를 구현하여 빈으로 등록하면 구현한 ErrorAttributes에 맞게 에러 메시지를 만들 수 있습니다. 다음은 DefaultErrorAttributes에 customValue라는 키값을 추가한 예입니다. 따라 하지 말고 눈으로만 봐주세요!

 

▼ DefaultErrorAttributes에 customValue라는 키값을 추가한 예

@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {

  @Override
  public Map<String, Object> getErrorAttributes(WebRequest webRequest,
ErrorAttributeOptions options) {
      Map<String, Object> result = super.getErrorAttributes(webRequest, options);
      result.put("customValue", "Hello, World!");
      return result;
  }
}

 

이렇게 구현하면 다음과 같이 임의 키값이 추가된 포맷을 에러 메시지로 만들어줍니다.

 

▼ 임의 키값이 추가된 포맷의 에러 메시지

{
  "timestamp": "2023-04-16T07:37:16.999+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/api/articles/333",
  "customValue": "Hello, World!"
}

 

그럼 다른 방법으로 에러 메시지를 만들 방법은 없는 걸까요? 다른 방법도 있습니다. 필자의 경우 에러 메시지용 객체를 만들어 사용하기를 더 좋아합니다. 객체로 에러 메시지를 만들면 어떤 키값이 있는지 한눈에 보기 좋습니다. 그리고 구조를 바꾸기도 용이하죠. 그래서 앞서 소개한 ErrorAttributes를 구현하는 방법 대신 에러 메시지용 객체를 별도로 만드는 방법을 실습하겠습니다. 아참, 기존의 예외 처리 로직에는 다음과 같은 두 가지의 아쉬운 점이 있었는데 이것도 해결해보겠습니다.

 

  1. 예외 이름만 보고는 왜 발생한 예외인지 파악이 어렵다.
  2. 예외 메시지가 여러 곳에 퍼져 있기 때문에 관리하기가 어렵다.

 

01단계

config 디렉터리에 error 디렉터리를 만들고 ErrorCode라는 이름을 가진 enum을 생성하세요.

 

@Getter
public enum ErrorCode {
    INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "E1", "올바르지 않은 입력값입니다."),
    METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "E2", "잘못된 HTTP 메서드를 호출했습니다."),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E3", "서버 에러가 발생했습니다."),
    NOT_FOUND(HttpStatus.NOT_FOUND, "E4", "존재하지 않는 엔티티입니다."),

    ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "A1", "존재하지 않는 아티클입니다.");

    private final String message;

    private final String code;
    private final HttpStatus status;

    ErrorCode(final HttpStatus status, final String code, final String message) {
        this.status = status;
        this.code = code;
        this.message = message;
    }
}

 

이 코드는 에러 코드를 한 곳에 모아 관리하기 위한 enum입니다. 에러가 발생했을 때 어떤 HTTP 상태값으로 응답하는지, 어떤 기본 메시지를 가지고 있는지, 어떤 고유한 에러 코드를 가지는지를 정의한 것이죠. 이렇게 정의하면 예외를 한 곳에서 관리할 수 있습니다.

 

02단계

ErrorResponse.java 파일을 생성한 뒤 다음 코드를 따라 입력하세요.

 

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResponse {

    private String message;
    private String code;


    private ErrorResponse(final ErrorCode code) {
        this.message = code.getMessage();
        this.code = code.getCode();
    }

    public ErrorResponse(final ErrorCode code, final String message) {
        this.message = message;
        this.code = code.getCode();
    }

    public static ErrorResponse of(final ErrorCode code) {
        return new ErrorResponse(code);
    }

    public static ErrorResponse of(final ErrorCode code, final String message) {
        return new ErrorResponse(code, message);
    }
}

 

ErrorAttributes를 대체할 에러 메시지용 객체입니다. 에러 메시지가 포함된 message 필드와 고유 에러 코드인 code 필드를 가지고 있습니다. ErrorResponse 객체를 사용하면 다음 형식의 JSON 응답을 받게 될 것입니다.

 

▼ 실제로 예외가 발생하면 생기는 에러 메시지

{
  "message": " .",
  "code": "E4"
}

 

03단계

error 디렉터리에 exception 디렉터리를 새로 만들고 BusinessBaseException.java 파일을 생성한 뒤 다음 코드를 입력하세요.

 

package me.shinsunyoung.springbootdeveloper.config.error.exception;

import me.shinsunyoung.springbootdeveloper.config.error.ErrorCode;

public class BusinessBaseException extends RuntimeException {

    private final ErrorCode errorCode;

    public BusinessBaseException(String message, ErrorCode errorCode) {
        super(message);
        this.errorCode = errorCode;
    }

    public BusinessBaseException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

 

이 예외 클래스는 비즈니스 로직을 작성하다 발생하는 예외를 모아둘 최상위 클래스입니다. BusinessBaseException을 상속받은 구조로 비즈니스 로직 관련 예외를 만드는 것이죠. 예를 들면 조회 대상이 없는 경우에 대한 예외를 정의하는 NotFoundException이나, 블로그 글을 조회했을 때 발생할 수 있는 예외인 ArticleNotFoundException을 만듭니다. 예외 이름만 봐도 예외가 난 이유를 명확하게 파악할 수 있습니다. 이 외에도 인증되지 않은 사용자에 대한 예외를 처리할 UnauthorizedException, 중복키에 대한 예외를 처리할 DuplicateKeyException 등이 있습니다.

 

 

04단계

계속해서 코드를 작성합시다. exception 디렉터리에 NotFoundException.java, ArticleNotFoundException.java 파일을 만들어 다음 코드를 작성하세요.

 

public class NotFoundException extends BusinessBaseException {
    public NotFoundException(ErrorCode errorCode) {
        super(errorCode.getMessage(), errorCode);
    }

    public NotFoundException() {
        super(ErrorCode.NOT_FOUND);
    }
}

 

public class ArticleNotFoundException extends NotFoundException {
    public ArticleNotFoundException() {
        super(ErrorCode.ARTICLE_NOT_FOUND);
    }
}

 

05단계

error 디렉터리에 GlobalExceptionHandler.java를 만들고 다음 코드를 입력하세요. 이 코드는 @ControllerAdvice를 사용한 예외 처리 핸들러입니다. @ControllerAdvice를 사용하면 모든 컨트롤러에서 발생하는 예외를 중앙에서 한꺼번에 처리할 수 있습니다.

 

@Slf4j
@ControllerAdvice // 모든 컨트롤러에서 발생하는 예외를 잡아서 처리
public class GlobalExceptionHandler {

    // 지원하지 않은 HTTP method 호출 할 경우 발생
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class) // HttpRequestMethodNotSupportedException 예외를 잡아서 처리
    protected ResponseEntity<ErrorResponse> handle(HttpRequestMethodNotSupportedException e) {
        log.error("HttpRequestMethodNotSupportedException", e);
        return createErrorResponseEntity(ErrorCode.METHOD_NOT_ALLOWED);
    }

    @ExceptionHandler(BusinessBaseException.class)
    protected ResponseEntity<ErrorResponse> handle(BusinessBaseException e) {
        log.error("BusinessException", e);
        return createErrorResponseEntity(e.getErrorCode());
    }

    @ExceptionHandler(Exception.class)
    protected ResponseEntity<ErrorResponse> handle(Exception e) {
        e.printStackTrace();
        log.error("Exception", e);
        return createErrorResponseEntity(ErrorCode.INTERNAL_SERVER_ERROR);
    }

    private ResponseEntity<ErrorResponse> createErrorResponseEntity(ErrorCode errorCode) {
        return new ResponseEntity<>(
                ErrorResponse.of(errorCode),
                errorCode.getStatus());
    }
}

 

@ExceptionHandler 애너테이션을 사용해 특정 예외 상황에 대한 처리를 정의할 수 있습니다. 예를 들어 HttpRequestMethodNotSupportedException 예외는 handle(HttpRequestMethodNotSupportedException e) 메서드로 예외를 처리합니다. HttpRequestMethodNotSupportedException 예외는 지원하지 않은 HTTP 메서드를 호출하면 발생하는 예외입니다. 이 예외가 발생하면 405 응답 코드와 함께 “잘못된 HTTP 메서드를 호출했습니다.”라는 메시지를 보내줍니다.

이렇게 하면 스프링이 동작하며 자체적으로 발생하는 예외를 @ExceptionHandler에서 잡아 적절한 ErrorResponse로 변환하여 일관성 있는 예외 처리를 할 수 있습니다. 이 외에도 BusinessBaseException 예외는 예외를 던질 때 전달받는 ErrorCode를 바탕으로 ErrorResponse를 만들고, 여기에 정의한 예외가 아니라면 Exception을 잡는 핸들러에 걸리므로 500 응답 코드와 함께 “서버 에러가 발생했습니다.”라는 메시지를 보내줍니다. 테스트 코드를 통해 실제로 그런지 확인해볼까요?

 

06단계

BlogApiControllerTest.java 파일을 열어 다음과 같이 테스트 코드를 작성하세요.

 

@DisplayName("findArticle: 잘못된 HTTP 메서드로 아티클을 조회하려고 하면 조회에 실패한다.")
@Test
public void invalidHttpMethod() throws Exception {
  // given
  final String url = "/api/articles/{id}";

  // when
  final ResultActions resultActions = mockMvc.perform(post(url, 1));

  // then
  resultActions
          .andDo(print())
          .andExpect(status().isMethodNotAllowed())
          .andExpect(jsonPath("$.message").value(ErrorCode.METHOD_NOT_ALLOWED.getMessage()));
}

 

위 테스트 코드는 GET 요청을 처리하는 컨트롤러만 있는 URL에 HttpRequestMethodNotSupportedException 예외가 발생할 POST 요청을 보냅니다. 테스트 코드를 실행하면 실제로 METHOD_NOT_ALLOWED 에러 코드에 정의한 상태 코드인 405 응답과 에러 메시지를 보내줍니다.

 

 

테스트는 잘 통과합니다. 검증문에 andDo(print())라는 내용을 작성했는데 이 코드를 추가하면 실제 응답이 어떻게 나오는지 콘솔 로그에서 확인할 수 있습니다. 로그 아래 쯤에 다음과 같은 로그를 확인할 수 있습니다.

 

 

그럼 이제 블로그 조회 로직의 예외도 바꿔보겠습니다. 그전에 지금은 어떤 응답이 오고 있는지 확인하기 위해 테스트 코드를 먼저 작성해보겠습니다.

 

07단계

BlogApiControllerTest.java 파일을 열어 다음 테스트 코드를 이어서 작성하고 테스트 코드를 실행하세요.

 

@DisplayName("findArticle: 존재하지 않는 아티클을 조회하려고 하면 조회에 실패한다.")
@Test
public void findArticleInvalidArticle() throws Exception {
  // given
  final String url = "/api/articles/{id}";
  final long invalidId = 1;

  // when
  final ResultActions resultActions = mockMvc.perform(get(url, invalidId));

  // then
  resultActions
          .andDo(print())
          .andExpect(status().isNotFound())
          .andExpect(jsonPath("$.message").value(ErrorCode.ARTICLE_NOT_FOUND.getMessage()))
          .andExpect(jsonPath("$.code").value(ErrorCode.ARTICLE_NOT_FOUND.getCode()));
}

 

 

이번에는 테스트가 실패했습니다. 그 이유는 블로그 글이 조회되지 않아 IllegalArgumentException을 반환하고 있는데 IllegalArgumentException을 ExceptionHandler에 정의하지 않았기 때문입니다. 지금은 상위 클래스인 Exception이 발생했을 때 처리하는 모습을 보여줍니다. 즉, 응답 코드는 500, 에러 메시지는 “서버 에러가 발생했습니다.”가 나옵니다.

 

08단계

BlogService.java 파일을 열어 다음과 같이 코드를 수정하세요.

 

public Article findById(long id) {
  return blogRepository.findById(id)
          .orElseThrow(ArticleNotFoundException::new);
}

 

id에 해당하는 레코드가 없으면 ArticleNotFoundException 예외를 던지도록 수정했습니다. 다시 테스트 코드를 실행하면 테스트가 잘 되고, 메시지도 잘 나옵니다.

 

 

지금까지 스프링 부트를 사용할 때 예외 처리하는 방식에 대해서 알아보았습니다. 

신선영

리멤버 백엔드 개발자. 하드 스킬과 소프트 스킬 역량을 강화하고자 부단히 공부하고 글로 남기는 백엔드 개발자입니다. 평일 기준 하루 평균 600뷰의 기술 블로그를 운영하고, 모교 학생을 대상으로 정기 세미나와 멘토링을 진행합니다. 구독자가 1,000명 정도 되는 사이드 프로젝트를 기획하고 개발하고 운영한 경험이 있습니다.

저자 블로그 shinsunyoung.tistory.com
저자 깃허브 github.com/shinsunyoung

 

Leave a Reply

©2020 GoldenRabbit. All rights reserved.
상호명 : 골든래빗 주식회사
(04051) 서울특별시 마포구 양화로 186, 5층 512호, 514호 (동교동, LC타워)
TEL : 0505-398-0505 / FAX : 0505-537-0505
대표이사 : 최현우
사업자등록번호 : 475-87-01581
통신판매업신고 : 2023-서울마포-2391호
master@goldenrabbit.co.kr
개인정보처리방침
배송/반품/환불/교환 안내