본 JPA 게시판 프로젝트는 단계별(step by step)로 진행됩니다.
이전 글에서는 JPA의 개념과 사용 방법에 대해 간단하게 알아보았고,
게시글 생성(Create), 조회(Read), 삭제(Delete) 기능을 테스팅해 보았습니다.
이번 글에서는 스프링에서의 예외 처리 방법에 대해 말씀드리려 합니다.
기존에 알던 예외 처리와는 차원이 다른, 애플리케이션 전역에서 예외를 핸들링할 수 있는... (두근두근)
그럼, 바로 시작해 볼게요 :)
1. 로그백(Logback) 적용하기
이전 글에서 테스팅을 진행했을 때 SELECT 쿼리의 결과는 다음과 같았습니다.
개발 과정에서 로깅(Logging)은 너무나도 필수적인 요소인데요.
작업에 들어가기 전, 몇 가지 부가적인 설정들을 적용하고 시작하는 게 좋을 듯합니다.
우선, SQL 쿼리가 실행됐을 때 더욱 상세한 로그를 확인할 수 있도록 로그백 설정을 적용해야 하는데요.
설정은 여기서 진행해 주시면 되겠습니다. (금방 끝나요!)
설정이 마무리된 뒤에 새로운 게시글을 하나 등록하고,
BoardTests 클래스의 findAll( ) 메서드를 실행해보면 콘솔에서 다음과 같은 로그를 확인하실 수 있습니다.
기존에는 볼 수 없었던 테이블(Table) 형태의 로그가 추가되었네요.
2. 전역 예외처리(GLobal Exception Handling) 적용하기
우리는 JPA와 Rest API 기반의 비동기 방식의 게시판을 구현하기로 했습니다.
즉, 페이지를 처리하는 Controller와 API를 처리하는 Controller를 따로 구성하게 되는데요,
API를 처리하는 RestController 전역에서 공통된 예외 처리를 적용하는 방법을 알려드리겠습니다.
2-1. API 처리용 BoardApiController 생성하기
package com.study.board.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class BoardApiController {
@GetMapping("/test")
public String test() {
throw new RuntimeException("Holy! Exception...");
}
}
브라우저에서 test( ) 메서드와 매핑된 URI인 localhost:8080/api/test를 호출해보면
스프링 부트에서 기본적으로 제공하는 에러 페이지를 볼 수 있는데요,
우리는 페이지와 API 컨트롤러를 따로 구성할 것이기 때문에
페이지 관련 예외 처리는 전혀 필요하지 않은 상황입니다.
2-2. 전역 예외 핸들링용 GlobalExceptionHandler 생성하기
package com.study.exception;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import lombok.extern.slf4j.Slf4j;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public String handleRuntimeException(final RuntimeException e) {
log.error("handleRuntimeException : {}", e.getMessage());
return e.getMessage();
}
}
@RestControllerAdvice
스프링은 예외 처리를 위해 @ControllerAdvice와 @ExceptionHandler 등의 기능을 지원해 주는데요.
@ControllerAdvice는 컨트롤러 전역에서 발생할 수 있는 예외를 잡아 Throw 해주고,
@ExceptionHandler는 특정 클래스에서 발생할 수 있는 예외를 잡아 Throw 합니다.
일반적으로 @ExceptionHandler는 @ControllerAdvice가 선언된 클래스에 포함된 메서드에 선언합니다.
우리는 페이지에 대한 예외 처리는 의미가 없기 때문에 @RestControllerAdvice를 선언했고,
해당 어노테이션은 @ControllerAdvice에 @ResponseBody가 적용된 형태로 이해해 주시면 될 듯합니다.
@Slf4j
롬복에서 제공해주는 기능으로, 해당 어노테이션이 선언된 클래스에 자동으로 로그 객체를 생성합니다.
코드에서 보시는 것처럼 log.error( ), log.debug( )와 같이 로깅 관련 메서드를 사용하실 수 있습니다.
@ExceptionHandler(RuntimeException.class)
앞에서 이야기한 @ExceptionHandler입니다.
속성으로는 RuntimeException.class를 지정했는데요,
BoardApiController의 test( ) 메서드를 보시면 RuntimeException을 throw 하고 있습니다.
@GetMapping("/test")
public String test() {
throw new RuntimeException("Holy! Exception...");
}
감이 좀 잡히시나요?
@ExceptionHandler에 지정된 예외와 동일한 예외, 즉 RuntimeException이 발생하면
GlobalExceptionHandler는 handleRuntimeException( ) 메서드를 실행합니다.
글로는 이해가 쉽지 않을 수 있으니, 다시 App을 실행해서 눈으로 확인해 보도록 하겠습니다.
해당 이미지는 handleRuntimeException( ) 메서드의 디버깅 모드입니다.
14번 라인은 실행된 상태이며, 콘솔을 보시면 컨트롤러에서 throw 한 예외 메시지를 확인하실 수 있습니다.
브라우저에서의 URI 호출 결과도 보시는 것처럼 정상적으로 예외 메시지가 출력되고 있습니다.
그런데, 딸랑 예외 메시지만 리턴해주다 보니, 허전함이 강하게 느껴지는데요.
2-3에서 효율적인 예외 처리 방법을 설명드리도록 하겠습니다.
2-3. 모든 예외를 한 곳에서 관리하기
우리는 더욱 효율적으로 예외를 관리할 수 있도록, 모든 예외를 하나의 Enum 클래스로 관리할 건데요,
최대한 쉽고 빠르게 진행해 볼 테니, 조금만 집중해 주세요 :)
다음은 Advanced REST Client에서 애플리케이션 내에 등록(매핑)되지 않은 URI를 호출한 결과입니다.
예외 응답은 이미지와 같이 일관성 있는 포맷을 가질 필요가 있습니다.
만약, API마다 예외에 대한 응답 포맷(Response Format)이 다르다면 어떻게 될까요?
예시를 한번 들어보도록 할게요.
개발자 A: "나는 status랑 message만 Response로 내려줄래."
개발자 B: "나는 status랑 error랑 message를 Response로 내려줄래."
개발자 C: "나는 timestamp랑 statu랑 error랑 message를 Response로 내려줄래.
네, 상상만 해도 끔찍한 상황이지요?
이렇게, 일관성이 흐트러지는 상황을 방지하기 위해서는
Response로 내려줄, 항상 동일한 구조의 응답 포맷을 미리 설계하는 게 가장 효율적이라고 생각하는데요,
글로는 이해가 쉽지 않으실 수 있으니, 직접 코드를 작성해 볼게요.
2-3-1. 모든 예외를 관리할 ErrorCode - Enum 생성하기
package com.study.exception;
import org.springframework.http.HttpStatus;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ErrorCode {
/*
* 400 BAD_REQUEST: 잘못된 요청
*/
BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
/*
* 404 NOT_FOUND: 리소스를 찾을 수 없음
*/
POSTS_NOT_FOUND(HttpStatus.NOT_FOUND, "게시글 정보를 찾을 수 없습니다."),
/*
* 405 METHOD_NOT_ALLOWED: 허용되지 않은 Request Method 호출
*/
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "허용되지 않은 메서드입니다."),
/*
* 500 INTERNAL_SERVER_ERROR: 내부 서버 오류
*/
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 오류입니다."),
;
private final HttpStatus status;
private final String message;
}
우선, 전체적인 예외를 관리할 Eunm 클래스가 필요합니다.
여기서는 여러분이 쉽게 이해하실 수 있도록 일부 소수의 예외만 선언했는데요,
POSTS_NOT_FOUND와 같이, 개발자가 직접 정의한 Custom 예외를 이곳에서 쉽게 관리할 수 있습니다.
예를 들어, 댓글 기능에서 댓글 Entity를 찾을 수 없는 경우, 다음과 같이 예외를 추가해 주시면 되며,
ErrorCode를 이용한 디테일한 예외 처리 방법은 뒤에서 제대로 알아보도록 하겠습니다 :)
COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "댓글 정보를 찾을 수 없습니다.")
status
HTTP 상태 코드를 상수로 선언해둔 HttpStatus 타입의 멤버로,
예외에 대한 상태 코드(status)와 이름(error)을 처리하는 데 사용됩니다.
message
예외에 대한 응답 메시지(message)를 처리하는 데 사용되는 멤버입니다.
2-3-2. 예외 응답을 처리할 Response 클래스 생성하기
package com.study.exception;
import java.time.LocalDateTime;
import lombok.Getter;
@Getter
public class ErrorResponse {
private final LocalDateTime timestamp = LocalDateTime.now();
private final int status;
private final String error;
private final String code;
private final String message;
public ErrorResponse(ErrorCode errorCode) {
this.status = errorCode.getStatus().value();
this.error = errorCode.getStatus().name();
this.code = errorCode.name();
this.message = errorCode.getMessage();
}
}
404 Error Response와 유사한 형태를 가진, 예외 응답을 처리할 Response 클래스입니다.
해당 클래스는 ErrorCode를 통한 객체 생성만을 허용합니다.
2-3-3. Custom 예외 처리용 Exception 클래스 생성하기
package com.study.exception;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class CustomException extends RuntimeException {
private final ErrorCode errorCode;
}
다음으로 개발자가 ErrorCode에 직접 정의한 Custom 예외를 처리할 Exception 클래스입니다.
ErrorResponse와 마찬가지로 ErrorCode를 통한 객체 생성만을 허용하는데요,
Unchecked Exception인 RuntimeException을 상속받는 것을 꼭 기억해 주셔야 합니다.
2-3-4. GlobalExceptionHandler 수정하기
package com.study.exception;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import lombok.extern.slf4j.Slf4j;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/*
* Developer Custom Exception
*/
@ExceptionHandler(CustomException.class)
protected ResponseEntity<ErrorResponse> handleCustomException(final CustomException e) {
log.error("handleCustomException: {}", e.getErrorCode());
return ResponseEntity
.status(e.getErrorCode().getStatus().value())
.body(new ErrorResponse(e.getErrorCode()));
}
/*
* HTTP 405 Exception
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
protected ResponseEntity<ErrorResponse> handleHttpRequestMethodNotSupportedException(final HttpRequestMethodNotSupportedException e) {
log.error("handleHttpRequestMethodNotSupportedException: {}", e.getMessage());
return ResponseEntity
.status(ErrorCode.METHOD_NOT_ALLOWED.getStatus().value())
.body(new ErrorResponse(ErrorCode.METHOD_NOT_ALLOWED));
}
/*
* HTTP 500 Exception
*/
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(final Exception e) {
log.error("handleException: {}", e.getMessage());
return ResponseEntity
.status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus().value())
.body(new ErrorResponse(ErrorCode.INTERNAL_SERVER_ERROR));
}
}
앞에서 다루었던 GlobalExceptionHandler입니다.
기존에 작성한 RuntimeException에 대한 예외 처리 메서드는 로직에서 제외되고,
개발자가 직접 정의한 CustomException과 HTTP 405, HTTP 500에 대한 Handler가 추가되었습니다.
ResponseEntity<ErrorResponse>
ResponseEntity<T>는 HTTP Request에 대한 응답 데이터를 포함하는 클래스로,
<Type>에 해당하는 데이터와 HTTP 상태 코드를 함께 리턴할 수 있습니다.
우리는 예외가 발생했을 때, ErrorResponse 형식으로 예외 정보를 Response로 내려주게 됩니다.
2-3-5. BoardApiController 수정하기
package com.study.board.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.study.exception.CustomException;
import com.study.exception.ErrorCode;
@RestController
@RequestMapping("/api")
public class BoardApiController {
@GetMapping("/test")
public String test() {
throw new CustomException(ErrorCode.POSTS_NOT_FOUND);
}
}
마지막으로 BoardApiController를 수정할 차례입니다.
test( ) 메서드를 호출했을 때 강제로 CustomException을 발생시키도록 변경해 주세요.
설명보다는 예외에 대한 응답이 어떤 흐름으로 처리되는지 직접 눈으로 확인해보는 게 좋을 듯합니다.
다시 App을 실행해서 localhost:8080/api/test URI를 호출해 보도록 하겠습니다.
ErrorCode에 선언된 POSTS_NOT_FOUND의 code, message와 응답 포맷이 동일하지요?
POSTS_NOT_FOUND(HttpStatus.NOT_FOUND, "게시글 정보를 찾을 수 없습니다."),
이번에는 Get Method로 매핑된 test( ) 메서드를 POST Method로 호출한 결과입니다.
예외에 대한 결과가 우리가 원하던 응답 포맷으로 내려오는 것을 확인할 수 있습니다.
하지만, 우리는 BoardApiController의 test( ) 메서드에서 강제로 예외를 throw 하도록 해둔 상태인데요,
정상적인 예외 처리는 비즈니스 로직을 담당하는 Service Layer에서 이루어져야 합니다.
우선은 BoardApiController의 test( ) 메서드를 삭제해 주시고,
비즈니스 로직을 작성하게 되는 다음 글에서 제대로 된 예외 처리 사용 방법을 설명드리도록 할게요.
마무리
다음 글부터는 서비스, 컨트롤러, 화면까지 전체적으로 구현하게 될 텐데요.
CRUD 순서 그대로, 생성(Create), 조회(Read), 수정(Update), 삭제(Delete) 순으로 진행하게 됩니다.
많지 않은 분량과, 더욱 퀄리티 높은 내용의 글로 찾아뵙도록 할게요!
오늘도 방문해 주셔서 감사드립니다 :)
진행에 어려움을 겪으시는 분들이 계실 수 있으니, 프로젝트를 첨부해 드리도록 하겠습니다.
application.properties의 데이터베이스 정보만 내 PC 환경과 일치하도록 변경해서 사용해 주세요 :)
댓글