콘텐츠로 이동

API Design Basics

분류: Layer 1 - 백엔드 기초 | 선수지식: HTTP Basics

API(Application Programming Interface) 설계는 독립적으로 배포되고 변경되는 클라이언트와 서버가 같은 의미로 데이터를 주고받도록 만드는 계약 설계다.

여기서 계약은 엔드포인트 목록만 뜻하지 않는다. URL, HTTP 메서드, 요청 바디, 응답 스키마, 상태 코드, 에러 형식, 페이지네이션, 버전 정책, rate limit까지 포함한다. 잘 설계된 API는 서버 내부 구현을 몰라도 클라이언트가 “무엇을 요청할 수 있고, 어떤 결과를 기대할 수 있으며, 실패하면 어떻게 판단해야 하는지”를 알게 해준다.

HTTP Basics에서 요청, 응답, 메서드, 상태 코드, 헤더를 배웠다면 API 설계는 그 재료에 제품 수준의 의미를 붙이는 단계다.

HTTP 자체는 GET, POST, 404, Retry-After 같은 도구를 제공한다. 하지만 “주문 생성은 어떤 URL이어야 하는가”, “같은 주문 생성 요청이 두 번 오면 어떻게 막을 것인가”, “v1 클라이언트가 v2 응답을 받지 않게 하려면 어떻게 알릴 것인가”는 HTTP가 자동으로 정해주지 않는다. 이 결정을 팀과 클라이언트가 공유 가능한 계약으로 고정하는 일이 API 설계다.

API가 계약이 되는 순간 서버와 클라이언트는 서로 독립적으로 움직일 수 있다. 서버는 내부 테이블을 바꿀 수 있고, 클라이언트는 배포 주기가 느린 모바일 앱이나 외부 파트너 시스템일 수 있다. 계약이 안정적이면 양쪽이 동시에 배포되지 않아도 서비스가 유지된다. 계약이 흔들리면 작은 필드 이름 변경도 장애가 된다.

2.5 선행 기술의 한계 — API 설계가 등장한 이유

섹션 제목: “2.5 선행 기술의 한계 — API 설계가 등장한 이유”

초기 원격 호출 방식은 자주 “함수 호출을 네트워크로 옮긴 것”처럼 설계됐다. 예를 들어 모든 작업을 POST /service로 보내고, 실제 동작은 바디 안의 method=getUsers나 XML envelope에 숨기는 방식이다. 이 방식은 서버 입장에서는 편해 보이지만, 클라이언트와 중간 인프라 입장에서는 의미를 읽기 어렵다.

  • 조회인지 변경인지 URL과 메서드만 보고 알 수 없다.
  • CDN, 브라우저, 프록시가 안전하게 캐시할 요청을 구분하기 어렵다.
  • 재시도해도 안전한 요청과 위험한 요청이 섞인다.
  • API 문서 밖에서는 어떤 필드가 안정 계약인지 판단하기 어렵다.
  • 엔드포인트 이름이 함수명처럼 늘어나면서 도메인 모델이 보이지 않는다.

REST(Representational State Transfer)는 이 문제를 “HTTP가 이미 가진 의미체계를 계약으로 쓰자”는 방향으로 풀었다. URL은 리소스를 식별하고, HTTP 메서드는 그 리소스에 대한 의도를 표현한다. 응답은 리소스의 현재 표현(representation)을 돌려준다. REST를 외우는 목표는 특정 URL 모양을 흉내 내는 것이 아니라, HTTP 의미를 클라이언트-서버 계약으로 재사용하는 감각을 얻는 것이다.

이 토픽이 사라지면 PUTPATCH의 차이, Idempotency-Key, cursor 페이지네이션, 버전 폐기 신호 같은 결정이 모두 “취향”처럼 보인다. 실제로는 모두 독립적인 클라이언트와 서버가 같은 계약을 해석하게 만들기 위한 장치다.

3. API 계약은 무엇을 고정하는가

섹션 제목: “3. API 계약은 무엇을 고정하는가”

좋은 API 설계는 “엔드포인트를 몇 개 만들 것인가”보다 “무엇을 바꾸지 않겠다고 약속할 것인가”를 먼저 정한다. 아래는 하나의 주문 생성 API가 고정해야 하는 계약 표면이다.

POST /v1/orders HTTP/1.1
Authorization: Bearer <token>
Idempotency-Key: order-20260401-0001
Content-Type: application/json
{
"productId": "prod_123",
"quantity": 2,
"shippingAddressId": "addr_456"
}
HTTP/1.1 201 Created
Location: /v1/orders/order_789
Content-Type: application/json
{
"data": {
"id": "order_789",
"status": "pending_payment",
"productId": "prod_123",
"quantity": 2,
"createdAt": "2026-04-01T09:00:00Z"
}
}

이 예시에서 계약으로 고정되는 것은 다음이다.

계약 요소고정되는 의미깨졌을 때의 영향
POST /v1/orders주문 컬렉션에 새 주문을 생성한다클라이언트가 생성 위치를 다시 찾아야 한다
Idempotency-Key같은 생성 요청의 중복 처리를 막는다타임아웃 재시도에서 중복 주문이 생긴다
요청 바디 필드주문 생성에 필요한 입력 이름과 타입배포 주기가 느린 클라이언트가 즉시 깨진다
201 Created새 리소스가 생성됐다성공/실패 판단과 재시도 정책이 흔들린다
Location 헤더생성된 리소스의 조회 위치후속 조회 흐름이 불명확해진다
응답 data 구조클라이언트가 파싱할 JSON 형태화면, SDK, 테스트가 동시에 깨진다

