Exception Handling과 Response 코드 개선

라모스·2022년 4월 4일
0

2022 캡스톤디자인

목록 보기
2/2
post-thumbnail

Intro

지난 작업 기록에선 Postman에 나타나는 Response Body 내용에 대한 개선점이 필요했었다. 이번 작업에선 이를 해결하기 위해 일관성 있는 코드 스타일을 유지하면서 Exception을 처리하고, 더 나아가 API의 Response 형태 또한 다듬고자 하였다.

Exception Handling

Spring에서 제공하는 @RestControllerAdvice, @ExceptionHandler를 활용하여 API 예외 처리를 하였다.

목표점은 다음과 같은 형식들이다.

  • 일관된 ErrorResponse 반환
{
    "status": 400,
    "code": "C005",
    "message": "bad credentials",
    "errors": []
}
{
    "status": 400,
    "code": "C002",
    "message": "invalid input type",
    "errors": [
        {
            "field": "email",
            "value": "test",
            "reason": "이메일의 형식이 맞지 않습니다."
        }
    ]
}

구성한 도메인을 통틀어 공통적으로 처리해야 하기 때문에 다음과 같은 패키지 구조로 만들었다.

ErrorResponse

ErrorResponse는 다음과 같다. 필드를 보면 JSON으로 반환되는 Response의 형식과 맞게 되어있다.
여기서 중요한 점은, 기본적인 생성자 대신 정적 팩터리 메서드를 활용했기 때문에 입력 매개변수에 따라 유연하게 ErrorResponse 객체를 반환할 수 있다는 점이다. 이는 다양한 예외 처리에 대해 대응하기 위한 것이다.

Effective Java에서 가장 처음 나오는 item으로 정적 팩터리 메서드의 내용이 나오는데 해당 내용은 이전에 정리했던 아이템 1. 생성자 대신 정적 팩터리 메서드를 고려하라를 참고하자.

package me.ramos.WaitForm.global.error;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.validation.BindingResult;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import javax.validation.ConstraintViolation;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

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

    private int status;
    private String code;
    private String message;
    private List<FieldError> errors;

    private ErrorResponse(final ErrorCode code, final List<FieldError> errors) {
        this.status = code.getStatus();
        this.code = code.getCode();
        this.message = code.getMessage();
        this.errors = errors;
    }

    private ErrorResponse(final ErrorCode code) {
        this.status = code.getStatus();
        this.code = code.getCode();
        this.message = code.getMessage();
        this.errors = new ArrayList<>();
    }

    public static ErrorResponse of(final ErrorCode code, final BindingResult bindingResult) {
        return new ErrorResponse(code, FieldError.of(bindingResult));
    }

    public static ErrorResponse of(final ErrorCode code, final Set<ConstraintViolation<?>> constraintViolations) {
        return new ErrorResponse(code, FieldError.of(constraintViolations));
    }

    public static ErrorResponse of(final ErrorCode code, final String missingParameterName) {
        return new ErrorResponse(code, FieldError.of(missingParameterName, "", "parameter must required"));
    }

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

    public static ErrorResponse of(final ErrorCode code, final List<FieldError> errors) {
        return new ErrorResponse(code, errors);
    }

    public static ErrorResponse of(MethodArgumentTypeMismatchException e) {
        final String value = e.getValue() == null ? "" : e.getValue().toString();
        final List<FieldError> errors = FieldError.of(e.getName(), value, e.getErrorCode());
        return new ErrorResponse(ErrorCode.INVALID_TYPE_VALUE, errors);
    }


    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public static class FieldError {
        private String field;
        private String value;
        private String reason;

        public FieldError(String field, String value, String reason) {
            this.field = field;
            this.value = value;
            this.reason = reason;
        }

        public static List<FieldError> of(final String field, final String value, final String reason) {
            List<FieldError> fieldErrors = new ArrayList<>();
            fieldErrors.add(new FieldError(field, value, reason));
            return fieldErrors;
        }

        public static List<FieldError> of(final BindingResult bindingResult) {
            final List<org.springframework.validation.FieldError> fieldErrors = bindingResult.getFieldErrors();
            return fieldErrors.stream()
                    .map(error -> new FieldError(
                            error.getField(),
                            error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(),
                            error.getDefaultMessage()
                    ))
                    .collect(Collectors.toList());
        }

        public static List<FieldError> of(final Set<ConstraintViolation<?>> constraintViolations) {
            List<ConstraintViolation<?>> lists = new ArrayList<>(constraintViolations);
            return lists.stream()
                    .map(error -> new FieldError(
                            error.getPropertyPath().toString(),
                            "",
                            error.getMessageTemplate()
                    ))
                    .collect(Collectors.toList());
        }
    }

}

