클라이언트 ↔︎ 서버 구조에서
클라이언트는 서버에 요청을 보내고 서버는 요청에 대한 결과를 응답합니다.
@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 상태 코드, 헤더 등의 정보를 어떻게 응답할 수 있을까요?
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를 통해 객체를 생성하는 방법도 존재합니다.
그렇다고 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
클래스를 만들었습니다.
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)
프론트엔드에서 코드값에 따라 적절한 응답 결과를 사용자에게 노출할 것으로 추측됩니다.
이 외에도 다양한 형식의 공통 응답 클래스가 존재합니다.
팀 내 상황 등을 고려해 알맞은 포맷 개발을 개발하여 사용할 수 있을 것 입니다.
좋은글이네요 잘 보고 갑니다