실용적인 RESTful API 설계에 대해

wo_ogie·2023년 7월 19일
68
post-thumbnail

개요

API 서버를 개발하다 보면 API를 설계함에 있어 고민이 드는 순간들이 있습니다. API URL은 어떻게 정의해야 하는지, 버전 관리는 어떻게 해야 하는지, 어떤 기능들을 제공해야 하는지, 응답은 어떤 구조로 설계해야 하는지 등을 예시로 들 수 있을 것 같네요. 이번 글에서는 이러한 고민을 할 때 도움이 될 만한 자료가 있어 소개하려고 합니다.
내용이 짧지 않으므로 목차를 보고 궁금한 내용만 살펴보아도 괜찮으나, 내용이 좋으니 전체적으로 한 번 훑어보는 것을 추천합니다. 😊

이 글은 기본적으로 Best Practices for Designing a Pragmatic RESTful API를 읽고 정리한 내용이나, 일부 내용이 수정되거나 다른 내용이 추가되었을 수 있습니다.


목차


API의 주요 요구사항

API는 개발자를 위한 유저 인터페이스이다. 그러므로 쾌적한 유저 인터페이스를 만들기 위해 우리는 노력해야 한다.

이 글의 목표는 오늘날의 웹 애플리케이션을 위해 설계된 실용적인 API의 모범 사례를 설명하는 것이다. 그렇기 때문에 옳지 않다고 생각되는 학술적인 표준을 만족시키려고 하지 않을 것이다.

우선, 의사 결정 과정을 돕기 위해 API가 추구해야 하는 몇 가지 요구사항을 살펴보자.

  • 적절한 웹 표준을 사용해야 한다.
  • 개발자에게 친숙해야 하며, 브라우저 주소창을 통해 탐색할 수 있어야 한다.
  • 간단하고, 직관적이며 일관성을 유지함으로써 선택이 쉬울 뿐만 아니라 즐거워야 한다.
  • Enchant UI의 대부분을 구동할 수 있는 충분한 유연성을 제공해야 한다.
  • 다른 요구사항과 균형을 유지하면서 효율적이어야 한다.

API는 개발자의 UI이다. 그렇기 때문에 여느 UI와 마찬가지로 UX를 신중하게 고려하는 것이 중요하다.


RESTful한 URL들과 Action들을 사용하라

REST의 핵심 원칙은 API를 논리적인 resources로 분리하는 것이다. 이러한 resource들은 특정 의미를 갖는 method(GET, POST, PUT, PATCH, DELETE)들을 사용하여 다뤄진다.

그렇다면, 무엇을 resource로 만들 수 있는가?

Resources는 동사가 아닌, API consumer의 관점에서 의미 있는 명사여야 한다.

주의: 내부 모델이 resources에 명확히 대응될 수 있지만, 일대일 대응은 아니다. 여기서 핵심은 API에 관련 없는 구현 세부 정보를 유출하지 않는 것이다. API resources는 API consumer의 관점에서 이해할 수 있어야 한다.

일단 resource를 정의한 후에는 resource에 적용되는 action과 해당 action이 API에 매핑되는 방식을 식별해야 한다. RESTful 원칙은 다음과 같이 매핑된 HTTP methods를 사용하여 CRUD actions를 처리하는 전략들을 제공한다.

  • GET /tickets - 티켓 리스트 조회
  • GET /tickets/12 - 특정 티켓 조회
  • POST /tickets - 새로운 티켓 생성
  • PUT /tickets/12 - 12번 티켓 수정
  • PATCH /tickets/12 - 12번 티켓을 부분적으로 수정
  • DELETE /tickets/12 - 12번 티켓 삭제

REST의 장점은 존재하는 HTTP methods를 활용하여 단 하나의 /tickets endpoint에서 중요한 기능을 구현한다는 것이다. 이 때문에 지켜야 할 method naming convention이 없으며, URL 구조가 깔끔하고 명확하다.

Endpoint name은 단수여야 하는가 복수여야 하는가?

실용적인 방식은 URL format을 일관되게 유지하고 항상 복수형을 사용하는 것이다. 그렇게 한다면 이상한 복수형(person/people, goose/geese 등)을 처리할 필요가 없기 때문에 API consumer의 삶이 더 좋아지고 API provider가 구현하기 더 쉬워진다.

But, relatations는 어떻게 처리하는가?

만약 relation(관련 있는 resource)이 다른 resource 내에만 존재할 수 있는 경우, RESTful 원칙은 유용한 지침을 제공한다.
예를 들어보자. Enchant의 ticket은 여러 개의 messages로 구성된다. 이러한 메시지들은 다음과 같이 /tickets endpoint에 논리적으로 매핑될 수 있다.

  • GET /tickets/12/messages - 12번 티켓에 대한 메시지 리스트 조회
  • GET /tickets/12/messages/5 - 12번 티켓의 5번 메시지 조회
  • POST /tickets/12/messages - 12번 티켓에 새로운 메시지 생성
  • PUT /tickets/12/messages/5 - 12번 티켓의 5번 메시지 수정
  • PATCH /tickets/12/messages/5 - 12번 티켓의 5번 메시지를 부분적으로 수정
  • DELETE /tickets/12/messages/5 - 12번 티켓의 5번 메시지 삭제

