본문 바로가기

Spring

[Spring Boot] 프로젝트 템플릿 만들기(1) - 예외 처리

템플릿을 만들면서 가장 먼저 떠올린 개선점은 예외 처리이다.

 

프로젝트를 진행할 때 프론트와 협업하면서 어느 부분에서 에러가 발생했는지 명확하지 않아 코드를 매 번 분석해야하는 불편함이 있었다. 특히, 배포 환경에서는 서버의 로그를 바로 볼 수 없기 때문에 EC2 콘솔에 접속해서 스프링부트 서버를 재시작하여 콘솔에 뜨는 에러로그를 직접 보아야 디버깅이 가능했다.

 

이러한 번거로움을 없애기 위해서 세분화된 예외처리한 눈에 파악할 수 있는 로그 데이터의 필요성을 느꼈다.

 

먼저 예외처리를 좀 더 세분화하고, 뱉어지는 예외 메시지가 에러의 이유를 바로 짐작할 수 있도록 구성하고 싶었다.

또한, 매 번 반복적으로 작성해야하는 Try / Catch 문에서도 벗어나고 싶었다.

 

예외처리 예시

		try {
			if (!Utils.isEmpty(params.get("userId"))) {
				responseBodyEntity.setData(userService.checkIdAvailable(params.get("userId")));
			}
			else {
				responseBodyEntity.setError("파라미터 정보가 존재하지 않습니다.");
			}
			
		} catch (ServiceException e) {
			LOG.error("------------------------------------------------------------");
			LOG.error("[ERROR] message={}, stackTrace={}", e.getErrorMessage(), e);
			LOG.error("------------------------------------------------------------");
			
			responseBodyEntity.setError(e);
		} catch (Exception e) {
			LOG.error("------------------------------------------------------------");
			LOG.error("[ERROR] message={}, stackTrace={}", e.getMessage(), e);
			LOG.error("------------------------------------------------------------");
			
			responseBodyEntity.setError(e);
		}

 

 

 

예외 처리를 세분화하고,  예외 상황에 따라 프로세스를 커스텀할 수 있도록 구성하고자 했다.

예외 처리 관련 클래스들을 모아둔 exception 모듈을 만들어 처음 프로젝트를 접하는 사람도  쉽게 파악할 수 있도록 했다.

 

예외처리 관련 클래스들


CustomException

@Slf4j
@Getter
@RequiredArgsConstructor
public class CustomException extends RuntimeException {

    private final ExceptionStatus exceptionStatus;

    //StackTrace 가지지 않도록 처리.
    @Override
    public synchronized Throwable fillInStackTrace() {
        return this;
    }
}

 

 

CustomException 는 RuntimeException을 확장하여 내가 정의한 예외처리를 담당할 클래스이다.

StackTrace를 재정의한 이유는 예외 발생시에 StackTrace에 아무 값도 주지 않게끔 처리하여 stacktrace 세팅에 들어가는 불필요한 리소스 낭비를 막기 위한 설정이다.

 

ExceptionStatus를  멤버 변수로 사용하여 에러 상황과 에러 메시지를 사용할 수 있도록 하였다.

 

 

ExceptionStatus

@Getter
@RequiredArgsConstructor
public enum ExceptionStatus {
    // 다뤄져야할 예외처리 케이스가 있을 경우, 추가한다.

    // 인증 관련
    PasswordNotMatchedException("PasswordNotMatchedException", 400, "로그인 패스워드가 일치하지 않습니다.");

    private final String exceptionName;
    private final int statusCode;
    private final String errorMessage;
}

 

ExceptionStatus Enum에는 각 예외 상황마다 사용할 상수를 선언하고, 각 상수에서 멤버 변수들을 초기화하여 사용할 수 있도록 설정하였다.

 

다뤄야하는 예외상황이 추가될 때 마다, 해당 enum 에 상수를 추가하여 사용하게 될 것이다.

 

 

예외 처리를 위해 코드 단에서 구현한 로직은 다음과 같다.

 

        // CustomException 사용 예시. 신규 에러 Case가 발생되면 ExceptionStatus에 추가한다.
        if (password == null) {
            throw new CustomException(ExceptionStatus.PasswordNotMatchedException);
        }

 

try / catch 문 없이 생각되는 예외상황에 예외를 던지도록 선언하고, param으로는 enum에 정의해둔 상수를 호출하여 전달하게 된다.

 

만약 내가 예외처리 하지 않은 신규 Case가 발생하게 된다면, ExceptionStatus에 해당 Case를 추가하고 에러 발생 지점에 예외처리 로직을 추가하도록 한다.

 

 

ExceptionDto

@Getter
@Builder
@AllArgsConstructor
public class ExceptionDto {
    private String message;
    private int statusCode;
}

 

예외 발생 시, 클라이언트 단에 전달한 예외처리 관련 데이터들을 모아둔 DTO 이다.

 

 

 

GlobalExceptionHandler 


GlobalExceptionHandler는 전역 예외처리를 핸들링할 클래스이다.

@RestControllerAdvice 어노테이션을 사용하여, @RestController / @Controller 어노테이션을 사용 중인 컨트롤러에서 발생하는 예외 처리들을 전역적으로 처리할 수 있도록 하였다. 

 

