본문으로 건너뛰기

REST API 설계 실전 가이드: 리소스 모델링부터 버저닝, RFC 9457 에러 응답, 멱등성 설계까지

Backend15분 읽기본문 7,651

핵심 요약

이 글에서 확인할 내용

API는 계약이다, 그런데 우리는 왜 매번 같은 실수를 반복할까 "/v1/users"를 만들고, 에러가 나면 { error: "메시지" } 를 대충 반환하고, 나중에 필드 하나를 지웠다가 모바일 클라이언트가 크래시 나는 경험. 이건 특정 팀만의 문제가 아닙니다.

  • 1. 리소스 모델링과 URL 설계 원칙
  • 2. HTTP 메서드 시맨틱과 멱등성: RFC 9110이 실제로 요구하는 것
  • 3. 에러 응답 표준화: RFC 9457 Problem Details

REST API resource modeling, HTTP method idempotency, RFC 9457 error responses, and versioning strategy diagram

API는 계약이다, 그런데 우리는 왜 매번 같은 실수를 반복할까

"/v1/users"를 만들고, 에러가 나면 { error: "메시지" }를 대충 반환하고, 나중에 필드 하나를 지웠다가 모바일 클라이언트가 크래시 나는 경험. 이건 특정 팀만의 문제가 아닙니다. REST API 설계는 몇 가지 원칙만 알면 끝나는 주제처럼 보이지만, 실제로는 리소스를 어떻게 URL로 쪼갤지, PATCH 요청이 두 번 들어왔을 때 서버 상태가 어떻게 되어야 할지, 에러 응답 포맷을 팀마다 다르게 만들지 않으려면 어떤 표준을 따라야 할지, 그리고 v1에서 v2로 넘어갈 때 기존 클라이언트를 얼마나 오래 지원해야 할지처럼 계속 판단이 필요한 문제들의 집합입니다.

이 글은 URL 하나 잘못 설계해서 6개월 뒤에 마이그레이션 프로젝트를 새로 여는 일을 줄이는 데 목적을 둡니다. 리소스 모델링과 URL 설계 원칙, RFC 9110이 정의하는 HTTP 메서드 시맨틱과 멱등성, RFC 9457 Problem Details로 에러 응답을 표준화하는 방법, URL·헤더·미디어타입 버저닝 전략의 실제 트레이드오프, 하위 호환성을 지키며 API를 진화시키는 방법, 그리고 REST가 아니라 GraphQL이나 gRPC를 선택해야 하는 기준까지 실무에서 바로 적용할 수 있는 수준으로 다룹니다.

1. 리소스 모델링과 URL 설계 원칙

REST API 설계의 첫 단추는 "무엇이 리소스인가"를 정하는 일입니다. 리소스는 명사여야 합니다. /getUser, /createOrder 같은 동사형 엔드포인트는 HTTP 메서드가 이미 행위를 표현하고 있다는 사실과 충돌합니다. GET /users/123POST /orders처럼 URL은 대상을, 메서드는 행위를 맡아야 계약이 예측 가능해집니다.

컬렉션은 복수형 명사(/users), 단일 리소스는 식별자로 표현합니다(/users/123). 중첩 리소스는 소유 관계가 명확할 때만 씁니다. /users/123/orders는 "이 사용자가 가진 주문 목록"이라는 의미가 분명하지만, 중첩을 3단계 이상 넘기면(/users/123/orders/456/items/789/reviews) URL 자체가 유지보수 부담이 됩니다. 실무에서는 2단계까지만 중첩하고, 그 이하는 최상위 리소스로 승격한 뒤 쿼리 파라미터로 관계를 표현하는 편이 안전합니다(/reviews?itemId=789).

문제는 항상 "이건 리소스가 아니라 행위인데 어떻게 표현하지?"에서 생깁니다. 주문 취소, 비밀번호 재설정, 결제 승인 같은 동작은 CRUD로 딱 떨어지지 않습니다. 필자가 예약 플랫폼의 취소·환불 도메인을 새로 설계할 때 실제로 부딪힌 문제이기도 합니다. 처음에는 POST /reservations/123/cancel처럼 동사를 URL에 남기는 방식으로 갔다가, 팀 내 리뷰에서 "그러면 환불도 /refund, 재확인도 /reconfirm으로 계속 늘어난다"는 지적을 받았습니다. 결국 택한 방식은 취소라는 행위 자체를 하위 리소스로 승격하는 것이었습니다.