대안 1: 만약 relation이 resource와 독립적으로 존재할 수 있는 경우, resource의 output representation 내에 relation에 대한 식별자를 포함하는 것이 합리적이다. 그러면 API consumer는 관련 resource의 endpoint에 도달함으로써 해당 자원을 활용할 수 있게 된다.

대안 2: 만약 독립적으로 존재하는 relation이 일반적으로 resource와 함께 요청되는 경우, API는 relation의 representation을 자동으로 포함하고 API에 대한 두번째 호출(second hit)을 방지하는 기능을 제공할 수 있다. Clean API and one hit to the server.

CRUD operations의 개념에 딱 맞지 않는 action은 어떻게 해야 하는가?

애매한 상황이 될 수 있다. 이 경우, 다음과 같은 여러가지 접근 방식이 있다.

  1. Action이 resource의 필드처럼 나타나도록 재구성한다. 예를 들어, activated action이라는 데이터는 boolean activated 필드에 매핑될 수 있고 PATCH를 통해 resource에 업데이트 될 수 있다.
  2. RESTful 원칙을 사용하여 해당 action을 sub-resource처럼 취급한다. 예를 들어, GitHub의 API를 사용하면 PUT /gists/:id/stargist에 star 할 수 있고 DELETE /gists/:id/starstar를 해제할 수 있다.
  3. 때로는 정말로 action을 합당한 RESTful 구조에 매핑할 방법이 없을 수도 있다. 예를 들어, multi-resource 검색은 특정 resource의 endpoint에 적용되는 것이 적합하지 않을 수 있다. 이 경우, resource가 아닐지라도 /search가 좋은 선택이 된다. 이상해 보일 수 있지만 이것은 괜찮다. API consumer의 관점에서 옳은 것이고, 명확히 혼란을 피하기 위해 문서화 했는지에만 주의하자.

언제, 어디서나 항상 SSL을 사용하라 (HTTPS)

항상 SSL을 사용해라. 예외는 없다. 오늘날, 웹 상에서의 API는 인터넷이 있는 모든 곳(도서관, 카페, 공항 등)에서 접속될 수 있다. 그리고 이들 모두가 보안적으로 안전하지는 않다. 많은 사람들이 통신을 전혀 암호화하지 않으므로, 자격 증명이 탈취될 경우 쉽게 도용되거나 위조될 수 있다.

주의해야 할 점은 API URL에 대한 non-SSL 접근이다. Non-SSL 접근을 SSL에 대응되는 항목으로 redirect 해서는 안된다. 이러한 경우에는 반드시 hard error를 발생시켜야 한다! 자동적으로 redirect가 설정되면 제대로 구현되지 않은(또는 문제가 있는) client가 자신도 모르게 암호화되지 않은 endpoint를 통해 request parameter들을 유출할 수 있다. Hard error는 이 실수를 조기에 발견하고 client가 적절하게 구성될 것을 보장한다.


API 문서화

API 문서는 쉽게 찾을 수 있고, 공개적으로 접근할 수 있어야 한다. 대부분의 개발자는 작업을 시작하기 전에 문서를 확인한다. 문서가 PDF 파일 안에 숨겨져 있거나 로그인이 필요한 경우 원하는 내용을 찾기 힘들 것이다.

문서에서는 전체 요청과 응답의 사이클이 설명되어야 한다. 요청은 브라우저에 붙여넣을 수 있는 예시여야 한다(브라우저에 붙여넣을 수 있는 링크나 터미널에 붙여넣을 수 있는 curl 등). 이에 대해 GitHubStripe에서 좋은 예시를 살펴볼 수 있다.

만약 public API를 배포한다면, 예고 없이 API를 중지하지 않겠다고 약속한 것이나 마찬가지다. 문서에는 반드시 deprecation schedule과 외부에서 볼 수 있는 API 업데이트 관련 세부 정보가 포함되어야 한다. 업데이트 사항들은 블로그(i.e. a changelog) 또는 이메일을 통해 전달되어야 한다.


Versioning

서버 개발자는 항상 API의 버전 관리를 해야 한다. 버전 관리를 하면 반복 작업을 더 빠르게 수행하고, 유효하지 않은 요청들이 새롭게 업데이트된 endpoints에 도달하는 것을 방지할 수 있다. 또한 일정 기간 동안은 이전 API 버전을 계속 제공할 수 있으므로 주요한 API 버전 전환을 원활하게 처리하는 데 도움이 된다.

