← Posts

API · REST · Design

Endpoint 설계

같은 목록을 주는 URL이 여럿이 되지 않게 하는 법. 컬렉션과 단건, 중첩 기준, 필터·정렬·페이징 파라미터와 목록 응답 모양을 정한다.

목차
  1. 같은 목록을 주는 URL이 자꾸 늘어난다
  2. 모양은 둘뿐이다: 컬렉션과 단건
  3. 복수형으로 통일한다
  4. 중첩은 소유 관계일 때만
  5. 거르기는 쿼리 파라미터로
  6. 정렬은 필드와 방향을 한 값에
  7. 페이징은 파라미터 이름부터 정한다
  8. 목록 응답은 배열이 아니라 객체로 감싼다
  9. 이름은 한 번 정하면 되돌리기 어렵다
  10. 그래서 실무에선 어떻게 쓰이나
  11. 정리

자원으로 말하기로 했으니, 이제 그 자원에 실제로 이름을 붙이고 조건을 붙일 차례다.

같은 목록을 주는 URL이 자꾸 늘어난다

주문 목록 API를 만든다. 처음엔 하나였다.

plaintext
GET /orders

그런데 마이페이지에서 “내 주문”이 필요하다. 사용자 밑으로 붙인다.

plaintext
GET /users/1024/orders

며칠 뒤 관리자 화면에서 “특정 사용자의 주문”을 봐야 한다. 관리자용이니 필터로 붙인다.

plaintext
GET /orders?userId=1024

또 며칠 뒤 상태별 조회가 필요해서 누군가 이걸 만든다.

plaintext
GET /orders/pending

이제 같은 데이터를 주는 입구가 넷이다. 넷 다 컨트롤러가 다르고, 정렬 기본값이 미묘하게 다르고, 한 곳에만 페이징이 붙어 있다. 그리고 앞 글들에서 본 대로 한 번 나간 URL은 지우기 어렵다.

문제는 각각의 URL이 나빠서가 아니다. 어떤 조건을 경로에 넣고 어떤 조건을 파라미터에 넣을지 기준이 없어서 사람마다 다르게 고른 것이다. 그 기준을 정하는 게 이 글이다.

모양은 둘뿐이다: 컬렉션과 단건

기준의 출발점은 단순하다. 자원 하나에 대해 URL은 기본적으로 두 개만 있으면 된다.

diagramdiagram
  • 컬렉션 /orders - 여럿을 다룬다. 목록을 주거나, 새로 하나를 만든다.
  • 단건 /orders/77 - 하나를 다룬다. 조회·수정·삭제.

앞의 넷 중 GET /orders/pending이 여기서 바로 걸린다. /orders/{id} 자리에 pending이 들어간 모양이라, 나중에 진짜로 pending이라는 식별자를 가진 주문이 생기면 충돌한다. 경로의 그 칸은 식별자 자리다. 상태는 조건이지 식별자가 아니다.

기준의 첫 줄이 여기서 나온다. 경로는 “무엇을”만 말하고, “어떤 조건으로”는 쿼리 파라미터가 말한다.

복수형으로 통일한다

컬렉션 이름은 복수형으로 쓴다. /orders, /users, /products.

이유는 대단하지 않다. /orders/77을 읽으면 “주문들 중 77번”으로 읽히기 때문이고, 무엇보다 하나로 정해두면 매번 고민하지 않아도 되기 때문이다. 단수와 복수를 섞으면 클라이언트 개발자가 URL을 칠 때마다 문서를 열어봐야 한다.

여기엔 판단이 갈리는 자리가 몇 개 있다.

  • 셀 수 없는 이름. /settings, /health처럼 원래 컬렉션이 아닌 것들은 굳이 억지 복수형을 만들 필요가 없다. 다만 팀 안에서 예외 목록을 짧게 유지한다.
  • “나” 자원. 로그인한 사용자 자신을 가리킬 때 /users/me를 흔히 쓴다. 식별자 자리에 예약어를 넣는 것이라 앞의 원칙과 충돌하지만, 클라이언트가 자기 ID를 모를 때 편해서 널리 쓰인다. 쓴다면 이런 예약어가 늘어나지 않게 관리한다.

