Spring BootREST APIResponse

Spring Rest API Response 구조 설계하기

응답 형식을 한 번 정해두면 프론트 연동과 운영 로그가 훨씬 안정된다는 걸 느끼면서, 성공/실패 응답 구조를 어떻게 정리했는지 적었습니다.

Srue2026년 3월 22일
Spring Rest API Response 구조 설계하기

REST API를 만들 때 처음에는 응답 구조를 깊게 고민하지 않았습니다.
데이터만 잘 내려가면 되는 거 아닌가 싶었기 때문입니다.

그런데 화면이 늘고 API가 많아질수록 응답 구조는 생각보다 큰 차이를 만들었습니다.
어떤 API는 바로 배열을 내려주고, 어떤 API는 data로 감싸고, 어떤 API는 페이징 정보가 응답 맨 아래에 붙는 식이면 프론트에서도 매번 다른 방식으로 해석해야 합니다.

결국 응답 구조를 정리하는 건 예쁘게 만드는 일이 아니라, API를 예측 가능하게 만드는 일에 더 가깝다고 느꼈습니다.

처음에 불편했던 점

가장 먼저 불편했던 건 같은 서비스 안에서도 응답이 제각각이라는 점이었습니다.

예를 들어:

{
  "id": 1,
  "name": "Notebook"
}

어떤 API는 이렇게 내려가고,

{
  "data": {
    "id": 1,
    "name": "Notebook"
  }
}

어떤 API는 이렇게 내려갔습니다.

처음엔 별 차이 없어 보여도 프론트에서는 이 차이가 계속 쌓입니다.
응답마다 파싱 방식이 다르고, 공통 처리도 어려워집니다.

무조건 한 가지 형태로 다 감쌀 필요는 없다고 봤다

처음에는 모든 성공 응답을 아래처럼 하나로 감싸는 방법도 고민했습니다.

{
  "success": true,
  "data": { ... }
}

하지만 실제로 운영해보니 항상 이렇게 감싸는 게 꼭 좋은 건 아니었습니다.

특히 단순 조회 API에서는 응답이 한 번 더 깊어지고, 프론트 코드도 괜히 response.data.data 같은 식으로 길어질 수 있습니다.
그래서 저는 "모든 응답을 억지로 통일"하기보다, 성공 응답은 단순하게, 실패 응답은 일관되게 가는 쪽이 더 좋았습니다.

성공 응답은 가능한 한 단순하게

지금은 조회나 저장 성공 응답은 필요한 데이터만 그대로 내려주는 편을 더 선호합니다.

public record ProductResponse(
    Long id,
    String name,
    int price
) {}
@GetMapping("/products/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
    return ResponseEntity.ok(productService.getProduct(id));
}

이 방식은 응답을 받는 쪽에서 곧바로 쓰기 쉽습니다.
API 문서도 단순하고, 디버깅할 때도 응답 구조를 바로 읽을 수 있습니다.

대신 목록과 페이징은 별도 구조를 둔다

목록 조회는 조금 달랐습니다.
리스트 데이터만 내려주면 다음 페이지가 있는지, 전체 개수가 얼마인지, 현재 페이지가 무엇인지 표현하기 어렵습니다.

그래서 목록은 공통 응답 객체를 따로 뒀습니다.

public record PageResponse<T>(
    List<T> items,
    int page,
    int size,
    long totalCount,
    boolean hasNext
) {}

이 구조는 단순했지만 효과가 좋았습니다.
목록 응답은 항상 items + page meta 조합이라는 기준이 생기니까, 프론트에서도 페이지 컴포넌트를 재사용하기 쉬워졌습니다.

ResponseBodyAdvice로 공통 래핑을 넣는 방법도 있다

여기까지 정리하면 자연스럽게 떠오르는 방법이 하나 있습니다.
컨트롤러에서 매번 응답 래퍼를 직접 만들지 말고, ResponseBodyAdvice로 한 번에 감싸는 방식입니다.

실제로 팀에서 성공 응답을 반드시 같은 형식으로 내리기로 했다면 이 방법이 꽤 편합니다.

@RestControllerAdvice
public class ApiResponseAdvice implements ResponseBodyAdvice<Object> {
 
    @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 (body instanceof ApiResponse<?>) {
            return body;
        }
 
        return ApiResponse.success(body);
    }
}

이렇게 두면 컨트롤러는 DTO만 반환해도 실제 응답은 공통 구조로 나가게 할 수 있습니다.

@GetMapping("/products/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
    return ResponseEntity.ok(productService.getProduct(id));
}

클라이언트가 받는 값은 아래처럼 정리됩니다.

{
  "success": true,
  "data": {
    "id": 1,
    "name": "Notebook"
  }
}

다만 모든 응답에 무조건 적용하는 건 조심했다

ResponseBodyAdvice는 분명 편합니다. 하지만 운영하면서 느낀 건, 이 방식이 모든 경우에 꼭 좋은 건 아니라는 점이었습니다.

