테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

API 설계

다른 팀과 다른 회사가 오래 기대어 쓰는 HTTP API를 설계하는 법 — 자원과 메서드, 상태 코드, 오류 형식, 페이지네이션, 버전, 멱등 키와 재시도, 동시성 제어, 인증, 속도 제한 — 을 표준 문서의 규칙과 직접 돌려 본 예로 정리하고, GraphQL·gRPC·웹훅이 언제 더 맞는지 비교한다.

목차 (18)
  1. API는 계약이다
  2. REST: 자원, URI, 메서드
  3. 오류 응답 형식: Problem Details
  4. 페이지네이션: 오프셋 vs 커서
  5. 필터·정렬·부분 응답
  6. 버전 관리와 하위 호환
  7. 멱등 키와 재시도
  8. 동시성 제어: ETag와 If-Match
  9. 인증과 인가
  10. 속도 제한: 429와 Retry-After
  11. GraphQL: 클라이언트가 모양을 고른다
  12. gRPC: 함수를 부르듯이
  13. REST·GraphQL·gRPC 고르기
  14. 웹훅과 이벤트
  15. 문서와 명세: OpenAPI
  16. CORS 개요
  17. 흔한 오해
  18. 핵심 자료

API는 계약이다

API(Application Programming Interface)는 한 프로그램이 다른 프로그램에게 "이렇게 부르면 이렇게 답한다"고 약속한 경계다. 웹 API라면 어떤 주소에 어떤 메서드로 무엇을 보내면, 어떤 상태 코드와 어떤 모양의 본문이 돌아오는지가 약속의 내용이다. 오류일 때 무엇이 오는지, 누가 부를 수 있는지, 얼마나 자주 부를 수 있는지도 약속에 들어간다.

이것을 계약으로 보는 이유는 한번 공개하면 내 마음대로 바꿀 수 없기 때문이다. 내부 함수는 이름을 바꾸고 호출하는 곳을 함께 고치면 되지만, 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, 204, 200 가운데 하나한도를 넘겼나예429 Too Many Requests누구인지 모르나예401 Unauthorized알지만 권한이 없나예403 Forbidden자원이 없나예404 Not Found형식이 깨졌나예400 Bad Request형식은 맞는데 값이 틀렸나예422 Unprocessable Content지금 상태와 부딪치나예409 Conflict서버가 실패했나예500 Internal Server Error모두 아니오 → 성공201만들었다 (Location 헤더)204했고 돌려줄 본문 없음200그 밖의 성공
상태 코드 고르기. 위에서부터 묻고 처음 걸리는 코드를 쓴다(이 순서는 한 가지 관례다). 401은 '누구인지 모른다'(WWW-Authenticate 헤더를 함께 보낸다), 403은 '알지만 안 된다'. 400은 JSON이 깨진 것처럼 읽을 수 없는 요청, 422는 읽을 수는 있지만 값이 규칙에 맞지 않는 요청이다.
  • 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)은 "지난번 마지막으로 받은 것 다음부터"다. 정렬 열에 인덱스가 있으면 어느 페이지든 같은 속도로 찾는다.

최신순 글 목록에서 오프셋과 커서로 두 쪽을 읽을 때, 새 글이 끼거나 글이 지워지면 오프셋만 겹치거나 빠뜨리는 단계 그림글 목록 (최신순)10987654321id쪽1쪽을 읽었다?limit=310191817654321id쪽새 글 12·11 추가 → 오프셋 2쪽?offset=3&limit=3새 글12새 글1110191281272654321id쪽겹침: 9, 8번을 두 번 받았다받은 글: 10, 9, 8, 9, 8, 7같은 상황 → 커서 2쪽?cursor=8&limit=3새 글12새 글1110191817262524321id쪽겹침도 빠짐도 없다받은 글: 10, 9, 8, 7, 6, 5읽은 글 9 삭제 → 오프셋 2쪽?offset=3&limit=3101817625242321id쪽빠짐: 7번은 한 번도 못 받았다받은 글: 10, 9, 8, 6, 5, 4같은 상황 → 커서 2쪽?cursor=8&limit=3101817262524321id쪽겹침도 빠짐도 없다받은 글: 10, 9, 8, 7, 6, 5
1 / 6

글 10개가 최신순으로 있다. 한 쪽에 3개씩 읽는다.

오프셋과 커서 페이지네이션. 칸 아래 1·2는 몇 쪽에서 받았는지다. 겹침과 빠짐은 lib/cs/apiDesign.ts가 두 번의 읽기를 비교해 계산했다.

