teklog

GraphQL은 무엇이고 왜 사용할까?

2022/10/19

n°17

category : GraphQL


img





GraphQL은 클라이언트 애플리케이션에서 개발자가 필요한 데이터에 불러오는데 초점을 맞춰, 뷰에 필요한 데이터를 정확하게 지정해서 한 번의 요청으로 통해 불러올 수 있는 새로운 방법을 제시합니다. GraphQL은 리소스 기반의 REST 같은 기존 접근 방법에 비해 더 효과적으로 데이터를 불러오고, 서버 측에서 중복된 로직이 반복되거나, 이를 피하기 위해 커스텀 엔드포인트를 추가하는 일을 작업을 방지하며, 더 나아가서 제품 코드를 서버의 로직으로부터 분리(Decouple)하는 것을 돕습니다. 예를 들면, 관련 서버 엔드포인트의 변경 없이 더 많거나 적은 정보를 가져오게끔 변경할 수 있습니다. - Thinking in GraphQL

Hyeseong Kim 님께서 번역해주셨습니다.



이번 글에선 GraphQL에 대해 간략히 알아보려 한다. gql의 간단한 소개와 실제 사용 후 느낀 점들과 함께 graphql의 기본적인 문법을 살펴볼 예정이다.



Intro : GraphQL의 소개와 장점



GraphQL 은 API를 위한 쿼리 언어이며 타입 시스템을 사용하여 쿼리를 실행하는 서버사이드 런타임입니다. -- 한국어 공식 사이트


GraphQL의 공식 사이트에서는 gql의 장점을 다음과 같이 설명하고 있다.



  • 단일 endpoint 요청
  • endpoint를 대신하는 타입과 필드
  • 쿼리의 재사용, 유지보수의 간편함


일반적인 REST API를 통해 백엔드 서버와 통신하는 것과 달리, gql은 클라인언트와 서버사이드 모두 gql의 방식을 따른다. 이 방식의 큰 차이점은 첫째로 gql의 쿼리 문법이다. 둘째는 'endpoint'가 하나다. (그리고 모두 POST 요청으로 보낸다)

처음 듣기만 했을 땐 상당히 편리하겠다-싶었다. 프론트에서의 REST는 필터링을 구현하기 위한 쿼리 작업, 쿼리 스트링으로 페이지 이동 등 번거로운 점이 많기 때문이다. 그래서 단순히 이런 점들을 보완해주지 않을까-라고 예상했다.

그러나 gql을 학습하기 위해선, 이 기술이 REST의 작은 부분들을 교체해 개선한 것이란 인식을 빨리 버려야했다. REST에 너무 익숙해져 비동기 함수는 그 방식 밖에 떠올리지 못하는 게 오히려 gql에 새로 적응하는데 방해가 되었기 때문이다. GQL은 REST와 확실히 다르다.


img

gql로 구현한 페이지네이션에서 나타나는 동일한 endpoint의 post 요청들.


  • endpoint 최소화의 장점


직접 사용해보니 endpoint의 최소화가 가져오는 이점이 상당하다. 먼저 클라이언트에서 일일히 api에 대응하는 비동기 함수를 작성할 일이 확실히 적어진다. 혹은 여러 응답 데이터를 합성하거나 가공할 일이 거의 없어진다. 이와 관련해서 overfetching/underfetching 이슈들이 자주 언급된다.


  • overfetching : 클라이언트에서 필요 이상의 응답 데이터를 받게 된다. id, name 정도만 필요한 요청에도 수많은 필드가 함께 오는 경우가 잦다.
  • underfetching : api가 필요한 값을 제공하지 않아 여러 api를 호출하고 클라이언트에서 데이터를 가공해야한다.


두 이슈는 프론트 개발에서 지루하고 반복적이지만 피할 수 없는 코드를 발생시킬 때가 많다. rest api를 사용하는 전직장에선 오버/언더 페칭은 피할 수 없는 숙명처럼 자주 접했다. 모든 api가 그렇진 않지만, overfetch된 한 api에서 필요한 작은 부분들을 따서 개발을 하거나, overfetch된 여러 api에서 다시 작은 부분을 따와서 하나로 묶어 사용할 때가 잦았다(underfetch). 즉 응답 이후에도 데이터 포맷팅에 많은 시간을 들이게 된다.