API 버전을 URL에 담아야 할지, header에 담아야 할지에 대해서는 고민해 볼 필요가 있다. Header에 포함하는 것이 좋다는 의견도 있지만, 브라우저에서 여러 버전에 대한 resource를 탐색할 수 있도록 하고, 개발자의 경험을 보다 편하게 하기 위해서는 URL에 버전 정보가 포함되어야 한다. 다만 각각의 장단점이 뚜렷하므로 API를 배포하고 사용하는 환경에 따라 적절히 선택하는 것을 권장한다. 심지어는 두 방식 모두 선택할 수도 있다. 이에 대한 예시 중 하나로 Stripe에서 API versioning을 하는 방법을 살펴보는 것을 추천한다. URL에는 major version number(v1)가 존재하고, custom request header에는 날짜 기반의 sub-versions가 존재하는 형태이다. 이 경우 major version은 API 전체의 구조적 안정성을 제공하고, sub-versions는 비교적 작은 변경사항(field deprecations, endpoint changes 등)을 식별한다.

API가 항상 완벽하게 안정적으로 제공될 수는 없다. 변화는 피할 수 없다. 중요한 것은 변화를 관리하는 방법이다. 잘 문서화되어 수개월에 걸쳐 발표되는 deprecation schedules는 많은 API와 API consumer들이 충분히 받아들일만 할 것이다.


Result filtering, sorting, searching

Base resource URL은 최대한 간결하게 유지하는 것이 좋다. 복잡한 필터링, 정렬 요구사항과 검색 기능은 모두 base URL의 위에 query parameter로 쉽게 구현될 수 있다. 좀 더 자세히 살펴보자.

Filtering

필터링을 구현하는 각 필드에 대해 unique query parameter를 사용한다. 예를 들어, /tickets endpoint에서 티켓 목록을 요청할 때, open 상태의 티켓들로 제한할 수 있다. 이는 GET /tickets?state=open 같은 요청으로 수행될 수 있다. 여기서 state는 필터룰 구현하는 query parameter이다.

Sorting

필터링과 마찬가지로 sort를 사용하여 정렬 방식을 설명할 수 있다. Sort parameter들을 쉼표로 구분하여 복잡한 정렬 요구사항을 표현할 수 있다. 이때, 각 필드는 descending sort order를 의미하는 단항 음수 기호가 포함될 수 있다. 몇가지 예시를 살펴보자.

  • GET /tickets?sort=-priority: Priority의 내림차순 정렬 기준으로 티켓 리스트를 검색한다.
  • GET /tickets?sort=-priority,created_at: Priority의 내림차순 정렬 기준으로 티켓 리스트를 검색한다. 같은 priority에서는 오래된 티켓이 앞 순서로 정렬된다.

Searching

때때로 기본 필터만으로는 충분하지 않고 텍스트 기반 검색 기능이 필요할 수도 있다. 이미 ElasticSearchLucene와 같은 검색 기술을 사용하고 있을 수도 있다. 특정 유형의 resource를 검색하는 메커니즘으로 텍스트 검색 방식을 사용하는 경우, 이는 API에서 resource의 endpoint에 query parameter로 노출될 수 있다. 검색 쿼리들은 검색 엔진으로 곧바로 전달되어야 하며 API의 결과는 일반적인 결과와 동일한 형식이어야 한다.

이러한 것들을 결합하여 다음과 같은 쿼리들을 작성할 수 있다.

  • GET /tickets?sort=-updated_at: 최근 수정된 된 티켓들을 검색
  • GET /tickets?state=closed&sort=-updated_at: 최근 closed 된 티켓들 검색
  • GET /tickets?q=return&state=open&sort=-priority,created_at: open 상태이며 ‘return’이라는 단어가 포함되고 priority가 높은 순서로 티켓들을 검색.

Common queries에 대한 aliases

일반적인 consumer에게 더 즐거운 API 경험을 제공하려면 조건들을 쉽게 접근할 수 있는 RESTful한 경로로 패키징 하는 것을 고려해라. 예를 들어, 위에서 살펴본 ‘최근 closed된 티켓 검색’은 GET /tickets/recently_closed로 변경될 수 있다.


API에서 반환되는 fields를 제한하는 방법을 제공하라

API consumer가 항상 resource의 전체 표현을 필요로 하는 것은 아니다. 반환된 필드들을 선택하는 기능은 API consumer가 네트워크 트래픽을 최소화하고 API 사용 속도를 높이는 데 큰 도움이 된다.

쉼표로 구분된, 포함할 필드들의 목록이라는 의미를 갖는 fields query parameter를 사용해라. 예를 들어, GET /tickets?fields=id,subject,updated_at&state=open&sort=-updated_at는 open 상태인 티켓들의 정렬된 목록을 표시하기에 필요한 정보만을 검색한다.

