Spring

[Packy] API 공통 응답 포맷 만들기 - 정상 응답인 경우& 예외 처리 응답인 경우

짱정연 2024. 1. 10. 13:48
반응형

1. 들어가며

클라이언트 단에서는 백엔드에서 개발한 API Response를 토대로 데이터 모델이라는 것을 만들어 화면에 데이터를 출력한다.

출처: https://medium.com/@jlwarfield/constructing-a-swift-data-model-for-dummies-5b79fbd22a2a

 

만약 통일된 규격의 API Response가 없으면 클라이언트 개발자들은 API마다 데이터 모델을 일일이 만들어야 한다. 그러므로 백엔드에서는 어느 정도 통일된 양식의 API를 전달해주는 것이 좋다.

 

이번 포스트에서는 정상(200)인 경우와 예외 처리의 경우에 대해 공통 Response 포맷을 만드는 과정을 알아보자.

 

2. 공통 응답 포맷

정상 응답

정상 응답일 경우에는 아래와 같은 형태의 응답 포맷을 만드는 것이 목표이다.

code와 message는 각각 에러가 발생할 경우 에러를 구분하는 에러 코드와 에러에 대한 설명이며 정상 응답일 경우 S000과 OK로 일정하다.

API로 전달한 데이터는 data의 value로 준다

{
  "code": "S000",
  "message": "OK",
  "data": {
    "id": 1,
    "name": "test"
  }
}

 

예외 처리 응답

예외 처리 응답의 경우 code와 message를 통해 어떤 예외 상황인지 설명하고 errors를 통해 요청 값에 대해 어떤 부분이 잘못되었는지 구체적으로 명시한다. 주로 @Valid 어노테이션을 통한 검증을 진행할 때 사용한다.

errors에 바인딩된 결과가 없을 때는 null이 아닌 빈 배열을 전달한다.

{
  "code": "ER000",
  "message": "에러 발생 원인에 대해 작성하기",
  "errors": [
    {
      "field": "name.last",
      "value": "",
      "reason": "must not be empty"
    },
    {
      "field": "name.first",
      "value": "",
      "reason": "must not be empty"
    }
  ]
}

 

 

클래스 구조

코드를 작성하기에 앞서 DTO의 상속 관계에 대해 알아보자.

DataResponseDto는 정상 응답인 경우 DTO, ErrorResponseDto는 예외처리 응답인 경우 DTO이다.

두 DTO 모두 ResponseDto를 상속받는다.

 

이제 진짜로 코드를 작성해보자. 응답 포맷 관련 클래스들은 global 패키지 안에 있는 response 디렉토리에 위치시켜주었다.

 

3. ErrorCode.java

응답 코드들을 모아둔 Enum 클래스이다. Success Code도 하나 들어가지만 클래스 이름을 Code로 할 경우 필드 값과 이름이 겹쳐 ErrorCode로 지었다. (더 좋은 클래스 이름이 있다면 알려주십셔 ... ㅎ.ㅎ)

 

code의 경우 알파벳 대문자와 3자리 숫자로 이루어져있다. 알파벳 대문자는 어떤 에러인지 분류하는 용도이고, 000부터 시작하여 차례대로 숫자를 기입한다.

status는 HTTP 응답 코드에 들어갈 코드이며, message는 응답 결과의 message 부분에 들어갈 문자열이다.

 

getMessage는 전달받은 예외의 message가 존재할 경우 해당 message를 반환하고, 없을 경우에는 필드값 message를 반환하는 메서드이다.

@Getter
@RequiredArgsConstructor
public enum ErrorCode {

    // Success
    OK("S000", HttpStatus.OK, "OK"),

    // Internal Server Error
    INTERNAL_ERROR("S000", HttpStatus.INTERNAL_SERVER_ERROR, "서버에 오류가 발생했습니다."),
    METHOD_NOT_ALLOWED("S001", HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP Method 요청입니다.")
    ;

    private final String code;
    private final HttpStatus httpStatus;
    private final String message;

    public String getMessage(Throwable throwable) {
        return this.getMessage(this.getMessage(this.getMessage() + " - " + throwable.getMessage()));
    }

    public String getMessage(String message) {
        return Optional.ofNullable(message)
            .filter(Predicate.not(String::isBlank))
            .orElse(this.getMessage());
    }
}

 

4. DataResponseDto.java

다양한 형태의 데이터를 담을 수 있도록 타입을 제네릭으로 지정해주었다.

@Getter
public class DataResponseDto<T> extends ResponseDto {

    private final T data;

    private DataResponseDto(T data) {
        super(ErrorCode.OK.getCode(), ErrorCode.OK.getMessage());
        this.data = data;
    }

    public static <T> DataResponseDto<T> from(T data) {
        return new DataResponseDto<>(data);
    }
}

 

 

5. 결과 예시 - 정상 응답인 경우

간단한 API를 만들어 결과를 확인해보자.

 

DTO를 반환할 때 인자에 원하는 data 값을 담은 from 메서드를 호출하면 된다. 

@RestController
@RequestMapping("/hello")
public class HelloController {

    @GetMapping
    public DataResponseDto<String> hello() {
        return DataResponseDto.from("Hello, World!");
    }
}

 

원하던대로 결과가 출력되는 모습을 확인할 수 있다 :)

 

