네트워크가 API 디자인에 어떤 영향을 미치는가?

느린 네트워크는 사용자 인터페이스를 느리게 만들고 장치의 배터리를 빠르게 소모시킬 수 있습니다. 그리고 컨슈머가 API를 사용하는 데에 어려움을 느끼게 되므로 사용자 경험에도 부정적인 영향을 미칠 수 있습니다. 따라서 API를 디자인할 때 네트워크 커뮤니케이션의 효율을 고려해야 합니다.

네트워크 효율성은 네트워크 속도, 데이터의 크기, 호출 횟수에 따라 달라집니다.

네트워크 속도

네트워크 속도가 느리면 API를 사용하는 데 어려움을 겪을 수 있습니다. 이런 것을 개선하기 위해서는 지속적인 연결 , 압축 , 캐싱 , 조건부 리퀘스트 를 사용하여 개선할 수 있습니다.

예를 들어 응답하는 데이터를 압축하여 전송하면 데이터의 양이 줄어들어 더 효율적인 네트워크 커뮤니케이션이 됩니다.

데이터의 크기

API의 출력의 크기가 크면 네트워크로 전송해야 되는 데이터의 크기도 증가하므로 네트워크가 비효율적이게 됩니다.

예를 들어 회고 작성까지 완료된 예약 목록을 보고 싶어서, 예약 목록 조회 API를 만들었다고 가정해 봅시다. 조회 로직을 회고 작성 여부와 관계없이 모든 예약을 불러오도록 짰다면 어떨까요? 회고가 작성되지 않은 예약까지 불러오게 되므로 데이터의 양이 불필요하게 증가하게 될 것입니다. 이런 경우 회고가 작성된 예약만 불러올 때보다 속도가 느려질 수 있겠죠.

이런 경우 컨슈머 관점에 초점을 맞춰서 보다 연관성 있는 데이터와 목표를 제공함으로써 개선할 수 있습니다. 혹은 데이터 쿼리를 활성화하여 전송되는 데이터 크기 자체를 줄일 수도 있습니다.

호출 횟수

API 호출이 빈번하면 그만큼 네트워크로 전송해야 하는 데이터의 양이 증가하게 됩니다.

예를들어 사용자가 작성한 댓글 목록을 보여주려고 합니다. 사용자는 댓글에 달린 답글도 한 번에 보고싶어 합니다. 그런데 댓글 목록을 가져오는 API와 답글 보는 API가 따로 존재한다면, 컨슈머는 댓글 목록을 조회하고, 각 댓글마다 답글을 조회해야 하므로 호출횟수가 증가하게 됩니다. 따라서 댓글을 조회할 때 이미 답글이 포함된 댓글을 응답한다면, 불필요한 호출 횟수를 줄일 수 있습니다.

네트워크 커뮤니케이션의 효율성을 보장하려면 어떻게 해야 하는가?

네트워크 커뮤니케이션의 최적화는 프로토콜 레벨에서부터 시작합니다. HTTP 기반의 API라면, 압축을 활성화하고, 지속적인 연결을 통해 데이터 크기와 처리 지연 문제를 감소시킬 수 있습니다. 캐싱조건부 리퀘스트를 활성화하면 단순히 데이터 크기뿐만 아니라 호출 횟수도 감소시킬 수 있습니다.

압축과 지속적인 연결 활성화

HTTP 기반의 API에서는 디자인에 영향을 주지 않고 수행할 수 있는 일반적인 최적화 방법인 압축과 지속적인 연결 활성화하는 방법에 대해서 알아보겠습니다.

압축(Compression)은 말 그대로 데이터를 압축하는 것입니다. 데이터가 작아지면 호출이 더 짧아지고, 최종적으로 사용자의 데이터 요금 제도 위협하지 않습니다. 또한 컨슈머 영역에도 도움이 됩니다. 더 작은 데이터의 크기는 네트워크 연결이 더 적은 시간 동안 개방되므로 대역폭 문제가 발생할 가능성이 줄어듭니다. 만약 클라우드 인프라 환경이라면 청구되는 비용을 줄일 수 있게 됩니다.

지속적인 HTTP 연결이 활성화 되면, API 호출의 처리 지연은 첫 성립 과정에서만 발생하게 됩니다. 그 후에 발생하는 호출은 이미 맺어진 연결을 사용합니다. 이 연결은 서버 설정에 따라 지정된 호출 횟수 동안만 유효하거나, 주어진 시간 동안만 유효하게 동작하게 됩니다.

