팀 프로젝트를 하다보면 다음과 같은 요청을 받기도 할 것이다.
여러 번 요청을 보내면 네트워크 리소스 비용이 더 많이 소모되니까. 그냥 한 번에 보내주세요.
이 얘기를 들으면 다음과 같은 생각이 들 것이다.
- “그래도 되나?”
- “괜찮아 보이기도 하고..”
- “뭔가 안 괜찮을 거 같은데 명확한 이유를 모르겠네..”
이러한 생각이 드는 이유는 좋은 API에 대한 기준이 스스로 확립이 안되었기 때문일 것이다.
API와 API 설계
API란 Application Programming Interface의 약자를 의미한다. 여기서 Interface는 시스템 간 정보를 교환할 수 있도록 공유되는 경계면을 말한다. 그렇다면 API는 어디와 어디 사이의 경계면일까?
그것은 바로 API를 요청하는 클라이언트와 응답하는 서버의 경계면이다. 따라서 이 둘 간의 경계면을 잘 설정하는 것이 중요하다.
사실 우리는 이미 코드 레벨에서 다른 요소 간의 경계 설정을 해왔었다. 바로 단일 책임, 모듈화, 관심사의 분리 같은 방법으로 말이다. 그래서 API도 클라이언트와 서버라는 다른 요소 간의 정보 전달이기 때문에 설계 단계에서 적절한 분리 지점을 고민하는 것이 중요하다. 왜냐하면 잘못된 경계를 설정했을 때, 사용하기 어려운 API가 될 수 있기 때문이다.
그럼 어떻게 하면 API를 잘 설계할 수 있을까?
GitHub의 API 설계 원칙
GitHub의 API 설계 원칙을 통해서 알아보자.
Pull Request API를 개발한다고 가정해보자.
Pull Request를 생성하려면, 다음과 같은 행위가 필요하다.
- 제목을 입력한다.
- 본문을 입력한다.
- 작업한 브랜치와 병합 대상 브랜치를 설정한다.
- Assigee를 배정한다.
- Label을 지정한다.
이 행위를 API로 옮기면 다음과 같은 형태의 API로 표현할 수 있을 것이다.
POST /pull-request
{
"title": "PR 제목",
"content": "PR 본문",
"head": "step1",
"base": "main",
"assignees": ["john"],
"labels": ["bug"]
}한 번에 요청에 title, content, head, base, assignees, label의 정보를 담았다.
이렇게 행위에 대해 한 번에 처리하기 때문에 클라이언트가 사용하기 편리할 것이다.
하지만, 이렇게 타이트하게 해놓으면 추후 요구사항 추가나 수정 시 유연성이 저하되는 문제가 발생할 것이다. 예를 들어, Draft PR 기능을 추가한다고 가정해보자.
기존 GitHub의 Draft PR 기능은 Create pull request 후 Convert to draft 버튼을 클릭해 Draft PR로 전환하는 기능을 의미한다. 실제로 Merge 할 의도는 없고, 코드 검토만 받고 싶을 때 사용되는 기능이다. 실제로 버튼을 클릭하면 PR은 Draft 상태로 변경이 되고 Merge pull request가 비활성화된다. Ready for review를 클릭해야 다시 원래의 PR 상태로 돌아갈 수 있다.
이렇게 기존의 PR과 Draft PR은 성질이 다르기 때문에 기존 API를 사용한다면 Draft PR에 필드를 추가해서 수정을 할 것이다.
{
"title": "PR 제목",
"content": "PR 본문",
"head": "step1",
"base": "main",
"assignees": ["john"],
"labels": ["bug"],
"draft": true
}이런 식으로 말이다.
근데 만약에 이러한 상황에서 Draft 기능을 개발하던 중에 문제가 발생하여 API가 정상 동작하지 않는다면 어떻게 될까? PR 생성만 실패를 하는 게 아니라 assignee 배정과 label 설정도 실패하게 된다.
또 다른 문제가 발생할 수 있다. 요구사항이 변경되어 PR을 생성한 이후에도 assignee나 label을 설정할 수 있도록 해야한다면, PR 생성 API와 assignee 생성 API 그리고 label 생성 API들을 전부 새로 만들어야 하고, 심지어 기존 API는 재활용이 불가능할 것이다.
하지만 만약에 처음부터 API를 조합 가능한 작업 단위로 분리를 하면 다양한 워크플로우에 유연하게 대처할 수 있다. ‘a, b, c’의 값을 설정한다는 행위가 아닌 ‘a’, ‘b’, ‘c’라는 대상 자체에 집중을 해서 API를 설계한다면 상황에 따라 ‘a’, ’c’, ’b’나 ‘b’, ‘c’, ‘a’ 처럼 API를 조합할 수 있다. 이 조합 가능한 작은 단위 즉 대상을 리소스라고 명시하겠다.
리소스에 따라 분리하기
기존 행위 즉 워크플로우에서 대상이 될 수 있는 것들을 분리한다.
워크플로우(행위)
- 제목을 입력한다.
- 본문을 입력한다.
- 작업한 브랜치와 병합 대상 브랜치를 설정한다.
- Assigee를 배정한다.
- Label을 지정한다.
리소스(대상)
- 제목
- 본문
- 작업한 브랜치 병합 대상 브랜치
- Assigee
- Label
사실 이중에서도 PR의 주속성과 부속성을 분리할 수 있다. 제목, 본문, 작업 브랜치, 병합 대상 브랜치는 PR의 생성의 상태에 관려하기 때문에 PR의 주속성이다. 하지만 Assignee와 Label은 각각 생성 시점이 유연하게 조정될 수 있고, 이것들의 생성에 실패해도 PR은 생성할 수 있기 때문에 PR의 부속성으로 볼 수 있다.
이렇게 분리한 리소스를 API 스펙으로 구현하면 다음과 같이 나타낼 수 있을 것이다.
POST /pull-requests
{
"title": "PR 제목",
"content": "PR 본문",
"head": "step1",
"base": "main"
}POST /issues/{id}/assignee
{
"assignees": ["john"]
}POST /issues/{id}/label
{
"labels": ["bug"]
}Pull Request는 title, content, head, base를 가지고, Assignee와 Label은 별도의 API로 각각의 설정을 한다.
실제로 GitHub는 Pull Request 기능을 제공할 때 이러한 형식과 유사한 방식으로 API를 분리하고 조합해서 다양한 워크플로우에 유연하게 대처하고 있다.
API를 잘 설계하는 방법
API를 잘 설계하려면 적절한 분리 지점을 찾아야 한다. 그렇기 위해서는 행위에 집중하기보다는 특정 대상, 즉 리소스에 집중하면 경계를 보다 쉽게 찾을 수 있다. 그러면 API를 클라이언트가 정해진 순서대로만 써야 하는 도구가 아닌 사용자가 원하는 시나리오에 맞게 조립할 수 있는 유연한 구성요소로서 사용할 수 있게 된다.
성능 최적화와 API 분리
앞선 내용대로 API를 분리하는 건 이해가 될 것이다. 하지만 이렇게 했을 때 성능상 문제는 생기지 않을까라는 의문과 걱정이 생길 것이다. 왜냐하면 요청이 많아지면 그만큼 네트워크와 서버의 부하가 클 수 있기 때문이다.
하지만 GitHub는 여러 번 API를 호출하는 건 불가피하고 대신 빠르게 요청을 처리하는 방향을 선택했다. 그래서 다음과 같은 기술들을 통해서 성능 문제를 해결하고 있다.
- 리소스를 분리하는 것 자체만으로도 Json의 Payload가 수백 바이트 이하로 떨어지기 때문에 요청이 가벼워져 네트워크 부담이 줄어들고 DB I/O도 감소하게 된다.
- 부담되는 쓰기 요청에 대해 비동기 처리를 해서 서버와 DB의 부하를 감소시킨다.
- 데이터 조회 시 GraphQL을 활용하여 필요한 경우에는 한 번에 데이터를 가져와서 네트워크 통신도 감소시킨다.
실제로 GitHub Engineering 블로그에 따른 REST API의 대부분은 1ms 이하의 CPU 시간을 사용한다고 한다. 또한 초당 수십만 요청을 처리하면서도 DB의 쓰기 지연은 95%(P95)가 15ms 이하라고 한다. 이를 통해 API 분리와 성능 최적화는 양립할 수 있다는 것을 알 수 있다.