백엔드는 서버 부하 최소화와 빈틈없는 로직도 중요하지만, 처리 후 얼마나 알아먹기 좋은 데이터를 전달할 수 있는가도 중요합니다.
때문에 하나의 포트폴리오가 될 이번 프로젝트에서 데이터를 최대한 이쁘게 Response하는 것 또한 저희 팀에서 중요한 과제였습니다.
Response에 대한 고민 과정과 저희 팀 나름대로의 해결 방안에 대해 정리해보았습니다.
응답할 형태를 정하기
Rest API 환경에서 Response에 대해 많이 헤매고 고민한 이유는 이전 프로젝트에선 GraphQL을 사용했기 때문입니다.
(GraphQL 쿼리 조회 사용하는 모습)
GrqphQL은 클라이언트가 필요한 데이터만 쿼리문으로 골라서 받아올 수 있기 때문에 데이터를 어떤 형식으로 줘야 할지에 대한 고민이 덜했던 것 같습니다.
RestAPI를 사용하기로 한 이상 먼저 어떤 형태로 응답할 것인지 가장 큰 틀을 만들기로 했습니다. 그리고 고민한 결과 아래와 같이 만들었습니다.
{
"code" : 200 , // 상태 코드
"message" : "성공했습니다." , // 전달 메시지
"payload" : { "name" : "홍길동" }, // 전달 데이터
"timestamp" : "2021-08-01T00:00:00Z" , // 응답 시간
"success" : true // 성공 여부
}
이전에 공공데이터를 사용했을 때의 경험을 토대로 성공 여부를 boolean으로 전달해주거나 상태 코드를 직접 전달해주는 공공 API가 사용이 간편하기에 두 필드를 추가했고, 성공 메시지 혹은 오류가 나면 어떤 오류가 났다는 내용을 전달해주는 메시지가 필요하다고 생각했습니다. 그리고 응답시간은 로그를 확인할 때 유용할 것 같아 추가했습니다.
이를 그대로 엔티티로 만들어주려는데, 성공했을때와 실패했을때의 엔티티 형태는 같지만 들어갈 값은 다르기에 BaseResponse라는 abstract class를 만들어주고 성공, 실패했을 때의 class를 각각 만들어주었습니다.
// BaseResponse.java
@ Getter
public abstract class BaseResponse < T > {
protected final boolean isSuccess;
protected final int code;
protected final String message;
protected final T payload;
protected final String timestamp;
protected BaseResponse ( boolean isSuccess , int code , String message , T payload ) {
this .isSuccess = isSuccess;
this .code = code;
this .message = message;
this .payload = payload;
this .timestamp = DateTimeFormatter.ISO_INSTANT. format (Instant. now (). atZone (ZoneId. of ( "Asia/Seoul" )));
}
}
// SuccessResponse.java
public class SuccessResponse < T > extends BaseResponse < T > {
protected final boolean isSuccess = true ;
protected final int code;
protected final String message;
protected final T payload;
public SuccessResponse ( int code , String message , T payload ) {
super ( true , code, message, payload);
this .code = code;
this .message = message;
this .payload = payload;
}
}
// ErrorResponse.java
public class ErrorResponse < T > extends BaseResponse < T > {
protected final boolean isSuccess = false ;
protected final int code;
protected final String message;
protected final T payload;
public SuccessResponse ( int code , String message ) {
super ( false , code, message, payload);
this .code = code;
this .message = message;
}
}
상태 코드 관리하기
위 형태의 엔티티 그대로 JSON 형태로 Response를 하기 위해선 엔티티 그 자체를 Controller에서 return하는 것이 아닌 ResponseEntity<\T>에 T를 return할 엔티티로 지정하면 됩니다.
그러기 위해선 우선 ResponseEntity에 넣을 상태 코드를 관리해주어야 합니다.
저는 ENUM을 통해 관리할 수 있겠다 생각하였고, HttpStatus자체와 상태코드 그리고 마지막으로 메시지 총 3개의 데이터를 가지도록 하였습니다.
// ApiStatus.java
@ Getter
@ AllArgsConstructor
public enum ApiStatus {
// 성공
_OK (HttpStatus.OK, 200 , "성공입니다." ),
_CREATED (HttpStatus.CREATED, 201 , "생성에 성공했습니다." ),
_ACCEPTED (HttpStatus.ACCEPTED, 202 , "요청이 수락되었습니다." ),
_NO_CONTENT (HttpStatus.NO_CONTENT, 204 , "No Content" ),
// 커스텀
_REISSUE_ACCESS_TOKEN (HttpStatus.CREATED, 201 , "액세스 토큰 재발행에 성공했습니다." ),
_CREATE_ACCESS_TOKEN (HttpStatus.CREATED, 201 , "액세스 토큰 발행에 성공했습니다." ),
// 실패
_BAD_REQUEST (HttpStatus.BAD_REQUEST, 400 , "잘못된 요청입니다." ),
_UNAUTHORIZED (HttpStatus.UNAUTHORIZED, 401 , "인증에 실패했습니다." ),
_FORBIDDEN (HttpStatus.FORBIDDEN, 403 , "접근 권한이 없습니다." ),
_NOT_FOUND (HttpStatus.NOT_FOUND, 404 , "찾을 수 없습니다." ),
_METHOD_NOT_ALLOWED (HttpStatus.METHOD_NOT_ALLOWED, 405 , "허용되지 않은 메소드입니다." ),
_CONFLICT (HttpStatus.CONFLICT, 409 , "충돌이 발생했습니다." ),
_INTERNAL_SERVER_ERROR (HttpStatus.INTERNAL_SERVER_ERROR, 500 , "서버 내부 오류가 발생했습니다." ),
_SERVICE_UNAVAILABLE (HttpStatus.SERVICE_UNAVAILABLE, 503 , "서비스를 사용할 수 없습니다." ),
// 커스텀
_INVALID_TOKEN (HttpStatus.UNAUTHORIZED, 401 , "유효하지 않은 토큰입니다." ),
_INVALID_ACCESS_TOKEN (HttpStatus.UNAUTHORIZED, 401 , "유효하지 않은 액세스 토큰입니다." ),
_INVALID_REFRESH_TOKEN (HttpStatus.UNAUTHORIZED, 401 , "유효하지 않은 리프레쉬 토큰입니다." ),
_INVALID_TEMPORARY_TOKEN (HttpStatus.UNAUTHORIZED, 401 , "유효하지 않은 임시 토큰입니다." ),
_REFRESH_TOKEN_NOT_FOUND (HttpStatus.NOT_FOUND, 404 , "해당 유저 ID의 리프레쉬 토큰이 없습니다." ),
private final HttpStatus httpStatus;
private final int code;
private final String message;
}
그래서 여기서 범용적이지 않은 오류를 커스텀할 때 상태코드와 메시지를 지정해주고 해당 ApiStatus를 ResponseEntity에 전달하도록 설정했습니다.
ResponseEntity 생성하기
이제 Controller에서 ResponseEntity를 return하기 위해 ResponseEntity를 생성해야하는데, 너무 반복적인 작업이었습니다. 때문에 ResponseEntity의 타입이 될 SuccessResponse, ErrorResponse에 함수를 만들어 ResponseEntity로 변신하도록 만들었습니다.
// SuccessResponse.java
public class SuccessResponse < T > extends BaseResponse < T > {
// ...생략
public static < T > ResponseEntity<BaseResponse< T >> of (ApiStatus code , T data ) {
BaseResponse< T > response = new SuccessResponse<>(code. getCode (), code. getMessage (), data);
return ResponseEntity. status (code. getHttpStatus ()). body (response);
}
}
// ErrorResponse.java
public class ErrorResponse < T > extends BaseResponse < T > {
// ...생략
public static < T > ResponseEntity<BaseResponse< T >> of (ApiStatus code ) {
BaseResponse< T > response = new ErrorResponse<>(code. getCode (), code. getMessage ());
return ResponseEntity. status (code. getHttpStatus ()). body (response);
}
}
각각 of라는 함수를 만들어 상태코드와 응답할 데이터를 인수로 전달하면 ResponseEntity로 만들어져 return되도록 설정했습니다.
오류를 처리하려고 하니 얼마전 보던 강의에서 ExceptionHandler로 전역적인 오류 처리를 하는 것이 기억났습니다. 이를 사용하여 간단하게 오류처리를 하였습니다.
먼저 Exception을 상황에 맞게 만들어줍니다. (예시는 Jwt관련 오류가 났을 때의 Exception입니다.)
// JwtTokenException.java
@ Getter
@ RequiredArgsConstructor
public class JwtTokenException extends RuntimeException {
private final ApiStatus status;
@ Override
public String getMessage () {
return status. getMessage ();
}
}
그리고 전체적으로 JwtTokenException이 발생하면 위에서 만든 ErrorResponse.of()함수를 통해 오류 응답 엔티티를 만들어 ResponseEntity로 생성해주면 되었습니다.
// GlobalExceptionHandler.java
@ RestControllerAdvice
public class GlobalExceptionHandler {
@ ExceptionHandler (JwtTokenException.class)
public ResponseEntity<BaseResponse< String >> handleJwtTokenException (JwtTokenException ex ) {
return ErrorResponse. of (ex. getStatus ());
}
}
이렇게 하면 JwtTokenException을 발생시킬 때의 ApiStatus에 맞춰서 오류 엔티티가 만들어지고 이를 ResponseEntity형태로 만들어 return할 수 있었습니다.
Controller에서 사용하기
위에서 만든 기능들을 종합적으로 사용하여 Controller에 적용해보겠습니다.
// AuthController.java
@ RestController
@ RequestMapping ( "/auth" )
@ RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
public ResponseEntity<BaseResponse< Object >> reissueAccessToken (@ CookieValue ( name = "refresh_token" , required = false ) String refreshToken ) {
// Error 발생 예시
if (refreshToken == null ) {
throw new JwtTokenException (ApiStatus._REFRESH_TOKEN_NOT_FOUND);
}
// 성공 리턴 예시
AccessTokenVo accessToken = authService. reissueAccessToken (refreshToken);
return SuccessResponse. of (ApiStatus._REISSUE_ACCESS_TOKEN, accessToken);
}
}
JwtTokenExcepion이 발생하는 상황에서는 따로 처리 과정이 없지만 @ExceptionHandler 로 오류 처리하기 과정에서 만든 ExceptionHandler에서 정의한대로 ErrorResponse형태의 ResponseEntity가 return됩니다.
성공했을 때는 ResponseEntity 생성하기 에서 만든 of함수를 사용했기 때문에 accessToken 데이터를 payload로 받은 SuccessResponse형태의 ResponseEntity가 만들어집니다.
이 두 형태 모두 ResponseEntity<BaseResponse</Object/>>형태를 만족하기 때문에 문제 없이 성공/실패 상황 모두 Response를 깔끔하게 처리할 수 있게 되었습니다.
[성공 결과]
{
"code": 201,
"message": "액세스 토큰 재발행에 성공했습니다.",
"payload": {
"access_token": "eyJhbGciOiJIUzM4NCJ9.eyJ1c2VySWQiOiI3YmE4YzlhMy1mM2I1LTRlNmUtYWRmNC03MWU2MmRlZDE0ZGEiLCJpYXQiOjE3Mzg0MjQ2NjQsImV4cCI6MTczODQyODI2NH0.nMnt_VEABM-EN6KpU00vXqDBJhpZtO2UBf4QZeEje3-ll6v"
},
"timestamp": "2025-12-31T15:44:24.849776752Z",
"success": true
}
[실패 결과]
{
"code": 404,
"message": "해당 유저 ID의 리프레쉬 토큰이 없습니다.",
"payload": null,
"timestamp": "2024-12-31T14:49:15.926913162Z",
"success": false
}