Logo

React Router로 사용자 인증하기 (로그인/로그아웃)

지난 두 개의 포스팅에 걸쳐서 React Router를 이용해서 React 앱에서 라우팅을 하는 방법을 알아보았습니다.

이번 포스팅에서는 그 동안 배운 라우팅 방법을 기반으로 React 앱에서 어떻게 React Router를 이용해서 사용자 인증을 구현할 수 있는지 살펴보도록 하겠습니다.

인증이 필요없는 컴포넌트

로그인 하기 전까지는 모든 기능을 차단하는 앱이 있지만, 더 많은 경우에는, 인증없이도 접근 가능한 영역이 있기 마련입니다. 예를 들어, 홈페이지의 경우 누구나 접근이 가능한 것이 일반적일 것이고, 모든 사용자가에게 동일한 컨텐츠를 보여주는 페이지도 굳이 로그인을 강제할 필요는 없을 것입니다. 이렇게 인증없이 접근 가능한 컴포넌트인 Home, About, NotFound 컴포넌트만 가지고 일단 기본적인 라우팅을 하겠습니다.

React Router로 라우팅하는 방법에 대한 사전 지식이 필요하신 분들은 관련 포스팅를 먼저 읽고 돌아오시길 추천드립니다.

  • App.js
import React, { useState, useEffect } from "react";
import { Link, Route, Switch, BrowserRouter as Router } from "react-router-dom";

import Home from "./Home";
import About from "./About";
import NotFound from "./NotFound";

function App() {
  return (
    <Router>
      <header>
        <Link to="/">
          <button>Home</button>
        </Link>
        <Link to="/about">
          <button>About</button>
        </Link>
      </header>
      <hr />
      <main>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route path="/about" component={About} />
          <Route component={NotFound} />
        </Switch>
      </main>
    </Router>
  );
}

인증이 필요한 컴포넌트

이제 본격적으로 인증이 필요한 컴포넌트는 어떻게 라우팅을 하는지 알아보겠습니다. 대표적으로 인증이 필요한 컴포넌트로 사용자 프로필 페이지를 들 수 있겠습니다. prop으로 넘어온 사용자의 이메일과 패스워드, 이름을 보여주는 간단한 컴포넌트를 작성합니다.

  • Profile.js
import React from "react";

function Profile({ user }) {
  const { email, password, name } = user || {};
  return (
    <>
      <h1>Profile</h1>
      <dt>Email</dt>
      <dd>{email}</dd>
      <dt>Password</dt>
      <dd>{password}</dd>
      <dt>Name</dt>
      <dd>{name}</dd>
    </>
  );
}

export default Profile;

이제 경로가 /profile인 경우, 이 <Profile/> 컴포넌트로 라우팅합니다.

  • App.js
import Profile from './Profile';

function App() {
  return (
    <Router>
      <header>
        <!-- 생략 -->
        <Link to="/profile">
          <button>Profile</button>
        </Link>
        <!-- 생략 -->
      </header>
      <hr />
      <main>
        <Switch>
          <!-- 생략 -->
          <Route path="/profile" component={Profile} />
          <!-- 생략 -->
        </Switch>
      </main>
    </Router>
  );
}

자 이제, Profile 버튼을 클릭하면, <Profile/> 컴포넌트가 렌더링 될 것입니다. 하지만 user prop이 넘어오지 않았기 때문에, 사용자 정보가 표시되지 않을 것입니다.

이 컴포넌트가 제대로 동작하려면 이 user prop으로 로그인된 사용자 객체가 넘어와야 합니다. 그럴러먼 어딘가에 먼저 로그인 기능을 구현되어 있어야 합니다.

예제용 인증 모듈

애플리케이션 보안의 기본인 사용자 인증은 일반적으로 자체 구축한 인가 서버나 소위 IDaaS(Identity as a Service)라고 불리는 Auth0와 같은 외부 인증 서비스를 이용하게 됩니다. 하지만 본 포스팅에서는 최대한 간단한 예제를 위해서 말도 안 되게 허접한 인증 모듈을 무려 클라이언트 단에서 구현하겠습니다. (상용 앱에서는 절대 이렇게 클라이언트에서 인증 처리를 하면 안 됩니다!)

  • auth.js
