Skip to main content
API는 서버 안의 메서드가 아니라, 서버 바깥의 누군가가 믿고 호출하는 약속이에요.
지난 글에서는 Spring MVC 요청이 DispatcherServlet을 지나 controller method로 도착하고, 반환값과 예외가 다시 HTTP 응답으로 바뀌는 흐름을 봤어요. 이제 그 흐름 위에 진짜 API를 올려볼게요. 처음에는 이런 controller를 만들기 쉬워요.
처음에는 간단해 보여요.
“POST /orders로 주문 객체를 받고, 저장한 뒤 다시 돌려주면 되는 거 아닌가요?”
근데요, API를 쓰는 쪽에서는 바로 더 구체적인 질문을 하게 돼요.
“필수값이 빠지면 어떤 status가 오나요?"
"에러 메시지는 사용자가 봐도 되는 문장인가요?"
"프론트엔드는 어떤 값으로 분기해야 하나요?"
"주문이 없을 때 404인가요, 200에 빈 body인가요?"
"필드 이름을 바꾸면 기존 앱은 깨지나요?"
"서버 내부 예외 class 이름이 응답으로 나가도 되나요?”
오늘은 controller method를 예쁘게 쓰는 법보다 한 단계 앞을 볼 거예요. REST API 설계는 Java method signature가 아니라 클라이언트와 서버가 공유하는 HTTP 계약이에요. Spring MVC는 @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 메서드처럼 느껴져요.
하지만 클라이언트가 보는 것은 Java method가 아니에요. 클라이언트가 보는 것은 HTTP 요청과 응답이에요.
그리고 기대하는 응답은 이런 모양일 수 있어요.
여기에는 이미 약속이 여러 개 들어 있어요. 이 약속을 코드 안쪽에서만 생각하면 늦어요. service와 repository까지 다 만든 뒤에 response 모양을 바꾸려면 프론트엔드, 모바일 앱, 외부 연동, 테스트가 같이 흔들리거든요. 이 그림에서 API 계약은 controller 안에 숨어 있는 세부 구현이 아니에요. 서버 바깥과 서버 안쪽을 나누는 경계예요. 그래서 API 응답 모양을 바꾸는 일은 단순 리팩터링이 아니라 외부 약속을 바꾸는 일이 될 수 있어요.

Entity를 그대로 주고받으면 처음에는 편하지만 오래가기 어려워요

처음 예제에서 controller가 Order를 그대로 받고 그대로 돌려줬죠.
작은 예제에서는 편해요. 하지만 실제 프로젝트에서는 이 모양이 빨리 부담이 돼요. 그래서 API 경계에는 보통 DTO를 둬요.
그리고 controller는 API DTO를 받고, service는 애플리케이션이 이해하는 명령으로 넘기는 식으로 경계를 나눌 수 있어요.
여기서 ResponseEntity는 status, header, body를 함께 표현하고 싶을 때 유용해요. 단순 조회처럼 항상 200 OK와 body만 있으면 DTO를 바로 return해도 되지만, 생성처럼 201 Created와 Location header를 명시하고 싶을 때는 응답 전체를 코드에 드러내는 편이 읽기 좋아요.
DTO는 “계층마다 파일을 늘리자”가 아니라 “외부 API 약속과 내부 모델을 분리하자”는 장치예요. 작은 API에서도 request DTO와 response DTO를 나누면 나중에 필드 추가, 숨김, 이름 변경을 더 안전하게 다룰 수 있어요.

Validation은 service에 들어가기 전의 문지기예요

요청 body가 JSON에서 Java 객체로 바뀌었다고 해서 그 값이 의미 있는 값은 아니에요.
JSON 문법은 맞아요. DTO로도 만들 수 있어요. 하지만 주문 생성 요청으로는 이상하죠. Spring MVC에서는 @RequestBody로 읽은 객체에 @Valid를 붙이면 Bean Validation 규칙을 적용할 수 있어요.
이때 검증은 controller method 본문에 들어오기 전에 실패할 수 있어요. 그러면 service는 호출되지 않고, Spring MVC의 예외 처리 흐름으로 넘어가요. 이 그림에서 중요한 건 validation이 비즈니스 규칙 전체를 대체하지 않는다는 점이에요. quantity가 1 이상인지, productId가 비어 있지 않은지 같은 입력 모양 검사는 DTO에서 빨리 걸러낼 수 있어요. 하지만 “이 상품이 지금 판매 가능한가”, “이 사용자가 이 주문을 만들 권한이 있는가”, “재고가 충분한가” 같은 규칙은 service나 domain 영역에서 판단해야 해요.
Bean Validation 구현체가 classpath에 있어야 검증이 동작해요. Spring Boot에서는 보통 spring-boot-starter-validation이 Hibernate Validator를 함께 올려줘요. @Valid를 붙였는데도 통과한다면 Annotation 위치, validation starter, nested object 구조를 같이 확인하세요.
조금 더 깊게 보면 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
애플리케이션 예외는 이렇게 중앙에서 바꿀 수 있어요.
이 코드는 “예외가 나면 JSON을 만든다”보다 더 구체적인 일을 해요. Java exception을 HTTP status와 공개 가능한 error body로 번역해요. 이 그림의 핵심은 exception class가 그대로 외부 계약이 되지 않는다는 점이에요. 내부에서는 OrderNotFoundException을 쓰더라도, 외부에는 404와 ORDER_NOT_FOUND 같은 안정적인 약속으로 보여줄 수 있어요.
detail 문장은 바뀔 수 있어요. 번역될 수도 있고, 더 친절하게 고칠 수도 있어요. 앱 로직이 분기해야 한다면 code처럼 안정적인 값을 따로 두는 편이 좋아요.