이 표면을 한 번 공개하면 서버 내부 구현보다 더 오래 살아남는다. DB 테이블명은 바꿀 수 있지만 productId를 갑자기 product_id로 바꾸면 클라이언트 계약이 깨진다. 함수명은 리팩터링할 수 있지만 201을 갑자기 200으로 바꾸면 “생성 성공”을 감지하던 클라이언트가 흔들릴 수 있다.

REST에서 가장 먼저 잡아야 하는 단위는 엔드포인트가 아니라 **리소스(resource)**다. 리소스는 클라이언트가 이름 붙여 다루는 대상이다. 사용자, 주문, 결제, 알림, 파일, 세션처럼 도메인에서 식별 가능한 명사가 리소스가 된다.

표현(representation)은 그 리소스를 특정 시점에 JSON, HTML, XML 같은 형태로 직렬화한 결과다. GET /users/123은 “DB의 users 테이블을 보여줘”가 아니라 “사용자 123이라는 리소스의 현재 표현을 줘”라는 계약이다. 서버가 내부적으로 Redis, PostgreSQL, 외부 API를 조합하더라도 클라이언트는 리소스 표현만 본다.

리소스 이름은 동사가 아니라 명사다

섹션 제목: “리소스 이름은 동사가 아니라 명사다”
좋음: GET /users
좋음: GET /users/123
좋음: POST /orders
좋음: POST /orders/123/cancellation
나쁨: GET /getUsers
나쁨: POST /createOrder
나쁨: POST /cancelOrder?id=123

POST /orders/123/cancellation은 동작처럼 보이는 “취소”도 리소스로 모델링한 예다. 취소 요청에는 상태, 사유, 요청자, 처리 시각이 생길 수 있다. 이런 정보를 다룰 필요가 있으면 cancelOrder라는 함수명보다 cancellation이라는 하위 리소스가 계약을 더 잘 설명한다.

반대로 모든 동작을 억지로 명사화할 필요는 없다. 결제 승인처럼 도메인에서 명령 성격이 강하고 별도 상태 리소스를 만들지 않을 작업은 POST /payments/{id}/capture 같은 컨트롤러성 엔드포인트가 실용적일 수 있다. 중요한 것은 “REST답게 보이는 이름”이 아니라 클라이언트가 상태, 재시도, 실패 결과를 예측할 수 있는가다.

리소스 모델링 판단 기준

컬렉션

여러 리소스의 집합이다. 목록 조회와 생성의 기준점이 된다.

GET /orders, POST /orders

단일 리소스

식별자 하나로 특정 대상을 가리킨다. 조회, 교체, 부분 수정, 삭제가 가능하다.

GET /orders/{orderId}

하위 리소스

상위 리소스 없이는 의미가 약한 대상이다. 중첩은 1~2단계에서 멈추는 편이 읽기 쉽다.

GET /orders/{orderId}/items

명령 리소스

취소, 승인, 재전송처럼 상태 전이가 중요한 작업을 별도 요청 리소스로 드러낸다.

POST /orders/{orderId}/cancellation

리소스는 DB 테이블과 같지 않다

섹션 제목: “리소스는 DB 테이블과 같지 않다”

API 리소스는 클라이언트가 이해하는 계약 단위이고, DB 테이블은 서버가 데이터를 저장하는 구현 단위다. 하나의 Order 응답은 주문 테이블, 주문 상품 테이블, 배송지, 결제 상태를 합쳐 만들 수 있다. 반대로 하나의 테이블이 여러 API 리소스로 나뉠 수도 있다.

이 경계를 지키면 내부 구조 변경이 쉬워진다. orders 테이블을 샤딩하거나 order_items를 별도 서비스로 옮겨도 GET /orders/{id}의 응답 계약이 유지되면 클라이언트는 바뀔 필요가 없다. API 설계가 구현 문서가 아니라 계약 문서여야 하는 이유가 여기에 있다.

5. HTTP 메서드는 동작 이름이 아니라 의미 계약이다

섹션 제목: “5. HTTP 메서드는 동작 이름이 아니라 의미 계약이다”

HTTP 메서드는 CRUD 매핑표를 외우기 위한 암기 대상이 아니다. 메서드마다 안전성, 멱등성, 캐시 가능성, 재시도 가능성에 대한 약속이 붙어 있다.

  • safe: 요청이 서버 상태를 바꾸지 않는다는 뜻이다.
  • idempotent: 같은 요청을 여러 번 보내도 최종 서버 상태가 같다는 뜻이다.
  • cacheable: 중간 캐시가 응답을 저장하고 재사용할 수 있다는 뜻이다.
메서드주된 의미SafeIdempotent설계 포인트
GET리소스 조회상태를 바꾸지 않아야 캐시와 재시도가 안전하다
POST컬렉션 생성, 명령 실행아니오기본은 아니오중복 생성 위험이 있으면 idempotency key가 필요하다
PUT단일 리소스 전체 교체아니오요청 바디가 전체 새 상태여야 한다
PATCH단일 리소스 부분 수정아니오설계에 따라 다름set email=...은 멱등, increment count는 비멱등이다
DELETE단일 리소스 삭제아니오보통 예두 번째 삭제의 응답 코드는 정책으로 정하되 최종 상태는 같다

안전성과 멱등성은 클라이언트 재시도 정책에 직접 연결된다. 네트워크가 끊겨 응답을 못 받은 GET은 보통 다시 보내도 된다. 같은 상황의 POST /orders는 재시도하면 주문이 두 개 생길 수 있다. 이 차이를 계약으로 드러내지 않으면 클라이언트는 장애 때마다 추측해야 한다.

퀴즈

GET 요청이 서버 상태를 바꾸면 왜 API 계약이 깨지는가?

힌트: 메서드 의미는 서버 코드만이 아니라 중간 인프라와 클라이언트 재시도 정책의 입력이다.

정답 보기

