[TeamBlog] 이 API는 RESTful의 정도가 이븐하네요 : Devths에서의 API 설계 고민 과정 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki

들어가며

안녕하세요! 개발자 취준생을 위한 이력서 분석 및 커뮤니티 서비스, Devths의 백엔드 개발을 맡은 yun입니다.

해당 프로젝트를 진행하며 본격적인 개발 시작 전 API를 설계했는데요. 탄탄한 설계가 뒷받침되어야 추후에 불필요한 변경이 최소화되므로 나름의 기준을 세우고 진행하고자 했습니다.

기준이라고 하면 여러 가지가 있겠지만, 저는 API하면 가장 먼저 떠오르는 ‘RESTful’이라는 키워드에 초점을 맞춰 보았습니다.

해당 페이지는 REST와 RESTful이 어떻게 다른지, 우리 서비스 관점에서 RESTful API는 어떻게 작성해야 할지 고민했던 과정을 기록해둔 문서입니다.

REST? RESTful?

API를 설계하기 전에 매번 언급되는 용어 두 가지를 간단하게 정리해 보았습니다.

REST와 RESTful은 언뜻 보기에 비슷해 서로 대치가 가능한 용어처럼 보이지만, 엄밀히 따지고 보면 사용처가 다르다는 것을 알게 되었습니다.

REST

REST는 ‘Representational State Transfer’의 약어입니다. 직역하자면 ‘표현적인 상태 전달’인데요, 어떤 상태를 어떻게 표현해서 누구에게 전달한다는 것일까요?

미국의 컴퓨터 공학자인 Roy Fielding 박사는 이렇게 정의했습니다 :

REST components communicate by transferring a representation of a resource in a format matching one of an evolving set of standard data types

REST 컴포넌트들은 지속적으로 확장·발전하는 표준 데이터 타입 집합 중 하나에 부합하는 형식으로 표현된 리소스의 표현(representation)을 전송함으로써 서로 통신한다.

The key abstraction of information in REST is a resource. Any information that can be named can be a resource: a document or image, a temporal service (e.g. "today's weather in Los Angeles"), a collection of other resources, a non-virtual object (e.g. a person), and so on.

REST에서 정보의 핵심 추상화 단위는 리소스이다. 이름을 부여할 수 있는 모든 정보는 리소스가 될 수 있다. 예를 들어 문서나 이미지와 같은 정적인 데이터, “오늘의 로스앤젤레스 날씨”와 같은 시간에 따라 변하는 서비스, 다른 리소스들의 집합, 사람과 같은 비가상 객체 등도 모두 리소스가 될 수 있다.

REST components perform actions on a resource by using a representation to capture the current or intended state of that resource and transferring that representation between components.

REST 컴포넌트들은 리소스의 현재 상태 또는 의도된 상태를 담은 표현(representation)을 사용하고, 그 표현을 컴포넌트 간에 전송함으로써 해당 리소스에 대한 동작을 수행한다.

참고 자료

이 내용들을 바탕으로 REST라는 건 ‘이름을 붙일 수 있는 모든 것(심지어는 웹 서비스의 기능까지도)’을 리소스로 보고, 리소스의 ‘현재 상태’ 또는 ‘의도된 상태(=이 리소스에 부여하고 싶은 상태)’를 담은 ‘표현’을 주고 받으며 동작을 수행하는 일종의 아키텍처라고 이해할 수 있겠습니다.

현대 웹 통신의 표준인 HTTP에서는 다음과 같이 구현됩니다 :

  • 리소스 = HTTP URI
  • 표현 = HTTP Request/Response Body + Header(Metadata)
  • 상태 = Body Payload(표현) 내 ‘리소스’의 상태
  • 리소스에 대한 의도 = HTTP Method
  • 행위 = HTTP Method가 가진 의도에 따라 표현을 전송하는 것

RESTful

‘REST’가 좋은 설계를 위한 아키텍처였다면, 형용사형 어미 -ful이 붙은 ‘RESTful’은 이름에서 유추 가능하듯이 ‘REST 아키텍처 스타일의 제약 조건을 잘 지켜 설계된’이라는 뜻으로 생각해볼 수 있겠습니다.

그럼 RESTful API가 뭐지?

간단하게 알아본 REST와 RESTful의 정의로 미루어 보았을 때, RESTful API는 말 그대로 ‘REST 아키텍처의 규칙을 잘 지켜 설계된 API’라고 보면 되겠습니다.

그렇다면 어떻게 작성해야 RESTful한 API가 될 수 있을까요? 그 규칙은 다음과 같습니다.

  1. 리소스 중심 : 행위가 아니라 리소스를 URI로 식별
  2. Uniform Interface 준수 : 상태 조작은 표현(representation), 의미 전달은 HTTP Method
  3. 상태가 표현에 담겨 전달 : 요청/응답은 항상 “이 리소스는 지금 이런 상태다” 또는 “이 리소스를 이런 상태로 만들고 싶다”
  4. HTTP 의미를 존중 : 각 HTTP Method가 가진 의도를 그대로 사용
  5. 클라이언트–서버의 독립 구조 유지 : 서버 구현 변경 ≠ 클라이언트 수정