const users = [
  { email: "kim@test.com", password: "123", name: "Kim" },
  { email: "lee@test.com", password: "456", name: "Lee" },
  { email: "park@test.com", password: "789", name: "Park" },
];

export function signIn({ email, password }) {
  const user = users.find(
    (user) => user.email === email && user.password === password
  );
  if (user === undefined) throw new Error();
  return user;
}

users 변수가 인가 서버의 데이터베이스 역할을 한다고 가정하고, signIn() 함수는 인자로 넘어온 emailpassword로 데이터베이스를 조회합니다. 사용자가 조회되지 않으면 예외를 던지고, 조회되면 해당 사용자를 반환합니다.

로그인/로그아웃 구현

위에서 구현한 인증 모듈을 이용해서 로그인과 로그아웃 기능을 앱의 최상위 컴포넌트인 <App/>에 추가하도록 하겠습니다.

  • App.js
import { signIn } from './auth';

function App() {
  const [user, setUser] = useState(null);
  const authenticated = user != null;

  const login = ({ email, password }) => setUser(signIn({ email, password }));
  const logout = () => setUser(null);

  return (
    <!-- 생략 -->
  )
}

로그인된 사용자 정보는 user state로 관리하고, authenticated 변수에 로그인된 사용자가 존재하는지, 즉 인증 여부를 저장합니다. 그리고 login 함수는 인증 모듈을 통해 로그인한 사용자의 정보를 user state에 저장하고, logout 함수는 로그인한 사용자의 정보를 user state로 부터 지우도록 구현합니다.

인증이 필요한 컴포넌트를 위한 라우트

사용자가 로그인을 하지 않고 인증이 필요한 컴포넌트에 접근하려는 경우, 접근을 차단하고 로그인 페이지로 보내야합니다. 이 로직을 인증이 필요한 모든 컴포넌트에 넣을 수도 있겠지만, 그러면 코드 중복이 생겨서 유지보수가 어렵게 됩니다. 따라서 React Router의 <Route/> 컴포넌트를 확장하여 인증이 필요한 컴포넌트를 위한 전용 라우트를 구현하겠습니다.

<Route/> 컴포넌트는 보통 component prop나 render prop을 통해 렌더링할 컴포넌트를 받습니다. 따라서 인증이 된 경우(authenticated === true)에는 받은 component prop나 render prop을 그대로 이용해서 렌더링을 해주고, 인증이 되지 않은 경우(authenticated === false)에는 받은 React Router의 <Redirect/> 컴포넌트를 이용해서 로그인 경로로 리다이렉트 해줍니다.

여기서, state 옵션에 현재 location을 그대로 넘겨주었는데, 이는 로그인 후에 다시 이 페이지로 돌아오게 함입니다. 이 부분에 대해서는 아래 로그인 폼 컴포넌트를 구현할 때 좀 더 자세히 알아보겠습니다.

  • AuthRoute.js
import React from "react";
import { Route, Redirect } from "react-router-dom";

function AuthRoute({ authenticated, component: Component, render, ...rest }) {
  return (
    <Route
      {...rest}
      render={(props) =>
        authenticated ? (
          render ? (
            render(props)
          ) : (
            <Component {...props} />
          )
        ) : (
          <Redirect
            to={{ pathname: "/login", state: { from: props.location } }}
          />
        )
      }
    />
  );
}

export default AuthRoute;

이제 <App/> 컴포넌트에서 React Router의 <Route/> 컴포넌트 대신에 인증 컴포넌트 전용 라우트인 <AuthRoute/>를 사용해서 <Profile/> 컴포넌트에 대한 라우팅을 해줍니다. 이때 인증 여부를 나타내는 authenticated 변수를 prop로 반드시 넘기도록 주의해야 합니다.

  • App.js
import Profile from './Profile';
import AuthRoute from './AuthRoute';

function App() {
  <!-- 생략 -->
  return (
    <Router>
      <header>
        <!-- 생략 -->
        <Link to="/profile">
          <button>Profile</button>
        </Link>
      </header>
      <hr />
      <main>
        <Switch>
          <!-- 생략 -->
          <AuthRoute
            authenticated={authenticated}
            path="/profile"
            render={props => <Profile user={user} {...props} />}
          />
          <!-- 생략 -->
        </Switch>
      </main>
    </Router>
  );
}

로그인 폼

