[Spring Boot] 공통 응답용 ApiResponse 만들기

tkdwns414·2024년 3월 22일
4
post-thumbnail

ResponseEntity

스프링에는 Rest API를 제작할 때 응답을 만들 수 있는 ResponseEntity라는 객체가 이미 존재한다. 아래 코드와 같이 안에 넣고 싶은 내용을 담아서 반환할 수 있다.

@RestController
@RequestMapping("/test")
public class TestController {

    @GetMapping("/responseEntity")
    public ResponseEntity<String> responseEntity() {
        return ResponseEntity.ok("ok");
    }
    
}

위 API로 요청을 날릴 경우 다음과 같은 응답을 받을 수 있다.

하지만 보통 프로젝트를 하다보면 더 구체적인 정보를 응답으로 반환받고 싶어하는 팀원들이 있다. 더 자세한 분기처리를 위해 요청하기도 하며 이미 자신에게 익숙한 형태의 응답을 처리하고 싶어서 요청하기도 한다. 또 사용해야할 응답 값이 처음에 하나의 키에 묶여있는 것이 사용하기 편하다고 말해주기도 했다. 그래서 제작하는 API들에 대해서 공통적으로 쓸 응답 형태를 만들기도 한다.

ApiResponse

그래서 구현한 것이 ApiResponse이다. 나는 지금껏 총 두개의 형태를 썼는데 첫 번째 형태가 마음에 들지 않아서 두번째 형태로 바꾸게 되었다.

첫 ApiResponse

{
    "code" : int,
    "message": string,
    "data": {}
}

처음으로 구현한 ApiResponse 형태이다. 해당 형태는 내가 활동했던 SOPT에서 처음 접하게 됐는데 많은 팀들이 위와 같은 형태의 ApiResponse를 사용했다. 사람들한테 물어보면 솝벤션이라고 부를 정도였는데 사실 왜 사용하는지 구체적으로 물어봤을 때 납득이 갈 만큼 명확한 대답을 해준 사람이 많지 않았다. 그러다보니 처음 사용할 때는 ApiResponse의 장점을 올바르게 사용하지 못했다.

그 예시로 응답에 있는 code의 값을 따로 만들지 않고 HTTP Status Code와 동일한 값을 넣어주었기 때문에 아무 의미 없는 값이 되었고, API 요청이 성공했을 때에도 굳이 message를 적어주었기 때문에 필요없는 값들이 자꾸 늘어났다. 해당 코드를 사용하면서 이런 의구심이 자꾸 들었던 나는 조금 다른 형태의 ApiResponse를 만들어서 사용하기로 했다.

두번째 ApiResponse

  • 성공 응답
{
	"success": true,
	"data": {}, // or [] or null
    "error": null
}
  • 실패 응답
{
	"success": false,
	"data": null,
    "error": {
        "code": int, // http status code 아니고 custom code임 ex)40001,40002
		"message": string
    }
}

실제로 사용하는 key 값은 변하지 않지만 성공 경우와 실패 경우에 대해서 눈에 더 잘 들어오도록 구분해서 적어놓았다. 성공 여부에 대해서 보내주는 success 라는 키 값을 만들었고 내려줄 값이 있을 때 사용할 data, 실패했을 때에 우리가 정의한 custom code와 message를 내려줄 error 키를 만들었다.

custom code의 경우에는 40000, 40001과 같은 형태를 사용할 예정인데 이는 클라의 분기처리를 도와주기 위함이다. 서버 입장에서는 잘못된 요청이기에 400이라는 동일한 code를 내려줌에도 잘못된 요청의 종류에 따라 다른 코드를 내려주는 것이다. 40000은 400의 0번째 케이스, 40001은 400의 1번째 케이스 같이 이해할 수 있다. 이러한 커스텀 코드를 잘 작성할 경우 커스텀 코드의 종류를 클라에게 잘 공유만 한다면 매번 어떤 것 때문에 일어나는 오류인지 같이 확인하는 시간도 줄어들 것이라 생각한다.

message는 해당 상황에 대해서 개발자가 바로 알 수 있도록 설명해놓는 메시지라고 이해하면 될 것 같다. message로도 케이스별 분류가 가능하긴 하지만 클라 코드에서의 깔끔함을 위해서라도 custom code 값은 따로 있는 것이 좋은 것 같다.

