Apollo Client (React)로 GraphQL 클라이언트 개발하기 (1)

곽태욱·2020년 1월 28일
13

깃허브 : https://github.com/rmfpdlxmtidl/movie-client

이번 글에선 Apollo 클라이언트를 활용해 React에서 외부 GraphQL API 서버로 GraphQL 쿼리를 요청하는 기능을 개발하려고 한다. 사전에 Node.js는 필수로 설치해야 하고, Yarn과 vscode는 설치를 권장한다. 클라이언트 개발에 사용하는 개념은 아래와 같다.

  • React
  • React Router
  • GraphQL
  • Apollo Client (React)
  • Apollo Client Hooks (useQuery)
  • TypeScript

Node.js 설치 : https://velog.io/@gwak2837/Node.js-설치

프로젝트 환경 설정

React 실행 환경 설치

> yarn create react-app app-name --template typescript
cd app-name

React 개발 환경을 TypeScript로 설정한 후 CRA(Create React App)를 이용해서 생성하고 해당 폴더로 이동한다. TypeScript를 사용하지 않으면 --template typescript 옵션을 빼준다. 본 프로젝트는 TypeScript를 사용한다.

Apollo 클라이언트 설치 (v3)

> yarn add @apollo/client graphql

GraphQL API 서버로 GraphQL을 요청하는 라이브러리를 제공하는 Apollo 클라이언트를 설치한다.

Apollo 클라이언트 공식 문서 : https://www.apollographql.com/docs/react/get-started/

React Router 설치

> yarn add react-router-dom @types/react-router-dom

리액트에서 링크를 생성하고 링크 클릭 시 특정 URL로 이동시키는 기능을 제공하는 React router를 설치한다. TypeScript 사용 시에만 @types/react-router-dom를 설치한다. (차후에 Next.js로 구현 예정)

Styled Components 설치 (선택)

> yarn add styled-components

css를 대체할 수 있는 패키지 중 하나이다. 이 시리즈에선 CSS 관련 개념은 다루지 않는다. CSS 파일의 원활한 유지 보수를 위해 2, 3번 방법을 추천한다.

  1. 원래 CSS 방식대로 해도 되고,
  2. Styled Components를 설치해도 되고,
  3. SASS, SCSS, CSS Module 방식대로 해도 된다.

CSS Reset (선택)

브라우저는 각각의 기본 스타일이 서로 다르기 때문에 스타일을 입힐 때 브라우저마다 다르게 보여질 수 있다. 그래서 각 브라우저의 스타일을 초기화하기 위해서 CSS를 초기화하는 파일을 사용한다.

public 폴더에 reset.css 파일을 새로 만들고 https://meyerweb.com/eric/tools/css/reset/ 에 있는 코드를 복사한다. 그리고 아래와 같이 public 폴더 안의 index.html 파일에 <link ... /> 1줄을 추가한다.

// public/index.html
<html>
  <head>
    ...
    <link rel="stylesheet" href="%PUBLIC_URL%/reset.css" />
  </head>
  ...
<html>

TypeScript 컴파일 옵션 설정 (선택)

yarn tsc --init

위 명령어로 TypeScript 컴파일 옵션을 초기화할 수 있다. 자세한 컴파일 옵션은 tsconfig.json에서 설정할 수 있다. 아니면 초기화하지 않고 React에서 기본으로 제공하는 tsconfig.json을 사용해도 된다. tsconfig.json 파일이 해당 프로젝트 내에 존재하면 초기화되지 않으니 초기화를 하려면 CRA에서 기본으로 제공하는 tsconfig.json 파일을 삭제하고 하자.

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "./src"
  }
}

baseUrl 항목을 추가하면 import를 통해 다른 파일을 불러올 때 상대경로가 아닌 src/ 폴더를 기준으로 한 절대경로로 불러올 수 있다. 아래 코드는 src/ 폴더를 기준으로 한 절대경로 import를 사용한다.

TypeScript 컴파일 옵션은 아래 공식 문서에서 확인할 수 있다.
https://www.typescriptlang.org/docs/handbook/compiler-options.html