GET은 safe 메서드라는 계약을 갖기 때문에 브라우저, 프록시, 크롤러, 재시도 로직이 상태 변경 위험을 고려하지 않는다. GET으로 상태가 바뀌면 캐시, 선조회, 자동 재시도만으로도 의도하지 않은 변경이 발생할 수 있다.

6. 요청과 응답의 모양을 먼저 안정화한다

섹션 제목: “6. 요청과 응답의 모양을 먼저 안정화한다”

API 계약은 URL만 안정적이면 충분하지 않다. 클라이언트가 실제로 의존하는 것은 요청 필드, 응답 필드, 상태 코드, 헤더, 에러 코드까지 포함한 전체 모양이다.

요청 바디는 명시적인 입력 계약이다

섹션 제목: “요청 바디는 명시적인 입력 계약이다”
{
"email": "hong@example.com",
"displayName": "홍길동",
"marketingOptIn": false
}

응답 필드는 충분하지만 과하지 않아야 한다. 목록 화면에 id, name, email만 필요한데 매번 permissions, auditLogs, profileBlob까지 내려보내면 응답 크기와 직렬화 비용이 계약의 일부가 된다. 필드 선택, 요약 DTO, 상세 조회 분리는 성능 최적화 이전에 “클라이언트가 실제로 의존하는 표면을 어디까지로 볼 것인가”의 문제다.

요청 필드는 다음 질문에 답해야 한다.

  • 필수 필드와 선택 필드는 무엇인가?
  • 타입은 무엇인가?
  • 빈 문자열, null, 누락은 서로 같은가 다른가?
  • 서버가 모르는 필드를 받으면 무시하는가, 에러로 거절하는가?
  • 같은 필드가 나중에 enum 값을 추가해도 클라이언트가 견딜 수 있는가?

이 질문을 정하지 않으면 validation 에러가 구현 디테일이 된다. 예를 들어 marketingOptIn이 선택 필드인지 필수 필드인지 명확하지 않으면, 새 클라이언트와 구 클라이언트가 같은 요청을 다르게 해석한다.

성공 응답은 다음 행동을 알려줘야 한다

섹션 제목: “성공 응답은 다음 행동을 알려줘야 한다”
{
"data": {
"id": "user_123",
"email": "hong@example.com",
"displayName": "홍길동",
"createdAt": "2026-04-01T09:00:00Z"
}
}

성공 응답은 클라이언트가 다음 화면이나 다음 요청을 만들 수 있을 만큼 충분해야 한다. 생성 응답이면 201 CreatedLocation 헤더가 유용하다. 목록 응답이면 data만이 아니라 페이지 정보가 필요하다. 비동기 작업이면 202 Accepted와 작업 상태를 조회할 URL이 필요하다.

HTTP/1.1 202 Accepted
Location: /v1/import-jobs/job_123
{
"data": {
"jobId": "job_123",
"status": "queued"
}
}

202는 “처리가 끝났다”가 아니라 “요청을 접수했고 나중에 상태를 확인하라”는 계약이다. 이 의미가 없으면 클라이언트는 버튼을 성공 처리해야 하는지, 대기 화면을 보여줘야 하는지, 재시도해야 하는지 알 수 없다.

에러 응답은 사람이 아니라 프로그램도 읽을 수 있어야 한다

섹션 제목: “에러 응답은 사람이 아니라 프로그램도 읽을 수 있어야 한다”

에러 메시지가 자연어만 있으면 화면에는 보여줄 수 있지만 자동 처리하기 어렵다. 좋은 에러 응답은 안정적인 코드, 사람이 읽을 설명, 필드별 원인, 추적 ID를 함께 제공한다.

{
"error": {
"code": "VALIDATION_ERROR",
"message": "요청 값이 올바르지 않습니다.",
"status": 422,
"traceId": "req_01HR...",
"fields": [
{
"name": "email",
"reason": "이메일 형식이 아닙니다."
}
]
}
}

code는 클라이언트 분기용이므로 안정적이어야 한다. message는 사용자나 운영자가 읽는 설명이므로 바뀔 수 있다. traceId는 서버 로그와 연결하기 위한 값이고, fields는 폼 UI나 SDK가 어느 입력을 고쳐야 하는지 알게 해준다.

400 Bad Request422 Unprocessable Entity는 팀 정책을 정해 일관되게 써야 한다. 일반적으로 JSON 파싱이 불가능하거나 요청 구조 자체가 깨졌으면 400, 구조는 맞지만 도메인 validation이 실패했으면 422로 나눈다. 중요한 것은 어떤 선택을 하든 모든 엔드포인트에서 같은 기준을 적용하는 것이다.

7. PUT vs PATCH — 데이터 보존 정책까지 달라진다

섹션 제목: “7. PUT vs PATCH — 데이터 보존 정책까지 달라진다”

PUTPATCH는 둘 다 수정처럼 보이지만 계약이 다르다. PUT은 리소스 전체를 새 표현으로 교체한다. PATCH는 리소스 일부를 바꾼다.

기존 사용자가 다음 상태라고 하자.

{
"id": "user_1",
"name": "홍길동",
"email": "hong@example.com",
"role": "user"
}

클라이언트가 이름만 바꾸고 싶어서 다음 요청을 보냈다.

PUT /v1/users/user_1
Content-Type: application/json
{
"name": "홍길동(수정)"
}

PUT 계약을 엄격히 따르면 이 요청은 “사용자 전체 표현을 이 바디로 교체”한다는 뜻이다. 서버가 누락 필드를 null이나 기본값으로 채우면 결과는 이렇게 망가질 수 있다.

{
"id": "user_1",
"name": "홍길동(수정)",
"email": null,
"role": null
}

이 상황에서 맞는 계약은 보통 PATCH다.