또 다른 대안으로 HTTP/2로 바꾸는 방법도 있습니다. 효율적인 지속적인 연결과 병렬 리퀘스트 처리를 지원하며, 바이너리 트랜스포트를 제공하며, HTTP/1.1의 하위 호환도 지원합니다.

효율성을 고려한 커뮤니케이션 최적화는 디자인에 영향을 미치기 때문에 처음부터 수행해야 합니다. 실제로 컨텍스트에 따라서는 다른 유형의 API를 사용하거나 아예 다른 커뮤니케이션 방법을 사용해야 할 수도 있습니다.

캐싱과 조건부 리퀘스트 활성화

커뮤니케이션을 최적화하는 두 번째 방법은 단순하게 커뮤니케이션을 하지 않거나 더 적게 하는 방법입니다.

캐싱은 네트워크 커뮤니케이션을 보다 효율적으로 하는 데 가장 큰 도움을 줍니다. 컨슈머들은 API 리스폰스를 캐시하도록 선택할 수 있지만, API에서 정확하고 효율적인 캐싱을 하도록 도와줄 수 있습니다. API 디자이너는 어떤 데이터가 얼마나 오랫동안 캐싱되어야 하는지 산정할 책임이 있습니다. 또한 데이터의 최신 여부와 정합성을 보장해주는 역할도 합니다.

이를 HTTP 프로토콜을 이용해서 어떻게 달성하게 되는지 알아보겠습니다. 컨슈머가 GET 요청으로 어떤 리소스에 대한 정보 조회를 요청한 후, 프로바이더는 리스폰스에 Cache-ControlETag내용이 들어있는 HTTP 헤더와 데이터를 반환합니다.

200 OK
Cache-Control: max-age=300
ETag: "z567dff"

Cache-Controlmax-age는 캐시가 얼마 동안 보존될 수 있는지를 의미합니다. 위의 max-age=300은 300초(5분)동안 캐시 되어 보존될 수 있다는 뜻입니다. 그리고 5분이 지나고 나면 이 캐시는 만료될 것입니다. 캐시가 만료되면 컨슈머는 다시 GET 요청으로 리소스를 요청해야 합니다.

하지만 처음처럼 완전히 새로운 리퀘스트를 던지는 대신, 조건부 리퀘스트를 요청합니다. “데이터가 변경되었다면 보내주세요” 하고 요청하는 것이죠. 바로 이때 ETag를 사용합니다. 최초에 반환받은 리스폰스의 ETag 값을 두 번째 리퀘스트 If-None-Match 헤더에 담아 보내면 됩니다.

GET /reservations/32
If-None-Match: "z567dff"

이제 서버가 위의 요청을 수신하면, 전달받은 ETag 값을 이용해 데이터가 변경되었는지를 판단합니다. 보통 ETag 값은 데이터의 해시나 일자 또는 버전 번호, 서버가 전에 어떠한 버전의 리소스를 제공할 수 있는 무언가를 의미합니다.

만약 데이터가 변경되지 않았다면, 서버는 다른 데이터 없이 304 Not Modified 리스폰스를 보내어 불필요한 데이터 로딩을 절약할 수 있습니다. 컨슈머는 캐시 만료 시간을 갱신하고, 처음 호출로 받았던 만료시간은 바뀌게 됩니다. 데이터가 변경되었다면, 200 OK 상태와 함께 갱신된 데이터와 Cache-Control, ETag 헤더를 함께 리스폰스 합니다.

만약 애플리케이션이 오직 데이터의 변경 여부에만 관심이 있고, 실제로 데이터를 가져오는 것에는 관심이 없다면 GET대신 HEAD 리퀘스트를 사용할 수 있습니다. HEAD HTTP 메서드는 GET과 동일하지만 서버가 리소스의 콘텐츠는 반환하지 않고 오직 헤더만 반환합니다.

캐싱에서 어려운 부분은 캐싱의 가능성에 대해서 목표별로 따져야 한다는 점입니다. 즉, 목표별로 반환되는 속성들 별로 계산해 볼 필요가 있습니다.

데이터의 캐시 여부는 ETag, Cache-Control 외의 다른 요소에도 영향을 받습니다. 예를 들자면, 부정확한 잔액을 보여주게 되면 서드파티의 애플리케이션이라 할지라도, 법이나 보안적인 이유로 문제를 야기할 수 있습니다.

캐시 정책 선택하기

