스프링 API 공통 응답 포맷 개발하기

Belluga·2022년 3월 10일
19

클라이언트 ↔︎ 서버 구조

클라이언트 ↔︎ 서버 구조에서
클라이언트는 서버에 요청을 보내고 서버는 요청에 대한 결과를 응답합니다.

@GetMapping("/products/{id}")
@ResponseBody
public Product getById(@PathVariable("id") final long id) {
    return productService.getById(id);
}

예를 들어 클라이언트가 1번 상품을 요청하는 경우 서버는 1번 상품을 조회해 응답하게 됩니다.
일반적인 API 통신의 경우 주로 Json으로 응답하게 되는데 @ResponseBody 어노테이션을 사용하고 객체를 반환하면 HttpMessageConverter에 의해 객체가 Json으로 변환됩니다.

그러나 HTTP 응답 메시지에는 Message body(실제 전송할 데이터)뿐만 아니라
HTTP Version, HTTP 상태 코드, HTTP 헤더, Message body 등으로 이루어져있습니다.

애플리케이션에서 데이터 외 적절한 HTTP 상태 코드, 헤더 등의 정보를 어떻게 응답할 수 있을까요?

ResponseEntity

HttpStatus, HttpHeaders, HttpBody 데이터를 갖는 ResponseEntity 클래스를 활용할 수 있습니다.

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

body, headers, status를 파라미터로 갖는 생성자가 존재합니다.

@GetMapping("/products/{id}")
@ResponseBody
public ResponseEntity<Product> getById(@PathVariable("id") final long id) {

	Product product = productService.getById(id);
    HttpHeaders header = new HttpHeaders();
    header.add("desc", "popular product");
    return new ResponseEntity<Product>(product, header, HttpStatus.OK);
}

위 생성자를 통해 생성한 ResponseEntity 클래스의 인스턴스를 반환하면
Json 데이터 뿐만 아니라 HTTP 헤더, 상태 코드를 함께 지정하여 응답할 수 있습니다.


public ResponseEntity(HttpStatus status) {
	this(null, null, status);
}
public ResponseEntity(@Nullable T body, HttpStatus status) {
	this(body, null, status);
}

다양한 생성자가 있기 때문에 응답 데이터가 없는 케이스, Http Header를 반환하지 않는 케이스 등 상황에 맞는 생성자를 선택해 사용할 수 있습니다.


@GetMapping("/products/{id}")
@ResponseBody
public ResponseEntity<Product> getById(@PathVariable("id") final long id) {

	Product product = productService.getById(id);
    return ResponseEntity.ok(product);
}

ResponseEntity를 매번 생성자로 생성할 필요 없이 static 메서드를 사용할수도 있습니다.


@GetMapping("/products/{id}")
@ResponseBody
public ResponseEntity<Product> getById(@PathVariable("id") final long id) {

	Product product = productService.getById(id);
    HttpHeaders header = new HttpHeaders();
    header.add("desc", "popular product");
    return ResponseEntity.status(HttpStatus.OK).headers(header).body(product);
}
@GetMapping("/products/{id}")
@ResponseBody
public ResponseEntity<Product> getById(@PathVariable("id") final long id) {

    return ResponseEntity.ok().build();
}

Builder를 통해 객체를 생성하는 방법도 존재합니다.

ResponseBody 항상 좋을까?

그렇다고 ResponseEntity를 사용하는 것이 무조건적인 답이 될수는 없을것 같습니다.

@RestControllerAdvice(basePackages = {"com.project.jagoga.user.presentation.controller"})
public class UserExceptionHandler {

    @ExceptionHandler(DuplicatedUserException.class)
    public ResponseEntity<String> handleDuplicatedUserException(RuntimeException exception) {
        return ResponseEntity.status(HttpStatus.CONFLICT).body(exception.getMessage());
    }
}

예를 들어 위와 같은 예외가 발생하는 경우 응답 body로 plain/text가 전달됩니다.

즉 예외가 발생했을 때, 성공했을 때 응답의 모양이 달라집니다.

이렇게 경우에 따라 응답 데이터의 형식이 달라진다면 해당 응답을 전달받는 주체에게 사용하기 어려운 데이터가 될 수 있을것이라 생각합니다.

따라서 성공했을 때 형식과 실패했을 때 응답형식을 항상 json으로 통일시킬 필요가 있습니다.

공통 응답 클래스 ApiResponse

정상 및 에러에 대한 응답을 동일한 공통포맷을 사용하여 처리하는것을 목적으로 ApiResponse 클래스를 만들었습니다.

1) 정상 응답 예시

{
  "status":"success",
  
  "data":{"email":"owner@naver.com","name":"arin","phone":"010-4321-4321","role":"사장님"},
  
  "message":null
}

2) 예외 응답 예시