Prettier 설정 (선택)

// .prettierrc.json
{
  // "printWidth": 80,
  // "tabWidth": 2,
  // "useTabs": false,
  // "semi": true,
  // "singleQuote": false,
  // "quoteProps": "as-needed",
  // "jsxSingleQuote": false,
  // "trailingComma": "es5",
  // "bracketSpacing": true,
  // "jsxBracketSameLine": false,
  // "arrowParens": true
}

자신의 입맛에 맞게 설정하면 된다. 주석으로 설정한 항목은 기본값이다. json은 기본적으로 주석을 허용하지 않기 때문에 실제 파일엔 주석 처리된 옵션은 빼고 사용할 옵션만 적자.

자세한 설명 : https://velog.io/@gwak2837/Typescript-개념#prettier-적용

프로젝트 최종 구조

아래 과정을 잘 따라하면 프로젝트 최종 구조는 위와 같다. 파일 및 폴더 정리는 취향껏 하되 import 경로만 잘 신경써주면 된다.

각 폴더 및 파일 설명

  • .git (신경 안 씀)
    git 관련 설정 파일이 들어있는 폴더

  • node_module (신경 안 씀)
    현재 프로젝트의 여러 패키지 파일이 들어있는 폴더

  • public (거의 신경 안 씀)
    가상 DOM을 사용하는 리액트도 실제 DOM 파일은 필요하다. 즉, 소스 코드에서 선언된 React 컴포넌트가 들어갈 실제 HTML 파일이 존재한다. 일반적으로 이미지 파일 등 클라이언트에게 제공할 파일이 들어있다.

    • favicon.ico : 브라우저 상단 탭 아이콘에 쓰일 이미지 파일
    • index.html : React 가상 DOM이 렌더링돼서 들어가는 실제 DOM 파일
    • manifest.json : 웹 앱 매니페스트는 사용자가 앱을 볼 것으로 예상되는 영역(예: 휴대기기 홈 화면)에 웹 앱이나 사이트를 나타내는 방식을 개발자가 제어하고, 사용자가 시작할 수 있는 항목을 지시하고, 시작 시의 모습을 정의할 수 있는 JSON 파일이다.
    • reset.css : 브라우저의 css 기본값을 초기화해주는 파일
    • robot.txt : 웹 사이트에 웹 크롤러같은 로봇들의 접근을 제어하기 위한 규약이 적혀 있다.
      참고 : https://create-react-app.dev/docs/using-the-public-folder/
  • src (중요)
    프로젝트 관련 모든 JavaScript, CSS 코드가 들어 있다.

  • src/apollo
    Apollo 클라이언트와 관련된 코드를 모아 놓은 폴더로서 클라이언트 설정, 클라이언트 전역 상태 관리, 캐시 정책 등이 정의되어 있다.

  • src/components
    프로젝트에서 쓰일 React 컴포넌트를 모아 놓은 폴더로서 pages 폴더에 있는 컴포넌트와는 다르게 이 폴더에 있는 컴포넌트는 재사용이 목적이다. 그래서 React 앱을 제대로 만들고 싶으면

    • 작고 단단한 컴포넌트를 만들고
    • 이런 컴포넌트 간의 관계를 유기적으로 잘 연결해야 한다.
  • src/pages
    Router.tsx의 Route 컴포넌트에서 사용하는 컴포넌트를 모아 놓은 폴더로서 특정 url로 접속했을 때 화면에 보여지는 일종의 웹 세부 페이지다. components 폴더와는 다르게 이 폴더에 있는 컴포넌트는 하나하나가 웹 세부 페이지라고 생각하면 된다.

  • src/index.tsx (신경 안 씀)
    yarn start 명령어를 실행했을 때 프로그램이 처음으로 진입하는 지점이다.

  • src/interfaces.ts
    TypeScript에서 쓰이는 여러 interfacetype이 정의되어 있는 파일

  • src/Router.tsx
    클라이언트 페이지 라우팅(주소 변경)이 정의되어 있다.

  • .gitignore
    git에 업로드할 때 업로드하지 않을 파일을 설정할 수 있다. node_module 같은 무거운 파일이나 프로젝트 공통 파일, OS 관련 파일, 빌드 결과물은 프로젝트에 필요없거나 따로 다운받을 수 있기 때문에 이러한 파일 목록이 기본값으로 쓰여 있다.

  • .prettierrc.json
    Prettier 세부 옵션을 설정할 수 있다.

  • package.json (거의 신경 안 씀)
    프로젝트의 대략적인 정보가 담겨있다. 해당 프로젝트 버전, 이름, Git 주소, 설명, 저자, 의존하는 패키지 목록, 스크립트 등의 정보가 저장되어 있다.

  • README.md
    마크다운 언어로 쓰여진, 말 그대로 프로젝트에 관한 설명이 담긴 Read me 파일이다.

  • tsconfig.json
    TypeScript 세부 컴파일 옵션을 설정할 수 있다.

  • yarn.lock (신경 안 씀)
    yarn 패키지 잠금 파일로서 프로젝트에 쓰인 패키지의 버전이 명시되어 있다.

    참고 : https://www.daleseo.com/js-package-locks/