데이터가 캐시될 수 있는 시간은 얼마나 자주 수정되는지에 달려 있습니다. 금융 회사는 통계적으로 5분짜리 캐시가 계좌 정보를 가져올 때 정확성과 효율성 사이에서 적절한 균형을 제공한다고 판단했었습니다. 그렇지만 머지않은 미래에 모든 은행 간의 커뮤니케이션이 실시간으로 이루어지고, 사람들이 항상 은행에서 정보를 실시간으로 얻는 데 익숙해지면 데이터 캐싱은 전혀 불가능해질 수 있습니다. 효율적이고 정확한 캐싱을 위해서는 실제로 캐싱이 가능한지 판단해야 하며, 가능한 경우에는 선택할 수 있는 유리한 캐시 기간을 결정하기 위한 작업이 필요합니다.

디자인 레벨에서 네트워크 커뮤니케이션 효율성 확보하기

컨슈머와 프로바이더 간의 커뮤니케이션이 프로토콜 수준에서 최적화될 수 있다고 해서 디자인 수준에서 부주의해도 된다는 것은 아닙니다. 기본적으로 API 디자인에는 컨슈머가 목표를 달성하는 데 필요한 API 호출 횟수와 컨슈머와 프로바이더 간에 주고받는 데이터의 크기를 포함하고 있습니다.

더 적은 데이터를 더 적게 호출하도록 만드는 API 디자인 최적화 과정

적은 데이터를 가져오는 방법

데이터의 크기를 절약하기 위해서는 정말 필요한 것들만 요청하는 방법인 필터링이 있습니다. 매번 필요한 모든 데이터를 제공해 주는 것은 좋지 않습니다. 컨슈머들이 처한 상황에 따라 모든 데이터가 꼭 필요하지는 않기 때문입니다. 필터링을 선택지로 제공하면 컨슈머가 실제로 필요한 데이터만 리퀘스트 할 수 있으므로 API를 보다 유용하고 효율적으로 사용할 수 있습니다. 어떤 종류의 필터를 이용해야 우리가 처한 문제를 해결할 수 있을까요?

컨슈머라면 누구든 기본적으로 그들이 선택한 만큼 정확히 가져올 수 있어야 합니다. 예시를 들어보겠습니다. 코드숨 공부방 예약 목록에서 페이지 처리를 통해 선택한 페이지만큼 만의 목록을 가져와 보겠습니다.

최근 한 달 동안 공부방 이용한 회원의 예약 전체 조회를 해보겠습니다.

// GET /reservations

{
  "reservations": [
    {
      "id": 1,
      "username": "김철수",
      "plan": "코테풀기,계획 세우기",
      "retrospective": {
        "id": 1,
        "content":  "잘했다.!"
      }
    },
    {
      "id": 2,
      "username": "이순신",
      "plan": "코테풀기,계획 세우기",
      "retrospective": {
        "id": 2,
        "content": "잘했다.!"
      }
    },
    // ....
    {
      "id": 100,
      "username": "김영희",
      "plan": ",계획 세우기",
      "retrospective": {
        "id": 100,
        "content": "잘했다.!"
      }
    }
  ],
}

첫 번째 예시에서는 전체 목록이 100건 이상의 예약 목록 API가 호출됩니다. 하지만 이 경우에는 사용자는 모든 내용을 보기 위해서는 스크롤을 아래로 한참 내려가야 합니다. 그뿐만 아니라 캐싱이 안 된 상황이라면 데이터 크기도 커집니다. 이 경우 네트워크 상황에서도 커뮤니케이션 비효율성을 줄 수 있습니다.

// GET /reservations?page=1&size=10

// 전체 조회  10 페이징 처리
{
  "reseravtions": [    
    {
      "id": 1,
      "username": "김철수",
      "plan": "코테풀기,계획 세우기",
      "retrospective": {
        "id": 1,
        "content":  "잘했다.!"
      }
    },     
    {
      "id": 2,
      "username": "이순신",
      "plan": "코테풀기,계획 세우기",
      "retrospective": {
        "id": 2,
        "content": "잘했다.!"
      }
    },
    // ...
  ],
  "pagination": {
    "page": 1,
    "size": 10,
    "totalPages": 3
  }
}

매번 호출할 때마다 많은 데이터를 불러오는 것은 좋은 방법이 아닌 거 같습니다. 매 상황마다 예약된 회원의 목록을 불러올 필요는 없습니다. 이 경우에는 필터링을 사용해서 데이터 크기와 속도를 향상할 수 있습니다.사용자는 한 달 분량의 예약 목록을 스크롤을 할 필요 없이 내용을 확인할 수 있습니다.

필터링이 하는 것은 교환 데이터의 양을 줄이고 사용성을 향상할 수 있는 전략 중 하나입니다.

적게 호출하는 방법

데이터 집합체 이용하기

