[UMC] 좋은 Web API, 이렇게 설계하자 ✒️

Backend·2025-10-01·by 1000hyehyang

3주차 UMC 시니어 미션의 마지막 과제는 Microsoft Azure Architecture Center의 Web API 디자인 모범 사례 문서를 바탕으로 정리하는 것이다.

이 문서는 API를 설계할 때 반드시 고민해야 할 여러 축—URI 설계, HTTP 메서드의 의미 준수, 상태 코드 활용, 비동기 작업 처리, 페이징·필터링, 버전 관리 같은 주제— 하나하나를 다룬다. “좋은 API란 무엇인가?”를 생각하게 해주는 설계 철학이 담긴 길잡이다.

 

RESTful API의 본질: 리소스를 중심으로 설계하기

문서는 먼저 RESTful 웹 API란 무엇인가를 다시 정의한다. RESTful한 API라는 것은 무엇인가?

API는 단순히 데이터를 꺼내고 바꾸는 도구가 아니라, 클라이언트와 서버 사이의 계약이다. 이 계약은 내부 구조를 숨기고, 클라이언트가 HTTP와 리소스 표현만으로 상호작용할 수 있게 만들어야 한다.

즉, API는 플랫폼 독립적이어야 한다. 클라이언트는 JSON·XML 같은 포맷으로 결과를 받고, 내부에서 어떤 DB를 쓰는지, 어떤 마이크로서비스가 연계되는지는 몰라도 된다.

또한 API는 stateless 해야 한다. 서버는 각 요청을 독립적으로 처리해야 하며, 이전 요청의 상태를 기억하지 않는다. 이렇게 하면 서버는 언제든 다른 인스턴스로 요청을 분산할 수 있어 확장성이 좋아진다.

하지만 동시에 “매번 모든 정보를 담아 요청해야 한다”는 제약이 생긴다. 즉, 서버가 클라이언트 상태를 기억하지 않기 때문에 클라이언트는 필요한 데이터를 매번 요청에 넣어야 하고, 이 때문에 데이터베이스 쿼리가 반복적으로 발생해 부하가 커질 수도 있다.

여기서 중요한 건 API가 단순히 응답만 주고받는 게 아니라, ‘어떤 포맷으로 대화할 것인가’를 합의하는 것이다. 이게 바로 표현 협상(content negotiation)이다. 클라이언트는 Accept 헤더로 원하는 응답 포맷을 지정하고, 서버는 Content-Type으로 실제 응답 포맷을 명시한다. 서버가 지원하지 못하면 406 Not Acceptable, 클라이언트가 올린 바디를 이해하지 못하면 415 Unsupported Media Type을 반환한다. 이 작은 계약이 어긋나면 API는 처음부터 대화가 끊긴다.

추가로 문서는 하이퍼미디어(HATEOAS) 개념을 강조한다. 단순히 데이터만 던지는 응답이 아니라, 응답 안에 “다음 가능한 행동”으로 이어지는 링크를 넣어주면 클라이언트는 API 문서 외부 참조 없이 탐색을 이어갈 수 있다. 예를 들어 주문 응답에 cancel 링크가 포함되어 있으면, 클라이언트는 “이 주문을 취소할 수 있구나”를 바로 알 수 있다. 즉, API 자체가 자기 설명적이 되고, 클라이언트는 링크 탐색만으로 가능한 행동들을 알 수 있게 된다.

 

URI 설계: 리소스 중심의 깔끔한 이름 짓기

문서에서 특히 강조하는 게 URI 설계다. 핵심은 명사형 + 계층 구조다.

  • orders → 주문 컬렉션

  • /orders/123 → 특정 주문 리소스

반대로 /create-order처럼 동사형으로 엔드포인트를 짓는 건 REST 철학과 맞지 않는다. “무엇을 한다”가 아니라 “무엇을 대상으로 한다”가 URI에 담겨야 하기 때문이다.

또한 관계 표현은 단순할수록 좋다. 예컨대 /customers/5/orders/10/products처럼 지나치게 계층이 깊어지는 구조는 클라이언트 입장에서 헷갈릴 수 있다. 대신 응답 JSON에 관련 링크를 두어 “이 주문의 상품 목록 보기 링크”를 제공하는 식이 더 유연하고 유지보수하기 좋다.

