[그림일기 서비스 보고 공부하기] Response 부분

오젼·2024년 8월 10일
0

https://github.com/tipi-tapi/ai-paint-today-BE

코드 보고 공부하기

프로젝트 구조

일단 common, domain으로 나뉨.
다른 데서도 보니까 global, domain 이런식으로 공통적인 것들, 도메인들로 패키지를 크게 나누는 듯.

common에는 BaseEntity, BaseException(BusinessException), BaseResponse(SuccessResponse, ErrorResponse) 등을 두고
해당 베이스 클래스들을 도메인에서 extends 해서 사용함.

특히 BaseException을 확장해서 쓰면 GlobalExceptionHandler에서 익셉션 처리하기가 굉장히 쉬워지는 듯.

SuccessResponse

@Getter
@Schema(description = "성공 Response")
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SuccessResponse<T> {

    @Schema(description = "성공 여부. 항상 true 이다.", defaultValue = "true")
    private final boolean status = true;

    @JsonInclude(Include.NON_NULL)
    private T data;

    public static <T> SuccessResponse<T> of(T data) {
        SuccessResponse<T> SuccessResponse = new SuccessResponse<>();

        SuccessResponse.data = data;

        return SuccessResponse;
    }

    public ResponseEntity<SuccessResponse<T>> asHttp(HttpStatus httpStatus) {
        return ResponseEntity.status(httpStatus).body(this);
    }
}

정리

@Schema

Swagger 관련 어노테이션. 문서화 관련.

@JsonInclude(Include.NON_NULL)

NULL인 애들을 JSON 직렬화에서 제외하겠다는 것.

  • 응답 크기 최적화: 불필요한 null 값을 제거하여 네트워크 트래픽을 줄일 수 있음.
  • 명확성: 클라이언트 측에서 null 값을 처리할 필요 없이, 존재하는 데이터만 처리하면 됨.
  • 이러면 클라이언트 측에서는 response의 data가 null인지 체크하지 않아도 됨. response.data?.name 이런식으로 옵셔널 체이닝을 안전하게 사용할 수 있음

@NoArgsConstructor(access = AccessLevel.PRIVATE)

  • 외부에서의 직접 인스턴스화 방지:
  • 객체 생성 제어: 클래스의 인스턴스 생성을 of 메서드로만 가능하게 함으로써, 객체 생성 방식을 일관되게 관리할 수 있음.
  • 불변성 보장: 직접적인 인스턴스 생성을 막음으로써, status 필드가 항상 true임을 보장할 수 있음.
  • 의도 명확화: 이 클래스는 팩토리 메서드를 통해서만 생성되어야 한다는 의도를 명확히 함.

팩토리 메서드 (of)

  • 캡슐화: 객체 생성 로직을 캡슐화하여, 향후 생성 로직이 변경되더라도 사용 코드를 수정할 필요가 없음.
  • 유연성: 제네릭 메서드를 통해 다양한 타입의 데이터를 처리할 수 있음.

오.. Response 구조를 일관되게 작성할 수 있다.
그리고 제네릭 메서드를 사용하니까 훨씬 유연해짐.
@JsonInclude도 잘 사용해야 하는구나.

궁금증

근데 @NoArgsConstructor를 사용하는 게 더 낫지 않을까?

@NoArgsConstructor(access = AccessLevel.PRIVATE)
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)로 바꾸고

public static <T> SuccessResponse<T> of(T data) {
	return new SuccessResponse<>(data);
}

이렇게만 해줘도 됐을듯

-> 라고 생각했는데 다시 보니까 @RequiredArgsConstructor초기화되지 않은 final 필드에 대한 생성자를 생성함. 그래서 저 코드가 올바르지 않음.

그리고 @AllArgsConstructor는 모든 필드에 대해서 생성자를 생성해버리고.

그래서 이 경우엔 어노테이션을 통해 생성자 코드를 줄이고 싶다면
불변성 보장을 위해 @NoArgsConstructor를 하고 status 필드는 건들지 말고 data만 따로 this.data = data로 해주는 게 맞았던 거임

-> 라고 생각했는데 datafinal로 선언해주면 됐다! 근데 다른 코드들에서도 그렇고 응답 결과는 final로 선언하지 않는 경우가 있던데 어떤 이유가 명확히 있어서 그런 건지 아닌지 모르겠다🤔 일단 불변성, 일관성을 위해 난 datafinal로 선언하는 것으로 결정.

ErrorResponse

public class ErrorResponse {

    private final boolean status = false;
    private final String code;
    private final String message;

    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private final List<ValidationError> errors;

    @Getter
    @Builder
    @RequiredArgsConstructor
    public static class ValidationError {

        private final String field;
        private final String message;

