2072 단어
10 분
Spring Boot에서 일관된 API 응답(Response) 설계하기

최근 다른 프로젝트에 참여하면서, 코드를 잘 짜시는 분의 코드를 보다 API 응답을 깔끔하게 처리하신 것을 보고 놀랐다. 그리고 내가 개발하던 프로젝트의 응답을 보니, 개발했을 당시에는 구현에만 초점을 두어서 그렇게 깔끔하다고 생각이 들지 않았다. 앞으로 유지보수를 하게 될때에도 문제가 생길 것 같아 이번 기회에 리팩토링을 할까 한다.

현재 코드 상황#

현재 Response 구조이다. Controller부터 보면,

  • endpoint
@GetMapping("/info")  
public ResponseEntity<?> getUser(HttpServletRequest request) {  
  Meta metaData = createSuccessMetaData(request.getQueryString());  
  ResponseDto responseData = new ResponseDto(metaData, List.of(memberService.getMember()));  
  return ResponseEntity.ok(responseData);  
}

그리고 ResponseDto와 Meta를 보면,

  • ResponseDto
@Getter  
@AllArgsConstructor  
public class ResponseDto {
  
  private Meta meta;
  
  private List<?> data;
  
  public ResponseDto() {
  }
}
  • Meta
@Getter  
public class Meta {  
  
  private final int status;  
  private final String message;  
  private final String timestamp;  
  private final String path;  
  private final String apiVersion;  
  private final String serverName;  
  private final String requestId;  
  
  public Meta(int status, String message, String path, String apiVersion,  
      String serverName) {  
    this.status = status;  
    this.message = message;  
    this.timestamp = DateTimeUtil.localDateTimeToStringSimpleFormat(LocalDateTime.now());  
    this.path = path;  
    this.apiVersion = apiVersion;  
    this.serverName = serverName;  
    this.requestId = generateRequestId();  
  }  
  
  private static String generateRequestId() {  
    return UUID.randomUUID().toString();  
  }  
  
  public static Meta createSuccessMetaData(String path, String apiVersion,  
      String serverName) {  
    return new Meta(200, "Success", path, apiVersion, serverName);  
  }  
  
  public static Meta createErrorMetaData(int status, String message, String path,  
      String apiVersion, String serverName) {  
    return new Meta(status, message, path, apiVersion, serverName);  
  }  
}

언뜻 보면 깔끔해보이긴 하지만, 실제로 커스텀 에러와 응답을 처리할 때 코드를 너무 보기 힘들다. 에러 코드를 정의한 문서가 도메인이 많아지다 보니 코드 줄수가 늘어나고 가독성이 점점 떨어진다. 앞으로 정확한 구조를 알아보기가 힘들고, 유지보수하기 쉽게 하기 위해 설계후 구현을 해보려고 한다.

설계#

uml

다음과 같은 설계가 나왔다. 다이어그램의 왼쪽을 보면, 모든 응답의 기본 구조를 정의하는 BaseResponse 추상 클래스가 있다. 이 클래스는 모든 자식 클래스에게 “너희는 반드시 metadata를 가져야 해!”라고 강제하는 역할을 한다. API 응답은 정해진 규격이 필요하므로 상태와 동작을 모두 추상화하는 추상 클래스를 사용하였다.

BaseResponse를 상속받는 SuccessResponseErrorResponse는 각각 성공과 실패라는 구체적인 상황을 표현한다. 예를 들어 SuccessResponse는 주로 데이터 목록을 반환할 것을 예상하여 제네릭 List를 사용하고, ErrorResponse는 상세 에러 메시지를 담기 위해 string을 사용하는 등 상황에 맞게 역할을 분담하게 설계하였다.

Meta 역시 추상 클래스로, 모든 메타데이터가 공통으로 가져야 할 status, message, request_id 같은 필드들을 정의한다.

여기서 자세히 보아야 할 건 Meta를 상속하는 세 가지 구체적인 클래스이다.

  • SuccessMeta: 일반적인 성공 응답에 사용된다.
  • ErrorMeta: 에러 응답에 사용된다.
  • CursorMeta: CursorMeta는 일반 정보에 더해, cursor, size, hasNext와 같은 페이지네이션(Pagination)을 위한 전용 정보를 추가로 가지고 있다.

