본문 바로가기
Spring Boot Project/Plming

[Plming] 게시판 예외 처리(Global Exception Handling) 알아보기

by slchoi 2022. 3. 29.
728x90
SMALL

1. Logback 적용하기

SQL 쿼리가 실행됐을 때 상세한 로그를 확인할 수 있도록 로그백 설정을 적용한다.

"main.resources.template" 패키지에 logback-spring.xml 파일을 추가하고, 아래 코드를 작성한다.

<?xml version="1.0" encoding="UTF-8"?>
<configuration debug="true">

    <!-- Appenders -->
    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <Pattern>%d %5p [%c] %m%n</Pattern>
        </encoder>
    </appender>

    <appender name="console-infolog" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <Pattern>%d %5p %m%n</Pattern>
        </encoder>
    </appender>

    <!-- Logger -->
    <logger name="com.board" level="DEBUG" appender-ref="console" />
    <logger name="jdbc.sqlonly" level="INFO" appender-ref="console-infolog" />
    <logger name="jdbc.resultsettable" level="INFO" appender-ref="console-infolog" />

    <!-- Root Logger -->
    <root level="off">
        <appender-ref ref="console" />
    </root>
</configuration>

쿼리 로그가 정렬되어 출력되고, 쿼리에 대한 추가적인 정보를 제공받을 수 있도록 Log4JDBC 라이브러리를 추가해볼 것이다. build.gradle의 dependencies에서 implements 가장 하단에 Log4JDBC 라이브러리만 추가한다. 라이브러리를 추가한 다음에는 반드시 다시 빌드해주어야 된다.

implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16' /* Log4JDBC */

 

이제 "main.resources" 디렉터리에 log4jdbc.log4j2.properties 파일을 추가하고, 아래 코드를 작성한다. 파일명이 다르면 정렬이 적용되지 않으니 파일명과 코드를 복사해서 사용하는 것을 권장한다.

log4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator
log4jdbc.dump.sql.maxlinelength=0

 

마지막으로 jdbc-url과 driver-class-name을 변경하면 된다. application.properties의 데이터 소스 설정을 아래와 같이 변경한다.

# 데이터 소스 (Data Source)
spring.datasource.hikari.driver-class-name=net.sf.log4jdbc.sql.jdbcapi.DriverSpy
spring.datasource.hikari.jdbc-url=jdbc:log4jdbc:mysql://localhost:3306/board?serverTimezone=Asia/Seoul&useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true
spring.datasource.hikari.username=root
spring.datasource.hikari.password=1111
spring.datasource.hikari.connection-test-query=SELECT NOW() FROM dual

#MyBatis
mybatis.configuration.map-underscore-to-camel-case=true

 

잘 적용되었는지 확인하기 위해 DB에 데이터를 하나 추가한 뒤, findAll 테스트 메서드를 실행시키면, 데이터가 콘솔 창에 정렬돼서 출력되는 것을 확인할 수 있다.

logback 적용 확인

 

2. 전역 예외처리(Global Exception Handling) 적용하기

API를 처리하는 RestController 전역에서 공통된 예외 처리를 적용하는 방법을 알아볼 것이다.

2.1. API 처리용 BoardApiController 생성하기

plming.board 패키지 아래 controller 패키지를 생성한 뒤 BoardApiController 클래스를 추가한다. 클래스가 생성되면 아래 코드를 작성한다.

package plming.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("/board")
public class BoardApiController {

    @GetMapping("/test")
    public String test() {
        throw new RuntimeException("Holy Exception");
    }
}

 

2.2. 전역 예외 핸들링용 GlobalExceptionHandler 생성하기

plming.board 패키지에 exception 패키지를 생성한 뒤 GlobalExceptionHandler 클래스를 추가하고, 아래 코드를 작성한다.

package plming.board.exception;

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

@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가 선언된 클래스에 포함된 메서드에 선언한다.
  • REST API에 대한 예외 처리를 하기 위해 @RestControllerAdvice를 선언했고, 이 어노테이션은 @ControllerAdvice에 @ReponseBody가 적용된 형태이다.

@Slf4j

  • 롬복에서 제공해주는 기능으로, 해당 어노테이션이 선언된 클래스에 자동으로 로그 객체를 생성한다.
  • log.error(), log.debug()와 같이 로깅 관련 메서드를 사용할 수 있다.

@ExceptionHandler(RuntimeException.class)

  • BoardApiController의 test() 메서드를 보면 RuntimeException을 throw하고 있다.
  • @ExceptionHandler에 지정된 예외와 동일한 예외(여기서는 RuntimeException)이 발생하면 GlobalExceptionHandler는 handleRuntimeException() 메서드를 실행한다.

 

