
공통 응답 형식을 왜 지정해야할까?
이번에 여러 프로젝트를 진행하면서 가장 뼈저리게 느꼈던 점은 다 같이 통일할 수 있는 건 최대한 통일하자는 것이었습니다.
그래서 최근 프로젝트에서 총괄 아닌 총괄을 맡으면서 제가 가장 먼저 진행한 일이 바로 API 공통 응답 형식 지정과 공통 에러 처리 체계 구축이었습니다.

그렇다면 왜 이러한 작업을 해야할까요?
개발을 하다 보면 백엔드와 프론트엔드를 한 사람이 동시에 개발하는 경우도 있지만, 현실적으로는 서로 다른 팀이 분리되어 있는 경우가 훨씬 많습니다.
즉, 프론트엔드 개발자는 백엔드의 코드를 직접 볼 수 없고, 오직 API 명세서와 실제 응답 데이터(JSON)를 기반으로 개발을 진행해야 합니다.
그런데 이때 API마다 응답 구조가 다르거나 성공/실패 케이스의 형태가 제각각이라면 프론트엔드는 매번 다른 형태를 파싱하고 예외를 따로 처리해야 하죠.
결국 이런 일관성 없는 구조는 개발 속도를 늦추고, 버그를 유발하며, 유지보수를 어렵게 만듭니다.
따라서 제가 프로젝트에서 어떤 방식으로 이러한 문제를 해결했는지 포스팅하려 합니다.
SpringBoot ResponseEntity를 이용한 공통 응답 형식 지정
먼저, 가장 간단하게 구현할 수 있는 방식입니다.
SpringBoot에서 제공하는 ResponseEntity 를 이용하는 방법입니다.
SpringBoot ResponseEntity란?
Spring Framework에서 HTTP 응답 데이터를 표현하는 객체입니다. 이 객체의 장점은 컨트롤러 메서드에서 직접 HTTP 상태 코드와 데이터를 제어할 수 있다는 점입니다.
구성 요소는 다음과 같이 3가지입니다.
- HttpBody: 클라이언트에게 보낼 데이터를 담습니다.
- HttpHeader: 클라이언트의 요청 또는 응답의 결과로 필요한 메타 데이터를 담습니다.
- HttpStatusCode: 클라이언트가 서버 측에 보낸 요청에 대한 결과를 코드로 구분하여 알려줍니다.
- 1xx: 정보 - 요청 처리 진행 중
- 2xx: 성공 - 요청 처리 성공
- 3xx: 리다이렉션 - 요청 완료를 위해 클라이언트가 추가 작업을 수행해야 함
- 4xx: 클라이언트 오류 - 클라이언트의 요청에 오류가 있거나 권한이 없는 경우 등
- 5xx: 서버 오류 - 서버가 요청을 처리하는 중 오류가 발생함
실제로는 다음과 같이 구성되어 있습니다.
public ResponseEntity(@Nullable T body, @Nullable MultiValueMap<String, String> headers, int rawStatus) {
this(body, headers, HttpStatusCode.valueOf(rawStatus));
}
어떻게 구현할 수 있을까?
이 객체를 이용해서 어떻게 공통 응답 형식을 지정할 수 있을까요?
바로 컨트롤러에서 보낼 데이터를 리턴할 때 ResponseEntity로 감싸서 보내주면 됩니다.
만약 제가 HttpBody에 "안녕"이라는 데이터를 넣어서 보내주고 싶다면 다음과 같은 객체를 생성해 보내주면 됩니다.
너의 요청은 성공했고, "안녕"이라는 데이터를 보내줄게!라는 의미로 해석이 됩니다.
ResponseEntity.ok().body("안녕")
실제로 제가 컨트롤러에 다음과 같이 작성했다고 가정해봅시다.
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/test1")
public ResponseEntity<String> test() {
return ResponseEntity.ok().body("안녕");
}
}
그렇다면 스웨거에서 테스트를 해볼까요?