커서는 정렬 기준이 유일해야 한다. 생성 시각만으로 정렬하면 같은 시각의 행 사이에서 경계가 흔들리므로 (created_at, id)처럼 동점을 깨는 열을 붙인다. 커서 값은 클라이언트가 해석하지 못하게 불투명하게 만든다(서버만 아는 형식으로 직렬화해 인코딩). 안을 들여다볼 수 있으면 클라이언트가 그 형식에 기대게 되고, 그 순간 구현 세부가 계약이 된다. 마지막 페이지는 다음 커서를 비워 알리고, 전체 개수는 비싸므로 꼭 필요할 때만 (어림값이라면 그렇다고 밝혀서) 준다.

필터·정렬·부분 응답

목록 API는 쿼리 문자열로 모양을 조절한다. 규칙을 하나 정해 API 전체에 똑같이 쓰는 것이 핵심이다. 어떤 엔드포인트는 sort=created_at desc, 다른 곳은 order_by=-createdAt이면 쓰는 사람은 매번 문서를 뒤져야 한다.

주문 목록 요청 주소를 경로, 필터, 범위 필터, 정렬, 부분 응답, 페이지 여섯 조각으로 나누고 각 조각의 규칙을 적은 그림GET /v1/orders자원 모음명사, 복수형. 동작은 메서드가 말한다?status=paid필터필드 이름 = 값. 여러 개면 AND&created_after=2026-10-01범위 필터날짜는 ISO 8601, 시간대까지&sort=-created_at정렬- 는 내림차순. 동점 깨기용 id를 뒤에&fields=id,total부분 응답필요한 필드만 받아 크기를 줄인다&limit=20&cursor=…페이지한도에는 상한을 둔다
주문 목록 요청 하나를 뜯어 본 것. 주소의 경로는 무엇(자원), 쿼리 문자열은 어떻게(조건·순서·모양·양)다.
  • 필터는 필드 이름을 그대로 쓰고, 범위는 _after·_before나 min_·max_처럼 접미사·접두사 하나로 통일한다. 날짜·시각은 ISO 8601에 시간대까지 적는다.
  • 정렬은 허용하는 필드를 정해 둔다. 인덱스 없는 열로 정렬을 허용하면 요청 하나가 데이터베이스 전체를 훑는다.
  • 부분 응답(fields=)은 모바일처럼 대역폭이 귀한 클라이언트가 필요한 필드만 받게 한다. 반대로 관련 자원을 함께 받는 expand=·include=도 흔하다. 이 요구가 많아지면 GraphQL이 답일 수 있다(아래).
  • limit에는 상한을 둔다. limit=1000000이 통과되면 페이지네이션이 없는 것과 같다.

버전 관리와 하위 호환

계약은 언젠가 바뀌어야 한다. 가장 좋은 버전 관리는 버전을 올릴 일을 줄이는 것이다. 클라이언트가 모르는 필드는 무시하고 서버가 없는 선택 필드는 기본값으로 다룬다는 규칙(관대한 읽기)을 지키면, 대부분의 변경은 기존 클라이언트를 깨지 않고 같은 버전 안에서 할 수 있다.

버전을 URL, 헤더, 미디어 타입에 적는 세 방식과, 하위 호환 변경 목록과 깨지는 변경 목록URL 경로/v2/orders헤더API-Version: 2026-10-01미디어 타입Accept: …vnd.shop.v2+json하위 호환 — 같은 버전으로· 응답에 새 필드 추가· 선택 매개변수 추가· 새 엔드포인트 · 새 메서드· 오류 메시지 문구 다듬기깨지는 변경 — 새 버전으로· 필드 삭제 · 이름 바꾸기· 타입 · 단위 · 형식 바꾸기· 필수 매개변수 추가· 기본값 · 정렬 순서 바꾸기· 열거 값 추가 (모르면 깨지는 클라이언트)
버전을 적는 세 자리와, 버전을 올릴 필요가 없는 변경·반드시 올려야 하는 변경. 열거 값 추가는 클라이언트가 모르는 값을 어떻게 다루느냐에 따라 깨지기도 하므로, 처음부터 '모르는 값이 올 수 있다'고 문서에 적어 둔다.
  • URL 경로(/v2/orders): 가장 흔하고 눈에 잘 띈다. 캐시·로그·라우팅이 쉽다. 대신 "버전 2의 주문"이 다른 자원처럼 보인다.
  • 헤더(API-Version: 2026-10-01처럼 날짜를 쓰기도 한다): 주소가 깔끔하고 작은 변경마다 날짜 버전을 낼 수 있다. 버전 없이 온 요청을 어떻게 다룰지 정해야 한다.
  • 미디어 타입(Accept): HTTP의 원래 설계에 가장 가깝지만 도구 지원이 약하다.

어느 방식이든 낡은 버전을 끄는 절차가 함께 있어야 한다. 사용량을 재고, 문서와 응답 헤더(Deprecation·Sunset 등)로 미리 알리고, 기한을 지킨다. 스키마가 진화할 때 앞·뒤 호환을 지키는 원리는 시스템 설계 › 데이터 모델과 인코딩의 스키마 진화 절에 있다.

