Spring

[개발 일상] Spring 공통 응답 만들기

윤밥밥 2024. 8. 13. 13:45

백엔드 서버에서 클라이언트에게 응답을 보낼 때 응답의 형식을 통일시키는 것은 중요하다.

프론트는 백엔드의 응답을 받아, 이를 처리하여 화면에 보여주게 되는데 응답의 형식이 불분명하면 프론트에서 불편함을 겪을 수 밖에 없다.

그래서 나는 프로젝트를 시작하기 전에 프론트분들과 소통하여 요구사항을 파악하고 공통 응답을 만들었다.

백엔드 요구사항

우선 백엔드 입장에서 생각했던 요구사항은 다음과 같았다.

  • 상태코드(403등)은 header에서 설정한다.
  • body에는 상태메세지(Not Found등), 본문 data, 시간등을 포함시킨다.

프론트엔드 요구사항

반면 프론트엔드 입장은 다음과 같았다.

  • 상태코드를 헤더로 주면 try catch로 처리해야해서 불편하다.
  • 응답에 상태코드와 상태가 둘 다 있으면 좋겠다.

결론적으로 시간,성공여부, 상태, 상태코드, (, 데이터, 에러 이유)등을 포함하기로 했다.

{
  "timestamp": "2024-08-13T03:34:04.190Z",
  "isSuccess": true,
  "status": "NOT_FOUND",
  "code" : 403,
  "data": {
    "id": 0
  }
}

이를 바탕으로 개발을 진행했다.

Response는 ErrorResponseDataRespone 로 구분하였다.

왜냐하면 에러가 날 때는 에러 이유를 포함해야하고,

일반적인 응답에서는 응답 분문(body)를 포함시켜야 했기 때문이다.

서로 관심사가 다르기 때문에 분리를 했고, 공통적인 부분(시간, 성공여부, 상태, 상태코드)은 BaseResponse 라는 추상 클래스로 만들어 이를 상속하도록 했다.

BaseResponse

@Getter
public abstract class BaseResponse {

    @JsonFormat(pattern = "yyyy-MM-dd kk:mm:ss")
    private final LocalDateTime timestamp = LocalDateTime.now();
    private final Boolean isSuccess;
    private final String status;
    private final Integer code;

    protected BaseResponse(Boolean isSuccess, HttpStatus status) {
        this.isSuccess = isSuccess;
        this.status = status.getReasonPhrase();
        this.code = status.value();
    }
}

abstract class

추상 클래스로 만들어, BaseResponse는 메서드를 갖지 못하게 하였으며, 상속하여 사용하게끔 하였다.

timeStamp

    @JsonFormat(pattern = "yyyy-MM-dd kk:mm:ss")
    private final LocalDateTime timestamp = LocalDateTime.now();

시간을 명시하는 필드이다. 객체를 생성한 동시에 시간이 기록되도록 해두었다.

또한 @JsonFormat 을 사용하여 역직렬화할 때 시간의 포맷을 지정해주었다.

만약 별도의 포맷을 지정해주지 않으면 yyyy-MM-dd T kk:mm:ss 와 같이 날짜와 시간 사이에 T가 붙는 형식으로 응답된다.

isSuccess

true or false로 응답하게 하여, 프론트 측에서 성공과 실패에 따라 편하게 처리할 수 있도록 하였다.

status, code

에러가 났을 때 명시적으로 어떤 에러인지 상태 메세지를 보여주는 status와, 프론트가 에러에 따라 로직을 처리하기 쉽게 code를 넣어주었다.

(참고로 code 부분은 http header에도 응답되긴하여, 중복느낌이 날 수 있다.

하지만 헤더를 사용하여 프론트가 에러를 핸들링하려면 try-catch문을 써야 하므로 body에 code를 넣어줘 편하게 핸들링 할 수 있도록 하였다.)

DataResponse

@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class DataResponse<T> extends BaseResponse {
    private final T data;

    private DataResponse(boolean isSuccess, HttpStatus status, T data) {
        super(isSuccess, status);
        this.data = data;
    }

    public static <T> DataResponse<T> from(T data) {
        return new DataResponse<>(true, HttpStatus.OK, data);
    }

    public static DataResponse<?> ok() {
        return new DataResponse<>(true, HttpStatus.OK, null);
    }
}

기본 BaseResponse 에 Data를 추가하였다. 제네릭을 사용하여 여러 타입을 받을 수 있게 하였다.

from 메서드

public static <T> DataResponse<T> from(T data) {
        return new DataResponse<>(true, HttpStatus.OK, data);
    }

DataResponse 로 응답하는 경우에는 성공적으로 응답하는 경우이기에 isSuccess: true , code:200 , status:OK 가 응답되도록한다.

ok 메서드

public static DataResponse<?> ok() {
        return new DataResponse<>(true, HttpStatus.OK, null);
    }

모든 응답은 형식을 맞추기위해 ErrorResponseDataResponse 둘 중 하나만 응답하도록 하였다.

그러다보면 Data가 없는 NoContent응답이 있을 수 있는 데, 이런 경우 ok메서드를 통해 Data에 null을 넣어 응답하도록 하였다.