단수냐 복수냐를 놓고 오래 논쟁할 값은 없다. 어느 쪽이든 팀에서 하나로 정하고 문서 첫 줄에 적어두는 것이 실제 이득의 대부분이다. 다만 나중에 바꾸는 건 앞 글에서 본 대로 깨는 변경이라, 처음에 정하는 편이 싸다.

중첩은 소유 관계일 때만

/users/1024/orders/orders?userId=1024는 같은 데이터를 준다. 둘 다 만들면 앞의 문제가 반복되니 하나를 골라야 한다.

diagramdiagram

가르는 질문은 하나다. 그 조건이 자원의 소속인가, 아니면 여러 조건 중 하나인가.

주문 상품은 주문 없이 존재하지 않는다. /orders/77/items가 자연스럽다. 반면 사용자 ID는 주문을 거르는 여러 조건 중 하나다. 상태·기간·금액과 나란히 놓이는 조건이라면 파라미터가 맞다. 조건들끼리 조합돼야 하는데 하나만 경로에 올라가 있으면 모양이 어그러진다.

plaintext
GET /users/1024/orders?status=paid&from=2026-05-01   ← userId만 경로에 있어 어색하다
GET /orders?userId=1024&status=paid&from=2026-05-01  ← 조건들이 나란하다

중첩은 한 단계까지만 두는 편이 대체로 낫다. /users/1024/orders/77/items/3까지 가면 URL이 길어지는 것보다 더 나쁜 일이 생긴다. /items/3/orders/77에 속하는지를 서버가 매번 검증해야 하고, 안 하면 남의 주문 상품이 조회된다. 주문 상품에 전역 ID가 있다면 /order-items/3으로 평평하게 두는 게 단순하다.

거르기는 쿼리 파라미터로

조건은 전부 쿼리 파라미터로 붙인다. 여기서 정할 건 이름 짓는 방식이다.

plaintext
GET /orders?status=paid&from=2026-05-01&to=2026-05-31&minAmount=10000

몇 가지만 미리 정해두면 나중에 흔들리지 않는다.

상황권할 만한 모양이유
값이 여럿?status=paid&status=shipped 또는 ?status=paid,shipped둘 다 쓰인다. 하나로 정해두면 된다
범위?from=...&to=...시작·끝을 별도 파라미터로. ?date=2026-05 같은 축약은 나중에 못 늘린다
있음·없음?hasCoupon=true불리언은 true/false 문자열로
검색어?q=키워드짧고 관행적이다
주의

파라미터를 없애는 것도 깨는 변경이다. ?type= 하나를 급하게 만들어두면 나중에 이름을 못 바꾼다. 그래서 급할수록 이름을 짧게 짓고 싶어지는 유혹을 참는 게 낫다. 특히 ?flag=1 같은 이름은 몇 달 뒤에 아무도 뜻을 모른다.

서버가 모르는 파라미터가 오면 어떻게 할지도 정해야 한다. 조용히 무시하면 클라이언트가 오타를 냈을 때 필터가 안 걸린 채 전체 목록이 내려온다. 400으로 거절하면 오타는 잡히지만, 나중에 파라미터를 늘릴 때 옛 클라이언트가 보내던 값이 거절당해 깨질 수 있다. 무시하는 쪽이 관대하고, 거절하는 쪽이 실수를 빨리 알려준다. 어느 쪽이든 엔드포인트마다 다르게 동작하지 않게 통일해두는 게 핵심이다.

정렬은 필드와 방향을 한 값에

정렬은 파라미터 하나로 필드와 방향을 같이 표현하는 방식이 흔하다.

plaintext
GET /orders?sort=createdAt,desc
GET /orders?sort=-createdAt          ← 앞의 빼기가 내림차순