Status code는 장식이 아니라 계약의 일부예요

REST API에서 status code는 “성공인지 실패인지”만 말하지 않아요. 실패했을 때 누가 무엇을 고쳐야 하는지도 알려줘요. 모든 실패를 200 OK로 감싸고 body 안에 success: false를 넣는 방식은 처음에는 편해 보일 수 있어요. 하지만 HTTP cache, client library, monitoring, gateway, retry 정책은 status code를 먼저 봐요. status를 무시하면 HTTP가 이미 제공하는 신호를 잃어버리게 돼요. 반대로 status code만으로 충분하지도 않아요.
이것만 받으면 클라이언트는 무엇을 고쳐야 하는지 몰라요. 그래서 status code와 error body를 함께 설계해야 해요. 실무에서는 500 응답이 특히 중요해요. stack trace, SQL, 내부 class name, secret 값이 밖으로 나가면 안 돼요. 클라이언트에게는 일반적인 메시지와 추적 가능한 식별자를 주고, 자세한 내용은 서버 로그와 tracing에서 찾는 편이 안전해요.

Validation error는 필드 단위로 읽을 수 있어야 해요

검증 실패 응답은 “잘못된 요청입니다”만으로 부족해요. 사용자가 고칠 수 있는 화면이라면 어떤 필드가 왜 틀렸는지 알려줘야 해요. 예를 들어 이런 요청이 왔다고 해볼게요.
응답은 이런 식으로 만들 수 있어요.
여기서 invalidParams는 RFC 9457의 기본 필드는 아니에요. 하지만 ProblemDetail은 표준 필드 외의 속성을 추가할 수 있어요. 중요한 건 “추가해도 된다”가 아니라 “추가한 모양도 계약으로 관리해야 한다”예요. 검증 에러를 설계할 때는 이 질문을 먼저 해보면 좋아요.
모든 에러에 거대한 구조를 만들 필요는 없어요. 다만 한 번 공개한 field 이름, status, error code는 클라이언트가 의존할 수 있으니 신중하게 잡아야 해요.

Error contract는 한곳에서 관리해야 해요

controller마다 직접 try-catch를 쓰면 금방 모양이 갈라져요.
이 방식은 작은 예제에서는 바로 보이지만, API가 늘어나면 이런 문제가 생겨요. 그래서 API error contract는 @RestControllerAdvice 같은 공통 경계에 모으는 편이 좋아요.
처음에는 여기까지만 해도 충분해요. 더 커지면 error code enum, message resolver, request id, 다국어 message, 보안 로그 정책을 추가할 수 있어요. 하지만 그때도 원칙은 같아요.
내부 예외를 외부 계약으로 번역하는 경계는 흩어뜨리지 않는다.

API versioning은 URL 숫자보다 “바꿔도 되는 것”을 정하는 일이에요

API 설계를 하다 보면 곧 versioning 이야기가 나와요.
혹은 header를 쓸 수도 있어요.
Spring Boot 4.1 문서에는 Spring MVC API versioning을 property나 WebMvcConfigurer로 설정할 수 있는 흐름이 나와요. 예를 들어 header 기반 version을 쓸 수 있어요.
하지만 versioning에서 더 중요한 질문은 “숫자를 어디에 둘까?”가 아니에요.
어떤 변경을 기존 클라이언트에게 깨지는 변경으로 볼 것인가?
예를 들어 이런 변경들은 조심해야 해요. 반대로 response에 optional field를 추가하는 일은 대체로 덜 위험해요. 그래도 클라이언트가 unknown field를 어떻게 처리하는지 확인해야 해요. JSON은 느슨해 보여도, 각 클라이언트의 parser 설정은 다를 수 있거든요.
/v2를 붙여도 어떤 변경이 breaking change인지 팀이 합의하지 않으면 같은 문제가 반복돼요. field 제거, 타입 변경, error code 변경, validation 강화 같은 기준을 먼저 정하세요.

실무에서는 문서와 테스트가 계약을 붙잡아줘야 해요

API 계약은 말로만 정하면 금방 흐려져요. 코드가 바뀌고, 예외가 추가되고, 화면 요구사항이 바뀌면 “원래 어떤 응답이었지?”가 애매해져요. 그래서 최소한 이 셋은 같이 잡는 편이 좋아요. 예를 들어 controller test에서는 service 내부 구현보다 HTTP 계약을 확인하는 편이 좋아요.
이 테스트는 “어떤 validator class가 호출됐는가”보다 “클라이언트가 어떤 실패 응답을 받는가”를 붙잡아요. API 경계에서는 이 관점이 더 오래 버텨요. 나중에 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로 볼지 정하는 일이 먼저예요.
다음 글에서는 JSON 변환 자체를 더 깊게 볼 거예요. Spring Boot 4.x의 Jackson 3 방향, Jackson 2를 쓰던 프로젝트에서 만날 수 있는 차이, 날짜와 enum과 unknown field가 왜 API 호환성 문제로 이어지는지 살펴볼게요.