백엔드 서버에서 클라이언트에게 응답을 보낼 때 응답의 형식을 통일시키는 것은 중요하다.
프론트는 백엔드의 응답을 받아, 이를 처리하여 화면에 보여주게 되는데 응답의 형식이 불분명하면 프론트에서 불편함을 겪을 수 밖에 없다.
그래서 나는 프로젝트를 시작하기 전에 프론트분들과 소통하여 요구사항을 파악하고 공통 응답을 만들었다.
백엔드 요구사항
우선 백엔드 입장에서 생각했던 요구사항은 다음과 같았다.
- 상태코드(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는 ErrorResponse
와 DataRespone
로 구분하였다.
왜냐하면 에러가 날 때는 에러 이유를 포함해야하고,
일반적인 응답에서는 응답 분문(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);
}
모든 응답은 형식을 맞추기위해 ErrorResponse
와 DataResponse
둘 중 하나만 응답하도록 하였다.
그러다보면 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가 올 때 addressLat
과 addressLong
이 동시에 양수가 아닌 경우가 있을 수 있고, 이런 경우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
참고로 나는 ErrorResponse
는 ErrorCode
를 받아 만들 수 있게 해두었다.
이 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 생성 포스트 글을 참고하며 될 거 같다.)
'Spring' 카테고리의 다른 글
Spring에서의 프로세스와 스레드 (0) | 2024.10.21 |
---|---|
Spring에서 @Async를 통한 비동기 처리하기 (0) | 2024.09.26 |
Spring에서의 동기, 비동기 (1) | 2024.09.26 |
java PageImpl에서 totalElements가 바뀌는 오류 해결 (0) | 2024.08.13 |
Spring 자바, 스프링에서 객체 응답시 is로 시작하는 변수가 변경되는 문제 (0) | 2024.05.25 |