둘 다 쓰인다. 중요한 건 정렬 기준으로 허용할 필드를 서버가 화이트리스트로 정해두는 것이다. 클라이언트가 보낸 문자열을 그대로 정렬 쿼리에 넣으면 인덱스가 없는 칼럼으로 전체 정렬이 돌 수 있고, 값에 따라 더 나쁜 일도 생긴다.

그리고 기본 정렬을 반드시 정한다. 정렬을 안 주면 어떤 순서로 내려가는지가 계약이 아니라 우연이 되는데, 앞 글에서 본 대로 클라이언트는 그 우연을 계약으로 믿는다. sort가 없으면 createdAt 내림차순처럼 문서에 못 박아 두는 편이 안전하다.

페이징은 파라미터 이름부터 정한다

목록 API에 페이징이 없으면 언젠가 응답 하나가 수십 메가가 된다. 방식은 크게 둘이고, 각각의 원리와 트레이드오프는 Pagination 글이 다룬다. 여기서는 그걸 API 표면에 어떻게 드러내는가만 본다.

Offset 방식은 몇 개 건너뛰고 몇 개를 줄지 받는다. Offset 방식: LIMIT과 OFFSET이 원리를 다룬다.

plaintext
GET /orders?page=2&size=20
GET /orders?offset=20&limit=20

Cursor 방식은 마지막으로 본 위치를 받는다. Cursor 방식: 마지막으로 본 값 다음부터를 참고.

plaintext
GET /orders?cursor=eyJpZCI6NzcsIn0&size=20
diagramdiagram

설계 관점에서 챙길 게 셋이다.

  • size에 상한을 둔다. 클라이언트가 size=1000000을 보내면 그대로 나가는 API가 의외로 많다. 최대값을 정하고, 넘으면 잘라내든 400을 주든 문서에 적는다.
  • 커서 값은 클라이언트가 해석하지 않게 만든다. 서버가 만든 불투명한 문자열을 그대로 돌려받는 형태여야, 나중에 커서에 담는 내용이 바뀌어도 클라이언트가 안 깨진다.
  • 전체 개수를 줄지 미리 정한다. 페이지 번호 UI에는 필요하지만 세는 비용이 든다. 총 개수와 페이지 번호는 대목이 이 값의 비용을 다룬다. 필요할 때만 ?withTotal=true로 받는 방법도 있다.

목록 응답은 배열이 아니라 객체로 감싼다

여기서 앞 글의 “더하기는 되고 빼기는 안 된다”가 그대로 걸린다. 목록 응답을 배열로 바로 내려주면 이렇게 된다.

json
[
  {"id": 77, "amount": 12000},
  {"id": 78, "amount": 8000}
]

깔끔해 보이지만, 여기에는 아무것도 더 붙일 수 없다. 다음 커서를, 전체 개수를, 다음 페이지 존재 여부를 넣을 자리가 없다. 나중에 필요해지면 응답 최상위 타입을 배열에서 객체로 바꿔야 하는데, 그건 클라이언트가 전부 깨지는 변경이다.

처음부터 객체로 감싸두면 뒤에 붙일 자리가 생긴다.

json
{
  "items": [
    {"id": 77, "amount": 12000},
    {"id": 78, "amount": 8000}
  ],
  "nextCursor": "eyJpZCI6NzgsIn0",
  "hasNext": true
}

totalCount가 나중에 필요해져도 필드 하나 추가로 끝난다. 옛 클라이언트는 모르는 필드를 무시하고 하던 대로 동작한다.

참고

감싸는 이름은 items·data·content 등으로 갈린다. Spring Data의 Page를 그대로 내보내면 content·pageable·sort 같은 필드가 통째로 나가는데, 이건 프레임워크 내부 구조를 계약으로 만드는 일이라 엔티티를 그대로 내보내는 것과 같은 문제다. 필요한 필드만 담은 응답 DTO를 따로 두는 편이 낫다.

이름은 한 번 정하면 되돌리기 어렵다

