thumnail-min.jpg

리액트에는 ContextAPI, Redux, MobX 등 전역으로 상태관리를 하기 위한 많은 선택지가 있다. Apollo Client도 그 중 하나의 선택지로 로컬에서 전역 상태관리를 하기위해 사용할 수 있다.

원래 Apollo Client는 GraphQL을 사용해 서버와 통신을 하며 반환 값을 캐시로 보관하는 상태 관리 라이브러리이다. 하지만 일부 서비스는 서버 없이 완전히 독립적으로 작동할 수 있고, Apollo Client는 그런 경우에도 로컬 상태를 관리할 수 있다. 즉, Apollo Client만 사용해 전역 상태관리를 할 수 있다.

물론 서버가 있는 경우에도 GraphQL 통신으로 가져온 상태와 로컬 상태 모두 함께 관리할 수 있다. 그렇기 때문에 이미 클라이언트에서 Apollo Client를 사용하고 있다면 굳이 Redux나 MobX같은 추가적인 상태관리 라이브러리를 사용하지 않고도 충분히 전역 상태관리를 적용할 수 있다. 😀

작동 원리

Apollo Client는 요청한 쿼리의 필드를 캐싱해 상태를 저장한다.

apollo-1-min.png

다음은 Apollo Client가 쿼리를 통해 서버로 필드를 요청하는 과정이다.

  1. 쿼리를 통해 로컬 및 원격 필드 요청
  2. 캐쉬는 요청받은 쿼리를 통해 동일한 쿼리가 캐싱되어있는지 확인
  3. 캐싱되어있지 않다면 서버로 원격 필드 요청
  4. 서버는 요청받은 필드를 확인 후 반환
  5. 캐쉬는 반환받은 필드를 캐싱 후 클라이언트에게 반환

다음 과정을 통해 클라이언트는 쿼리를 요청해 원하는 필드를 반환 받을 수 있다. 그럼 캐싱되어있는 쿼리가 있다면 어떻게 가져올까? 🤔

apollo2-min.png

다음은 Apollo Client가 캐싱되어있는 쿼리의 필드를 반환받는 과정이다.

  1. 동일한 쿼리로 필드 요청
  2. 캐쉬는 요청받은 쿼리를 통해 동일한 쿼리가 캐싱되어있는지 확인
  3. 캐싱되어 있다면 캐싱된 필드를 서버에 요청하지 않고 바로 반환

다음 과정을 통해 클라이언트는 캐싱되어 있는 필드를 서버에 요청하지 않고 바로 가져와 사용할 수 있다. 이 작동원리를 보면 알 수 있듯이 Apollo Client는 캐쉬를 통한 상태관리를 하고있고 이 흐름대로 로컬상태 또한 관리할 수 있다.

Apollo Client는 모든 쿼리를 위의 동작과정을 통해 처리하지 않는다. Cache를 통해 캐싱된 필드를 가져올 것인지, 서버에 요청할 것인지에 대한 정책을 Apollo Client의 캐시 관리 기법을 보고 선택할 수 있다.

로컬 상태 관리

Apollo Client는 위에서 소개한 동작 원리 흐름을 지원하기 위해 Apollo Client 3부터 새로운 상태관리 기법을 도입했다. (기존에는 로컬 리졸버 API를 사용해 로컬 상태관리를 적용했지만 더이상 지원되지 않음)

새로운 상태관리 기법으로는 필드 정책을 도입했는데 필드 정책을 통해 GraphQL 서버의 스키마에 정의되지 않은 필드를 포함하여 특정 필드를 쿼리할 때 발생하는 작업들을 정의할 수 있게 되었다. 즉, 클라이언트에 독립적인 쿼리와 그에 따른 작업을 정의할 수 있다. 그렇다면 간단한 Todo 리스트를 만들며 로컬 상태 관리를 적용해보자!

설치

npx create-react-app apollo-todolist --template typescript

빠른 프로젝트 설정을 위해 CRA를 사용해 리액트 프로젝트를 시작했다. 만약 CRA가 아니라 웹팩, 바벨부터 설정을 하려면 CRA없이 React + TypeScript 셋팅하기를 참고해서 설정하면 된다.

yarn add graphql @apollo/client

GraphQL과 Apollo Client를 사용하기 위해 다음 모듈도 설치한다.

