[React] useContext,useReducer로 로그인 구현하기

신세원·2021년 7월 13일
1

React

목록 보기
10/28
post-thumbnail

useContext 및 useReducer를 사용하여 사용자 로그인 인증하기

# 작업 전 폴더 구조

* AppRoute - 인증된 사용자만 액세스 할 수 있게 만드는 구성요소 (로그인 안되어있으면 Redirecte됨)

* action.js - 우리가 필요로 하는 다양한 context 객체와 로직을 구성하는 hooks를 초기화 하는 곳

* reducer.js - 초기 상태, 또는 원하는 상태를 관리하는 reducer

# 1. 프로젝트 생성 및 필요 라이브러리 설치

npx create-react-app login-auth
yarn add react-router-dom
yarn add axios

# 2. 폴더 생성 및 각 페이지 구성 요소 만들기(폴더 루트는 위에 사진 참고)

1-1. login.js

// pages/login/index.js

import React from 'react';
import styles from './login.module.css';
 
function Login(props) {
  return (
    <div className={styles.container}>
      <div className={styles.formContainer}>
        <h1>Login Page</h1>
 
        <form>
          <div className={styles.loginForm}>
            <div className={styles.loginFormItem}>
              <label htmlFor='email'>Username</label>
              <input type='text' id='email' />
            </div>
            <div className={styles.loginFormItem}>
              <label htmlFor='password'>Password</label>
              <input type='password' id='password' />
            </div>
          </div>
          <button>login</button>
        </form>
      </div>
    </div>
  );
}
 
export default Login;

1-2. login.css


// pages/login/login.module.css
 
.container {
  min-height: 100vh;
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}
.formContainer {
  width: 200px;
}
.error {
  font-size: 0.8rem;
  color: #bb0000;
}
 
.loginForm {
  display: flex;
  flex-direction: column;
}
 
.loginFormItem {
  display: flex;
  flex-direction: column;
  margin-bottom: 10px;
}

2-1. dashboard.js

// pages/dashboard/index.js
import React from 'react'
import styles from './Dashboard.module.css'

function Dashboard(props) {
   
    return (
        <div style={{ padding: 10 }}>
            <div className={styles.dashboardPage} >
                <h1>
                    Dashboard
                </h1>
                <button className={styles.logoutBtn} >Logout</button>
            </div>
            <p>Welcome to the dashboard</p>
        </div>
    )
}

export default Dashboard

2-2. dashboard.css

// pages/dashboard/dashboard.module.css 
.logoutBtn {
  height: '30px';
  width: '100px';
}

.dashboardPage {
  display: flex;
  width: 100%;
  justify-content: space-between;
}

3-1. dashboard.js

// pages/notFound/index.js
import React from 'react';
import styles from './notfound.module.css';

function NotFound(props) {
	return (
		<div className={styles.container}>
			<h1>Page not found</h1>
		</div>
	);
}

export default NotFound;

3-2. dashboard.css

// pages/notFound/notfound.module.css
.container {
 min-height: 100vh;
 width: 100%;
 display: flex;
 justify-content: center;
 align-items: center;
}

# 3. 라우팅 설정

  1. map을 사용한 route 경로 설정
// Config/routes.js
import React from 'react'
import Login from '../pages/login/index'
import DashBoard from '../pages/dashBoard/index'
import PageNotFound from '../pages/pageNotFound/index'

const routes =[
  {
    path:'/',
    component: Login
  },
  {
    path:'/dashboard',
    component: Dashboard
  },
  {
    path:'/*',
    component: PageNotFound
  },
]
 
export default routes
  1. App.js파일에서 router 설정
// App.js
import React from 'react';
import {
  BrowserRouter as Router,
  Redirect,
  Route,
  Switch,
} from 'react-router-dom';
import routes from './Config/routes.js';
 
function App() {
  return (
    <Router>
      <Switch>
        {routes.map((route) => (
          <Route
            exact
            key={route.path}
            path={route.path}
            component={route.component}
          />
        ))}
      </Switch>
    </Router>
  );
}
 
export default App;

# 4. 인증을 위한 context 설정

  • AuthStateContext: 이 컨텍스트에는 인증 토큰과 사용자 세부 정보가 포함된다.
  • AuthDispatchContext: 이 컨텍스트는 useReducer를 사용하여 상태를 관리하기 위해 나중에 생성할 dispatch를 전달한다 . 이렇게 하면 필요한 구성 요소에 dispatch를 쉽게 제공할 수 있습니다.

컨텍스트 객체를 생성하기 위해 context.js파일에 아래를 추가 한다.

// Context/context.js
 
import React,{useContext,createContext,useReducer} from "react";
import {AuthReducer,initialState} from './reducer'

const AuthStateContext = createContext(null)
const AuthDispatchContext = createContext(null)

동일한 파일(context.js)에서 useAuthDispatch,useAuthDispatch hooks, provider를 생성 한다 .


// Context/context.js
 
[...]
 