다음과 같이 ResponseBody에 "안녕"이라는 문자열이 제대로 보내진 것을 알 수 있습니다.
단점?
해당 방식은 3가지의 단점을 가지고 있습니다.
먼저, 일관성이 부족합니다. ResponseEntity는 상태 코드와 데이터만 다룰 뿐, 응답 메시지 형식에 대한 일관성을 강제하지는 않기때문에 API마다 응답 구조가 다를 가능성이 존재합니다. 따라서 클라이언트 단의 처리 로직이 복잡해질 수도 있습니다.
쉽게 말하면 항상 백엔드 상에서 리턴을 할 때 ResponseEntity로 묶어서 보내줘야하기 때문에 깜빡하고 묶어서 보내지 않는다면...응답 형식의 일관성이 지켜지지 않겠죠?
두번째로, 추가적인 정보(메타정보)가 부족합니다. APi 호출 성공 여부 이외에도 에러 코드, 상태 메시지 등 메타 정보가 필요할 때가 존재할 수 있습니다. 이럴때마다 항상 백엔드 개발자들은 별도의 구현을 진행해야합니다.
마지막으로 에러 처리의 중복입니다. 각 컨트롤러마다 예외 처리와 에러 응답포맷을 따로 구현 해야 하기 때문에 코드 중복과 관리의 어려움이 발생합니다.
위와 같은 치명적인 단점들 때문에 우리는 응답 데이터를 표준화할 필요성이 생겼습니다.
ApiResponse를 이용한 응답 데이터 표준화
우리는 응답 데이터를 표준화하기 위해 ApiResponse를 이용하려고 합니다.
ApiResponse란?
ApiResponse<T>는 API 응답 데이터의 통일된 표준 형식을 정의하는 DTO입니다.
ApiResponse는 보통 아래와 같은 필드를 포함합니다.
- status (success / fail / error)
- code (HTTP 상태 코드, 비즈니스 에러 코드)
- message (응답 메시지)
- data (응답 데이터)
어떻게 구현할 수 있을까?
이 DTO는 Spring Boot 프레임워크의 내장 기능이 아니라 개발자가 직접 정의해서 사용하는 “커스텀 DTO (Data Transfer Object)” 입니다. 따라서 보통은 다음과 같이 직접 표준 응답 클래스를 만들어 사용합니다.
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()));
}
}
일단, DTO를 정의할 때 record형식으로 정의했는데요. record란 Java 16 이상에서 지원하는 불변 데이터 저장용 객체(Immutable DTO)를 정의하는 문법입니다.
record를 사용한 이유는 응답 객체의 불변성을 보장하기 때문에 응답 데이터가 생성된 후에 수정될 여지를 차단합니다. 따라서 안정성과 예측 가능성을 높일 수 있죠.
그렇다면 먼저 필드를 설명해보도록 하겠습니다.
@JsonIgnore HttpStatus httpStatus
Spring의 HttpStatus 열거형을 내부적으로는 가지고 있지만, 실제 JSON 응답에는 포함되지 않도록 처리했습니다. 클라이언트는 상태 코드 숫자(ex. 200, 400, ...)를 이미 HTTP Response Header에서 받을 수 있기 때문에 굳이 Body에 중복해서 실을 필요가 없기 때문입니다.
boolean success
요청을 성공 여부를 단순하게 나타냅니다. true면 정상 응답, false면 실패 또는 예외 응답입니다. 프론트엔드는 이 값 하나만 보고도 로직 분기를 단순하게 처리할 수 있습니다.
@Nullable T data
제네릭 타입 T를 사용해서 데이터를 담습니다. 제네릭 타입을 사용하기 때문에 어떤 데이터 타입이던 모두 담을 수 있습니다. 이번 포스팅에서는 성공 응답(ok, created)에서는 data가 채워지고, 실패 응답(fail)에서는 null로 설정되도록 구현했습니다.
@Nullable ExceptionDTO error
예외 발생 시 포함되는 에러 정보 객체입니다. 이는 다음 포스팅에서 다룰 예정입니다 :)
구현한 메서드는 다음과 같이 3가지 입니다.
ok() — 성공 (200 OK)
public static <T> ApiResponse<T> ok(@Nullable final T data) {
return new ApiResponse<>(HttpStatus.OK, true, data, null);
}
일반적인 성공 응답에 사용됩니다.
created() — 생성 성공 (201 Created)
public static <T> ApiResponse<T> created(@Nullable final T data) {
return new ApiResponse<>(HttpStatus.CREATED, true, data, null);
}
자원이 새로 생성된 경우에 사용합니다. POST 요청 성공 시 사용하려고 구현했으나 실제 프로젝트에서는 잘 사용하지 않았습니다....
fail() — 실패 응답
public static <T> ApiResponse<T> fail(final CustomException e) {
return new ApiResponse<>(
e.getErrorCode().getHttpStatus(),
false,
null,
ExceptionDTO.of(e.getErrorCode())
);
}
CustomException에서 가져온 ErrorCode 정보를 기반으로 응답을 생성합니다 이렇게 하면 모든 예외를 통일된 형태로 반환할 수 있어 프론트엔드는 에러 구조를 예측 가능하게 다룰 수 있습니다.
위 구조가 실제로 정상적으로 동작하는지 확인하기 위해 저는 정상 응답 케이스, 커스텀 에러 케이스, 일반 에러 케이스 이렇게 3가지 경우를 테스트 했습니다.
1. 정상 응답 케이스
@GetMapping("/test/ok")
public String ok() {
return "정상 응답임";
}

2. 커스텀 에러 케이스
@GetMapping("/test/error/user")
public String getUser() {
throw new CustomException(ErrorCode.USER_NOT_FOUND);
}

3. 일반 에러 케이스
@GetMapping("/test/error")
public String getError() {
throw new RuntimeException("알 수 없는 오류 발생");
}

문제점?
위 테스트를 살펴보면