// 나쁜 예: 동사가 URL에 남는다
router.post('/reservations/:id/cancel', cancelReservation);

// 나은 예: "취소"라는 행위 자체를 리소스로 모델링한다
// POST로 취소 리소스를 생성 = 취소 이력이 남고, 상태 조회도 자연스럽다
router.post('/reservations/:id/cancellations', createCancellation);
router.get('/reservations/:id/cancellations/:cancellationId', getCancellation);

이렇게 바꾸면 취소 사유, 처리 시각, 환불 금액 같은 부가 정보를 취소 리소스 자체의 필드로 자연스럽게 넣을 수 있고, 취소 이력 조회 API도 별도 설계 없이 따라옵니다. 다만 모든 동작을 리소스로 억지로 승격할 필요는 없습니다. Google API 설계 가이드처럼 :customMethod 표기(POST /reservations/123:cancel)를 쓰는 조직도 있습니다. 중요한 것은 "리소스로 모델링할 가치가 있는 개념(이력이 남고, 상태를 가지며, 다시 조회할 필요가 있는 것)"과 "단순 트리거성 동작"을 구분하는 기준을 팀 안에서 합의하는 것입니다.

2. HTTP 메서드 시맨틱과 멱등성: RFC 9110이 실제로 요구하는 것

HTTP 메서드의 의미는 관습이 아니라 표준입니다. 2022년 발행된 RFC 9110 HTTP Semantics는 기존 RFC 7230~7235를 통합하며 메서드별 안전성(safe)과 멱등성(idempotent)을 명확히 정의합니다. 안전한 메서드는 서버 상태를 바꾸지 않는 메서드(GET, HEAD, OPTIONS)이고, 멱등한 메서드는 "같은 요청을 여러 번 보내도 서버에 미치는 의도된 효과가 한 번 보낸 것과 동일한" 메서드입니다. 모든 안전한 메서드는 멱등하지만, 역은 성립하지 않습니다.

RFC 9110의 9.2.2절은 PUT과 DELETE를 멱등 메서드로 명시하고, POST와 PATCH는 멱등하지 않다고 분명히 선을 긋습니다. 여기서 실무자들이 자주 오해하는 지점이 있습니다. PATCH가 "부분 수정이니까 PUT보다 안전하겠지"라고 생각하기 쉬운데, 스펙은 정반대로 말합니다. { "op": "increment", "field": "quantity", "value": 1 } 같은 증분형 PATCH 요청은 두 번 보내면 결과가 완전히 달라집니다. 반면 PUT은 리소스 전체를 절대값으로 교체하기 때문에, 네트워크 재시도로 같은 요청이 중복 도착해도 최종 상태는 동일합니다.

이 차이는 재시도 로직을 설계할 때 결정적입니다. API 회복탄력성 패턴에서 다룬 것처럼 timeout이 발생한 요청을 재시도하려면 그 메서드가 정말 멱등한지 먼저 확인해야 합니다. 멱등하지 않은 POST·PATCH 요청에 side effect(결제, 포인트 적립, 재고 차감)가 있다면, 클라이언트 재시도만으로는 안전하지 않습니다. 이때 필요한 것이 Idempotency-Key 헤더입니다. Stripe가 수년간 프로덕션에서 검증해 온 방식이고, 2026년 현재 IETF HTTPAPI 워킹그룹의 draft-ietf-httpapi-idempotency-key-header 초안으로 표준화가 진행 중입니다(아직 정식 RFC로 승격되지는 않았습니다).

// server/idempotency.ts — Redis SETNX 기반 idempotency key 처리
import { Request, Response, NextFunction } from 'express';
import { redis } from './redisClient';

const IDEMPOTENCY_TTL_SECONDS = 60 * 60 * 24; // 24시간 보존

export async function idempotencyMiddleware(req: Request, res: Response, next: NextFunction) {
  const key = req.header('Idempotency-Key');
  if (!key || req.method === 'GET') return next();

  const cacheKey = `idem:${req.path}:${key}`;
  // SET NX: 이미 존재하면 실패 → 새 요청이 아니라 재시도라는 뜻
  const acquired = await redis.set(cacheKey, JSON.stringify({ status: 'processing' }), {
    NX: true,
    EX: IDEMPOTENCY_TTL_SECONDS,
  });

  if (!acquired) {
    const cached = await redis.get(cacheKey);
    const parsed = cached ? JSON.parse(cached) : null;
    if (parsed?.status === 'processing') {
      return res.status(409).json({
        type: 'https://api.example.com/problems/request-in-progress',
        title: '동일한 요청이 이미 처리 중입니다',
        status: 409,
      });
    }
    // 이전에 완료된 응답을 그대로 재반환 (재시도 안전성 보장)
    return res.status(parsed.statusCode).json(parsed.body);
  }

  res.locals.idempotencyCacheKey = cacheKey;
  next();
}

