Skip to content

기술공유 03. graphQL과 Apollo 이용하여 리액트에서 상태관리하기

Aeree Cho edited this page Dec 21, 2019 · 1 revision

graphQL과 Apollo 이용하여 리액트에서 상태관리하기



graphQL, Apollo 도입 이유

data over fetching 방지

GraphQL의 개념적 모델은 모든 리소스가 그래프처럼 서로 연결되어있기 때문에 URL을 분리할 필요가 없이 하나의 end point를 가집니다. 서버는 리소스를 가져오는 Query와 변경하는 Mutation만 제공하면 됩니다. 따라서 기존 REST API방식에서 발생했던 데이터 오버 페칭을 방지할수 있습니다.

특히 dropy는 채팅, 슬라이드와 같은 작은 서비스들이 채널이라는 하나의 페이지에서 구현되어야 하므로 각각 컴포넌트에서 다른 api url로 요청을 보내는 것 보다 graphQL을 적용하는 것이 효율적이라고 판단했습니다.

아폴로의 캐싱기능을 활용하여 데이터 fetching과 함께 전역 상태 관리

Apollo가 제공하는 캐싱 기능을 통해서 클라이언트에서 전역으로 상태를 관리하는 방식을 구현해보고자 하였습니다. 이를 통해 리덕스와같은 상태관리 라이브러리를 대체할 수 있다고 판단하였습니다.


graphQL의 단점

Http caching전략을 그대로 사용할 수 없음

HTTP의 캐싱 전략은 http 헤더에 정책을 설정하는 형식으로 이루어집니다. REST API의 경우 URL마다 각각의 캐싱 전략을 적용하는 것이 가능합니다. 그러나 end point가 하나인 graphQL의 경우 이러한 http 캐싱 전략을 그대로 이용하기 어렵습니다.

이를 보완하기 위해 http caching을 지원해야하는 자원관련 서버(웹서버)를 분리하였습니다. 또한 apollo engine을 이용하여 api 요청에 대한 결과를 캐싱하고 있습니다.

파일업로드에 추가비용이 들어감

multipart/formData같은 파일 업로드 방식이 지원되지 않아 파일업로드 시 base64로의 인코딩이 필요합니다.

이러한 비용을 줄이고, 서비스를 작은 단위로 모듈화하기 위해 이미지 컨버터 서버를 분리하였습니다. 파일 업로드 및 해당 파일의 parsing을 담당하는 역할로, REST API 방식으로 개발하였습니다.


graphQL 적용 구조

서버

typeDefs 디렉토리 안에서 도메인에 관련된 타입들을 하나의 파일에 정의해주고 있습니다.

const typeDefs = `
type Chat {
  id: String
  생략
}

extend type Query {
  getChatLogs(channelId: String!): [Chat]
}

extend type Mutation {
  addChat(channelId: String!, message: String!): Chat
}

extend type Subscription {
  chatChanged(channelId: String!): Chat
}
`;

reslover 디렉토리에서 typeDefs에서 정의한 Query, Mutation을 처리하는 로직을 작성해주었습니다.

const addChat = async (_, { channelId, message }, { user, pubsub }) => {
  try {
    const newChat = await new Chat({ 생략 }).save();
    const payload = newChat.toPayload({ author: user });

    pubsub.publish(CHAT_CHANGED, { chatChanged: payload });

    return payload;
  } catch (err) {
    throw new ApolloError(err.message);
  }
};

클라이언트

useQuery, useMutation과 같은 리액트 훅을 이용하여 데이터를 요청하는 로직을 작성하였습니다. 이를 custom hook으로 구현하여 여러 컴포넌트에서 사용할 수 있도록 하였습니다.

const ADD_CHAT = gql`
  mutation AddChat($channelId: String!, $message: String!) {
    addChat(channelId: $channelId, message: $message) {
      id
    }
  }
`;
const useAddChat = () => {
  const [addChat] = useMutation(ADD_CHAT);
  return { mutate: addChat };
};
const { mutate } = useAddChat();
mutate({ variables: { channelId, message } });

graphQL Subscription


Subscription은 graphQL 스펙에서 지원하는 쿼리, 뮤테이션과 같은 오퍼레이션 타입 중 하나입니다.
기본적으로 데이터를 요청하여 가져오는 쿼리와 같은 역할이지만 서버에 특정이벤트가 발생할때 마다 응답한다는 점이 다릅니다.

아폴로에서는 subsription을 쉽게 구현할수 있도록 여러 모듈을 제공하고 있습니다.


apollo link

Apollo Links는 체이닝 가능한 유닛으로 GraphQL 클라이언트가 각 GraphQL 요청을 처리하는 방법을 정의하기 위해 사용됩니다. GraphQL 요청을 시작하면 각 링크의 기능이 하나씩 적용됩니다.

dropy는 네트워크 에러를 처리하는 errorLink, 사용자 인증을 진행하는 authLink를 custom으로 정의하여 사용하고 있습니다.

Subscription구현을 위해서 apollo-link에서 제공하는 웹소켓 링크를 사용할 수 있습니다.
split메소드를 통해 요청이 subscription일떄는 web socket 링크, 그 외는 http 링크로 연결되도록 설정하였습니다.