이제 위에서 구현한 authenticated 변수와 login 함수를 prop으로 받아서 사용하는 로그인 폼을 작성해보겠습니다. 일단, 사용자가 로그인 페이지로 넘어올 수 있는 시나리오를 생각해보겠습니다.

첫번째는 사용자가 자발적으로 직접 로그인 버튼을 클릭한 경우입니다. 두번째는 로그인하지 않고 인증이 필요한 페이지를 접근하려다가 강제로 보내진 경우입니다.

여기서 두번째 경우에 대한 처리가 살짝 까다로울 수가 있는데, <App/> 컴포넌트로 부터 인증 여부를 받기 위해서 authenticated prop을 사용하면 됩니다.

일단 이메일과 패스워드 입력 후 버튼을 클릭 시, <App/> 컴포넌트부터 prop으로 내려받은 login 함수를 호출해줍니다. 그러면 <App/> 컴포넌트의 login 함수는 user state에 로그인된 사용자를 저장하거나, 예외를 발생킬 것입니다. 정상적으로 로그인이 되었다면 <App/> 컴포넌트로 부터 넘어온 authenticated prop값은 true가 될 것입니다. 그러면 로그인 폼이 렌더링되는 대신에 React Router의 <Redirect/> 컴포넌트를 통해 로그인 이전에 접근하려고 했었던 페이지로 리다이렉션 됩니다.

  • LoginForm.js
import React, { useState } from "react";
import { Redirect } from "react-router-dom";

function LoginForm({ authenticated, login, location }) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleClick = () => {
    try {
      login({ email, password });
    } catch (e) {
      alert("Failed to login");
      setEmail("");
      setPassword("");
    }
  };

  const { from } = location.state || { from: { pathname: "/" } };
  if (authenticated) return <Redirect to={from} />;

  return (
    <>
      <h1>Login</h1>
      <input
        value={email}
        onChange={({ target: { value } }) => setEmail(value)}
        type="text"
        placeholder="email"
      />
      <input
        value={password}
        onChange={({ target: { value } }) => setPassword(value)}
        type="password"
        placeholder="password"
      />
      <button onClick={handleClick}>Login</button>
    </>
  );
}

export default LoginForm;

로그아웃 버튼

로그아웃 버튼은 <App/> 컴포넌트로 부터 logout 함수를 prop으로 내려받습니다. 버튼 클릭 시 이 logout 함수를 호출하고, 사용자를 홈페이지로 이동시킵니다.

  • LogoutButton.js
import React from "react";
import { withRouter } from "react-router-dom";

function LogoutButton({ logout, history }) {
  const handleClick = () => {
    logout();
    history.push("/");
  };
  return <button onClick={handleClick}>Logout</button>;
}

export default withRouter(LogoutButton);

로그인/로그아웃 추가

마지막으로 위에서 구현한 로그인 폼과 로그아웃 버튼을 <App/> 컴포넌트에 추가하도록 하겠습니다. 로그아웃 버튼은 로그인 했을 때만, 반대로 로그인 버튼은 로그인 하지 않았을 때만 보이게 해줍니다. 로그인 페이지는 당연히 인증 없이 접근 가능해야 하기 때문에 React Router의 <Router/> 컴포넌트를 그대로 사용합니다. 이 때, authenticatedlogin prop을 넘기는 것을 빠뜨리지 않도록 주의해야합니다.

  • App.js
import NotFound from './NotFound';
import LoginForm from './LoginForm';
import LogoutButton from './LogoutButton';

function App() {
  <!-- 생략 -->
  return (
    <Router>
      <header>
        <!-- 생략 -->
        {authenticated ? (
          <LogoutButton logout={logout} />
        ) : (
          <Link to="/login">
            <button>Login</button>
          </Link>
        )}
      </header>
      <hr />
      <main>
        <Switch>
          <!-- 생략 -->
          <Route
            path="/login"
            render={props => (
              <LoginForm authenticated={authenticated} login={login} {...props} />
            )}
          />
          <!-- 생략 -->
        </Switch>
      </main>
    </Router>
  );
}

전체 코드

마치면서

최대한 간단하게 React Router로 어떻게 인증 흐름을 잡을 수 있는지만 다뤄보려고 했는데도 내용이 예상보다 길어졌네요. 로그인과 로그아웃은 필수적인 기능이지만 막상 직접 구현해보려고 하면 생각보다 쉽지 않은 부분 중에 하나 입니다.