~11 min · rest-design, hateoas, honest, rpc-over-http
Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"Fielding은 하이퍼텍스트가 애플리케이션 상태를 이끌지 않으면 RESTful하지 않다고 했어. 업계는 그 엄격한 정의를 따르지 않는 HTTP+JSON API에도 REST라는 이름을 널리 써 왔고. 실용적 선택은 가능하지만, 채택하지 않은 제약까지 갖춘 척할 필요는 없어."
2026년에 'REST'라고 불리는 API의 현실
널리 쓰이는 API의 공개 계약을 큰 흐름으로 살펴보면 이래:
Stripe — 리소스 지향 URI와 SDK 중심 계약을 사용해. 클라이언트는 문서화된 경로와 생성된 SDK를 알고 호출하며, 전면적인 HATEOAS 탐색 모델은 아니야.
OpenAI — /v1/chat/completions처럼 결과 생성 작업이 전면에 나오는 RPC 성격의 엔드포인트가 있어. 하이퍼미디어가 상태 전환을 주도하는 구조는 아니고.
GitHub REST API — 응답의 URL과 페이지 나누기 Link 헤더처럼 하이퍼미디어 요소를 일부 제공하지만, 일반적인 클라이언트는 문서화된 URI와 SDK도 함께 사용해.
AWS — 서비스마다 형태가 다르지만, 서명된 작업 요청과 생성 SDK를 중심으로 한 RPC 성격이 강한 API가 많아.
Twilio, SendGrid, Slack, Anthropic, Google API — 제품과 서비스마다 차이는 있어도, 공개 계약의 중심은 대체로 고정된 작업·리소스 경로와 문서·SDK야. 하이퍼미디어 탐색이 전체 상태 전환을 이끄는 경우는 드물어.
Fielding의 엄격한 정의를 적용하면 이들 가운데 많은 API가 REST의 하이퍼미디어 제약을 온전히 만족하지 않아. 그렇다고 전부 같은 종류의 RPC라고 뭉뚱그릴 수도 없어. 리소스 지향 HTTP, 작업 중심 RPC, 둘의 혼합처럼 실제 설계 성격은 서로 달라. 중요한 건 이름보다 채택한 제약과 계약을 정확히 설명하는 거야.
HATEOAS가 주류가 되지 못한 이유
URI 구조와의 결합 완화, 동적 기능 발견, 공통 클라이언트 계층이라는 이득을 얻으려면 링크를 싣는 것만으로는 부족해. 미디어 타입, 관계 어휘, 클라이언트 해석 계층을 함께 설계하고 운영해야 해:
1. 도메인 의미는 여전히 필요해. 링크를 따라가는 공통 기능은 만들 수 있지만, 주문 승인과 결제 환불의 의미까지 링크만으로 알 수는 없어. 오류 처리, 재시도 정책, 입력 형식, 사용자 경험은 도메인 계약을 요구해. API 전용 클라이언트가 필요한 순간, 많은 팀은 링크 탐색과 고정 URI·SDK 가운데 더 단순한 쪽을 택해.
2. 주류 도구는 경로 중심이야. OpenAPI와 JSON Schema, 자동 생성 SDK, 요청·응답 검증 도구는 대체로 문서화된 작업과 URI 경로를 중심으로 설계돼. OpenAPI에도 links 같은 기능은 있지만, 전면적인 런타임 탐색을 자동으로 해결해 주지는 않아. HATEOAS를 채택하면 기존 도구에 하이퍼미디어 계약과 클라이언트 지원을 더해야 해.
3. 표현과 처리 비용이 늘 수 있어. 링크와 동작 메타데이터는 응답 크기와 직렬화 비용을 늘려. 증가 폭은 리소스와 형식에 따라 다르므로 측정해야 하지만, 처리량이 큰 API에서는 무시할 수 없는 비용이 될 수 있어.
4. 검증 범위가 넓어져./users/42의 응답만 확인하는 계약 검증에 더해, 관계가 올바른 URI와 동작을 가리키는지, 상태에 따라 링크가 정확히 나타나고 사라지는지도 검증해야 해. 모든 검증이 반드시 진입점부터 순회해야 하는 것은 아니지만, 하이퍼미디어 흐름 자체는 별도 검증 대상이 돼.
5. URI 진화의 가치는 팀마다 달라. 운영 API는 기존 URI를 오래 유지하거나 새 버전과 폐기 절차로 바꾸는 경우가 많아. HATEOAS가 일부 경로 변경의 충격을 줄일 수 있지만, 관계 의미와 표현 계약까지 바뀌면 클라이언트 변경은 여전히 필요해. 실제 변경 패턴을 보고 가치가 있는지 판단해야 해.
실제 API가 자주 택하는 정직한 중간
HATEOAS를 전면 도입하지 않아도 이득이 분명한 일부 하이퍼미디어 요소는 선택적으로 쓸 수 있어:
201 Created의 Location 헤더. 새로 만든 리소스의 URI를 응답에서 알려 줘. 클라이언트가 식별자를 조합하지 않아도 돼.
페이지 나누기 Link 헤더(RFC 8288).Link: </items?cursor=abc>; rel="next"처럼 다음 페이지의 URI를 전달해. GitHub가 이 방식을 사용해.
Location을 포함한 리디렉션. 301/302/307/308 응답은 Location으로 이동할 URI를 전달해. 메서드 보존 규칙은 상태 코드마다 다르므로 구분해서 써야 해.
응답 안의 관련 리소스 URI. 사용자 표현에 편의 정보로 {"orders_url": "..."}를 포함할 수 있어. 이것만으로 전체 API가 HATEOAS 방식이 되는 것은 아니야.
많은 팀이 출시하는 "REST API"는 리소스 지향 URI와 HTTP 메서드의 표준 의미, 정확한 상태 코드, 그리고 필요한 곳의 하이퍼미디어 힌트를 조합한 형태야. Fielding의 모든 REST 제약을 만족한다고 단정할 수는 없지만, 그렇다고 곧바로 순수 RPC인 것도 아니야. 설계 성격과 의도한 절충을 구체적으로 말하면 돼.
HATEOAS는 미덕이 아니라 설계 도구야. 서버 주도 UI, 동적 작업 흐름, 복잡한 권한을 가진 관리 도구처럼 이득이 분명한 곳에서는 써. 고정된 CRUD 계약이나 SDK 중심 통합처럼 비용이 더 큰 곳에서는 건너뛸 수 있고. 어느 선택이든 채택한 제약과 포기한 이득을 솔직하게 설명하면 돼. Fielding의 정의와 업계의 넓은 용례가 다르다는 사실도 함께 밝혀.
HATEOAS가 실제로 값어치를 하는 때
서버 주도 UI. Spring HATEOAS 같은 도구나 Hotwire/Turbo의 HTML 기반 흐름, 하이퍼미디어 중심 앱은 서버가 제공하는 전환을 활용해 클라이언트 변경을 줄일 수 있어. 다만 프레임워크마다 매체와 계약은 서로 달라.
작업 흐름 엔진. 가능한 다음 단계가 리소스 상태, 사용자 권한, 비즈니스 규칙에 따라 달라진다면 서버가 전환을 제공하는 편이 여러 클라이언트에 상태 기계를 중복 구현하는 것보다 단순할 수 있어.
동적 권한을 가진 관리 도구. 서버가 인가 결과에 따라 허용된 동작만 제공하고 클라이언트가 그것을 표시하면 UI와 서버 권한의 불일치를 줄일 수 있어. 링크를 숨기는 것만으로 보안이 완성되는 것은 아니므로 실제 요청에서도 인가를 다시 검사해야 해.
오래 진화하며 클라이언트가 다양한 API. 즉시 갱신할 수 없는 클라이언트가 많고 URI 구조가 실제로 자주 바뀐다면 관계 기반 탐색이 결합을 줄일 수 있어. 대신 미디어 타입과 관계 어휘, SDK 또는 공통 클라이언트 지원을 장기 계약으로 운영해야 해.
정직하게 이름 붙이기
"REST인가 아닌가"만 놓고 싸우기보다 설계 선택을 이렇게 설명해 봐:
"리소스 지향 HTTP+JSON API야. URI에는 명사를 쓰고 HTTP 메서드와 상태 코드의 표준 의미를 활용해. 클라이언트는 문서화된 URI 템플릿과 생성 SDK를 사용해."
"HATEOAS 링크와 동작을 제공하는 작업 흐름 API야. 클라이언트는 응답에서 가능한 전환을 발견하고 HAL 기반 표현을 해석해."
"RPC over HTTP API야. URI는 작업 이름이고 주로 POST로 호출하며 오류는 구조화된 객체로 반환해."
셋 모두 정확한 설명이 될 수 있어. 맥락 없이 "REST API"라고만 부르는 것보다 훨씬 많은 정보를 줘.
cwkPippa의 정직한 이름
cwkPippa API는 "동작 엔드포인트를 일부 포함한 리소스 지향 HTTP+JSON API이며, HATEOAS 탐색은 사용하지 않는다"고 설명할 수 있어. 프런트엔드는 호출할 URI를 알고 있고, 201의 Location 헤더가 제한적인 하이퍼미디어 힌트야. 단일 클라이언트를 함께 배포하는 현재 구조에는 알맞은 선택이야. 제3자 공개 API가 생긴다면 페이지 나누기 Link 헤더나 관련 리소스 URL 같은 힌트의 가치가 커질 수 있어. 그렇다고 전면적인 HATEOAS가 반드시 정답이 되는 것은 아니며, 실제 소비자와 변경 요구를 보고 다시 계산하면 돼.
Code
전면적 HATEOAS 없이 활용하는 하이퍼미디어 힌트·http
# 정직한 중간 — full HATEOAS 없는 hypermedia 힌트
# 1. 201 Created 의 Location header — 보편적 hypermedia 힌트
HTTP/1.1 201 Created
Location: /orders/o_xyz
Content-Type: application/json
{"id":"o_xyz","status":"pending"}
# 2. Pagination Link header (RFC 8288) — URI 계산 없이 next/prev 따라
HTTP/1.1 200 OK
Link: </items?cursor=abc>; rel="next", </items?cursor=zyx>; rel="prev"
Content-Type: application/json
{"items": [...], "cursor": "abc"}
# 3. 편의 sub-resource URL (강제 HATEOAS 아님, 그냥 helpful)
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "u_42",
"name": "Pippa",
"orders_url": "https://api.example.com/users/u_42/orders"
}
실용적 클라이언트 — 고정 URI와 Location·Link 헤더 함께 쓰기·python
# 실용적 client — 하드코딩 URI + Location 과 Link header 존중
import httpx
class PragmaticClient:
"""현실 client: 하드코딩 URI template + opportunistic hypermedia."""
BASE = 'https://api.example.com'
def __init__(self, token: str):
self.client = httpx.Client(
base_url=self.BASE,
headers={'Authorization': f'Bearer {token}'},
)
def create_order(self, payload: dict) -> dict:
resp = self.client.post('/orders', json=payload)
resp.raise_for_status()
# Location header 존중 — 새 URI 계산 절약
new_url = resp.headers.get('Location')
return {'id': resp.json()['id'], 'url': new_url}
def list_orders_all_pages(self):
"""Page 자체 계산 대신 pagination Link header 따라."""
url = '/orders'
while url:
resp = self.client.get(url)
resp.raise_for_status()
for item in resp.json()['items']:
yield item
# RFC 8288 Link parsing — 있으면 next 따라
next_link = self._parse_link_header(resp.headers.get('Link', ''), 'next')
url = next_link
@staticmethod
def _parse_link_header(header: str, rel: str) -> str | None:
# 단순화된 parser; production: `linkheader` 같은 라이브러리
for part in header.split(','):
if f'rel="{rel}"' in part:
return part.split(';')[0].strip(' <>')
return None
HATEOAS가 값어치를 하는 사례 — 서버 주도 작업 흐름 UI·python
# HATEOAS 가 이기는 때: server-driven workflow client
from dataclasses import dataclass
import httpx
@dataclass
class WorkflowAction:
name: str
method: str
href: str
class WorkflowClient:
"""이 client 는 link 읽는 법 빼고 workflow 에 대해 아무것도 모름."""
def __init__(self, entry: str):
self.client = httpx.Client()
self.entry = entry
def available_actions(self, resource_url: str) -> list[WorkflowAction]:
resp = self.client.get(resource_url).json()
actions = []
for rel, link in resp.get('_links', {}).items():
if rel in ('self', 'parent'):
continue
actions.append(WorkflowAction(
name=rel,
method=link.get('method', 'GET'),
href=link['href'],
))
return actions
def perform(self, action: WorkflowAction, payload: dict | None = None):
return self.client.request(action.method, action.href, json=payload).json()
# UI 가 available_actions() 항목 당 버튼 render — server 가 workflow control.
# Server 에 새 전환 가진 새 state 추가하면 코드 변경 없이 여기 버튼 추가.
# 이게 HATEOAS 가 제 값 하는 거.
통합해 본 API 세 개를 골라. Stripe, GitHub, OpenAI, Slack 또는 직접 만든 API를 써도 좋아. 각각에서 (1) 클라이언트가 문서화된 URI나 SDK를 알고 호출하는지, 응답의 링크를 따라가는지, (2) Location, Link, _links 같은 하이퍼미디어 요소를 제공하는지, (3) Fielding의 엄격한 정의에서 어떤 REST 제약을 만족하거나 빠뜨렸는지 점검해. 그중 자주 쓰는 API 하나에는 마케팅 이름 대신 실제 설계를 설명하는 한 문단짜리 '정직한 이름'을 붙여 봐.
Hint
판결보다 정확한 분류가 목적이야. 리소스 지향 URI를 쓴다고 HATEOAS까지 자동으로 충족하는 것은 아니고, HATEOAS가 없다고 모든 API가 같은 RPC가 되는 것도 아니야. GitHub의 페이지 나누기 Link 헤더처럼 일부 하이퍼미디어만 쓰는 경우도 구분해. cwkPippa는 '단일 React 클라이언트와 함께 배포되는, 동작 엔드포인트를 일부 포함한 리소스 지향 HTTP+JSON API로 HATEOAS 탐색은 사용하지 않는다'고 설명할 수 있어.
Progress
Progress is local-only — sign in to sync across devices.