SpringBoot API 응답 통일과 예외 처리

구환준/모건·2023년 11월 13일

UMC-5th

목록 보기
7/10

간만에 코딩 많이 하니까 어질어질하더군뇨..

API 응답 통일

팀 프로젝트 때 프론트로부터 “이거 응답 형태가 뭔가요?” 라는 물음표 살인마의 저주로부터 많이 안전해집니다

{
	isSuccess:
	code:
	message:
	result:
}

조금씩 바꿔도 괜찮습니다 (result 대신 data를 쓴다던지)

ApiResponse 클래스 생성

@Getter
@AllArgsConstructor
@JsonPropertyOrder({"isSuccess", "code", "message", "result"})
public class ApiResponse<T> {

    @JsonProperty("isSuccess")
    private final Boolean isSuccess;
    private final String code;
    private final String message;
    @JsonInclude(JsonInclude.Include.NON_NULL) // result 값은 null이 아닐 때만 응답에 포함시킨다는 뜻
    private T result;

    //성공한 경우 응답 생성
    public static <T> ApiResponse<T> onSuccess(T result) {
        return new ApiResponse<>(true, SuccessStatus._OK.getCode(), SuccessStatus._OK.getMessage(), result);
    }

    // 실패한 경우 응답 생성
    public static <T> ApiResponse<T> onFailure(String code, String message, T data){
        return new ApiResponse<>(true, code, message, data);
    }
}
  • T 타입 = 어떤 타입도 될 수 있다는 뜻
  • <T> ApiResponse<T> 이건 또 어느나라 말이람?
    → 앞에 : 우리가 아는 반환 타입이 T(제네릭) 이다
    → 뒤에 : ApiResponse가 사용하는 인자에 어떤 타입이든 들어갈 수 있다
    예시) ApiResponse<String> stringResponse = ApiResponse.onSuccess("Hello, World!");

BaseCode 인터페이스 생성

public interface BaseCode {

  public ReasonDTO getReason();

  public ReasonDTO getReasonHttpStatus();
}

ReasonDTO 생성

API 응답 양식을 지정해주기 위해 DTO를 만들어준다

@Getter
@Builder
public class ReasonDTO {
  private HttpStatus httpStatus;

  private final boolean isSuccess;
  private final String code;
  private final String message;
}

Converter 생성

public class TempConverter {

  public static TempResponse.TempTestDto toTempTestDTO() {
      return TempResponse.TempTestDto.builder()
              .testString("테스트")
              .build();
  }

  public static TempResponse.TempExceptionDTO toTempExceptionDTO(Integer flag) {
      return TempResponse.TempExceptionDTO.builder()
              .flag(flag)
              .build();
  }
}

TempResponse라는 HTTP 응답 관련 DTO들을 보관하는 클래스에 TempTestDto 만들기

public class TempResponse { //TempResponse가 다양한 응답을 감싸는 역할

  @Builder
  @Getter
  @NoArgsConstructor
  @AllArgsConstructor
  public static class TempTestDto{ //TempTestDto는 구체적인 응답데이터 구조를 정의
      String testString;
  }

  @Builder
  @Getter
  @NoArgsConstructor
  @AllArgsConstructor
  public static class TempExceptionDTO {
      Integer flag;
  }
}
  • DTO 클래스 속 변환함수from() 또는 of()를 넣어 stream().map() 을 사용해 변환하는 것과 Converter 클래스를 두는 것의 차이
    • stream().map()을 사용하면 객체 컬렉션을 스트림으로 변환하고, 각 객체를 DTO로 변환하는 데 사용할 수 있다.
    • 이 방법은 람다식과 스트림 API를 활용하여 코드를 간결하게 작성할 수 있다.
    • 그러나 대량의 데이터를 처리할 때 성능 이슈가 발생할 수 있다. 스트림 연산은 객체를 하나씩 처리하므로 대규모 데이터셋에서는 오버헤드가 발생할 수 있다.

컨트롤러 생성