예를 들어 아래 응답은 공통 래핑과 잘 맞지 않는 경우가 있었습니다.

  • 파일 다운로드 응답
  • 문자열 그대로 내려야 하는 응답
  • 외부 콜백용 응답
  • SSE나 스트리밍 응답

이런 경우까지 한 번에 감싸버리면 생각보다 예외 처리가 많이 생깁니다.
그래서 저는 ResponseBodyAdvice를 쓴다면, "정말 공통 응답 규칙이 필요한 JSON API"에만 적용 범위를 명확히 두는 편이 좋다고 생각합니다.

즉, 이 방식은 충분히 좋은 선택지지만, 응답을 예쁘게 통일하기 위한 기술이라기보다 팀 규칙을 코드로 강제하는 장치에 더 가깝다고 느꼈습니다.

문자열 응답은 별도 처리해야 할 때가 있었다

이 부분은 실제로 한 번 겪고 나서 더 분명해졌습니다.
ResponseBodyAdvice에서 일반 객체를 감싸는 건 괜찮았지만, String 응답은 그대로 같은 방식으로 처리하면 예상과 다르게 동작할 수 있었습니다.

보통은 StringHttpMessageConverter가 같이 엮이기 때문입니다.
그래서 문자열 응답은 따로 분기해서 처리하는 편이 안전했습니다.

@Override
public Object beforeBodyWrite(
    Object body,
    MethodParameter returnType,
    MediaType selectedContentType,
    Class selectedConverterType,
    ServerHttpRequest request,
    ServerHttpResponse response
) {
    if (body instanceof ApiResponse<?>) {
        return body;
    }
 
    if (body instanceof String) {
        return objectMapper.writeValueAsString(ApiResponse.success(body));
    }
 
    return ApiResponse.success(body);
}

이런 분기가 없으면 어떤 응답은 잘 감싸지고, 어떤 응답은 변환기 단계에서 어색하게 깨질 수 있습니다.

그래서 ResponseBodyAdvice를 도입할 때는 보통 아래도 같이 봤습니다.

  • String 응답은 따로 처리할 것인지
  • 파일 다운로드나 스트리밍은 제외할 것인지
  • 이미 공통 응답 타입인 경우는 그대로 통과시킬 것인지

결국 이 기능은 "한 번에 예쁘게 감싼다"보다, 응답 타입별 예외를 얼마나 차분하게 정리하느냐가 더 중요했습니다.

실패 응답은 반드시 같은 구조로 정리했다

성공 응답은 상황에 따라 조금 달라도 되지만, 실패 응답은 가능하면 꼭 같아야 한다고 느꼈습니다.

{
  "code": "ORDER-404",
  "message": "주문을 찾을 수 없습니다."
}

이 구조가 좋았던 건 다음 때문입니다.

  • 프론트가 공통 에러 처리 UI를 만들기 쉽다
  • 로그와 응답 코드가 연결된다
  • 운영에서 같은 문제를 분류하기 쉽다

실패 응답까지 제각각이면 API는 곧바로 불친절해집니다.

너무 많은 메타는 오히려 줄였다

예전에는 응답마다 timestamp, path, status, success, requestId를 다 넣고 싶었던 시기도 있었습니다.
그런데 실제로는 모든 응답에 그렇게 많은 메타가 필요하지 않았습니다.

오히려 중요한 건:

  • 지금 요청이 성공했는지
  • 데이터가 무엇인지
  • 실패했다면 어떤 코드인지

정도였습니다.

메타 정보를 무조건 많이 넣는 것보다, 필요한 자리에서만 명확하게 넣는 편이 더 읽기 좋았습니다.

실제로 기준이 정리되니 좋았던 점

응답 구조를 정리하고 나서 가장 먼저 좋아진 건 프론트와 이야기할 때였습니다.

예전에는 "이 API는 data 안에 있고, 저 API는 바로 배열이에요" 같은 설명이 자주 필요했습니다.
지금은 아래 정도로 말하면 끝납니다.

  • 단건 조회: DTO 그대로
  • 목록 조회: PageResponse<T>
  • 실패 응답: code, message

이 기준이 있으면 API가 늘어나도 설명 비용이 확실히 줄었습니다.

제가 지금도 지키는 기준

응답 구조를 설계할 때 지금은 아래 질문을 먼저 봅니다.

  1. 이 응답을 프론트가 바로 쓰기 쉬운가
  2. 목록 메타가 필요한 응답인가
  3. 실패했을 때 공통 처리할 수 있는가
  4. 메타 필드가 진짜 필요한가

이 질문을 먼저 던지면 과하게 복잡한 래퍼를 만드는 일을 꽤 줄일 수 있었습니다.

마무리

REST API Response 구조는 화려하게 설계하는 것보다, 오래 흔들리지 않는 기준을 만드는 일이 더 중요했습니다.

지금도 새 API를 만들 때는 응답을 어떻게 감쌀까보다, 이 응답을 다음 사람이 얼마나 쉽게 예측할 수 있을지를 먼저 봅니다.
그 기준이 맞을수록 API는 문서 없이도 조금 더 읽기 쉬워졌습니다.