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

곽태욱·2020년 2월 18일
4

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

이번 글에선 회원가입/로그인/로그아웃 기능을 구현한 GraphQL 서버와 Apollo 클라이언트를 연결하려고 한다. 클라이언트 개발에 새로 사용하는 개념은 아래와 같다.

  • React Hooks (useState, useEffect)
  • Apollo Client Hooks (useMutation)
  • Apollo Client Local State
// src/interfaces.ts
...

export interface ILogin {
  ID: string;
  passwordHash: string;
}

export interface IUser {
  id: number;
  name: string;
  ID: string;
  passwordHash: string;
  role: string[];
  token: string;
}

export interface ILoginData {
  login: IUser | null;
}

export interface ILoginVars {
  ID: string;
  password: string;
}

export interface ILogoutData {
  logout: boolean;
}

export interface ISignupData {
  signup: boolean;
}

export interface ISignupVars {
  name: string;
  ID: string;
  password: string;
}

export interface ICurrentUserData {
  user: IUser;
}

우선 위와 같이 interfaces.ts 파일에 아래에서 쓰일 새로운 자료형을 추가한다.

클라이언트 전역 상태

// src/apollo/cache.ts
import { ..., makeVar, gql } from "@apollo/client";
import { IUser } from "interfaces";

// Local states
export const currentUserVar = makeVar<IUser | null>(null);

// Local queries
export const GET_CURRENT_USER = gql`
  query {
    user @client
  }
`;

// Field policy of local states
export const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        user() {
          return currentUserVar();
        },
      },
    },
  },
});

Apollo 클라이언트는 클라이언트에서만 접근할 수 있는 전역 상태 관리 방식을 제공한다. 2가지 방법이 있는데 첫번째는 Apollo 클라이언트 v3에서 새로 등장한 반응형 변수(Reactive variable)을 이용하는 것이고, 두번째는 InMemoryCache만을 이용하는 것이다. 이 글에선 반응형 변수를 이용한다.

Apollo에서 제공하는 makeVar로 반응형 변수를 생성하고 Apollo 캐시의 필드 정책(Field policy)를 설정하면 GraphQL 쿼리를 로컬로 보낼 수 있다. 아래에서 클라이언트 전역 상태의 각 개념을 자세히 살펴보려고 한다.

// Local query
const GET_DATA = gql`
  query {
    user @client
    movie {
      name
      rating
    }
  }
`;

기본적으로 GraphQL 쿼리에서 @client를 붙인 필드는 로컬에 요청하고 그 외 필드는 외부 GraphQL API 서버에 요청한다. 위 코드에선 user는 로컬에 요청하고 movie는 외부 GraphQL API 서버에 요청한다. 만약 로컬 또는 외부 서버로부터 오류가 발생하면 위 쿼리는 값을 반환하지 않는다. 두 경우 모두에서 쿼리를 성공했을 때 그 결과를 합쳐서 반환해준다.

// Local state
const currentUserVar = makeVar<IUser | null>(null);

// Field policy of local state
const config: InMemoryCacheConfig = {
  typePolicies: {
    Query: {
      fields: {
        user() {
          return currentUserVar();
        },
        ...
      },
    },
  },
}

/* 아래 두 항목은 서로 동일하다.
user() {
  return currentUserVar();
} 

user: { 
  read() {
    return currentUserVar();
} */

쿼리가 로컬로 요청됐을 때 어떻게 응답할지를 캐시 필드 정책에서 설정할 수 있다. 위 코드는 user 필드를 가진 쿼리가 로컬로 요청되면 currentUserVar() 값을 반환한다는 뜻이다.

currentUserVar(새로운 상태);

그리고 반응형 변수에 첫번째 인자로 값을 넘겨주면 반응형 변수의 상태가 업데이트된다. 이렇게 반응형 변수 상태가 변하면 Apollo 클라이언트는 이 변수 값을 참조하는 모든 로컬 쿼리를 재실행해서 새로운 상태가 해당 컴포넌트 UI에 반영될 수 있도록 한다.

2020년 9월 기준 React Fast Refresh 시 반응형 변수 상태가 초기화되는 이슈가 있다.

Apollo 클라이언트 전역 상태 관리 공식 문서
https://www.apollographql.com/docs/react/local-state/local-state-management/

컴포넌트 라우팅 추가