export function useAuthState() {
  const context = React.useContext(AuthStateContext);
  if (context === undefined) {
    throw new Error("useAuthState는 AuthProvider 안에서만 사용 가능합니다.")
  }
 
  return context;
}
 
export function useAuthDispatch() {
  const context = React.useContext(AuthDispatchContext);
  if (context === undefined) {
        throw new Error("useAuthDispatch는 AuthProvider 안에서만 사용 가능합니다.")
  }
 
  return context;
}

export const AuthProvider =({children})=>{
    const [user,dispatch] = useReducer(AuthReducer,initialState)

    return(
        <AuthStateContext.Provider value={user}>
            <AuthDispatchContext.Provider value={dispatch}>
                {children}
            </AuthDispatchContext.Provider>
        </AuthStateContext.Provider>
    )
}

이렇게만 하면 reducer를 만들기 않았기 때문에 에러가 난다. reducer,action을 만들어보자

1-1. reducer.js


// Context/reducer.js

let user = localStorage.getItem('currentUser')? JSON.parse(localStorage.getItem('currentUser')).user : '';
let token = localStorage.getItem('currentUser')? JSON.parse(localStorage.getItem('currentUser')).auth_token : '';

export const initialState ={
    user:""||user,
    token:""||token,
    loading:false,
    errorMessage:null
}



export const AuthReducer =(initialState,action)=>{
    switch (action.type){
        case 'REQUEST_LOGIN':
            return{
                ...initialState,
                loading: true
            }
        case 'LOGIN_SUCCESS':
            return{
                ...initialState,
                user:action.payload.user,
                token:action.payload.auth_token,
                loading: false
            }
        case 'LOGOUT':
            return{
                ...initialState,
                user:'',
                token:''
            }
        case 'LOGIN_ERROR':
            return{
                ...initialState,
                loading: false,
                errorMessage: action.error
            }
        default:
            throw new Error( `Unhandled action type: ${action.type}`)
    }
}

1-2. action.js


// Context/action.js

import axios from "axios";
const ROOT_URL = 'https://secret-hamlet-03431.herokuapp.com';


export const loginUser=async (dispatch,loginPayload)=>{
    const requestOptions={
        url:`${ROOT_URL}/login`,
        method: 'POST',
        headers: {
            'Content-Type':'application/json'
        },
        data:loginPayload
    }
    try{
        const response = await axios(requestOptions)
        if(response.status ===200){
            dispatch({type:'LOGIN_SUCCESS',payload:response.data})
            localStorage.setItem('currentUser',JSON.stringify(response.data))
            return response.data
        }else{
            dispatch({type:'LOGIN_ERROR',error:response.data.error[0]})
        }
        return ;
    }catch (e){
        dispatch({type:'LOGIN_ERROR',error:e})
    }
}


export async function logout(dispatch) {
    dispatch({ type: 'LOGOUT' });
    localStorage.removeItem('currentUser');
    localStorage.removeItem('token');
}

이제 useReducer를 사용하여 컨텍스트 및 상태 관리에 대한 모든 설정을 완료 했으고, Context폴더의 모든 내용을 내보낼 폴더에 index.js 파일을 생성한다.

import {loginUser,logout,axiosLoginUser} from './actions'
import {AuthProvider,useAuthState,useAuthDispath} from './context'

export {loginUser,logout,useAuthDispath,useAuthState,AuthProvider,axiosLoginUser}

# 5. context 통합하기


// App.js

import routes from './Config/routes.js';
import { AuthProvider } from "./Context";

function App() {
  return (
    <AuthProvider>
      <Router>
        <Switch>
          {routes.map((route) => (
            <Route
    		  exact
              key={route.path}
              path={route.path}
              component={route.component}
            />
          ))}
        </Switch>
      </Router>
    </AuthProvider>
  );
}

export default App;

# 6. 로그인 페이지 구성 요소에서 인증 구현

이메일과 비밀번호 입력 필드의 상태와 입력 핸들러를 정의한다.


// pages/login/index.js
 
[...]
 
function Login(props) {
 
    const [email, setEmail] = useState('')
    const [password, setPassword] = useState('')
 
   
    return (
        <div className={styles.container}>
            <div className={{ width: 200 }}>
                <h1>Login Page</h1>
               
                <form >
                    <div className={styles.loginForm}>
                        <div className={styles.loginFormItem}>
                            <label htmlFor="email">Username</label>
                            <input type="text" id='email' value={email} onChange={(e) => setEmail(e.target.value)} />
                        </div>
                        <div className={styles.loginFormItem}>
                            <label htmlFor="password">Password</label>
                            <input type="password" id='password' value={password} onChange={(e) => setPassword(e.target.value)}  />
                        </div>
                    </div>
                    <button>login</button>
                </form>
            </div>
        </div>
    )
}
[...]

이 시점에서 상태에서 사용할 수 있는 이메일 및 비밀번호 필드가 있어 이 정보들을 서버에 제출하는 것을 처리하는 함수를 만들 수 있다.
handleLogin 함수를 만들어 호출하고, login버튼 을 클릭하면 호출된다.

로그인 구성 요소에서 handleLogin함수를 만듭니다 .