에러를 반환했음에도 불구하고 다음과 같이 상태 코드가 200(성공)으로 처리되었습니다... 치명적인 문제점이죠!
따라서 우리는 또 다른 구조를 사용해야합니다.
RestControllerAdvice + ResponseBodyAdvice를 활용한 공통 응답 형식 구현
따라서 우리는 RestControllerAdvice와 ResponseBodyAdvice를 활용해보겠습니다.
그렇다면 이것들이 대체 뭔지 알아야겠죠?
RestControllerAdvice란?
Spring MVC의 전역 예외 처리 및 응답 조작 기능을 제공하는 어노테이션입니다. 즉 @ControllerAdvice와 @ResponseBody가 합쳐진 구조라고 보아도 무방합니다.
이를 활용해서 우리는 모든 @RestController에서 발생하는 예외를 한 곳에서 처리하거나 응답을 일괄적으로 후처리할 수 있습니다.
- 예외 처리(@ExceptionHandler)
- 바인딩 처리(@InitBinder)
- 모델 데이터 추가(@ModelAttribute)
RestBodyAdvice<T>란?
모든 컨트롤러 응답(ResponseBody)을 가로채 후처리를 할 수 있게 해주는 인터페이스입니다. 주로 @RestControllerAdvice와 함께 사용합니다.
모든 응답을 ApiResponse 형태로 자동 래핑합니다.
어떻게 구현할 수 있을까?
저는 아래와 같이 구현해보았습니다.
@RestControllerAdvice
public class ApiResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true; // 모든 응답 대상
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType,
MediaType contentType,
Class<? extends HttpMessageConverter<?>> converterType,
ServerHttpRequest request,
ServerHttpResponse response) {
if (body instanceof ApiResponse) {
HttpStatus status = ((ApiResponse<?>) body).httpStatus();
response.setStatusCode(status);
}
return body;
}
}
약간의 설명을 더해보자면
support 메서드는 여러 요청 중 beforeBodyWrite에 의해 처리될 요청들을 정의하는 메서드이고,
beforeBodyWriter 메서드는 컨트롤러에서 처리한 응답을 클라이언트에게 전송하기 전 가로채 추가처리하는 메서드입니다.
저는 이 부분이 공부하면서 가장 어려웠습니다..!
대표적으로 한 가지 경우만 테스트 해보았습니다.

이전 방법과 다른 부분은 모두 동일하지만 상태 코드가 변경된 것을 확인할 수 있습니다.
최종 처리 방식; RestControllerAdvice를 활용한 ApiResponse 통합 처리
따라서 우리는 최종적으로 RestControllerAdvice를 활용해 ApiResponse를 통합적으로 처리했습니다.
위와 같은 방식으로
먼저, 예외 처리를 통합하여 공통 예외를 잡고 ApiResponse 형식의 에러 응답으로 변환하여 반환할 수 있습니다.
둘째로, 응답 래핑을 일괄 처리할 수 있습니다. ResponseBodyAdvice 인터페이스를 구현해 ApiResponse 형식으로 자동으로 감싸 일관된 응답으로 처리합니다.
따라서 컨트롤러에서는 오로지 비즈니스 로직만 처리하고 응답 포맷은 전역 핸들러가 관리하는 형식을 구현할 수 있었습니다.
위에서 언급한 3가지 방식을 모두 정리해보자면 다음과 같습니다.
| 구현 | 문제점 | 개선 방안 |
| ResponseEntity 단독 사용 | 응답 형식 불일치, 에러 처리 중복 | 공통 응답 형식 필요 |
| ApiResponse 직접 반환 | 중복 코드 발생, 예외 일관 처리 미흡 | 전역 예외 처리 도입 필요 |
| RestControllerAdvice + ResponseBodyAdvice | 응답 및 예외 처리 포맷 일관성 확보, 중복 코드 최소화 | - |
지금까지 API 공통 응답 형식을 구현하는 방법을 알아보았습니다.
다음 포스팅에서는 이어서 공통 에러를 처리하는 방법에 대해 작성하도록 하겠습니다!

'자바 > 스프링부트' 카테고리의 다른 글
| [JAVA / SSE] 스프링부트에서 SSE 구현하기 (SSE + Redis pub/sub) - 2 (0) | 2025.10.21 |
|---|---|
| [JAVA / 스프링 부트] 커스텀 필터 & AuthorizationManager로 그룹별 API 접근 제어하기 (1) | 2025.10.21 |
| [JAVA / 스프링부트] UTC 저장 + KST 응답 구조로 타임존 문제 해결하기 (Spring Boot + MariaDB) (0) | 2025.10.20 |
| [JAVA / SSE] 스프링부트에서 SSE 구현하기 (SSE + Redis pub/sub) - 1 (0) | 2025.10.14 |
| [JAVA / 스프링부트] API 공통 응답 형식 지정 + 공통 에러 처리 - 2 (0) | 2025.10.13 |