public class TempRestController {

  private final TempQueryService tempQueryService;

  @GetMapping("/test")
  public ApiResponse<TempResponse.TempTestDto> testAPI() {
      return ApiResponse.onSuccess(TempConverter.toTempTestDTO());
  }
}
  • testAPI()의 반환 값으로 ApiResponse<TempResponse.TempTestDto>로 구체적으로 지정해줬죠?
  • 우리 실습때는 ResponseEntity로 제네릭 타입으로 지정해줬으나 컨트롤러 메서드 반환값은 구체적으로 지정해주는 것이 성능면에서 훨씬 좋다고 합니다.

Exception 핸들러

살짝 어려운 파트

ErrorStatus

@Getter
@AllArgsConstructor
public enum ErrorStatus implements BaseErrorCode {

    //일반적인 응답
    _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."),
    _BAD_REQUEST(HttpStatus.BAD_REQUEST,"COMMON400","잘못된 요청입니다."),
    _UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"COMMON401","인증이 필요합니다."),
    _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."),

    //Member Error
    MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4001", "사용자가 없습니다"),
    NICKNAME_NOT_EXIST(HttpStatus.BAD_REQUEST, "MEMBER4002", "닉네임은 필수 입니다."),

    //Article Error
    ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."),

    // Error Test
    TEMP_EXCEPTION(HttpStatus.BAD_REQUEST, "TEMP4001", "에러 테스트"),
    ;


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

    @Override
    public ErrorReasonDTO getReason() {
        return ErrorReasonDTO.builder()
                .message(message)
                .code(code)
                .isSuccess(false)
                .build();
    }

    @Override
    public ErrorReasonDTO getReasonHttpStatus() {
        return ErrorReasonDTO.builder()
                .message(message)
                .code(code)
                .isSuccess(false)
                .httpStatus(httpStatus)
                .build();
    }
}
  • 매우 중요한 기본지식!
    enum 타입으로 만든 클래스 무조건 위에 enum들을 선언하고 그 밑에 필드를 선언해야 합니다. → 순서가 바뀌면 컴파일러 오류가 나게 되고, 이 규칙을 모른다면 디버깅에 엄청난 시간을 허비하게 된다ㅠㅠ

GeneralException

@Getter
@AllArgsConstructor
public class GeneralException extends RuntimeException {

  private BaseErrorCode code;

  public ErrorReasonDTO getErrorReason() {
      return this.code.getReason();
  }

  public ErrorReasonDTO getErrorReasonHttpStatus() {
      return this.code.getReasonHttpStatus();
  }
}

Exception의 종류를 지정해준 것이다

ExceptionAdvice

@Slf4j
@RestControllerAdvice(annotations = {RestController.class}) // 이 클래스는 모든 @RestController에서 발생하는 예외를 처리한다
public class ExceptionAdvice extends ResponseEntityExceptionHandler {

  @org.springframework.web.bind.annotation.ExceptionHandler
  public ResponseEntity<Object> validation(ConstraintViolationException e, WebRequest request) { //Bean Validation API를 사용하여 검증할 때 발생하는 예외로, 주로 요청 데이터의 유효성 검사 실패 시 발생
      String errorMessage = e.getConstraintViolations().stream()
              .map(constraintViolation -> constraintViolation.getMessage())
              .findFirst()
              .orElseThrow(() -> new RuntimeException("ConstraintViolationException 추출 도중 에러 발생"));

      return handleExceptionInternalConstraint(e, ErrorStatus.valueOf(errorMessage), HttpHeaders.EMPTY,request); //예외로부터 에러 메시지를 추출하여 handleExceptionInternalConstraint 메서드를 호출
  }