Apollo 기본 설정

// src/apollo.ts

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

const cache = new InMemoryCache();

const client = new ApolloClient({
  cache,
});

export default client;

우선 Apollo Client를 사용하기 위해 src/apollo.ts에 다음과 같이 설정을 해준다. 따로 서버에 보낼 요청이 없기 때문에 link 등의 설정은 하지 않고 cache만 생성해서 넣어줬다.

// src/App.tsx

import { FC } from 'react';
import { ApolloProvider } from '@apollo/client';

import apollo from './apollo';

const App: FC = () => (
  <ApolloProvider client={apollo}>
  </ApolloProvider>
);

export default App;

생성한 Apollo Client를 src/App.tsxApolloProvider에 넣어줘서 하위 컴포넌트들이 Apollo Client를 사용할 수 있도록 설정했다. 이 부분은 Redux의 Provider와 비슷하게 생각하면 된다.

Todo 리스트 폼 만들기

Todo 리스트의 폼을 만드는 부분은 그렇게 중요한 내용은 아니므로 간단한 설명으로 빠르게 코드를 작성해보자!

// src/components/TodoForm.tsx

import { FC, useState } from 'react';

const TodoForm: FC = () => {
  const [content, setContent] = useState('');

  const onSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // Todo 저장 로직
    setContent('');
  };

  const onChangeContent = (e: React.ChangeEvent<HTMLInputElement>) => {
    setContent(e.target.value);
  };

  return (
    <form onSubmit={onSubmit}>
      <input type="text" onChange={onChangeContent} value={content} placeholder="내용" />
      <button type="submit">입력</button>
    </form>
  );
};

export default TodoForm;

Todo를 입력할 폼이다. 간단하게 inputbutton을 사용해 값을 입력받을 수 있도록 구성했다.

// src/components/TodoItem.tsx

import { FC } from 'react';

const TodoItem: FC = () => {
  const removeItem = () => {
    // remove
  };

  return (
    <div>
      <input type="checkbox" />
      <span>Todo 내용</span>
      <span onClick={removeItem}></span>
    </div>
  );
};

export default TodoItem;

Todo의 내용을 표시해줄 컴포넌트다. 중간의 Todo내용 부분에는 나중에 가져온 Todo의 content데이터를 표시하고 ❌를 누르면 Todo를 제거할 수 있도록 구현할 예정이다.

// src/components/TodoItem.tsx

import { FC } from 'react';

import TodoItem from './TodoItem';

const TodoList: FC = () => {
  return (
    <section>
      <TodoItem />
    </section>
  );
};

export default TodoList;

그리고 Todo를 리스트형태로 출력해줄 컴포넌트이다. Todo데이터를 Apollo Client로부터 받아와 출력하는 역할을 할 것이다.

// src/App.tsx

import { FC } from 'react';
import { ApolloProvider } from '@apollo/client';
import TodoForm from './components/TodoForm';
import TodoList from './components/TodoList';

import apollo from './apollo';

const App: FC = () => (
  <ApolloProvider client={apollo}>
    <TodoForm />
    <TodoList />
  </ApolloProvider>
);

export default App;

만든 컴포넌트들은 App컴포넌트에서 불러와 표시한다. 이제 Apollo Client를 사용해 Todo 리스트의 기능을 만들어보자!

스토어, 쿼리 및 Todo 추가 함수

// src/stores/todo.ts

import { makeVar } from '@apollo/client';

export interface Todo {
  id: number;
  content: string;
}
const todoIdCounterVar = makeVar(0);
const todoVar = makeVar<Todo[]>([]);

export const addTodo = (content: string) => {
  const prevId = todoIdCounterVar();
  const currentTodo = todoVar();
  const newTodo = { id: prevId + 1, content };
  todoVar([...currentTodo, newTodo]);
  todoIdCounterVar(prevId + 1);
};

export default todoVar;

Todo 데이터를 저장하기 위한 스토어를 만들었다. Apollo Client에서 제공하는 makeVar를 통해 반응 변수라는 것을 만들 수 있는데, 이 반응 변수는 Apollo Client 캐시 외부에 로컬 상태를 저장하기 위해 사용된다.

