spring & react를 사용해 MVP(Minimum viable Product) 프로젝트를 만드는 과정중 REST API END POINT를 리팩토링 하는 과정에서 처음 rest pai 설계 원칙을 참고하여 END POINT를 개발한 후 end point를 수정해야 했었다.


초기에는 빠르게 프로토 타입으로 프로젝트를 완성하는 것을 목표로 했기 때문에, REST API 설계 원칙을 완전히 이해하지 못한 채로 위 사진과 같이 엉성한 REST API를 구성하였었다.
그러나 리팩토링 시점에 이르러, API 버전 관리, 언더바(_) 사용, 명사의 단수/복수 표현, 리소스-식별자-서브 리소스 계층 구조를 제대로 지키지 않았던 문제들이
생겨났다.
그 결과, 단순히 End Point만 수정하는 과정임에도 불구하고 "이 API가 어떤 요청을 처리하는지"를 코드까지 열어봐야 이해할 수 있었습니다. 프론트엔드 개발 과정에서도, 특정 화면에 필요한 여러 개의 API 중 어떤 것이 어떤 데이터를 반환하는지 직관적으로 구분하기 어려웠습니다.
이를 통해 REST API 설계 원칙을 정확히 지키는 것의 중요성을 크게 깨닫게 되었고, 블로그를 통해 REST API 설계 원칙을 정리하고, 실제 리팩토링 과정을 기록해 두기로 했습니다.
일관된 인터페이스(Uniform Interface)
URI로 지정한 리소스에 대한 조작을 통일하고 한정적인 인터페이스로 수행하여야 한다. 즉, API 설계자가 정한 HTTP METHOD와 규약으로만 수행할 수 있다.
클라이언트-서버(Client — Server)
자원를 제공하는 쪽이 서버, 자원을 요청 하는 쪽을 클라이언트라 한다. 이 둘은 독립적으로 분리되어 구성되어야 한다. 각각 별도로 개발되고 대체 가능하도록 유지 해야 한다.
무상태(Stateless)
작업에 대한 요청의 상태 정보를 저장하거나 관리 하지 않는다. 세션, 인증, 쿠키 등의 정보는 별도로 저장하기 때문에 요청에 대한 어떠한 상태 정보를 저장하지 않고 단순 처리할 수 있어야 한다. 이를 위해 모든 요청에는 필요한 모든 정보가 포함되어야 한다.
캐시 가능(Cacheable)
서버는 캐시 가능 여부를 제공해야 한다. 캐시가 가능 할 경우 클라이언트는 응답 데이터를 재 사용 하여 성능과 서버의 가용성을 올릴 수 있다.
5.계층화된 시스템(Layered System)
계층화된 구조로 구성되어야 한다. 각 계층 자신이 통신하는 컴포넌트 외 다른 계층에 대한 정보를 얻을 수 없도록 분리되어야 한다.
- BAD CASE
<https://api.example.com/members/getCart
- GOOD CASE
GET <https://api.example.com/members/cart>
- PLURAL CASE
GET https://api.example.com/orders
- SINGULAR CASE
GET https://api.example.com/orders/:orderId
팀에 따라 단일 자원을 반환 RESOURCE는 단수형 명사를 사용하기도 한다.
- BAD CASE
GET <https://api.example.com/productOptions/:optionId>
- GOOD CASE
GET <https://api.example.com/product-options/:optionId>
- BAD CASE
<https://api.example.com/products/1/Reviews>
- GOOD CASE
<https://api.example.com/products1/reviews>
- BAD CASE
GET <https://api.example.com/orders/orderId?order-id=1>
- GOOD CASE
GET <https://api.example.com/orders/1>
- BAD CASE
GET <https://api.example.com/products/page/2/size/20>
- GOOD CASE
GET <https://api.example.com/products?page=2&size=20>
https://api.example.com/v1/members/1
https://api.example.com/members/1?version=1
https://api.example.com/members/1
Accept: application/vnd.example.v1+json
https://api.example.com/members/1
- http header
API-Version: 1
가장 많이 사용되는 방식은 URI versioning이다.
요청(Request) 시에는 Content-Type으로 본문(body)의 형식을 명시한다.
응답(Response) 시에는 Accept 헤더를 참고하여 클라이언트가 원하는 형식(JSON, XML 등)을 반환한다.
일반적으로 REST API에서는 application/json을 기본으로 사용한다.
- 요청 예시
POST /api/v1/members HTTP/1.1
Content-Type: application/json
Accept: application/json
가능하다면 리소스 자체를 그대로 반환한다.
불필요하게 data, result 같은 추가 래퍼(wrapper) 객체로 감싸지 않는다.
- BAD CASE
{
"data": {
"id": 1,
"name": "Alice"
}
}
- GOOD CASE
{
"id": 1,
"name": "Alice"
}
빈 배열은 null이 아닌 빈 배열([]) 로 반환한다.
이 방식이 클라이언트 단에서 처리하기 가장 일관적이다.
- BAD CASE
NULL
- GOOD CASE
[], {}
존재하지 않는 단일 리소스는 404 Not Found 상태코드로 반환한다.
빈 응답 대신 명확한 상태코드를 사용해야 한다.
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"errorCode": "MEMBER_NOT_FOUND",
"message": "해당 회원을 찾을 수 없습니다."
}
소규모 프로젝트에서는 리소스 중첩의 Depth가 얕아 크게 문제가 되지 않는다. 하지만 프로젝트 규모가 커질수록 중첩 구조가 점점 깊어질 수 있고, 그럴 경우 URL의 가독성과 유지보수성이 급격히 떨어진다.
이를 해결하기 위해 보통 중첩 구조(Nested Resource) 를 평탄화(Flat Resource) 방식으로 풀어내는 전략을 사용한다.
예시:
# 스토어의 상품 목록
GET /stores/10/products
# 상품의 리뷰 목록
GET /products/200/reviews
# 주문의 아이템 목록
GET /orders/555/items
# 스토어의 상품 목록
GET /products?storeId=10
# 상품의 리뷰 목록
GET /reviews?productId=200
# 주문의 아이템 목록
GET /order-items?orderId=555
# 스토어의 특정 카테고리 상품 목록
GET /stores/10/categories/3/products
# 고객의 특정 주문의 아이템 목록
GET /customers/99/orders/555/items
# 상품의 특정 옵션(variant)의 재고 이력
GET /products/200/variants/12/inventories
# 스토어의 특정 카테고리 상품 목록
GET /products?storeId=10&categoryId=3
# 고객의 특정 주문의 아이템 목록
GET /order-items?customerId=99&orderId=555
# 상품의 특정 옵션(variant)의 재고 이력
GET /inventories?productId=200&variantId=12
# 스토어 → 카테고리 → 상품 → 리뷰
GET /stores/10/categories/3/products/200/reviews
# 고객 → 주문 → 아이템 → 반품
GET /customers/99/orders/555/items/7/returns
# 스토어 → 상품 → 옵션(variant) → 가격 변경 이력
GET /stores/10/products/200/variants/12/price-changes
# 스토어 → 카테고리 → 상품 → 리뷰
GET /reviews?storeId=10&categoryId=3&productId=200
# 고객 → 주문 → 아이템 → 반품
GET /returns?customerId=99&orderId=555&itemId=7
# 스토어 → 상품 → 옵션(variant) → 가격 변경 이력
GET /price-changes?storeId=10&productId=200&variantId=12
직접 예시를 만들어 보면, 3중첩부터는 평탄화(flat) 하는 편이 가독성과 재사용성 측면에서 유리해 보인다.
2중첩의 경우도 상황에 따라 flat 대안이 좋을 수 있지만, 예를 들어 GET /products/200/reviews처럼 목록을 “어느 문맥에서 보느냐”가 중요한 경우에는 2중첩을 유지하는 것이 더 자연스러울 때가 있다.
결국 Path Param은 리소스 식별, Query Param은 필터/정렬/페이지네이션, Body는 리소스 표현(생성/수정 데이터) 이라는 원칙 아래 상황에 맞게 유연하게 선택하는 것이 바람직하다. 단일 리소스 조회/수정/삭제는 가능하면 flat으로 두고, 목록·생성 컨텍스트는 2중첩까지는 허용, 3중첩 이상은 flat로 풀기를 기본 가이드로 삼는것이 바람직해 보인다.