이번 글에선 회원가입/로그인/로그아웃 기능을 구현한 GraphQL 서버와 Apollo 클라이언트를 연결하려고 한다. 클라이언트 개발에 새로 사용하는 개념은 아래와 같다.
// 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 헤더에 넣을 데이터를 설정할 수 있다. 그리고 기존 링크인 httpLink
와 setContext()
로 반환되는 Apollo 링크를 합쳐준다.
위 오류는 언마운트된 컴포넌트에서 상태를 업데이트하려고 시도했기 때문에 발생한 오류인데 (아래서 나오겠지만) 반응형 변수의 상태를 업데이트 하는 부분에서 발생했다. 오류가 난 이유는 생각해보면 당연하다. 언마운트된 컴포넌트는 상태가 없기 때문이다.
Apollo 클라이언트는 반응형 변수의 상태가 변하면 해당 변수를 참조하는 모든?(every active) 로컬 쿼리를 다시 실행한다. 이 과정에서 해당 쿼리를 가진 컴포넌트의 상태가 업데이트돼서 반응형 변수의 새로운 상태가 해당 컴포넌트 UI에 반영된다.
따라서 반응형 변수를 참조하는 쿼리를 가진 컴포넌트가 언마운트된 후 해당 반응형 변수 상태가 변하면 언마운트된 컴포넌트의 상태를 업데이트하려고 시도하기 때문에 위 오류가 발생하는 것 같다. 추측이다.
원래는 로그인/회원가입 페이지와 로그아웃/내정보 페이지를 사용자의 로그인 정보에 따라 조건부 렌더링해야 하기 때문에 저 4개 컴포넌트는 반응형 변수에 저장된 사용자 로그인 정보를 참조하는 로컬 쿼리를 가지고 있다. 그런데 로그인, 로그아웃 시 반응형 변수의 상태가 변하면 저 4개 컴포넌트를 모두 업데이트하기 때문에? 위 오류가 발생하는 것 같다.
전에는 로그인, 로그아웃, 마이페이지 컴포넌트가 사용자 정보를 서로 공유하기 위해 React Context API를 활용했었다. 근데 React Context를 이용해도 문제없을 거 같다. 조금 직관적이지 않은 로직이 추가되긴 하지만.
안녕하세요 해당 App 컴포넌트 query 에 me를 호출하게된다면 유저 모든 정보들이 넘어올거같은데
본인 에대한 정보만 resolve 해야되지 않나요?? 잘몰라서 질문합니다!