이 문제는 결과적으로 너무 많은 시간 소요를 가져온다. 응답 데이터를 가공하면서, api 수정을 요청하면서, api 개발을 기다리면서, 기다리는 동안 작성한 코드를 수정하면서. 모든 단계에서 소요가 발생한다. 이는 프론트에서 편리한 데이터 상태관리 툴을 사용하는 것과도 무관했다. 또한 api docs 없이 응답 데이터를 예측하기가 어렵다. 데이터 커플링 때문에 프론트의 코드 수정이 잦아지고, api 수정에 매우 민감하기 때문에 코드 수명이 짧아지는 원인이 되지 않았을까도 싶다. 결과적으로 너무 효율적이지 못하다. graphql은 이러한 문제를 상당 부분 개선할 수 있다.


  • 타입과 필드의 장점


GQL은 요청/응답의 데이터의 타입을 Schema를 통해 지정한다. 프레임워크마다 차이가 있겠지만, 내가 사용한 Relay는 schema.graphql에 작성된 쿼리의 타입을 (artifact를 통해)그대로 가져올 수 있었다. 즉 프론트에서 응답 데이터의 타입을 자동으로 지정할 수 있다. endpoint 최소화에 이어 가히 혁신적이라 할 수 있다. 특히 타입스크립트를 사용한다면, interface와 type을 줄이는 것만으로도 상당량의 작업 시간과 코드를 줄일 수 있다.



  • 쿼리의 재사용, 유지보수의 간편함


"GraphQL은 특정 데이터베이스에 제한받지 않고" 동일한 api를 제공한다. 만일 클라이언트에서 응답에 필드를 하나 추가하여 받고자 한다면, 쿼리에 필드 한 줄만 추가해주면 된다.

query { 
  viewer { 
    login
    + url  // url을 추가로 요청한다
  }
}

또한 fragment를 사용하여 동일한 쿼리를 계속하여 재사용할 수도 있다. fragment는 보통 응답 데이터를 props로 넘겨받는 자식 컴포넌트에서 사용한다.


// 자식컴포넌트.tsx

fragment 자식컴포넌트_Fragment on ParentQuery {
 viewer {
    login
    url 
   }
 }

 // 부모컴포넌트.tsx

query ParentQuery {
...자식컴포넌트_Fragment // 부모 쿼리에 fragment 추가
} 

프레임워크마다 차이가 있겠지만, 굳이 fragment가 아니더라도 query를 import하여 재사용할 수도 있다. api마다 다른 비동기 함수와 header, body를 작성해야 하는 지긋지긋한 반복 작업에서 벗어날 수 있다. 또한 gql의 query는 상당히 직관적이기 때문에 어떤 데이터를 요청하고 반환 받을지 예측하기도 쉽다.


이외에도 gql의 이점이 상당히 많다. 그 중 특히 캐싱이 상당히 인상깊다. 관련해서는 Thinking in GraphQL의 캐싱 내용을 읽어보시길 추천드린다.


GraphQL의 단점


아직 gql을 깊게 사용해본 것은 아니기에 정확히 구체적으로 파악하고 있지는 못하다. 그리고 클라이언트에서만 사용해 본 한계가 있다. 그럼에도 불구하고 몇 가지를 생각해보자면..


  1. 진입 장벽이 있다.
  2. 서버에서 작성된 스키마를 파악하는데 시간이 걸린다.


1)은 사실 gql 보다는 gql 프레임워크와 연관이 깊다. 관련해서 릴레이 문서에서 더 자세히 다뤄보기로 하겠다. 2)은 사실 github graphql api를 사용하면서 느낀 점이다. 1:n, n:n 테이블에 있는 데이터를 가져오기 위해 connection을 파악하고, inline fragment를 사용해야 한다. 문법의 난이도보다도, 그 과정에서 github api에서 스키마를 파악하는 것이 까다롭게 느껴졌다. api가 하나이다 보니, 스키마가 방대해지면 쿼리를 작성하기 까다로울 수 있을 것 같다.



GraphQL의 주요 요소들


Schema & Resolver

// schema.graphql

type Character {
  name: String!
  appearsIn: [Episode!]!
}

type Starship {
  id: ID!
  name: String!
  length(unit: LengthUnit = METER): Float
}

type Query {
  hero(episode: Episode): Character
  starship(id: ID!): Starship 
}

Schema : query, mutation 등 우리가 보낼 요청과 응답의 타입으로 구성된다. 그렇기에 우리는 임의의 argument를 아무 필드에 적용시킬 수 없고, 타입에 일치하지 않는 값을 argument로 넘겨줄 수도 없다. 스키마는 일종의 설계도로 기능하고, gql 프레임워크 컴파일러가 필드를 인식할 수 있게 도와준다.



const resolver = {
Query {
 hero: ( __, episode) => getCharacter(episode),
 starship: (__, id) => getStarship(id)
}

}