기본 제공 파일 수정

// src/index.tsx
import React from "react";
import ReactDOM from "react-dom";
import App from "components/App";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

src 폴더 내 App.tsxindex.tsx를 제외한 모든 파일을 삭제하고 정리한다. 여기서 App.tsx는 일반적으로 최상위 컴포넌트로 쓰이고, index.tsxyarn start 명령어를 실행했을 때 프로그램이 처음으로 진입하는 파일이다.

Apollo 클라이언트 생성

// src/apollo/client.ts
import { ApolloClient, createHttpLink } from "@apollo/client";
import { cache } from "apollo/cache";

const httpLink = createHttpLink({
  uri: "http://localhost:4000/graphql",
});

export const client = new ApolloClient({
  link: httpLink,
  cache: cache,
});

createHttpLink()uri에는 GraphQL API를 사용하는 서버 주소를 적으면 Apollo Link가 반환된다. 이전 글에서 만든 GraphQL 기반 서버를 실행했다면 위 주소를 적는다.

// src/apollo/cache.ts
import { InMemoryCache } from "@apollo/client";

export const cache = new InMemoryCache();

Apollo 클라이언트가 내부적으로 사용하는 캐시를 new InMemoryCache()로 생성한다. 다시 말해 클라이언트 브라우저 메모리에 GraphQL 요청-응답을 캐싱하는 공간을 생성한다는 뜻이다.

HTTP 캐시

캐시란 브라우저에서 서버로 보낸 요청과 그에 대한 응답을 로컬에 저장하고 다시 사용한다는 개념이다. 그래서 나중에 동일한 요청을 동일한 서버로 보냈을 때 네트워크를 거치지 않고 로컬 저장소로부터 응답을 가져올 수 있다. 일반적으론 캐시를 저장한 후 서버 컨텐츠가 업데이트됐을 수도 있기 때문에 서버로 필요한 정보만 보내서 내 로컬에 캐시된 데이터가 아직 유효한지 확인하는 과정(304 Not Modified)을 거친다. Mozilla MDN에 따르면, HTTP를 따르는 요청-응답은 캐시를 아래와 같이 활용한다고 한다.

캐싱의 대상

HTTP 캐싱은 부가 기능이지만 캐시된 리소스를 재사용하는 것은 보통 바람직한 일입니다. 하지만 일반적인 HTTP 캐시들은 GET에 대한 응답을 캐싱하는 것으로 제한되며 다른 메서드들은 제외될 겁니다. 일차 캐시 키는 요청 메서드 그리고 대상 URI로 구성됩니다(종종 GET 요청으로만 사용되는 URI만이 캐싱이 대상이 되곤 합니다).

그래서 GraphQL과 비슷한 위치에 있는 REST API는 기존에 존재하는 HTTP 캐시 정책을 그대로 사용할 수 있다. REST API는 GET, POST 요청을 둘 다 활용해 API를 구성하기 때문이다. 하지만 GraphQL은 요청을 기본적으로 POST 방식으로 보내고 GraphQL endpoint가 1개이기 때문에 HTTP 캐싱 정책을 그대로 활용할 수가 없다. 그래서 Apollo 클라이언트가 GraphQL 캐시 기능을 InMemoryCache로 제공하는 것이다.