여기서 핵심은 "먼저 조회하고 없으면 저장"하는 방식이 아니라 SETNX처럼 원자적 연산으로 동시성 경쟁을 막는 것입니다. 두 요청이 거의 동시에 도착해도 락은 하나만 성공하고, 나머지는 처리 중이라는 409 응답을 받습니다. Idempotency key store는 Redis 같은 키-값 저장소라면 조회와 쓰기 모두 평균 O(1)로 처리되므로, 결제처럼 초당 요청량이 큰 API에도 추가 지연을 거의 주지 않습니다.

3. 에러 응답 표준화: RFC 9457 Problem Details

팀마다 에러 응답 포맷이 다른 것은 API 설계에서 가장 흔하고 가장 저평가된 문제입니다. 어떤 엔드포인트는 { error: "메시지" }를, 어떤 엔드포인트는 { code: 4001, msg: "..." }를 반환하면, 클라이언트는 엔드포인트마다 다른 에러 파싱 로직을 짜야 합니다. 이 문제를 해결하기 위해 IETF는 2016년 RFC 7807 "Problem Details for HTTP APIs"를 발행했고, 2023년에는 이를 개정한 RFC 9457이 RFC 7807을 공식적으로 대체(obsolete)했습니다.

RFC 9457은 application/problem+json 미디어 타입으로 다섯 개의 표준 필드를 정의합니다.

필드의미비고
type문제 유형을 식별하는 URI생략 시 기본값은 about:blank
title사람이 읽을 수 있는 요약같은 type이면 항상 같은 문구
statusHTTP 상태 코드와 동일한 값프록시가 상태 코드를 바꿔도 원래 값 확인 가능
detail이 특정 요청에 대한 상세 설명사용자마다 달라질 수 있음
instance문제가 발생한 구체적 리소스 URI로그 추적에 유용

여기에 더해 RFC 9457은 errors 같은 확장 멤버를 자유롭게 추가할 수 있도록 허용합니다. 필드 단위 유효성 검증 오류처럼 여러 개의 세부 오류를 한 응답에 담아야 할 때 유용합니다.

// server/errorHandler.ts — Express 에러 핸들러가 RFC 9457을 따르도록 구성
import { Request, Response, NextFunction } from 'express';

interface ProblemDetail {
  type: string;
  title: string;
  status: number;
  detail?: string;
  instance?: string;
  errors?: { field: string; message: string }[];
}

export function problemJsonErrorHandler(
  err: unknown,
  req: Request,
  res: Response,
  _next: NextFunction,
) {
  const problem: ProblemDetail = toProblemDetail(err, req);
  res
    .status(problem.status)
    .set('Content-Type', 'application/problem+json')
    .json(problem);
}

function toProblemDetail(err: unknown, req: Request): ProblemDetail {
  if (err instanceof ValidationError) {
    return {
      type: 'https://api.example.com/problems/validation-error',
      title: '요청 값이 유효하지 않습니다',
      status: 400,
      instance: req.originalUrl,
      errors: err.fieldErrors, // [{ field: 'email', message: '형식이 올바르지 않습니다' }]
    };
  }
  if (err instanceof NotFoundError) {
    return {
      type: 'https://api.example.com/problems/resource-not-found',
      title: '리소스를 찾을 수 없습니다',
      status: 404,
      detail: `id=${err.resourceId} 리소스가 존재하지 않습니다`,
      instance: req.originalUrl,
    };
  }
  return {
    type: 'about:blank',
    title: '서버 내부 오류가 발생했습니다',
    status: 500,
    instance: req.originalUrl,
  };
}

RFC 9457을 도입하면 클라이언트는 엔드포인트마다 다른 에러 파싱 로직을 짤 필요 없이 type 필드 하나로 오류 종류를 분기할 수 있습니다. type을 실제로 열람 가능한 문서 URL로 만들어 두면(정적 페이지 하나만 있어도 충분합니다) 사람이 URL을 클릭했을 때 원인과 해결 방법을 바로 확인할 수 있다는 것도 RFC 9457이 강조하는 지점입니다. MDN의 HTTP 상태 코드 문서와 함께 참고하면, 상태 코드는 "카테고리"를, Problem Detail은 "구체적 원인"을 담당하도록 역할을 나누는 것이 자연스럽습니다.