Resolver : schema에서 지정된 타입을 바탕으로 query, mutation, fragment 등을 실제로 구현해주는 함수로 구성되어있다. 스키마에서 정의된 모든 필드를 리졸버에서 실제로 구현하는 것으로 보인다. resolver는 db로 요청을 보내는 실질적인 api 부분으로 이해했다. (MVC 패턴의 서비스-모델 부분에 해당하겠다.) 클라이언트에서는 스키마와 리졸버로 구현된 gql api의 타입에 맞춰 gql query를 작성하게 된다.


Query & Argument

query ExampleQuery {
      data(id: 332) {
        user {
          name
          url      
        }
     }
}

/// JSON 반환값

{
 "data": {
    "user": {
           name: "tek",
           url: "https://teklog.site"  
      }
   }
}
일반적인 쿼리의 필드와 이에 대한 반환 값


rest의 get에 해당하는 요청을 할 땐 query를 사용한다. argument는 rest api에서 query string에 대응한다. 만약 검색, 필터링처럼 인자를 넣어서 결과를 얻고 싶다면, 쿼리 내부에 argument의 타입과 그 argument가 사용될 필드 이름 뒤에 괄호로 연결해준다. argument를 정의할 때 이름 앞에 $를 붙여야한다. argument의 타입은 임의로 정해질 순 없고, 스키마의 정의를 따르게 된다.


검색을 위한 query 예시
 
query Search_Query(
    $query: String! // argument 정의, !는 non-nullable
    $first: Int
    $after: String
  ) {
    search(
      query: $query // search 필드의 argument로 연결
      first: $first
      after: $after
    ) {
      repositoryCount // query
    }
  }

// 혹은 같은 필드이더라도 다른 argument을 주고, 필드에 다른 이름을 줄 수도 있다.

query Search_Query(type:$String){
 orgUser : user(type: "oragnization"){
      name
      url  
  }
 user(type: "user"){
      name
      url 
  }
}

// JSON
 { "orgUser" : {....},
   "user": {....}
}


Fragment

앞서서 fragment의 간단한 적용을 살펴보았다. 그러나 graphql에서는 fragment를 사용하지 않아도 동일한 응답를 얻을 수 있다. 혹은 여러 개의 query를 만들어서도 같은 결과를 렌더링할 수 있다. 처음 fragment를 사용했을 시, 왜 굳이? - 라는 의문이 들었다. 다음 예시를 보자.


//부모컴포넌트.tsx

1) fragment를 사용하지 않는 query 
const parent = graphql`
 query 부모컴포넌트_Query($id: Number!, $repoId: Number!) {
  totalUserCount
    nodes {
       users(id:$id) {
       name
   }
    edges (id:$repoId){
       id
       name 
       url
       organization
  }
}
`

2) fragment 사용 시
...
{
 ....
 ... on Nodes {
    ...자식컴포넌트_User_Fragment
    ...자식컴포넌트_Repo_Fragment
  }
}

const 부모컴포넌트 = () => {

  const { data } = useQuery(parent, {})

  return (<>
      {data && <p> User : {data.totalUserCount}</p>}
      {data && data.nodes.users.map((user) => (<p>user : {user.name}</p>))}
      {data && data.nodes.edges.map((edge) => (<자식컴포넌트 props={edge}/>))}
       </>)
    }

2) fragment 사용 시 자식 컴포넌트

 ... 
fragment 자식컴포넌트_Fragment on Search($id: Number!,$repoId:Number! ) {
 ... on User (id:$id){
 name
    }
 ... on Repository (id:$repoId){
       id
       name 
       url
       organization
     }
  }
}

3) 독립된 query를 부모, 자식 컴포넌트 양쪽에 따로 작성하는 방식

// 부모컴포넌트.tsx

const parent = graphql`
 query 부모컴포넌트_Query {
   totalUserCount
   nodes {
     users {
     id
   }
    edges  {
       id
    }
  }
}
`
...


// 자식컴포넌트.tsx
const childUser = graphql`
query 자식컴포넌트_User_Query($id: Number) {
   nodes {
    user(id:$id) {
      name
     }
  }
 }
`

query 자식컴포넌트_Repo_Query($id: Number) {
   nodes {
    edge(id:$id) {
      id
       name 
       url
       organization
     }
  }
 }
