Side Project/SWYP

[SWYP] Spring Boot API 공통 응답 포맷 개발하기

newtownboy 2024. 5. 10. 10:32


[Version]
⦁ 2024.05.10 / [SWYP] Spring Boot API 공통 응답 포맷 개발하기

 

클라이언트와 서버가 통신하는 구조에서, 클라이언트는 서버에 요청을 보내고 서버는 요청에 대한 결과를 응답한다.

@ResponseBody
@GetMapping("/member")
public ResponseEntity<CommonApiResponse<MemberDTO.Response>> getMemberProfileByLoginId(Authentication authentication) {
    String loginId = authentication.getName();
    Member member = memberService.findMemberAndSurveyByLoginId(loginId);

    return member;
}

 

예시와 같이 클라이언트에서 회원의 정보를 요청하는 경우 서버는 현재 로그인한 회원의 정보를 응답하게 된다. API로 통신하는 경우 보통 Json으로 응답하는데, `@ResponseBody` 을 사용하고 객체를 반환하면 `HttpMessageConverter`에 의해 객체가 Json으로 변환된다.

상태 라인(HTTP Version / Status Code) 
헤더(Header)
공백 라인
응답 메세지(Body)

 

하지만 HTTP 응답 메시지에는  실제 전송을 할 데이터뿐만 아니라 HTTP Version, Status Code, Header, Body 등으로 이루어져 있다. 서버에서는 어떻게 데이터 외 HTTP Version, Status Code, Header 등의 정보를 포함하여 클라이언트로 응답할 수 있을까?

 

 ResponseEntity 활용

위 질문에 대한 답으로, `ResponseEntity` 클래스를 활용하여 데이터 외 추가적인 정보를 함께 응답할 수 있다. 먼저 `ResponseEntity` 클래스의 구조부터 확인해 보자!

public class ResponseEntity<T> extends HttpEntity<T> {
    public ResponseEntity(@Nullable T body, @Nullable MultiValueMap<String, String> headers, HttpStatus status) {
        super(body, headers);
        this.status = status;
    }
    
    public ResponseEntity(HttpStatus status) {
        this(null, null, status);
    }
    
    public ResponseEntity(@Nullable T body, HttpStatus status) {
        this(body, null, status);
    }
}

 

위 코드의 생성자를 보면 body, headers, status 세 가지의 파라미터를 가지는 것을 확인할 수 있다. 생성자를 활용하여 인스턴스를 반환하면 객체의 데이터뿐만 아니라 Header, Status Code도 함께 지정하여 응답할 수 있다.

@GetMapping("/member")
public ResponseEntity<Member> getMemberProfileByLoginId(Authentication authentication) {
    String loginId = authentication.getName();
    Member member = memberService.findMemberAndSurveyByLoginId(loginId);

    return ResponseEntity.status(HttpStatus.OK).body(member);
}

 

 

@ResponseBody을 사용한 응답, 항상 같을까?

@ExceptionHandler(StorageException.class)
public ResponseEntity<String> handleStorageException(StorageException e) {
    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}

 

예를 들어 위와 같은 예외가 발생했을 때 응답은 어떻게 전달될까? `e.getMessage()`의 경우 String 형태이기 때문에 `plain/text`로 응답을 전달한다. 이 말은 즉, 동일한 API를 호출해도 예외가 발생했을 때와 그렇지 않을 때의 응답 데이터 모양이 달라지는 것을 의미한다.

 

경우에 따라 응답 데이터의 형식이 달라진다면 클라이언트에서 응답 포맷에 따라 로직을 달리 구성해야 할 것이다. 따라서 모든 경우에 대해 공통된 응답 포맷을 제공해야 할 필요가 있다.

 

공통 API 응답 클래스 생성

모든 경우에 대한 공통 포맷을 생성하기 위해 `CommonApiResponse` 클래스를 생성했다. 내가 응답으로 보낼 Json 구조는 다음과 같다.