PATCH /v1/users/user_1
Content-Type: application/json
{
"name": "홍길동(수정)"
}
{
"id": "user_1",
"name": "홍길동(수정)",
"email": "hong@example.com",
"role": "user"
}

PATCH를 쓴다고 모든 문제가 사라지는 것은 아니다. 다음 두 PATCH는 멱등성이 다르다.

{ "name": "홍길동(수정)" }

이 요청은 여러 번 실행해도 최종 이름이 같다.

{ "op": "increment", "path": "/loginCount", "value": 1 }

이 요청은 실행 횟수마다 loginCount가 달라진다. 따라서 PATCH API도 “필드 값을 설정하는 PATCH인지, 연산을 적용하는 PATCH인지”를 문서에 드러내야 한다.

퀴즈

이름만 바꾸는 API에서 PUT을 쓰면 왜 위험한가?

힌트: 메서드 선택은 라우팅 문제가 아니라 데이터 보존 계약이다.

정답 보기

PUT은 전체 교체 의미라 요청 바디에 없는 필드를 null이나 기본값으로 덮을 수 있다. 일부 필드 수정이면 PATCH가 맞고, PUT을 허용하려면 모든 필드를 요구해야 한다.

실패 신호로 보는 PUT/PATCH 설계 문제

섹션 제목: “실패 신호로 보는 PUT/PATCH 설계 문제”

PUT/PATCH 문제가 운영에서 보이는 방식은 구현 프레임워크보다 비슷하다.

실패 신호설계상 원인계약 보강
이름 수정 뒤 이메일, 권한, 설정값이 비어 있다부분 수정에 PUT을 사용했다PATCH로 분리하거나 PUT 요청에 전체 표현을 필수화한다
같은 PATCH 재시도 후 숫자가 계속 증가한다PATCH가 비멱등 연산인데 재시도 정책을 명시하지 않았다연산형 PATCH는 별도 명령 리소스와 idempotency key를 고려한다
동시 수정에서 마지막 요청이 이전 변경을 덮는다수정 전 상태 확인 계약이 없다ETagIf-Match 같은 조건부 요청을 사용한다

긴 복구 절차보다 중요한 학습 포인트는 이것이다. API 메서드 선택이 틀리면 서버는 정상적으로 200 OK를 반환해도 데이터 의미는 조용히 깨진다. 이런 실패는 “서버 에러”가 아니라 “계약이 잘못 설계된 것”으로 봐야 한다.

POST는 기본적으로 멱등하지 않다. POST /orders를 두 번 보내면 주문이 두 개 생기는 것이 자연스러운 해석이다. 하지만 네트워크는 신뢰할 수 없고, 클라이언트는 타임아웃을 받으면 같은 요청을 다시 보낼 수밖에 없다.

이때 필요한 계약이 Idempotency-Key다. 클라이언트는 “이 요청은 이전 요청과 같은 의도”라는 키를 함께 보낸다. 서버는 같은 키가 같은 payload와 다시 오면 새 작업을 만들지 않고 이전 결과를 돌려준다.

POST /v1/orders HTTP/1.1
Idempotency-Key: order-client-123
Content-Type: application/json
{
"productId": "prod_123",
"quantity": 2
}
1번째 요청: 주문 order_789 생성 → 201 Created
응답 유실: 클라이언트는 성공 여부를 모름
2번째 요청: 같은 Idempotency-Key로 재시도
서버 판단: 이미 처리한 요청 → 이전 201 응답을 다시 반환
결과: 주문은 1개만 존재

Idempotency-Key 계약에는 최소한 다음 항목이 필요하다.

항목왜 필요한가
키의 범위같은 키가 사용자별, 엔드포인트별, 전체 시스템별 중 어디에서 유일해야 하는지 정한다
payload 비교같은 키로 다른 payload가 오면 중복 요청이 아니라 충돌일 수 있다
TTL서버가 키와 응답을 얼마 동안 기억할지 정한다
저장할 응답성공만 저장할지, 최종 실패도 저장할지 정한다
재시도 가능 오류클라이언트가 같은 키를 재사용할지 새 키를 만들지 판단하게 한다

예를 들어 결제 API는 키를 24시간 보관할 수 있다. 그 시간 안에는 같은 키와 같은 payload가 오면 이전 결과를 반환하고, 같은 키인데 payload가 다르면 409 Conflict 또는 그에 준하는 에러를 돌려주는 편이 안전하다.

{
"error": {
"code": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD",
"message": "같은 Idempotency-Key로 다른 요청 본문이 전송되었습니다.",
"status": 409
}
}

Idempotency-Key가 항상 성공만 캐시한다고 생각하면 위험하다. 일부 결제 API는 같은 키의 모든 결과를 보존한다. 첫 요청이 일시적인 DB 장애로 500을 받았다면, 같은 키로 재시도해도 같은 500이 반복될 수 있다. 클라이언트는 “계속 서버가 아프다”고 생각하지만 실제로는 첫 실패 응답이 계약상 재사용되는 상태다.

이 실패는 다음 신호로 나타난다.

  • 같은 idempotency key에서 같은 상태 코드가 반복된다.
  • 주문, 결제, 티켓 같은 실제 리소스는 새로 늘지 않는다.
  • 전체 트래픽이나 success-rate는 크게 변하지 않아 장애처럼 보이지 않는다.
  • 사용자 입장에서는 같은 버튼을 눌러도 계속 실패한다.

복구 절차를 길게 외우는 것보다 설계 교훈이 중요하다. 멱등성 계약은 “중복 생성 방지”뿐 아니라 “실패 결과를 어떻게 재사용할 것인가”까지 포함해야 한다. 클라이언트 문서에는 retryable 오류와 non-retryable 오류, 같은 키 재사용 조건, 새 키 발급 조건이 분리되어 있어야 한다.

