API는 서버 안의 메서드가 아니라, 서버 바깥의 누군가가 믿고 호출하는 약속이에요.지난 글에서는 Spring MVC 요청이
DispatcherServlet을 지나 controller method로 도착하고, 반환값과 예외가 다시 HTTP 응답으로 바뀌는 흐름을 봤어요.
이제 그 흐름 위에 진짜 API를 올려볼게요.
처음에는 이런 controller를 만들기 쉬워요.
“POST /orders로 주문 객체를 받고, 저장한 뒤 다시 돌려주면 되는 거 아닌가요?”
근데요, API를 쓰는 쪽에서는 바로 더 구체적인 질문을 하게 돼요.
“필수값이 빠지면 어떤 status가 오나요?"오늘은 controller method를 예쁘게 쓰는 법보다 한 단계 앞을 볼 거예요. REST API 설계는 Java method signature가 아니라 클라이언트와 서버가 공유하는 HTTP 계약이에요. Spring MVC는
"에러 메시지는 사용자가 봐도 되는 문장인가요?"
"프론트엔드는 어떤 값으로 분기해야 하나요?"
"주문이 없을 때 404인가요, 200에 빈 body인가요?"
"필드 이름을 바꾸면 기존 앱은 깨지나요?"
"서버 내부 예외 class 이름이 응답으로 나가도 되나요?”
@RequestBody, Bean Validation, @ExceptionHandler, ProblemDetail, message converter 같은 도구를 제공하지만, 어떤 요청과 응답을 약속할지는 우리가 먼저 정해야 해요.
이 글은 Spring Boot 4.1.0과 Spring Framework 공식 문서의 Spring MVC, validation, error response 설명을 바탕으로 작성했어요. API 설계 원칙은 버전에 크게 묶이지 않지만,
ProblemDetail, validation 예외, starter 이름, API versioning 설정처럼 구체적인 기능은 사용 중인 Spring Boot 버전 문서를 함께 확인하세요.API 계약은 “메서드 호출”이 아니에요
controller method만 보면 API가 Java 메서드처럼 느껴져요.
이 약속을 코드 안쪽에서만 생각하면 늦어요. service와 repository까지 다 만든 뒤에 response 모양을 바꾸려면 프론트엔드, 모바일 앱, 외부 연동, 테스트가 같이 흔들리거든요.
이 그림에서 API 계약은 controller 안에 숨어 있는 세부 구현이 아니에요. 서버 바깥과 서버 안쪽을 나누는 경계예요. 그래서 API 응답 모양을 바꾸는 일은 단순 리팩터링이 아니라 외부 약속을 바꾸는 일이 될 수 있어요.
Entity를 그대로 주고받으면 처음에는 편하지만 오래가기 어려워요
처음 예제에서 controller가Order를 그대로 받고 그대로 돌려줬죠.
그래서 API 경계에는 보통 DTO를 둬요.
ResponseEntity는 status, header, body를 함께 표현하고 싶을 때 유용해요. 단순 조회처럼 항상 200 OK와 body만 있으면 DTO를 바로 return해도 되지만, 생성처럼 201 Created와 Location header를 명시하고 싶을 때는 응답 전체를 코드에 드러내는 편이 읽기 좋아요.
Validation은 service에 들어가기 전의 문지기예요
요청 body가 JSON에서 Java 객체로 바뀌었다고 해서 그 값이 의미 있는 값은 아니에요.@RequestBody로 읽은 객체에 @Valid를 붙이면 Bean Validation 규칙을 적용할 수 있어요.
quantity가 1 이상인지, productId가 비어 있지 않은지 같은 입력 모양 검사는 DTO에서 빨리 걸러낼 수 있어요. 하지만 “이 상품이 지금 판매 가능한가”, “이 사용자가 이 주문을 만들 권한이 있는가”, “재고가 충분한가” 같은 규칙은 service나 domain 영역에서 판단해야 해요.
조금 더 깊게 보면 Spring MVC의 validation 실패 예외는 method signature에 따라 달라질 수 있어요.
@RequestBody나 @ModelAttribute 단일 객체 검증에서는 MethodArgumentNotValidException을 볼 수 있고, controller method parameter 자체에 @Min, @NotBlank 같은 제약을 붙인 경우에는 method validation 흐름에서 HandlerMethodValidationException을 볼 수 있어요.
처음에는 이름을 외울 필요까지는 없어요. 다만 error handler를 만들 때 “검증 실패는 한 종류의 예외만 온다”고 단정하면 빠지는 경우가 생겨요.
에러 응답은 나중에 대충 맞추면 안 돼요
성공 응답만 있으면 API는 반쪽이에요. 실제 클라이언트 코드는 실패를 더 자주 신경 써야 해요. 예를 들어 주문 조회 API가 있다고 해볼게요.
Spring Framework는 RFC 9457 형식의 문제 상세 응답을 표현하는
ProblemDetail을 제공해요. Spring Boot에서는 spring.mvc.problemdetails.enabled=true로 Spring MVC 기본 예외에 대한 Problem Details 지원을 켤 수 있고, 직접 @ControllerAdvice를 만들어 애플리케이션 예외를 원하는 계약으로 바꿀 수도 있어요.
application.yml
OrderNotFoundException을 쓰더라도, 외부에는 404와 ORDER_NOT_FOUND 같은 안정적인 약속으로 보여줄 수 있어요.
Status code는 장식이 아니라 계약의 일부예요
REST API에서 status code는 “성공인지 실패인지”만 말하지 않아요. 실패했을 때 누가 무엇을 고쳐야 하는지도 알려줘요.
모든 실패를
200 OK로 감싸고 body 안에 success: false를 넣는 방식은 처음에는 편해 보일 수 있어요. 하지만 HTTP cache, client library, monitoring, gateway, retry 정책은 status code를 먼저 봐요. status를 무시하면 HTTP가 이미 제공하는 신호를 잃어버리게 돼요.
반대로 status code만으로 충분하지도 않아요.
실무에서는 500 응답이 특히 중요해요. stack trace, SQL, 내부 class name, secret 값이 밖으로 나가면 안 돼요. 클라이언트에게는 일반적인 메시지와 추적 가능한 식별자를 주고, 자세한 내용은 서버 로그와 tracing에서 찾는 편이 안전해요.
Validation error는 필드 단위로 읽을 수 있어야 해요
검증 실패 응답은 “잘못된 요청입니다”만으로 부족해요. 사용자가 고칠 수 있는 화면이라면 어떤 필드가 왜 틀렸는지 알려줘야 해요. 예를 들어 이런 요청이 왔다고 해볼게요.invalidParams는 RFC 9457의 기본 필드는 아니에요. 하지만 ProblemDetail은 표준 필드 외의 속성을 추가할 수 있어요. 중요한 건 “추가해도 된다”가 아니라 “추가한 모양도 계약으로 관리해야 한다”예요.
검증 에러를 설계할 때는 이 질문을 먼저 해보면 좋아요.
모든 에러에 거대한 구조를 만들 필요는 없어요. 다만 한 번 공개한 field 이름, status, error code는 클라이언트가 의존할 수 있으니 신중하게 잡아야 해요.
Error contract는 한곳에서 관리해야 해요
controller마다 직접try-catch를 쓰면 금방 모양이 갈라져요.
그래서 API error contract는
@RestControllerAdvice 같은 공통 경계에 모으는 편이 좋아요.
내부 예외를 외부 계약으로 번역하는 경계는 흩어뜨리지 않는다.
API versioning은 URL 숫자보다 “바꿔도 되는 것”을 정하는 일이에요
API 설계를 하다 보면 곧 versioning 이야기가 나와요.WebMvcConfigurer로 설정할 수 있는 흐름이 나와요. 예를 들어 header 기반 version을 쓸 수 있어요.
어떤 변경을 기존 클라이언트에게 깨지는 변경으로 볼 것인가?예를 들어 이런 변경들은 조심해야 해요.
반대로 response에 optional field를 추가하는 일은 대체로 덜 위험해요. 그래도 클라이언트가 unknown field를 어떻게 처리하는지 확인해야 해요. JSON은 느슨해 보여도, 각 클라이언트의 parser 설정은 다를 수 있거든요.
실무에서는 문서와 테스트가 계약을 붙잡아줘야 해요
API 계약은 말로만 정하면 금방 흐려져요. 코드가 바뀌고, 예외가 추가되고, 화면 요구사항이 바뀌면 “원래 어떤 응답이었지?”가 애매해져요. 그래서 최소한 이 셋은 같이 잡는 편이 좋아요.
예를 들어 controller test에서는 service 내부 구현보다 HTTP 계약을 확인하는 편이 좋아요.
springdoc-openapi를 붙이면 문서를 자동 생성할 수 있고, contract test를 붙이면 외부 연동까지 더 강하게 확인할 수 있어요. 하지만 자동 문서 도구도 계약을 대신 정해주지는 않아요. 먼저 status, body, error code, versioning 정책이 있어야 도구가 그 정책을 보여줄 수 있어요.
처음에는 여기까지만 잡아도 충분해요
REST API 설계는 멋진 URL 이름을 짓는 일만은 아니에요. Spring MVC controller method를 만들기 전에, 클라이언트가 믿고 사용할 요청과 응답의 모양을 정하는 일이에요. 처음에는 이 정도만 지켜도 좋아요.
조금 더 깊게 보면 이런 원칙이 남아요.
REST API 계약은 Spring MVC가 자동으로 만들어주는 게 아니라, 우리가 정한 HTTP 경계를 Spring MVC 도구로 구현하는 거예요.
자, 정리해볼까요?
- REST API는 controller method가 아니라 클라이언트와 서버가 공유하는 HTTP 계약이에요.
- Entity를 API에 그대로 노출하면 내부 모델 변경이 외부 breaking change가 되기 쉬워요.
- Bean Validation은 요청 값의 기본 모양을 service 앞에서 걸러주는 문지기예요.
- Problem Details는 status, type, title, detail, instance 같은 표준 필드로 에러 응답을 표현하는 방식이에요.
- 에러 메시지는 바뀔 수 있으니 클라이언트 분기에는 안정적인 error code를 두는 편이 좋아요.
- API versioning은 숫자를 어디에 둘지보다 어떤 변경을 breaking change로 볼지 정하는 일이 먼저예요.