makeVar를 통해 2개의 반응변수를 생성하게 되는데 Todo의 id를 카운팅하기 위한 todoIdCounterVar와 Todo 데이터를 보관할 todoVar를 만들면 된다.

반응변수는 todoVar()와 같이 인자를 넣지 않고 호출하면 해당 반응 변수의 값이 반환되고 todoVar(data)와 같이 인자를 넣고 호출하면 해당 인자의 값으로 반응 변수가 업데이트 된다. 이러한 구조를 사용해 addTodo라는 함수를 만들었는데 말 그대로 todoVar에 새 Todo 데이터를 추가해주는 함수이다.

// src/apollo.ts

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

import todoVar from './store/todo';

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        getTodos: {
          read() {
            return todoVar();
          },
        },
      },
    },
  },
});

const client = new ApolloClient({
  cache,
});

export default client;

이제 만든 Todo 데이터를 GraphQL 쿼리로 조회할 수 있도록 로컬 전용 필드에 등록해야 한다. 다음과 같이 InMemoryCachetypePolicies에 정의를 해주면 된다.

그리고 그 안에 fileds 프로퍼티 안에 정의할 필드를 작성하면 된다. Todo데이터를 가져오는 뜻 그대로 getTodos로 작성했고 read함수를 실행하면 todoVar를 실행해 Todo데이터를 반환해주도록 설정했다.

read함수가 정의된 필드는 쿼리 요청이 들어올 때마다 캐시는 해당 함수를 호출하여 필드 값을 계산한다. 즉, 로컬 상태는 캐싱된 데이터를 사용하지 않기 때문에 read함수를 통해 데이터를 조회해야 하고 데이터 조회는 반응 변수를 통해 작동된다.

Apollo Client 자체에서 쿼리 없이 로컬의 상태만 가져오는 기능이 추가되었기 때문에 상태를 쿼리를 통해 서버의 필드와 함께 관리할 것이 아니라면 작성하지 않아도 된다.

// src/queries.todo.ts

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

export const GET_TODOS = gql`
	query {
    getTodos @client
  }
`;

/* 서버에 요청하는 쿼리의 필드에도 로컬 전용 필드를 추가해 동시에 가져올 수 도 있다.
  query userInfo {
    name
    phone
    getTodos @client
  }
 */

그리고는 이제 Todo 데이터를 조회할 쿼리를 구현한다. 앞써 typePolicies에 정의한 getTodos와 같은 이름으로 쿼리를 작성해 호출하면 된다. 다만 쿼리 뒤에 @client를 작성해 로컬 전용 필드임을 명시해줘야 한다.

Apollo Client 자체에서 쿼리 없이 로컬의 상태만 가져오는 기능이 추가되었기 때문에 상태를 쿼리를 통해 서버의 필드와 함께 관리할 것이 아니라면 작성하지 않아도 된다.

Todo 추가 기능 적용

// src/components/TodoForm.tsx

import { FC, useState } from 'react';

import { addTodo } from '../store/todo';

const TodoForm: FC = () => {
  const [content, setContent] = useState('');

  const onSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    addTodo(content); // todo 추가
    setContent('');
  };

  const onChangeContent = (e: React.ChangeEvent<HTMLInputElement>) => {
    setContent(e.target.value);
  };

  return (
    <form onSubmit={onSubmit}>
      <input type="text" onChange={onChangeContent} value={content} placeholder="내용" />
      <button type="submit">입력</button>
    </form>
  );
};

export default TodoForm;

Apollo Client에서 관리하는 상태 업데이트는 아주 간단하다. 반응 변수를 호출할 때 인자로 업데이트할 상태값을 넣어주면 된다. 좀 전에 만든 store의 addTodo함수에 해당 업데이트 기능을 모두 넣어놨으므로 content만 넣어 호출하면 Todo 추가 기능이 적용된다.

하지만 아직 Todo 조회기능을 적용하지 않아 제대로 추가기능이 적용되었는지 확인이 어렵다. 바로 Todo 조회기능도 적용해보자!

Todo 조회 기능 적용

// src/components/TodoItem.tsx

import { FC } from 'react';

import { Todo } from '../store/todo';

interface Props {
  todo: Todo;
}

const TodoItem: FC<Props> = ({ todo }) => {
  const removeItem = () => {
    // remove
  };

  return (
    <div>
      <input type="checkbox" />
      <span>{todo.content}</span>
      <span onClick={removeItem}></span>
    </div>
  );
};

