본문 바로가기
C.W.K.
Stream
Lesson 02 of 07 · published

URI 설계 — 오래 버티는 주소를 만드는 법

~10 min · rest-design, uri-design, naming

Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"URI는 API 계약에서 가장 작으면서도 가장 자주 노출되는 조각이야. 로그, 관리 화면, 문서, 청구 보고서, 오류 제보에 계속 나타나. 잘 지으면 오래 편하고, 성급하게 지으면 몇 년 동안 그 선택을 끌고 가야 해."

대부분의 결정을 정리하는 여섯 가지 기본값

1. 기본은 명사, 동작은 HTTP 메서드로 표현해. URI에는 수행할 명령보다 다루는 대상을 놓는 편이 좋아. /users/42/getUser/42보다 간결해. GET /users/42만으로 읽기 의미가 이미 드러나므로 GET /getUser/42처럼 동사를 되풀이할 필요가 없어.

2. 컬렉션 이름은 한 가지 수 체계로 통일해. 이 과정에서는 컬렉션을 복수형으로 써. /users는 사용자 집합이고, /users/{id}는 그 집합의 특정 항목이야. 단수형도 기술적으로 가능하지만 /user/42와 복수형 컬렉션을 섞으면 소비자가 매번 이름을 외워야 해.

3. 중첩은 소유나 명확한 범위를 드러낼 때만 써. /users/{id}/orders는 특정 사용자의 주문이라는 범위를 자연스럽게 보여 줘. 다대다 관계이거나 부모를 거치지 않아도 독립적으로 식별되는 리소스라면 최상위 컬렉션과 필터를 쓰는 편이 나을 수 있어. 예를 들어 /orders?user_id={id}/users/{id}/orders 가운데 도메인 의미와 권한 경계에 맞는 쪽을 골라.

4. 소문자와 하이픈을 일관된 관례로 삼아. /account-settings/{id}처럼 쓰면 /AccountSettings/{id}/account_settings/{id}와 섞일 일이 없어. URI 경로의 대소문자 처리는 서버와 경로에 따라 구별될 수 있으므로 소문자로 통일하는 편이 안전해. 하이픈은 링크 밑줄과 겹쳐 보일 수 있는 언더스코어보다 읽기 편한 경우가 많아(account_settingsaccount-settings를 비교해 봐).

5. 표현 형식은 보통 경로에서 분리해. 이 API에서는 /users/42를 쓰고 /users/42.json은 쓰지 않아. 표현 형식은 Content-Type과 콘텐츠 협상으로 다루면 같은 리소스 식별자를 유지할 수 있어. URI의 .json 같은 확장자가 프로토콜상 금지된 것은 아니지만, 여러 표현을 제공할 API라면 URI에 형식을 고정하지 않는 편이 유연해.

6. 깊은 중첩은 경고 신호로 봐. /users/{u}/orders/{o}/items/{i} 정도만 되어도 클라이언트가 알아야 할 조상 정보가 많아져. /users/{u}/orders/{o}/items/{i}/comments/{c}/replies/{r}처럼 계속 깊어지면, 안정된 ID가 있는 리소스를 최상위로 올려 /replies/{r}처럼 평평하게 접근할 수 있는지 검토해. 두세 단계는 법칙이 아니라 복잡도를 다시 살펴볼 실용적 기준이야.

ID 선택 — 공개 식별자에는 예측하기 어려운 값을 고려해

흔히 쓰는 ID 전략은 크게 둘이야:

  • 순차 정수(/users/1, /users/2): 생성 비용이 낮고 사람이 읽고 디버깅하기 쉬워. 반면 값의 증가 추세가 규모를 드러낼 수 있고, 인접 값을 추측하기도 쉬워(/users/1, /users/2, ...).
  • 불투명한 문자열(/users/usr_8x3kPq, UUID, ULID, Stripe 스타일 접두사 ID): 값에서 순서나 규모를 추측하기 어렵고 분산 환경에서 발급하기 편할 수 있어. 대신 사람이 외우거나 직접 입력하기는 어렵고, 형식에 따라 길이와 인덱스 비용도 달라져.

공개 API라면 예측하기 어려운 ID를 우선 검토해. Stripe의 접두사 관례는 고객에 cus_, 결제에 pay_를 붙여 로그에서 타입을 빠르게 구분하게 해. 다만 불투명한 ID는 권한 검사를 대신하지 않아. 모든 리소스 접근에는 별도의 인증과 인가가 필요하고, 내부 전용 시스템에서는 순차 정수가 더 단순한 선택일 수 있어.

동작 URI — 명사만으로 뜻이 흐려질 때

리소스의 일반적인 생성·조회·수정·삭제로 자연스럽게 표현하기 어려운 작업에는 POST /resources/{id}/actionName 같은 형태를 쓸 수 있어. POST /payments/42/refund, POST /jobs/abc/cancel이 그 예야. 첫 번째 형태는 문법을 설명하기 위한 도식이고, 실제 경로 이름은 앞서 정한 소문자·하이픈 관례에 맞춰. 동작을 숨기지 않으면서 API의 나머지 리소스 지향 구조도 유지하는 혼합형 패턴이야.