본문 중간 핵심 흐름을 시각화한 Backend 일러스트

4. API 버저닝 전략 비교: URL, 헤더, 미디어 타입

버저닝 전략은 셋 중 하나를 고르는 문제가 아니라, 무엇을 희생할지를 고르는 문제입니다.

전략예시장점단점
URL 경로 버저닝/v1/users가장 직관적, 브라우저 주소창에서 확인 가능, 캐싱·라우팅·문서화가 단순리소스 URI가 버전에 따라 달라져 "같은 리소스인데 URL이 다르다"는 REST 원칙 위반
커스텀 헤더 버저닝Api-Version: 2026-07-01URL은 안정적으로 유지, 날짜 기반이면 변경 이력 추적이 쉬움헤더를 빠뜨리면 기본 동작이 무엇인지 팀 내 합의 필요, 브라우저 주소창 테스트 불가
미디어 타입(Accept 헤더) 버저닝Accept: application/vnd.example.v2+jsonHTTP 표준 content negotiation 메커니즘을 그대로 활용, 리소스당 여러 표현을 공식적으로 허용툴링·문서화가 번거롭고, 프록시나 CDN 캐시 키 설정에서 실수하기 쉬움
쿼리 파라미터 버저닝?version=2구현이 가장 간단캐싱 키에서 누락되기 쉽고, REST 리소스 식별과 쿼리 파라미터의 의미가 뒤섞임

실무에서 가장 많이 쓰이는 것은 URL 버저닝이지만, 대규모 결제 API를 운영하는 Stripe는 날짜 기반 헤더 버저닝을 택했습니다. 계정별로 "가입 시점의 API 버전"을 기본값으로 고정하고, 클라이언트가 명시적으로 헤더를 바꾸기 전까지는 그 동작이 유지되는 방식입니다. 이 방식의 장점은 v1, v2처럼 큰 단위로 브레이킹 체인지를 몰아서 터뜨리지 않고, 작은 변경 단위(하루 단위)로 점진적으로 배포할 수 있다는 점입니다. 다만 이 접근은 서버 내부에 버전별 어댑터 계층을 잘 만들어야만 유지보수가 가능해지므로, 팀 규모가 작다면 오히려 URL 버저닝이 더 실용적일 수 있습니다.

어떤 전략을 쓰든 공통 원칙은 하나입니다. 버전은 "구현이 바뀌었다"가 아니라 "계약이 깨졌다"는 신호로만 올려야 합니다. 필드 추가처럼 하위 호환되는 변경에 새 버전을 만드는 것은 클라이언트에게 불필요한 마이그레이션 부담을 지우는 일입니다.

5. 하위 호환 변경 관리와 폐기(Deprecation) 절차

버전을 새로 만드는 것보다 어려운 일은 기존 버전을 안전하게 없애는 것입니다. 안전한 변경과 위험한 변경을 구분하는 기준은 명확합니다.

  • 안전한 변경: 응답에 새 필드 추가, 요청에 optional 필드 추가, 새 엔드포인트 추가, enum에 새 값 추가(클라이언트가 unknown 값을 무시하도록 설계된 경우에 한함)
  • 위험한 변경: 필드 제거, 필드 타입 변경, 필수 필드 추가, 기존 enum 값의 의미 변경, 응답 배열의 정렬 순서 변경

"안전한 변경"조차 클라이언트가 엄격한 스키마 검증을 하고 있다면 깨질 수 있습니다. 그래서 클라이언트는 알 수 없는 필드를 무시하는 tolerant reader로 만들어야 하고, 서버는 이 가정이 실제로 지켜지는지 계약 테스트로 검증해야 합니다. 이 부분은 API 계약 테스트 도입: Consumer Driven Contract에서 다룬 접근과 정확히 맞닿아 있습니다. OpenAPI 문서만으로는 실제 소비자가 어떤 필드에 의존하는지 알 수 없기 때문에, 계약 테스트가 버저닝 정책의 실효성을 검증하는 마지막 안전망 역할을 합니다.

엔드포인트를 폐기할 때는 RFC 8594가 정의하는 DeprecationSunset 헤더를 함께 반환하는 것이 표준적인 관행입니다.