멱등 키와 재시도

네트워크는 응답을 잃어버린다. 클라이언트가 "주문하기" POST를 보냈는데 응답이 오지 않으면, 주문이 안 됐는지 됐는데 응답만 사라졌는지 알 수 없다. 다시 보내면 이중 주문(이중 결제) 위험이 있고, 안 보내면 주문이 없을 위험이 있다. 멱등 키가 이 딜레마를 푼다. 클라이언트가 요청마다 고유한 키(보통 UUID v4)를 만들어 Idempotency-Key 헤더에 싣고, 재시도할 때 같은 키를 다시 쓴다. 서버는 키별로 처리 결과를 저장해 두었다가 같은 키가 다시 오면 처리하지 않고 저장한 응답을 돌려준다.

멱등 키를 단 주문 요청 여섯 번에 대해 서버의 키 저장소와 응답 상태 코드가 단계마다 바뀌는 그림클라이언트 → 서버키 저장소키본문상태주문POST /orders · 키 k1 · 10,000원응답: 201 Created실제 주문 1건k110,000원끝남 · 201 저장#1POST /orders · 키 k1 · 10,000원응답: 201 (저장한 응답 다시)실제 주문 1건k110,000원끝남 · 201 저장#1POST /orders · 키 k1 · 20,000원응답: 422 Unprocessable Content실제 주문 1건k110,000원끝남 · 201 저장#1POST /orders · 키 k2 · 5,000원응답: 처리 중… (응답 아직)실제 주문 1건k110,000원끝남 · 201 저장#1k25,000원처리 중—POST /orders · 키 k2 · 5,000원응답: 409 Conflict실제 주문 1건k110,000원끝남 · 201 저장#1k25,000원처리 중—키 k2의 처리가 끝남응답: 201 Created실제 주문 2건k110,000원끝남 · 201 저장#1k25,000원끝남 · 201 저장#2
1 / 6

클라이언트가 키 k1(보통 UUID)을 만들어 주문을 보낸다. 서버는 처음 보는 키라 처리하고, 응답을 키와 함께 저장한다.

멱등 키 저장소. 응답 규칙은 IETF httpapi 작업 그룹의 Idempotency-Key 초안을 따랐다. 저장소는 키마다 요청 본문, 상태, 결과를 기억한다.

이 헤더는 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로 거절한다.

두 클라이언트가 같은 ETag를 읽은 뒤 차례로 쓸 때, 조건 없는 PUT은 A의 변경을 잃고 If-Match를 건 PUT은 B가 412를 받는 비교조건 없이 PUT① A, B 둘 다 GET → ETag "v1"② A: PUT (조건 없음) → 200③ B: PUT (조건 없음) → 200최종 값재고 9 (B)A의 변경이 사라졌다If-Match로 PUT① A, B 둘 다 GET → ETag "v1"② A: PUT If-Match: "v1" → 200③ B: PUT If-Match: "v1" → 412최종 값재고 9 (A)B는 다시 읽고 고친다
같은 순서의 두 쓰기. 조건 없이 쓰면 B가 A의 변경을 지운다. If-Match를 걸면 B가 412를 받고, 최신 값을 다시 읽어 고친 뒤 다시 보낸다. 값은 도움 함수로 따라가 본 결과다.

데이터베이스의 낙관적 동시성 제어(버전 열을 조건으로 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)를 확인한다.

클라이언트가 인가 서버에서 액세스 토큰을 받아 Authorization Bearer 헤더로 API 서버에 보내고, API 서버가 토큰을 확인해 200, 401, 403 가운데 하나로 답하는 흐름. 위에는 API 키 방식을 따로 적었다API 키X-Api-Key: sk_live_…서버 ↔ 서버. 키는 호출한 앱의 신원이고, 유출되면 바꿀 때까지 유효하다클라이언트인가 서버API (자원 서버)① 사용자 동의② 액세스 토큰③ Authorization: Bearer eyJ…④ API가 서명 · 만료 · 발급자 · 범위(scope)를 확인200 통과401 토큰 없음 · 만료 · 위조403 유효하지만 범위 밖
API 키와 bearer 토큰. 토큰이 없거나 틀리면 401, 토큰은 맞지만 그 일의 범위 밖이면 403이다. 인가 코드 흐름과 PKCE는 보안 노트에 단계 그림으로 있다.

가장 흔한 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-* 같은 자체 헤더가 많다.