of라는 정적 팩터리 메소드들은 여러가지 상황에 대응할 수 있다. 대표적으로 다음과 같다.

  • javax.validation에서 제공하는 @Valid나 Spring Boot에서 제공하는 @Validated를 통해 검증처리 시 binding error가 발생하는 경우
  • 일반적인 예외 핸들링
  • 데이터 유효성 검사 실패 시 발생하는 예외에서 실패 정보를 담고 있는 ConstraintViolation를 가져오는 경우

Error Code 정의

package me.ramos.WaitForm.global.error;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum ErrorCode {

    // Common
    INTERNAL_SERVER_ERROR(500, "C001", "internal server error"),
    INVALID_INPUT_VALUE(400, "C002", "invalid input type"),
    METHOD_NOT_ALLOWED(405, "C003", "method not allowed"),
    INVALID_TYPE_VALUE(400, "C004", "invalid type value"),
    BAD_CREDENTIALS(400, "C005", "bad credentials"),

    // Member
    MEMBER_NOT_EXIST(404, "M001", "member not exist"),
    USER_EMAIL_ALREADY_EXISTS(400, "M002", "user email already exists"),
    NO_AUTHORITY(403, "M003", "no authority"),
    NEED_LOGIN(401, "M004", "need login"),
    AUTHENTICATION_NOT_FOUND(401, "M005", "Security Context에 인증 정보가 없습니다."),
    MEMBER_ALREADY_LOGOUT(400, "M006", "member already logout"),

    // Auth
    REFRESH_TOKEN_INVALID(400, "A001", "refresh token invalid");

    private int status;
    private final String code;
    private final String message;
}

Enum 타입으로 위와 같이 한 곳에서 에러 코드를 관리하였다. 이는 에러 코드가 도메인 전체적으로 흩어져있을 경우, 코드 및 메시지의 중복이 발생하기 때문에 이를 해결 하기 위한 가장 효율적인 방법이다.

GlobalExceptionHandler

Spring에서 제공하는 @RestControllerAdvice, @ExceptionHandler의 막강한 기능을 활용하여 모든 예외를 한 곳에서 처리할 수 있다. @ControllerAdvice@ExceptionHandler, @ModelAttribute, @InitBinder가 적용된 메소드들을 AOP를 적용해 컨트롤러 단에 적용하기 위해 고안된 애너테이션이다.

package me.ramos.WaitForm.global.error;

import lombok.extern.slf4j.Slf4j;
import me.ramos.WaitForm.global.error.exception.BusinessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.validation.BindException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import static me.ramos.WaitForm.global.error.ErrorCode.*;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler
    protected ResponseEntity<ErrorResponse> handleBadCredentialException(BadCredentialsException e) {
        final ErrorResponse response = ErrorResponse.of(BAD_CREDENTIALS);
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler
    protected ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
        final ErrorResponse response = ErrorResponse.of(e);
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler
    protected ResponseEntity<ErrorResponse> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
        final ErrorResponse response = ErrorResponse.of(METHOD_NOT_ALLOWED);
        return new ResponseEntity<>(response, HttpStatus.METHOD_NOT_ALLOWED);
    }

    // @Valid, @Validated 에서 binding error 발생 시 (@RequestBody)
    @ExceptionHandler
    protected ResponseEntity<ErrorResponse> handleBindException(BindException e) {
        final ErrorResponse response = ErrorResponse.of(INVALID_INPUT_VALUE, e.getBindingResult());
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }

    // 비즈니스 요구사항에 따른 Exception
    @ExceptionHandler
    protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        final ErrorCode errorCode = e.getErrorCode();
        final ErrorResponse response = ErrorResponse.of(errorCode, e.getErrors());
        return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
    }

    // 그 밖에 발생하는 모든 예외처리가 이곳으로 모인다.
    @ExceptionHandler(Exception.class)
    protected ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("Exception: ", e);
        final ErrorResponse response = ErrorResponse.of(INTERNAL_SERVER_ERROR);
        return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

Business Exception

RuntimeException을 상속받아 최상위에 BusinessException을 정의해두면 아래와 같이 구체적인 Exception에 대해 예외 처리를 통일감 있게 할 수 있다.

package me.ramos.WaitForm.global.error.exception;

