상세 컨텐츠

본문 제목

Spring Boot에서 REST API 응답 구조를 일관되게 설계하는 방법

Programming/Spring Boot

by 추천캐릭터 2026. 6. 25. 17:27

본문

728x90

Spring Boot로 REST API를 만들다 보면 처음에는 Controller에서 데이터를 바로 반환하는 방식으로 개발하게 됩니다.

@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
    return userService.getUser(id);
}

간단한 예제에서는 이 방식도 문제가 없어 보입니다. 하지만 실제 프로젝트가 커지면 API 응답 형식이 제각각 달라지는 문제가 생깁니다.

어떤 API는 데이터만 반환하고, 어떤 API는 메시지를 포함하고, 어떤 API는 에러 발생 시 전혀 다른 구조로 응답합니다. 이렇게 되면 프론트엔드에서는 응답을 처리하기 어려워지고, 백엔드에서도 유지보수가 복잡해집니다.

그래서 REST API를 설계할 때는 성공 응답과 실패 응답의 구조를 일관되게 정리하는 것이 중요합니다.

왜 API 응답 구조를 통일해야 할까?

API 응답 구조를 통일하면 다음과 같은 장점이 있습니다.

첫째, 프론트엔드에서 응답을 예측하기 쉬워집니다.
둘째, 에러 처리가 단순해집니다.
셋째, API 문서화가 쉬워집니다.
넷째, 백엔드 코드의 유지보수성이 좋아집니다.
다섯째, 팀 단위 개발에서 규칙을 맞추기 쉽습니다.

예를 들어 어떤 API는 다음처럼 응답한다고 해보겠습니다.

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

그런데 다른 API는 이렇게 응답합니다.

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

또 에러가 발생했을 때는 다음처럼 응답할 수 있습니다.

{
  "timestamp": "2026-06-25T10:00:00",
  "status": 404,
  "error": "Not Found",
  "path": "/users/1"
}

이렇게 응답 구조가 통일되어 있지 않으면 API를 사용하는 쪽에서는 매번 다른 방식으로 데이터를 처리해야 합니다. 따라서 처음부터 공통 응답 포맷을 정해두는 것이 좋습니다.

기본 응답 구조 설계하기

가장 단순한 공통 응답 구조는 다음과 같이 만들 수 있습니다.

{
  "success": true,
  "message": "요청이 성공했습니다.",
  "data": {}
}

각 필드의 의미는 다음과 같습니다.

필드설명

success 요청 성공 여부
message 응답 메시지
data 실제 응답 데이터

이를 Java 클래스로 만들면 다음과 같습니다.

@Getter
@AllArgsConstructor
public class ApiResponse<T> {

    private boolean success;
    private String message;
    private T data;

    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, "요청이 성공했습니다.", data);
    }

    public static <T> ApiResponse<T> success(String message, T data) {
        return new ApiResponse<>(true, message, data);
    }
}

이제 Controller에서는 데이터를 직접 반환하지 않고 ApiResponse로 감싸서 반환할 수 있습니다.

@GetMapping("/users/{id}")
public ApiResponse<UserResponse> getUser(@PathVariable Long id) {
    UserResponse response = userService.getUser(id);
    return ApiResponse.success(response);
}

응답 결과는 다음과 같습니다.

{
  "success": true,
  "message": "요청이 성공했습니다.",
  "data": {
    "id": 1,
    "name": "kim"
  }
}

이렇게 하면 모든 성공 응답이 동일한 구조를 갖게 됩니다.

실패 응답 구조 설계하기

성공 응답보다 더 중요한 것이 실패 응답입니다.
실제 서비스에서는 성공보다 실패 상황을 더 명확하게 설계해야 합니다.

예를 들어 사용자를 찾을 수 없는 경우 다음과 같은 응답을 만들 수 있습니다.

{
  "success": false,
  "message": "사용자를 찾을 수 없습니다.",
  "errorCode": "USER_NOT_FOUND"
}

실패 응답 클래스는 다음과 같이 분리할 수 있습니다.

@Getter
@AllArgsConstructor
public class ErrorResponse {

    private boolean success;
    private String message;
    private String errorCode;

    public static ErrorResponse of(String message, String errorCode) {
        return new ErrorResponse(false, message, errorCode);
    }
}

그리고 에러 코드는 enum으로 관리하면 더 안정적입니다.

@Getter
@AllArgsConstructor
public enum ErrorCode {

    USER_NOT_FOUND("USER_NOT_FOUND", "사용자를 찾을 수 없습니다."),
    INVALID_REQUEST("INVALID_REQUEST", "잘못된 요청입니다."),
    INTERNAL_SERVER_ERROR("INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다.");

    private final String code;
    private final String message;
}

이렇게 하면 문자열을 직접 입력하다가 오타가 나는 문제를 줄일 수 있습니다.

커스텀 예외 만들기

서비스 로직에서 예외를 명확하게 던지기 위해 커스텀 예외 클래스를 만들 수 있습니다.

@Getter
public class BusinessException extends RuntimeException {

    private final ErrorCode errorCode;

    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}

사용자를 찾지 못했을 때는 다음과 같이 예외를 던집니다.

public UserResponse getUser(Long id) {
    User user = userRepository.findById(id)
            .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));

    return new UserResponse(user.getId(), user.getName());
}

이제 서비스 계층에서는 상황에 맞는 예외만 던지면 됩니다.
실제 응답으로 변환하는 작업은 전역 예외 처리기에서 담당하게 만들 수 있습니다.