마지막으로 이름 자체에 관한 것들이다. 사소해 보이지만 전부 계약이라 나중에 못 고친다.

  • 경로는 소문자에 하이픈. /order-items. 대소문자를 섞으면 어느 환경에서 대소문자를 구분하는지에 따라 동작이 갈린다.
  • 경로와 JSON 필드의 규칙은 서로 다를 수 있다. 경로는 하이픈, JSON 필드는 카멜케이스가 흔한 조합이다. 각각 안에서 일관되면 된다. JSON 쪽 이야기는 JSON 편에서 다시 본다.
  • 확장자를 URL에 안 쓴다. /users/1024.json이 아니라 Accept 헤더로 정한다.
  • 약어를 아껴 쓴다. /usr, /ord는 몇 글자 아끼고 매번 문서를 열게 만든다.
  • CRUD가 아닌 동작은 마지막 수단으로 남긴다. 어쩔 수 없이 쓸 때도 /orders/77/cancel처럼 부모 자원 아래에 두고, 팀 안에서 같은 모양으로만 쓴다.

그래서 실무에선 어떻게 쓰이나

Spring에서는 지금까지 정한 것들이 컨트롤러 시그니처에 그대로 나타난다.

java
@GetMapping("/orders")
public OrderListResponse list(
        @RequestParam(required = false) OrderStatus status,
        @RequestParam(required = false) Long userId,
        @RequestParam(defaultValue = "createdAt,desc") String sort,
        @RequestParam(required = false) String cursor,
        @RequestParam(defaultValue = "20") int size) {

    int capped = Math.min(size, 100);          // 상한을 서버가 정한다
    return orderService.list(status, userId, sort, cursor, capped);
}

defaultValue가 붙은 자리가 곧 문서에 적어야 할 기본값이고, Math.min이 상한이다. 이 두 개가 코드에만 있고 문서에 없으면 클라이언트는 그걸 실험으로 알아낸다.

엔드포인트가 늘어나면 사람 기억으로는 일관성이 유지되지 않는다. 실무에서 기대는 장치가 둘 있다.

명세 파일을 같이 관리한다. OpenAPI로 경로·파라미터·응답 스키마를 적어두면 목록을 한눈에 훑을 수 있고, 새 엔드포인트가 기존 규칙에서 벗어난 게 리뷰에서 눈에 띈다. 문서 자동 생성보다 이 일관성 검토가 실제 이득인 경우가 많다.

리뷰 체크리스트를 짧게 둔다. 새 엔드포인트가 올라오면 다섯 줄만 확인한다. 복수형인가, 경로에 동사가 없나, 조건이 파라미터에 있나, 목록이면 페이징과 상한이 있나, 목록 응답이 객체로 감싸져 있나. 이 정도면 앞의 “URL이 넷이 된” 상황은 대체로 막힌다.

이미 어그러진 API를 정리해야 한다면, 옛 URL을 지우는 대신 새 URL을 정본으로 만들고 옛 URL은 그쪽을 호출하도록 남겨두는 방식이 안전하다. 호출량을 계측하다가 0이 되면 그때 내린다. 이 절차는 버저닝 편에서 자세히 다룬다.

정리

기본 모양자원 하나에 컬렉션 /orders와 단건 /orders/77
경로 vs 파라미터경로는 “무엇을”, 쿼리 파라미터는 “어떤 조건으로”
복수형컬렉션은 복수형으로 통일. 예외는 짧게 유지
중첩 기준소속이면 중첩, 여러 조건 중 하나면 파라미터. 한 단계까지
정렬허용 필드를 화이트리스트로. 기본 정렬을 문서에 못 박는다
페이징offset·cursor 중 택일. size 상한, 불투명한 커서, 전체 개수 정책
목록 응답배열로 바로 내리지 않는다. 객체로 감싸야 나중에 필드를 더할 수 있다
이름소문자·하이픈, 확장자 없음, 약어 자제. 전부 되돌리기 어려운 계약

여기까지가 요청을 어떻게 받을지에 관한 결정이었다. 그런데 요청이 성공했는지 실패했는지, 실패했다면 무엇 때문인지를 알리는 자리는 아직 정하지 않았다.

다음 글은 그 자리다. 200 OK인데 본문에 "error"가 들어 있는 응답부터 시작한다.

관련 글