import lombok.Getter;
import me.ramos.WaitForm.global.error.ErrorCode;
import me.ramos.WaitForm.global.error.ErrorResponse;

import java.util.ArrayList;
import java.util.List;

@Getter
public class BusinessException extends RuntimeException {

    private ErrorCode errorCode;
    private List<ErrorResponse.FieldError> errors = new ArrayList<>();

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

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

    public BusinessException(ErrorCode errorCode, List<ErrorResponse.FieldError> errors) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
        this.errors = errors;
    }
}

BusinessException을 상속받은 세부적인 예외들은 도메인 내에서 예외가 발생할만한 부분마다 디테일하게 처리할 수 있다는 장점이 있다. 다만, 해당 예외 케이스들을 직접 한땀한땀 정의해야 하기 때문에 클래스가 많아질 수 있다는 점이 단점이라 할 수 있다.

다음은 인가 처리에서 발생하는 예외를 처리하기 위한 클래스를 정의한 코드이다. 다른 디테일한 예외 클래스들도 이와 같은 형식으로 정의하면 된다.

package me.ramos.WaitForm.global.error.exception;

import me.ramos.WaitForm.global.error.ErrorCode;

public class AuthenticationNotFoundException extends BusinessException {
    public AuthenticationNotFoundException() {
        super(ErrorCode.AUTHENTICATION_NOT_FOUND);
    }
}

// 결론적으론 서버 내부 코드에서 예외가 발생할 여지가 있다면 Exception을 발생시키고 위 처럼 미리 정의한 예외 핸들링을 탈 수 있도록 설계하는 방식이다.

API Response

이번에는 응답 결과를 다음과 같은 형식으로 일관적으로 만들고자 한다.

  • 일관된 ResultResponse 반환
{
    "status": 200,
    "code": "M001",
    "message": "회원가입 되었습니다.",
    "data": {
        "email": "test@test.com",
        "nickname": "test"
    }
}

앞 서 Error Code, Error Response를 정의한 바와 비슷하게 Result Code, Result Response를 정의하면 된다.

Result Response

package me.ramos.WaitForm.global.result;

import lombok.Getter;

@Getter
public class ResultResponse {

    private int status;
    private String code;
    private String message;
    private Object data;

    public static ResultResponse of(ResultCode resultCode, Object data) {
        return new ResultResponse(resultCode, data);
    }

    public ResultResponse(ResultCode resultCode, Object data) {
        this.status = resultCode.getStatus();
        this.code = resultCode.getCode();
        this.message = resultCode.getMessage();
        this.data = data;
    }
}

Result Code

package me.ramos.WaitForm.global.result;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum ResultCode {

    // Member
    REGISTER_SUCCESS(200, "M001", "회원가입 되었습니다."),
    LOGIN_SUCCESS(200, "M002", "로그인 되었습니다."),
    REISSUE_SUCCESS(200, "M003", "재발급 되었습니다."),
    LOGOUT_SUCCESS(200, "M004", "로그아웃 되었습니다."),
    GET_MY_INFO_SUCCESS(200, "M005", "내 정보 조회 완료");

    private int status;
    private final String code;
    private final String message;
}

도메인 내에 있는 API마다 응답 결과를 위와 같이 세부적으로 정의할 수 있다.
Controller 계층에선 다음과 같이 앞서 정의한 ResultResponseResponseEntity에 담아 반환하면 된다.

@PostMapping("/signup")
public ResponseEntity<ResultResponse> signup(@Valid @RequestBody MemberRegisterRequestDto memberRegisterRequestDto) {
    MemberResponseDto memberResponseDto = authService.signup(memberRegisterRequestDto);
    ResultResponse result = ResultResponse.of(ResultCode.REGISTER_SUCCESS, memberResponseDto);
    return new ResponseEntity<>(result, HttpStatus.valueOf(result.getStatus()));
}

Postman 테스트 결과

Postman을 통해 API를 테스트하면 최종적으로 일관된 형식으로 결과 정보가 반환된다.

회원가입

로그인 - Validation 예외

로그인 - 정상 응답

로그인 상태에서 내 정보 보기

다음 포스팅에선?

이번 작업까진 회원 도메인의 JWT 인증/인가와 모든 도메인에 공통으로 적용될 응답/예외 코드 및 처리를 다루었다.
다음 포스팅에선 Swagger를 통해 REST API Docs를 어떻게 생성했는지에 대해 작성하고자 한다.

References

profile
Step by step goes a long way.

0개의 댓글