graphQL 시작하기 (feat. Apollo Client)

이번 프로젝트에서 graphQL을 사용하게 되었는데, graphQL을 공부하면서 내용들을 정리해 본 포스트이다!

graphQL이란?

graphQL은 흔히 알려져 있는 sql과 마찬가지로 쿼리 언어 중 하나이다.

둘의 가장 큰 차이는 sql은 서버에서 데이터베이스에 요청하기 위해 사용하는 언어이고,

graphQL은 클라이언트에서 서버에 데이터를 요청하는 목적으로 사용되는 언어라는 점이다.

생김새도 엄청 다르다. heroes라는 테이블에서 히어로들의 이름과 키를 알고 싶다고 가정했을때, sql문은 이런 모양일 것이다.

SELECT name, height FROM heroes

같은 요청을 graphQL에서는 이런 식으로 보낼 수 있다.

{
  hero {
    name
    height
  }
}

graphQL 과 REST API

지금까지 프로젝트를 하며 백엔드 서버로부터 데이터를 받아올 때 REST API방식을 사용했었다.

REST API는 url(엔드포인트)을 통해서 데이터의 종류를 말하고, http method를 이용해 해당 데이터에 대한 행위를 설명한다.

GET /post/:post_id/comments

따라서 REST API에서는 http methodurl 의 조합으로 여러 endpoint가 존재하고, 각 endpoint를 통해서 다른 데이터를 주고받는다.

반면에 graphQL에서는 단 하나의 endpoint를 사용한다. 주로 /graphql 엔드포인트를 사용하며,

받아올 데이터는 엔드포인트가 아니라 각 요청 마다 보내는 쿼리문 에 따라서 달라진다.

이미지 출처: https://www.apollographql.com/blog/graphql-vs-rest-5d425123e34b/

이전에 프로젝트를 하면서 한 페이지에 그릴 데이터를 받아오기 위해서 4개의 endpoint에 요청을 보낸 적이 있는데,

graphQL을 이용한다면 단 한 번의 요청으로 끝낼 수 있다는 얘기다! 이렇게 멋질 수가!! 🥺🥺

Apollo Client

Apollo Client는 react에서 graphQL 을 쓰는 것을 도와주는 라이브러리이다.

apollo client는 hooks를 지원하면서, 아무런 설정을 해줄 필요 없이!! 서버로부터 받아오는 데이터를 로컬에 캐싱해주고 loading state나 error state 등을 반환하고 polling 등 여러가지 기능도 걸어줄 수 있다. 그리고 docs도 되게 잘 되어 있다!!

graphQL로 서버와 데이터를 주고받을 때 주로 요청하게 될 것은 크게 3가지로, query, mutation, subscription 인데, 앞으로 이들의 특징과 apollo client를 사용해서 어떻게 서버에 요청을 보내는지 알아보도록 하자

시작

일단 우리의 react 프로젝트에 apollo client와 graphQL을 설치해주어야 한다.

$ npm install @apollo/client graphql
또는
$ yarn add @apollo/client graphql

명령어를 입력해 패키지를 설치해 주도록 하자!

설치가 끝나고 나면, 우리는 앞으로 서버에게 요청을 보낼 apollo client 인스턴스를 만들어서 @apollo/client 패키지 내의 ApolloProvider를 사용해서 하위 컴포넌트들에게 인스턴스를 넘겨줄 것이다.

마치 reduxProviderstyled-componentsThemeProvider과 유사하다고 볼 수 있다

import { ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  uri: 'http://example.com/graphql', // 서버 주소
  cache: new InMemoryCache() 
});

이렇게 client를 생성해 준 후 최상위 component에서 ApolloProvider를 통해서 방금 생성한 client를 내려주면 된다.

import { ApolloProvider } from '@apollo/client';
import { render } from 'react-dom';

function Index() {
  return (
    <ApolloProvider client={client}>
			<App />
    </ApolloProvider>
  );
}

render(<Index />, document.getElementById('root'));

앞으로 작성할 예제들은 Apollo Client 공식 문서의 get started 항목을 참조한다.

graphQL 서버를 직접 띄워서 서버로부터 받아오는 데이터들을 직접 눈으로 보면서 하고 싶다면 해당 문서를 보는 것을 추천한다!

Query (useQuery)

query는 쉽게 말해서 CRUD에서 R을 담당한다고 볼 수 있다.

apollo client에서 query 요청을 보내고 싶을 때는 useQuery hook을 사용해주면 된다.

import { gql, useQuery } from '@apollo/client';

const GET_DOGS = gql`
  query GetDogs {
    dogs {
      id
      breed
    }
  }
`;

Graphql 문을 작성할 때에는 gql을 불러온 후 백틱으로 쿼리문을 감싸주면 된다.