구현

그래서 두번째 ApiResponse를 구현해보도록 하겠다.

수정) 아래 코드에서 개선할 내용을 담은 다음 글이 올라왔으니 해당 글까지 다 읽기를 권장

dependencies

우선 현재 dependecy는 다음과 같다.

dependencies {
	// Spring
	implementation 'org.springframework.boot:spring-boot-starter-web'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'

	// Lombok
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'

	// Validation
	implementation 'org.springframework.boot:spring-boot-starter-validation'

	// Test
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

ApiResponse

다음은 ApiResponse record이다.

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.tkdwns414.Template.exception.CustomException;
import jakarta.validation.constraints.NotNull;
import org.springframework.http.HttpStatus;
import org.springframework.lang.Nullable;

public record ApiResponse<T>(
        @JsonIgnore
        HttpStatus httpStatus,
        boolean success,
        @Nullable T data,
        @Nullable ExceptionDto error
) {

    public static <T> ApiResponse<T> ok(@Nullable final T data) {
        return new ApiResponse<>(HttpStatus.OK, true, data, null);
    }

    public static <T> ApiResponse<T> created(@Nullable final T data) {
        return new ApiResponse<>(HttpStatus.CREATED, true, data, null);
    }

    public static <T> ApiResponse<T> fail(final CustomException e) {
        return new ApiResponse<>(e.getErrorCode().getHttpStatus(), false, null, ExceptionDto.of(e.getErrorCode()));
    }
}

ApiResponse에서 받는 값은 httpsStatus 값(Response Header의 http status 값), API 응답의 성공 여부를 담을 success, 내려줘야할 값이 있다면 담을 data, 예외가 터졌다면 알려줄 error이다. error의 경우에는 내부에 code와 message 값을 가지므로 따로 ExceptionDto를 정의해주었다.

이때 httpStatus의 경우에는 JsonIgnore 어노테이션으로 실제 응답에 포함되지 않도록 했는데 그럼에도 httpStatus를 받는 이유는 후에 서술할 ResponseInterceptor에서 사용하기 위함이다.

success는 항상 값을 가지므로 boolean을 사용해주었고, data, error의 경우에는 상황에 따라 값이 비어있을 수 있으므로 @Nullable을 달아주었다. 처음에는 data와 error가 null일 때는 보내주지 않으려 했으나 클라 측에서 null이어도 있는게 편하다고 해서 굳이 JsonInclude(Include.NON_NULL)를 이용해서 있는 값만 보내주도록 하지는 않았다.

응답 값으로는 자주 쓰일 200값인 ok와 201 값인 created에 대해서 미리 정의를 해주었고 그에 맞는 값들을 넣어주었다.

실패의 경우에는 일단 개발자(우리) 정의 에러를 반환하는데에 사용할 fail을 만들어주었는데 개발자 정의 에러는 CustomException 클래스이며 해당 클래스에서 필요한 값들을 알맞게 httpStatus, error 등에 넣어주었다.

ExceptionDto

ApiResponse에서 error를 담당할 ExceptionDto이다.

import com.tkdwns414.Template.dto.type.ErrorCode;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;

@Getter
public class ExceptionDto {
    @NotNull
    private final Integer code;

    @NotNull
    private final String message;

    public ExceptionDto(ErrorCode errorCode) {
        this.code = errorCode.getCode();
        this.message = errorCode.getMessage();
    }

    public static ExceptionDto of(ErrorCode errorCode) {
        return new ExceptionDto(errorCode);
    }
}

ExceptionDto에서는 우리가 정의한 custom code를 보여줄 code 값, 그리고 상황에 맞는 문장을 보여줄 message 값을 정의하였다. 둘 다 예외가 일어났을 때 필수적으로 보내주는 값이므로 @NotNull이 붙게 되었다. 바로 뒤에 설명하겠지만 CustomException과 ErrorCode라는 것이 존재하는 것을 확인할 수 있는데 ErrorCode는 우리가 정의한 예외에 대해서 모아놓은 ENUM이다. ErrorCode에서 우리가 정의한 code와 message를 가져와서 넣어줄 수 있다.

CustomException & ErrorCode

CustomException

import com.tkdwns414.Template.dto.type.ErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class CustomException extends RuntimeException{
    private final ErrorCode errorCode;

    public String getMessage() {
        return errorCode.getMessage();
    }
}

CustomException은 개발자가 추가로 정의할 Exception을 표현하기 위한 class로 RuntimeException을 상속받고 있다. 내부 값으로는 ErrorCode를 가지고 있는데 필요할 경우 해당 ErrorCode의 메시지를 받아올 수 있게 만들었다. 이는 후에 예외가 일어난 부분에 대한 로그를 띄우기 위함이다.

ErrorCode

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
@AllArgsConstructor
public enum ErrorCode {
    // Test Error
    TEST_ERROR(10000, HttpStatus.BAD_REQUEST, "테스트 에러입니다."),
    // 404 Not Found
    NOT_FOUND_END_POINT(40400, HttpStatus.NOT_FOUND, "존재하지 않는 API입니다."),
    // 500 Internal Server Error
    INTERNAL_SERVER_ERROR(50000, HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류입니다.");

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

ErrorCode는 위에서 언급했듯 개발자(우리)가 추가적으로 예외처리하고 싶은 부분에 대해서 정의해놓은 ENUM이다. 값으로는 custom code를 저장할 code, http status code 값을 저장할 httpStatus, 그리고 개발자가 어떤 오류인지 더 잘 파악할 수 있게 해주는 message를 갖고 있다.

GlobalExceptionHandler

import com.tkdwns414.Template.dto.common.ApiResponse;
import com.tkdwns414.Template.dto.type.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    // 존재하지 않는 요청에 대한 예외
    @ExceptionHandler(value = {NoHandlerFoundException.class, HttpRequestMethodNotSupportedException.class})
    public ApiResponse<?> handleNoPageFoundException(Exception e) {
        log.error("GlobalExceptionHandler catch NoHandlerFoundException : {}", e.getMessage());
        return ApiResponse.fail(new CustomException(ErrorCode.NOT_FOUND_END_POINT));
    }


    // 커스텀 예외
    @ExceptionHandler(value = {CustomException.class})
    public ApiResponse<?> handleCustomException(CustomException e) {
        log.error("handleCustomException() in GlobalExceptionHandler throw CustomException : {}", e.getMessage());
        return ApiResponse.fail(e);
    }

    // 기본 예외
    @ExceptionHandler(value = {Exception.class})
    public ApiResponse<?> handleException(Exception e) {
        log.error("handleException() in GlobalExceptionHandler throw Exception : {}", e.getMessage());
        e.printStackTrace();
        return ApiResponse.fail(new CustomException(ErrorCode.INTERNAL_SERVER_ERROR));
    }
}

다음은 전역적인 예외 처리를 담당할 GlobalExceptionHandler이다. 로그를 남기기 위해 @Slf4j를 달아주었으며 @ControllerAdvice@ResponseBody가 적용된 @RestControllerAdvice를 사용해주었다.

@RestControllerAdvice는 Bean으로 등록됨을 통해 전체 controller에 대해서 작동하게 할 advice(method)를 정의할 수 있게 해준다. 그리고 @ExceptionHandler가 특정 예외가 일어났을 때의 조건을 잡아주는 어노테이션이며 value 값을 통해서 어떤 예외에 대해 아래 메소드가 실행될지 지정해줄 수 있게 해준다. 이때 각 method는 log를 출력하는데 여기서 ErrorCode에서 정의된 message를 가져와서 보여줄 수 있다. 반환 값으로는 아까 만들어둔 ApiResponse의 fail에 CustomException을 넣어주었다.

application.yml

spring:
  web:
    resources:
      add-mappings: false # Disable static resource mapping (For NoHandlerFoundException)

application.yml에 다음과 같은 내용을 추가해주었는데 이는 NoHandlerFoundException이 발생했을 때 스프링이 static 파일까지 뒤져보는 것을 방지하기 위함이다. 내가 만든 것은 RestApi니까 static 파일까지 보지 않을 수 있게 넣어주었다.

중간 점검

여기까지만 코드를 작성하면 예외 처리와 ApiResponse에 대한 내용이 거의 끝나간다. 지금까지의 작업 결과를 보기 위해 테스트용 컨트롤러를 하나 만들어주자.

  • TestController
import com.tkdwns414.Template.dto.common.ApiResponse;
import com.tkdwns414.Template.dto.type.ErrorCode;
import com.tkdwns414.Template.exception.CustomException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/test")
public class TestController {

    @GetMapping("/responseEntity")
    public ResponseEntity<String> responseEntity() {
        return ResponseEntity.ok("ok");
    }

    @GetMapping("/success1")
    public ApiResponse<?> successWithMessage() {
        return ApiResponse.ok("ok");
    }

    @GetMapping("/success2")
    public ApiResponse<?> successWithoutMessage() {
        return ApiResponse.ok(null);
    }

    @GetMapping("/success3")
    public ApiResponse<?> successWithObject() {
        return ApiResponse.created("created");
    }

    @GetMapping("/exception1")
    public ApiResponse<?> testCustomException() {
        throw new CustomException(ErrorCode.TEST_ERROR);
    }

    @GetMapping("/exception2")
    public ApiResponse<?> testException() {
        throw new RuntimeException();
    }
}

해당 API로 요청을 보내보면 다음과 같은 결과들을 얻을 수 있다. 그런데 여기에는 문제점이 존재하는데 한 번 찾아보자.

API 요청 결과

문제점

문제점은 바로 ResponseHeader의 http status code가 모두 200이라는 것이다.

ApiResponse를 사용하게 되면서 서버 입장에서는 모두 의도한 값을 내려주는 것이기 때문에 자동으로 ResponseHeader의 http status code가 바뀌지 않게 됐다. 그럼 이것을 어떻게 해결해야할까?

ResponseInterceptor

위의 문제점을 해결하기 위한 방법에는 여러가지가 있겠지만 (ex/ ResponseEntity로 다시 ApiResponse를 감싼다는 등...) 나는 앞서 설명했던 @RestControllerAdvice를 이용하겠다.

앞서서 @RestControllerAdvice가 전체 controller에 대해서 작동하게 할 advice(method)를 정의할 수 있게 해준다고 했다. 여기에 ResponseBodyAdvice라는 클래스를 상속받아서 ResponseInterceptor를 만들어보도록 하겠다.

ResponseBodyAdvice는 응답 값을 반환하기 전에 가로채어 수정하거나 추가적인 처리를 할 수 있게 해주는 class이다. @RestControllerAdvice 어노테이션을 사용함을 통해 전체 controller에 대해서 내가 만들 ResponseInterceptor가 적용되도록 하겠다.

import com.tkdwns414.Template.dto.common.ApiResponse;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

@RestControllerAdvice
public class ResponseInterceptor implements ResponseBodyAdvice {

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                  Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        if (returnType.getParameterType() == ApiResponse.class) {
            HttpStatus status = ((ApiResponse<?>) body).httpStatus();
            response.setStatusCode(status);
        }

        return body;
    }
}

supports 메소드는 현재 요청이 beforeBodyWrite에 의해 처리될 요청이 맞는지 조건문을 정의해주는 메소드인데 우리는 모든 요청에 대해서 적용할 예정이므로 그냥 true를 적어주었다.

beforeBodyWrite 메소드는 컨트롤러 메소드가 반환한 객체를 최종 응답으로 클라이언트에 전송하기 전 가로채어 필요한 변형이나 추가 처리를 수행하는 메소드이다. 응답이 ApiResponse class를 반환하는 경우 내부의 httpStatus를 받아와서(아까 @JsonIgnore가 적용돼있던 값, 지금까지 계속 넣어주던 HttpStatus 값) 내보낼 응답의 statusCode를 해당 값으로 바꿔준다.
이후에 body를 반환함을 통해서 원래 의도했던 HttpStatus 값을 ResponseHeader의 status code로 사용할 수 있다.

바뀐 API 요청 결과

  • 해당 CustomException은 ErrorCode에서 HttpStatus.BAD_REQUEST를 입력하였기 때문에 400으로 뜬다

마무리

오늘은 ApiResponse 클래스를 정의하고 그에 맞는 나머지 세팅을 구성해보았다. 다음엔 Spring Security와 함께 JWT로 회원 인증을 하는 방법을 작성해보도록 하겠다.

관련 코드 pr: https://github.com/tkdwns414/FleetingSpring/pull/2

0개의 댓글