GET과 POST

GET은 www.naver.com?var=value&var2=value&...처럼 데이터를 URL에 담아서 서버로 요청을 보내는 방식이고, POST는 데이터를 HTTP Body에 담아서 서버로 요청을 보내는 방식이다.

Apollo 클라이언트 연결

// src/components/App.tsx
import React from "react";
import Router from "Router";
import { ApolloProvider } from "@apollo/client";
import { client } from "apollo/client";

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

export default App;

@apollo/client는 Apollo 클라이언트와 관련된 모든 것이 들어 있는 패키지로서 GraphQL 기반 클라이언트 생성과 GraphQL API 서버와 통신할 수 있는 React Hook 기반 함수, ApolloProvider 등을 제공한다. ApolloProvider는 React Context API의 Provider와 비슷한 역할을 하는데 ApolloProvider로 최상위 컴포넌트인 App을 감싸면 컴포넌트 어디서든 Apollo 클라이언트에 접근할 수 있다.

위와 같이 Apollo 클라이언트와 React를 연결한 후 App.tsx에선 라우터만 불러오고 세부 라우팅 설정은 Router.tsx에서 불러온다.

컴포넌트 라우팅 설정

// src/Router.tsx
import React from "react";
import { HashRouter, Route, Redirect } from "react-router-dom";
import Navigation from "components/Navigation";
import Home from "pages/Home";
import About from "pages/About";
import Detail from "pages/Detail";

function Router() {
  return (
    <HashRouter>
      <Navigation />
      <br />
      <Route path="/" component={Home} exact={true} />
      <Route path="/about" component={About} />
      <Route path="/movie/:id" component={Detail} />
      <Redirect from="*" to="/" />
    </HashRouter>
  );
}

export default Router;

위와 같이 Route 컴포넌트를 추가해 (네비게이션 바에 존재하는) Link 컴포넌트를 클릭했을 때 특정 URL의 컴포넌트로 이동하게 할 수 있다. Router는 <HashRouter /> 대신 다른 걸 사용해도 되고, Navigation 컴포넌트 안에 Link 컴포넌트를 만들어서 다른 페이지로 라우팅할 것이다.

라우터는 현재 주소와 path의 주소가 일치하는 모든 컴포넌트를 화면에 보여준다. 만약 현재 주소가 /about이라면 Home 컴포넌트와 About 컴포넌트를 화면에 보여준다. 현재 주소가 /movie/1이라면 Home 컴포넌트와 Detail 컴포넌트를 화면에 보여준다. 일반적으로 우리는 이러한 방식을 원하지 않기 때문에 다른 컴포넌트의 렌더링을 방지하기 위해 Route 컴포넌트에 exact={true}를 전달한다. 그러면 현재 주소와 정확히 일치하는 컴포넌트만 화면에 보여진다.

그리고 Redirect를 사용하면 리다이렉팅 기능을 사용할 수 있고, 위 코드는 경로가 Route와 하나도 일치하지 않는 경우 /으로 리다이렉팅한다는 뜻이다.

// src/components/Navigation.tsx
import React from "react";
import { Link } from "react-router-dom";

function Navigation() {
  return (
    <div className="nav">
      <Link to="/">Home</Link> <Link to="/about">About</Link>
    </div>
  );
}

export default Navigation;

모든 페이지 상단에 있는 네비게이션 바로서 클릭 시 다른 페이지로 이동할 수 있는 링크를 제공한다. /로 갈 수 있는 Home 버튼과 /about으로 갈 수 있는 About 버튼을 가지고 있다.

세부 페이지 설정

// src/pages/About.tsx
import React from "react";

function About() {
  return <div>About Page</div>;
}

export default About;

간단하게 구성한 About 페이지다. 화면 상단 네비게이션 바에서 About 버튼을 클릭하면 이 컴포넌트가 화면에 렌더링된다.

