API 설계
다른 팀과 다른 회사가 오래 기대어 쓰는 HTTP API를 설계하는 법 — 자원과 메서드, 상태 코드, 오류 형식, 페이지네이션, 버전, 멱등 키와 재시도, 동시성 제어, 인증, 속도 제한 — 을 표준 문서의 규칙과 직접 돌려 본 예로 정리하고, GraphQL·gRPC·웹훅이 언제 더 맞는지 비교한다.
목차 (18)
API는 계약이다
API(Application Programming Interface)는 한 프로그램이 다른 프로그램에게 "이렇게 부르면 이렇게 답한다"고 약속한 경계다. 웹 API라면 어떤 주소에 어떤 메서드로 무엇을 보내면, 어떤 상태 코드와 어떤 모양의 본문이 돌아오는지가 약속의 내용이다. 오류일 때 무엇이 오는지, 누가 부를 수 있는지, 얼마나 자주 부를 수 있는지도 약속에 들어간다.
이것을 계약으로 보는 이유는 한번 공개하면 내 마음대로 바꿀 수 없기 때문이다. 내부 함수는 이름을 바꾸고 호출하는 곳을 함께 고치면 되지만, API를 부르는 앱은 사용자 휴대폰에 몇 년씩 깔려 있고 파트너의 코드는 내가 고칠 수 없다. 좋은 API 설계는 결국 "나중에 바꾸기 쉬운 계약"을 처음에 고르는 일이다.
그래서 API 설계의 첫 원칙은 내부 구조를 그대로 드러내지 않는 것이다. 데이터베이스 테이블과 열 이름을 그대로 응답에 비추면, 테이블을 고칠 때마다 계약이 깨진다. 클라이언트가 무엇을 하려는지(주문하기, 주문 목록 보기)에서 출발해 자원과 필드를 고른다.
REST: 자원, URI, 메서드
REST 방식의 HTTP API는 다룰 대상을 자원(resource)으로 나누고 주소(URI)를 붙인 뒤, 그 자원에 할 일을 HTTP 메서드로 말한다. 주소는 명사, 동작은 메서드다. POST /createOrder가 아니라 POST /orders, GET /getOrder?id=42가 아니라 GET /orders/42다. 모음은 복수형, 모음 안의 하나는 그 아래 id로 적고, 소속 관계는 한 단계 정도만 중첩한다(/orders/42/items). 더 깊이 중첩하면 주소가 데이터 구조에 묶인다.
메서드마다 HTTP가 정해 둔 성질이 있다. 안전한(safe) 메서드는 서버 상태를 바꾸지 않겠다는 약속이라 브라우저·크롤러·프록시가 마음 놓고 미리 부르거나 다시 부른다. 멱등(idempotent) 메서드는 같은 요청을 한 번 하든 열 번 하든 서버에 남는 결과가 같다는 약속이라, 응답을 못 받았을 때 다시 보내도 된다. RFC 9110은 GET·HEAD·OPTIONS·TRACE를 안전하다고, 여기에 PUT·DELETE를 더한 것을 멱등이라고 정한다. PATCH(RFC 5789)와 POST는 둘 다 아니다.
| 메서드 | 안전 | 멱등 | 쓰임 |
|---|---|---|---|
GET | 예 | 예 | 자원 읽기 |
HEAD | 예 | 예 | 본문 없이 헤더만 읽기 |
OPTIONS | 예 | 예 | 허용된 메서드 묻기(CORS 사전 요청) |
POST | 아니오 | 아니오 | 모음에 새 자원 만들기, 동작 실행 |
PUT | 아니오 | 예 | 자원 전체를 이 값으로 바꾸기 |
PATCH | 아니오 | 아니오 | 자원 일부 고치기 |
DELETE | 아니오 | 예 | 자원 지우기 |
멱등은 "응답이 같다"가 아니라 "서버에 남는 효과가 같다"는 뜻이다. DELETE /orders/42는 첫 번째에 204, 두 번째에 404를 돌려줄 수 있지만 주문 42가 없는 상태라는 결과는 같다. PUT은 자원 전체를 보낸 값으로 바꾸므로 멱등이고, "재고를 1 줄여라" 같은 PATCH는 보낼 때마다 결과가 달라지므로 멱등이 아니다. POST를 안전하게 재시도하는 법은 아래 멱등 키 절에 있다.
상태 코드는 클라이언트가 본문을 읽기 전에 무슨 일이 났는지 알게 하는 첫 신호다. 많이 쓰는 것만 골라도 충분하고, 겹치는 코드 사이에서는 일관되게 하나를 고르는 것이 중요하다.
- 201 Created는 새 자원을 만들었을 때 쓰고,
Location헤더로 새 자원의 주소를 알린다. 본문 없이 끝난 삭제·갱신에는 204 No Content가 맞다. - 409 Conflict는 요청이 지금 자원 상태와 부딪칠 때(이미 결제된 주문 취소, 이미 있는 아이디로 가입) 쓴다. 클라이언트가 상황을 고쳐 다시 보낼 수 있다는 뜻이 담긴다.
- 권한이 없는 사람에게 자원이 있다는 사실조차 숨기려면 403 대신 404를 돌려주기도 한다. 어느 쪽이든 API 전체에서 같은 규칙을 쓴다.
- 5xx는 서버 잘못이다. 클라이언트 잘못(검증 실패)을 500으로 돌려주면 모니터링이 거짓 경보로 가득 차고, 클라이언트는 다시 보내면 될지 고쳐야 할지 모른다.
오류 응답 형식: Problem Details
상태 코드만으로는 "무엇이 왜 틀렸나"를 다 말할 수 없다. 팀마다 { "error": … }, { "message": … }처럼 제각각 만들던 오류 본문을 IETF가 표준으로 정리한 것이 RFC 9457 Problem Details다(2023년, 이전 판 RFC 7807을 대신한다). 미디어 타입은 application/problem+json이고, 다섯 멤버를 정의한다.
type: 문제 종류를 가리키는 URI. 클라이언트가 분기할 때 쓰는 주된 식별자다. 없으면about:blank로 보며, 상태 코드 이상의 의미가 없다는 뜻이다.title: 사람이 읽는 짧은 요약. 같은 종류면 매번 같아야 한다.status: 상태 코드(참고용 사본).detail: 이번 일에 대한 설명. 클라이언트가 고치는 데 도움이 되는 말.instance: 이번 발생을 가리키는 URI. 로그와 맞춰 볼 때 쓴다.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/invalid-field",
"title": "입력값이 올바르지 않습니다",
"status": 422,
"detail": "quantity는 1 이상이어야 합니다",
"instance": "/orders/requests/7f3a",
"errors": [{ "field": "quantity", "reason": "minimum" }]
}위 errors처럼 필드별 검증 결과 같은 확장 멤버를 덧붙여도 된다. 클라이언트는 title·detail의 문구가 아니라 type으로 분기해야 문구를 다듬어도 깨지지 않는다. 오류 본문에는 스택 트레이스·SQL 같은 내부 정보를 넣지 않는다. 공격자에게 지도를 건네는 일이다.
페이지네이션: 오프셋 vs 커서
목록을 한 번에 다 돌려주는 API는 데이터가 늘면 언젠가 넘어진다. 그래서 목록 API에는 처음부터 페이지네이션을 넣는다. 나중에 넣으면 "다 오던 것이 일부만 오는" 깨지는 변경이 된다(Google의 AIP-158도 이것을 첫 규칙으로 둔다).
오프셋 방식(?offset=40&limit=20)은 "앞의 40개를 건너뛰고 20개"다. 만들기 쉽고 "7쪽으로 바로 가기"가 된다. 하지만 읽는 사이에 앞쪽에 행이 끼거나 빠지면 건너뛸 대상이 바뀌어 겹치거나 빠뜨린다. 데이터베이스도 건너뛸 40개를 실제로 읽어야 해서 뒤쪽 페이지일수록 느려진다. 커서 방식(?cursor=…&limit=20)은 "지난번 마지막으로 받은 것 다음부터"다. 정렬 열에 인덱스가 있으면 어느 페이지든 같은 속도로 찾는다.
글 10개가 최신순으로 있다. 한 쪽에 3개씩 읽는다.
커서는 정렬 기준이 유일해야 한다. 생성 시각만으로 정렬하면 같은 시각의 행 사이에서 경계가 흔들리므로 (created_at, id)처럼 동점을 깨는 열을 붙인다. 커서 값은 클라이언트가 해석하지 못하게 불투명하게 만든다(서버만 아는 형식으로 직렬화해 인코딩). 안을 들여다볼 수 있으면 클라이언트가 그 형식에 기대게 되고, 그 순간 구현 세부가 계약이 된다. 마지막 페이지는 다음 커서를 비워 알리고, 전체 개수는 비싸므로 꼭 필요할 때만 (어림값이라면 그렇다고 밝혀서) 준다.
필터·정렬·부분 응답
목록 API는 쿼리 문자열로 모양을 조절한다. 규칙을 하나 정해 API 전체에 똑같이 쓰는 것이 핵심이다. 어떤 엔드포인트는 sort=created_at desc, 다른 곳은 order_by=-createdAt이면 쓰는 사람은 매번 문서를 뒤져야 한다.
- 필터는 필드 이름을 그대로 쓰고, 범위는
_after·_before나min_·max_처럼 접미사·접두사 하나로 통일한다. 날짜·시각은 ISO 8601에 시간대까지 적는다. - 정렬은 허용하는 필드를 정해 둔다. 인덱스 없는 열로 정렬을 허용하면 요청 하나가 데이터베이스 전체를 훑는다.
- 부분 응답(
fields=)은 모바일처럼 대역폭이 귀한 클라이언트가 필요한 필드만 받게 한다. 반대로 관련 자원을 함께 받는expand=·include=도 흔하다. 이 요구가 많아지면 GraphQL이 답일 수 있다(아래). limit에는 상한을 둔다.limit=1000000이 통과되면 페이지네이션이 없는 것과 같다.
버전 관리와 하위 호환
계약은 언젠가 바뀌어야 한다. 가장 좋은 버전 관리는 버전을 올릴 일을 줄이는 것이다. 클라이언트가 모르는 필드는 무시하고 서버가 없는 선택 필드는 기본값으로 다룬다는 규칙(관대한 읽기)을 지키면, 대부분의 변경은 기존 클라이언트를 깨지 않고 같은 버전 안에서 할 수 있다.
- URL 경로(
/v2/orders): 가장 흔하고 눈에 잘 띈다. 캐시·로그·라우팅이 쉽다. 대신 "버전 2의 주문"이 다른 자원처럼 보인다. - 헤더(
API-Version: 2026-10-01처럼 날짜를 쓰기도 한다): 주소가 깔끔하고 작은 변경마다 날짜 버전을 낼 수 있다. 버전 없이 온 요청을 어떻게 다룰지 정해야 한다. - 미디어 타입(
Accept): HTTP의 원래 설계에 가장 가깝지만 도구 지원이 약하다.
어느 방식이든 낡은 버전을 끄는 절차가 함께 있어야 한다. 사용량을 재고, 문서와 응답 헤더(Deprecation·Sunset 등)로 미리 알리고, 기한을 지킨다. 스키마가 진화할 때 앞·뒤 호환을 지키는 원리는 시스템 설계 › 데이터 모델과 인코딩의 스키마 진화 절에 있다.
멱등 키와 재시도
네트워크는 응답을 잃어버린다. 클라이언트가 "주문하기" POST를 보냈는데 응답이 오지 않으면, 주문이 안 됐는지 됐는데 응답만 사라졌는지 알 수 없다. 다시 보내면 이중 주문(이중 결제) 위험이 있고, 안 보내면 주문이 없을 위험이 있다. 멱등 키가 이 딜레마를 푼다. 클라이언트가 요청마다 고유한 키(보통 UUID v4)를 만들어 Idempotency-Key 헤더에 싣고, 재시도할 때 같은 키를 다시 쓴다. 서버는 키별로 처리 결과를 저장해 두었다가 같은 키가 다시 오면 처리하지 않고 저장한 응답을 돌려준다.
클라이언트가 키 k1(보통 UUID)을 만들어 주문을 보낸다. 서버는 처음 보는 키라 처리하고, 응답을 키와 함께 저장한다.
이 헤더는 IETF httpapi 작업 그룹에서 표준화 중인 초안이다(2026년 10월에 08판). 초안은 같은 키에 다른 본문이 오면 422, 첫 요청이 아직 처리 중일 때 재시도가 오면 409를 권한다. Stripe는 오래전부터 이 방식을 써 왔다. 키는 255자까지, 키를 지우는 시점은 최소 24시간 뒤이고, 들어온 매개변수를 처음 요청과 비교해 다르면 오류를 낸다. 모든 POST가 키를 받고, GET·DELETE는 원래 멱등이라 키가 필요 없다고 적는다.
- 키와 결과를 저장하는 일과 실제 처리를 같은 트랜잭션으로 묶어야 한다. 처리는 했는데 키 저장에 실패하면 재시도가 다시 처리한다.
- 재시도하는 쪽은 간격을 점점 늘리고(지수 백오프) 무작위 흔들림(jitter)을 더해, 장애가 풀린 순간 모두가 한꺼번에 다시 몰리지 않게 한다. 시간 제한·재시도·서킷 브레이커를 함께 설계하는 법은 시스템 설계 › 설계 부품에 있다.
- 결제처럼 돈이 걸린 흐름에서 멱등 키를 원장·대사와 어떻게 엮는지는 시스템 설계 › 정확성이 중요한 시스템에서 다룬다.
동시성 제어: ETag와 If-Match
두 사람이 같은 자원을 동시에 고치면 나중에 쓴 쪽이 먼저 쓴 쪽의 변경을 모르고 덮어쓴다. 이를 잃어버린 갱신(lost update)이라 한다. HTTP는 조건부 요청으로 이를 막는다. 서버는 응답에 자원 버전의 지문인 ETag를 붙이고, 클라이언트는 고쳐 쓸 때 "내가 읽은 버전이 아직 최신일 때만 바꿔라"는 뜻으로 If-Match: "그 ETag"를 보낸다. 그사이 누가 바꿨다면 서버는 412 Precondition Failed로 거절한다.
데이터베이스의 낙관적 동시성 제어(버전 열을 조건으로 UPDATE)를 HTTP 계약으로 드러낸 것이다. 서버가 조건 없는 쓰기를 아예 받지 않으려면 428 Precondition Required를 쓸 수 있다. ETag는 같은 원리로 캐시 재검증(If-None-Match → 304)에도 쓰인다. 데이터베이스 쪽 이야기는 데이터베이스 › 동시성에 있다.
인증과 인가
API는 매 요청마다 "누가 부르나"(인증)와 "이 일을 해도 되나"(인가)를 따로 묻는다. 서버끼리 부르는 API는 흔히 API 키를 헤더에 싣는다. 단순하지만 키는 앱의 신원일 뿐 사용자를 대신하지 않고, 유출되면 바꿀 때까지 유효하다. 그래서 키는 환경 변수·비밀 저장소에 두고, 키마다 권한 범위를 좁히고, 돌려 바꿀(rotate) 수 있게 만든다.
사용자를 대신해 부르는 API는 OAuth 2.0 인가 서버가 발급한 액세스 토큰을 Authorization: Bearer … 헤더로 받는다(RFC 6750). bearer 토큰은 이름대로 "가진 사람"이면 누구나 쓸 수 있으므로, RFC 6750은 클라이언트가 반드시 TLS(HTTPS)로 보내야 한다고 정한다. 자원 서버는 토큰의 서명·만료·발급자·대상과 범위(scope)를 확인한다.
가장 흔한 API 취약점은 인증이 아니라 객체 단위 인가의 누락이다. 로그인한 사용자가 /orders/42를 /orders/43으로 바꿔 남의 주문을 보는 것(IDOR)이다. 토큰이 유효한지만 보지 말고, 요청한 자원이 그 사용자 것인지를 핸들러마다 확인한다. 비밀번호 저장, 세션과 토큰, OAuth·OIDC 흐름, 역할 기반 인가는 보안 › 인증과 인가에 있다.
속도 제한: 429와 Retry-After
공개 API는 한 클라이언트가 자원을 독차지하지 못하게 속도를 제한한다. 한도를 넘으면 429 Too Many Requests(RFC 6585)로 답하고, 얼마나 기다렸다가 다시 보낼지를 Retry-After 헤더(초 또는 HTTP 날짜)로 알린다. 남은 한도를 미리 알려 주는 RateLimit·RateLimit-Policy 헤더는 IETF에서 아직 초안(2026년 5월 11판)이라, 지금은 서비스마다 X-RateLimit-* 같은 자체 헤더가 많다.
토큰 버킷은 짧은 몰림(통의 크기만큼)을 허용하면서 평균 속도(충전 속도)를 지키게 해 가장 널리 쓰인다. 한도는 사용자·키·IP 가운데 무엇을 기준으로 셀지 먼저 정한다. 서버가 여러 대면 카운터를 Redis 같은 공유 저장소에 둔다. 알고리즘별 비교(고정 창·슬라이딩 창·누출 버킷)와 분산 카운터는 시스템 설계 › 설계 부품에 있다. 클라이언트는 429를 받으면 Retry-After를 지키고, 없으면 지수 백오프로 물러난다.
GraphQL: 클라이언트가 모양을 고른다
GraphQL은 서버가 스키마(타입과 필드, 그 사이의 관계)를 하나 공개하고, 클라이언트가 그 안에서 필요한 필드만 골라 쿼리 하나로 묻는 방식이다. 보통 엔드포인트는 하나(/graphql)이고, 읽기는 query, 쓰기는 mutation, 구독은 subscription이다. 명세는 graphql.org 재단이 관리하며 가장 최근 정식판은 2025년 9월판이다.
type Post {
id: ID!
title: String!
author: User!
}
type Query {
posts(first: Int!, after: String): [Post!]!
}REST에서 화면 하나를 그리려면 글 목록, 작성자, 댓글 수를 따로 불러야 하거나(과소 페칭, 왕복이 는다), 엔드포인트가 쓰지도 않을 필드까지 다 돌려준다(과다 페칭). GraphQL은 둘 다 줄인다. 대가는 서버 쪽에 있다. 필드마다 값을 채우는 함수인 리졸버가 따로 돌기 때문에, 글 10개의 작성자를 각각 부르면 데이터베이스 쿼리가 11번이 된다(N+1 문제). 짧은 순간의 요청을 모아 한 번에 부르는 DataLoader 같은 묶음 계층을 두면 2번으로 준다.
- 쿼리 비용 제한: 클라이언트가 모양을 고를 수 있다는 것은 아주 깊고 넓은 쿼리도 보낼 수 있다는 뜻이다. 깊이·복잡도 상한, 시간 제한, 신뢰한 쿼리만 받는 지속 쿼리(persisted query)로 막는다.
- 캐시: 기본이 POST라 HTTP 캐시가 그대로 먹지 않는다. 읽기 쿼리를 GET으로 받거나, 쿼리 대신 해시를 보내는 지속 쿼리로 CDN 캐시를 쓴다.
- 오류: 일부 필드만 실패해도 HTTP 200에
errors배열이 함께 오는 경우가 많아, 상태 코드만 보는 모니터링으로는 실패를 못 본다.
쿼리 제한과 다른 데이터 흐름 방식과의 견줌은 시스템 설계 › 데이터 모델과 인코딩의 "데이터가 흐르는 세 길"에도 있다.
gRPC: 함수를 부르듯이
gRPC는 다른 컴퓨터에 있는 서버의 메서드를 마치 로컬 객체의 메서드처럼 부르게 하는 RPC 프레임워크다. 서비스와 메시지를 기본 IDL인 Protocol Buffers로 .proto 파일에 정의하면, 도구가 언어마다 클라이언트·서버 코드를 만들어 준다. 전송은 HTTP/2 위에서 하고, 상태는 정상일 때도 트레일러의 grpc-status로 보낸다.
syntax = "proto3";
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc WatchOrders(WatchOrdersRequest) returns (stream OrderEvent);
}
message Order {
string id = 1;
int64 total = 2;
}메시지의 필드는 이름이 아니라 번호(= 1, = 2)로 직렬화된다. 그래서 필드 이름은 바꿔도 되지만 번호는 바꾸거나 다시 쓰면 안 된다. 지운 필드의 번호는 예약해 둔다. 이것이 gRPC의 하위 호환 규칙이다.
클라이언트는 호출마다 마감 시간(deadline)을 정하고, 넘기면 DEADLINE_EXCEEDED로 끝난다. 서비스가 서비스를 부르는 사슬에서 마감을 이어 넘기면, 이미 포기한 요청을 아래 서비스가 계속 처리하는 낭비를 막는다. 브라우저는 HTTP/2 프레임과 트레일러를 직접 다루지 못해 gRPC를 바로 부를 수 없으므로, 프록시를 두거나 바깥 API는 REST·GraphQL로 따로 연다.
REST·GraphQL·gRPC 고르기
셋은 경쟁이라기보다 자리가 다르다. 한 시스템 안에서 바깥에는 REST, 앱 화면을 위한 층에는 GraphQL, 내부 서비스끼리는 gRPC를 함께 쓰는 일도 흔하다. 클라이언트별로 응답을 맞춰 주는 층(BFF)과 API 게이트웨이의 역할은 시스템 설계 › 설계 부품에 있다.
| 방식 | 계약의 단위 | 얻는 것 | 치르는 것 | 잘 맞는 자리 |
|---|---|---|---|---|
| REST | 자원(URL)과 HTTP 메서드 | HTTP 캐시·프록시·도구를 그대로 쓴다. 누구나 curl로 부를 수 있다 | 화면마다 필요한 모양이 달라지면 엔드포인트가 늘거나 왕복이 는다 | 공개 API, 자원 중심 CRUD, 외부 파트너 |
| GraphQL | 스키마 하나와 클라이언트가 고른 필드 | 한 번에 필요한 필드만. 스키마가 곧 문서 | 쿼리 비용 제한, N+1, HTTP 캐시가 어렵다 | 화면이 다양한 앱, 여러 백엔드를 묶는 층 |
| gRPC | .proto에 정의한 메서드 호출 | 이진 인코딩, 스트리밍, 언어별 코드 생성, 마감 시간 | 브라우저에서 바로 못 부른다. 사람이 읽기 어렵다 | 내부 서비스끼리, 지연에 민감한 호출 |
판단이 서지 않으면 REST에서 시작한다. 가장 많은 사람과 도구가 이해하고, 나중에 앞에 GraphQL 층을 얹거나 내부 호출을 gRPC로 바꾸는 길이 열려 있다. 반대 방향은 어렵다.
웹훅과 이벤트
"결제가 끝났나?"를 클라이언트가 몇 초마다 묻는(폴링) 대신, 서버가 일이 생기면 클라이언트가 등록해 둔 주소로 HTTP POST를 보내 알리는 것이 웹훅이다. 방향이 거꾸로라 설계할 것도 거꾸로다. 보내는 쪽이 재시도와 서명을 책임지고, 받는 쪽이 검증과 중복 처리를 책임진다.
- 서명: 아무나 내 웹훅 주소로 가짜 이벤트를 보낼 수 있으므로, 공유 비밀로 본문의 HMAC을 계산해 헤더에 싣는다. GitHub은
X-Hub-Signature-256헤더에 HMAC-SHA256 값을 싣고, 받는 쪽이 일반 문자열 비교가 아니라 상수 시간 비교(Node.js의crypto.timingSafeEqual등)로 확인하라고 안내한다. 서명에 시각을 넣고 오래된 것을 거절하면 재전송 공격도 막는다. - 최소 한 번 전달: 재시도 때문에 같은 이벤트가 두 번 올 수 있다. 이벤트 id를 기록해 두고 이미 처리한 것은 건너뛴다(받는 쪽의 멱등성).
- 빨리 답하기: 보내는 쪽은 시간 제한이 짧다. 받는 즉시 큐에 넣고 2xx를 돌려준 뒤 천천히 처리한다.
- 순서: 재시도가 섞이면 순서가 뒤집힌다. 이벤트에 시각이나 버전을 넣고, 본문을 믿기보다 이벤트를 받으면 API로 최신 상태를 다시 읽는 설계가 튼튼하다.
웹훅을 안전하게 보내는 공통 지침을 모은 Standard Webhooks, 이벤트 데이터를 공통 형식으로 기술하는 CNCF 졸업 프로젝트 CloudEvents(2024년 1월 졸업)가 서비스마다 다른 형식을 맞추려는 시도다. 서비스 안에서 이벤트를 흘리는 메시지 큐·로그는 시스템 설계 › 처리 방식에 있다.
문서와 명세: OpenAPI
계약은 사람의 기억이 아니라 파일에 있어야 한다. OpenAPI는 HTTP API를 언어와 상관없이 기계가 읽을 수 있게 적는 표준으로, 소스 코드를 보지 않고도 사람과 프로그램이 서비스가 할 수 있는 일을 알게 하는 것이 목적이다. 경로와 메서드, 매개변수, 요청·응답 본문의 스키마(JSON Schema), 인증 방식을 YAML·JSON 한 파일에 담는다. 2026년 10월 기준 최신판은 3.2.1(2026년 9월)이다.
명세를 먼저 쓰고 리뷰한 뒤 구현하면(design-first) 클라이언트 팀이 목 서버로 동시에 개발할 수 있고, 계약 변경이 코드 리뷰에서 diff로 보인다. CI에서 이전 명세와 새 명세를 비교해 깨지는 변경(필드 삭제, 필수 매개변수 추가)을 자동으로 잡아낼 수도 있다. GraphQL은 스키마가, gRPC는 .proto가 같은 역할을 한다.
CORS 개요
브라우저 안의 자바스크립트가 다른 출처(도메인·포트·스킴이 다른 곳)의 API를 부르면, 브라우저는 동일 출처 정책에 따라 기본적으로 그 응답을 읽지 못하게 한다. CORS는 API 서버가 Access-Control-Allow-Origin 같은 응답 헤더로 "이 출처에는 읽게 해도 된다"고 알리는 방법이다. 단순하지 않은 요청(사용자 정의 헤더, JSON 본문 등)은 브라우저가 먼저 OPTIONS로 허락을 묻는 사전 요청을 보낸다.
API 설계에서 기억할 것은 두 가지다. CORS는 브라우저가 지키는 규칙이라 서버끼리의 호출이나 curl에는 아무 효과가 없으므로 인증·인가를 대신하지 못한다. 그리고 자격 증명(쿠키)을 허용하면서 모든 출처(*)를 열 수는 없으니, 허용 출처를 목록으로 관리한다. 동작과 함정은 보안 › 웹 보안에, 브라우저에서의 fetch 사용은 웹 개발 노트에 있다.
흔한 오해
- "REST = JSON을 주고받는 HTTP API": 원래 REST는 하이퍼미디어까지 포함한 아키텍처 제약의 묶음이다. 실무에서 말하는 REST API는 대개 "자원과 HTTP 메서드를 지키는 HTTP API"이고, 그것으로 충분하다. 이름보다 메서드 의미와 상태 코드를 지키는 일관성이 중요하다.
- "PUT은 수정, POST는 생성": 기준은 생성·수정이 아니라 멱등성이다. 클라이언트가 id를 정해 "이 자리에 이 값"을 놓는다면 생성도 PUT이 맞고, "재고 1 줄이기" 같은 동작은 수정이어도 POST나 PATCH다.
- "오류도 200에 담아 보내면 클라이언트가 편하다": 프록시·캐시·모니터링·재시도 라이브러리가 모두 상태 코드를 본다. 실패를 200으로 보내면 실패가 캐시되고, 경보가 울리지 않고, 재시도가 일어나지 않는다.
- "GraphQL을 쓰면 버전 관리가 필요 없다": 필드를 더하는 것은 쉽지만, 지우거나 의미를 바꾸면 똑같이 깨진다. GraphQL은
@deprecated로 표시하고 사용량을 본 뒤 지우는 절차를 둘 뿐이다. - "내부 API니까 대충 해도 된다": 내부 API도 다른 팀이 쓰기 시작하면 공개 API와 같은 계약이 된다. 바꾸려면 쓰는 팀을 다 찾아 함께 배포해야 한다.
- "429는 서버가 아프다는 뜻": 429는 클라이언트가 한도를 넘었다는 뜻이고, 서버가 과부하라면 503(필요하면 Retry-After와 함께)이 맞다. 둘을 섞으면 클라이언트가 자기 잘못인지 알 수 없다.
핵심 자료
요청이 오가는 HTTP와 TLS의 바닥은 네트워크 노트, 언어별 HTTP 클라이언트와 동시 요청은 프로그래밍 언어 비교에 있다.
| 자료 | 종류 | 요지 | 링크 |
|---|---|---|---|
| RFC 9110: HTTP Semantics | 표준 | 메서드의 안전성·멱등성, 상태 코드, 조건부 요청(If-Match), Retry-After의 정의. API 설계 질문의 절반은 여기서 답이 나온다. | RFC Editor |
| RFC 9457: Problem Details for HTTP APIs | 표준 | RFC 7807을 대신하는 오류 응답 형식. type·status·title·detail·instance 다섯 멤버와 application/problem+json. | RFC Editor |
| The Idempotency-Key HTTP Header Field | IETF 초안 | 재시도해도 한 번만 처리되게 하는 헤더. 같은 키에 다른 본문이면 422, 처리 중 재시도면 409를 권한다. | IETF Datatracker |
| AIP-158: Pagination | 설계 지침 | Google API의 페이지네이션 규칙. 불투명한 page_token, 비어 있는 next_page_token이 유일한 끝 표시, 처음부터 페이지네이션을 넣으라는 조언. | google.aip.dev |
| GraphQL — Performance | 공식 문서 | N+1 문제와 DataLoader식 묶음 요청, GET과 지속 쿼리로 HTTP 캐시를 쓰는 법. | graphql.org |
| gRPC — Core concepts | 공식 문서 | 단항·서버 스트리밍·클라이언트 스트리밍·양방향 네 가지 메서드와 마감 시간, 기본 IDL인 Protocol Buffers. | grpc.io |
| OpenAPI Specification | 명세 | HTTP API를 언어와 상관없이 기계가 읽을 수 있게 적는 표준. 문서·SDK·검증 도구가 모두 이것을 읽는다. | OpenAPI Initiative |
- RFC 9110: HTTP Semantics — RFC Editor
- RFC 9457: Problem Details for HTTP APIs — RFC Editor
- The Idempotency-Key HTTP Header Field — IETF Datatracker
- AIP-158: Pagination — google.aip.dev
- GraphQL — Performance — graphql.org
- gRPC — Core concepts — grpc.io
- OpenAPI Specification — OpenAPI Initiative
- RFC 5789: PATCH Method for HTTP — RFC Editor
- RFC 6585: Additional HTTP Status Codes (429) — RFC Editor
- RFC 6750: OAuth 2.0 Bearer Token Usage — RFC Editor
- RateLimit header fields for HTTP — IETF Datatracker
- Idempotent requests — Stripe API Reference
- GraphQL Specification — spec.graphql.org
- Introduction to gRPC — grpc.io
- gRPC over HTTP2 — grpc/grpc (GitHub)
- Validating webhook deliveries — GitHub Docs
- Standard Webhooks
- CloudEvents