무엇보다 데이터베이스 테이블 구조를 그대로 노출하지 않는 게 핵심이다. API URI가 DB 테이블 이름, 컬럼 이름, 조인 구조까지 따라가면 내부 변경 시 API까지 파편화된다.

예를 들어 DB에 tbl_customer라는 테이블이 있다고 해서 API를 /tbl_customer라고 만드는 건 잘못이다. DB 테이블이 바뀌면 API도 깨져버리기 때문이다. API는 “사용자”라는 비즈니스 개념을 추상화해야 하므로 /customers처럼 의미 있는 이름을 쓰는 게 맞다. 즉, API는 DB의 거울이 아니라 비즈니스 언어의 표현이어야 한다.

또 너무 잘게 쪼개서 클라이언트가 여러 번 호출해야 하는 chatty API를 만들면 불필요한 왕복이 발생한다. 이럴 땐 적절한 비정규화나 합리적 데이터 통합으로 요청 횟수를 줄이는 게 낫다.

 

HTTP 메서드와 상태 코드: 의미를 지키는 것이 우선

API 설계에서 HTTP 메서드를 “그냥 쓰는 것”이 아니라 그 의미를 준수하는 게 문서의 중요한 축이다.

  • GET → 조회, 성공 시 200 OK

  • POST → 새 리소스 추가, 성공 시 201 Created + Location 헤더

  • PUT → 전체 교체, 멱등성을 지켜야 함

  • PATCH → 일부 수정, 실패 시 400/415/409 등 적절한 코드

  • DELETE → 리소스 삭제, 성공 시 204 No Content

POST는 멱등하지 않다. 같은 요청을 여러 번 보내면 리소스가 중복 생성될 수 있다. 이를 보완하려고 결제 같은 도메인에서는 Idempotency-Key 헤더를 써서 중복 요청에 같은 응답을 돌려주는 패턴을 쓴다.

PUT은 전체 교체이자 멱등해야 하고, PATCH는 부분 수정을 위한 메서드다. PATCH에는 두 가지 표준이 있는데, 단순히 null을 삭제 의미로 해석하는 JSON Merge Patch(application/merge-patch+json)와 연산 시퀀스를 명시하는 JSON Patch(application/json-patch+json)가 있다. 서버가 형식을 모르면 415, 문서 구조가 잘못되면 400, 현재 상태와 충돌하면 409를 반환한다.

여기서 잠깐, HTTP 헤더와 Location 헤더 개념을 짚고 넘어가면 이해가 쉽다.

HTTP 헤더란 요청이나 응답에 붙는 메타데이터다. 예를 들어 Content-Type: application/json은 “이 바디는 JSON이다”라는 걸 알려주는 역할을 한다. Location 헤더는 특히 중요하다. 새로운 리소스를 생성하는 요청(POST)이 성공했을 때, 서버는 “새 리소스는 여기 있다”라는 의미로 URI를 Location 헤더에 담아 돌려준다.

👉 참고로 HTTP 상태 코드를 귀여운 고양이 짤로 정리해 놓은 사이트도 있다 🐱

한편 오류 응답은 상태 코드만 맞춘다고 끝이 아니다. 바디 구조도 일관되게 정의하는 게 좋다. 예를 들어 Azure 문서는 다음과 같은 에러 응답 포맷을 권장한다.

{
  "error": {
    "code": "InvalidInput",
    "message": "name is required",
    "target": "name",
    "details": [
      { "code": "Null", "message": "value is null", "target": "name" }
    ],
    "innererror": { "trace": "..." }
  }
}

서비스마다 이 구조를 지키면 클라이언트, 로깅, 모니터링 도구가 한 번에 정리된다.

 

장기 실행 작업과 비동기 패턴 처리

API 중에는 즉시 끝나지 않는 작업도 있다. 예를 들어 대용량 파일을 다른 서버로 내보내거나, 동영상 파일을 변환(transcoding)하는 경우다. 이런 요청은 몇 초 안에 끝나지 않기 때문에 문서는 비동기 패턴을 제안한다.