요청 8개가 시각 0, 0.5, 2초에 몰려 올 때 토큰 버킷이 5개를 통과시키고 나머지는 429로 거절하는 시간 축 그림통 3개 · 초당 1개 충전 · 요청 하나에 토큰 하나0초1초2초통과 2통과 1통과 0429429통과 1통과 0429통과 칸의 숫자는 남은 토큰. 429 응답에는 Retry-After: 1을 붙인다
토큰 버킷. 통에 토큰이 최대 3개, 초당 1개씩 다시 찬다. 0초에 몰린 요청 넷 가운데 셋이 통과하고, 2초에는 다시 찬 토큰만큼 통과한다. 판정은 도움 함수로 계산했다.

토큰 버킷은 짧은 몰림(통의 크기만큼)을 허용하면서 평균 속도(충전 속도)를 지키게 해 가장 널리 쓰인다. 한도는 사용자·키·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번으로 준다.

글 10개와 작성자를 묻는 GraphQL 쿼리 하나가 리졸버 방식에 따라 데이터베이스 쿼리 11번 또는 2번이 되는 비교클라이언트가 보낸 쿼리 하나query {posts(first: 10) {titleauthor { name }}}필요한 필드만 묻는다:과다 페칭이 없고, 한 번에 받아과소 페칭(여러 번 왕복)도 없다글마다 따로SELECT postsSELECT user 1SELECT user 2SELECT user 3SELECT user 4SELECT user 5SELECT user 6SELECT user 7SELECT user 8SELECT user 9SELECT user 10DB 쿼리 11번DataLoader로 모아서SELECT postsSELECT users IN (…)DB 쿼리 2번
같은 GraphQL 쿼리가 리졸버 구현에 따라 데이터베이스에 주는 부담. 쿼리 수는 도움 함수로 셌다.
  • 쿼리 비용 제한: 클라이언트가 모양을 고를 수 있다는 것은 아주 깊고 넓은 쿼리도 보낼 수 있다는 뜻이다. 깊이·복잡도 상한, 시간 제한, 신뢰한 쿼리만 받는 지속 쿼리(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의 하위 호환 규칙이다.

gRPC 메서드 네 종류에서 클라이언트와 서버가 주고받는 메시지 수. 단항은 하나씩, 서버 스트리밍은 응답 여럿, 클라이언트 스트리밍은 요청 여럿, 양방향은 둘 다 여럿클라이언트서버단항rpc Get(Req) returns (Res)서버 스트리밍returns (stream Res)클라이언트 스트리밍rpc Upload(stream Req)양방향stream Req ↔ stream Res
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를 보내 알리는 것이 웹훅이다. 방향이 거꾸로라 설계할 것도 거꾸로다. 보내는 쪽이 재시도와 서명을 책임지고, 받는 쪽이 검증과 중복 처리를 책임진다.

웹훅을 서명해 보내고 받는 쪽이 확인한 뒤 바로 응답하는 세 단계와, 실패할 때 6번까지 간격을 3배씩 늘려 재시도하는 시간 축보내는 쪽이벤트 발생HMAC-SHA256으로 서명받는 쪽서명 확인(상수 시간 비교)오래된 시각 · 중복 id 거절받는 쪽바로 2xx 응답무거운 일은 큐에 넣어 나중에2xx가 안 오면 재시도: 1분부터 3배씩 (가로축은 제곱근 눈금)실패1회 · 1분2회 · 4분3회 · 13분4회 · 40분5회 · 2시간 1분6회 · 6시간 4분그래도 실패하면 멈추고 알린다. 받는 쪽이 놓친 이벤트를 다시 받을 수 있게 목록 API를 함께 둔다
웹훅 한 번의 흐름과 재시도 간격. 받는 쪽은 서명만 확인하고 바로 2xx로 답한 뒤 일은 큐로 넘긴다. 재시도 간격은 도움 함수로 계산한 예다.
  • 서명: 아무나 내 웹훅 주소로 가짜 이벤트를 보낼 수 있으므로, 공유 비밀로 본문의 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월)이다.

가운데 OpenAPI 명세 파일에서 문서, 클라이언트 SDK, 서버 검증, 목 서버와 계약 테스트 네 가지가 만들어지는 그림openapi.yamlpaths: 경로 · 메서드components.schemas: 본문 모양securitySchemes: 인증 방식사람이 읽는 문서Swagger UI · Redoc클라이언트 SDK 생성TS · Java · Go · Python서버의 요청 검증스키마에 안 맞으면 400·422목 서버 · 계약 테스트구현 전 병렬 개발, CI에서 diff명세를 먼저 쓰면(design-first) 리뷰가 쉽고,코드에서 뽑으면(code-first) 어긋남이 적다
명세 한 벌에서 나오는 것들. 문서와 SDK와 검증이 같은 파일을 읽으므로, 명세를 고치면 모두가 함께 바뀌고 서로 어긋날 틈이 줄어든다.

명세를 먼저 쓰고 리뷰한 뒤 구현하면(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 FieldIETF 초안재시도해도 한 번만 처리되게 하는 헤더. 같은 키에 다른 본문이면 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