9. 페이지네이션은 목록 응답의 안정성 계약이다

섹션 제목: “9. 페이지네이션은 목록 응답의 안정성 계약이다”

데이터가 많을 때 목록 API는 한 번에 모든 항목을 보내지 않는다. 페이지네이션은 응답 크기를 줄이는 장치이면서, 클라이언트가 “다음 목록을 어떻게 이어서 받을지”를 정하는 계약이다.

GET /v1/orders?page=2&limit=20

Offset 방식은 “앞에서 몇 개를 건너뛸 것인가”를 기준으로 한다. 구현이 쉽고 페이지 번호 UI와 잘 맞는다. 관리자 화면처럼 데이터 변동이 적고 사용자가 1, 2, 3페이지를 직접 이동하는 화면에는 여전히 좋은 선택이다.

문제는 목록 앞쪽에 데이터가 추가되거나 삭제될 때다.

1페이지 조회 시점: [100, 99, 98, 97, 96]
새 주문 101 추가
2페이지 조회 시점: [96, 95, 94, 93, 92]
결과: 96이 중복 노출되고 91 같은 항목은 뒤로 밀릴 수 있다

Offset은 DB 성능에서도 불리해질 수 있다. OFFSET 100 정도는 보통 큰 문제가 아니지만, OFFSET 100000은 앞의 100000개를 지나가야 하므로 응답 시간이 급증할 수 있다. 이 숫자는 절대 기준이 아니라 “깊은 페이지로 갈수록 비용이 선형적으로 커진다”는 감각을 주기 위한 예다.

Cursor 방식은 “마지막으로 본 위치 다음부터 달라”는 계약이다. 클라이언트는 서버가 준 cursor를 다음 요청에 그대로 넘긴다.

GET /v1/orders?limit=2
{
"data": [
{
"id": "ord_100",
"createdAt": "2026-04-01T09:00:00Z"
},
{
"id": "ord_099",
"createdAt": "2026-04-01T08:59:00Z"
}
],
"pageInfo": {
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA0LTAxVDA4OjU5OjAwWiIsImlkIjoib3JkXzA5OSJ9",
"hasNextPage": true,
"limit": 2
}
}
GET /v1/orders?limit=2&cursor=eyJjcmVhdGVkQXQiOiIyMDI2LTA0LTAxVDA4OjU5OjAwWiIsImlkIjoib3JkXzA5OSJ9

서버는 cursor 안의 마지막 정렬 키를 기준으로 다음 항목을 찾는다.

WHERE (created_at, id) < ('2026-04-01T08:59:00Z', 'ord_099')
ORDER BY created_at DESC, id DESC
LIMIT 2;

여기서 id를 함께 쓰는 이유가 중요하다. created_at만 cursor로 쓰면 같은 timestamp를 가진 주문들의 순서가 흔들릴 수 있다. 동일 시각에 생성된 행이 여러 개 있을 때 쿼리 실행마다 순서가 바뀌면 중복이나 누락이 발생한다. 따라서 cursor는 보통 created_at + id처럼 안정적인 tie-breaker를 포함한다.

페이지네이션 선택 기준

Offset 유지

구현이 단순하고 특정 페이지 번호로 이동하기 좋다. 데이터 변동이 적은 목록에 적합하다.

관리자 목록, 검색 결과, OFFSET 100 안팎, 응답 50ms 이내

Cursor 전환

새 데이터가 계속 들어와도 이어 읽기가 안정적이다. 깊은 페이지에서도 비용이 예측 가능하다.

피드, 알림, 주문 이력, OFFSET 1000 이상, 응답 100ms 초과

같은 시스템 안에서 두 방식을 섞어도 된다. 알림 피드는 cursor를 쓰고, 어드민 사용자 목록은 offset을 유지할 수 있다. 페이지네이션은 전체 시스템 정책이 아니라 엔드포인트별 목록 성격과 UI 요구에 맞춰 정한다.

실패 신호설계상 원인계약 보강
첫 페이지와 둘째 페이지에 같은 항목이 나온다Offset 중간에 새 데이터가 삽입됐다Cursor 전환 또는 snapshot 기준 고정
일부 항목을 영원히 못 본다정렬 기준이 안정적이지 않다고유 tie-breaker를 cursor에 포함
cursor를 다른 필터에 재사용하면 이상한 결과가 나온다cursor가 filter/sort와 묶여 있지 않다cursor에 필터, 정렬, 방향 정보를 포함하거나 재사용을 거절
큰 페이지 번호에서 응답이 급격히 느려진다깊은 OFFSET 비용이 커졌다keyset/cursor 방식 검토

10. 버전 관리는 독립 배포를 보호하는 계약이다

섹션 제목: “10. 버전 관리는 독립 배포를 보호하는 계약이다”

API는 한 번 공개되면 여러 클라이언트가 서로 다른 시간표로 사용한다. 웹은 바로 배포할 수 있지만 모바일 앱은 심사와 사용자 업데이트가 필요하고, 외부 파트너는 분기 단위로만 변경할 수 있다. 버전 관리는 이 시간표 차이를 흡수하는 장치다.

/v1/users 기존 클라이언트가 계속 사용
/v2/users 새 응답 구조나 새 동작을 제공

URL path 방식은 가장 눈에 잘 보이고 디버깅하기 쉽다. Header 방식은 URL을 깔끔하게 유지하지만 로그나 브라우저에서 덜 보인다. Query parameter 방식은 구현이 쉽지만 캐싱과 라우팅 정책이 흐려질 수 있다. 중요한 것은 전략 하나를 정하고 섞지 않는 것이다.

