REST API 설계: HTTP 메서드 의미론·멱등성, 리처드슨 성숙도, HATEOAS, 버전 관리
이 글의 핵심
엔드포인트 이름을 명사로 바꾸는 것만으로는 REST다운 API가 되지 않습니다. 결제 요청이 타임아웃 뒤 재시도돼 중복 결제가 생기는 상황처럼 메서드 의미를 잘못 쓸 때의 실제 사고에서 출발해, 하이퍼미디어가 언제 가치 있는지와 오류 모델·페이징·레이트 리밋 같은 운영 패턴까지 계약 관점에서 정리합니다.
REST(Representational State Transfer)는 범용 인터페이스(HTTP) 위에서 자원·표현·상태 전이를 일관되게 다루는 설계 규율입니다. 엔드포인트 이름만 다듬는 수준을 넘어서려면 메서드 의미, 안전성·멱등성, 성숙도 모델, 하이퍼미디어, 표현 협상, 진화 가능한 계약(버전), 프로덕션 운영이 함께 맞물려야 합니다. 이 글은 실무에서 자주 막히는 프로토콜·아키텍처 내부 관점을 정리합니다.
리처드슨 성숙도 모델
리처드슨 성숙도(Richardson Maturity Model)는 REST 스타일 API가 HTTP 기능을 얼마나 “의도에 맞게” 쓰는지를 네 단계로 나눈 참고 프레임입니다. “점수”가 아니라 팀이 어디에 서 있는지 진단하고 다음 개선을 논의하는 언어로 쓰는 것이 좋습니다.
Level 0: HTTP를 터널로만 쓰는 단계
하나의 엔드포인트(예: 단일 POST)에 모든 연산을 실어 보내는 원격 프로시저 호출(RPC)에 가까운 형태입니다. HTTP는 전송 수단일 뿐이며, 메서드·상태 코드·캐시 의미론을 거의 활용하지 않습니다. 레거시 연동이나 급한 마이그레이션에서 보일 수 있으나, 캐시·프록시·표준 클라이언트 동작과의 합치가 떨어집니다.
Level 1: 자원(리소스) 중심
여러 URI로 “대상”을 나눕니다. 예를 들어 /orders, /orders/{id}, /customers/{id}처럼 식별 가능한 자원이 드러납니다. 다만 아직 HTTP 메서드 의미가 일관되지 않거나, 모든 변경이 POST로 처리되는 식의 혼합이 남을 수 있습니다.
Level 2: HTTP 메서드와 상태 코드 활용
GET/POST/PUT/PATCH/DELETE를 의도에 맞게 매핑하며, 200/201/204/400/404/409/429 등 의미 있는 상태 코드로 결과를 표현합니다. 이 단계부터 중간 캐시, 조건부 요청(ETag 등), 재시도·멱등성 같은 HTTP 생태계의 이점을 제대로 가져갈 수 있습니다. 많은 공개 API가 목표로 삼는 실무적 “RESTful”의 핵심에 해당합니다.
Level 3: 하이퍼미디어 컨트롤(HATEOAS)
응답에 다음에 할 수 있는 행동(링크·폼·관계 이름) 이 포함되어, 클라이언트가 URL을 하드코딩하지 않고 표현이 제공하는 전이를 따릅니다. 서버가 워크플로를 바꿔도 연결 관계(rel) 수준에서 흡수할 여지가 생깁니다. 대신 미디어 타입·관계 사전·클라이언트 해석 비용이 들어가므로, 모바일 단일 앱처럼 배포 주기가 짧은 경우에는 OpenAPI+버전으로 대체하는 팀도 많습니다.
성숙도를 어떻게 적용할까
내부 마이크로서비스라면 Level 2에 명확한 오류 모델과 멱등 설계를 더한 수준이 보통 균형이 좋습니다. 공개·파트너 API에서는 Level 3보다 Deprecation 헤더, Problem Details, 버전 정책을 먼저 갖추는 것이 효과가 크고, 상태에 따라 가능한 행동이 달라지는 부분에만 HAL이나 JSON:API로 하이퍼미디어를 부분 도입하는 방식이 현실적입니다. “정말 REST인가”라는 논쟁보다 클라이언트, 운영, 보안 담당자가 같은 의미로 이해하는 계약을 갖추는 쪽이 생산적입니다.
HTTP 메서드 의미론과 멱등성
HTTP 메서드는 임의의 “동사”가 아니라 RFC 9110 등에서 규정한 의미론(semantics) 을 가집니다. 클라이언트·캐시·프록시·재시도 로직이 이를 전제로 동작하므로, 잘못 매핑하면 캐시 오염, 중복 부작용, 복구 불가한 상태로 이어질 수 있습니다.
안전성(Safe), 멱등성(Idempotent), 캐시 가능성
- 안전(Safe): 요청이 자원 상태를 바꾸지 않는다는 기대.
GET,HEAD,OPTIONS(표준 의미를 따를 때)가 대표적입니다. - 멱등(Idempotent): 동일 요청을 여러 번 보내도 자원 상태가 한 번 성공한 결과와 같다는 기대. 재전송·재시도와 직결됩니다.
- 캐시 가능(Cacheable): 응답이 캐시될 수 있는지는 메서드와
Cache-Control등에 따릅니다.GET은 조건부로 캐시되기 쉽지만, 개인 데이터는private/no-store로 명시해야 합니다.
실무에서 자주 참고하는 정리는 다음과 같습니다.
| 메서드 | 안전 | 멱등 | 비고 |
|---|---|---|---|
| GET | 예 | 예 | 조회. URI에 민감 정보를 넣는 것은 로그·Referer 유출에 유의 |
| HEAD | 예 | 예 | 메타데이터만; 본문 없음 |
| OPTIONS | 예* | 예 | CORS preflight 등. 노출 범위는 보안 정책으로 제한 |
| PUT | 아니오 | 예 | 치환(replace) 모델이 자연스러움. 없으면 생성 가능(계약에 따름) |
| DELETE | 아니오 | 예 | 이미 삭제된 자원에 대한 재요청 시 404 vs 204 등은 팀 합의로 통일 |
| POST | 아니오 | 아니오 | 생성·액션·파이프라인 트리거. 멱등 키 없으면 중복 생성 위험 |
| PATCH | 아니오 | 보통 아니오 | 부분 수정. 표현 형식에 따라 멱등성 여부가 달라짐 |
* OPTIONS는 “부작용 없음”이 일반적이나, 로깅·과금 훅은 운영 정책으로 정의합니다.
PUT과 PATCH의 실무적 구분
- PUT: 리소스 전체 표현을 치환한다는 모델이 자연스럽습니다. 누락 필드를
null로 볼지, 기본값으로 둘지는 스키마·문서에 명시해야 합니다. - PATCH: 부분 갱신이 목적입니다.
application/merge-patch+json,application/json-patch+json, 도메인 특화 JSON 등 표현이 갈립니다. Merge Patch는null이 삭제 의미가 되는 등 함정이 있으므로 공개 API에서는 예시와 오류 케이스를 문서화하는 것이 좋습니다.
POST의 멱등성: 멱등 키(Idempotency-Key)
결제·주문·티켓처럼 한 번만 성공해야 하는 POST에는 클라이언트 생성 멱등 키(예: Idempotency-Key 헤더)와 서버 측 요청 지문 저장소(TTL)를 두는 패턴이 널리 사용됩니다. 동일 키·동일 본문이면 최초 응답을 그대로 재생하고, 같은 키에 다른 본문이 오면 오류로 거부합니다(어떤 상태 코드를 쓰는지는 아래에서 다룹니다).
POST /v1/payments HTTP/1.1
Host: api.example.com
Content-Type: application/json
Idempotency-Key: 7b291f6c-2c4a-4f1e-9d0a-3e8c5f2a1b00
{"amount":{"value":"1000","currency":"KRW"},"orderId":"ord_42"}
서버 쪽은 Express 미들웨어 하나로 구현할 수 있습니다. 아래 예시는 Redis(node-redis v4 API)에 요청 지문과 응답을 함께 저장합니다.
// idempotency-middleware.js
const crypto = require('crypto');
function hashBody(body) {
return crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
}
function idempotencyMiddleware(redis, ttlSeconds = 86400) {
return async function (req, res, next) {
const key = req.header('Idempotency-Key');
if (!key) {
// 키 없이 통과시킬지 400으로 거부할지는 엔드포인트 정책입니다.
// 결제처럼 민감한 경로는 필수로 강제하는 편이 안전합니다.
return next();
}
const fingerprint = hashBody(req.body);
const storeKey = `idem:${key}`;
// NX: 키가 없을 때만 "처리 중" 표식을 남깁니다. 동시에 들어온 두 재시도 중 하나만 통과합니다.
const acquired = await redis.set(
storeKey,
JSON.stringify({ state: 'processing', fingerprint }),
{ NX: true, EX: ttlSeconds }
);
if (!acquired) {
const raw = await redis.get(storeKey);
if (!raw) {
// SET NX 실패 직후 키가 만료·삭제된 드문 경우: 잠시 뒤 재시도하게 합니다.
return res.status(409).set('Retry-After', '1').end();
}
const record = JSON.parse(raw);
if (record.fingerprint !== fingerprint) {
return res.status(422).type('application/problem+json').json({
type: 'https://api.example.com/problems/idempotency-key-reused',
title: 'Idempotency-Key reused with a different body',
status: 422,
});
}
if (record.state === 'processing') {
// 첫 요청이 아직 끝나지 않았습니다. 결제를 두 번 실행하지 않도록 기다리게 합니다.
return res.status(409).set('Retry-After', '1').end();
}
return res.status(record.status).json(record.body); // 최초 응답 재생
}
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode >= 500) {
// 서버 오류는 저장하지 않고 표식을 지워, 같은 키로 다시 시도할 수 있게 합니다.
redis.del(storeKey);
} else {
redis.set(
storeKey,
JSON.stringify({ state: 'done', fingerprint, status: res.statusCode, body }),
{ EX: ttlSeconds }
);
}
return originalJson(body);
};
next();
};
}
module.exports = { idempotencyMiddleware };
이 미들웨어에서 흔히 빠뜨리는 부분이 두 가지 있습니다. 첫째는 동시 재시도입니다. “저장된 응답이 있는지 조회하고, 없으면 처리”하는 순서로만 짜면, 타임아웃 직후 클라이언트가 재시도를 보냈을 때 첫 요청이 아직 처리 중이라 저장된 응답이 없으므로 두 요청이 모두 결제 로직을 실행합니다. 그래서 SET NX로 “처리 중” 표식을 먼저 원자적으로 남기고, 처리 중인 키에 대한 재시도는 409와 Retry-After로 잠시 뒤에 다시 오게 합니다. 둘째는 무엇을 재생할 것인가입니다. 4xx 같은 확정된 결과는 그대로 재생해야 하지만, 5xx까지 저장하면 일시적 장애가 TTL 동안 고정되어 재시도해도 계속 실패합니다. 예시는 res.json만 가로채므로, 핸들러가 res.send나 res.end로 응답하거나 응답 전에 프로세스가 죽으면 “처리 중” 표식이 TTL까지 남습니다. 실제로는 처리 중 표식에 짧은 별도 TTL을 두거나 res.on('finish')에서 결과를 기록하는 식으로 보완합니다.
같은 키에 다른 본문이 온 경우는 클라이언트 버그이거나 키 재사용입니다. 위 예시는 IETF Idempotency-Key 헤더 초안의 권고를 따라 422로 응답했고, 처리 중 충돌은 409로 구분했습니다. 어떤 코드를 쓰든 문서에 명시해 두면 됩니다. TTL은 클라이언트의 최대 재시도 기간보다 길어야 하며, 너무 길면 저장소 용량이 늘어나므로 도메인에 맞게 정합니다. 실제 결제 부작용과 멱등 기록을 한 트랜잭션으로 묶을 수 없다는 한계도 남는데, 이 부분은 아래 “멱등성 저장소·중복 제거”의 Outbox·자연 키 upsert와 함께 설계합니다.
GET에 쓰기를 넣지 않는 이유
GET은 캐시·프리패치·프록시·크롤러에 의해 의도치 않게 반복 실행될 수 있습니다. GET /cancelOrder?id=... 같은 설계는 실무에서 위험합니다. 상태 변경은 POST/PUT/PATCH/DELETE로 모델링하며, 권한·CSRF(브라우저)·감사 로그를 명시적으로 태웁니다.
CONNECT·TRACE 등
CONNECT는 일반적으로 프록시 터널 용도로 사용되며, 공개 REST API 설계의 중심은 아닙니다. TRACE는 진단용이며 보안상 비활성화하는 환경이 많습니다. 일반적인 REST 자원 API 설계에서는 GET/HEAD/OPTIONS와 엔티티를 바꾸는 메서드에 집중하면 됩니다.
HATEOAS 원리와 구현 패턴
HATEOAS(Hypermedia as the Engine of Application State)는 응답에 실린 링크가 애플리케이션 상태 전이를 이끈다는 REST의 제약을 말합니다. 클라이언트는 고정된 URL 목록보다 rel로 식별되는 전이를 따라 업무를 진행합니다.
자원 상태 vs 애플리케이션 상태
- 자원 상태: 서버가 저장한 도메인 데이터(주문 상태, 잔액 등).
- 애플리케이션 상태: “지금 이 클라이언트가 어디까지 진행했는가”에 해당하는 세션성. HATEOAS는 후자를 하이퍼미디어 링크로 인코딩하려는 시도입니다.
왜 어렵으며, 언제 가치가 있는가
HATEOAS를 쓰면 URI 변경, 권한에 따라 가능한 행동을 숨기는 일, 워크플로 분기를 서버가 통제할 수 있어 공개 API나 오래 유지되는 API에서 유리합니다. 대신 클라이언트가 링크를 실행 가능한 행동으로 해석하려면 미디어 타입과 관계 이름 사전에 대한 합의가 필요합니다. 모바일 앱 하나만 붙는 내부 API라면 이 비용을 들이기보다 OpenAPI와 버전 정책으로 충분한 경우가 많습니다.
가치가 가장 분명하게 드러나는 곳은 상태에 따라 가능한 행동이 달라지는 워크플로입니다. 주문을 예로 들면, 결제 대기 상태에서만 결제·취소가 가능하고 결제 후에는 배송·환불만 가능합니다. 이 규칙을 클라이언트가 상태값으로 직접 분기하면, 서버의 상태 머신이 바뀔 때마다 웹·모바일·파트너 클라이언트를 모두 고쳐 배포해야 합니다.
HAL(application/hal+json)
_links에 self, next, 커스텀 관계를 둡니다.
{
"id": "ord_42",
"status": "PENDING_PAYMENT",
"_links": {
"self": { "href": "/orders/ord_42" },
"pay": { "href": "/orders/ord_42/payments" },
"cancel": { "href": "/orders/ord_42/cancellation" }
}
}
클라이언트는 rel 이름만 알면 되고, href 변경에 더 강해집니다.
서버에서는 상태 머신에 따라 “지금 할 수 있는 행동”만 링크로 노출합니다.
// hal-builder.js
function buildOrderResource(order, baseUrl) {
const self = `${baseUrl}/orders/${order.id}`;
const links = { self: { href: self } };
if (order.status === 'PENDING_PAYMENT') {
links.pay = { href: `${self}/payments` };
links.cancel = { href: `${self}/cancellation` };
} else if (order.status === 'PAID') {
links.ship = { href: `${self}/shipment` };
links.refund = { href: `${self}/refund` };
}
return { id: order.id, status: order.status, _links: links };
}
module.exports = { buildOrderResource };
프론트엔드는 버튼 활성화를 status === 'PAID' 같은 조건으로 판단하는 대신 “refund 링크가 있는가”로 판단합니다. 서버가 “부분 배송 후에도 환불 가능” 같은 규칙을 추가해도, 링크만 제대로 내려가면 클라이언트 배포 없이 반영됩니다. 다만 링크의 존재가 곧 권한 검사는 아니므로, 서버는 해당 전이 요청이 실제로 들어왔을 때 상태와 권한을 다시 검증해야 합니다.
JSON:API·Siren·Collection+JSON
- JSON:API:
links·relationships·included로 연관을 표현. 규격이 무겁지만 일관성이 좋고, 팀이 JSON:API를 지원하는 서버·클라이언트 라이브러리를 함께 쓰면 직렬화·페이징·관계 로딩 규칙을 직접 설계하지 않아도 되어 무게가 상쇄됩니다. - Siren: 엔티티에
actions(메서드·필드 스키마)를 실어 폼형 하이퍼미디어를 표현합니다. - Collection+JSON: 컬렉션 중심 하이퍼미디어. 특정 도메인에 맞을 때 채택합니다.
“가벼운 하이퍼미디어” 절충
전 규격 도입이 부담이면:
- 응답에 관계 이름 → URL 맵(
_actions등 팀 표준)을 둡니다. - Problem Details(RFC 9457, RFC 7807의 후속)로
typeURI와 힌트를 제공합니다. - OpenAPI로 링크 필드·
Link헤더를 문서화합니다.
콘텐츠 협상(Content Negotiation)
콘텐츠 협상은 동일 자원(URI)에 대해 표현(representation)을 선택하는 과정입니다. Accept, Accept-Language, Accept-Encoding, Content-Type 등이 핵심입니다.
프로액티브 협상(요청 헤더 기반)
GET /reports/2026/q1 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.report+json; version=1, application/json;q=0.8
Accept-Language: ko-KR, en;q=0.7
서버는 가능한 표현을 선택하거나 406 Not Acceptable을 반환할 수 있습니다. 엄격한 계약을 원하면 406이 분명하며, 관대한 모드에서는 기본 표현+경고 헤더를 쓰기도 합니다.
리액티브 협상(300·Link)
300 Multiple Choices나 Link 헤더로 대안을 제시합니다. CDN·캐시 키와 맞물리므로 캐시 전략을 먼저 정합니다.
Content-Type과 415 Unsupported Media Type
POST/PATCH 본문 형식은 Content-Type으로 식별합니다. 지원하지 않는 조합이면 415가 클라이언트에게 명확합니다.
언어 협상과 데이터 로캘
Accept-Language는 오류 메시지나 UI 문구처럼 표시용 텍스트에는 잘 맞지만, 금액 형식·날짜·약관 같은 규정 텍스트는 사용자의 계정 설정이나 거래 국가 같은 도메인 규칙이 우선하는 경우가 많습니다. 브라우저 언어가 영어인 한국 사용자에게 원화 결제 약관을 영어로 보여 줘도 되는지는 HTTP 헤더가 결정할 문제가 아닙니다. Accept-Language, locale 쿼리 파라미터, 사용자 프로필의 preferredLocale 중 무엇이 우선하는지를 문서에 명시하고, 언어에 따라 응답이 달라진다면 Content-Language와 Vary: Accept-Language를 함께 내려 캐시가 섞이지 않게 합니다.
압축과 조건부 GET
Accept-Encoding: gzip, br은 JSON처럼 반복이 많은 본문의 전송량을 크게 줄입니다. 여기에 ETag/Last-Modified를 함께 쓰면 If-None-Match로 304를 유도해 본문 전송 자체를 생략할 수 있습니다. 압축된 표현과 압축되지 않은 표현은 다른 표현이므로 Vary: Accept-Encoding이 필요하고, 약한 ETag(W/"...")는 인코딩과 무관하게 같은 값을 쓸 수 있어 압축 계층을 거쳐도 조건부 요청이 깨지지 않습니다.
Accept 파싱을 정규식으로 직접 하면 q 값 우선순위나 와일드카드(application/*)를 놓치기 쉽습니다. Express라면 req.accepts([...])(내부적으로 negotiator)를 써서 서버가 지원하는 목록 중 가장 선호되는 것을 고르고, 결과가 false면 406을 돌려주는 편이 안전합니다.
API 버전 관리 전략
버전은 문자열 하나가 아니라 호환 변경과 파괴적 변경의 정의, 릴리스·폐기 정책, 소비자 통지를 포함한 계약입니다.
버전 없이 시작한 API가 겪는 일
버전 없는 /api/orders 하나로 시작하는 팀은 흔하고, 처음에는 문제가 없어 보입니다. 문제는 클라이언트의 배포 주기가 갈라질 때 생깁니다. 웹은 매주 배포되지만 모바일 앱의 오래된 빌드는 몇 달씩 운영 중이고, B2B 파트너는 분기에 한 번 배포합니다. 그 사이 선택 필드였던 값이 사실상 필수가 되고 null의 의미가 조금씩 바뀌어도, “이 변경은 v2”라고 선언할 수단이 없으니 모든 변경이 기존 경로에 누적됩니다. 결국 어떤 클라이언트가 어떤 가정에 의존하는지 아무도 모르는 상태가 되고, 그때 가서 버전을 도입하려 하면 “현재 동작”이 무엇인지부터 다시 정의해야 합니다. 처음부터 /v1을 붙이는 비용은 거의 없으므로, 공개·파트너 API라면 첫날부터 버전 규칙과 파괴적 변경의 정의를 정해 두는 편이 낫습니다.
URL 경로 (/v1/...)
URL에 버전이 보이므로 라우팅, 게이트웨이, 모니터링에서 버전별로 나누기 쉽습니다. 다만 주문 ID 같은 도메인 식별자는 버전과 무관하게 같아야 v1과 v2 클라이언트가 같은 자원을 가리킬 수 있습니다.
헤더·Accept 기반
Accept: application/vnd.example.v2+json처럼 미디어 타입에 버전을 심습니다.
URI가 버전과 무관하게 안정적이라는 장점이 있지만, 같은 URI가 헤더에 따라 다른 응답을 내므로 캐시가 섞이지 않도록 Vary: Accept가 필요하고, 브라우저 주소창이나 로그만으로는 어떤 버전이 호출됐는지 알기 어려워 디버깅이 번거로워집니다.
쿼리 파라미터 (?apiVersion=2)
단순하지만 로그·프록시 규칙과 섞이기 쉬워 대규모 공개 API에서는 덜 선호되는 편입니다.
호환성 규칙 예시
- 호환(비파괴) 변경: 선택 필드 추가, enum 값 추가(클라이언트가 알 수 없는 값은 무시), 오류 코드 세분화.
- 파괴적 변경: 필드 제거·의미 변경·필수 필드 추가 → 새 버전 또는 새 미디어 타입/프로파일.
폐기(Deprecation)
Deprecation(RFC 9745), Sunset(RFC 8594) 헤더와 Link의 deprecation·sunset·successor-version 관계로 일정과 대체 경로를 알립니다. 로그에서 구버전 호출 비율을 보고 단계적으로 차단합니다. 일정과 대체 경로가 빠진 폐기 공지는 문의만 늘리고 이전을 앞당기지 못합니다.
Express에서 경로 기반 버전과 폐기 헤더를 함께 다루면 다음과 같습니다.
// versioned-router.js
const express = require('express');
const v1 = express.Router();
v1.get('/orders/:id', (req, res) => {
res.json({ id: req.params.id, status: 'PENDING_PAYMENT' }); // status: 문자열 enum
});
const v2 = express.Router();
v2.get('/orders/:id', (req, res) => {
// status가 문자열에서 객체로 바뀐 파괴적 변경 → 새 버전 경로
res.json({ id: req.params.id, status: { code: 'PENDING_PAYMENT', label: '결제 대기' } });
});
function deprecationHeaders(req, res, next) {
res.set('Deprecation', '@1767225599'); // RFC 9745: 폐기 시점(Unix 초)
res.set('Sunset', 'Wed, 31 Dec 2026 23:59:59 GMT'); // RFC 8594: 응답 중단 예정 시점
res.set('Link', '</v2/orders>; rel="successor-version"');
next();
}
function mountVersionedRoutes(app) {
// 헤더 미들웨어는 라우터보다 먼저 등록해야 합니다. 라우터가 응답을 끝내면 뒤의 미들웨어는 실행되지 않습니다.
app.use('/v1', deprecationHeaders, v1);
app.use('/v2', v2);
}
module.exports = { mountVersionedRoutes };
Deprecation 헤더는 RFC로 확정되면서 값 형식이 초안 시절의 true에서 구조화 필드의 날짜(@ 뒤에 Unix 초)로 바뀌었습니다. 오래된 예제를 그대로 옮기면 Deprecation: true를 쓰게 되는데, 이를 해석하는 도구가 형식 오류로 무시할 수 있으니 현재 형식을 쓰는 편이 안전합니다.
프로덕션 REST 패턴
관측 가능성(Observability)
X-Request-Id 또는 W3C traceparent로 로그·메트릭·트레이스를 연결합니다. method, route_template, status, latency_ms, tenant 등 구조화 필드를 일관되게 남깁니다.
오류 모델
Problem Details(application/problem+json, RFC 7807을 대체한 RFC 9457)로 type, title, status, detail, instance를 맞추면 클라이언트가 기계적으로 분기하기 쉽습니다. 스택 트레이스는 노출하지 않습니다.
Express라면 공통 오류 처리 미들웨어 하나로 형식을 강제할 수 있습니다.
// problem-details-error-handler.js
class ApiError extends Error {
constructor(status, type, title, detail) {
super(detail);
this.status = status;
this.type = type;
this.title = title;
}
}
// Express는 인자가 4개인 미들웨어를 오류 처리기로 인식하므로 next를 쓰지 않아도 남겨 둡니다.
function problemDetailsHandler(err, req, res, next) {
const status = err.status || 500;
const body = {
type: err.type || 'about:blank',
title: err.title || 'Internal Server Error',
status,
detail: status >= 500 ? '서버 내부 오류가 발생했습니다.' : err.message,
instance: req.originalUrl,
};
if (status >= 500) {
req.log?.error({ err, requestId: req.id }, 'unhandled error'); // 원인은 로그에만
}
res.status(status).type('application/problem+json').json(body); // 이미 지정한 Content-Type은 유지됨
}
module.exports = { ApiError, problemDetailsHandler };
4xx는 클라이언트가 원인을 고칠 수 있도록 detail을 그대로 전달하고, 5xx는 내부 구현이 드러나지 않도록 일반화된 메시지만 내보낸 뒤 실제 원인은 요청 ID와 함께 서버 로그에 남깁니다.
인증·인가·테넌시
공개 인터넷에서는 OAuth 2.x 범위에서 Bearer, 파트너 연동은 mTLS, 키 회전은 JWKS 등을 문서화합니다. 권한은 리소스 단위 RBAC/ABAC을 OpenAPI나 정책 엔진과 연결합니다.
레이트 리밋·쿼터
429 Too Many Requests와 Retry-After로 클라이언트가 언제 다시 시도할지 알려 줍니다. 남은 할당량은 관행적으로 쓰이는 X-RateLimit-* 헤더나 IETF httpapi 초안의 RateLimit/RateLimit-Policy 필드로 노출하는 경우가 많습니다.
캐시·프라이버시
조회 API는 ETag/Last-Modified로 조건부 요청을 지원하면 비용이 줄어듭니다. 개인 데이터는 Cache-Control: private 또는 no-store로 명시합니다.
게이트웨이·BFF·타임아웃
API 게이트웨이는 인증, 레이트 리밋, WAF, 라우팅, 카나리 배포처럼 모든 API에 공통인 일을 맡습니다. BFF(Backend for Frontend)는 웹·모바일 등 클라이언트별로 여러 도메인 서비스의 응답을 합성해, 내부 서비스의 REST 계약과 외부에 공개하는 계약을 분리합니다. 업스트림 호출에는 각각 타임아웃과 재시도 상한을 두고, 요청 전체의 시간 예산(deadline)을 하위 호출에 전파하면 한 서비스의 지연이 연쇄 장애로 번지는 것을 줄일 수 있습니다.
멱등성 저장소·중복 제거
POST 멱등 키 외에도 소비자 재처리(at-least-once) 환경에서는 자연 키 기반 upsert, Outbox, 메시지 중복 키 등과 조합해 정확히 한 번에 가깝게 만드는 설계가 필요합니다.
계약 테스트와 호환성
Pact 등 소비자 주도 계약으로 브레이킹 변경을 CI에서 걸러냅니다. OpenAPI 스키마 스냅샷과 스테이징 트래픽 리플레이로 회귀를 줄입니다.
페이징·부분 실패
오프셋은 단순하지만 대량 데이터에서 비용이 커질 수 있어 커서(키셋) 기반을 고려합니다. 배치 작업은 202 Accepted + 상태 폴링 URL로 비동기화합니다.
보안 기본기
TLS 강제와 브라우저 대상 HSTS, 쿠키 세션을 쓴다면 CSRF 방어, 입력 검증, 대량 할당 방지, 업로드 크기 제한, SSRF 방어, 로그의 PII 마스킹은 REST를 넘어 공통 과제입니다.
자주 묻는 질문 (FAQ)
Q. 결제 POST 요청이 타임아웃 후 재시도되어 중복 결제가 생기는 것을 어떻게 막나요?
A. POST는 멱등하지 않으므로 클라이언트가 요청마다 고유한 Idempotency-Key 헤더를 생성하고, 서버는 키와 요청 지문을 TTL이 있는 저장소에 기록하는 패턴을 씁니다. 같은 키와 같은 본문이 다시 오면 처리하지 않고 최초 응답을 그대로 재생하며, 같은 키에 본문이 다르면 422로 거부하고, 첫 요청이 아직 처리 중이면 409와 Retry-After로 잠시 뒤 다시 오게 합니다. 재시도는 반드시 같은 키로 보내야 이 보호가 작동합니다.