[Spring] API 응답통일 & 에러 핸들링

박도연·2024년 10월 10일

Spring

목록 보기
2/7
post-thumbnail

API 응답통일

팀 프로젝트를 진행할 때, API마다 응답 형식이 다르다면, 프론트 측에서 혼란스러울 것이다. 이를 방지하기 위해 API 응답 통일을 해준다.

API 응답은 다음과 같은 형식을 따른다

{
	isSuccess : Boolean
	code : String
	message : String
	result : {응답으로 필요한 또 다른 json} //실패한 경우(isSuccess : false) null값 반환
}

파일 구조

파일 구조는 프로젝트 파일 안에 위와 같은 형식으로 만들어주었다.

status(ErrorStatus, SuccessStatus) : 응답 상세enum 작성

BaseCode, BaseErrorCode : 인터페이스, status에 있는 파일들은 이 메소드를 반드시 override할 것

ErrorReasonDTO, ReasonDTO : 응답형식 DTO

ApiResponse : API응답에 대한 클래스 (응답 형식 지정)

*exception은 아래 에러핸들링에서 다룰 예정이다.


ApiResponse

API 응답 형식을 지정해주는 클래스이다.

@Getter
@AllArgsConstructor
@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) //JSON 속성의 순서 지정
public class ApiResponse<T> {

    @JsonProperty("isSuccess")  //JSON에서 해당 속성의 이름 정의
    private final Boolean isSuccess;
    
    private final String code;
    private final String message;
    
    @JsonInclude(JsonInclude.Include.NON_NULL)  //NULL이 아닐때만 result 반환, JSON에 포함 여부 결정(ALWAYS, NON_NULL, ABSENT, NON_EMPTY)
    private T result;

    // 성공한 경우
    public static <T> ApiResponse<T> onSuccess(T result) {
        return new ApiResponse<>(true, SuccessStatus._OK.getCode(), SuccessStatus._OK.getMessage(), result);
    }

	//특정 성공 상태 코드가 필요할 때
    public static <T> ApiResponse<T> of(BaseCode code, T result){
        return new ApiResponse<>(true, code.getReasonHttpStatus().getCode() , code.getReasonHttpStatus().getMessage(), result);
    }

    // 실패한 경우
    public static <T> ApiResponse<T> onFailure(String code, String message, T data) {
        return new ApiResponse<>(false, code, message, data);
    }

    public static <T> ApiResponse<T> ofFailure(BaseErrorCode code, T result) {
        return new ApiResponse<>(false, code.getReasonHttpStatus().getCode(), code.getReasonHttpStatus().getMessage(), result);
    }
}

ErrorStatus

@Getter
@AllArgsConstructor
public enum ErrorStatus implements BaseErrorCode {

    //일반적인 응답
    _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."),
    _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."),
    _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다. 로그인 정보를 확인해주세요."),
    _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."),
    _NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "요청한 리소스를 찾을 수 없습니다"),
   
    //Member 에러
    MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4001", "해당하는 사용자를 찾을 수 없습니다.");

    private final HttpStatus httpStatus;
    private final String code;
    private final String message;

    @Override
    public ErrorReasonDTO getReason() {
        return ErrorReasonDTO.builder()
                .message(message)
                .code(code)
                .isSuccess(false)
                .build();
    }

    @Override
    public ErrorReasonDTO getReasonHttpStatus() {
        return ErrorReasonDTO.builder()
                .message(message)
                .code(code)
                .isSuccess(false)
                .httpStatus(httpStatus)
                .build();
    }

}

errorCode를 설정할 때, 어떤 파트의 에러인지 작성해주면 프론트측에서 알아보기 쉽다. 예를 들어 멤버와 관련된 에러이면, MEMBER4000,MEMBER4001...이런식으로 뒤에 숫자를 순서대로 붙여주었다.

SuccessStatus

@Getter
@AllArgsConstructor
public enum SuccessStatus implements BaseCode {

    //일반적인 응답
    _OK(HttpStatus.OK, "COMMON200", "성공입니다.");

    private final HttpStatus httpStatus;
    private final String code;
    private final String message;

    @Override
    public ReasonDTO getReason() {
        return ReasonDTO.builder()
                .message(message)
                .code(code)
                .isSuccess(true)
                .build();
    }

    @Override
    public ReasonDTO getReasonHttpStatus() {
        return ReasonDTO.builder()
                .message(message)
                .code(code)
                .isSuccess(true)
                .httpStatus(httpStatus)
                .build();
    }

}

ReasonDTO, ErrorReasonDTO

@Getter
@Builder
public class ReasonDTO {

    private HttpStatus httpStatus;

    private final boolean isSuccess;
    private final String code;
    private final String message;

}

ReasonDTO, ErrorReasonDTO 둘 다 코드가 똑같아서 그냥 ReasonDTO 하나로 통일시켜주어도 될 것 같다. (아래 ErrorReasonDTO를 ReasonDTO로 모두 변경)


BaseCode

public interface BaseCode {

    public ReasonDTO getReason();  //일반적인 이유 반환

    public ReasonDTO getReasonHttpStatus(); //HTTP 상태 코드와 함께 이유를 반환
}

BaseErrorCode

public interface BaseErrorCode {

    public ErrorReasonDTO getReason();

    public ErrorReasonDTO getReasonHttpStatus();
}

이렇게 하면 ApiResponse는 완성이다.


에러 핸들러

