API 공통 응답은 클라이언트가 결과를 일관되게 해석하도록 응답 필드를 약속하는 방식입니다. 공통 응답 객체를 사용하더라도 성공·실패에 맞는 HTTP 상태 코드는 유지해야 합니다. 모든 요청을 200으로 반환하고 본문에서만 실패를 표시할 필요는 없습니다.
이 글에서는 status·message·data 필드를 사용합니다. 이는 프로젝트의 API 계약이며 HTTP 표준이 정한 필수 형태는 아닙니다.
| 상황 | HTTP 상태 예 | 본문 |
|---|---|---|
| 조회 성공 | 200 | 성공 상태와 데이터 |
| 잘못된 입력 | 400 | 안전한 오류 설명 |
| 존재하지 않는 대상 | 404 | 대상을 찾을 수 없다는 설명 |
| 예상하지 못한 서버 오류 | 500 | 내부 정보를 제외한 오류 설명 |
201 응답에서는 필요에 따라 Location을 제공하고, 204처럼 본문을 보내지 않는 상태에는 공통 JSON을 억지로 넣지 않습니다. 파일 다운로드·스트리밍 응답도 일반 JSON과 다른 형식을 유지할 수 있습니다.
다음 코드는 Java 17, Spring MVC 6.1 계열과 Jackson을 사용하는 Spring Boot 3.x 웹 애플리케이션용입니다. 클래스들을 article90 패키지의 각 파일에 넣고 컴포넌트 스캔 범위에 포함합니다. 예제 서비스는 ID 1인 사용자 한 명을 메모리에 둡니다. 실제 DB 저장소나 인증·인가 구현은 포함하지 않습니다.
record를 사용해 접근자 없는 필드만 선언하던 예제의 직렬화 문제를 피하고, 응답 데이터를 명시적으로 표현합니다. 엔티티 전체 대신 외부에 제공할 필드만 담는 DTO를 사용합니다.
package article90;
public record ApiResponse<T>(String status, String message, T data) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>("SUCCESS", "OK", data);
}
public static ApiResponse<Void> failure(String message) {
return new ApiResponse<>("FAILURE", message, null);
}
}
입력 오류와 대상 부재를 별도 예외로 구분합니다. IllegalArgumentException 전체를 무조건 400으로 처리하면 서버 내부의 프로그래밍 오류까지 클라이언트 잘못으로 분류할 수 있습니다.
package article90;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class DemoUserService {
public record UserView(long id, String name) {}
public static class InvalidUserId extends RuntimeException {}
public static class UserNotFound extends RuntimeException {}
private final Map<Long, UserView> users = Map.of(1L, new UserView(1L, "anlee"));
public UserView find(long id) {
if (id <= 0) throw new InvalidUserId();
UserView user = users.get(id);
if (user == null) throw new UserNotFound();
return user;
}
}
package article90;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private final DemoUserService service;
public UserController(DemoUserService service) {
this.service = service;
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<DemoUserService.UserView>> get(
@PathVariable("id") long id) {
return ResponseEntity.ok(ApiResponse.success(service.find(id)));
}
}
@RestControllerAdvice는 @ControllerAdvice와 @ResponseBody의 의미를 함께 제공합니다. @ControllerAdvice에서도 ResponseEntity를 반환하면 본문 응답을 만들 수 있지만, REST 응답 목적을 명확히 하기 위해 여기서는 @RestControllerAdvice를 사용합니다.
ResponseEntityExceptionHandler를 확장해 타입 변환 실패·지원하지 않는 HTTP 메서드 등 Spring MVC가 처리하는 예외의 원래 상태와 헤더를 보존합니다. 일반 Exception 처리기만 두면 이런 오류가 부정확한 500 응답으로 바뀔 수 있습니다.
package article90;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(DemoUserService.InvalidUserId.class)
public ResponseEntity<ApiResponse<Void>> invalidId() {
return ResponseEntity.badRequest()
.body(ApiResponse.failure("User id must be positive"));
}
@ExceptionHandler(DemoUserService.UserNotFound.class)
public ResponseEntity<ApiResponse<Void>> notFound() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.failure("User not found"));
}
@Override
protected ResponseEntity<Object> handleExceptionInternal(
Exception ex, Object body, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
String message = status.is5xxServerError()
? "Internal server error" : "Request could not be processed";
return super.handleExceptionInternal(ex,
ApiResponse.failure(message), headers, status, request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> unexpected(Exception ex) {
log.error("Unexpected request failure", ex);
return ResponseEntity.internalServerError()
.body(ApiResponse.failure("Internal server error"));
}
}
handleExceptionInternal은 공통 본문으로 바꾸되 프레임워크가 결정한 상태·헤더를 넘깁니다. 응답이 이미 커밋된 경우 등 상위 클래스의 처리 조건도 유지합니다. 서비스에서 발생한 예상 밖의 오류는 서버에 기록하고, 응답에는 내부 예외 메시지를 그대로 내보내지 않습니다. Spring MVC 예외 처리
이 예제는 프레임워크 오류에 일반적인 메시지를 사용합니다. 실무에서는 필드별 검증 오류나 클라이언트가 분기할 안정적인 오류 코드 등을 계약에 추가할 수 있습니다. 로그에도 토큰·비밀번호 등 민감한 값이 포함되지 않도록 관리해야 합니다.
MockMvc로 다음 6개 경우의 상태와 JSON 응답을 확인했습니다. 실제 네트워크·DB·Spring Security까지 포함한 통합 테스트를 의미하지는 않습니다.
| 요청·조건 | 확인 결과 |
|---|---|
| GET /api/v1/users/1 | 200, SUCCESS와 사용자 DTO |
| GET /api/v1/users/2 | 404, FAILURE |
| GET /api/v1/users/0 | 400, FAILURE |
| GET /api/v1/users/abc | 타입 변환 실패를 400으로 유지 |
| POST /api/v1/users/1 | 405와 Allow 헤더 유지 |
| 서비스의 예상하지 못한 예외 | 500, 내부 예외 문구는 응답에서 제외 |
ControllerAdvice는 애플리케이션의 모든 스레드와 모든 예외를 감싸는 전역 try-catch가 아닙니다. Spring MVC의 DispatcherServlet과 예외 해석 체계에서 처리하는 범위를 대상으로 합니다.
Security 필터에서 발생하는 인증·인가 실패는 AuthenticationEntryPoint·AccessDeniedHandler 등 해당 경계에서 맞춰야 합니다. 백그라운드 작업·스케줄러·별도 스레드, 이미 응답을 전송한 스트리밍 오류도 별도의 처리 방식이 필요합니다.
Advice가 예외를 처리했다고 기존 트랜잭션의 롤백이 자동 취소되는 것도 아닙니다. 트랜잭션 경계와 HTTP 오류 응답은 역할이 다릅니다.
장점은 반복 코드를 줄이고 클라이언트의 성공·오류 처리를 일관되게 만들 수 있다는 점입니다. 비용은 추가 필드·래핑·변환과 형식 변경 시의 호환성 관리입니다. 공통 응답 때문에 HTTP 상태 코드 사용이 제한되는 것은 아닙니다.
오류 응답에는 RFC 9457 기반의 ProblemDetail·ErrorResponse를 사용하는 선택지도 있습니다. Spring은 이를 위한 지원을 제공하므로, 자체 응답 형식과 비교해 API 계약에 맞는 방식을 선택합니다. Spring 오류 응답 지원
모든 결과를 똑같이 감싸는 것보다 HTTP 의미와 클라이언트가 이해할 오류 계약을 일관되게 지키는 것이 중요합니다.