변경판정이유
응답 필드 추가보통 non-breaking관대한 클라이언트는 모르는 필드를 무시할 수 있다
필드 제거breaking기존 클라이언트가 파싱하던 값이 사라진다
필드 이름 변경breakingname을 읽던 클라이언트가 fullName을 모른다
필수 요청 필드 추가breaking구버전 클라이언트가 더 이상 요청을 만들 수 없다
상태 코드 의미 변경breaking 가능클라이언트 분기와 재시도 정책이 달라진다
enum 값 추가상황에 따라 다름클라이언트가 unknown 값을 견디도록 설계됐는지가 중요하다
새 엔드포인트 추가non-breaking기존 호출에는 영향이 없다

버전을 올릴지 말지는 “서버 코드가 많이 바뀌었는가”가 아니라 “기존 클라이언트가 같은 요청을 보냈을 때 같은 의미로 해석되는가”로 판단한다.

구버전 API를 없앨 때는 클라이언트가 미리 알 수 있어야 한다. HTTP 헤더를 사용하면 기계적으로 감지 가능한 폐기 신호를 줄 수 있다.

HTTP/1.1 200 OK
Deprecation: @1782864000
Sunset: Fri, 01 Jan 2027 00:00:00 GMT
Link: </v2/users>; rel="successor-version"

Deprecation은 RFC 9745의 Structured Field Date라서 @ 뒤에 Unix timestamp를 적고, 해당 리소스가 언제 deprecated 상태가 되는지를 알린다. Sunset은 RFC 8594의 HTTP-date 형식으로, 리소스가 언제 응답을 중단할 예정인지를 알린다. 따라서 deprecation 시점은 마이그레이션을 시작하라는 신호이고, 더 뒤의 sunset 시점은 사용 가능 기간의 끝을 나타낸다.

이 신호는 사람에게 공지하는 changelog와 함께 가야 한다. 하지만 changelog만 있고 응답 헤더가 없으면 실제로 아직 v1을 쓰는 클라이언트를 운영 중에 찾기 어렵다. 반대로 헤더만 있고 마이그레이션 가이드가 없으면 클라이언트 팀은 무엇을 바꿔야 하는지 알 수 없다.

좋은 버전 폐기 계약은 보통 다음 정보를 포함한다.

  • 폐기 대상: 어떤 endpoint, method, field가 영향을 받는가
  • 종료 시각: 언제부터 호출이 실패하는가
  • 대체 경로: 어떤 v2 endpoint나 field로 이동해야 하는가
  • 호환 기간: 내부 API와 공개 API의 유예 기간이 다른가
  • 관측 방법: 어떤 client id가 아직 v1을 쓰는지 볼 수 있는가

내부 API는 배포 주체가 같으면 며칠~몇 주 단위로 강하게 밀어붙일 수 있다. 반대로 외부 파트너나 모바일 앱처럼 클라이언트 업데이트를 통제하기 어려우면 몇 달 단위의 Sunset 기간과 사용량 계측이 필요하다. 숫자는 조직마다 다르지만, 호환 기간은 “서버가 언제 지우고 싶은가”가 아니라 “클라이언트가 안전하게 따라올 수 있는가”로 정한다.

11. OpenAPI/Swagger는 문서가 아니라 실행 가능한 계약이다

섹션 제목: “11. OpenAPI/Swagger는 문서가 아니라 실행 가능한 계약이다”

OpenAPI는 REST API의 계약을 기계가 읽을 수 있는 형식으로 적는 표준이다. Swagger UI는 그 계약을 사람이 읽고 테스트하기 쉽게 보여주는 도구다. 둘을 합쳐 “Swagger 문서”라고 부르는 경우가 많지만 핵심은 UI가 아니라 스키마화된 계약이다. 앞에서 만든 주문 생성 API를 OpenAPI로 옮긴다면 POST /orders, 요청 바디 필드, Idempotency-Key 헤더, 201/409/422/429 응답, 에러 body shape가 모두 계약 표면이 된다. 즉 OpenAPI는 “문서를 예쁘게 보여주는 도구”가 아니라 클라이언트와 서버가 무엇을 바꿔도 되는지 합의하는 파일이다. OpenAPI를 바로 쓰기 어렵다면 먼저 수동 계약 표를 만들어도 된다. endpoint, method, 요청 필드, 성공/실패 응답, idempotency key, rate limit 헤더를 한 표에 적으면 이미 계약 사고의 절반은 시작한 것이다. OpenAPI는 그 표를 기계가 읽을 수 있게 옮기는 다음 단계로 보면 된다.

간단한 OpenAPI 조각은 다음처럼 생겼다.

paths:
/v1/users/{userId}:
get:
summary: 사용자 단건 조회
parameters:
- name: userId
in: path
required: true
schema:
type: string
responses:
"200":
description: 조회 성공
content:
application/json:
schema:
$ref: "#/components/schemas/UserResponse"
"404":
description: 사용자 없음
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"

OpenAPI가 있으면 다음 일이 가능해진다.

  • 클라이언트 SDK를 생성한다.
  • API mock server를 만들어 서버 완성 전에도 화면을 개발한다.
  • CI에서 breaking change를 감지한다.
  • 요청/응답 예시를 계약 테스트에 사용한다.
  • 외부 파트너에게 같은 문서를 안정적으로 공유한다.

코드에서 자동 생성한 문서는 현재 구현을 잘 보여줄 수 있지만, 항상 좋은 계약을 보장하지는 않는다. 계약 우선 설계는 먼저 클라이언트가 의존할 요청/응답 의미를 정하고, 구현과 문서가 그 계약을 따라가게 만드는 접근이다.

OpenAPI를 “문서 자동화”로만 보면 부족하다. 컨트롤러 코드에서 자동 생성하더라도 실제 계약을 검토해야 한다. DTO에 optional이 잘못 표시되거나, 에러 응답이 문서에 빠지거나, rate limit 헤더가 누락되면 클라이언트는 여전히 중요한 계약을 모른다.