        public static ValidationError of(final FieldError fieldError) {
            return ValidationError.builder()
                .field(fieldError.getField())
                .message(fieldError.getDefaultMessage())
                .build();
        }
    }
}
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
@Getter
public enum ErrorCode {
    // Common
    INVALID_INPUT_VALUE(400, "C001", "잘못된 입력값입니다."),
    METHOD_NOT_ALLOWED(405, "C002", "허용하지 않는 HTTP 메서드입니다."),
    ....
    
    // GPT
    GPT_REQUEST_FAIL(500, "G001", "GPT 요청에 실패하였습니다."),
    ....
    
    private final int status;
    private final String code;
    private final String message;

    ErrorCode(final int status, final String code, final String message) {
        this.status = status;
        this.code = code;
        this.message = message;
    }
}
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<Object> handleBusinessException(BusinessException e) {
        if (e.getErrorCode().getStatus() == HttpStatus.INTERNAL_SERVER_ERROR.value()) {
            log.error("handleBusinessException", e);
        } else {
            log.warn("handleBusinessException", e);
        }
        ErrorCode errorCode = e.getErrorCode();
        return handleExceptionInternal(errorCode);
    }
    
    ...
    
    private ErrorResponse makeErrorResponse(ErrorCode errorCode) {
        return ErrorResponse.builder()
            .code(errorCode.getCode())
            .message(errorCode.getMessage())
            .build();
    }

    private ErrorResponse makeErrorResponse(ErrorCode errorCode, String message) {
        return ErrorResponse.builder()
            .code(errorCode.getCode())
            .message(message)
            .build();
    }

...

정리

ErrorCode

error인 경우 에러 코드를 따로 두심. 그래서 성공 응답에는 요청에 대한 결과 객체만 반환했던 것과 달리 code, message, errors를 두셨다.

ValidationError

ValidationError를 중첩 클래스로 구현하심. @Valid를 사용했을 때 예외 시 나오는 FieldError 객체를 그대로 반환하지 않고 필요한 부분만 반환하게 하셨음.

그래서 JsonInclude.Include.NON_EMPTY를 해서 리스트가 empty인 경우 json serialize가 안 되게 하셨다. BAD_REQUEST가 아닌 경우엔 필요 없는 필드이기 때문에.

// 기본 @Valid 사용 시 오류 응답
{
  "timestamp": "2024-08-15T10:15:30.000+00:00",
  "status": 400,
  "error": "Bad Request",
  "errors": [
    {
      "codes": ["NotBlank.user.name","NotBlank.name","NotBlank.java.lang.String","NotBlank"],
      "arguments": [{"codes":["user.name","name"],"arguments":null,"defaultMessage":"name","code":"name"}],
      "defaultMessage": "이름은 공백일 수 없습니다",
      "objectName": "user",
      "field": "name",
      "rejectedValue": "",
      "bindingFailure": false,
      "code": "NotBlank"
    }
  ],
  "message": "Validation failed for object='user'. Error count: 1",
  "path": "/api/users"
}
// 커스텀 ErrorResponse 사용 시 오류 응답
{
  "status": false,
  "code": "VALIDATION_ERROR",
  "message": "입력값 검증에 실패했습니다.",
  "errors": [
    {
      "field": "name",
      "message": "이름은 공백일 수 없습니다"
    }
  ]
}

@RestControllerAdvice

https://velog.io/@shyeon4643/에러-코드-및-성공-코드-도메인-별-분리하기feat.-interface#2-스프링의-다양한-예외-처리-방법 👍

@ControllerAdvice 말고 @RestControllerAdvice를 사용하면 반환값이 @ResponseBody로 처리된다!

ResponseEntityExceptionHandler

https://velog.io/@appti/ResponseEntityExceptionHandler를-사용해야-하는-이유

Spring MVC의 예외들은 기본적으로 DefaultHandlerExceptionResolver에 의해 처리된다.

ResponseEntityExceptionHandler에는 Spring MVC의 예외들을 RFC-7807에 따라 ResponseEntity로 반환할 수 있게 예외처리가 되어 있다.

ResponseEntityExceptionHandler를 확장하지 않으면 Spring MVC 예외들에 대해 error response를 반환할 exception handler들을 일일이 정의해줘야 한다.

그 대신 ResponseEntityExceptionHandler를 확장하면 Spring MVC의 기본 예외들을 포함한 모든 예외를 일관적으로 처리할 수 있는 기반을 제공받을 수 있다!

이 중 커스터마이징이 필요한 익셉션 핸들러는 오버라이딩을 해주면 된다.

ex) @Valid에서 예외가 발생하는 경우 MethodArgumentNotValidException이 발생하는데, 이는 Spring MVC의 기본 예외 중 하나이다.

일관된 예외 처리를 위해서는 두 가지 방법이 있다:
1) ResponseEntityExceptionHandler를 확장하고 필요한 메소드를 오버라이딩하여 커스터마이징하는 방법
2) 완전히 새로운 예외 처리기를 만들어 모든 예외를 직접 처리하는 방법