참고로 이 접근 방식은 관련된 resource representations를 자동으로 불러오는 방법을 제공하라와 결합될 수 있다.
ex) GET /tickets?embed=customer&fields=id,customer.id,customer.name


생성, 수정 시에는 반드시 resource representation을 반환해야 한다

PUT, POST or PATCH 호출은 제공된 parameter의 일부가 아닌, resource의 여러 필드들을 수정할 수 있다 (ex. created_at or updated_at timestamps). 업데이트 된 정보를 확인하기 위해 API consumer가 API를 다시 호출하지 않도록 하려면, API가 응답의 일부로 수정된(또는 생성된) 정보를 반환하도록 해야 한다.


HATEOAS를 반드시 적용해야 하는가?

참고로 HATEOAS에 대해 잘 모른다면 그런 REST API로 괜찮은가 - HATEOAS에서 알기 쉽게 설명해주고 있으니 참고하시면 좋을 것 같습니다. 유명한 keyword이니 직접 검색해보아도 좋은 자료가 많이 있을 거에요.

API consumer가 링크를 생성해야 하는지, 아니면 API를 통해 링크를 제공해야 하는지에 대해서는 의견이 분분하다. RESTful 설계 원칙에서는 HATEOAS에 대해 다음과 같이 명시하고 있다. HATEOAS란 endpoint 와의 상호작용이 out-of-band information을 기반으로 하지 않고 output representation과 함께 제공되는 metadata 내에서 정의되어야 한다고 대략적으로 명시하는 것이다.

Web은 일반적으로 웹 사이트의 첫 페이지로 이동하여 페이지에 표시되는 내용을 기반으로 링크를 따라가는 HATEOAS type principles에 따라 작동하지만, 아직은 API에서 HATEOAS를 적용할 준비가 되지 않은 것 같다. 웹 사이트를 이용할 때 어떤 링크를 클릭할지는 run time 시점에 결정된다. 하지만, API를 사용하면 어떤 요청을 보낼지에 대한 결정이 run time이 아닌, API 통합 코드가 작성될 때 결정된다. 이러한 결정을 run time으로 연기할 수 있을까? 물론, 그렇게 하면 코드가 중단되지 않고 중요한 API 변경 사항을 처리할 수 없기 때문에 얻을 수 있는 이점이 많지 않다. 즉, HATEOAS는 유망하지만 아직 prime time에서 사용하기에는 무리가 있다고 생각한다. HATEOAS의 잠재력을 완전히 실현하려면 이러한 원칙을 중심으로 표준과 tool을 정의하는 데 좀 더 많은 노력을 기울여야 할 것이다.

현재로서는 사용자가 문서에 접근할 수 있다고 가정하고 API consumer가 링크를 만들 때 사용할 resource identifier를 output representation에 포함시키는 것이 가장 좋다. Resource에 대한 identifier를 사용하면 network에서 주고받는 데이터가 최소화되고, API consumer가 저장하는 데이터도 최소화된다(identifier가 포함된 URL을 대신 매우 작은 identifier만 저장하면 되기 때문).

또한 앞서 살펴본 versioning 방식을 고려했을 때, API consumer는 URL이 아닌 resource identifier를 저장하는 것이 장기적으로 더 합리적일 것이다. 결국 resource를 식별하기 위한 identifier는 여러 버전에 걸쳐 안정적으로 사용할 수 있지만, 특정 URL은 그렇지 않다.


응답 형태는 JSON만 사용하라

XML은 API에 적합하지 않다. XML은 장황하고, 파싱하기 어렵고, 읽기 어렵고, 데이터 모델이 대부분의 프로그래밍 언어에서의 데이터 모델과 호환되지 않으며, output representation의 주요 요구 사항이 internal representation에서 직렬화되는 경우 XML의 확장성이라는 이점이 무의미해진다. 이러한 것들 외에도 더 많은 단점들이 존재한다. 주목해야 할 점은 오늘날 여전히 XML을 지원하는 주요 API를 찾기 어려울 것이라는 점이다. 물론 당신도 그렇게 해서는 안된다.

만약 고객들이 많은 수의 기업 고객으로 구성된 경우, XML을 지원해야 할 수도 있다. 만약 이 작업을 수행해야 한다면 다음과 같은 새로운 질문에 직면하게 된다.

Media type은 Accept header 또는 URL을 기반으로 하여 변경해야 하는가?

브라우저에서의 탐색 가능성을 보장하려면 URL에 있어야만 한다. 여기에서 가장 합리적인 선택지는 endpoint URL에 .json 또는 .xml 확장자를 추가하는 것이다.

이에 대한 예시로 Kakao REST API - 키워드로 장소 검색하기를 살펴볼 수 있다. 해당 API는 응잡 데아터의 format으로 JSONXML을 모두 지원하고 있는데, https://dapi.kakao.com/v2/local/search/keyword.${FORMAT} URL의 ${FORMAT} 값을 통해 어떠한 format으로 응답을 받을지 결정할 수 있다.