`

const 자식컴포넌트 = ({userId, repoId}) => {
const { data: userData } = useQuery(childUser , {id : userId})
const { data: repoData } = useQuery(childUser , {id : repoId})


return (
<>
  <p>User : {userData.nodes.user.name}</p>
  <p>Repo : {repoData.nodes.edge.name}</p>
</>
)

}


1~2번 쿼리는 사실상 동일한 쿼리이고 같은 결과를 응답으로 받는다. 3번은 쿼리도 다르고, 응답으로 다른 정보를 얻어오지만, 1,2번과 같은 결과를 렌더링한다. 우리는 3번의 방식을 최대한 방지하기 위해 fragment를 사용한다. 3번의 방식은 부모컴포넌트_Query가 유저와 레포의 id가 담긴 객체 배열을 반환하기 전까지 자식컴포넌트는 필요한 데이터를 요청할 수 없다. 네트워크 지연이 발생하게 될 뿐만 아니라, 불필요한 쿼리가 부모와 자식 컴포넌트 모두의 복잡성을 높인다. (덧붙여서 fragment를 적극적으로 사용하지 않는다면 릴레이에서 제공하는 강력한 훅을 사용하기 어려워진다.) 우리는 fragment를 사용해 단 한번의 요청으로 필요한 정보를 모두 가져올 수 있다. 특히 컴포넌트 내부에 작성된 fragment는 일종의 api 명세서 역할을 하기 때문에 코드의 가독성이 높아지는 효과가 있다.



"제품 코드를 서버의 로직으로부터 분리(Decouple)하는 것을 돕습니다." - Thinking in GraphQL


그렇다면 2번은 무엇이 문제일까? 2번과 3번은 사실 동일한 문제를 갖고 있다. 그건 바로 데이터 커플링이다. 커플링은 props와 연관이 깊다. 예를 들어 부모컴포넌트에서 자식컴포넌트로 응답 데이터를 가공하여 prop로 넘겨준다고 한다면, 자식 컴포넌트는 부모 컴포넌트에서 일어나는 여러 효과의 영향에 노출된다. 즉 자식 컴포넌트는 부모 컴포넌트에 대한 의존성이 너무 높아진다. 만일 우리가 부모 컴포넌트에서 의도치 않게 prop으로 넘겨주는 데이터를 변경하는 함수를 실행한다면, 자식 컴포넌트에서 발생하는 무수한 타입 에러와 런타임 에러를 보게 될 것이다. 이를 방지하기 위해 단축 평가와 옵셔널 체이닝, 분기처리, etc..를 활용하여 신경을 많이 써야 한다. 결과적으로 코드의 불확실성을 높이고, 에러에 취약할 수 있기 때문에 비용이 될 수 있다.


하지만 이 모든 에러는 부모에서 아예 자식으로 내려줄 데이터에 접근할 수 없다면 방지할 수 있다. 이를 Data Masking이라고 한다. fragment는 data masking을 위해 사용한다. 이 부분에서 meta(구 facebook)가 설계한 리액트의 flux 패턴이 떠오르지 않을 수 없었다. 이는 컴포넌트를 작은 단위로 쪼개는 것과 같이, fetch data도 컴포넌트 단위에 맞춰 작게 나누어 분리할 수 있기 때문이다. 앞서 살펴본 문제로 fetch data가 code splitting을 무의미하게 만들 때가 많은데, graphql은 이를 온전하게 구현해준다. 이는 스키마와 리졸버를 구성할 때도 마찬가지이다. graphql은 앞서 살피듯 api가 하나이기 때문에 우리가 요청을 받아줄 api가 아예 사라질 가능성이 적다. 또한 스키마와 리졸버의 수정은 기존의 환경을 유지하면서 독립된 기능을 추가해가는 방식으로 구현해갈 수 있다.


Mutation

get(read) 이외의 요청(create, update, delete)은 gql에서 mutation으로 구현된다. query와 상당히 유사하고, argument의 개념만 잡혀있으면 어렵지 않게 구현할 수 있다. 마찬가지로 mutation의 필드와 argument는 스키마에서 정의한 타입을 따른다.

// github api를 활용한 star update mutation

const addStar = graphql`
  mutation RepoItem_AddStar_Mutation($input: AddStarInput!) {
    addStar(input: $input) {
      starrable {
        id
        viewerHasStarred
      }
    }
  }
`;

이 mutation을 바탕으로 프론트에서 이벤트가 발생했을 시, 상태에 저장된 값을 loadQuery 메서드의 변수로 넣어줌으로써 mutation의 argument에 적절히 넣어줄 수 있다.


이상 GraphQL의 기본적인 사용법과 주요 개념들을 간략히 살펴보았다. directive와 resolver, schema의 type, inline fragment에 대해 다루지 못한 것이 아쉽다. 추후에 더 살펴보도록 하자.