/* 성공 응답 포맷 */
{
    "status": "success",
    "data": {
        "email": "newtownboy@naver.com",
        "name": "newtownboy",
        "phone": "010-1234-5678",
        "role": "ADMIN"
    },
    "message": null
}

/* 예외 응답 포맷 */
{
    "status": "error",
    "data": null
    "message": "예외 메세지가 리턴됩니다"
}

/* 데이터 유효성 오류 응답 포맷 */
{
    "status": "fail",
    "data": {
        "email": "이메일 형식이 일치하지 않습니다",
        "name": "이름은 빈 값일 수 없습니다"
    },
    "message": null
}

 

위 포맷을 응답으로 리턴하기 위한 클래스는 아래와 같이 구성할 수 있다.

package com.swyg.oneului.common;

import lombok.Getter;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Getter
public class CommonApiResponse<T> {
    private static final String SUCCESS_STATUS = "success";
    private static final String FAIL_STATUS = "fail";
    private static final String ERROR_STATUS = "error";

    private static final String SUCCESS_MESSAGE = "정상적으로 처리되었습니다.";
    private static final String FAIL_MESSAGE = "유효하지 않은 값을 입력하셨습니다.";
    private static final String ERROR_MESSAGE = "에러가 발생했습니다.";

    private String status;
    private T data;
    private String message;

    public CommonApiResponse() {
    }

    private CommonApiResponse(String status, T data, String message) {
        this.status = status;
        this.data = data;
        this.message = message;
    }

    public static <T> CommonApiResponse<T> createSuccess(T data) {
        return new CommonApiResponse<>(SUCCESS_STATUS, data, SUCCESS_MESSAGE);
    }

    public static CommonApiResponse<?> createSuccessWithNoContent() {
        return new CommonApiResponse<>(SUCCESS_STATUS, null, SUCCESS_MESSAGE);
    }

    public static CommonApiResponse<?> createFail(BindingResult bindingResult) {
        Map<String, String> errors = new HashMap<>();

        List<ObjectError> allErrors = bindingResult.getAllErrors();
        for (ObjectError error : allErrors) {
            if (error instanceof FieldError) {
                errors.put(((FieldError) error).getField(), error.getDefaultMessage());
            } else {
                errors.put( error.getObjectName(), error.getDefaultMessage());
            }
        }
        return new CommonApiResponse<>(FAIL_STATUS, errors, FAIL_MESSAGE);
    }

    public static CommonApiResponse<?> createError(String message) {
        return new CommonApiResponse<>(ERROR_STATUS, null, message);
    }
}

 

`CommonApiResponse` 클래스는 status, data, message 세 가지 필드를 갖는다. 각 필드에 대해 정의하자면 다음과 같다.

  • status: 정상(success), 실패(fail), 예외(error) 중 하나의 값을 갖는다.
  • data: 정상 처리되었을 경우 전송될 데이터를 반환하고, 실패 처리되었을 경우 유효성 검증에 실패한 데이터의 목록을 응답한다.
  • message: 에러가 발생했을 경우 예외 메시지를 응답한다.

 

생성한 `CommonApiResponse` 클래스를 실제 코드에 적용하면 다음과 같이 변경할 수 있다.

@GetMapping("/member")
public ResponseEntity<CommonApiResponse<MemberDTO.Response>> getMemberProfileByLoginId(Authentication authentication) {
    String loginId = authentication.getName();
    Member member = memberService.findMemberAndSurveyByLoginId(loginId);

    return ResponseEntity.status(HttpStatus.OK).body(CommonApiResponse.createSuccess(MemberDTO.Response.of(member)));
}

 

정상, 실패, 예외 상황에서 ResponseEntity 클래스를 통해 각 상황에 적절한 Status Code를 설정하고 Body로 공통 응답 클래스인 `CommonApiResponse`의 인스턴스를 세팅하여 반환하도록 했다. 이로써 각 상황에 따라 일관된 응답 형식을 반환할 수 있게 되었다.