export default TodoItem;

우선 TodoItem컴포넌트에 Todo 데이터의 타입과 보여줄 컨텐츠를 설정한다.

// src/components/TodoList.tsx

import { FC } from 'react';
import { useReactiveVar, useQuery } from '@apollo/client';

import todoVar from '../store/todo';
import TodoItem from './TodoItem';
import { GET_TODOS } from '../queries/todo';

const TodoList: FC = () => {
  //   const { data } = useQuery(GET_TODOS);
  const todos = useReactiveVar(todoVar);

  return (
    <section>
      {todos.map((todo) => (
        <TodoItem todo={todo} key={`todo_${todo.id}`} />
      ))}
    </section>
  );
};

export default TodoList;

그리고 TodoList컴포넌트에서 useQuery를 사용해 Todo 데이터를 쿼리로 가져올 수 있다. 하지만 따로 서버의 데이터와 같이 가져오는 것이 아니라면 useReactiveVar를 통해 직접 반응 변수에서 상태를 가져올 수 있다. (이 경우에는 cache에 정의를 하지 않아도 되고 쿼리를 작성하지 않아도 된다)

만들고 있는 Todo 리스트는 서버와 통신을 하지 않기 때문에 useReactiveVar를 사용했다. (사실 이 경우엔 cachetypePolicies에 정의하지 않고 쿼리도 만들지 않아도 되지만 학습 차원에서 만들어봤다)

게다가 useQuery를 사용하면 Todo 데이터가 변경될 때마다 Todo 데이터를 사용하는 모든 쿼리가 다시 트리거 되지만 useReactiveVar를 사용하면 독립적이므로 성능적으로도 유리하다.

Todo 제거 기능 적용

// src/store/todo.ts
...
export const deleteTodo = (id: number) => {
  const currentTodo = [...todoVar()];
  const deleteIndex = currentTodo.findIndex((todo) => todo.id === id);

  if (deleteIndex === -1) return;

  currentTodo.splice(deleteIndex, 1);
  todoVar(currentTodo);
};
...

일단 Todo 데이터를 보관하는 store파일에 다음과 같이 Todo 데이터를 제거하는 deleteTodo함수를 구현했다.

// src/components/TodoItem.tsx

import { FC } from 'react';

import { Todo, deleteTodo } from '../store/todo';

interface Props {
  todo: Todo;
}

const TodoItem: FC<Props> = ({ todo }) => {
  const removeItem = () => {
    deleteTodo(todo.id); // todo 제거
  };

  return (
    <div>
      <input type="checkbox" />
      <span>{todo.content}</span>
      <span onClick={removeItem}></span>
    </div>
  );
};

export default TodoItem;

그리고 TodoItem컴포넌트에서 ❌를 클릭했을 때 deleteTodo를 실행 해 해당 Todo 데이터를 제거하는 기능을 적용했다. 그럼 이제 개발 서버를 켜서 확인해보자!

todo.gif

다음과 같이 데이터 입력과 제거, 모두 잘 동작하는 Todo 리스트가 완성되었다. 사실 위의 과정을 보면 알듯이 상태관리 코드는 정말 간단하다. 실제로 주관적인 생각이지만 상태관리 라이브러리 중 recoil다음으로 쉽게 상태를 관리할 수 있는 라이브러리라고 생각한다. (하지만 Apollo Client를 설치해야 사용할 수 있어서 단독으로 사용하기엔 다른 라이브러리에 비해선 무거운 편)

결론

뭔가 기능 구현보다는 동작 원리와 다른 사용법에 대해 많이 알아본 것 같다. 그만큼 기능 구현 자체가 엄청나게 간단했다. 지금 보고 있는 이 블로그도 Apollo Client로 구현되었고 상태관리또한 Apollo Client를 사용하고 있는데 확실히 그냥 사용할때와 포스팅하면서 자세히 알아보는 것은 다른 것 같다. 확실히 좀 더 자게하게 알아가는 것 같다. 특히 최근 추가된 useReactiveVar의 경우 아주 유용하게 사용할 것 같다. 😁

본 포스트는 다음 문서를 참고해 작성했습니다.

https://www.apollographql.com/docs/react/