import { WebSocketLink } from 'apollo-link-ws';
  생략...
const link = from([
  errorLink,
  authLink,
  split(
    ({ query }) => {
      const { kind, operation } = getMainDefinition(query);
      return kind === 'OperationDefinition' && operation === 'subscription';
    },
    wsLink,
    httpLink,
  ),
]);

PubSub


서버에서는 아폴로의 PubSub 모듈을 이용하여 publish-subscribe 패턴을 구현하였습니다.
default로 적용되는 그래프큐엘 엔진이 asyncIterator로 반환되는 publish 이벤트들을 처리합니다.

import { PubSub } from 'apollo-server-express';
const pubsub = new PubSub();

비즈니스 로직에서 이벤트 이름(토픽)과 수행할 resolver, payload를 인자로 주어 publish합니다.

pubsub.publish(SLIDE_CHANGED, { slideChanged: payload });

subscription resolver

withFilter 메소드로 resolver에 넘어온 payload와, 클라이언트에서 넘어온 variables을 비교하여 발행할 데이터를 필터링할수 있습니다.

const slideChanged = {
  subscribe: withFilter(
    (_, __, { pubsub }) => pubsub.asyncIterator(SLIDE_CHANGED),
    (payload, variables) => payload.slideChanged.channelId === variables.channelId,
  ),
};

useSubscription hook

클라이언트는 useSubscription 훅을 이용하여 구독하는 데이터를 fetch할수 있습니다.

const { data, loading, error } = useSubscription(SLIDE_CHANGED, { variables: { channelId } });

슬라이드 동기화에 subscription 적용

  1. 스피커가 페이지를 이동할 때마다 mutation요청을 보냅니다.
  2. server에서 mutation을 처리하고 스피커의 데이터를 publish 합니다.
  3. client는 해당 이벤트를 구독하여 publish 된 데이터를 사용합니다.

채팅에 subscription 적용

채팅을 구현하기 위해서 채팅이 추가될때마다 해당 채널의 모든 채팅 로그를 조회하여 publish하는 방법이 있습니다. 그러나 불특정 다수의 유저가 매우 자주 요청할수 밖에 없는 채팅 데이터를 매번 실어 나르는 것은 리소스 낭비라고 판단하였습니다.


Caching

apollo-client의 cache state를 사용하여 client의 cache를 읽어오고 업데이트 할 수 있습니다. apollo cache는 Query, Mutation으로 네트워킹한 결과를 자동으로 캐쉬에 기록합니다. 이와 별도로 원하는 만큼의 추가 데이터를 관리할 수 있습니다.

Caching을 통해 쿼리 속도를 획기적으로 향상시킬 수 있지만, 단점으로는 데이터가 업데이트되는 Mutation 요청이 진행되었을 때, 페이지에서 새로고침을 하지않는 한 렌더된 데이터가 변하지 않는 현상이 발생한다는 것이 있습니다. 이는 DB를 조회하기 전, Cache 에 같은 Query의 이름이 있으면, 기존에 Cache에 있던 데이터만 가져오기 때문입니다.


mutation 후 자동으로 cache 업데이트하는 방법

위의 문제를 해결하기 위해서는 mutation응답에 캐시 객체를 고유하게 식별 할 수 있는 id 필드가 포함되어야 합니다. 그러나 nesting된 데이터의 일부만 mutation이 일어났을 때 관련된 모든 데이터의 id필드를 포함하는 것은 불가능 합니다. 이러한 상황을 해결하기 위해 fragment를 사용하여 쿼리에서 필드를 공유할 수 있습니다.

혹은 Mutation resolver에 refetchQueries 옵션을 사용하는 방안도 있습니다. 이를 이용하여 mutation완료 후 실행할 쿼리를 지정할 수 있습니다.

혹은 로컬 캐시에 직접 접근하여 writeQuery로 캐시데이터를 업데이트하는 것도 가능합니다.

추가적으로, useQuery 응답 객체에 포함되어있는 fetchMore 메소드와 updateQuery 함께 사용하여 캐시에 데이터를 누적 업데이트 하는 것도 가능합니다.


subscription 데이터를 cache에 업데이트하기

그러나 채팅의 경우 Mutation 후 자동으로 캐시를 업데이트 하는 것이 아니라, 해당 채널에 접속 중인 클라이언트에게 퍼블리싱 된 데이터를 업데이트 해야 합니다. 따라서 직접 캐시에 접근하여 데이터를 업데이트 하는 방식을 사용하고 있습니다.

const useChatChanged = (channelId) => {
  const client = useApolloClient();
  const published = useSubscription(CHAT_CHANGED, { variables: { channelId } });
  ...생략
  const cacheData = client.readQuery({ query: GET_CHAT_CACHED });
  const data = addOrUpdateChat(cacheData, published);

  client.writeQuery({ query: GET_CHAT_CACHED, data });
};
  1. 최초 렌더링 시 채팅 로그를 가져오는 Query수행
  2. 채팅이 추가되는 mutation이 발생하면 데이터가 publish됨
  3. readQuery로 캐시 데이터를 읽고,
  4. publish 된 데이터를 합치고,
  5. writeQuery로 캐시에 다시쓰기

참조

Clone this wiki locally