다만, 프론트에서 Data: null 이렇게 명시되는 것은 보기 안좋으니, 이럴 경우 아예 필드가 보이지 않도록

@JsonInclude(JsonInclude.Include.NON_NULL) 애노테이션을 명시하여 null이 들어간 필드는 응답에서 제외하도록 하였다.

사용

    @GetMapping("/self")
    public DataResponse<PlacesFindResponse> placesFind() {
        String roomId = memberLoader.getRoomId();

        PlacesFindResponse response = placeService.findPlaces(roomId);

        return DataResponse.from(response);
    }

참고로 ResponseEntity를 사용하여 ResponseEntity.status(HttpStatus.NOT_FOUND).body(DataResponse.from(response)) 와 같이 한번 더 응답을 감싸줄 수도 있다.

이럴 경우 응답 헤더의 상태코드를 자유롭게 변경할 수 있다는 장점이 있다.

다만 나는 모든 성공했을 때의 응답 상태코드가 200이고, 기본으로 제공해주는 상태 코드가 200이기에 별도로 ResponseEntity로 감싸주지는 않았다.

ErrorResponse

@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse extends BaseResponse {
    private final String message;
    private final List<String> reasons;

    private ErrorResponse(Boolean isSuccess, HttpStatus status, String message) {
        super(isSuccess, status);
        this.message = message;
        this.reasons = null;
    }

    public ErrorResponse(Boolean isSuccess, HttpStatus status, String message, List<String> reasons) {
        super(isSuccess, status);
        this.message = message;
        this.reasons = reasons;
    }

    public static ErrorResponse of(ErrorCode errorCode, List<String> reasons) {
        Boolean isSuccess = false;
        HttpStatus status = errorCode.getHttpStatus();
        String message = errorCode.getMessage();

        return new ErrorResponse(isSuccess, status, message, reasons);
    }

    public static ErrorResponse from(ErrorCode errorCode) {
        Boolean isSuccess = false;
        HttpStatus status = errorCode.getHttpStatus();
        String message = errorCode.getMessage();

        return new ErrorResponse(isSuccess, status, message);
    }
}

private final String message;

이 부분은 해당하는 회원을 찾을 수 없습니다 와 같이 개발자가 명시한 에러 원인을 넣어주기 위한 필드이다.

private final List reasons;

이 부분은 여러 에러 원인을 명시하기 위한 필드이다.

일반적인 에러에서는 reasons가 사용되지 않고, message만 명시된다.

하지만 validation의 경우 한번에 여러 예외가 발생할 수 있다.

아래 코드를 보면 이해할 수 있다.

    @Getter
    @NoArgsConstructor(access = AccessLevel.PRIVATE)
    public static class PlaceSaveOrUpdateRequest {

        @NotBlank(message = "siDo는 비어 있을 수 없습니다.")
        private String siDo;
        @NotBlank(message = "siGunGu는 비어 있을 수 없습니다.")
        private String siGunGu;
        @NotBlank(message = "roadNameAddress는 비어 있을 수 없습니다.")
        private String roadNameAddress;
        @NotNull
        @Positive(message = "addreesLat은 양수이어야 합니다.")
        private Double addressLat;
        @NotNull
        @Positive(message = "addreesLong은 양수이어야 합니다.")
        private Double addressLong;
    }

이 Request가 올 때 addressLataddressLong 이 동시에 양수가 아닌 경우가 있을 수 있고, 이런 경우
MethodArgumentNotValidException 이 발생하게 된다.

이 예외는 핸들러에서 캐치하여 에러 응답을 반환하게 하였다.

// Exception Handler(RestControllerAdvice) 일부분, validation 에러를 캐치하여 예외를 핸들링 해준다.
@Override
    public ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException e,
        HttpHeaders headers,
        HttpStatusCode status,
        WebRequest request) {
        log.warn("handleIllegalArgument", e);

        List<String> messages = e.getBindingResult().getFieldErrors() // 이부분
            .stream()
            .map(ex -> ex.getDefaultMessage())
            .collect(Collectors.toList());

        ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;
        return makeErrorResponseEntity(errorCode, messages);
    }

MethodArgumentNotValidException 에는 애러가 발생한 이유를 List로 갖고 있어 위와 같이 메세지들을 빼낼 수 있다.

이렇게 얻은 에러 원인들을 ErrorResponse 에 넣어주기 위해 reasons 필드를 추가했다.

ErrorCode

참고로 나는 ErrorResponseErrorCode를 받아 만들 수 있게 해두었다.

ErrorCode 는 아래와 같다.

@Getter
public enum ErrorCode {

    MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당하는 회원을 찾을 수 없습니다."),
    ;

    private final HttpStatus httpStatus;
    private final String message;

    ErrorCode(HttpStatus httpStatus, String message) {
        this.httpStatus = httpStatus;
        this.message = message;
    }
}

(이 Error Code에 대해 자세히 알고 싶으면 ExceptionHandler 생성 포스트 글을 참고하며 될 거 같다.)