  @Override
  public ResponseEntity<Object> handleMethodArgumentNotValid( //메서드 인자의 유효성 검사에 실패한 경우 발생하는 예외를 처리한다
          MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) {

      Map<String, String> errors = new LinkedHashMap<>();

      e.getBindingResult().getFieldErrors().stream()
              .forEach(fieldError -> {
                  String fieldName = fieldError.getField();
                  String errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse("");
                  errors.merge(fieldName, errorMessage, (existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage);
              });

      return handleExceptionInternalArgs(e,HttpHeaders.EMPTY,ErrorStatus.valueOf("_BAD_REQUEST"),request,errors);
  }

  @org.springframework.web.bind.annotation.ExceptionHandler
  public ResponseEntity<Object> exception(Exception e, WebRequest request) { //일반적인 예외를 처리한다
      e.printStackTrace();

      return handleExceptionInternalFalse(e, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(),request, e.getMessage());
  }

  @ExceptionHandler(value = GeneralException.class) //사용자 정의 예외(GeneralException)를 처리한다
  public ResponseEntity onThrowException(GeneralException generalException, HttpServletRequest request) {
      ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus();
      return handleExceptionInternal(generalException,errorReasonHttpStatus,null,request);
  }

  private ResponseEntity<Object> handleExceptionInternal(Exception e, ErrorReasonDTO reason,
                                                         HttpHeaders headers, HttpServletRequest request) { //예외를 처리하고 그 결과를 Http Response로 반환한다

      ApiResponse<Object> body = ApiResponse.onFailure(reason.getCode(),reason.getMessage(),null);
//        e.printStackTrace();

      WebRequest webRequest = new ServletWebRequest(request);
      return super.handleExceptionInternal(
              e,
              body,
              headers,
              reason.getHttpStatus(),
              webRequest
      );
  }

  private ResponseEntity<Object> handleExceptionInternalFalse(Exception e, ErrorStatus errorCommonStatus,
                                                              HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) { //이 메서드는 예외를 처리하고 실패 상태를 클라이언트에 응답으로 반환한다
      ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorPoint);
      return super.handleExceptionInternal(
              e,
              body,
              headers,
              status,
              request
      );
  }

  private ResponseEntity<Object> handleExceptionInternalArgs(Exception e, HttpHeaders headers, ErrorStatus errorCommonStatus,
                                                             WebRequest request, Map<String, String> errorArgs) { //예외를 처리하고 유효성 검사 에러 메시지와 함께 클라이언트에 응답으로 반환한다
      ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorArgs);
      return super.handleExceptionInternal(
              e,
              body,
              headers,
              errorCommonStatus.getHttpStatus(),
              request
      );
  }

  private ResponseEntity<Object> handleExceptionInternalConstraint(Exception e, ErrorStatus errorCommonStatus,
                                                                   HttpHeaders headers, WebRequest request) { //예외를 처리하고 제약 위반 에러 메시지를 클라이언트에 응답으로 반환한다
      ApiResponse<Object> body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), null);
      return super.handleExceptionInternal(
              e,
              body,
              headers,
              errorCommonStatus.getHttpStatus(),
              request
      );
  }
}

다른 부분은 일단 제쳐두고 onThrowException() 메서드를 유심히 보시면 됩니다

@ExceptionHandler(value = GeneralException.class) //사용자 정의 예외(GeneralException)를 처리한다
  public ResponseEntity onThrowException(GeneralException generalException, HttpServletRequest request) {
      ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus();
      return handleExceptionInternal(generalException,errorReasonHttpStatus,null,request);
  }

중요 - GeneralException타입의 예외 발생시 getErrorReasonHttpStatus()를 실행한다

TempHandler

public class TempHandler extends GeneralException {

  public TempHandler(BaseErrorCode errorCode) {
      super(errorCode);
  }
}
  • 핸들러 호출 시, GeneralException에 에러코드(ErrorStatus) 전달한다는 뜻

CommandService vs QueryService

Command Service (명령 서비스):

  • "Command"는 시스템에 변경을 가하는 요청 또는 명령
  • Command Service는 주로 데이터의 생성, 업데이트, 삭제 (CRUD)와 관련된 기능
  • Command Service는 데이터의 일관성과 트랜잭션 관리를 중요하게 다루며, 데이터의 상태를 변경하는 작업