// src/Router.tsx
...
import Signup from "pages/Signup";
import Login from "pages/Login";
import Logout from "pages/Logout";
import MyPage from "pages/MyPage";

function Router() {
  return (
    <HashRouter>
      ...
      <Route path="/signup" component={Signup} />
      <Route path="/login" component={Login} />
      <Route path="/logout" component={Logout} />
      <Route path="/@:id" component={MyPage} />
      <Redirect from="*" to="/" />
    </HashRouter>
  );
}

Router.tsx에서 특정 url에 접속했을 때 화면에 보여줄 컴포넌트를 설정한다. 여기선 /signup에 접속하면 Signup 컴포넌트를, /login에 접속하면 Login 컴포넌트를 화면에 보여주도록 Router.tsx에 Route 컴포넌트를 새로 추가한다.

새로운 링크 추가

// src/components/Navigation.tsx
import React from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@apollo/client";
import { ICurrentUserData } from "interfaces";
import { GET_CURRENT_USER } from "apollo/cache";

function Navigation() {
  const currentUser = useQuery<ICurrentUserData>(GET_CURRENT_USER);
  const user = currentUser.data?.user;

  return (
    <div className="nav">
      <Link to="/">Home</Link> <Link to="/about">About</Link>{" "}
      {user ? (
        <>
          <Link to="/logout">로그아웃</Link>{" "}
          <Link to={`/@${user.ID}`}>내 정보</Link>
        </>
      ) : (
        <>
          <Link to="/login">로그인</Link> <Link to="/signup">회원가입</Link>
        </>
      )}
    </div>
  );
}

export default Navigation;

사용자 로그인 정보를 조회하는 로컬 쿼리의 결과를 useQuery를 이용해 가져와서 컴포넌트를 조건부 렌더링한다.

위 코드는 currentUser.data?.user에 접근해 해당 값이 존재하면 로그아웃, 내 정보 링크를 보여주고, 없으면 로그인, 회원가입 링크를 보여준다. 즉, 로그인 전에는 로그인, 회원가입 링크를 보여주고 로그인 성공한 후에는 로그아웃, 마이페이지 링크를 보여준다.

새로운 페이지 추가

회원가입

// src/pages/Signup.tsx
import React, { useState, useEffect } from "react";
import { useHistory } from "react-router-dom";
import { gql, useMutation, useQuery } from "@apollo/client";
import { ICurrentUserData, ISignupData, ISignupVars } from "interfaces";
import Loading from "components/Loading";
import Error from "components/Error";
import { GET_CURRENT_USER } from "apollo/cache";

const SIGNUP = gql`
  mutation signup($ID: String!, $password: String!, $name: String!) {
    signup(ID: $ID, password: $password, name: $name)
  }
`;

function Signup() {
  const [ID, setID] = useState("");
  const [password, setPassword] = useState("");
  const [name, setName] = useState("");
  const history = useHistory();
  const currentUser = useQuery<ICurrentUserData>(GET_CURRENT_USER);
  const [signup, signupResult] = useMutation<ISignupData, ISignupVars>(SIGNUP);

  useEffect(() => {
    if (signupResult.data?.signup === true) {
      alert("회원가입에 성공했습니다");
      history.replace("/login");
    } else if (signupResult.data?.signup === false) {
      alert("회원가입에 실패했습니다");
    }
  }, [signupResult.data, history]);

  if (currentUser.data?.user) history.replace("/");
  if (signupResult.loading) return <Loading />;
  if (signupResult.error) return <Error msg={signupResult.error.message} />;

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    e.stopPropagation();
    signup({ variables: { ID, password, name } });
    setID("");
    setPassword("");
    setName("");
  }

  return (
    <div>
      <div>회원가입</div>
      <form onSubmit={handleSubmit}>
        <input
          value={ID}
          onChange={(e) => setID(e.target.value)}
          type="text"
          placeholder="ID"
        />
        <input
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          type="password"
          placeholder="Choose a safe password"
        />
        <input
          value={name}
          onChange={(e) => setName(e.target.value)}
          type="text"
          placeholder="이름"
        />
        <button type="submit">회원가입</button>
      </form>
    </div>
  );
}

export default Signup;

회원가입 과정은 서버 데이터를 수정하기 때문에 Mutation으로 요청한다. Mutation 요청은 useMutation() 함수가 반환하는 값을 이용한다. 이 함수는 배열 첫번째 값으로 서버로 Mutation을 요청하는 함수와 배열 두번째 값으로 서버로부터 받은 응답 데이터를 반환한다.