잘 정제된 리소스는 유연하고 가치 있는 방법으로 다른 형태의 데이터 콘셉트의 부분집합을 제공해 주지만 컨슈머가 많은 API 호출을 하는 상황이 생길 수 있습니다. 예를 들어 코드숨 공부방 사용자의 정보와 회원의 예약 내역을 같이 사용해야 하는 경우가 있다면 회원정보 API , 예약 내역 API 가 따로 있다면 2개의 API 호출이 필요할 것입니다.

// GET /user
{
  "id": "codesoom@mail.com"
  "name": "코드숨",
  "adress": "서울 특별시"
}

// GET /reservations
[
  {
    "id": 1,
    "date": "2022-10-28",
    "plan": "오늘은 자바스크립트!"
  },
  {
    "id": 2
    "date": "2022-10-29",
    "plan": "오늘은 깃!"
  },
  {
    "id": 3
    "date": "2022-10-30",
    "plan": "오늘은 알고리즘!"
  },
]

예약 내역을 사용자 데이터에 포함시켜 모든 데이터를 가져오는 것은 어떨까요. 예약 내역은 정기적으로 변화가 됩니다. 회원의 데이터는 데이터양이 비교적 적기 때문에 예약 내역을 회원 데이터는 통합하는 것이 더 좋다고 생각이 듭니다.

// GET /user

{
  "id": "codesoom@mail.com"
  "name": "코드숨",
  "adress": "서울 특별시"
  "reservations": [
    {
      "id": 1,
      "date": "2022-10-28",
      "plan": "오늘은 자바스크립트!"
    },
    {
      "id": 2,
      "date": "2022-10-29",
      "plan": "오늘은 깃!"
    },
    {
      "id": 3,
      "date": "2022-10-30",
      "plan": "오늘은 알고리즘!"
    }
  ]
}

집합체가 구성된 데이터의 수명은 모든 개별 속성 중에서 가장 작은 수명을 지니는 속성의 수명과 같습니다.

따라서 예약 내역이 변경된다면 소비자는 많은 양의 데이터를 다시 로드 해와야 합니다. 대수롭게 보일 수도 있지만 적대적인 네트워크 조건에서는 한 번에 매우 오랫동안 리퀘스트를 하는 것이 짧게 여러 번 호출하는 것보다 문제가 발생하기 쉽습니다.

성능적인 부분에서 집합체는 사용성 측면에 영향을 줄 수 있습니다.

데이터를 집합체로 구성하는 것은 가능한 커뮤니케이션 성능 문제에 대한 유효한 솔루션이 될 수 있지만 모든 시사점을 잘 파악하고 이해한 상태에서 신중하게 수행해야 합니다.

적은 데이터를 가져오면서 호출도 적게하는 방법

쿼리 활성화하기

데이터 크기를 줄이고, 가능하면 API 호출 횟수까지도 줄일 수 있는 API를 만들 수 있습니다. 데이터 크기를 줄이기 위해 보다 복잡한 쿼리가 필요한 경우라면 이미 존재하는 쿼리 언어를 사용하는 방법이 있습니다. REST만이 유일한 API가 아닙니다. GraphQL을 예를 들겠습니다. 컨슈머가 그들이 원하는 데이터를 스스로 구성하고 요청하는 쿼리 언어입니다. GrapghQL은 다음과 같이 스스로를 규정짓습니다.

API에 대한 쿼리 언어이자, 기존 데이터로 이러한 쿼리에 대응하는 런타임

https://grapql.org

REST API를 이용해 집합체를 구성하지 않고 제공하려면 여러 가지 API를 연쇄적으로 호출해야 하지만 GraphQL을 이용한다면 한 번에 호출할 수 있습니다.

하지만 데이터 쿼리를 활성화하는 것은 어떠한 경우에는 적절한 방법이지만 그렇지 않을 수도 있습니다. 데이터 쿼리의 활성화는 전송되는 데이터 크기와 API 호출 횟수를 줄일 수 있지만 캐싱의 여지마저 감소시켜버리기 때문입니다. 그러므로 단순히 적게 데이터를 가져오고 호출한 다는 이유로 API 타입을 REST에서 GraphQL로 변경하는 것은 재고할 필요가 있습니다.

사용성이 뛰어나고 네트워크 효율도 뛰어난 API 디자인을 제공하려면 단순히 어떤 데이터를 목록에 넣어서 반환할지라던가 리소스를 어떻게 쪼갤지에 대한 고민으로는 부족합니다. 연관성 있는 데이터와 목표를 제공하는 것이 이러한 디자인을 제공하는 데 있어서 중요한 열쇠입니다.