SpringBoot에서 발생하는 예외와 위 ErrorStatus처럼 커스텀 해준 예외를 처리해준다.

파일 구조

GeneralException : exception의 종류를 지정해줌

ExceptionAdvice : Spring Boot 애플리케이션에서 발생하는 예외를 전역적으로 처리하기 위한 클래스

ExceptionHandler : 특정한 예외를 처리하기 위한 커스텀 예외 클래스


GeneralException

@Getter
@AllArgsConstructor
public class GeneralException extends RuntimeException{
    private BaseErrorCode code;   //Api 응답 통일 시 작성해주었던 BaseErrorCode

    public ErrorReasonDTO getErrorReason() {
        return this.code.getReason();
    }

    public ErrorReasonDTO getErrorReasonHttpStatus(){
        return this.code.getReasonHttpStatus();
    }
}

ExceptionAdvice

@RestControllerAdvice

: Spring의 어노테이션으로, RestController에서 발생하는 예외를 처리하는 데 사용. annotations = {RestController.class}는 이 어드바이스가 @RestController가 붙은 클래스에서만 작동하도록 지정(@RestController는 컨트롤러에 붙음)

@Slf4j
@RestControllerAdvice(annotations = {RestController.class})
public class ExceptionAdvice extends ResponseEntityExceptionHandler{

    //유효성 검사에서 제약 조건이 위반되었을 때 발생하는 예외
    @org.springframework.web.bind.annotation.ExceptionHandler
    public ResponseEntity<Object> validation(ConstraintViolationException e, WebRequest request) {
        String errorMessage = e.getConstraintViolations().stream()
                .map(constraintViolation -> constraintViolation.getMessage())
                .findFirst()
                .orElseThrow(() -> new RuntimeException("ConstraintViolationException 추출 도중 에러 발생"));

        return handleExceptionInternalConstraint(e, ErrorStatus.valueOf(errorMessage), HttpHeaders.EMPTY,request);
    }

    //메서드 인자가 유효하지 않을 때 발생
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) {

        Map<String, String> errors = new LinkedHashMap<>();

        e.getBindingResult().getFieldErrors().stream()
                .forEach(fieldError -> {
                    String fieldName = fieldError.getField();
                    String errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse("");
                    errors.merge(fieldName, errorMessage, (existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage);
                });

        return handleExceptionInternalArgs(e,HttpHeaders.EMPTY,ErrorStatus.valueOf("_BAD_REQUEST"),request,errors);
    }

    //일반적인 예외 처리
    @org.springframework.web.bind.annotation.ExceptionHandler
    public ResponseEntity<Object> exception(Exception e, WebRequest request) {
        e.printStackTrace();

        return handleExceptionInternalFalse(e, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(),request, e.getMessage());
    }

    @ExceptionHandler(value = GeneralException.class)
    public ResponseEntity onThrowException(GeneralException generalException, HttpServletRequest request) {
        ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus();
        return handleExceptionInternal(generalException,errorReasonHttpStatus,null,request);
    }

    //예외 처리 기본 로직
    private ResponseEntity<Object> handleExceptionInternal(Exception e, ErrorReasonDTO reason,
                                                           HttpHeaders headers, HttpServletRequest request) {

        ApiResponse<Object> body = ApiResponse.onFailure(reason.getCode(),reason.getMessage(),null);

        WebRequest webRequest = new ServletWebRequest(request);
        return super.handleExceptionInternal(
                e,
                body,
                headers,
                reason.getHttpStatus(),
                webRequest
        );
    }

    private ResponseEntity<Object> handleExceptionInternalFalse(Exception e, ErrorStatus errorCommonStatus,
                                                                HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) {
        ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorPoint);
        return super.handleExceptionInternal(
                e,
                body,
                headers,
                status,
                request
        );
    }

    private ResponseEntity<Object> handleExceptionInternalArgs(Exception e, HttpHeaders headers, ErrorStatus errorCommonStatus,
                                                               WebRequest request, Map<String, String> errorArgs) {
        ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorArgs);
        return super.handleExceptionInternal(
                e,
                body,
                headers,
                errorCommonStatus.getHttpStatus(),
                request
        );
    }

    private ResponseEntity<Object> handleExceptionInternalConstraint(Exception e, ErrorStatus errorCommonStatus,
                                                                     HttpHeaders headers, WebRequest request) {
        ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), null);
        return super.handleExceptionInternal(
                e,
                body,
                headers,
                errorCommonStatus.getHttpStatus(),
                request
        );
    }


}

ExceptionHandler

public class ExceptionHandler extends GeneralException {
    public ExceptionHandler(BaseErrorCode code) {
        super(code);
    }
}

BaseErrorCode 객체를 매개변수로 받아, 상위 클래스인 GeneralException의 생성자를 호출한다. super(code);를 통해 ExceptionHandler클래스는 GeneralException과 동일한 방식으로 예외를 처리한다.

사용 예시

Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new ExceptionHandler(MEMBER_NOT_FOUND))  
                //new ExceptionHandler(ErrorStatus에 있는 에러enum값)

이렇게 API 응답형식 통일과 예외 처리를 해주었다 !

<참고사이트>
https://velog.io/@ddeo99/Spring-Boot-API-응답-통일-Error-Handling
https://velog.io/@koojun99/SpringBoot-API-응답-통일과-예외-처리

profile
도여줄게 완전히 도라진 나

0개의 댓글