이번 글에선 Apollo 클라이언트를 활용해 React에서 외부 GraphQL API 서버로 GraphQL 쿼리를 요청하는 기능을 개발하려고 한다. 사전에 Node.js는 필수로 설치해야 하고, Yarn과 vscode는 설치를 권장한다. 클라이언트 개발에 사용하는 개념은 아래와 같다.
Node.js 설치 : https://velog.io/@gwak2837/Node.js-설치
> yarn create react-app app-name --template typescript
cd app-name
React 개발 환경을 TypeScript로 설정한 후 CRA(Create React App)를 이용해서 생성하고 해당 폴더로 이동한다. TypeScript를 사용하지 않으면 --template typescript
옵션을 빼준다. 본 프로젝트는 TypeScript를 사용한다.
> yarn add @apollo/client graphql
GraphQL API 서버로 GraphQL을 요청하는 라이브러리를 제공하는 Apollo 클라이언트를 설치한다.
Apollo 클라이언트 공식 문서 : https://www.apollographql.com/docs/react/get-started/
> yarn add react-router-dom @types/react-router-dom
리액트에서 링크를 생성하고 링크 클릭 시 특정 URL로 이동시키는 기능을 제공하는 React router를 설치한다. TypeScript 사용 시에만 @types/react-router-dom
를 설치한다. (차후에 Next.js로 구현 예정)
> yarn add styled-components
css를 대체할 수 있는 패키지 중 하나이다. 이 시리즈에선 CSS 관련 개념은 다루지 않는다. CSS 파일의 원활한 유지 보수를 위해 2, 3번 방법을 추천한다.
브라우저는 각각의 기본 스타일이 서로 다르기 때문에 스타일을 입힐 때 브라우저마다 다르게 보여질 수 있다. 그래서 각 브라우저의 스타일을 초기화하기 위해서 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>
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
// .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에서 쓰이는 여러 interface
나 type
이 정의되어 있는 파일
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.tsx
와 index.tsx
를 제외한 모든 파일을 삭제하고 정리한다. 여기서 App.tsx
는 일반적으로 최상위 컴포넌트로 쓰이고, index.tsx
는yarn start
명령어를 실행했을 때 프로그램이 처음으로 진입하는 파일이다.
// 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 요청-응답을 캐싱하는 공간을 생성한다는 뜻이다.
캐시란 브라우저에서 서버로 보낸 요청과 그에 대한 응답을 로컬에 저장하고 다시 사용한다는 개념이다. 그래서 나중에 동일한 요청을 동일한 서버로 보냈을 때 네트워크를 거치지 않고 로컬 저장소로부터 응답을 가져올 수 있다. 일반적으론 캐시를 저장한 후 서버 컨텐츠가 업데이트됐을 수도 있기 때문에 서버로 필요한 정보만 보내서 내 로컬에 캐시된 데이터가 아직 유효한지 확인하는 과정(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은 www.naver.com?var=value&var2=value&...
처럼 데이터를 URL에 담아서 서버로 요청을 보내는 방식이고, POST는 데이터를 HTTP Body에 담아서 서버로 요청을 보내는 방식이다.
// 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 항목을 만들면 쿼리를 보낼 때 쿼리 매개변수의 $id
가 variables
안의 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)으로 id
와 name
을 받아 /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 페이지로 이동하고, 영화 이름을 누르면 영화 세부 정보 페이지로 이동한다.
안녕하세요. 포스팅 내용 중 질문이 있습니다...
다름이 아니라, inmemorycache 함수 관련 내용중에 아래와 같이 표현을 하셨는대요,
'GraphQL은 요청을 POST 방식으로만 보내기 때문에 HTTP 캐싱 정책을 그대로 활용할 수가 없다.'
이 graphql 공식 문서에는 https://graphql.org/learn/serving-over-http/
'GraphQL HTTP 서버는 HTTP GET, POST 메서드를 처리해야합니다.'
라고 명시가 되어있어서요.
혹시 GraphQL 요청을 POST 방식으로만 보낸다는 내용을 어디서 확인하셨는지 알 수 있을까요?
저도 왜 굳이 inmemorycache를 쓰나 궁금해서 이것저것 찾아보고 있는데.. 잘 안나와서요. ㅎㅎ..
많이 배웁니다 ^^