OpenAPI에서 특히 빠지기 쉬운 항목은 다음이다.

빠지기 쉬운 계약빠졌을 때 문제
에러 응답 schema실패 처리를 화면마다 다르게 구현한다
pagination response다음 페이지 판단을 응답에서 추측한다
auth scheme어떤 토큰이 필요한지 SDK가 알 수 없다
rate limit headers429 이후 대기 시간을 추측한다
idempotency key header안전한 POST 재시도 방법을 모른다
deprecation 정보구버전 호출이 언제 깨질지 모른다

12. Rate Limiting은 공정성과 복구 가능성을 위한 계약이다

섹션 제목: “12. Rate Limiting은 공정성과 복구 가능성을 위한 계약이다”

Rate Limiting은 일정 시간 동안 허용할 요청 수를 제한하는 정책이다. 목적은 단순히 사용자를 막는 것이 아니라, 한 클라이언트가 전체 자원을 독점하지 못하게 하고 장애 상황에서 시스템을 회복 가능하게 만드는 것이다.

예시는 다음과 같다.

전역 읽기 API: API key당 1분에 1000건
로그인 API: IP당 15분에 5회 실패까지
결제 생성 API: 사용자당 1분에 10건

숫자는 도메인과 인프라 여유에 따라 달라진다. 하지만 “누구 기준으로 세는가”, “어떤 시간 창을 쓰는가”, “초과하면 어떤 응답을 주는가”는 API 계약에 들어가야 한다.

Rate limit 응답 헤더는 클라이언트가 스스로 속도를 조절하게 해준다. 아래 형식은 2026년 5월의 IETF draft-ietf-httpapi-ratelimit-headers-11을 기준으로 한다. 아직 RFC가 아닌 작업 중인 초안이므로 구현과 문서가 어느 draft를 따르는지 함께 고정해야 한다.

HTTP/1.1 200 OK
RateLimit-Policy: "default";q=1000;w=60
RateLimit: "default";r=87;t=60

q는 정책의 전체 quota, w는 정책 window(초), r은 현재 사용할 수 있는 quota다. t는 클라이언트가 r을 넘겨 사용할 수 없는 유효 구간을 초로 나타낸다.

한도를 넘으면 429 Too Many RequestsRetry-After가 중요하다.

HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "요청 한도를 초과했습니다. 60초 후 다시 시도하세요.",
"status": 429
}
}

Retry-After가 없으면 클라이언트는 언제 재시도해야 하는지 추측한다. 추측 기반 재시도는 트래픽 폭증을 만들 수 있다. 반대로 서버가 Retry-After를 주면 클라이언트 SDK, 배치 작업, 외부 파트너가 같은 방식으로 backoff할 수 있다.

Rate limit 설계에서 자주 헷갈리는 지점은 다음이다.

선택지맞는 상황주의점
IP 기준로그인, 공개 익명 APINAT 환경에서 여러 사용자가 같은 IP를 공유할 수 있다
사용자 기준로그인된 제품 API토큰 탈취나 다계정 abuse를 별도로 봐야 한다
API key 기준파트너, 서버 간 연동key rotation과 quota plan이 필요하다
고정 window구현 단순window 경계에서 burst가 생길 수 있다
token bucketburst를 일부 허용현재 token 수와 refill rate를 설명해야 한다

Rate limit은 보안 장치이기도 하지만 소비자 계약이기도 하다. API 문서에 한도와 초과 응답이 없으면 클라이언트는 정상적인 사용량 계획을 세울 수 없다.

13. 계약 실패는 어떤 모습으로 나타나는가

섹션 제목: “13. 계약 실패는 어떤 모습으로 나타나는가”

긴 트러블슈팅 명령어보다 먼저 봐야 할 것은 “어떤 설계 선택이 깨졌는가”다. 아래 표는 API 설계 실패가 운영에서 어떤 신호로 보이는지 정리한다.

관찰되는 현상실패한 설계 선택먼저 의심할 계약
결제나 주문이 중복 생성된다비멱등 POST에 재시도 정책이 없다Idempotency-Key, payload 충돌 처리
수정 성공 후 일부 필드가 사라진다부분 수정에 PUT을 썼다PUT 전체 교체, PATCH 부분 수정
목록에서 중복/누락이 생긴다unstable paginationcursor tie-breaker, filter/sort 고정
v1 앱이 갑자기 파싱 오류를 낸다breaking change를 버전 없이 배포했다versioning, deprecation, response schema
화면마다 에러 처리가 다르다안정적인 에러 코드가 없다error response shape, field errors
파트너 batch가 429 이후 폭주한다backoff 계약이 없다Retry-After, 버전이 명시된 RateLimit 계약
Swagger에는 성공 응답만 있다실패 계약이 문서화되지 않았다OpenAPI error schema, examples
GET 호출만 했는데 데이터가 바뀐다safe 메서드 계약을 어겼다읽기와 쓰기 endpoint 분리

이 표를 디버깅 출발점으로 쓰면 구현 프레임워크보다 API 계약을 먼저 보게 된다. 예를 들어 400 Bad Request가 많다는 사실만으로는 validation 로직 문제가 아니다. 요청 필드 정의가 모호하거나, 에러 응답이 필드별 원인을 주지 않거나, 클라이언트가 모르는 breaking change가 들어갔을 수 있다.

이 문서의 중심은 REST지만, API 설계 원리는 다른 인터페이스에도 적용된다. 중요한 질문은 “어떤 기술이 더 최신인가”가 아니라 “이 계약을 누가 소비하고, 어떤 변경 비용을 감당하는가”다.

REST vs GraphQL vs gRPC 선택 기준

REST

HTTP 의미체계와 캐싱, 공개 문서화, 단순한 리소스 모델에 강하다.