위 예제는 강아지들의 id와 종을 불러오는 쿼리문을 작성했다. 이제 요청을 보내 보자

function Dogs() {
  const { loading, error, data } = useQuery(GET_DOGS);

  if (loading) return 'Loading...';
  if (error) return `Error! ${error.message}`;

  return (
    <select name="dog" >
      {data.dogs.map(dog => (
        <option key={dog.id} value={dog.breed}>
          {dog.breed}
        </option>
      ))}
    </select>
  );
}

useQuery hook에 방금 전 작성한 GET_DOGS를 넣어주면 결과가 반환된다.

현재 로딩 중인지, 에러가 발생했는지도 그냥 가져다가 쓰기만 하면 되며, 받아온 데이터는 data 변수에 담긴다.

또 같은 GETDOGS 요청에 대한 결과는 로컬에 캐싱이 되어 다른 컴포넌트에서 또 GETDOGS 요청을 보내게 된다면 캐싱된 데이터를 반환하게 된다.


보통 데이터를 가져올 때에는 위처럼 항상 같은 요청을 보낼 때도 있겠지만, 변수를 이용해 동적으로 데이터를 가져오기도 한다. 이번에는 그 방법에 대해서 알아볼 것이다.

우선 이전에 작성했던 Dogs 컴포넌트를 아래와 같이 수정해준다.

function Dogs() {
  const { loading, error, data } = useQuery(GET_DOGS);
  const [dog, setDog] = useState('');
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error! {error}</p>;

  const onDogSelected = (e) => {
    setDog(e.target.value);
  };

  return (
    <>
      <select name='dog' onChange={onDogSelected}>
        {data.dogs.map((dog) => (
          <option key={dog.id} value={dog.breed}>
            {dog.breed}
          </option>
        ))}
      </select>
      {dog && <DogPhoto breed={dog} />}
    </>
  );
}

우리는 이제 option에서 선택된 dog의 breed를 이용해서 해당 dog의 사진을 가져올 것이다. 쿼리문을 작성해보자

const GET_DOG_PHOTO = gql`
  query Dog($breed: String!) {
    dog(breed: $breed) {
      id
      displayImage
    }
  }
`;

위에서 본 쿼리문과 다른 점은 괄호가 생겼다는 점이다. 첫번째 줄

($breed: String!)는 변수로 사용할 breed는 String타입이라는 것을 명시한다. 뒤에 붙은 !는 꼭 필요한 변수라는 뜻이다.

dog(breed: $breed)는 breed 변수로 들어온 breed를 가진 dog를 가져오기 위한 것이다.

그러니까 총 정리를 하자면, 우리는 해당하는 breed와 일치하는 dog의 id와 displayImage를 가져올 것이고 breed는 꼭 필요한 String 타입의 변수라는 것이다,

이제 DogPhoto 컴포넌트를 작성해보자.

function DogPhoto({ breed }) {
  const { loading, error, data } = useQuery(GET_DOG_PHOTO, {
    variables: { breed },
  });

  if (loading) return null;
  if (error) return `Error! ${error}`;

  return (
    <img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
  );
}

Dogs 컴포넌트에서 보내준 breed props에 해당하는 강아지의 이미지를 받아올 수 있다.

useQuery의 두번째 인자로 variables 객체를 보내주면 바로 전에 작성했던 gql 문의 $breed로 들어가게 된다.

Mutation (useMutation)

Mutation 은 CRUD의 CUD를 담당한다고 보면 된다. 데이터를 건들이는 행위는 모두 Mutation을 통해서 이루어지게 된다!

이번 예제는 우리 모두의 친구 to-do list다.

import { gql, useMutation } from '@apollo/client';

const ADD_TODO = gql`
  mutation AddTodo($type: String!) {
    addTodo(type: $type) {
      id
      type
    }
  }
`;

방금 전에 강아지 사진을 받아오던 것과 거의 똑같다. 다른 점은 query가 아니라 mutation이라는 것.

function AddTodo() {
  let input;
  const [addTodo, { data }] = useMutation(ADD_TODO);

  return (
    <div>
      <form
        onSubmit={e => {
          e.preventDefault();
          addTodo({ variables: { type: input.value } });
          input.value = '';
        }}
      >
        <input
          ref={node => {
            input = node;
          }}
        />
        <button type="submit">Add Todo</button>
      </form>
    </div>
  );
}

input에 값을 입력하고 submit 버튼을 누르거나 enter를 입력하면 useMutation으로부터 반환된 addTodo 함수를 실행해 variable에 type을 input에 입력된 값으로 보내게 된다.

그리고 그 결과 data 객체에 새로 생긴 todo item의 id, type을 받게 된다.

변화를 확인하기 위해 현재까지 만들어진 todo item들을 받아오는 query를 작성해보자

