REST API에서 리소스 이름을 정하는 기준
REST API에서 리소스 이름을 정하는 기준
URL은 명사형 리소스를 표현하고 HTTP 메서드가 동작을 표현한다. 컬렉션과 단일 리소스, 하위 관계를 일관되게 구분한다.
목차
- #왜 이 문제가 생기는가
- #화면 동작이 아니라 도메인 리소스를 찾는다
- #컬렉션과 단일 리소스의 기본 형태
- #중첩 경로는 소유 관계를 표현할 때 사용한다
- #CRUD로 표현하기 어려운 동작
- #필터, 정렬, 검색은 query로 분리한다
- #URL에 구현 세부사항을 노출하지 않는다
- #일관성을 확인하는 API 표
- #리뷰할 때 던질 질문
- #결론
- #관련 노트
왜 이 문제가 생기는가
처음 API를 만들 때 화면의 버튼 이름을 그대로 경로에 넣기 쉽다. 주문 생성 버튼에는 /createOrder, 취소 버튼에는 /cancelOrder, 배송 시작 버튼에는 /startDelivery를 만든다. 기능이 늘면 /getOrderList, /updateOrderStatus, /forceCancelOrder처럼 동사와 예외가 계속 쌓인다.
이 경로들은 당장 이해하기 쉽지만, HTTP 메서드의 의미와 서버가 관리하는 데이터의 관계를 재사용하지 못한다. 화면이 바뀔 때마다 API 이름도 흔들린다. URL을 정할 때는 사용자가 누른 버튼보다 서버가 어떤 리소스의 상태와 관계를 관리하는지부터 찾는 편이 좋다.
주문과 배송 API는 리소스 모델링 원칙을 설명하기 위해 만든 가상 예제다. 실제 서비스 경로나 데이터 모델을 옮기지 않았다.
화면 동작이 아니라 도메인 리소스를 찾는다
다음 API는 동작 이름이 URL과 메서드에 중복된다.
POST /api/createOrder
POST /api/getOrder
POST /api/updateOrder
POST /api/deleteOrder
주문을 orders라는 리소스로 보면 HTTP 메서드가 기본 동작을 표현할 수 있다.
POST /orders # 주문 컬렉션에 새 주문 생성
GET /orders/42 # 주문 42 조회
PATCH /orders/42 # 주문 42의 일부 상태 변경
DELETE /orders/42 # 주문 42 삭제
URL은 “무엇”을, 메서드는 “어떻게”를 표현한다. 이 규칙을 절대적인 문법으로 외우기보다 경로 구조만 보고도 리소스 종류와 관계를 예측할 수 있게 만드는 일관성으로 이해하는 편이 유용하다.
컬렉션과 단일 리소스의 기본 형태
컬렉션과 단일 리소스를 구분하면 기본 API 표가 자연스럽게 만들어진다.
| 요청 | 의미 | 대표 성공 코드 |
|---|---|---|
GET /orders |
주문 목록 조회 | 200 |
POST /orders |
주문 생성 | 201 |
GET /orders/{orderId} |
주문 하나 조회 | 200 |
PATCH /orders/{orderId} |
주문 일부 변경 | 200 또는 204 |
DELETE /orders/{orderId} |
주문 제거 | 204 |
생성 결과에는 새 리소스의 위치를 Location으로 알려 줄 수 있다.
HTTP/1.1 201 Created
Location: /orders/42
Content-Type: application/json
{"id":"42","status":"pending"}
식별자 형식은 내부 DB의 auto increment에 반드시 맞출 필요가 없다. 외부에 안정적인 UUID나 별도 public ID를 제공하면 저장 구조 변경과 URL 계약을 분리할 수 있다.
복수형과 단수형 중 무엇이 정답이라기보다 프로젝트 전체에서 하나의 규칙을 유지하는 것이 중요하다. 이 글에서는 컬렉션을 복수형으로 표현한다.
중첩 경로는 소유 관계를 표현할 때 사용한다
주문의 항목을 조회하는 경로는 관계를 드러낼 수 있다.
GET /orders/42/items
POST /orders/42/items
GET /orders/42/items/7
이 구조는 “item 7은 order 42 문맥 안에서 식별된다”는 계약을 표현한다. 반면 항목이 전체 시스템에서 독립적인 식별자와 생명주기를 가진다면 /order-items/7처럼 최상위 리소스로 다룰 수도 있다.
중첩은 두세 단계만 지나도 경로가 길어지고 권한 규칙이 복잡해진다.
/organizations/9/projects/3/orders/42/items/7/comments/2
이 경우 모든 상위 관계를 URL에 반복해야 하는지 검토한다. 권한 문맥에 꼭 필요한 부모 한 단계만 남기고 독립 식별자를 활용하면 API가 단순해질 수 있다.
자식은 부모 없이 존재할 수 있는가? 자식 ID가 전역적으로 고유한가? 부모가 삭제되면 자식도 사라지는가? 접근 권한은 부모를 기준으로 결정되는가?
CRUD로 표현하기 어려운 동작
주문 취소처럼 도메인에서 중요한 동작은 단순 필드 수정으로만 표현하기 어려울 수 있다.
PATCH /orders/42
Content-Type: application/json
{"status":"cancelled"}
이 방식은 단순하지만 클라이언트가 허용되지 않는 상태를 임의로 지정할 것처럼 보이고, 취소 사유·요청자·시각 같은 별도 생명주기를 표현하기 어렵다. 취소 요청 자체를 리소스로 모델링할 수 있다.
POST /orders/42/cancellations
Content-Type: application/json
{"reason":"changed-mind"}
HTTP/1.1 201 Created
Location: /orders/42/cancellations/cancel-8
이 모델은 취소가 승인 대기, 완료, 거절 상태를 가질 때 특히 자연스럽다. 재시도 중복을 막아야 한다면 취소 요청 ID나 idempotency key를 계약에 포함할 수 있다.
모든 동작을 억지로 명사화할 필요는 없다. 복잡한 계산이나 일회성 명령을 RPC 스타일 endpoint로 표현하는 편이 더 명확할 때도 있다. 중요한 것은 CRUD 모양을 지키는 것보다 API 소비자가 동작, 상태 변화, 재시도 결과를 예측할 수 있게 하는 것이다.
필터, 정렬, 검색은 query로 분리한다
같은 주문 컬렉션의 조회 조건은 별도 동사 경로보다 query parameter로 표현할 수 있다.
GET /orders?status=paid&sort=-createdAt&limit=20
GET /orders?customerId=customer-7&after=cursor-value
다음처럼 조건 조합마다 경로를 만들면 경우의 수가 늘어난다.
/getPaidOrders
/getRecentPaidOrders
/getCustomerRecentPaidOrders
query 이름과 허용 값을 문서화하고, 알 수 없는 필드를 조용히 무시할지 400으로 거부할지도 정한다. 페이지 방식은 Offset pagination과 Cursor pagination 비교처럼 데이터 변경 특성과 UI 요구를 기준으로 선택한다.
URL에 구현 세부사항을 노출하지 않는다
URL이 DB 테이블, ORM entity, 서버 클래스 이름을 그대로 반영하면 내부 리팩터링이 외부 계약 변경으로 번진다.
# 피하고 싶은 예
/tbl_order_master/selectByPk/42
/OrderController/getOrderDto/42
외부 API의 orders는 도메인 계약이고 내부에서는 여러 테이블이나 외부 시스템을 조합할 수 있다. 반대로 내부 모델 이름이 바뀌어도 공개 경로는 유지할 수 있어야 한다.
버전을 URL에 넣는 방식(/v1/orders)을 선택할 수 있지만, 작은 응답 필드 추가마다 버전을 올리는 것은 아니다. 호환되지 않는 계약 변경의 기준과 이전 버전 종료 정책을 함께 정해야 버전 문자열이 의미를 가진다.
일관성을 확인하는 API 표
기능 구현 전에 짧은 표를 만들면 이름과 상태 코드를 함께 검토하기 쉽다.
| 사용 사례 | 메서드와 경로 | 요청 본문 | 성공 | 주요 실패 |
|---|---|---|---|---|
| 주문 생성 | POST /orders |
상품과 수량 | 201 | 400, 409 |
| 주문 조회 | GET /orders/{id} |
없음 | 200 | 404 |
| 주문 취소 요청 | POST /orders/{id}/cancellations |
사유 | 201 | 404, 409 |
| 취소 조회 | GET /orders/{id}/cancellations/{cid} |
없음 | 200 | 404 |
이 표에서 같은 개념이 order, purchase, checkout으로 섞이지 않는지, 동일 실패가 endpoint마다 다른 코드로 표현되지 않는지 확인한다.
리뷰할 때 던질 질문
- 경로가 화면 버튼 이름이 아니라 도메인 개념을 표현하는가?
- 컬렉션과 단일 리소스 규칙이 일관적인가?
- 중첩 깊이가 소유 관계 설명에 실제로 필요한가?
- 중요한 상태 변화를 별도 리소스로 보는 편이 나은가?
- HTTP 메서드의 안전성·멱등성 의미를 지키는가?
- 내부 테이블과 클래스 이름이 외부 계약에 새어 나오지 않는가?
URL은 명사형 리소스를 표현하고 HTTP 메서드가 동작을 표현한다. 컬렉션과 단일 리소스, 하위 관계를 일관되게 구분한다.
결론
좋은 API 경로는 화면의 동작 이름보다 서버가 관리하는 리소스와 관계를 안정적으로 표현한다. 컬렉션과 단일 리소스에 HTTP 메서드의 의미를 적용하고, 취소처럼 독립적인 상태와 기록이 필요한 동작은 새 리소스로 모델링할 수 있다. REST 모양 자체보다 이름, 상태 변화, 실패와 재시도 계약이 프로젝트 전체에서 일관적인지가 더 중요하다.