2.3. 모든 예외를 한 곳에서 관리하기

예외를 효율적으로 관리하기 위해, 모든 예외를 Enum 클래스로 관리할 것이다.

조금 전에 생성한 exception 패키지 아래 ErrorCode enum 클래스를 생성하고 더보기 코드를 작성한다.

더보기
package plming.board.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@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;
}

 

일부 소수의 예외만 선언했지만, POSTS_NOT_FOUND와 같이, 개발자가 직접 정의한 Custom 예외를 여기서 쉽게 관리할 수 있다. 댓글 기능에서 댓글 Entity를 찾을 수 없는 경우, 아래와 같이 예외를 추가해주면 된다.

COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "댓글 정보를 찾을 수 없습니다.")

 

코드를 살펴보면 

status HTTP 상태 코드를 상수로 선언해둔 HttpStatus 타입의 멤버

예외에 대한 상태 코드(status)와 이름(error)을 처리하는 데 사용된다.
message 예외에 대한 응답 메시지를 처리하는데 사용되는 멤버이다.

 

2.4. 예외 응답을 처리할 Response 클래스 생성하기

exception 패키지 내에 ErrorResponse 클래스를 생성한 뒤, 더보기 코드를 추가한다. 이 클래스는 ErrorCode를 통한 객체 생성만 허용합니다.

더보기
package plming.board.exception;

import lombok.Getter;

import java.time.LocalDateTime;

@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();
    }
}

 

2.5. Custom 예외 처리용 Exception 클래스 생성하기

exception 패키지 내에 CustomeException 클래스를 생성하고 아래 코드를 작성한다. 이 클래스도 ErrorResponse와 마찬가지로 ErrorCode를 통한 객체 생성만 허용한다. 이때, Unchecked Exception인 RuntimeException을 상속받는다.

package plming.board.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class CustomException extends RuntimeException{

    private final ErrorCode errorCode;
}

 

2.6 GlobalExceptionHandler 수정하기

기존에 작성한 RuntimeException에 대한 예외 처리 메서드를 제거하고 개발자가 직접 정의한 CustimException과 HTTP 405, HTTP 500에 대한 Handler가 추가되었다.

더보기
package plming.board.exception;

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

@RestController
@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> handleHttpRequestMethodNtoSupportedException(final HttpRequestMethodNotSupportedException e) {
        log.error("handleJttpRequestMethodNotSupportedException: {}", 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("handlerException : {}", e.getMessage());
        return ResponseEntity.status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus().value())
                .body(new ErrorResponse(ErrorCode.INTERNAL_SERVER_ERROR));
    }
}

ResponseEntity<ErrorResponse>

  • ResponseEntity<T>는 HTTP Request에 대한 응답 데이터를 포함하는 클래스이다.
  • <Type>에 해당하는 데이터와 HTTP 상태 코드를 함께 리턴할 수 있다.
  • 예외가 발생했을 때, ErrorResponse 형식으로 예외 정보를 Response로 내려주게 된다.

 

2.7. BoardApiController 수정하기

마지막으로 예외 처리가 잘 되었는지 확인할 수 있도록 test() 메서드를 호출했을 때 강제로 CustomException을 발생시키도록 변경한다.

package plming.board.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import plming.board.exception.CustomException;
import plming.board.exception.ErrorCode;

@RestController
@RequestMapping("/board")
public class BoardApiController {

    @GetMapping("/test")
    public String test() {
        throw new CustomException(ErrorCode.POSTS_NOT_FOUND);
    }
}

Postman을 실행시켜 localhost:8080/api/test URI를 호출하면 설정한 예외가 발생하는 것을 확인할 수 있다.

이 과정에서 Spring Boot를 실행시켰는데 서버가 열리지 않는다면, logback-spring.xml 파일을 경로를 확인해보자. logback-spring.xml 파일이 resource.template이 아닌 resource에 위치해있다면 스프링 부트가 실행되지 않는다. 이 부분 해결하는데 시간을 너무 많이 썼다...

 

오류 메시지 테스트

현재 BoardApiController의 test 메서드에서 강제로 예외를 throw 하도록 해둔 상태인데, 정상적인 예외 처리는 비즈니스 로직을 담당하는 Service Layer에서 이루어져야 한다.

우선 BoardApiController의 test 메서드를 삭제하고 Service Layer에서 예외 처리를 진행할 것이다.

 

본 프로젝트는 아래 블로그를 참고해서 만들었습니다.
https://congsong.tistory.com/53?category=749196
728x90
LIST

댓글