@RestControllerAdvice로 전역 예외 처리하기

Spring Boot에서는 @RestControllerAdvice를 사용해 전역 예외 처리를 할 수 있습니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        ErrorCode errorCode = e.getErrorCode();

        ErrorResponse response = ErrorResponse.of(
                errorCode.getMessage(),
                errorCode.getCode()
        );

        return ResponseEntity
                .badRequest()
                .body(response);
    }
}

이렇게 하면 Controller마다 try-catch를 작성하지 않아도 됩니다.

@GetMapping("/users/{id}")
public ApiResponse<UserResponse> getUser(@PathVariable Long id) {
    UserResponse response = userService.getUser(id);
    return ApiResponse.success(response);
}

Controller는 정상 흐름만 담당하고, 예외 처리는 GlobalExceptionHandler가 담당합니다.
이 구조는 코드의 책임을 분리한다는 점에서 좋습니다.

HTTP 상태코드도 함께 고려해야 한다

API 응답 구조를 설계할 때 JSON 형식만 맞추면 되는 것은 아닙니다. HTTP 상태코드도 함께 고려해야 합니다.

대표적으로 다음과 같이 사용할 수 있습니다.

상황상태코드

조회 성공 200 OK
생성 성공 201 Created
잘못된 요청 400 Bad Request
인증 실패 401 Unauthorized
권한 없음 403 Forbidden
리소스 없음 404 Not Found
서버 오류 500 Internal Server Error

예를 들어 사용자를 찾을 수 없는 경우에는 400보다 404가 더 적절할 수 있습니다.
따라서 ErrorCode에 HTTP 상태코드까지 포함시키면 더 좋습니다.

@Getter
@AllArgsConstructor
public enum ErrorCode {

    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "사용자를 찾을 수 없습니다."),
    INVALID_REQUEST(HttpStatus.BAD_REQUEST, "INVALID_REQUEST", "잘못된 요청입니다."),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다.");

    private final HttpStatus status;
    private final String code;
    private final String message;
}

그리고 예외 처리에서는 다음과 같이 사용할 수 있습니다.

@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
    ErrorCode errorCode = e.getErrorCode();

    ErrorResponse response = ErrorResponse.of(
            errorCode.getMessage(),
            errorCode.getCode()
    );

    return ResponseEntity
            .status(errorCode.getStatus())
            .body(response);
}

이렇게 하면 에러 종류에 따라 적절한 HTTP 상태코드를 반환할 수 있습니다.

성공 응답과 실패 응답을 하나로 합쳐야 할까?

API 응답을 설계할 때 자주 하는 고민이 있습니다.

성공 응답과 실패 응답을 하나의 클래스로 통합할지, 아니면 분리할지에 대한 문제입니다.

예를 들어 다음처럼 하나로 만들 수도 있습니다.

{
  "success": true,
  "message": "요청 성공",
  "data": {},
  "errorCode": null
}

하지만 성공 응답에는 errorCode가 필요 없고, 실패 응답에는 data가 필요 없는 경우가 많습니다.
그래서 성공 응답과 실패 응답은 분리하는 방식을 추천합니다.

성공 응답:

{
  "success": true,
  "message": "요청이 성공했습니다.",
  "data": {}
}

실패 응답:

{
  "success": false,
  "message": "사용자를 찾을 수 없습니다.",
  "errorCode": "USER_NOT_FOUND"
}

이렇게 분리하면 각 응답의 목적이 더 명확해집니다.

실무에서 추천하는 구조

실무에서는 다음과 같은 구성을 추천합니다.

common
 ├── response
 │    ├── ApiResponse.java
 │    └── ErrorResponse.java
 └── exception
      ├── BusinessException.java
      ├── ErrorCode.java
      └── GlobalExceptionHandler.java

각 클래스의 역할은 다음과 같습니다.

클래스역할

ApiResponse 성공 응답 공통 포맷
ErrorResponse 실패 응답 공통 포맷
ErrorCode 에러 코드와 메시지 관리
BusinessException 비즈니스 예외
GlobalExceptionHandler 전역 예외 처리

이 구조를 사용하면 응답 형식과 예외 처리 로직을 한 곳에서 관리할 수 있습니다.

마무리

Spring Boot에서 REST API를 만들 때 응답 구조는 초반에는 사소해 보일 수 있습니다. 하지만 프로젝트가 커질수록 응답 구조의 일관성은 매우 중요해집니다.

성공 응답은 일정한 형식으로 감싸고, 실패 응답은 에러 코드와 메시지를 명확하게 내려주는 것이 좋습니다. 또한 예외 처리는 Controller에서 직접 처리하지 말고 @RestControllerAdvice를 사용해 전역에서 처리하는 것이 유지보수에 유리합니다.

정리하면 핵심은 다음과 같습니다.

  1. 성공 응답과 실패 응답의 구조를 미리 정합니다.
  2. 에러 코드는 enum으로 관리합니다.
  3. 서비스 계층에서는 커스텀 예외를 던집니다.
  4. 전역 예외 처리기로 응답을 변환합니다.
  5. JSON 응답뿐 아니라 HTTP 상태코드도 함께 설계합니다.

REST API는 단순히 데이터를 주고받는 기능이 아닙니다.
클라이언트와 서버가 약속한 통신 규칙입니다.
따라서 일관된 응답 구조를 설계하는 것은 좋은 백엔드 API를 만드는 기본 조건입니다.

728x90

관련글 더보기