Mutation 요청 매개변수는 signup() 함수의 첫번째 매개변수에서 설정할 수 있다. 위 코드에선 variable 객체 안에 있는 항목이 Mutation 요청 매개변수로 설정된다.

useEffect()는 회원가입 요청에 대한 결과가 변할 때마다 실행되는 함수로서 회원가입 결과를 확인할 수 있다.

위 코드에선 ID, password, name 값을 입력한 후 회원가입 버튼을 누르면 handleSubmit()이 호출되면서 입력한 데이터를 매개변수로 가지는 mutation signup 요청이 서버로 전송된다. 그 후 서버로부터 응답이 반환되면 그 값이 signupResult에 저장되고 useEffect()가 호출돼서 회원가입 성공 여부 알림창이 뜬다.

로그인

// src/pages/Login.tsx
import React, { useState, useEffect } from "react";
import { useHistory } from "react-router-dom";
import { gql, useMutation, useQuery } from "@apollo/client";
import { ICurrentUserData, ILoginData, ILoginVars } from "interfaces";
import { currentUserVar, GET_CURRENT_USER } from "apollo/cache";
import Loading from "components/Loading";
import Error from "components/Error";

const LOGIN = gql`
  mutation login($ID: String!, $password: String!) {
    login(ID: $ID, password: $password) {
      ID
      name
      role
      token
    }
  }
`;

function Login() {
  const [ID, setID] = useState("");
  const [password, setPassword] = useState("");
  const history = useHistory();
  const currentUser = useQuery<ICurrentUserData>(GET_CURRENT_USER);
  const [login, loginResult] = useMutation<ILoginData, ILoginVars>(LOGIN);

  useEffect(() => {
    if (loginResult.data?.login) {
      currentUserVar(loginResult.data?.login);
      alert("로그인에 성공했습니다.");
      history.replace("/");
    } else if (loginResult.data?.login === null)
      alert("아이디 또는 비밀번호를 잘못 입력했습니다.");
  }, [loginResult.data, history]);

  if (currentUser.data?.user) history.replace("/");
  if (loginResult.loading) return <Loading />;
  if (loginResult.error) return <Error msg={loginResult.error.message} />;

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    e.stopPropagation();
    login({ variables: { ID, password } });
    setID("");
    setPassword("");
  }

  return (
    <div>
      <div>로그인</div>
      <form onSubmit={handleSubmit}>
        <input
          value={ID}
          onChange={(e) => setID(e.target.value)}
          type="text"
          placeholder="ID"
        />
        <input
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          type="password"
          placeholder="Choose a safe password"
        />
        <button type="submit">로그인</button>
      </form>
    </div>
  );
}

export default Login;

위 코드에선 ID, password 값을 입력한 후 로그인 버튼을 누르면 handleSubmit()이 호출되면서 입력한 데이터를 매개변수로 가지는 mutation signup 요청이 서버로 전송된다. 그 후 서버로부터 응답이 반환되면 그 값이 loginResult에 저장되고 useEffect()가 호출돼서 로그인 정보를 반응형 변수currentUserVar에 저장한다.

내 정보

// src/pages/MyPage.tsx
import { useQuery } from "@apollo/client";
import { GET_CURRENT_USER } from "apollo/cache";
import { ICurrentUserData } from "interfaces";
import React from "react";
import { useHistory } from "react-router-dom";

function MyPage() {
  const history = useHistory();
  const currentUser = useQuery<ICurrentUserData>(GET_CURRENT_USER);
  const user = currentUser.data?.user;

  if (user === null) history.replace("/login");

  return (
    <div>
      <h2>MyPage</h2>
      <h4>ID</h4>
      <div>{user?.ID}</div>
      <h4>Token</h4>
      <div>{user?.token}</div>
      <h4>Name</h4>
      <div>{user?.name}</div>
    </div>
  );
}

export default MyPage;

마이페이지답게 적당히 꾸미면 된다.

로그아웃

// src/pages/Logout.tsx
import React, { useEffect } from "react";
import { gql, useMutation, useQuery } from "@apollo/client";
import { ICurrentUserData, ILogoutData } from "interfaces";
import { currentUserVar, GET_CURRENT_USER } from "apollo/cache";
import Loading from "components/Loading";
import Error from "components/Error";
import { useHistory } from "react-router-dom";

