스프링에서 API 응답 방식은 보통 ResponseEntity 방식을 많이 사용하곤 한다. 데이터가 있을 때에는 문제 없이 잘 내려가지만 예외가 발생한다던가 하면 json으로 내려가지 않는 것을 본 적 있을 것이다.
일반 ResponseEntity 형식으로 반환하면 예외가 발생하는 경우 body가 json이 아닌 PlainText로 내려간다.
즉, 예외가 발생했을 때 응답의 모양이 달라지는 것!!
그리고 API의 작동 성공/실패 여부나 예외 에러메시지를 알 수 없이 DTO만 반환하면 서버 로그를 확인하기 전까진 아무 정보도 알 수 없을 것이다.
응답 데이터를 전달받는 주체가 사용하기 편하도록 성공했을 때와 실패했을 때 어떠한 처리 결과에도 동일한 포맷의 응답을 리턴하도록 통일시킬 필요가 있다.
또 API의 성공/실패에 따른 상태 메시지나 코드를 내려주게 되면 더욱 편하겠지?(HTTP 상태코드 말고 프로젝트 내에서 정한 약속 코드 같은 것 말이다.)
개발자마다 선호하는 공통 응답 필드는 다를 수 있다.
이번 포스트에선 status, data, message를 사용해보겠다.
status: 응답 상태를 String으로 표시(Success, error 둘 중 하나 리턴)
data: 응답 결과 json(success일 경우 응답 데이터, error일 경우엔 null)
message: 예외일 경우 에러메시지 표시
HTTP 상태코드는 헤더에서 가져올 수 있으므로 따로 넣지 않았다.
package com.test.apihandler.utils;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
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
@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> successResponse(T data) {
return new ApiResponse<>(SUCCESS_STATUS, data, null);
}
public static ApiResponse<?> successWithNoContent() {
return new ApiResponse<>(SUCCESS_STATUS, null, null);
}
public static ApiResponse<?> failResponse(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);
}
public static ApiResponse<?> errorResponse(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를 만들었다.
data 필드에는 어떠한 타입이라도 들어갈 수 있도록 제네릭을 사용해주자.
package com.test.apihandler.utils.exception;
import com.openeg.openegscts.utils.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@RestControllerAdvice(basePackages = {"com.test.apihandler"})
public class ApiExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleExceptions(RuntimeException exception) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.errorResponse(exception.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<?>> handleValidationExceptions(BindingResult bindingResult) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.failResponse(bindingResult));
}
}
@PutMapping("/info")
public ApiResponse<UserDto> editUserInfo(@RequestBody EditUserInfoDto editUserInfoDto) throws Exception {
iAdminUserService.editUserInfo(editUserInfoDto);
return ApiResponse.successResponse(UserDto.of(iAdminUserService.findUserInfo(editUserInfoDto.getUserId())));
}
@Override
public boolean editUserInfo(EditUserInfoDto editUserInfoDto) throws Exception {
boolean result = iAdminUserMapper.editUserInfo(editUserInfoDto);
if (!result) {
throw new BindingException("id:'" + editUserInfoDto.getId + "' 업데이트 실패");
}
return true;
}
위와 같은 유저 수정 API를 만들어서 공통 응답을 테스트 해보자.
유저 수정이 성공했을 경우엔 수정된 UserDto가 공통 응답 클래스의 data에 싸여 나가게 될 것이고, 유저 수정이 실패했을 경우 공통응답 클래스의 message에 RuntimeException 에러메시지가 리턴된다.
{
"status": "success",
"data": {
"userId": "student01",
"name": "김두한",
"email": "fourdollars@yain.com",
"phone": "010-4444-4444",
"type": 1,
"regDate": "2022-12-21 01:33:01",
"groupId": 13,
"classId": 6,
"status": 1,
"expireYn": "N",
"cloudAccount": {
"id": 178,
"username": "am01-001",
"password": "RRpJ024'iia+b=G",
"url": "https://1234.signin.aws.amazon.com/console",
"accessKeyId": "1234567890",
"clientSecret": "-",
"regDate": "2023-01-11 04:13:17"
}
},
"message": null
}
{
"status": "error",
"data": null,
"message": "id: 'student01' 업데이트 실패"
}