// pages/login/index.js
 
[...]
import {useAuthState,useAuthDispath,axiosLoginUser} from '../../context'
[...]

function Login(props) {
    const dispatch = useAuthDispath()
    const [email,setEmail] =useState('')
    const [password,setPassword] =useState('')
    const {loading} = useAuthState() //얘는 initialState안에 있는 얘들 구조분해 할당


    const handleLogin =async (e)=>{
        e.preventDefault()
        let payload = {email,password}
        try{
            const response = await axiosLoginUser(dispatch,payload)
            if(!response.user) return
            props.history.push('/dashboard')
        }catch (e){
            console.error(e)
        }
    }

    return (
        <div className={styles.container}>
            <div className={styles.formContainer}>
                <h1>Login Page</h1>

                <form >
                    <div className={styles.loginForm}>
                        <div className={styles.loginFormItem}>
                            <label htmlFor="email">UserName</label>
                            <input type="text" id={'email'} value={email} onChange={(e)=>setEmail(e.target.value)} disabled={loading}/>
                        </div>
                        <div className={styles.loginFormItem}>
                            <label htmlFor="password">Password</label>
                            <input type="password" id={'password'} value={password} onChange={(e)=>setPassword(e.target.value)} disabled={loading}/>
                        </div>
                    </div>
                    <button onClick={handleLogin}>login</button>
                </form>
            </div>
        </div>
    );
}

export default Login;

로그인 테스트 email: nero@admin.com,
로그인 테스트 password: admin123

#7. 대시보드에서 로그아웃 구현

// pages/dashboard/index.js
 
import React from 'react'
import styles from './dashBoard.module.css'
import {useAuthState,logout,useAuthDispath} from "../../context";

function Dashboard(props) {
    const dispatch = useAuthDispath()
    const userDetails = useAuthState() // 얘는  initialState
    

    const handleLogout=()=>{
        logout(dispatch)
        props.history.push('/login')
    }

    return (
        <div style={{ padding: 10 }}>
            <div className={styles.dashboardPage} >
                <h1>
                    Dashboard
                </h1>
                <button className={styles.logoutBtn} onClick={handleLogout}>Logout</button>
            </div>
            <p>Welcome {userDetails.user.email}</p>
        </div>
    )
}

export default Dashboard

이렇게하면 간단하게 로그인,로그아웃을 만들수 있지만, 이제 인증이 있음에도 불구하고 사용자가 인증되지 않은 경우에도 대시보드 경로와 같은 경로에 계속 액세스할 수 있다는 문제가 있다.

#8. 인증된 경로 보호

이 문제를 해결하려면 개인 경로(인증된 사용자만 액세스할 수 있는 경로)를 정의하고, 사용자가 인증된 경우 적절한 구성 요소를 렌더링하는 상위 구성 요소를 생성해야 한다. 그렇지 않은 경우 로그인 페이지로 리디렉션된다.

먼저 경로 구성에서 isPrivate경로가 비공개인지 여부를 지정 하는 속성을 추가하려고 한다.
우리 애플리케이션의 두 개인 경로는 대시보드 페이지와 404 페이지이다.


// Config/routes.js
 
[]
const routes = [
  {
    path: '/login',
    component: Login,
    isPrivate: false,
  },
  {
    path: '/dashboard',
    component: Dashboard,
    isPrivate: true,
  },
  {
    path: '/*',
    component: NotFound,
    isPrivate: true,
  },
];
 
 
export default routes;

다음으로 보호된 경로에 도움이 되는 component를 만든다. components 폴더를 만들고 AppRoutes.js 파일을 생성해 보자 .
그런 다음 AppRoute구성 요소에 다음을 추가할 수 있다.


// components/AppRoute.js
 
import React from 'react';
import {Redirect,Route} from 'react-router-dom'
import { useAuthState} from "../context";

function AppRoute({component:Component,path,isPrivate,...rest}) {
    const userDetails = useAuthState()

    return (
        <Route
            exact
            path={path}
            render={props =>
                isPrivate && !Boolean(userDetails.token) ? (
                    <Redirect to={{pathname: '/'}}/>
                ) : (
                    <Component {...props}/>
                )
            }
            {...rest}
        />
    );
}

export default AppRoute;

#8. App.js 수정하기


// App.js


function App() {
    return (
        <AuthProvider>
            <Router>
                <Switch>
                    {routes.map((route) => (
                        <AppRoute
                            exact
                            key={route.path}
                            path={route.path}
                            component={route.component}
                            isPrivate={route.isPrivate}
                        />
                    ))}
                </Switch>
            </Router>
        </AuthProvider>
    );
}


export default AppRoute;

그리고 이제 실행해주고 테스트하면 된다.

profile
생각하는대로 살지 않으면, 사는대로 생각하게 된다.

3개의 댓글

comment-user-thumbnail
2021년 10월 29일

useContext, useReduce를 통해 사용자 데이터를 관리하는 방법을 찾고 있었는데, 잘 정리해주셔서 감사합니다 ㅎㅎ

답글 달기