웹/모바일 제품 API, 파트너 공개 API, CRUD 중심 도메인

GraphQL

클라이언트가 필요한 필드를 선택한다. 화면별 데이터 요구가 자주 달라질 때 유리하다.

다양한 화면, BFF, over-fetching 문제가 큰 제품

gRPC

Protobuf 스키마와 HTTP/2 기반으로 내부 서비스 간 고성능 호출에 강하다.

서버 간 통신, 낮은 latency, 명확한 schema ownership

한 시스템이 하나만 써야 하는 것은 아니다. 파트너 공개 API는 REST로 제공하고, 내부 결제 코어는 gRPC로 호출하며, 화면 조합이 복잡한 클라이언트 앞에는 GraphQL BFF를 둘 수 있다. 이때도 질문은 같다.

  1. 요청과 응답 schema는 어디에 정의되는가?
  2. 실패와 재시도는 어떻게 표현되는가?
  3. breaking change는 어떻게 감지하고 배포하는가?
  4. 소비자는 새 필드나 새 enum 값을 견딜 수 있는가?
  5. 관측 가능한 rate limit, quota, trace id가 있는가?

REST를 배우는 목적은 REST만 쓰기 위해서가 아니다. API를 “네트워크 너머 함수 호출”이 아니라 “변경 가능한 조직 사이의 계약”으로 보는 눈을 만들기 위해서다.

팀이나 공개 API 문서 하나를 골라 다음 질문으로 읽어본다. 긴 curl 실습보다 계약을 찾는 연습이 우선이다.

  • 리소스 이름이 명사 중심인가?
  • GET이 상태를 바꾸지 않는가?
  • POST 생성 API에 중복 요청 방지 방법이 있는가?
  • 부분 수정과 전체 교체가 PATCHPUT으로 분리되어 있는가?
  • 목록 응답에 nextCursor, hasNextPage, limit 같은 페이지 정보가 있는가?
  • cursor가 정렬 기준과 tie-breaker를 포함하는가?
  • 에러 응답에 안정적인 code와 field-level 오류가 있는가?
  • OpenAPI 문서에 성공 응답뿐 아니라 실패 응답도 있는가?
  • 버전 폐기 계획이 Deprecation, Sunset, changelog로 드러나는가?
  • 429 응답에 Retry-After가 있는가?

간단한 HTTP 확인이 필요하면 한두 요청만 보면 된다.

Terminal window
curl -i https://api.example.com/v1/users/user_123

관찰할 것은 응답 본문보다 계약 표면이다.

HTTP/1.1 200 OK
Content-Type: application/json
RateLimit-Policy: "default";q=1000;w=60
RateLimit: "default";r=87;t=60
Trace-Id: req_01HR...

상태 코드, 헤더, 에러 형식, 페이지 정보가 문서와 일치하는지 확인한다. 실제 서버 구현을 추적하기 전에 계약이 먼저 맞는지 보는 습관이 중요하다.

API 설계 복습 체크

  • API 설계를 엔드포인트 목록이 아니라 클라이언트-서버 계약으로 설명할 수 있다.
  • REST에서 리소스, 표현, HTTP 메서드 의미가 어떻게 연결되는지 설명할 수 있다.
  • safe와 idempotent의 차이를 재시도 관점에서 설명할 수 있다.
  • POST 생성 API에 Idempotency-Key가 필요한 상황을 예로 들 수 있다.
  • PUT과 PATCH의 차이를 데이터 손실 counterexample으로 설명할 수 있다.
  • Offset과 Cursor 페이지네이션의 선택 기준과 cursor tie-breaker 필요성을 설명할 수 있다.
  • Breaking change와 non-breaking change를 클라이언트 호환성 기준으로 구분할 수 있다.
  • 에러 응답, rate limit, OpenAPI가 왜 API 계약의 일부인지 설명할 수 있다.
  • OpenAPI / Swagger
  • RFC 9457 Problem Details
  • ETag / If-Match
  • HATEOAS
  • Cursor Pagination / Keyset Pagination
  • API Gateway
  • API Key vs OAuth
  • GraphQL
  • gRPC
  • Consumer-driven contract test

19. 요약 — 이것만 기억해도 된다

섹션 제목: “19. 요약 — 이것만 기억해도 된다”

API 설계는 서버 함수를 외부에 노출하는 일이 아니다. 독립적으로 변경되는 클라이언트와 서버가 같은 의미를 공유하게 만드는 계약 설계다.

핵심 판단 기준은 다음이다.

상황우선 확인할 계약
URL을 설계한다동사가 아니라 리소스 명사인가
메서드를 고른다safe, idempotent 의미가 맞는가
생성 API를 만든다중복 재시도 방지 키가 필요한가
수정 API를 만든다전체 교체인지 부분 수정인지 분리했는가
목록 API를 만든다offset으로 충분한가, 안정 cursor가 필요한가
응답을 바꾼다기존 클라이언트에 breaking change인가
에러를 반환한다프로그램이 읽을 안정 code와 field 오류가 있는가
한도를 둔다429, Retry-After, RateLimit, RateLimit-Policy가 계약에 있는가

5줄로 줄이면 다음과 같다.

  1. API는 엔드포인트 목록이 아니라 클라이언트-서버 계약이다.
  2. REST는 HTTP 메서드와 리소스 URL을 계약 언어로 재사용한다.
  3. 멱등성, PUT/PATCH, cursor, versioning은 모두 독립 배포와 재시도를 안전하게 만들기 위한 장치다.
  4. 좋은 API는 성공뿐 아니라 실패, 한도, 폐기, 마이그레이션까지 예측 가능하게 만든다.
  5. 구현 코드를 보기 전에 계약 표면이 안정적인지 먼저 읽는 습관이 API 설계의 출발점이다.