REST API 설계: 리소스와 HTTP 의미론으로 만드는 분산 인터페이스
REST 아키텍처의 리소스 모델링, HTTP 메소드 의미론, 캐시·동시성·보안 설계 원칙을 실무 API 관점에서 정리한다.
2026-08-14 · 최초 발행 2025-10-14
REST는 리소스의 표현을 주고받으며 상태를 바꾼다
REST(Representation State Transfer)는 웹처럼 분산된 환경에서 자원의 표현과 상태 전이를 HTTP 표준 위에서 처리하는 아키텍처 스타일이다. 자원은 URI로 식별하고, 표준 HTTP 메소드로 상태 변화를 나타내며, JSON이나 XML 같은 표현을 교환한다.
이 스타일의 제약은 클라이언트와 서버의 분리, 무상태성, 캐시 가능성, 계층화된 시스템, 균일 인터페이스로 구성된다. Code-on-Demand는 선택 사항이다. 표현에는 링크와 메타데이터를 담아 다음 상태로 이동할 수 있는 모델도 포함될 수 있다.
서버가 클라이언트 세션 상태를 보관하지 않는 무상태 설계는 수평 확장을 쉽게 만든다. 반면 각 요청은 인증 정보와 필요한 컨텍스트를 포함해야 하므로 네트워크 비용과 보안 설계를 함께 다뤄야 한다.
URI에는 자원을, HTTP에는 동작을 둔다
URI는 명사형으로 자원을 나타내고, 컬렉션과 개별 항목을 구분한다. 예를 들어 /orders는 주문 컬렉션이고 /orders/{id}는 특정 주문이다. 계층 표현은 필요하지만 지나치게 깊어지지 않도록 제한한다. 관계를 제공해야 한다면 rel, next, prev 같은 링크를 사용할 수 있다.
HTTP 메소드는 자원에 대한 행위를 표현한다. GET은 조회이며 안전하고 멱등적이다. POST는 생성 또는 액션에 쓰이며 비멱등적이다. PUT은 전체 교체, DELETE는 삭제를 뜻하며 둘 다 멱등적이다. PATCH는 부분 수정이고 조건부로 멱등성을 가질 수 있다.
| Method | CRUD | 안전성(Safe) | 멱등성(Idempotent) | 캐시 가능성 | 주요 상태코드 |
|---|---|---|---|---|---|
| GET | Read | 예 | 예 | 예 | 200, 304, 404 |
| POST | Create/Action | 아니오 | 아니오 | 조건부 | 201, 202, 400, 409 |
| PUT | Replace | 아니오 | 예 | 아니오 | 200/204, 201, 409, 412 |
| PATCH | Partial Update | 아니오 | 조건부 | 아니오 | 200/204, 400, 409, 412 |
| DELETE | Delete | 아니오 | 예 | 아니오 | 204, 404 |
컬렉션에는 POST로 생성 요청을 보내고, 개별 항목에는 PUT 또는 PATCH로 변경을 적용하며, DELETE로 제거한다. 상태코드와 조건부 요청, 캐시 헤더까지 같은 규약 안에서 사용해야 클라이언트와 운영자가 API의 결과를 예측할 수 있다.
Content-Type과 Accept 헤더는 application/json, application/xml 같은 Media Type 협상에 사용한다. 버전은 /v1처럼 경로에 둘 수도 있고 vnd.company.v1+json 같은 미디어 타입으로 관리할 수도 있다. 경로 버전은 배포가 단순한 반면, 미디어 타입 버전은 더 세밀하게 제어할 수 있다. 장기 운영에서는 중복 엔드포인트 관리 비용도 고려해야 한다.
요청은 게이트웨이와 서비스 계층을 거쳐 표현으로 돌아온다
요청은 메소드, URI, 헤더, 바디로 들어온다. 게이트웨이 또는 프록시에서 라우팅, 인증·인가, 레이트 리밋을 처리할 수 있고, 서비스는 유효성 검사와 비즈니스 규칙, 데이터 저장소 트랜잭션을 거쳐 표현을 만든다. 응답에는 상태코드와 ETag·Cache-Control 같은 헤더, Representation 바디를 담는다. 오류가 발생하면 표준 오류 스키마를 반환한다.
ETag와 If-Match는 동시성 제어에 쓸 수 있다. 클라이언트가 알고 있는 자원 버전을 If-Match로 전달하고, 현재 ETag와 다르면 412를 반환해 경쟁 업데이트를 막는다.
캐시 계층은 게이트웨이, 프록시, CDN, 에지 캐시로 구성할 수 있다. Cache-Control, ETag, 304를 함께 사용하고, 캐시 무효화 정책과 일관성 수준을 미리 정한다. 공개 캐시와 사설 캐시는 구분하며 민감 데이터에는 no-store를 적용한다.
마이크로서비스와 외부 연동에서 API 계약을 유지하는 방법
주문·결제·회원처럼 분리된 서비스는 독립 API를 제공하고, API 게이트웨이에서 라우팅·인증·레이트 리밋을 구성할 수 있다. 무상태성은 수평 확장과 배포 독립성을 뒷받침한다.
파트너 연동이나 공개 데이터 API에서는 표준 HTTP와 JSON이 온보딩 비용을 줄인다. API 키, OAuth2, 스코프를 통해 접근 제어를 운영한다. 모바일이나 단일 페이지 앱을 위한 BFF에서는 데이터 취득 인터페이스를 제공하고, 캐시와 조건부 요청으로 네트워크를 최적화한다. 페이징, 정렬, 필터 파라미터도 일관된 인터페이스로 제공해야 한다.
대규모 데이터셋을 다룰 때는 page, size, sort, filter 규약을 정하고, 고성능 스캔에는 커서 기반 또는 시간 기반 페이징을 선택할 수 있다.
인증·관측성·회복탄력성은 API 설계에서 분리되지 않는다
인증과 인가에는 OAuth2/OIDC, mTLS, 스코프와 권한 모델을 적용할 수 있다. 레이트 리밋, 쿼터, 감사 로깅도 함께 운영한다.
장애를 추적하려면 코릴레이션 ID, 구조화 로그, 레이트·에러·지연 메트릭, 분산 트레이싱이 필요하다. 타임아웃, 서킷브레이커, 리트라이 지수백오프, POST 재시도를 위한 멱등성 키는 회복탄력성을 위한 수단이다.
캐시 적중률이 높아지면 평균 지연을 30~80% 줄일 수 있고 백엔드 호출도 절감된다. 무상태 확장은 인스턴스 증감의 선형 확장성을 제공한다. 표준 기반 인터페이스는 이식성과 상호운용성을 높이며, 일관된 메소드와 상태코드 체계는 장애 분석과 운영 표준화에 도움이 된다.
FastAPI로 보는 ETag 기반 전제조건 처리
다음 예제는 Python 3.11, FastAPI 0.110+, Uvicorn 0.29+ 환경을 전제로 한다. 메모리 저장소를 사용하는 데모이며 ETag 기반 전제조건 처리를 포함한다.
# app.py
from fastapi import FastAPI, HTTPException, Header, Response
from fastapi.responses import JSONResponse
from hashlib import md5
app = FastAPI(title="REST Demo")
db = {} # in-memory storage
def etag_of(obj: dict) -> str:
return md5(JSONResponse(content=obj).body).hexdigest()
@app.post("/v1/items", status_code=201)
def create_item(item: dict, response: Response):
if "id" not in item:
raise HTTPException(400, "id required")
if item["id"] in db:
raise HTTPException(409, "duplicate id")
db[item["id"]] = item
response.headers["ETag"] = etag_of(item)
return item
@app.get("/v1/items/{item_id}")
def get_item(item_id: str):
item = db.get(item_id)
if not item:
raise HTTPException(404, "not found")
return JSONResponse(content=item, headers={"ETag": etag_of(item)})
@app.put("/v1/items/{item_id}")
def replace_item(item_id: str, item: dict, if_match: str | None = Header(default=None, alias="If-Match")):
current = db.get(item_id)
if not current:
# 생성 시 201 반환 가능
db[item_id] = item
return JSONResponse(status_code=201, content=item, headers={"ETag": etag_of(item)})
# 전제조건 검사
if if_match and if_match != etag_of(current):
raise HTTPException(412, "precondition failed")
db[item_id] = item
return JSONResponse(content=item, headers={"ETag": etag_of(item)})
@app.delete("/v1/items/{item_id}", status_code=204)
def delete_item(item_id: str):
if item_id not in db:
raise HTTPException(404, "not found")
del db[item_id]
return Response(status_code=204)