즉, RESTful API의 본질은 HTTP가 가진 의미론을 ‘의도적으로 그대로 사용’하고, 리소스와 상태 전이를 그 의미 안에 담는 것이라고 볼 수 있겠습니다.

Devths에서의 API 설계

설계 및 작성 과정에서 RESTful에 대한 고민이 잘 드러나는 API를 정리해 보았습니다.

리소스 식별(명사화) 관점 1 : ‘팔로우’를 어떻게 바라볼 것인가?

저희 서비스에는 회원 간 팔로우 기능이 존재합니다.

팔로우는 유저 간 M:N 연관 관계를 가지므로 follows라는 매핑 테이블을 만들어 팔로우 정보를 관리하게끔 설계했습니다.

이때 ‘다른 회원을 팔로우하는 기능’의 API는 어떻게 작성해야 할까요?

회원 A가 회원 B를 팔로우하면, follower_id=a.id, following_id=b.id를 값으로 갖는 follow 엔티티 하나가 생성됩니다.

여기서 저는 URI 작성에 대한 고민을 하게 됩니다.

처음에는 /api/users/{userId}/follows로 설계했다가, 경로에 동사(follow)가 포함되어 변경하기로 했습니다. RESTful 아키텍처에서는 자원을 명사로 표현해야 하는 것이 원칙이기 때문입니다.

그렇다면 어떻게 바꾸는 것이 좋을까요? 팔로우 기능의 동작 흐름을 간단하게 체크해봅시다.

제가 yun을 팔로우하면, yun의 ‘팔로워 목록’에 제가 추가됩니다. 즉, ‘{userId}의 팔로워 목록에 나라는 팔로워를 추가’하는 거죠.

저는 이 관점-팔로워라는 자원을 중심-으로 접근하여 /api/users/{userId}/followers로 결정했습니다.

같은 관점에서 HTTP Method는 POST로 결정했습니다. REST에서 HTTP Method는 리소스에 대한 의도를 나타내고, 팔로우 기능이 정상적으로 실행되면 회원 간의 관계를 표현하는 리소스 하나가 생성되니까요.

즉, POST /api/users/{userId}/followers는 ‘{userId}의 팔로워 목록에 나를 추가해줘!’라는 의미를 갖게 됩니다.

리소스 식별(명사화) 관점 2 : ‘첨부파일’이 내포하는 의미

Devths는 챗봇, 게시글, 사용자 간 채팅 등 다양한 도메인에서 파일 첨부 기능을 지원합니다.

해당 기능을 구현하기 위해 클라이언트가 S3에 파일을 직접 업로드하는 Presigned URL 방식을 채택했는데요, 이 과정에서 업로드가 정상적으로 완료된 이후 해당 파일의 정보를 서버로 전달해주는 API가 필요했습니다.

저희 서비스에서 S3에 저장되는 파일은 대부분 첨부 파일의 성격을 띄고 있기에 최초 설계됐던 URI는 /api/attachments였습니다. 특정 엔티티에 귀속된, '첨부파일'이라는 비즈니스 의미를 강조하고 싶었거든요.

그러나 URI는 특정 사용 맥락이나 행위를 드러내기보다 리소스 그 자체의 성격을 표현하는 것이 더 적절하지 않을지 아래처럼 고민하게 되었습니다.

  • attachment라는 단어 자체에 이미 ‘무언가에 첨부한다’는 행위와 맥락이 포함되어 있지 않은가? 그렇다면 추후 첨부의 성격을 갖지 않는 파일까지 포괄하기에는 의미적으로 어색해지지 않는가?

생각해보면 어떤 파일이든 우리가 어딘가에 첨부해야 ‘첨부파일’이라고 부르니까요. 또한 서버로 파일 정보를 전달하는 단계는 아직 관계가 맺어지기 전인 '자원의 생성' 단계이므로 적절하지 않은 리소스 네이밍이라고 판단했습니다.

게다가 프로필 사진도 S3에 업로드된다는 사실을 간과하고 있었습니다. 어떻게 보면 유저 도메인의 첨부파일이라고 볼 수도 있겠지만, 보통 프로필 사진은 유저의 속성이지 유저에게 첨부한다고는 하지 않으니까요.

이러한 이유로 S3 파일 첨부 관련 API들의 URI는 /api/attachments/~에서 좀 더 명사스럽고(?) 포괄적인 /api/files/~로 변경되었습니다.

PUT vs. PATCH : 같은 듯 다른 두 Method

HTTP Method를 사용해 ‘리소스에 대한 수정’의 의도를 나타낼 때 흔히 두 가지 방식 중 하나를 택합니다.

하나는 PUT, 다른 하나는 PATCH입니다.

이 둘은 수정하려는 값이 리소스의 전체냐 부분이냐를 넘어, 멱등성의 보장 여부와 리소스 상태를 바라보는 관점에서 큰 차이를 보입니다.