설계를 하고 보니, 팩토리 패턴이라고 불리는 디자인 패턴을 사용하게 된 거 같다. 이제 설계를 바탕으로 구현한 내용을 살펴보자.

구현#

Meta#

@Getter  
public abstract class Meta {  
  
  private final int status;  
  
  private final String message;  
  
  private final String timestamp;  
  
  private final String path;  
  
  private final String apiVersion;  
  
  private final String serverName;  
  
  private final String requestId;  
  
  protected Meta(int status, String message, RequestInfo info) {  
    this.status = status;  
    this.message = message;  
    this.timestamp = DateTimeUtil.localDateTimeToStringSimpleFormat(LocalDateTime.now());  
    this.path = info.path();  
    this.requestId = generateRequestId();  
    this.apiVersion = info.apiVersion();  
    this.serverName = info.serverName();  
  }  
  
  private static String generateRequestId() {  
    return UUID.randomUUID().toString();  
  }  
  
  public static Meta of(StatusCode code, RequestInfo info) {  
    return new SuccessMeta(code, info);  
  }  
}

Meta는 설계 다이어그램에 따라 모든 메타데이터 클래스가 가져야 할 공통 필드(status, message, requestId 등)를 정의한 추상 클래스로 구현하였다. 생성자를 통해 상태 코드, 메시지, 그리고 요청 정보(RequestInfo)를 받아 공통 필드를 초기화한다.

SuccessMeta#

@Getter  
public class SuccessMeta extends Meta {  
  
  protected SuccessMeta(StatusCode code, RequestInfo info) {  
    super(code.getStatusCode(), code.getMessage(), info);  
  }  
  
  public static SuccessMeta of(SuccessCode code, RequestInfo info) {  
    return new SuccessMeta(code, info);  
  }  
}

Meta를 상속받아 범용적으로 성공하는 응답을 위한 메타데이터를 구체화한 클래스이다. 부모 클래스인 Meta의 생성자를 호출하여 성공 상태 코드와 메시지를 설정한다. 정적 팩토리 메서드 of()를 통해 SuccessCodeRequestInfo만으로 간편하게 SuccessMeta 객체를 생성할 수 있다.

ErrorMeta#

@Getter  
public class ErrorMeta extends Meta {  
  
  private final String customErrorCode;  
  
  protected ErrorMeta(ErrorCode code, RequestInfo info) {  
    super(code.getStatusCode(), code.getMessage(), info);  
    this.customErrorCode = code.getCustomCode();  
  }  
  
  public static ErrorMeta of(ErrorCode code, RequestInfo info) {  
    return new ErrorMeta(code, info);  
  }  
}

에러 응답을 위한 메타데이터를 처리하며 Meta를 상속받는다. 공통 필드 외에, 애플리케이션 내부에서 정의한 고유 에러 코드를 담는 customErrorCode 필드를 추가로 가진다.

CursorMeta#

@Getter  
public class CursorMeta extends Meta {  
  private final String cursor;  
  private final int size;  
  private final boolean hasNext;  
  
  protected CursorMeta(SuccessCode code, RequestInfo info, String cursor, int size, boolean hasNext) {  
    super(code.getStatusCode(), code.getMessage(), info);  
    this.cursor = cursor;  
    this.size = size;  
    this.hasNext = hasNext;  
  }  
  
  public static CursorMeta of(SuccessCode code, RequestInfo info,  
      String cursor, int size, boolean hasNext) {  
    return new CursorMeta(code, info, cursor, size, hasNext);  
  }  
}

커서 기반 페이지네이션이 적용된 성공 응답을 위해 특화된 메타데이터 클래스이다. Meta를 상속받아 기본적인 성공 정보를 가지면서, 다음 페이지를 조회하는 데 필요한 cursor, 현재 페이지의 데이터 개수인 size, 그리고 다음 페이지 존재 여부를 나타내는 hasNext 필드를 추가로 정의한다.