URI는 오래 남아. URI가 운영 로그와 고객 통합 코드, 제3자 문서, 브라우저 북마크, 검색 엔진 색인에 퍼진 뒤에는 바꾸는 데 긴 전환 기간이 필요해. 처음 설계할 때 한 시간 더 검토하는 편이 훗날의 버전 전환과 호환성 계층보다 훨씬 싸게 먹혀.

잘못된 예 → 더 나은 예로 고치기

아래는 이 API가 택한 관례에 맞춰 흔한 문제를 고친 예야:

BAD:  GET    /getUserById?id=42         (path 에 verb, ID 에 query)
GOOD: GET    /users/42

BAD:  POST   /createOrder               (path 에 verb)
GOOD: POST   /orders

BAD:  DELETE /users/42/delete           (verb 가 method 와 중복)
GOOD: DELETE /users/42

BAD:  GET    /User/42.json              (대소문자 + 확장자)
GOOD: GET    /users/42  (Accept: application/json)

BAD:  PATCH  /user_settings_for/42      (snake + underscore + 어색)
GOOD: PATCH  /users/42/settings

cwkPippa의 URI 선택

cwkPippa는 복수형 컬렉션, 불투명한 대화 ID(UUID), 소문자와 하이픈, 확장자 없는 경로, 얕은 중첩을 기본 관례로 써. POST /api/council/{id}/finalizePOST /api/heartbeat/cron/{id}/run-now처럼 기존 리소스에 명시적 작업을 수행하는 엔드포인트도 있어. 이런 예외는 의도가 분명하면 괜찮아. 새 동작 경로를 추가하기 전에는 "기존 리소스의 표준 메서드로 뜻을 더 명확하게 표현할 수 있는가?"를 먼저 물어봐.

Code

여섯 가지 기본값을 적용한 URI 어휘·text
# Resource-oriented URI 어휘 — 이 template 복사

# Collection (복수)
GET    /users                                # 나열
POST   /users                                # 생성

# Item (collection 안 단수)
GET    /users/{id}                           # 읽기
PUT    /users/{id}                           # 교체
PATCH  /users/{id}                           # 부분 update
DELETE /users/{id}                           # 제거

# Nested ownership (max 2-3 레벨)
GET    /users/{id}/orders                    # user 에 속한 order
POST   /users/{id}/orders                    # user 아래 order 생성
GET    /users/{id}/orders/{oid}              # 특정 order 읽기

# Sub-collection (깊은 nesting 피해)
GET    /orders/{oid}/items                   # OK
GET    /orders/{oid}/items/{iid}             # OK (3 깊이, edge)
GET    /comments/{cid}                       # 승격, /orders/{o}/items/{i}/comments/{c} 아님

# Action endpoint (path 에 verb, POST method, 이름 붙은 action)
POST   /payments/{id}/refund
POST   /jobs/{id}/cancel
POST   /messages/{id}/forward
FastAPI — 복수형 컬렉션, 불투명한 ID, 얕은 중첩, 동작 엔드포인트·python
# 여섯 규칙 다 따르는 FastAPI route
from fastapi import FastAPI, APIRouter, status

app = FastAPI()

# 최상위 collection: /users
users = APIRouter(prefix='/users', tags=['users'])

@users.get('')
async def list_users():
    return await query_users()

@users.post('', status_code=status.HTTP_201_CREATED)
async def create_user(payload: dict):
    new_id = await create(payload)  # opaque ID, 'usr_8x3kPq' 같은
    return {'id': new_id, **payload}

@users.get('/{uid}')
async def read_user(uid: str):
    return await get(uid)

# Nested: /users/{uid}/orders
@users.get('/{uid}/orders')
async def list_user_orders(uid: str):
    return await orders_for_user(uid)

# 승격 (3 레벨 이상 안 감): /orders/{oid}
orders = APIRouter(prefix='/orders', tags=['orders'])

@orders.get('/{oid}')
async def read_order(oid: str):
    return await get_order(oid)

# Action endpoint — path 에 verb-name, POST method
@orders.post('/{oid}/cancel', status_code=status.HTTP_202_ACCEPTED)
async def cancel_order(oid: str):
    return await cancel(oid)

app.include_router(users)
app.include_router(orders)

External links

Exercise

다음 URI를 이 과의 여섯 가지 기본값에 맞게 다시 설계해 봐. (1) POST /createUser, (2) GET /User_Profile/42.json, (3) POST /user/42/delete, (4) GET /getOrdersForUser?userId=42, (5) PUT /modify-order/abc/status?to=cancelled, (6) GET /users/42/orders/abc/items/1/comments/9/replies/3. 각 항목에서 무엇이 불일치하거나 불필요하게 복잡한지도 설명해.
Hint
(1) POST /users. (2) GET /users/42와 Accept: application/json. (3) DELETE /users/42. (4) GET /users/42/orders 또는 소유 의미가 약하면 GET /orders?user_id=42. (5) PATCH /orders/abc와 {status: 'cancelled'}, 또는 명시적 명령인 POST /orders/abc/cancel. (6) 안정된 ID와 독립적인 접근 권한이 있다면 GET /replies/3로 평탄화할 수 있어. 핵심은 동사 중복, 이름 관례의 혼용, 표현 형식과 식별자의 결합, 지나친 중첩을 찾아내는 거야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 피파warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.