const GET_TODOS = gql`
  query GetTodos {
    todos {
      id
      type
    }
  }
`;

function Todos() {
  const { loading, error, data } = useQuery(GET_TODOS);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error :(</p>;
  return data.todos.map(({ id, type }) => {
    return (
      <div key={id}>
        <p>{type}</p>
      </div>
    );
  });
}

Todos 컴포넌트를 작성했고, AddTodo 컴포넌트 아래에 Todo 컴포넌트를 배치했다.

띠용?? 입력을 했지만 아래에 생기지 않는다

새로고침을 하면 아까전에 입력한 데이터들이 생성돼있다. 당연한 결과다.

query는 처음 페이지가 로딩 될 때 요청이 되고 있고, mutation을 보낼 때에 query를 보내고 있지 않기 때문에 이런 결과가 나타나는 것이다

이를 해결해주기 위해서는 useMutation hook에 update 함수를 선언해줘서 update를 할 때마다 해당 쿼리 요청에 대한 캐싱 된 데이터를 수정해주면 된다.

AddTodo 컴포넌트의 useMutation 부분을 아래와 같이 수정해보자

  const [addTodo] = useMutation(ADD_TODO, {
    // 캐시를 직접 조작해서 새로 추가된 todo object를 추가해줌
    update(cache, { data: { addTodo } }) {
      cache.modify({
        fields: {
          todos(existingTodos = []) {
            const newTodoRef = cache.writeFragment({
              data: addTodo,
              fragment: gql`
                fragment NewTodo on Todo {
                  id
                  type
                }
              `,
            });
            return [...existingTodos, newTodoRef];
          },
        },
      });
    },
  });

이제 실시간으로 우리가 생성한 todo가 반영된다. 어메이징…

Subscription (useSubscription)

subscriptionquerymutation과 성질이 약간 다르다. query, mutation이 http 통신을 사용한다면 subscription은 websocket 통신을 위한 operation이다.

그렇기 때문에 우리가 맨 처음에 생성했던 apollo client를 수정해줄 필요가 있다.

import { WebSocketLink } from '@apollo/client/link/ws';

const link = new WebSocketLink({
  uri: 'ws://example.com/graphql',
  options: {
    reconnect: true,
  },
});

const client = new ApolloClient({
  link,
  uri: 'http://example.com/graphql',
  cache: new InMemoryCache(),
});

이번에는 채팅 앱을 예로 들어보자. 공식 문서가 아니라 해당 유튜브 영상을 참고했다.

const GET_MESSAGES = gql`
  subscription {
    messages {
      id
      user
      content
    }
  }
`;

이번에는 subscription으로 messages를 받아오며, id, 해당 메세지를 보낸 유저, 메세지 내용을 받아온다.

그리고 받아온 데이터들을 보여줄 Messages 컴포넌트를 작성해준다.

function Messages({ user }) {
  const { data } = useSubscription(GET_MESSAGES);

  if (!data) return null;
  return data.messages.map(({ id, user: messageUser, content }) => (
    <div
      key={id}>
      {user !== messageUser && (
        <div>
          {messageUser.slice(0, 2).toUpperCase()}
        </div>
      )}
      <div>
        {content}
      </div>
    </div>
  ));
}

이제 메세지를 추가할 Chat 컴포넌트를 작성해보자.

const POST_MESSAGE = gql`
  mutation($user: String!, $content: String!) {
    postMessage(user: $user, content: $content)
  }
`;


function Chat() {
  const [state, setState] = useState({ user: 'Mengkki', content: '' });
  const [postMessage] = useMutation(POST_MESSAGE);

  const onSend = (e) => {
    e.preventDefault();
    if (state.content.length > 0) {
      postMessage({ variables: state });
    }
    setState({ ...state, content: '' });
  };

  return (
    <div>
      <Messages user={state.user} />
      <form className='inputWrapper' onSubmit={onSend}>
        <input
          className='userInput'
          type='text'
          value={state.user}
          onChange={(e) => setState({ ...state, user: e.target.value })}
        />
        <input
          className='contentInput'
          type='text'
          value={state.content}
          onChange={(e) => setState({ ...state, content: e.target.value })}
        />
        <button className='sendButton'>Send</button>
      </form>
    </div>
  );
}

이제 채팅을 해보자!

subscription을 이용해서 실시간 웹소켓 통신까지 이루어냈다!

아직 graphQL 초보지만… 프론트엔드 입장에서 자유롭게 받을 데이터를 정할 수 있다는 데서 오는 매력이 엄청나다

이제 프로젝트에서도 본격적으로 사용하게 될텐데… 가슴이 웅장해진다…

#wecode #위코드


Written by@Mengkki
common fangirl

GitHub