HTTP/1.1 200 OK
Deprecation: @1735689600
Sunset: Sat, 31 Jan 2026 00:00:00 GMT
Link: <https://api.example.com/docs/migration/v1-to-v2>; rel="deprecation"

Deprecation 헤더는 이 엔드포인트가 폐기 예정임을, Sunset은 실제로 응답이 중단되는 시점을 알립니다. 여기에 Link 헤더로 마이그레이션 가이드 URL을 함께 내려주면 클라이언트 개발자가 문서를 따로 찾을 필요가 없습니다. 최소 6개월의 유예 기간을 두고, 폐기 예정 헤더가 붙은 요청의 호출량을 모니터링해 실제로 트래픽이 0에 가까워졌는지 확인한 뒤에 코드를 제거하는 것이 안전합니다.

6. GraphQL과 gRPC는 언제 REST보다 나은 선택인가

REST가 모든 상황에 최선은 아닙니다. 클라이언트가 화면마다 필요한 필드 조합이 크게 다르고 오버페칭·언더페칭이 반복된다면 GraphQL의 단일 엔드포인트와 클라이언트 주도 쿼리가 더 적합합니다. 반대로 내부 서비스 간 통신에서 낮은 지연시간과 강한 타입 계약, deadline 전파가 중요하다면 gRPC가 더 나은 선택입니다. 이 판단 기준은 gRPC vs REST: 내부 서비스 통신에 gRPC를 선택했을 때 실제로 달라지는 것들에서 실제 사례와 함께 자세히 다뤘습니다.

기준RESTGraphQLgRPC
주 사용처공개 API, 외부 파트너 연동다양한 클라이언트가 화면별로 다른 필드를 요구할 때내부 서비스 간 고빈도 통신
캐싱HTTP 캐싱(ETag, Cache-Control)을 그대로 활용엔드포인트가 단일해 HTTP 캐싱이 어렵고 별도 캐싱 레이어 필요응답 캐싱은 애플리케이션 레벨에서 직접 구현
페이로드 크기·속도JSON 텍스트, 사람이 읽기 쉬움JSON 텍스트, 필요한 필드만 요청해 페이로드 절감Protobuf 바이너리, 직렬화 비용과 페이로드 크기 모두 유리
진입 장벽낮음, 브라우저·curl로 바로 테스트스키마 설계와 리졸버 최적화(N+1 문제) 학습 필요코드 생성 도구 체인, HTTP/2 전제 조건 필요

REST의 한계도 분명히 짚어야 합니다. 목록 조회에서 페이지네이션 전략을 잘못 고르면 대량 데이터에서 성능이 급격히 나빠지는데, 이 주제는 이 글의 범위를 벗어나므로 API 페이지네이션 설계: Offset과 Cursor의 차이에서 별도로 정리했습니다. 또한 REST는 여러 리소스를 한 번에 조합해서 가져오는 데 구조적으로 약하기 때문에, 모바일 앱처럼 화면 하나에 사용자·주문·배송 정보가 동시에 필요한 경우 BFF(Backend for Frontend) 계층을 두거나 GraphQL로 전환하는 것이 REST를 억지로 확장하는 것보다 근본적인 해결책입니다. "모든 것을 REST로 해결하려는 시도"야말로 REST 설계에서 가장 흔한 안티패턴입니다.

7. 결론: 실무 체크리스트

리소스 모델링, 멱등성, 에러 표준화, 버저닝은 따로따로 배우는 주제가 아니라 하나의 API 생명주기 안에서 서로 맞물립니다. 처음 URL을 설계할 때부터 "이 엔드포인트가 언젠가 폐기될 수 있다"는 전제를 깔아 두면, 버저닝과 하위 호환성 문제의 절반은 설계 단계에서 미리 줄어듭니다.

  • URL은 명사(리소스) 중심으로 설계했고, 동사성 동작은 하위 리소스나 명시적 규칙으로 분리했는가
  • Side effect가 있는 POST·PATCH 요청에 Idempotency-Key 처리가 되어 있는가
  • 에러 응답이 application/problem+json 같은 일관된 포맷을 따르는가
  • 버저닝 전략(URL·헤더·미디어타입)이 팀 규모와 릴리스 주기에 맞게 의도적으로 선택되었는가
  • 폐기 예정 엔드포인트에 Deprecation·Sunset 헤더와 계약 테스트가 함께 붙어 있는가

이 다섯 가지만 지켜도 "API 하나 잘못 설계해서 6개월짜리 마이그레이션 프로젝트가 열리는" 상황의 상당수는 미리 막을 수 있습니다.