정적 매개변수 쿼리

// src/pages/Home.tsx
import React from "react";
import { gql, useQuery } from "@apollo/client";
import Movie from "components/Movie";
import { IMovie, IMoviesData } from "interfaces";
import Error from "components/Error";
import Loading from "components/Loading";

const GET_MOVIE = gql`
  query {
    movies {
      id
      name
    }
  }
`;

function Home() {
  const { loading, error, data } = useQuery<IMoviesData>(GET_MOVIE);
  const movies = data?.movies.length !== 0 ? data?.movies : null;

  if (loading) return <Loading />;
  if (error) return <Error msg={error.message} />;

  return (
    <div>
      {movies?.map((movie: IMovie) => (
        <Movie key={movie.id} id={movie.id} name={movie.name} />
      )) ?? "No movie..."}
    </div>
  );
}

export default Home;

GraphQL API 서버로 매개변수가 없는(매개변수가 정적인) 쿼리 요청을 보내는 부분이다. GraphQL Playground에서 사용한 쿼리문을 그대로 쓸 수 있어 직관적으로 쿼리를 작성할 수 있다.

쿼리문을 gql로 감싸준 후 useQuery에 넘겨주면 Apollo 클라이언트가 GraphQL 서버로 GraphQL 쿼리를 요청하고 응답을 반환한다.

useQuery()의 반환값은 3가지로 구성되어 있는데 loading, error, data는 각각 데이터를 받아왔는지, 도중에 어떤 에러가 발생했는지, 응답 데이터를 의미한다. 쿼리 전송 후 반환되는 data의 구조는 useQuery에 첫번째 제네릭으로 넘겨준다. 여기선 interface IMoviesData이다.

data 뒤의 ?Optional Chaining으로서 data가 null이 아닌 경우만 movies를 조회해 오류 발생을 방지한다.

위의 Home 컴포넌트는 서버에 쿼리 요청 중이면 화면에 Loading...을 표시하고, 로딩이 완료되면 각 영화 데이터를 위에서 만든 Movie 컴포넌트에 전달해 영화 세부 페이지로 갈 수 있는 링크를 생성한다. 만약 서버로부터 빈 배열을 반환받으면 화면에 No Movie...를 표시한다.

동적 매개변수 쿼리

// src/pages/Detail.tsx
import React from "react";
import { useParams } from "react-router-dom";
import { gql, useQuery } from "@apollo/client";
import { IMovieData, IMovieVars } from "interfaces";
import Error from "components/Error";
import Loading from "components/Loading";

const GET_MOVIE = gql`
  query getMovie($id: Int!) {
    movie(id: $id) {
      id
      name
      rating
    }
  }
`;

function Detail() {
  const { id } = useParams<{ id: string }>();
  const { loading, error, data } = useQuery<IMovieData, IMovieVars>(GET_MOVIE, {
    variables: { id: Number(id) },
  });

  if (loading) return <Loading />;
  if (error) return <Error msg={error.message} />;

  // 서버로부터 받은 데이터가 있으면 영화 정보를 반환하고
  // 없으면 'No Detail...' 반환
  return (
    <div>
      {data?.movie ? (
        <>
          <div>Name : {data.movie.name}</div>
          <div>Rating : {data.movie.rating}</div>
        </>
      ) : (
        "No Detail..."
      )}
    </div>
  );
}

export default Detail;

GraphQL 쿼리문의 매개변수를 동적으로 설정할 땐 매개변수의 자료형을 명시해야 한다. 그래서 query getMovie($id: Int!) {}로 GraphQL 쿼리문을 감싸서 매개변수 자료형이 정수이고 null이 아니라는 것을 명시한다. 이 자료형은 GraphQL 서버 API와 일치해야 한다. 대신 getMovie라는 이름은 아무거나 해도 된다.

실제 넘겨줄 매개변수 값은 useQuery()의 2번째 매개변수의 variables 항목으로 전달한다. variables 항목에 id 항목을 만들면 쿼리를 보낼 때 쿼리 매개변수의 $idvariables 안의 id 값으로 치환된다.