6. ErrorResponseDto.java

public class ErrorResponseDto extends ResponseDto {

    private ErrorResponseDto(ErrorCode errorCode) {
        super(errorCode.getCode(), errorCode.getMessage());
    }

    private ErrorResponseDto(ErrorCode errorCode, Exception e) {
        super(errorCode.getCode(), errorCode.getMessage(e));
    }

    public static ErrorResponseDto from(ErrorCode errorCode) {
        return new ErrorResponseDto(errorCode);
    }

    public static ErrorResponseDto of(ErrorCode errorCode, Exception e) {
        return new ErrorResponseDto(errorCode, e);
    }
}

 

 

DataResponseDto와 다르게 ErrorResponseDto는 Controller나 Service 단에서 바로 호출되지 않는다.

아래는 잘못된 예외 처리의 예시이다.

// 잘못된 예시
try {
    // 어쩌구 저쩌구
} catch (Exception e) {
    return ErrorResponseDto.from(e.getMessage());
}

 

아래 코드 예시처럼 예외를 던지면 GlobalExceptionHandler 클래스가 예외를 catch해서 적절한 포맷의 예외 처리 응답을 반환하도록 하는 것이다. 즉, ErrorResponseDto는 GlobalExceptionHandler 클래스에서 사용된다.

memberRespository.findById(id).orElseThrow(() -> 
    return new BusinessException("어쩌구"));

 

7. GlobalExceptionHandler.java

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 커스텀 예외 처리
    @ExceptionHandler(BusinessException.class)
    protected ResponseEntity<Object> handleBusinessException(BusinessException e) {
        log.error(e.toString(), e);
        return handleExceptionInternal(e.getErrorCode());
    }

    // 지원하지 않는 HTTP method를 호출할 경우
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    protected ResponseEntity<Object> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
        log.error("HttpRequestMethodNotSupportedException : {}", e.getMessage());
        return handleExceptionInternal(ErrorCode.METHOD_NOT_ALLOWED);
    }

    // 그 밖에 발생하는 모든 예외 처리
    @ExceptionHandler(value = {Exception.class, RuntimeException.class, SQLException.class, DataIntegrityViolationException.class})
    protected ResponseEntity<Object> handleException(Exception e) {
        log.error(e.toString(), e);

        return handleExceptionInternal(ErrorCode.INTERNAL_ERROR, e);
    }

    private ResponseEntity<Object> handleExceptionInternal(ErrorCode errorCode) {
        return ResponseEntity.status(errorCode.getHttpStatus())
            .body(ErrorResponseDto.from(errorCode));
    }

    private ResponseEntity<Object> handleExceptionInternal(ErrorCode errorCode, Exception e) {
        return ResponseEntity.status(errorCode.getHttpStatus())
            .body(ErrorResponseDto.of(errorCode, e));
    }
}

 

 

@ExceptionHandler는 value로 지정한 예외를 핸들링할 수 있도록 하는 어노테이션이다. 이를 사용하여 예외에 대한 세부적인 정보를 응답으로 전달해주는 것이 가능하다.

 

@RestControllerAdvice는 @ControllerAdvice와 @ResponseBody를 합쳐놓은 어노테이션이다. 여기서 @ControllerAdvice란 스프링 부트 애플리케이션에서 전역적으로 예외를 핸들링할 수 있도록 해주는 어노테이션이다.

단순히 예외만 처리하고 싶으면 @ControllerAdvice를 사용하고, 응답을 객체로 반환해야 한다면 @RestControllerAdvice를 사용하면 된다.

 

handlerExceptionInternal 메서드는 ResponseEntity와 앞서 만든 ErrorResponseDto를 활용하여 예외처리에 대한 응답을 전달해준다.

 

이 외에도 @Valid나 @Validated binding error, BindException, AccessDeniedException 등 핸들링할 예외들이 더 있지만 우선은 기본적인 예외들만 핸들링하고 추후 코드를 추가하면서 더 핸들링하도록 하자.

 

8. 결과 예시 - 예외처리 응답인 경우

단순한 결과 확인용이므로 레이어를 무시하고 코드를 작성하였다.

@RestController
@RequestMapping("/hello")
@RequiredArgsConstructor
public class HelloController {

    private final MemberRepository memberRepository;

    @GetMapping
    public DataResponseDto<String> hello() {

        Member member = memberRepository.findById(1L).orElseThrow(() ->
            new BusinessException(ErrorCode.INTERNAL_ERROR));

        return DataResponseDto.from(member.getNickname() + "님 안녕하세요");
    }
}

 

 

원하던대로 결과가 출력되는 모습을 확인할 수 있다 :)

 

9. 마치며

이번 포스트에서는 정상 응답인 경우와 예외처리 응답인 경우에 대한 공통 응답 포맷을 만들어보았다.

공통 응답 포맷을 만드는 것은 프로젝트 세팅 단계이기 때문에 이전에 했던 프로젝트의 코드를 복붙해와서 적용한 것이 대부분이였다.

 

Best Practice를 참고하며 직접 하나씩 만드는 과정, 특히 예외 처리 핸들링 코드를 작성하며 응답 처리에 대해 깊이 있게 이해할 수 있는 시간이였다.

 

Reference

반응형