Field name으로 더 적합한 것은? snake_case vs camelCase

Primary representation format으로 JSON을 사용하는 경우, JavaScript naming conventions를 따르는 것이 “올바른” 방법이다. 즉, filed name으로 camelCase가 사용된다는 것을 의미한다. 그런 다음 다양한 languages로 client library를 구축하는 경로로 이동하는 경우 해당하는 관용적 naming convention을 사용하는 것이 가장 좋다. (camelCase for C# & Java, snake_case for python & ruby)


기본적으로 pretty print를 사용하고 gzip을 지원하라

공백이 압축된 output

{"name":"woogie","age":20,"sex": "male"}

Pretty print

{
  "name":"woogie",
  "age":20,
  "sex":"male"
}

공백이 압축된 output을 제공하는 API는 browser에서 보기에 별로 재미있지 않다. Pretty(예쁜, 보기 편한) printing을 활성화하기 위해 query parameter(ex. pretty=true)를 제공할 수도 있지만, 기본적으로 pretty prints를 제공하는 API가 이용하기 더 쉽다. Pretty printing에 대한 추가적인 데이터 전송에 드는 비용은 무시할 수 있는 수준이며, 특히 gzip을 구현하지 않았을 때의 비용과 비교하면 더더욱 그렇다.

몇 가지 사례에 대해 생각해보자. API consumer가 디버깅 중일 때, 직접 작성한 코드에 의해 API에서 받은 데이터를 출력하도록 하면 기본적으로 쉽게 읽을 수 있다. 또는 consumer가 생성하는 URL을 가져와 browser에서 직접 입력하면 기본적으로 쉽게 읽을 수 있다. 이런 사소한 것들이 API를 사용하기 좋게 만든다.

이러한 부가적인 데이터 전송을 하는 것이 정말 괜찮을까?

실제 사례를 살펴보며 생각해보자. 기본적으로 pretty print를 사용하는 GitHub의 API에서 일부 데이터를 살펴보고 gzip 비교도 함께 해보자.

$ curl https://api.github.com/users/veesahni > with-whitespace.txt
$ ruby -r json -e 'puts JSON.parse(STDIN.read)' < with-whitespace.txt > without-whitespace.txt
$ gzip -c with-whitespace.txt > with-whitespace.txt.gz
$ gzip -c without-whitespace.txt > without-whitespace.txt.gz

결과로 얻은 파일의 크기는 다음과 같다.

  • without-whitespace.txt - 1221 bytes
  • with-whitespace.txt - 1290 bytes
  • without-whitespace.txt.gz - 477 bytes
  • with-whitespace.txt.gz - 480 bytes

위 예시에서 공백은 gzip을 사용하지 않을 때 출력 크기를 5.7% 증가시켰고, gzip을 사용할 때는 0.6% 증가시켰다. 반면에 gzip을 사용하는 것만으로도 크기를 60% 이상 절약할 수 있었다. Pretty print에 드는 비용이 상대적으로 적기 때문에 기본적으로 pretty print를 사용하고 gzip 압축이 지원되는지 확인하는 것이 가장 좋은 선택이다!


Don’t use an envelope by default, but make it possible when needed

많은 API는 다음과 같이 response를 envelope한다.

{
  "data" : {
    "id" : 123,
    "name" : "John"
  }
}

이렇게 응답을 envelope 하는 데에는 그럴만한 몇 가지 이유가 있다. 추가적인 metadata 또는 pagination information을 쉽게 포함할 수 있고, 일부 REST client는 HTTP header에 쉽게 access 할 수 없으며 JSONP requests는 HTTP header에 access 할 수 없다. 그러나, CORSLink header from RFC 5988과 같이 빠르게 채택되는 표준으로 인해 enveloping이 불필요해지기 시작했다.

기본적으로는 envelope를 사용하지 않고, 예외적인 case에만 enveloping을 사용함으로써 API의 미래(추후 적용 및 호환성)를 보장할 수 있다.

예외적인 경우에 envelope는 어떻게 사용해야 하는가?

Envelope가 실제로 필요한 두 가지 상황이 있다 - 이는 API가 JSONP를 통해 domain 간의 request를 지원해야 하거나 client가 HTTP header로 작업할 수 없는 경우이다.

  1. Cross-domain에서의 JSONP를 지원하려면: 이러한 requests에는 callback function의 이름을 나타내는 추가적인 query parameter(일반적으로 callback or jsonp로 명명된)가 함께 제공된다. 이 parameter가 있는 경우, API는 항상 200 HTTP status code로 응답하고 JSON payload에서 실제 status code를 전달하는 full envelope mode로 전환해야 한다. 응답와 함께 전달될 추가적인 HTTP headers는 다음과 같이 JSON fields에 mapping 되어야 한다.

    callback_function({
    	  status_code: 200,
    	  next_page: "https://..",
    	  response: {
    		    ... actual JSON response body ... 
    	  }
    })
  2. 제한된 HTTP clients를 지원하려면: JSONP callback function 없이 enveloping을 trigger하는 special query parameter ?envelope=true를 허용한다.


JSON encoded POST, PUT & PATCH bodies

API input에 대해 JSON의 고려 사항을 생각해보자.

많은 API가 API request body에서 URL encoding을 사용한다. URL encoding은 말 그대로 URL query parameter의 데이터를 encoding 하는 데 사용하는 것과 동일한 규칙을 사용하여 key-value 쌍이 encoding 된 request body이다. 이 방법은 간단하고 널리 지원되며 작업이 수행되도록 한다.

하지만 URL encoding에는 몇 가지 문제가 있다. 일단 data type에 대한 개념이 존재하지 않기 때문에 API는 문자열에서 정수와 boolean 값을 파싱해야 한다. 또한 계층 구조에 대한 실제 개념이 존재하지 않는다. Key-value쌍으로 일부 구조를 만들 수 있는 몇 가지 규칙(ex. 배열을 나타내기 위해 key에 []를 추가하는 등)이 있기는 하지만 이를 JSON의 기본 계층 구조와 비교할 수는 없다.

API가 간단하다면 URL encoding으로 충분할 수도 있다. 하지만, 출력 형식과 일치하지 않을 수도 있다.

JSON 기반 API의 경우, API input도 JSON을 고수해야 한다.

JSON으로 encoding 된 POST, PUT, PATCH 요청을 받는 API는 Content-Type header를 application/json으로 설정하거나 415 Unsupported Media Type HTTP status code를 던져야 한다.


Pagination

Envelope를 지원하는 API는 일반적으로 envelope 자체에 pagination data를 포함한다. 최근까지만 해도 더 나은 선택지가 많지 않았기 떄문이다. 하지만 오늘날 pagination 세부 정보를 포함하는 올바른 방법은 RFC 8288에서 도입한 Link header를 사용하는 것이다.

Link header를 사용하는 API는 미리 만들어진 링크 집합을 반환할 수 있으므로 API consumer는 링크를 직접 구성할 필요가 없다. 이는 pagination이 cursor 기반일 때 특히 중요하다. 다음은 GitHub 문서에서 가져온 올바르게 사용된 Link header의 예시이다.

Link: <https://api.github.com/user/repos?page=3&per_page=100>; rel="next", <https://api.github.com/user/repos?page=50&per_page=100>; rel="last"

이에 대한 내용은 GitHub - Using pagination in the REST API에서 더 자세히 살펴볼 수 있다.

그러나 많은 API가 사용 가능한 총 결과 수와 같은 추가적인 pagination information을 반환하기를 원하기 때문에 이것이 완전한 solution은 아니다. Count를 전달해야 하는 API는 X-Total-Count와 같은 custom HTTP header를 사용할 수도 있다.


관련된 resource representations를 자동으로 불러오는 방법을 제공하라

API consumer가 요청중인 resource와 관련된 (또는 resource에서 참조되는) data를 load 해야 하는 경우가 많다. Consumer가 이 information을 얻기 위해 반복적으로 API를 hit 하도록 요구하는 대신, 관련 data 요청 시 original resource와 함께 return 되도록 허용한다면 상당한 효율성이 있을 것이다.

그러나, 이는 일부 RESTful principles에 위배되므로 embed (or expand) query parameter 기반으로만 사용함으로써 편차를 최소화 할 수 있다.

이 경우, embed는 embed 되는 fields의 comma separated list이다. 또한 sub-fields를 참조하기 위해 dot-notation을 사용할 수 있다. 예를 들어 GET /tickets/12?embed=customer.name,assigned_user처럼 하면 다음과 같은 additional details가 포함된 ticket이 return된다.

{
  "id" : 12,
  "subject" : "I have a question!",
  "summary" : "Hi, ....",
  "customer" : {
    "name" : "Bob"
  },
  assigned_user: {
   "id" : 42,
   "name" : "Jim",
  }
}

물론 이러한 것들을 구현하는 능력은 실제로 내부 복잡성에 달려 있다. 이러한 종류의 embedding은 쉽게 N+1 select issue(N+1 problem)로 이어질 수 있다.


HTTP method를 override 하는 방법을 제공하라

일부 HTTP client는 간단한 GET, POST 요청만 처리할 수 있다. 제한된 clients에 대한 접근성을 높이려면 API에 HTTP method를 override 할 수 있는 방법이 필요하다. 이에 대해서는 명확한 표준이 없지만, 일반적으로 PUT, PATCH 또는 DELETE 중 하나가 포함된 문자열 값으로 X-HTTP-Method-Override를 받는 것이 일반적이다.

Overrider header는 반드시 POST 요청에서만 허용되어야 한다. GET 요청은 절대 서버의 데이터를 변경해서는 안 된다!


Rate limiting에 유용한 response headers를 제공하라

Abuse를 방지하기 위해 API에 속도 제한을 추가하는 것이 표준 관행이다. RFC 6585는 이를 수용하기 위해 HTTP status code 429 Too Many Requests를 도입했다.

하지만 실제로 limit에 도달하기 전에 소비자에게 limit을 알려주는 것은 매우 유용할 수 있다. 이 영역은 현재 표준이 부족하지만 HTTP response header를 사용하여 널리 사용되는 여러 규칙이 있다.

최소한 다음 header를 포함할 것을 권장한다.

  • X-Rate-Limit-Limit: 현재 period 동안 허용된 요청 수
  • X-Rate-Limit-Remaining: 현재 peroid에서 남은 요청 수
  • X-Rate-Limit-Reset: 현재 period에서 남은 시간(초)

X-Rate-Limit-Reset에 time stamp 대신 남은 시간(초)이 사용되는 이유는 무엇인가?

Timestamp에는 날짜 및 시간대와 같이 유용하지만 불필요한 모든 종류의 정보가 포함되어 있다. APU consumer는 언제 다시 요청을 보낼 수 있는지만 알고 싶을 뿐이며, 초 단위의 남은 시간은 최소한의 추가 처리로 이 질문에 대한 답을 제공한다. 또한 clock skew와 관련된 문제도 방지할 수 있다.

일부 API는 X-Rate-Limit-Reset에 UNIX timestamp(seconds since epoch)를 사용하기도 하는데, 이는 좋지 않다. 이렇게 하지 말 것!

X-Rate-Limit-Reset에 UNIX timestamp를 사용하는 것이 왜 나쁜 예시인가?

HTTP spec은 이미 RFC 1123 날짜 형식을 사용하도록 명시하고 있다(현재 Date, If-modified-Since, Last-Modified HTTP headers). 만약 timestampe를 사용하는 새로운 HTTP header를 지정하려면 UNIX timestamp를 사용하는 대신 RFC 1123 규칙을 따라야 한다.


Authenticaton

RESTful API는 stateless 해야 한다. 이는 request authentication이 cookie나 session에 의존하지 않아야 함을 의미한다. 대신, 각 request에는 일종의 authentication credentials(자격 증명)가 제공되어야 한다.

항상 SSL을 사용하면 authentication credentials를 HTTP basic auth의 user name field에 전달되는, 무작위로 생성된 access token으로 간소화 할 수 있다. 이 방법의 가장 큰 장점은 브라우저에서 완벽하게 탐색 가능하다는 것이다. 즉, 브라우저는 서버에서 401 Unauthorized status code를 수신하면 credentials에 대해 묻는 prompt를 표시할 수 있다.

그러나 이런 token-over-basic-auth 인증 방법은 user가 관리자 인터페이스에서 API consumer 환경으로 token을 복사하는 것이 실용적인 경우에만 허용된다. 이것이 가능하지 않은 경우에는 OAuth2를 사용하여 제 3자에게 안전한 token 전송을 제공해야 한다. OAuth2는 Bearer tokens를 사용하며 전송 암호화를 위해 SSL에 의존한다.

JSONP를 지원해야 하는 API는 JSONP 요청이 Http basic auth credentials나 Bearer tokens를 전송할 수 없으므로 세 번째 인증 방법이 필요하다. 이 경우 special query parameter access_token을 사용할 수 있다. 참고로 대부분의 웹 서버는 query parameter를 서버 로그에 저장하므로 token에 대해 query parameter를 사용하는 데는 보안 이슈가 있다.

위의 세 가지 방법은 모두 API boundary를 넘어 token을 전송하는 방법일 뿐이지, 실제 token 자체는 동일할 수 있다.


Caching

HTTP는 built-in caching framework를 제공한다. 추가적인 outbound response headers를 추가하고 inbound request headers를 수신할 때 약간의 검사를 수행하기만 하면 된다.

여기에는 ETagLast-Modified라는 두 가지 접근 방식이 존재한다.

ETag

Response를 생성할 때, representation의 hash 또는 checksum이 포함된 HTTP header ETag를 포함한다. 이 값은 output representation이 변경될 때마다 변경되어야 한다. 이제 inbound HTTP requests에 일치하는 ETag 값과 함께 If-None-Match header가 포함된 경우, API는 resource의 output representation 대신 304 Not Modified status code를 반환해야 한다.

Last-Modified

Timestamp를 사용한다는 점을 제외하면 기본적으로 ETag와 비슷하게 작동한다. Response header Last-Modified에는 RFC 1123 형식의 timestamp가 포함되며, 이 timestamp는 If-Modified-Since와 비교하여 유효성을 검사한다. HTTP spec에는 허용되는 세 가지 날짜 형식이 있으며 서버는 이 중 하나를 사용할 수 있도록 해야 한다.


Errors

HTML error page가 방문자에게 유용한 error message를 보여주는 것처럼 API는 유용한 에러 메시지를 known consumable format으로 제공해야 한다. Error의 representation은 고유한 fileds의 set이 있는 어떠한 resource의 representation과 다르지 않아야 한다.

API는 항상 적절한 HTTP status code를 return 해야 한다. API error는 일반적으로 두 가지 유형으로 나뉜다: client issues에 대한 400 series status codes & server issues에 대한 500 series status codes. API는 최소한 모든 400 series error가 consumable JSON error representation과 함께 제공되도록 표준화 해야 한다. 또한 가능한 경우(i.e. load balancers & reverse proxies가 custom error bodies를 생성할 수 있는 경우), 500 series status codes로 확장되어야 한다.

JSON error body는 개발자를 위해 몇가지 항목을 제공해야 한다 - 유용한 에러 메시지, unique한 에러 코드(문서에서 더 자세한 내용을 찾아볼 수 있는)와 가능한 한 상세한 설명. JSON output representation은 다음과 같다.

{
  "code" : 1234,
  "message" : "Something bad happened :(",
  "description" : "More details about the error here"
}

PUT, PATCH, POST requests에 대한 validation errors는 field breakdown(분석, 명세)이 필요하다. 이는 validation failures에 대해 고정된 상위 에러 코드를 사용하고 다음과 같이 추가적인 errors field에 상세한 errors를 제공하여 설계된다.

{
  "code" : 1024,
  "message" : "Validation Failed",
  "errors" : [
    {
      "code" : 5432,
      "field" : "first_name",
      "message" : "First name cannot have fancy characters"
    },
    {
       "code" : 5622,
       "field" : "password",
       "message" : "Password cannot be blank"
    }
  ]
}

자주 사용하는 HTTP status codes

HTTP는 API에서 응답할 수 있는 여러가지 의미 있는 status code를 정의한다. 이것들을 활용하여 API consumer는 응답을 적절하게 routing 할 수 있다. 다음은 자주 사용되는 HTTP status code 목록을 간략하게 정리한 것이다.

  • 200 OK - 성공적인 GET, PUT, PATCH or DELETE에 대한 response. 또한 무언가를 생성하지 않은 POST에 대해서도 사용될 수 있다.
  • 201 Created - 무언가를 생성하는 POST에 대한 응답. 새로운 resource의 위치를 가리키는 Location header와 함께 사용되어야 한다.
  • 204 No Content - body를 반환하지 않는 성공적인 응답 (like a DELETE request).
  • 304 Not Modified - HTTP caching header가 동작되고 있을 때 사용된다.
  • 400 Bad Request - body가 파싱되지 않는 경우처럼 요청이 잘못된 경우.
  • 401 Unauthorized - 인증 관련 정보가 없거나 유효하지 않은 경우. 또한 API가 브라우저에서 사용되는 경우, 인증 관련 팝업 발생시키는 데에도 유용하다.
  • 403 Forbidden - 인증에는 성공했으나, 요청한 사용자가 resource에 접근할 수 없거나 접근 권한이 없는 경우.
  • 404 Not Found - 존재하지 않는 resource가 요청된 경우.
  • 405 Method Not Allowed - 인증되었으나, 요청한 사용자에게 허용되지 않는 HTTP method가 요청된 경우.
  • 410 Gone - 이 endpoint의 resource를 더 이상 사용할 수 없음을 나타낸다. 오래된 버전의 API들에 대한 포괄적인 응답으로 유용하다.
  • 415 Unsupported Media Type - 요청의 일부로 잘못된 content type이 제공된 경우.
  • 422 Unprocessable Entity - validation error들에 사용됨.
  • 429 Too Many Requests - rate limiting으로 인해 요청이 거부된 경우.

결론

이렇게 RESTful API를 설계할 때 고려할 점들에 대해 살펴보았습니다. 물론 이러한 사항들을 모두, 반드시 지켜야 할 필요는 없습니다. 결국 API를 설계하는 개발자가 고민하고 결정하는 것이지요. 하지만 RESTful API를 설계하고자 하는 개발자라면 이러한 사항들에 대해 살펴보고, 어떻게 설계해야 하는지에 대한 고민을 해보는 과정은 반드시 필요하지 않을까 싶습니다.

References

4개의 댓글

comment-user-thumbnail
2023년 7월 25일

You've made some really good points there. I checked on the net for more info about the issue and found most people will go along with your views on this site. https://gab.com/rtyhgp/posts/110655424025395500

답글 달기
comment-user-thumbnail
2023년 7월 26일

좋은 글 잘 읽고 갑니다!

1개의 답글
comment-user-thumbnail
2023년 7월 29일

잘읽고 갑니다 ~

답글 달기