컬렉션
여러 리소스의 집합이다. 목록 조회와 생성의 기준점이 된다.
GET /orders, POST /orders분류: 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가 계약이 되는 순간 서버와 클라이언트는 서로 독립적으로 움직일 수 있다. 서버는 내부 테이블을 바꿀 수 있고, 클라이언트는 배포 주기가 느린 모바일 앱이나 외부 파트너 시스템일 수 있다. 계약이 안정적이면 양쪽이 동시에 배포되지 않아도 서비스가 유지된다. 계약이 흔들리면 작은 필드 이름 변경도 장애가 된다.
초기 원격 호출 방식은 자주 “함수 호출을 네트워크로 옮긴 것”처럼 설계됐다.
예를 들어 모든 작업을 POST /service로 보내고, 실제 동작은 바디 안의 method=getUsers나 XML envelope에 숨기는 방식이다.
이 방식은 서버 입장에서는 편해 보이지만, 클라이언트와 중간 인프라 입장에서는 의미를 읽기 어렵다.
REST(Representational State Transfer)는 이 문제를 “HTTP가 이미 가진 의미체계를 계약으로 쓰자”는 방향으로 풀었다. URL은 리소스를 식별하고, HTTP 메서드는 그 리소스에 대한 의도를 표현한다. 응답은 리소스의 현재 표현(representation)을 돌려준다. REST를 외우는 목표는 특정 URL 모양을 흉내 내는 것이 아니라, HTTP 의미를 클라이언트-서버 계약으로 재사용하는 감각을 얻는 것이다.
이 토픽이 사라지면 PUT과 PATCH의 차이, Idempotency-Key, cursor 페이지네이션, 버전 폐기 신호 같은 결정이 모두 “취향”처럼 보인다.
실제로는 모두 독립적인 클라이언트와 서버가 같은 계약을 해석하게 만들기 위한 장치다.
좋은 API 설계는 “엔드포인트를 몇 개 만들 것인가”보다 “무엇을 바꾸지 않겠다고 약속할 것인가”를 먼저 정한다. 아래는 하나의 주문 생성 API가 고정해야 하는 계약 표면이다.
POST /v1/orders HTTP/1.1Authorization: Bearer <token>Idempotency-Key: order-20260401-0001Content-Type: application/json
{ "productId": "prod_123", "quantity": 2, "shippingAddressId": "addr_456"}HTTP/1.1 201 CreatedLocation: /v1/orders/order_789Content-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=123POST /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}/cancellationAPI 리소스는 클라이언트가 이해하는 계약 단위이고, DB 테이블은 서버가 데이터를 저장하는 구현 단위다.
하나의 Order 응답은 주문 테이블, 주문 상품 테이블, 배송지, 결제 상태를 합쳐 만들 수 있다.
반대로 하나의 테이블이 여러 API 리소스로 나뉠 수도 있다.
이 경계를 지키면 내부 구조 변경이 쉬워진다.
orders 테이블을 샤딩하거나 order_items를 별도 서비스로 옮겨도 GET /orders/{id}의 응답 계약이 유지되면 클라이언트는 바뀔 필요가 없다.
API 설계가 구현 문서가 아니라 계약 문서여야 하는 이유가 여기에 있다.
HTTP 메서드는 CRUD 매핑표를 외우기 위한 암기 대상이 아니다. 메서드마다 안전성, 멱등성, 캐시 가능성, 재시도 가능성에 대한 약속이 붙어 있다.
| 메서드 | 주된 의미 | Safe | Idempotent | 설계 포인트 |
|---|---|---|---|---|
GET | 리소스 조회 | 예 | 예 | 상태를 바꾸지 않아야 캐시와 재시도가 안전하다 |
POST | 컬렉션 생성, 명령 실행 | 아니오 | 기본은 아니오 | 중복 생성 위험이 있으면 idempotency key가 필요하다 |
PUT | 단일 리소스 전체 교체 | 아니오 | 예 | 요청 바디가 전체 새 상태여야 한다 |
PATCH | 단일 리소스 부분 수정 | 아니오 | 설계에 따라 다름 | set email=...은 멱등, increment count는 비멱등이다 |
DELETE | 단일 리소스 삭제 | 아니오 | 보통 예 | 두 번째 삭제의 응답 코드는 정책으로 정하되 최종 상태는 같다 |
안전성과 멱등성은 클라이언트 재시도 정책에 직접 연결된다.
네트워크가 끊겨 응답을 못 받은 GET은 보통 다시 보내도 된다.
같은 상황의 POST /orders는 재시도하면 주문이 두 개 생길 수 있다.
이 차이를 계약으로 드러내지 않으면 클라이언트는 장애 때마다 추측해야 한다.
퀴즈
힌트: 메서드 의미는 서버 코드만이 아니라 중간 인프라와 클라이언트 재시도 정책의 입력이다.
GET은 safe 메서드라는 계약을 갖기 때문에 브라우저, 프록시, 크롤러, 재시도 로직이 상태 변경 위험을 고려하지 않는다. GET으로 상태가 바뀌면 캐시, 선조회, 자동 재시도만으로도 의도하지 않은 변경이 발생할 수 있다.
API 계약은 URL만 안정적이면 충분하지 않다. 클라이언트가 실제로 의존하는 것은 요청 필드, 응답 필드, 상태 코드, 헤더, 에러 코드까지 포함한 전체 모양이다.
{ "email": "hong@example.com", "displayName": "홍길동", "marketingOptIn": false}응답 필드는 충분하지만 과하지 않아야 한다. 목록 화면에 id, name, email만 필요한데 매번 permissions, auditLogs, profileBlob까지 내려보내면 응답 크기와 직렬화 비용이 계약의 일부가 된다. 필드 선택, 요약 DTO, 상세 조회 분리는 성능 최적화 이전에 “클라이언트가 실제로 의존하는 표면을 어디까지로 볼 것인가”의 문제다.
요청 필드는 다음 질문에 답해야 한다.
null, 누락은 서로 같은가 다른가?이 질문을 정하지 않으면 validation 에러가 구현 디테일이 된다.
예를 들어 marketingOptIn이 선택 필드인지 필수 필드인지 명확하지 않으면, 새 클라이언트와 구 클라이언트가 같은 요청을 다르게 해석한다.
{ "data": { "id": "user_123", "email": "hong@example.com", "displayName": "홍길동", "createdAt": "2026-04-01T09:00:00Z" }}성공 응답은 클라이언트가 다음 화면이나 다음 요청을 만들 수 있을 만큼 충분해야 한다.
생성 응답이면 201 Created와 Location 헤더가 유용하다.
목록 응답이면 data만이 아니라 페이지 정보가 필요하다.
비동기 작업이면 202 Accepted와 작업 상태를 조회할 URL이 필요하다.
HTTP/1.1 202 AcceptedLocation: /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 Request와 422 Unprocessable Entity는 팀 정책을 정해 일관되게 써야 한다.
일반적으로 JSON 파싱이 불가능하거나 요청 구조 자체가 깨졌으면 400, 구조는 맞지만 도메인 validation이 실패했으면 422로 나눈다.
중요한 것은 어떤 선택을 하든 모든 엔드포인트에서 같은 기준을 적용하는 것이다.
PUT과 PATCH는 둘 다 수정처럼 보이지만 계약이 다르다.
PUT은 리소스 전체를 새 표현으로 교체한다.
PATCH는 리소스 일부를 바꾼다.
기존 사용자가 다음 상태라고 하자.
{ "id": "user_1", "name": "홍길동", "email": "hong@example.com", "role": "user"}클라이언트가 이름만 바꾸고 싶어서 다음 요청을 보냈다.
PUT /v1/users/user_1Content-Type: application/json
{ "name": "홍길동(수정)"}PUT 계약을 엄격히 따르면 이 요청은 “사용자 전체 표현을 이 바디로 교체”한다는 뜻이다.
서버가 누락 필드를 null이나 기본값으로 채우면 결과는 이렇게 망가질 수 있다.
{ "id": "user_1", "name": "홍길동(수정)", "email": null, "role": null}이 상황에서 맞는 계약은 보통 PATCH다.
PATCH /v1/users/user_1Content-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인지”를 문서에 드러내야 한다.
퀴즈
힌트: 메서드 선택은 라우팅 문제가 아니라 데이터 보존 계약이다.
PUT은 전체 교체 의미라 요청 바디에 없는 필드를 null이나 기본값으로 덮을 수 있다. 일부 필드 수정이면 PATCH가 맞고, PUT을 허용하려면 모든 필드를 요구해야 한다.
PUT/PATCH 문제가 운영에서 보이는 방식은 구현 프레임워크보다 비슷하다.
| 실패 신호 | 설계상 원인 | 계약 보강 |
|---|---|---|
| 이름 수정 뒤 이메일, 권한, 설정값이 비어 있다 | 부분 수정에 PUT을 사용했다 | PATCH로 분리하거나 PUT 요청에 전체 표현을 필수화한다 |
| 같은 PATCH 재시도 후 숫자가 계속 증가한다 | PATCH가 비멱등 연산인데 재시도 정책을 명시하지 않았다 | 연산형 PATCH는 별도 명령 리소스와 idempotency key를 고려한다 |
| 동시 수정에서 마지막 요청이 이전 변경을 덮는다 | 수정 전 상태 확인 계약이 없다 | ETag와 If-Match 같은 조건부 요청을 사용한다 |
긴 복구 절차보다 중요한 학습 포인트는 이것이다.
API 메서드 선택이 틀리면 서버는 정상적으로 200 OK를 반환해도 데이터 의미는 조용히 깨진다.
이런 실패는 “서버 에러”가 아니라 “계약이 잘못 설계된 것”으로 봐야 한다.
POST는 기본적으로 멱등하지 않다.
POST /orders를 두 번 보내면 주문이 두 개 생기는 것이 자연스러운 해석이다.
하지만 네트워크는 신뢰할 수 없고, 클라이언트는 타임아웃을 받으면 같은 요청을 다시 보낼 수밖에 없다.
이때 필요한 계약이 Idempotency-Key다.
클라이언트는 “이 요청은 이전 요청과 같은 의도”라는 키를 함께 보낸다.
서버는 같은 키가 같은 payload와 다시 오면 새 작업을 만들지 않고 이전 결과를 돌려준다.
POST /v1/orders HTTP/1.1Idempotency-Key: order-client-123Content-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이 반복될 수 있다.
클라이언트는 “계속 서버가 아프다”고 생각하지만 실제로는 첫 실패 응답이 계약상 재사용되는 상태다.
이 실패는 다음 신호로 나타난다.
복구 절차를 길게 외우는 것보다 설계 교훈이 중요하다. 멱등성 계약은 “중복 생성 방지”뿐 아니라 “실패 결과를 어떻게 재사용할 것인가”까지 포함해야 한다. 클라이언트 문서에는 retryable 오류와 non-retryable 오류, 같은 키 재사용 조건, 새 키 발급 조건이 분리되어 있어야 한다.
데이터가 많을 때 목록 API는 한 번에 모든 항목을 보내지 않는다. 페이지네이션은 응답 크기를 줄이는 장치이면서, 클라이언트가 “다음 목록을 어떻게 이어서 받을지”를 정하는 계약이다.
GET /v1/orders?page=2&limit=20Offset 방식은 “앞에서 몇 개를 건너뛸 것인가”를 기준으로 한다. 구현이 쉽고 페이지 번호 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 DESCLIMIT 2;여기서 id를 함께 쓰는 이유가 중요하다.
created_at만 cursor로 쓰면 같은 timestamp를 가진 주문들의 순서가 흔들릴 수 있다.
동일 시각에 생성된 행이 여러 개 있을 때 쿼리 실행마다 순서가 바뀌면 중복이나 누락이 발생한다.
따라서 cursor는 보통 created_at + id처럼 안정적인 tie-breaker를 포함한다.
구현이 단순하고 특정 페이지 번호로 이동하기 좋다. 데이터 변동이 적은 목록에 적합하다.
관리자 목록, 검색 결과, OFFSET 100 안팎, 응답 50ms 이내새 데이터가 계속 들어와도 이어 읽기가 안정적이다. 깊은 페이지에서도 비용이 예측 가능하다.
피드, 알림, 주문 이력, OFFSET 1000 이상, 응답 100ms 초과같은 시스템 안에서 두 방식을 섞어도 된다. 알림 피드는 cursor를 쓰고, 어드민 사용자 목록은 offset을 유지할 수 있다. 페이지네이션은 전체 시스템 정책이 아니라 엔드포인트별 목록 성격과 UI 요구에 맞춰 정한다.
| 실패 신호 | 설계상 원인 | 계약 보강 |
|---|---|---|
| 첫 페이지와 둘째 페이지에 같은 항목이 나온다 | Offset 중간에 새 데이터가 삽입됐다 | Cursor 전환 또는 snapshot 기준 고정 |
| 일부 항목을 영원히 못 본다 | 정렬 기준이 안정적이지 않다 | 고유 tie-breaker를 cursor에 포함 |
| cursor를 다른 필터에 재사용하면 이상한 결과가 나온다 | cursor가 filter/sort와 묶여 있지 않다 | cursor에 필터, 정렬, 방향 정보를 포함하거나 재사용을 거절 |
| 큰 페이지 번호에서 응답이 급격히 느려진다 | 깊은 OFFSET 비용이 커졌다 | keyset/cursor 방식 검토 |
API는 한 번 공개되면 여러 클라이언트가 서로 다른 시간표로 사용한다. 웹은 바로 배포할 수 있지만 모바일 앱은 심사와 사용자 업데이트가 필요하고, 외부 파트너는 분기 단위로만 변경할 수 있다. 버전 관리는 이 시간표 차이를 흡수하는 장치다.
/v1/users 기존 클라이언트가 계속 사용/v2/users 새 응답 구조나 새 동작을 제공URL path 방식은 가장 눈에 잘 보이고 디버깅하기 쉽다. Header 방식은 URL을 깔끔하게 유지하지만 로그나 브라우저에서 덜 보인다. Query parameter 방식은 구현이 쉽지만 캐싱과 라우팅 정책이 흐려질 수 있다. 중요한 것은 전략 하나를 정하고 섞지 않는 것이다.
| 변경 | 판정 | 이유 |
|---|---|---|
| 응답 필드 추가 | 보통 non-breaking | 관대한 클라이언트는 모르는 필드를 무시할 수 있다 |
| 필드 제거 | breaking | 기존 클라이언트가 파싱하던 값이 사라진다 |
| 필드 이름 변경 | breaking | name을 읽던 클라이언트가 fullName을 모른다 |
| 필수 요청 필드 추가 | breaking | 구버전 클라이언트가 더 이상 요청을 만들 수 없다 |
| 상태 코드 의미 변경 | breaking 가능 | 클라이언트 분기와 재시도 정책이 달라진다 |
| enum 값 추가 | 상황에 따라 다름 | 클라이언트가 unknown 값을 견디도록 설계됐는지가 중요하다 |
| 새 엔드포인트 추가 | non-breaking | 기존 호출에는 영향이 없다 |
버전을 올릴지 말지는 “서버 코드가 많이 바뀌었는가”가 아니라 “기존 클라이언트가 같은 요청을 보냈을 때 같은 의미로 해석되는가”로 판단한다.
구버전 API를 없앨 때는 클라이언트가 미리 알 수 있어야 한다. HTTP 헤더를 사용하면 기계적으로 감지 가능한 폐기 신호를 줄 수 있다.
HTTP/1.1 200 OKDeprecation: @1782864000Sunset: Fri, 01 Jan 2027 00:00:00 GMTLink: </v2/users>; rel="successor-version"Deprecation은 RFC 9745의 Structured Field Date라서 @ 뒤에 Unix timestamp를
적고, 해당 리소스가 언제 deprecated 상태가 되는지를 알린다. Sunset은 RFC
8594의 HTTP-date 형식으로, 리소스가 언제 응답을 중단할 예정인지를 알린다.
따라서 deprecation 시점은 마이그레이션을 시작하라는 신호이고, 더 뒤의 sunset
시점은 사용 가능 기간의 끝을 나타낸다.
이 신호는 사람에게 공지하는 changelog와 함께 가야 한다. 하지만 changelog만 있고 응답 헤더가 없으면 실제로 아직 v1을 쓰는 클라이언트를 운영 중에 찾기 어렵다. 반대로 헤더만 있고 마이그레이션 가이드가 없으면 클라이언트 팀은 무엇을 바꿔야 하는지 알 수 없다.
좋은 버전 폐기 계약은 보통 다음 정보를 포함한다.
내부 API는 배포 주체가 같으면 며칠~몇 주 단위로 강하게 밀어붙일 수 있다. 반대로 외부 파트너나 모바일 앱처럼 클라이언트 업데이트를 통제하기 어려우면 몇 달 단위의 Sunset 기간과 사용량 계측이 필요하다. 숫자는 조직마다 다르지만, 호환 기간은 “서버가 언제 지우고 싶은가”가 아니라 “클라이언트가 안전하게 따라올 수 있는가”로 정한다.
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가 있으면 다음 일이 가능해진다.
코드에서 자동 생성한 문서는 현재 구현을 잘 보여줄 수 있지만, 항상 좋은 계약을 보장하지는 않는다. 계약 우선 설계는 먼저 클라이언트가 의존할 요청/응답 의미를 정하고, 구현과 문서가 그 계약을 따라가게 만드는 접근이다.
OpenAPI를 “문서 자동화”로만 보면 부족하다. 컨트롤러 코드에서 자동 생성하더라도 실제 계약을 검토해야 한다. DTO에 optional이 잘못 표시되거나, 에러 응답이 문서에 빠지거나, rate limit 헤더가 누락되면 클라이언트는 여전히 중요한 계약을 모른다.
OpenAPI에서 특히 빠지기 쉬운 항목은 다음이다.
| 빠지기 쉬운 계약 | 빠졌을 때 문제 |
|---|---|
| 에러 응답 schema | 실패 처리를 화면마다 다르게 구현한다 |
| pagination response | 다음 페이지 판단을 응답에서 추측한다 |
| auth scheme | 어떤 토큰이 필요한지 SDK가 알 수 없다 |
| rate limit headers | 429 이후 대기 시간을 추측한다 |
| idempotency key header | 안전한 POST 재시도 방법을 모른다 |
| deprecation 정보 | 구버전 호출이 언제 깨질지 모른다 |
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 OKRateLimit-Policy: "default";q=1000;w=60RateLimit: "default";r=87;t=60q는 정책의 전체 quota, w는 정책 window(초), r은 현재 사용할 수
있는 quota다. t는 클라이언트가 r을 넘겨 사용할 수 없는 유효 구간을
초로 나타낸다.
한도를 넘으면 429 Too Many Requests와 Retry-After가 중요하다.
HTTP/1.1 429 Too Many RequestsRetry-After: 60Content-Type: application/json
{ "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "요청 한도를 초과했습니다. 60초 후 다시 시도하세요.", "status": 429 }}Retry-After가 없으면 클라이언트는 언제 재시도해야 하는지 추측한다.
추측 기반 재시도는 트래픽 폭증을 만들 수 있다.
반대로 서버가 Retry-After를 주면 클라이언트 SDK, 배치 작업, 외부 파트너가 같은 방식으로 backoff할 수 있다.
Rate limit 설계에서 자주 헷갈리는 지점은 다음이다.
| 선택지 | 맞는 상황 | 주의점 |
|---|---|---|
| IP 기준 | 로그인, 공개 익명 API | NAT 환경에서 여러 사용자가 같은 IP를 공유할 수 있다 |
| 사용자 기준 | 로그인된 제품 API | 토큰 탈취나 다계정 abuse를 별도로 봐야 한다 |
| API key 기준 | 파트너, 서버 간 연동 | key rotation과 quota plan이 필요하다 |
| 고정 window | 구현 단순 | window 경계에서 burst가 생길 수 있다 |
| token bucket | burst를 일부 허용 | 현재 token 수와 refill rate를 설명해야 한다 |
Rate limit은 보안 장치이기도 하지만 소비자 계약이기도 하다. API 문서에 한도와 초과 응답이 없으면 클라이언트는 정상적인 사용량 계획을 세울 수 없다.
긴 트러블슈팅 명령어보다 먼저 봐야 할 것은 “어떤 설계 선택이 깨졌는가”다. 아래 표는 API 설계 실패가 운영에서 어떤 신호로 보이는지 정리한다.
| 관찰되는 현상 | 실패한 설계 선택 | 먼저 의심할 계약 |
|---|---|---|
| 결제나 주문이 중복 생성된다 | 비멱등 POST에 재시도 정책이 없다 | Idempotency-Key, payload 충돌 처리 |
| 수정 성공 후 일부 필드가 사라진다 | 부분 수정에 PUT을 썼다 | PUT 전체 교체, PATCH 부분 수정 |
| 목록에서 중복/누락이 생긴다 | unstable pagination | cursor 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 설계 원리는 다른 인터페이스에도 적용된다. 중요한 질문은 “어떤 기술이 더 최신인가”가 아니라 “이 계약을 누가 소비하고, 어떤 변경 비용을 감당하는가”다.
HTTP 의미체계와 캐싱, 공개 문서화, 단순한 리소스 모델에 강하다.
웹/모바일 제품 API, 파트너 공개 API, CRUD 중심 도메인클라이언트가 필요한 필드를 선택한다. 화면별 데이터 요구가 자주 달라질 때 유리하다.
다양한 화면, BFF, over-fetching 문제가 큰 제품Protobuf 스키마와 HTTP/2 기반으로 내부 서비스 간 고성능 호출에 강하다.
서버 간 통신, 낮은 latency, 명확한 schema ownership한 시스템이 하나만 써야 하는 것은 아니다. 파트너 공개 API는 REST로 제공하고, 내부 결제 코어는 gRPC로 호출하며, 화면 조합이 복잡한 클라이언트 앞에는 GraphQL BFF를 둘 수 있다. 이때도 질문은 같다.
REST를 배우는 목적은 REST만 쓰기 위해서가 아니다. API를 “네트워크 너머 함수 호출”이 아니라 “변경 가능한 조직 사이의 계약”으로 보는 눈을 만들기 위해서다.
팀이나 공개 API 문서 하나를 골라 다음 질문으로 읽어본다. 긴 curl 실습보다 계약을 찾는 연습이 우선이다.
GET이 상태를 바꾸지 않는가?POST 생성 API에 중복 요청 방지 방법이 있는가?PATCH와 PUT으로 분리되어 있는가?nextCursor, hasNextPage, limit 같은 페이지 정보가 있는가?code와 field-level 오류가 있는가?Deprecation, Sunset, changelog로 드러나는가?429 응답에 Retry-After가 있는가?간단한 HTTP 확인이 필요하면 한두 요청만 보면 된다.
curl -i https://api.example.com/v1/users/user_123관찰할 것은 응답 본문보다 계약 표면이다.
HTTP/1.1 200 OKContent-Type: application/jsonRateLimit-Policy: "default";q=1000;w=60RateLimit: "default";r=87;t=60Trace-Id: req_01HR...상태 코드, 헤더, 에러 형식, 페이지 정보가 문서와 일치하는지 확인한다. 실제 서버 구현을 추적하기 전에 계약이 먼저 맞는지 보는 습관이 중요하다.
RateLimit과 RateLimit-Policy 예제가 고정한 2026년 5월 Internet-Draft다. 아직 RFC가 아니므로 도입 시 최신 revision과 wire format을 다시 확인한다.API 설계는 서버 함수를 외부에 노출하는 일이 아니다. 독립적으로 변경되는 클라이언트와 서버가 같은 의미를 공유하게 만드는 계약 설계다.
핵심 판단 기준은 다음이다.
| 상황 | 우선 확인할 계약 |
|---|---|
| URL을 설계한다 | 동사가 아니라 리소스 명사인가 |
| 메서드를 고른다 | safe, idempotent 의미가 맞는가 |
| 생성 API를 만든다 | 중복 재시도 방지 키가 필요한가 |
| 수정 API를 만든다 | 전체 교체인지 부분 수정인지 분리했는가 |
| 목록 API를 만든다 | offset으로 충분한가, 안정 cursor가 필요한가 |
| 응답을 바꾼다 | 기존 클라이언트에 breaking change인가 |
| 에러를 반환한다 | 프로그램이 읽을 안정 code와 field 오류가 있는가 |
| 한도를 둔다 | 429, Retry-After, RateLimit, RateLimit-Policy가 계약에 있는가 |
5줄로 줄이면 다음과 같다.