PUT은 항상 멱등성을 보장해야 하며, 클라이언트가 보낸 데이터가 서버 리소스의 ‘새로운 상태’가 됩니다.

PATCH는 요청 의미에 따라 멱등하지 않을 수도 있으며, 기존 리소스를 유지한 채 클라이언트가 보낸 데이터에 포함된 값만 수정합니다.

멱등성(Idempotency)이 뭐죠? 중요한 건가요?

‘멱등하다(Idempotent)’는 것은 동일한 연산을 여러 번 수행해도 최초 결과와 이후의 결과가 달라지지 않는 것을 말하고, 이런 성질을 ‘멱등성(Idempotency)’이라고 합니다.

이 성질이 HTTP Method에서 갑자기 왜 언급된 걸까요?

네트워크 환경에서는 동일한 요청이 중복 전송될 가능성이 항상 존재하기 때문입니다.

API 관점에서도 멱등성은 매우 중요합니다. API가 멱등하다는 건 동일한 요청을 여러 번 보내도 서버(DB) 상태를 최초 1회 변경한 이후 변경하지 않는다는 뜻이거든요.

사용자가 실수로 ‘따닥’ 클릭했을 때, 호출된 API가 멱등성을 갖는다면 UX를 훼손하지 않는 것은 물론이고 의도하지 않은 요청으로 인한 문제를 방지할 수 있습니다.

그렇기 때문에 PUT과 PATCH를 잘 구분해서 사용해야 한다고 판단했습니다. 위에서도 언급했지만 PUT은 항상 멱등해야 하고, PATCH는 경우에 따라 멱등하지 않을 수도 있기 때문입니다.

이 논리를 바탕으로 저는 사용자가 폼을 통해 리소스의 상태를 입력하고 그 값으로 서버 상태를 명확히 덮어쓰는 경우에는 PUT, 알림 ON/OFF처럼 상태값을 토글하는 경우에는 PATCH를 채택했습니다.

정보를 수정하는 플로우에서는 동일한 입력에 대해서 동일한 결과가 보장되어야 하고, 상태 토글의 경우에는 누를 때마다 값이 변경되어야 하기 때문입니다.

그렇다면 Devths에서는 어떤 결정을 내렸을까요?

다시 본론으로 돌아와서, 저는 ‘내 정보 수정 API’에서 둘 중 어떤 것을 써야 할지 고민이었습니다.

해당 API에서는 이런 식으로 값을 받습니다. nickname(닉네임)은 필수값이고, interests(관심 분야)는 선택값입니다.

내 정보 수정 페이지의 특성상 사용자가 입력한 화면 전체의 폼 데이터와 서버의 리소스 상태를 완전히 동기화해야 하며, 서버의 최종 상태와 사용자가 마지막으로 보낸 상태가 동일하게 유지되는 것(멱등성)이 중요하다고 생각했습니다. 현재 설계에서는 PATCH를 사용해도 멱등하게 동작하는 것처럼 보이지만, 저는 HTTP Method 명세 레벨에서 보장되는(보장해야 하는!) 멱등성과 상태 동기화를 우선 순위에 두어 PUT을 사용했습니다.

PATCH에서는 interests와 같은 선택적 필드가 누락되는 경우, ‘데이터를 지우려는 의도(null 전송)’와 ‘수정하지 않으려는 의도(필드 누락)’의 구분이 모호해질 수 있습니다.

반면 PUT은 전체 상태를 동기화하므로 필드의 누락을 명확하게 ‘잘못된 요청’으로 간주할 수 있어 데이터 정합성을 유지하기에 유리하다고 판단했습니다.

이 관점에서, PUT은 리소스 전체를 전달받은 표현으로 대체하겠다는 의도를 가지고 멱등성을 항상 보장해야 하므로 제가 설계한 API의 의도에 적절하다고 결론을 내렸습니다!

최종적으로 내 정보 수정 API는 PUT /api/users/me로 확정하게 되었습니다.

다만 interests 필드를 누락하지 않게 validation을 하고, 관심 분야가 없으면 빈 배열을 전송하도록 정의했습니다.

돌아보며

제가 예시로 작성한 API가 Best Practice라고는 단언할 수 없습니다. 완벽한 설계란 존재하지 않고, 나름대로 고민해 내린 결론들이 다른 기획에서는 틀릴 수도 있기 때문입니다.

그러나 이 과정을 통해 RESTful하게 API를 설계한다는 게 무엇인지에 대해 근본적으로 다시 생각해볼 수 있었습니다.

결국 RESTful함을 추구하는 것은 정답이 정해진 규격을 맞추는 것이 아니라 HTTP라는 표준 프로토콜이 가진 의미와 특성을 활용해 '누구나 이해할 수 있고 예측 가능한 인터페이스'를 만드는 과정임을 배웠습니다.

앞으로도 모든 설계가 항상 정답일 수는 없겠지만, 적어도 설계 이유를 명확히 설명할 수 있는지를 하나의 기준으로 삼고자 합니다.

참고 자료

Representational State Transfer (REST) RESTful API란 무엇인가요?