{
  "status":"error",
  
  "data":null,
  
  "message":"아이디가 존재하지 않거나 비밀번호가 일치하지 않습니다"
}

3) 데이터 유효성 오류 응답 예시

{
    "status": "fail",
  
    "data": {
        "phone": "000-0000-0000'과 같은 형식을 맞추어야합니다",
        "email": "이메일은 빈 값일 수 없습니다"
    },
  
    "message": null
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ApiResponse<T> {

    private static final String SUCCESS_STATUS = "success";
    private static final String FAIL_STATUS = "fail";
    private static final String ERROR_STATUS = "error";

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

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

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

    // Hibernate Validator에 의해 유효하지 않은 데이터로 인해 API 호출이 거부될때 반환
    public static ApiResponse<?> 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 ApiResponse<>(FAIL_STATUS, errors, null);
    }

    // 예외 발생으로 API 호출 실패시 반환
    public static ApiResponse<?> createError(String message) {
        return new ApiResponse<>(ERROR_STATUS, null, message);
    }

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

ApiResponse 클래스는 status, data, message 세가지 필드를 갖습니다.
status : 정상(success), 예외(error), 오류(fail) 중 한 값을 갖습니다.
data : 정상(success)의 경우 실제 전송될 데이터를, 오류(fail)의 경우 유효성 검증에 실패한 데이터의 목록을 응답합니다.
message : 예외(error)의 경우 예외 메시지를 응답합니다.

data 필드에는 어떤 타입도 처리할 수 있도록 제네릭을 사용하였습니다.


생성한 ApiResponse 클래스는 아래와 같이 사용할 수 있습니다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;
    private final Authentication authentication;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ApiResponse<UserResponseDto> signUp(@Valid @RequestBody final UserCreateRequestDto userCreateRequestDto) {
        User user = userService.signUp(userCreateRequestDto);
        return ApiResponse.createSuccess(UserResponseDto.createInstance(user));
    }

정상 응답의 경우
반환 타입을 ResponseEntity<ApiResponse<UserResponseDto>>로 설정할 수 있지만,
대부분의 경우 200 상태코드를 응답하는 경우가 많을 것 입니다.

별도의 처리가 없는 경우 default로 200 상태코드를 반환하기 때문에
return Type을 ApiResponse<UserResponseDto> 으로 지정하고 static 메서드를 사용해 생성한 인스턴스를 반환하였습니다.

특별한 상태코드를 응답해주고 싶은 경우 위와 같이 @ResponseStatus 어노테이션을 사용할 수 있습니다.

@RestControllerAdvice(basePackages = {"com.project.jagoga"})
public class UserExceptionHandler {

    @ExceptionHandler(DuplicatedUserException.class)
    public ResponseEntity<ApiResponse<?>> handleDuplicatedUserException(RuntimeException exception) {
        return ResponseEntity.status(HttpStatus.CONFLICT).body(ApiResponse.createError(exception.getMessage()));
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<?>> handleValidationExceptions(BindingResult bindingResult) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.createFail(bindingResult));
    }

예외 발생의 경우 & 데이터 유효성 오류의 경우
ResponseEntity 클래스를 통해 각 예외에 따른 적절한 Http 상태 코드를 설정하고 body로 공통 응답 클래스인 ApiResponse의 인스턴스를 셋팅하여 반환하도록 하였습니다.

마치며..

포스팅을 하며 다시 돌이켜보니, 개선해야 할 점들이 보여 아쉬움이 많이 남습니다.
bindingResult 처리를 위해 예외 응답, 데이터 유효성 오류 응답 status를 따로 구분하였으나 사실 데이터 유효성 오류 또한 예외의 일종이라고 볼 수 있습니다.

또한 status 자체 또한 응답 데이터를 전달받는 프론트 입장에서 과연 유의미한 데이터인가 하는 의문이 남습니다. 개인적으로 프론트엔드 개발 경험이 없어 프론트 측에서 응답 데이터를 받아 어떤식으로 처리하는지에 대한 기본적인 이해가 부족한 것 같습니다.

페이스북 같은 경우 API가 반환할 수 있는 오류 코드에 대해 아래 링크와 자체적으로 정의하고 있습니다.
(https://developers.facebook.com/docs/marketing-api/error-reference?locale=ko_KR)

프론트엔드에서 코드값에 따라 적절한 응답 결과를 사용자에게 노출할 것으로 추측됩니다.

이 외에도 다양한 형식의 공통 응답 클래스가 존재합니다.
팀 내 상황 등을 고려해 알맞은 포맷 개발을 개발하여 사용할 수 있을 것 입니다.

Reference

https://devlog-wjdrbs96.tistory.com/182

1개의 댓글

comment-user-thumbnail
2023년 4월 11일

좋은글이네요 잘 보고 갑니다

답글 달기