어떤 방법을 선택하든, 개발자가 원하는 일관된 구조로 예외 처리를 구현해야 한다.

궁금증

SuccessResponse/ErrorRespone를 나눈 이유가 뭘까

SuccessResponse와 ErrorResponse 구조를 왜 다르게 하셨을까?

다른 데에선 BaseResponse 하나만 두고 status, code, message, result로 구성하고 ResponseStatus enum class를 만들어서 성공일 때, 실패일 때 code를 모두 다 적어둔 걸 봤었기 때문에 의문이 들었다.

https://mingeun2154.github.io/project/tranche-day2/

클로드한테 물어보니까 YAGNI 원칙을 따른 것 같다고 한다.

정확한 의도는 파악하지 못 하겠다.. 아니면 리팩토링이 필요한 코드일 수도 있고...


-> 아 이거 다시 보니까 일단 SucceeResponse는 따로 성공 코드를 두지 않고 성공했을 때 객체를 담아서 보내는 걸로 간소화 시켰네. 그래서 T result만 있다고 생각하면 되는 거고.
ErrorResponse에서는 실패 객체를 따로 두는 게 아니라 ErrorCode를 넘겨주고 싶은 거니까 code, message를 둔 거고.

흠.. 근데 또 생각해보면 SUCCESS(true, 200, "요청에 성공하였습니다") 이렇게 성공인 경우에도 isSuccess, code, message 구조로 통일 시키면 되는 거 아닌가?

다시 보니까 그냥 BaseResponse, BaseResponseStatus로 일관성 있게 작성하는 게 더 나은 것 같다

아아 다시 보니까 ErrorResponse에 List<ValidationError> errors 필드 때문에도 response를 따로 나누는 게 여기선 맞았던 것 같다.

보면 FiledError 객체에서 원하는 부분만 선택해서 ValidationError 객체를 만들고 있는데 이건 ErrorResponse에만 필요한 필드이기 때문이다.

https://velog.io/@tkdwns414/Spring-Boot-공통-응답용-ApiResponse-만들기
-> 오 개인적으로 이 분 커스텀 response가 제일 공감된다. 특히나 status line에 있는 status code를 body에도 똑같이 적어주는 건 불필요하다는 거에 적극 공감.

SuccessResponse에는 code, message가 필요 없고 ErrorResponse에는 data가 필요 없다는 결론이 났다.

정리해보니까 그림일기서비스의 응답 구조가 제일 맞는듯....

성공 응답의 경우에만 요청에 대한 data를 반환해주는 게 맞고 성공 코드를 따로 두는 건 의미가 없고, 오류 응답인 경우에는 요청에 대한 data가 없는 게 맞고, 에러 코드와 메세지, BAD_REQUEST인 경우 유효성 검증에 실패한 부분을 반환해주는 게 좋은 것 같다.

대신 ProblemDetail을 이용한 예외처리는 더 찾아보기.
(https://www.inflearn.com/community/questions/1037608/apiresponse에-httpstatus를-설정하는것의-의미-관련-질문)

status 변수명 -> success로 바꾸는 건 어떨까?

SuccessResponse, ErrorResponse의 status 필드가 true, fasle로 각각 초기화 되어 있는데
status line에 들어갈 status와 이름이 같아서 약간 혼동이 오는듯.
success로 바꾸는 게 나은 것 같다.

status: true/false 필드를 따로 둔 이유가 뭘까?

status line에 http status를 따로 지정할 건데 왜 SuccessResponse, ErrorResopnse 객체에 status 필드를 true/false로 또 두신 걸까? 의문이 생김.

확실히는 모르겠지만 많은 클라이언트 라이브러리나 프레임워크에서 HTTP status code를 직접 접근하기 어려울 수 있다고 함. 그리고 http status가 200 OK여도 비즈니스 로직 측면에서는 실패인 경우가 있을 수도 있고.

프론트랑 얘기해보니까 보통 status code로 성공/실패 여부를 판단한다고 한다. success 필드를 따로 두지 않기로 했음.

ErrorResponse에도 asHttp를 사용하는 게 낫지 않을까?

왜 SuccessResponse에선 asHttp 메서드가 있는데 ErrorResponse에선 asHttp 메서드가 없는지 모르겠음. -> 나는 그냥 ErrorResponse에도 asHttp 만들기로 함.

ErrorCode 도메인 별로 분리하기

https://devnm.tistory.com/27
https://blog.letsdev.me/synopsys-interface-error-code-kor
https://dev-seonghun.medium.com/java-spring-특정-인터페이스를-구현한-클래스-찾기-cb8c38a586eb

이전 프로젝트에서도 고민했었던 내용. 한 곳에서 집중적으로 관리하는 것보다 BaseErrorCode를 두고 도메인 별로 확장해서 사용하는 게 나아 보인다.

0개의 댓글