최근 다른 프로젝트에 참여하면서, 코드를 잘 짜시는 분의 코드를 보다 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);
}
}
언뜻 보면 깔끔해보이긴 하지만, 실제로 커스텀 에러와 응답을 처리할 때 코드를 너무 보기 힘들다. 에러 코드를 정의한 문서가 도메인이 많아지다 보니 코드 줄수가 늘어나고 가독성이 점점 떨어진다. 앞으로 정확한 구조를 알아보기가 힘들고, 유지보수하기 쉽게 하기 위해 설계후 구현을 해보려고 한다.
설계
다음과 같은 설계가 나왔다. 다이어그램의 왼쪽을 보면, 모든 응답의 기본 구조를 정의하는 BaseResponse
추상 클래스가 있다. 이 클래스는 모든 자식 클래스에게 “너희는 반드시 meta
와 data
를 가져야 해!”라고 강제하는 역할을 한다. API 응답은 정해진 규격이 필요하므로 상태와 동작을 모두 추상화하는 추상 클래스를 사용하였다.
이 BaseResponse
를 상속받는 SuccessResponse
와 ErrorResponse
는 각각 성공과 실패라는 구체적인 상황을 표현한다. 예를 들어 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()
를 통해 SuccessCode
와 RequestInfo
만으로 간편하게 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)
어노테이션은 data
가 null
일 경우 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 응답을 모두 수정하는 데 시간이 꽤 걸렸다. 그리고 기존 응답과 달라진 부분이 많아 이 부분은 팀원과 상의해서 수정을 반영해야 할 것 같다. (기존 응답이 달라져서 테스트코드도 수정하는데 꽤 많은 시간을 소요했다…)
이제 다음 이슈를 해결하러…