API 설계는 "잘 돌아가는 코드"와 "시스템이 되는 코드"를 가르는 경계선이다. 시니어 백엔드는 엔드포인트를 만드는 사람이 아니라, 5년 뒤에도 deprecate 비용이 작게 드는 계약(contract)을 남기는 사람이다. 특히 커머스 백엔드처럼 주문, 결제, 쿠폰, 알림이 한 트랜잭션 스토리에 묶이는 도메인은 API 한 줄의 의미를 잘못 정하면, 몇 달 뒤 중복 결제 이슈, 외부 파트너 연동 롤백, 앱 강제 업데이트 같은 운영 사고로 돌아온다.
면접에서 "이 API를 설계해 주세요"라는 문제가 나오는 이유도 같다. 면접관은 엔드포인트 리스트를 듣고 싶은 게 아니라, 자원 모델링 → 메서드 의미 → 에러/멱등/페이지네이션 → 버전/배포/문서화로 이어지는 일관된 설계 판단을 듣고 싶어 한다. 이 문서는 그 판단 흐름을 재현할 수 있도록 실제 커머스 백엔드 관점에서 정리한다.
REST에서 URI는 동사를 담지 않는다. POST /createOrder는 REST가 아니라 "HTTP로 감싼 RPC"다. 자원(resource)은 명사, 동작은 메서드로 표현한다.
나쁜 예:
POST /api/createOrder
POST /api/cancelOrder?orderId=123
GET /api/getOrderList?userId=7
개선:
POST /v1/orders
POST /v1/orders/{orderId}/cancellations
GET /v1/users/{userId}/orders
포인트:
cancelOrder가 아니라 "주문 취소"라는 하위 자원(cancellations)으로 모델링했다. 취소는 상태 전이 그 자체가 기록 대상이기 때문이다./orders)과 아이템(/orders/{id})을 명확히 구분한다.| 메서드 | Safe | Idempotent | 쓰는 곳 |
|---|---|---|---|
| GET | O | O | 조회 |
| HEAD | O | O | 존재/메타 확인 |
| PUT | X | O | 전체 교체, 식별자 클라이언트가 알 때 |
| DELETE | X | O | 삭제 |
| PATCH | X | 조건부 | 부분 수정 |
| POST | X | X (기본) | 생성, 트리거, 비정형 동작 |
여기서 자주 헷갈리는 두 지점:
{"stock": {"op": "increment", "value": 1}} 같은 델타 PATCH는 호출 횟수에 따라 결과가 달라진다. 멱등 PATCH를 원하면 JSON Merge Patch처럼 "결과 상태"를 보내야 한다.시니어 코드 리뷰에서 지적이 가장 많은 부분이 상태 코드다. 최소 다음 셋은 몸에 붙여 둬야 한다.
Location 헤더 필수Retry-After 헤더 동반"재고 부족"을 400으로 주는 서비스가 아직도 많다. 400은 요청이 말이 안 되는 경우에 쓰고, 비즈니스 룰 위반은 422가 더 정확하다. 클라이언트 재시도 전략이 달라지기 때문이다.
POST는 본래 멱등하지 않다. 그런데 결제, 주문, 쿠폰 발급처럼 "두 번 실행되면 돈이 날아가는" 동작은 반드시 멱등이어야 한다. 네트워크 재시도, 타임아웃 재요청, 모바일 앱의 중복 탭은 현실에서 끊임없이 발생한다.
Idempotency-Key 헤더클라이언트가 요청마다 UUID를 만들어 헤더에 실어 보낸다.
POST /v1/payments
Idempotency-Key: 2f3d6b1e-0c2a-4a34-9f6f-7c4d9e01c5ad
Content-Type: application/json
{"orderId":"O-10293","amount":28900,"currency":"KRW","method":"card_token_xxx"}
서버는 (route, key) 조합으로 첫 요청의 응답 전체(HTTP status, headers, body, 그리고 부작용 커밋 여부)를 저장하고, 같은 키로 들어온 재요청에는 저장된 응답을 그대로 재생한다. 첫 요청이 아직 처리 중이면 409 또는 동일 키에 락을 걸고 대기한다.
테이블 예시(MySQL 8 기준):
CREATE TABLE idempotency_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
idem_key VARCHAR(80) NOT NULL,
route VARCHAR(120) NOT NULL,
request_hash CHAR(64) NOT NULL,
response_status SMALLINT NULL,
response_body JSON NULL,
state ENUM('IN_PROGRESS','DONE') NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
UNIQUE KEY uq_idem (route, idem_key)
) ENGINE=InnoDB;
중요한 디테일 세 가지:
request_hash를 같이 저장해 "같은 키인데 다른 body"가 오면 422로 거절한다. 그렇지 않으면 클라이언트 버그를 서버가 덮어 쓰게 된다.merchantUid를 키와 1:1로 연결해 두 번 승인되지 않게 한다.내부 이벤트 발행(쿠폰 사용 → 알림 발송)을 멱등으로 만들려면, DB 트랜잭션과 메시지 발행을 한 커밋으로 묶어야 한다. Outbox 테이블에 이벤트 레코드를 같이 쓰고, 별도 디스패처가 읽어 발행하면 "DB는 커밋됐는데 메시지는 안 나감" 또는 그 반대가 사라진다. 멱등 키는 이 이벤트의 event_id로 끝까지 따라간다.
?page=10&size=20 → LIMIT 20 OFFSET 200
next_cursor 토큰을 준다.
WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20실무에서 cursor와 keyset은 거의 같은 말이다. 공개 API는 cursor로 감싸고, 내부 구현은 keyset으로 한다.
나쁜 쿼리:
SELECT id, order_no, total_price
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;
개선 (keyset):
SELECT id, order_no, total_price, created_at
FROM orders
WHERE user_id = 42
AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;
필요한 인덱스: INDEX idx_orders_user_created (user_id, created_at DESC, id DESC).
응답 스키마:
{
"items": [ ... ],
"page_info": {
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNC0xOFQwNDozMDoxMloiLCJpZCI6MTIzNDV9",
"has_next": true
}
}
커서는 Base64(JSON)로 감싸 포맷을 언제든 바꿀 수 있게 해 둔다. 다만 커서 내부에 민감 정보를 담지 않는다(HMAC 서명을 붙이는 것도 방법).
/v1/orders, /v2/orders. 캐싱/라우팅이 단순하고, CDN 로그만 봐도 트래픽 분포가 보인다. 실무 기본값.X-API-Version: 2026-01-15. Stripe 방식. 날짜 기반 버전을 계정 단위로 고정해 점진 이관.Accept: application/vnd.company.order.v2+json. 순수주의엔 맞지만 클라이언트 구현 비용이 높고 CDN에 불친절하다.선택 기준:
버전을 올리는 것보다 내리는 것이 진짜 설계다.
Deprecation: true 및 Sunset: Wed, 01 Oct 2026 00:00:00 GMT 응답 헤더 부착.에러 응답이 자유 형식이면 클라이언트마다 파싱 로직이 다르고, 결국 "status 200에 error 필드" 같은 반(反) 패턴이 생긴다. 표준화가 필요하다. RFC 7807(Problem Details)이 출발점이지만 커머스에는 살짝 확장해 쓴다.
{
"type": "https://errors.example.com/orders/stock-insufficient",
"title": "Stock insufficient",
"status": 422,
"code": "ORDER_STOCK_INSUFFICIENT",
"detail": "Requested 3 but only 1 available.",
"instance": "/v1/orders",
"trace_id": "b7c9...e3",
"errors": [
{"field": "items[0].quantity", "code": "QUANTITY_EXCEEDS_STOCK", "max": 1}
]
}
원칙:
code**는 도메인 계층. 둘을 분리한다. 같은 422라도 ORDER_STOCK_INSUFFICIENT와 COUPON_EXPIRED는 다르다.trace_id는 분산 트레이스 ID와 동일하게 두어 고객 문의 1회로 원인을 찾을 수 있게 한다.title, detail)과 기계용(code)을 섞지 않는다.한 번 공개한 API는 "살아 있는 DB 스키마"로 취급해야 한다.
Backward compatible 변경(허용):
Breaking 변경(버전 업 필요):
실전 규칙:
price를 그대로 두고 price_with_tax를 추가하는 식.| 축 | REST+JSON | gRPC | GraphQL |
|---|---|---|---|
| 주 사용처 | 공개 API, 웹, 모바일 | 내부 서비스 간, 저지연 | 프론트 주도 조합형 조회 |
| 스키마 | OpenAPI(옵션) | Proto(강제) | SDL(강제) |
| 성능 | JSON 파싱 비용 | HTTP/2 + Protobuf | 쿼리에 따라 들쭉날쭉 |
| 캐싱 | HTTP 캐시 친화 | 제한적 | GET+persisted query 필요 |
| 러닝 커브 | 낮음 | 보통 | 높음(N+1, 권한 경계) |
선택 기준을 한 문장으로 쓰면:
BFF는 프론트별 전용 백엔드를 둬서, 공용 내부 API를 그 프론트의 화면 형태로 조합·가공한다.
쓸 만할 때:
피해야 할 때:
Authorization: Bearer ...로 고정한다. 자체 헤더는 피한다.orders:read, payments:write)와 리소스 소유권 검사를 분리한다. 스코프만 검사하면 "내 권한으로 남의 주문 조회"가 뚫린다.RateLimit-Limit: 1000RateLimit-Remaining: 37RateLimit-Reset: 42Retry-After.나쁜 예:
POST /api/order/new
Body: {"user":7,"products":[{"pid":1,"qty":2}],"pay":"card","coupon":"X"}
Response 200 OK
{"success": true, "orderId": 10293, "errorMsg": null}
문제: 동사 URI, success 플래그 반패턴, 쿠폰·결제·주문이 한 엔드포인트에 엉켜 있음, 201 대신 200.
개선:
POST /v1/orders
Idempotency-Key: <uuid>
Authorization: Bearer <token>
{
"items": [{"sku": "SKU-001", "quantity": 2}],
"shipping_address_id": "addr_123",
"coupon_code": "SPRING10",
"payment_method_id": "pm_456"
}
201 Created
Location: /v1/orders/O-10293
{
"order_id": "O-10293",
"status": "PENDING_PAYMENT",
"total_price": 28900,
"currency": "KRW",
"payment": {"status": "REQUIRES_ACTION", "next_action_url": "..."}
}
POST /v1/orders/{orderId}/payments
Idempotency-Key: <uuid>
GET /v1/payments/{paymentId}로 폴링/웹훅.code=PAYMENT_DECLINED. 카드사 원문은 detail에만, code는 우리 쪽 도메인 코드로.쿠폰 사용은 "쿠폰 자원의 상태 전이"다.
POST /v1/users/me/coupons/{couponId}/redemptions
code=COUPON_ALREADY_USED.code=COUPON_EXPIRED.code=COUPON_MIN_AMOUNT_NOT_MET.GET /v1/users/me/notifications?limit=20&cursor=<opaque>
cursor=가 없으면 최신부터.POST /v1/users/me/notifications/{id}/reads (상태 전이를 하위 자원으로).httpie 또는 curlk6(부하 테스트)redoc-cli(OpenAPI 문서 렌더)CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no CHAR(12) NOT NULL UNIQUE,
user_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
total_price INT NOT NULL,
currency CHAR(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_user_created (user_id, created_at DESC, id DESC)
) ENGINE=InnoDB;
-- 1페이지
SELECT id, order_no, total_price, created_at
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 다음 페이지 (커서에서 꺼낸 값)
SELECT id, order_no, total_price, created_at
FROM orders
WHERE user_id = 42
AND (created_at, id) < ('2026-04-18 04:30:12.000', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;
KEY=$(uuidgen)
for i in 1 2 3; do
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:8080/v1/payments \
-H "Authorization: Bearer dev" \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"orderId":"O-10293","amount":28900,"currency":"KRW"}'
done
# 기대 출력: 201, 200(재생), 200(재생)
세 번째 요청에서 중복 결제가 일어나지 않는지, DB에 승인 기록이 1건인지 확인하는 것이 핵심이다.
paths:
/v1/orders:
post:
operationId: createOrder
parameters:
- in: header
name: Idempotency-Key
required: true
schema: { type: string, format: uuid }
requestBody: { ... }
responses:
"201": { $ref: "#/components/responses/Order" }
"409": { $ref: "#/components/responses/Problem" }
"422": { $ref: "#/components/responses/Problem" }
spectral lint openapi.yaml로 네이밍/필수 필드 규칙을 자동 검사한다. 문서-코드 불일치는 CI에서 막는다.
success:false를 실어 에러를 감춘다 → 모니터링이 전부 녹색으로 보인다.created_at_ts(epoch ms)를 병기하는 식으로 합의.시니어 백엔드 면접에서 이런 열린 문제를 받으면 다음 순서로 말하면서 설계를 내려간다. 면접관이 중간에 끊지 않으면 약 8~10분짜리 talk-through가 된다.
중요한 태도: 완벽한 설계보다 근거 있는 선택과 폐기 가능한 설계를 보여준다. 면접관이 "재고 부족은 400 아닌가요?"처럼 찌르면, "422로 놓은 이유는 ... 다만 클라이언트가 단순 재시도만 한다면 400으로도 타협 가능합니다"처럼 조건부로 답한다.
code가 분리돼 있다.Idempotency-Key 필수다.request_hash 검증이 있다.trace_id가 실려 있다.Retry-After가 있다.