Query Service (쿼리 서비스):

  • "Query"는 시스템으로부터 정보를 가져오는 요청
  • Query Service는 주로 데이터를 조회하고 읽어오는 기능
  • Query Service는 데이터의 일관성보다는 읽기 성능을 최적화하는 데 중점을 두며, 복잡한 데이터 조회 및 필터링 작업을 처리(검색)

오늘은 QueryService 인터페이스와 QueryServiceImpl만 작성해보자

public interface TempQueryService {
  void CheckFlag(Integer flag);
}
@Service
@RequiredArgsConstructor
public class TempQueryServiceImpl implements TempQueryService{

  @Override
  public void CheckFlag(Integer flag) {
      if (flag == 1) { //flag값이 1일 때 예외 발생
          throw new TempHandler(ErrorStatus.TEMP_EXCEPTION);
      }
  }
}

컨트롤러 완성

@RestController
@RequestMapping("/temp")
@RequiredArgsConstructor
public class TempRestController {

  private final TempQueryService tempQueryService;

  @GetMapping("/test")
  public ApiResponse<TempResponse.TempTestDto> testAPI() {
      return ApiResponse.onSuccess(TempConverter.toTempTestDTO());
  }

  @GetMapping("/exception")
  public ApiResponse<TempResponse.TempExceptionDTO> exceptionAPI(//추가된 부분
          @RequestParam Integer flag
  ) {
      tempQueryService.CheckFlag(flag);
      return ApiResponse.onSuccess(TempConverter.toTempExceptionDTO(flag));
  }
}

예외 흐름

  • flag의 값이 1일 때, 위에서 만들어 둔 TEMP_EXCEPTION이라는 에러를 만든다는 것을 알 수 있다.
  1. /temp/exception?flag=1로 요청이 들어옴
  2. TempQueryService속 CheckFlag 호출
  3. TempQueryServiceImpl로 넘어감
  4. flag값이 1임을 감지하여 TempHandler 에러코드와 함께 호출(Exception 발생 지점)
  5. TempHandler는 GeneralException을 상속받기 때문에 ExceptionAdvice가 감지함
  6. ExceptionAdvice에서 GeneralException은 getErrorReasonHttpStatus() 를 시키라고 함
  7. getErrorReasonHttpStatus() 에 의해 에러코드를 가지고 BaseErrorCode로 이동
  8. getReasonHttpStatus() 를 호출 → BaseErrorCode를 상속받는 ErrorStatus로 이동
  9. ErrorStatus의 enum에 속하는 것을 확인한 뒤, 그 값들을 가지고 getReasonHttpStatus()
  10. HTTP 응답 생성됨!

일련의 과정이 이해됐다 = 예외 처리 마스터임 하산하세요

RestControllerAdvice의 유용함

  1. 전역 예외 처리: @RestControllerAdvice를 사용하면 애플리케이션 전반에 걸쳐 예외 처리를 일관되게 관리할 수 있었다. 특정 컨트롤러나 메서드에 종속되지 않고 예외 처리를 정의할 수 있는 것도 객체지향 프로그래밍의 방향성에 아주 적합하다고 생각한다
  2. 코드 중복 최소화: 예외 처리 로직을 여러 곳에 중복해서 작성하지 않고 한 곳에서 관리할 수 있다. 이로써 코드 중복을 최소화하고 유지 보수성을 향상시킨다.
  3. 사용자 정의 응답: 예외가 발생하면 예외 유형에 따라 사용자 정의 응답을 생성할 수 있다. 이를 통해 클라이언트에게 이해하기 쉬운 에러 메시지와 상태 코드를 반환할 수 있었다.
  4. 다양한 예외 처리: 다양한 예외 유형에 대한 처리를 구현할 수 있다. 예를 들어, 비즈니스 로직 예외, 데이터 유효성 검사 실패 예외, 인증 및 권한 관련 예외 등을 상황별로 처리할 수 있을 것 같다.

0개의 댓글