const LOGOUT = gql`
  mutation {
    logout
  }
`;

function Logout() {
  const history = useHistory();
  const currentUser = useQuery<ICurrentUserData>(GET_CURRENT_USER);
  const [logout, logoutResult] = useMutation<ILogoutData>(LOGOUT);

  useEffect(() => {
    if (logoutResult.data?.logout === true) {
      currentUserVar(null);
      alert("로그아웃에 성공했습니다");
      history.replace("/");
    } else if (logoutResult.data?.logout === false) {
      alert("로그아웃에 실패했습니다");
    }
  }, [logoutResult.data, history]);

  if (currentUser.data?.user === null) history.replace("/login");
  if (logoutResult.loading) return <Loading />;
  if (logoutResult.error) return <Error msg={logoutResult.error.message} />;

  function handleClick(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
    e.preventDefault();
    logout();
  }

  return (
    <button type="button" onClick={handleClick}>
      로그아웃
    </button>
  );
}

export default Logout;

여타 페이지와 로직은 비슷하다.

사용자 인증 정보 전송

// src/apollo/client.ts
import { ... , GraphQLRequest } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { ... , currentUserVar } from "apollo/cache";

...

// Authenticate using HTTP header
function contextSetter(_: GraphQLRequest, { headers }: any) {
  // get the authentication token from local storage if it exists
  const token = currentUserVar()?.token;
  // return the headers to the context so httpLink can read them
  return {
    headers: {
      ...headers,
      authorization: token ? token : "",
    },
  };
}

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

contextSetter() 함수는 Apollo 클라이언트가 서버로 GraphQL 요청을 수행하기 전에 매번 실행된다. 그래서 여기에 HTTP 헤더에 넣을 데이터를 설정할 수 있다. 그리고 기존 링크인 httpLinksetContext()로 반환되는 Apollo 링크를 합쳐준다.

프로젝트 최종 구조

실행 화면

회원가입

로그인

내 정보

로그아웃

할 일

  1. 부적절한 로그인/회원가입 입력값은 클라이언트 측에서 필터
  2. React Router (Client Side Rendering) 대신 Next.js (Server Side Rendering) 적용

오류

위 오류는 언마운트된 컴포넌트에서 상태를 업데이트하려고 시도했기 때문에 발생한 오류인데 (아래서 나오겠지만) 반응형 변수의 상태를 업데이트 하는 부분에서 발생했다. 오류가 난 이유는 생각해보면 당연하다. 언마운트된 컴포넌트는 상태가 없기 때문이다.

Apollo 클라이언트는 반응형 변수의 상태가 변하면 해당 변수를 참조하는 모든?(every active) 로컬 쿼리를 다시 실행한다. 이 과정에서 해당 쿼리를 가진 컴포넌트의 상태가 업데이트돼서 반응형 변수의 새로운 상태가 해당 컴포넌트 UI에 반영된다.

따라서 반응형 변수를 참조하는 쿼리를 가진 컴포넌트가 언마운트된 후 해당 반응형 변수 상태가 변하면 언마운트된 컴포넌트의 상태를 업데이트하려고 시도하기 때문에 위 오류가 발생하는 것 같다. 추측이다.

원래는 로그인/회원가입 페이지와 로그아웃/내정보 페이지를 사용자의 로그인 정보에 따라 조건부 렌더링해야 하기 때문에 저 4개 컴포넌트는 반응형 변수에 저장된 사용자 로그인 정보를 참조하는 로컬 쿼리를 가지고 있다. 그런데 로그인, 로그아웃 시 반응형 변수의 상태가 변하면 저 4개 컴포넌트를 모두 업데이트하기 때문에? 위 오류가 발생하는 것 같다.

전에는 로그인, 로그아웃, 마이페이지 컴포넌트가 사용자 정보를 서로 공유하기 위해 React Context API를 활용했었다. 근데 React Context를 이용해도 문제없을 거 같다. 조금 직관적이지 않은 로직이 추가되긴 하지만.

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

7개의 댓글

comment-user-thumbnail
2020년 6월 2일

안녕하세요 해당 App 컴포넌트 query 에 me를 호출하게된다면 유저 모든 정보들이 넘어올거같은데
본인 에대한 정보만 resolve 해야되지 않나요?? 잘몰라서 질문합니다!

3개의 답글