Lunit CARE는 왜 GraphQL을 선택했는가?
Lunit CARE (https://www.lunit.care) 서비스는 인터페이스 프로토콜로 GraphQL + Relay를 사용하고 있다. 오늘 팀 내에서 프론트엔드로부터 백엔드가 GraphQL을 선택한 이유에 대한 질문이 있었고, 그 대답에 대한 내용을 정리하여 공유한다.
History (TMI)
- 2022년 1월, 프로젝트를 시작하면서 백엔드 기술로 ‘Golang과 GraphQL’ — Gin-Gonic, Gorm, Gqlgen — 을 선택
- 프론트엔드에서 GraphQL 라이브러리를 Apollo에서 Relay로 변경을 제안하며, 백엔드에서 Relay의 규격에 맞게 API 변경 가능한지 문의. Relay 사용하기로 합의
- 3월 21일 Lunit CARE 베타 런칭
- 3월 28일 GraphQL Postmortem 회의 소집, 일정 문제로 Relay의 Full-Spec이 적용되지 못한 부분에 대한 확인과 기술부채 대응 방안 수립. 2주 후 리팩토링 완료
- 4월 13일 데일리 스탠드업 미팅 후 프론트엔드에서 서버에 GraphQL을 사용한 이유 질문
처음에는 서버에서 GraphQL을 사용한다고 하여 프론트엔드는 그냥 따랐다. 그러나 프로젝트를 진행하면서 메타(전 페이스북)가 React에서 GraphQL과 Relay를 만들어서 사용하는 이유를 알 것 같다. 지금 프론트엔드는 이 기술에 만족도가 매우 높다. 다만, 서버에서는 왜 GraphQL을 선택했는지 궁금하다.
실무자 입장에서 RESTful API의 단점
RESTful API의 장점은 정보가 매우 많으므로 생략한다.
1. 문서화 (Documentation)
RESTful API의 문서화는 과거 별도의 문서공간(예: README.md, Confluence)에서 이루어지다가 Swagger가 등장하였고, 이는 OpenAPI 스펙으로 통합 발전하였다. 이 문서화는 프론트엔드와 백엔드가 통신하는데 아주 중요한 역할을 하며, 문서의 업데이트가 깨지게 되면 개발 과정에 상당한 진통을 유발하게 된다. 그리고 이 문제가 지속적으로 발생하면 프론트엔드와 백엔드의 골이 깊어질 수 있다.
대부분의 웹프레임워크들이 Swagger에 대응하는 OpenAPI 문서를 만들 때, 도구들로 Annotation(예: Java, Python)이나 Comment(예: Golang)의 정보를 취합한다. 문제는 기능 코드의 변경이 Annotation이나 Comment 연동에 강제성이 없다는 것이다. 때문에, 실무에서 백엔드 API 스펙 변경 시 Annotation이나 Comment들을 함께 변경하지 않은 채 배포하여 프론트엔드가 당황하게 되고, 스스로 문제를 찾다가 안되어 백엔드에 문의하면 ‘일정이 급해서 우선 변경하여 배포했고 이를 프론트에 알리려 했는데 깜빡 잊었다’는 답변을 받는 일이 흔하게 일어난다. 이 일을 몇 번 겪으면 프론트엔드가 백엔드를 정말 싫어하게 된다.
2. 정합성 (Validation)
RESTful API의 구현체는 HTTP Handler에 직접 대응한다. 이 말은 HTTP 프로토콜 기본 규격에만 맞으면 router callback에 어떤 데이터든 전달이 된다는 뜻이다. 즉, 데이터 정합성(각 필드들의 타입과 값의 범위 등)을 체크하는 것을 직접 코드로 구현해야 한다.
GET, DELETE는 URI Path만 값의 대상이기 때문에 Request Router에서 비교적 간단하게 필터링 되지만, Body에 JSON을 실어서 보내는 POST와 PUT, PATCH는 별도의 정합성 검사가 필요하다.
물론, JSON schema라는 규격이 존재하고 이 규격에 따라 Body에 들어오는 값을 검사하는 라이브러리들이 있다. 하지만, 결국 API 변경 시 검사 기준이 되는 JSON schema를 업데이트 해야하는데 이 규격의 텍스트 양은 검사 대상이 되는 JSON 형태의 3배 이상으로 거대하다. 게다가 JSON schema는 OpenAPI 문서와 완전히 별개이다. 그래서 실무에서 대부분의 구현체들을 보면 이 정합성 검사가 빠져있고, 잘못된 값이 뒤로 넘어간 뒤 비지니스 로직이나 DB의 에러에서 문제가 검출된다. 최악의 경우에는 서버가 다운되거나 데이터가 훼손되기도 한다.
잘못된 호출 문제는 문서화 문제와도 연결된다. 백엔드에서 API가 수정되었으나 문서는 수정되지 않았고, 그래서 바뀌기 전의 값이 API에 전달되어 에러가 발생하게 된다. 그런데, 프론트엔드 입장에서는 문서 상으로 이상이 없고 에러도 입력값에 대한 에러가 아니라 비지니스 로직 에러나 DB 에러가 보이므로 환장하게 된다. 특히 어제나 조금 전까지 정상 동작하던 기능이면 더더욱…
3. 올바른 API 설계 (API Design)
RESTful API는 URI Path의 대상이 되는 것을 Resource로 보며, 복수형 명사를 쓰도록 제안하고 있다. 그리고 해당 리소스를 어떻게 다룰 것인지에 대해서는 HTTP Method — GET, POST, PUT, PATCH, DELETE — 에 의존한다.
이 가이드에는 두 가지 어려움이 있다.
첫째는 URI Path의 패턴 지정이 모호하다. API의 모든 조작 대상이 Resource로 표현되기는 어렵다. 또한, 각각의 리소스들이 상황에 따라 계층(hiarachy)으로 표현할지 독립적으로 표현할지 모호할 때도 있다. 같은 리소스가 관계에 따라 여러 가지 표현식으로 설계되기도 한다. RESTful API의 가이드를 자세히 안읽고 대충 단수 명사들 때려 넣고, Selector들을 쿼리스트링(Query String)에 넣고 하는 식으로 마구 지어버리기도 한다.
둘째는 HTTP Method가 우리가 정의하고자 하는 행동의 동사(Verb)에 항상 1:1 매칭을 시키기 어렵다. 예를 들어 어떤 객체의 상태가 상태머신처럼 순서대로 변경되어야 할 때, RESTful API 대로면 PATCH를 쓴다. 이 때, PATCH의 Body에 상태를 포함하면 될 것 같다. 그런데, 하나의 리소스에 다양한 변경 시나리오가 존재한다면 상황에 필요한 행위는 여러 개인데 PATCH 하나만 사용 가능하기 때문에, PATCH의 구현 복잡도가 조금 당황스럽다. 일단, 전체 테이블에서 변경을 원하는 값을 구분하는 것부터 짜증스럽다. 그럼 결국 GET으로 모두 얻어와서 일부 필드 변경 후 PUT으로 전부 밀어넣게 되는데 이는 구현의 편리함을 따르는 방식이지 논리적인 의미에는 위배된다.
즉, 우리가 인식하는 대상과 행위들을 URI Path와 HTTP Method의 조합만으로 직관적인 API 정의를 하려면 많은 고민이 필요하기 때문에 일정이 부족하고 할 일이 많을수록 애매모호하거나 룰을 포기한 작명을 하는 현상들이 일어나며, 결국 URL Path를 함수처럼 정해버리기도 한다.
4. 하나의 페이지에서 너무 많은 API (Multiple API call on single page)
SPA와 MSA 아키텍처가 전파되면서 하나의 페이지를 이루는 정보들을 다수의 API에서 각각 받아와야 하는 설계들이 종종 사용되고 있다. 이때, 프론트엔드에서는 Ajax로 여러 번의 API 호출이 이루어지며 이는 프론트엔드와 백엔드의 네트워크 트래픽에 부담을 늘린다.
또한, 각각의 값들을 함께 받아서 프론트엔드가 조합해야 하는 경우라면, 동시성에도 추가적인 신경을 써야한다.
GraphQL이 보완하는 RESTful API의 단점
gqlgen 기준으로 설명한다.
1. 문서화
GraphQL은 쿼리를 정의하는 DSL(Domain Specific Language) 형식을 갖고 있다. 그리고 gqlgen을 이용하면, 서버 코드가 자동 생성된다. 자동으로 생성된 코드에는 PlayGround라는 웹서비스 페이지가 포함되어 HTTP Router에 연결하면 자동으로 Swagger와 같은 테스트 페이지를 보여줄 수 있다. 또한, 이 PlayGround에는 Query와 Query에 사용되는 Structure들의 구조에 대한 명세도 DOCS와 SCHEMA 탭에 함께 표현이 되어 있으며, DSL에 추가적인 설명을 Comment로 작성하면 이 설명을 PlayGround에서 함께 노출한다.
만약, API의 변경이 결정되면 DSL 명세를 수정한 후 gqlgen 도구를 실행하여 자동으로 PlayGround 코드를 업데이트하면 된다. 서버 구현(Implementation)에는 gqlgen으로 자동 생성한 Router와 Callback 코드들이 필요하기 때문에 절대 문서 업데이트를 피할 수 없다.
또한, 직접 DSL 코드를 열어보아도 쉽게 API 형태를 파악할 수 있다. Lunit CARE에서는 Monorepo를 사용하기 때문에 이와 같은 참조가 더 쉽다.
2. 정합성 (Validation)
gqlgen 도구로 자동 생성한 코드들에는 Query Parameter의 정합성을 체크하는 코드가 포함되어 있기 때문에 DSL의 명세를 따르지 않은 Query 입력들은 비지니스 로직을 담고 있는 Callback에 도달하기 전에 자동으로 에러처리 되어 클라이언트에 반환된다.
따라서, DSL의 변경 후 gqlgen 도구로 코드를 재생성했다면, 절대로 명세에서 벗어난 입력이 비지니스 로직에 도달할 수 없다.
또한, Relay도 Relay Compiler로 DSL의 명세를 Typescript로 컴파일하기 때문에 소스코드 저장소에서 최신 DSL만 공유한다면, 프론트엔드에서도 변경된 명세에 대하여 컴파일 타임에 타입체크의 이점을 얻을 수 있다.
3. 올바른 API 설계 (API Design)
GraphQL의 Query 종류는 query와 mutation, subscription의 세 가지가 있다. query와 subscription은 백엔드로부터 정보를 전달 받기 위한 것이고, mutation은 백엔드에게 변경이나 행동을 요청한다.
이 때, query와 subscription은 조회하고 싶은 대상을 명사로 mutation은 함수 형식으로 이름을 작성하면 된다. 값을 얻는 것에 대해서 ‘get’과 같은 동사(Verb)를 쓰지 않는 것이 조금 어색할 수 있으나 query 자체가 get의 의미를 갖기 때문에 모호하거나 착각을 일으킬 여지는 없다.
4. 하나의 페이지에서 많은 API의 호출
GraphQL은 하나의 POST에 다수의 query를 담을 수 있다. 네트워크에서는 하나의 HTTP Request이지만, gqlgen으로 자동 생성된 HTTP Router를 통과하면서 각각의 독립된 Query Callback이 호출된다. 각각의 Callback에서 응답을 반환하면, 하나의 HTTP Response에 모든 응답이 담겨서 클라이언트에게 전달된다. RESTful API 호출에 비해 네트워크 부하와 동시성에서 유리하다.
GraphQL의 단점
첫째, GraphQL은 모든 것을 POST로 처리하기 때문에 웹브라우저의 GET 캐시를 사용할 수 없다. 초기 API에서는 개발 속도와 기능 완성이 중요하기 때문에 무시할 수 있으나 만약 특정 query들의 내용 변경 빈도 대비 트래픽 비용이 너무 높다면, 해당 query에 대해서는 GET을 이용한 RESTful API로 변경하는 것을 고려할 수 있다.
둘째, GraphQL은 클라이언트에 노출하는 규격일 뿐이므로 gqlgen 생성 코드가 백엔드의 데이터 처리 코드와 직접적인 연관을 갖지 않는다. 또한, Structure들이 graph 관계를 갖기 때문에 graph의 깊이에 제한이 없을 경우 무한으로 서로 꼬리를 물며 데이터를 불러오도록 잘못된 요청을 할 수도 있다. 따라서 상황에 따라 GraphQL의 특성에 맞춰 Callback의 구현체를 효율적으로 작성하는 것은 생각보다 쉽지 않을 수 있다.
사용 경험
의도
- DSL과 PlayGround를 통해 문서화와 테스트 환경에 대한 작업량을 줄인다.
- 백엔드에서 RESTful API 대비 더 적은 코드와 더 나은 안정성을 확보한다.
- 빠른 시간 안에 직관적인 고품질의 API를 설계한다.
프론트엔드
- React + GraphQL + Relay 사용 경험은 매우 만족스러웠다.
- Relay를 사용하기 위해서는 백엔드가 규격을 맞춰야 했는데 적극적으로 지원해줘서 고마웠다.
- 다른 곳에서는 쉽게 접하지 못 할 경험이었다.
백엔드
- API 설계와 구현이 상대적으로 편했다.
- 문서화 부담이 적어서 좋았다.
- 프론트엔드와 소통이 잘 되었다.
마치며
서비스를 개발 하다보면, 단순히 기능 코드를 짜는 것이 이외에도 엄청나게 많은 것들을 챙겨야 한다는 것을 알게 된다. 특히 협업 환경에서는 그것들을 챙기지 못했을 때, 단순히 일정의 문제로 끝나는 것이 아니라 서비스의 품질 문제나 팀웍의 문제로까지 번질 수 있다. 이러한 문제를 미연에 방지하기 위해서 우리에게 맞는 기술을 선택하는 것은 매우 중요하다. 가장 많은 사람들이 사용하고 있는 기술이나 현재 인기가 급상승하고 있는 기술보다 더 관심을 가져야 하는 것은 현재의 내 문제를 해결해 줄 수 있는 기술이다. 때문에 우리는 항상 나와 내 주변의 문제에 관심을 기울이고 면밀히 분석해야 한다.
우리는 보통 기술을 피상적으로 다룬다. 만약, 각각의 기술들이 이루고자 하는 목표와 해결하고자 하는 문제에 대해 좀 더 깊이 이해하려 한다면, 내 상황에서 적용했을 때의 장점과 단점을 더 잘 볼 수 있다. 그렇다면, 더 나은 선택을 할 수 있고 잘못된 선택에 의해 고통 받는 일도 줄어들 것이다.
Lunit CARE는 아직은 작지만 매우 훌륭한 팀이고, 팀원들 덕분에 나의 고민이 제품을 위해 좋은 선택이었다는 결과를 볼 수 있었다. 그들이 자신의 일에 더 깊은 고민을 하는 개발자들이 아니었다면 이런 만족을 얻을 수 없었을 것이다. 좋은 동료가 최고의 복지라는 말은 이럴 때 사용하는 것이라고 본다.