useQuery()의 제네릭을 활용해 반환되는 값인 data의 구조와 자료형은 interface IMovieData로, 동적 매개변수인 variables의 구조와 자료형은 interface IMovieVars로 명시한다.

Detail 페이지는 서버로부터 응답을 기다리는 중이면 화면에 Loading...을 표시하고, 로딩이 완료되면 영화 세부 정보를 화면에 표시한다. 만약 서버로부터 받은 데이터가 없으면 화면에 No Detail...를 표시한다.

기타 컴포넌트

// src/components/Error.tsx
import React from "react";

function Error({ msg }: { msg: string }) {
  return <p>An error occured. Error: {msg}</p>;
}

export default Error;

오류가 발생했을 때 화면에 보여지는 컴포넌트로서 컴포넌트 속성으로 받은 오류 메시지를 출력한다.

// src/components/Loading.tsx
import React from "react";

function Loading() {
  return <p>Loading...</p>;
}

export default Loading;

데이터를 불러오는 중일 때 화면에 보여지는 컴포넌트로서 여기에 로딩 스피너 등 로딩 애니메이션을 넣어주면 좋다.

// src/components/Movie.tsx
import React from "react";
import { Link } from "react-router-dom";
// import { IMovie } from "interfaces";

function Movie({ id, name }: { id: number; name: string }) {
  return (
    <div>
      {" "}
      <Link to={`/movie/${id}`}>{name}</Link>
    </div>
  );
}

export default Movie;

Movie 컴포넌트는 컴포넌트 속성(properties, props)으로 idname을 받아 /movie/${id}로 갈 수 있는 링크를 생성한다. 예를 들어 id가 2면 해당 링크를 클릭했을 때 /movie/2로 이동한다. 링크 이름은 name으로 표시된다. {" "}은 링크 사이의 공백을 의미한다.

// src/interfaces.ts
export interface IMovie {
  id: number;
  name: string;
  rating: number;
}

export interface IMovieData {
  movie: IMovie;
}

export interface IMovieVars {
  id: number;
}

export interface IMoviesData {
  movies: IMovie[];
}

그리고 Movie 컴포넌트가 받는 컴포넌트 속성의 구조와 자료형을 TypeScript의 interface 또는 type를 이용해 명시한다. 각각의 자료형은 GraphQL 서버의 스키마 파일이나 GraphQL Playground에 나와 있는 스키마를 직접 참고해서 작성한다.

클라이언트 실행

> yarn start

React 실행하는 것과 동일하게 실행할 수 있다.

실행 화면

처음에 실행하면 아래와 같은 화면이 나온다. 여기서 Home 버튼을 누르면 Home 화면으로 이동하고, About 버튼을 누르면 About 페이지로 이동하고, 영화 이름을 누르면 영화 세부 정보 페이지로 이동한다.

Home

About

영화 세부 정보 페이지

profile
이유와 방법을 알려주는 메모장 겸 블로그. 블로그 내용에 대한 토의나 질문은 언제나 환영합니다.

5개의 댓글

comment-user-thumbnail
2020년 6월 22일

많이 배웁니다 ^^

1개의 답글
comment-user-thumbnail
2021년 3월 17일

안녕하세요. 포스팅 내용 중 질문이 있습니다...
다름이 아니라, inmemorycache 함수 관련 내용중에 아래와 같이 표현을 하셨는대요,
'GraphQL은 요청을 POST 방식으로만 보내기 때문에 HTTP 캐싱 정책을 그대로 활용할 수가 없다.'
이 graphql 공식 문서에는 https://graphql.org/learn/serving-over-http/
'GraphQL HTTP 서버는 HTTP GET, POST 메서드를 처리해야합니다.'
라고 명시가 되어있어서요.
혹시 GraphQL 요청을 POST 방식으로만 보낸다는 내용을 어디서 확인하셨는지 알 수 있을까요?
저도 왜 굳이 inmemorycache를 쓰나 궁금해서 이것저것 찾아보고 있는데.. 잘 안나와서요. ㅎㅎ..

1개의 답글