흐름은 이렇게 된다:

  1. 클라이언트가 요청을 보낸다.

  2. 서버는 즉시 202 Accepted로 응답한다. 이건 “작업을 받았고, 진행 중이다”라는 의미다.

  3. 응답 헤더의 Location 필드에 작업 상태를 확인할 수 있는 URI를 담아준다.

  4. 클라이언트는 이 URI를 주기적으로 호출해 작업 상태를 확인한다. 이 과정을 **폴링(Polling)**이라고 하는데, 말 그대로 일정 간격으로 “다 됐어?” 하고 서버에 물어보는 방식이다.

  5. 작업이 완료되면 서버는 303 See Other 또는 200 OK와 함께 최종 결과 리소스의 URI나 상태 정보를 돌려준다.

이렇게 하면 클라이언트가 무한정 기다리지 않아도 되고, 서버는 긴 작업을 백그라운드에서 처리할 수 있다.

 

페이지네이션과 필터링: 데이터 과부하 방지

데이터 양이 많을수록 클라이언트가 한 번에 모든 데이터를 가져오는 건 위험하다. 느려지고, 메모리도 터지고, 네트워크도 막힌다. 그래서 문서는 페이지네이션과 필터링을 반드시 기본에 포함시키라고 권한다.

가장 흔한 방식은 limit과 offset 쿼리 매개변수 방식이다. 예를 들어 /orders?limit=20&offset=40처럼. 또한 응답에는 기본값과 최대 값 제한을 두어 클라이언트가 무작정 큰 요청을 보내는 걸 방지해야 한다는 조언이 있다.

더 나아가 문서는 206 Partial Content와 Content-Range 같은 HTTP 표준 헤더를 활용해서 부분 콘텐츠 전송을 지원하라는 얘기도 한다. 이걸 쓰면 클라이언트가 여러 번 쪼개 받아 전체 결과를 조합할 수 있다.

 

API 버전 관리: 변화와 호환성 사이의 균형

API는 시간이 지나면서 변화한다. 문제는 그 변화를 어떻게 사용자에게 덜 거슬리게 보여줄 것인가다. 문서는 네 가지 전략을 제시한다.

  1. URI 기반 버전: /v2/orders/123 → 주소 자체에 버전을 넣는 방식. 가장 단순하고 직관적이다. 다만 모든 URI가 바뀌기 때문에 클라이언트 코드 수정이 많아질 수 있다.

  2. 쿼리 매개변수 버전: /orders/123?version=2 → 버전을 옵션처럼 붙인다. 간단하고 캐싱에도 유리하지만, 링크 공유나 문서화 시 버전이 눈에 잘 안 띌 수 있다.

  3. 헤더 버전: api-version: 2 → HTTP 헤더에 버전을 숨긴다. URI가 깔끔해지지만, 클라이언트가 반드시 헤더를 신경 써야 한다는 단점이 있다.

  4. 미디어 타입 버전: Accept: application/vnd.contoso.v1+json → MIME 타입에 버전을 담는다. 표현 방식까지 세밀하게 구분 가능하지만, 서버와 프록시 캐시가 요청을 구분하기 어려워질 수 있다.

각 방식마다 trade-off가 있다. URI/쿼리 방식은 캐싱에 유리하고 단순하지만, HATEOAS 링크를 구성할 때 버전 정보를 포함해야 하는 번거로움이 있다. 반대로 Accept 헤더나 미디어 타입 버전은 링크 구성이 깔끔하지만, 캐시나 프록시 수준에서 버전별 요청을 구분하기 어렵다는 단점이 있다. 따라서 조직·클라이언트 패턴·운영 환경을 고려해 전략을 골라야 한다는 게 문서의 핵심 메시지다.

 

정리하며...

  • API는 내부 구조를 숨기고 클라이언트와 HTTP 인터페이스로 소통해야 한다.

  • URI는 명사 중심으로, 동사형 엔드포인트는 지양한다.

  • HTTP 메서드와 상태 코드는 무시할 수 없는 계약이다.

  • 긴 작업은 비동기 패턴으로 풀고, 완료 흐름까지 설계해야 한다.

  • 데이터 과잉을 막기 위해 페이지네이션·필터링을 기본에 두어야 한다.

  • API 변화는 버전 관리 전략을 초기에 세워야 한다.

댓글 로딩 중...