BaseResponse#

@Getter  
public abstract class BaseResponse<T> {  
  private Meta meta;  
  
  @JsonInclude(JsonInclude.Include.NON_NULL)  
  private final T data;  
  
  protected BaseResponse(Meta meta, T data) {  
    this.meta = meta;  
    this.data = data;  
  }  
}

BaseResponse는 모든 응답 객체의 기본 구조를 정의하는 추상 클래스로 구현하였다. 제네릭 타입 T를 사용하여 어떤 종류의 데이터든 담을 수 있는 data 필드와 응답의 부가 정보를 담는 meta 필드를 공통으로 가진다.

data 필드에 적용된 @JsonInclude(JsonInclude.Include.NON_NULL) 어노테이션은 datanull일 경우 JSON 응답에 포함되지 않도록 하여, 불필요한 필드 노출을 막고 응답 본문을 깔끔하게 유지하기 위해 사용하였다.

SuccessResponse#

@Getter  
public class SuccessResponse<T> extends BaseResponse<T> {  
  
  protected SuccessResponse(Meta meta, T result) {  
    super(meta, result);  
  }  
  
  public static <T> SuccessResponse<T> of(SuccessCode code, RequestInfo info) {  
    return new SuccessResponse<>(SuccessMeta.of(code, info), null);  
  }  
  
  public static <T> SuccessResponse<T> of(SuccessCode code, RequestInfo info, T result) {  
    return new SuccessResponse<>(SuccessMeta.of(code, info), result);  
  }  
  
  public static <T> SuccessResponse<T> of(SuccessCode code, RequestInfo info, T result, String cursor, int size, boolean hasNext) {  
    return new SuccessResponse<>(CursorMeta.of(code, info, cursor, size, hasNext), result);  
  }  
}

BaseResponse를 상속받아 성공 응답을 구체화한 클래스이다. 정적 팩토리 메서드인 of()를 오버로딩하여 범용적으로 사용할 수 있게 구현하였다.

데이터가 없는 단순 성공 응답, 데이터가 포함된 성공 응답, 그리고 커서 기반 페이지네이션을 위한 응답(CursorMeta 사용)을 각각의 of() 메서드를 통해 일관되고 간결하게 생성할 수 있다.

ErrorResponse#

@Getter  
public class ErrorResponse<T> extends BaseResponse<T>{  
  
  protected ErrorResponse(Meta meta, T result) {  
    super(meta, result);  
  }  
  
  public static <T> ErrorResponse<T> of(ErrorCode code, RequestInfo info) {  
    return new ErrorResponse<>(ErrorMeta.of(code, info), null);  
  }  
  
  public static <T> ErrorResponse<T> of(ErrorCode code, RequestInfo info, T result) {  
    return new ErrorResponse<>(ErrorMeta.of(code, info), result);  
  }  
}

에러 상황에 대한 응답을 처리하기 위해 BaseResponse를 상속받은 클래스이다. SuccessResponse와 마찬가지로 정적 팩토리 메서드 of()를 제공하여 컨트롤러나 서비스단에서 에러 코드를 기반으로 표준화된 에러 응답 객체를 쉽게 생성할 수 있도록 구현하였다. 추가적인 에러 데이터를 포함하거나 포함하지 않는 두 가지 경우를 모두 지원하게 구현하였다.

모두 구현하고, 기존의 API 응답을 모두 수정하는 데 시간이 꽤 걸렸다. 그리고 기존 응답과 달라진 부분이 많아 이 부분은 팀원과 상의해서 수정을 반영해야 할 것 같다. (기존 응답이 달라져서 테스트코드도 수정하는데 꽤 많은 시간을 소요했다…)

gradlew

이제 다음 이슈를 해결하러…

Spring Boot에서 일관된 API 응답(Response) 설계하기
https://blog-full-of-desire-v3.vercel.app/posts/resona/api-response/api-response-refactoring/
저자
SpeculatingWook
게시일
2025-07-26
라이선스
CC BY-NC-SA 4.0