대부분의 프로젝트에서 @RestController 를 사용하고 있고, 예외 Response를 Json으로 내려준다는 점에서 @RestControllerAdvice 를 채택하였다.

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    /*
        공통 예외처리 (Enum에 지정한 에러 코드, 메시지 반환
        별도의 프로세스로 처리되어야할 Case에 대해서는 @ExceptionHandler를 추가하여 대응한다.
     */
}

 

클래스 내부에는 각 예외처리를 담당할 핸들러들이 들어가게 될 것이다.

각 핸들러 별로 명시해둔 예외가 발생하게 되면, 핸들러 내부의 로직으로 예외처리를 진행하게 되는 것이다.

 

 

- CustomException

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ExceptionDto> handleCustomException(CustomException ex){
        log.error("=====================================================================");
        log.error("{} : {}", ex.getExceptionStatus().getExceptionName(),
                ex.getExceptionStatus().getErrorMessage());
        log.error("=====================================================================");

        return new ResponseEntity<>(ExceptionDto.builder()
                .message(ex.getExceptionStatus().getErrorMessage())
                .statusCode(ex.getExceptionStatus().getStatusCode())
                .build(),
                HttpStatus.valueOf(ex.getExceptionStatus().getStatusCode()
                ));
    }

 

앞서 얘기했던 불편함들을 해결하기 위해서 CustomException을 구현하였고, 해당 Exception이 발생했을 때 처리할 수 있는 Handler를 매핑해주었다.

 

내가 생각하기에, 디버깅을 위해 예외처리에서 기본적으로 출력해주어야 할 내용은 2가지였다.

1. 서버 로그
2. 클라이언트 단에서 에러의 원인을 파악할 수 있는 Response

 

1번을 적용하기 위해서 log4j 의 구현체인 @Slf4j 를 사용하였다.

욕심으로는 예외가 발생한 클래스와 Line Number 까지 표기해주고 싶었지만, stackTrace를 사용하면 너무 긴 로그가 남게되어 가독성이 떨어지고, 저장 공간에 쌓일 때 낭비가 심할 것 같다는 생각이 들었다.

 

아래에서 CustomException 작동방식에 대해 설명하겠지만, 예외 메시지 + 호출한 API 를 알고있으면 에러 발생위치를 충분히 특정할 수 있다는 점에서 에러 발생 위치까지는 명시하지 않았다.

 

Log 출력 결과
스웨거 호출 결과

 

 

- MethodArgumentNotValidException

 

클라이언트 단에서 Controller 로 넘어온 Param / Body 에 대한 Validation을 수행하기 위해서 스프링에서 제공하는 @Valid 어노테이션을 사용했다.

 

 

해당 어노테이션은 전달 받은 Body / Param 에 대한 Validation을 수행한다.

 

@Getter
public class MemberInsertRequest {
    @Schema(description = "유저 아이디", example = "user1")
    @NotNull
    @Size(min = 8, max = 20, message = "유저 아이디는 8 ~ 20 자리 입니다.")
    String userId;
}

 

위에서는 @Size를 통해 특정 필드의 value의 길이를 정해두었다.

만약 규칙에 위배되게 되면, Spring에서는 MethodArgumentNotValidException 을 발생시키게 된다.

 

이러한 유효성 검사에 대한 예외를 처리해주기 위해서 추가로 핸들러를 등록했다.

 

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ExceptionDto> handleValidException(MethodArgumentNotValidException ex){
        BindingResult rs = ex.getBindingResult();
        List<FieldError> fieldErrors = rs.getFieldErrors();

        StringBuilder errorMessages = new StringBuilder();

        for (int i=0; i < fieldErrors.size(); i++) {
            FieldError fieldError = fieldErrors.get(i);

            errorMessages
                    .append("Parameter : ").append(fieldError.getField())
                    .append(" -> ")
                    .append(fieldError.getDefaultMessage());

        // 마지막 필드일 경우에는 줄바꿈하지 않음
            if (i < fieldErrors.size() - 1) {
                errorMessages.append("\n");
            }
        }

        log.error("=====================================================================");
        log.error("MethodArgumentNotValidException : {}", errorMessages);
        log.error("=====================================================================");

        return new ResponseEntity<>(ExceptionDto.builder()
                .message(errorMessages.toString())
                .statusCode(400)
                .build(),
                HttpStatus.valueOf(400));
    }

 

MethodArgumentNotValidException 이 발생했을 때, 예외 객체에서 해당 필드에 대한 정보를 뽑아내어, 서버 로그에 남기고, 클라이언트에게도 전달할 수 있도록 하여 어느 필드가 유효성 검사에 통과하지 못했는지 확인할 수 있도록 하였다.

 

아래는 예외 발생 시 출력 결과이다.

 

서버 로그

 

스웨거 호출 결과

 

 

후기


사실, 전역 예외처리는 이전 프로젝트에서도 구현을 해본 경험은 있었다.

하지만 알고 사용하기 보다는 블로그에 있는 소스들을 가져다 일정 부분만 고쳐서 사용했고, 왜 전역 예외 처리가 필요한지 느끼지 못하고 사용했었던 것 같다.

어떤 게 좋은 예외 처리일지 고민했다. 디테일한게 좋은 예외가 아닐까?? 하면서도 너무 길어지면 가독성이 떨어졌다. 간결하면서도 핵심적인 내용을 담고있는 예외 처리 메시지가 무엇일까 고민해보면서 많은 공부가 되었던 것 같다.

 

다음은 예외 처리에 더해서 서버에 남을 로그에 대한 설정을 진행해볼 생각이다.