본문 바로가기
개발/React

리액트 - 리덕스로 로그인 상태 관리하기 (Managing Login State with Redux)

by 피로물든딸기 2024. 1. 22.
반응형

리액트 전체 링크

 

참고

- 인증 토큰 획득 서버 구현하기

- 깃허브 OAuth 콜백 처리하기

 

- 리덕스로 상태 관리하기

- Context API로 상태 관리하기

- recoil로 상태 관리하기

- SWR로 상태 관리하기

 

위의 링크를 참고해서 구현하면 로그인 정보를 유지하기가 매우 까다롭다.

App.js에서 loginStatususeState로 관리하고, 각 Component에 전달해야 하기 때문이다.

 

리덕스를 이용하여 아래와 같이 로그인 상태 정보를 유지해 보자.

 

아래의 구현에서부터 시작한다.

 

Node JS

 

githubLogin.js

const axios = require("axios");
const express = require("express");
const router = express.Router();
const dotenv = require('dotenv');

const getResponse = async (code, clientID) => {
  dotenv.config();
  const response = await axios.post(
    "https://github.com/login/oauth/access_token",
    {
      code,
      client_id: clientID,
      client_secret: process.env.CLIENT_SECRET, 
    },
    {
      headers: {
        accept: "application/json",
      },
    }
  );

  return response;
};

router.get("/", async (req, res) => {
  let code = req.query.code;
  let clientID = req.query.clientID;
  let response = await getResponse(code, clientID);
  
  console.log(response.data.access_token);

  res.send({token: response.data.access_token});
});

module.exports = router;

 

리액트 

 

App.js

import React, { useState } from "react";
import { Route, Link, Routes } from "react-router-dom";

import "./App.css";

import Router1 from "./page/Router1.js";
import Router2 from "./page/Router2.js";
import GitHubLoginCallback from "./page/GitHubLoginCallback.js";

const App = () => {
  const [loginStatus, setLoginStatus] = useState(false);
  return (
    <div className="App">
      <div className="router">
        <span>
          <Link to="/r1">Router 1</Link>
        </span>
        <span>
          <Link to="/r2">Router 2</Link>
        </span>
      </div>
      <div>
        <Routes>
          <Route
            path="/r1"
            element={
              <Router1
                loginStatus={loginStatus}
                setLoginStatus={setLoginStatus}
              />
            }
          />
          <Route
            path="/r2"
            element={
              <Router2
                loginStatus={loginStatus}
                setLoginStatus={setLoginStatus}
              />
            }
          />
          <Route
            path="/callback"
            element={
              <GitHubLoginCallback
                loginStatus={loginStatus}
                setLoginStatus={setLoginStatus}
              />
            }
          />
        </Routes>
      </div>
    </div>
  );
};

export default App;

 

GitHubLoginCallback.js

import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";

import axios from "axios";
import queryString from "query-string";

import * as ck from "../cookielibrary.js";

const clientID = process.env.REACT_APP_CLIENT_ID;

const GitHubLoginCallback = ({ loginStatus, setLoginStatus }) => {
  const navigate = useNavigate();

  const getAccessToken = async (code) => {
    try {
      let server = `http://192.168.55.120:3002`;
      let accessInfo = await axios.get(
        `${server}/githubLogin?code=${code}&clientID=${clientID}`
      );

      let token = accessInfo.data.token;

      const userResponse = await axios.get("https://api.github.com/user", {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });

      const userData = userResponse.data;
      const avatarUrl = userData.avatar_url;
      const loginID = userData.login;

      console.log(userData);
      console.log(avatarUrl);
      console.log(loginID);

      let options = {
        path: "/",
        maxAge: 60 * 60 * 24 * 7, // seconds
        // secure: true, // https 연결에만 전송
        // httpOnly: true, // 클라이언트에서 read 불가
      };

      ck.setCookies("GITHUB_TOKEN", token, options);
      ck.setCookies("AVATAR_URL", avatarUrl, options);
      ck.setCookies("LOGIN_ID", loginID, options);
      
      setLoginStatus(true);

      navigate('/',  { replace: true });
    } catch (e) {
      console.log(e);
      setLoginStatus(false);
    }
  };

  const getCode = () => {
    let qs = queryString.parse(window.location.search);
    let code = qs.code;
    getAccessToken(code);
  };

  useEffect(() => getCode(), []);

  return <div>{loginStatus ? "로그인 성공!" : "로그인 실패..."}</div>;
};

export default GitHubLoginCallback;

 

GitHubLoginButton.js

import React from "react";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Avatar from "@mui/material/Avatar";

const clientID = process.env.REACT_APP_CLIENT_ID;
const callbackURL = "http://localhost:3000/callback";
const loginURL = `https://github.com/login/oauth/authorize?client_id=${clientID}&scope=repo:status read:repo_hook user:email&redirect_uri=${callbackURL}`;

const GitHubLoginButton = ({ loginStatus, setLoginStatus }) => {
  const logout = () => {
    setLoginStatus(false);
    
    localStorage.removeItem("GITHUB_TOKEN");
    localStorage.removeItem("AVATAR_URL");
    localStorage.removeItem("LOGIN_ID");
  };

  return (
    <div>
      <Box sx={{ m: 2 }}>
        {loginStatus === false && (
          <div>
            <Stack direction="row" spacing={2}>
              <Avatar alt="GitHub Login" src="/static/images/avatar/1.jpg" />
              <Button variant="outlined" color="secondary" href={loginURL}>
                로그인
              </Button>
            </Stack>
          </div>
        )}
        {loginStatus === true && (
          <div>
            <Stack direction="row" spacing={2}>
              <Avatar
                alt="GitHub Login"
                src={localStorage.getItem("AVATAR_URL")}
              />
              <Button variant="outlined" color="error" onClick={logout}>
                로그아웃
              </Button>
            </Stack>
          </div>
        )}
      </Box>
    </div>
  );
};

export default GitHubLoginButton;

 

Router1.js

import React from "react";
import GitHubLoginButton from "./GitHubLoginButton";

const Router1 = ({ loginStatus, setLoginStatus }) => {
  return (
    <div>
      <p>Router 1</p>
      <GitHubLoginButton
        loginStatus={loginStatus}
        setLoginStatus={setLoginStatus}
      />
    </div>
  );
};

export default Router1;

 

Router2.js

import React from "react";
import GitHubLoginButton from "./GitHubLoginButton";

const Router2= ({ loginStatus, setLoginStatus }) => {
  return (
    <div>
      <p>Router 2</p>
      <GitHubLoginButton
        loginStatus={loginStatus}
        setLoginStatus={setLoginStatus}
      />
    </div>
  );
};

export default Router2;

리덕스 구현

 

리덕스를 설치하자.

$ npm install redux react-redux --legacy-peer-deps

 

먼저 상태를 저장할 store를 등록해야 한다.

 

src/store/store.js

import { legacy_createStore as createStore } from "redux";
import rootReducer from "../reducer/rootReducer";

const store = createStore(rootReducer);

export default store;

 

위의 스토어에 저장될 루트는 다음과 같다.

 

src/reducer/rootReducer.js

import loginStatus from "./loginReducers";
import { combineReducers } from "redux";

const rootReducer = combineReducers({ loginStatus });

export default rootReducer;

 

루트에 저장되어 있는 loginStatus 리듀서는 다음과 같다. (리듀서가 추가되면 combineReducers에서 합치면 된다.)

 

src/reducer/loginReducers.js

const loginStatus = (state = {}, action) => {
  switch (action.type) {
    case "LOGIN":
      return { ...state, loginInfo: action.loginInfo, status: true };
    case "LOGOUT":
      return { ...state, loginInfo: "", status: undefined };
    default:
      return state;
  }
};

export default loginStatus;

 

리듀서의 상태를 변화시킬 액션은 다음과 같다.

 

src/actions/loginAction.js

export const login = (loginInfo) => {
  return {
    type: "LOGIN",
    loginInfo,
  };
};

export const logout = () => {
  return {
    type: "LOGOUT",
  };
};

Component에 리덕스 적용

 

먼저 최상단에 LoginStatus Component를 실행시켜서 로그인 상태를 확인하자.

여기서는 로컬 스토리지에 값이 있다면 로그인을 했다고 판단한다.

그리고 스토리지에 값이 있으므로, dispatch로 스토어에 login 정보를 추가한다.

 

src/LoginStatus.js

import { useEffect } from "react";
import { useDispatch } from "react-redux";

import * as actions from "./actions/loginAction";

const LoginStatus = () => {
  const dispatch = useDispatch();

  useEffect(() => {
    let check = localStorage.getItem("LOGIN_INFO");
    if (check === null) return;

    let loginInfo = JSON.parse(check);

    console.log({ loginInfo });

    dispatch(actions.login(loginInfo));
  }, []);

  return null;
};

export default LoginStatus;

 

위의 컴포넌트를 index.js에서 <App /> 보다 위에 추가하면, 

로그인 상태를 확인한 후, App이 실행된다.

//index.js
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { BrowserRouter } from "react-router-dom";
import LoginStatus from "./LoginStatus"; // 로그인 확인
import { Provider } from "react-redux"; // redux 추가
import store from "./store/store";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <BrowserRouter basename={process.env.PUBLIC_URL}>
    <Provider store={store}>
      <LoginStatus />
      <App />
    </Provider>
  </BrowserRouter>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

 

GitHubLoginCallback.js에서 getAccessToken 메서드를 수정하자.

필요한 로그인 정보는 action에 등록된 login 액션을 이용하면 된다.

그리고 로그인 정보를 로컬 스토리지에 저장하자. (필요에 따라 쿠키세션 스토리지 사용)

import { useDispatch } from "react-redux";
import * as actions from "../actions/loginAction";

const GitHubLoginCallback = () => {
  const dispatch = useDispatch();

  const getAccessToken = async (code) => {
    try {
      ...

      const userData = userResponse.data;
      const avatarUrl = userData.avatar_url;
      const loginID = userData.login;

      let loginInfo = {
        avatarUrl, loginID
      }

      // 로그인 처리
      dispatch(actions.login(loginInfo));    
      localStorage.setItem("LOGIN_INFO", JSON.stringify(loginInfo));     
      navigate('/',  { replace: true }); 
    } catch (e) {
      console.log("error ", e);
    }
  };
  
  ...

 

로그인 버튼(GitHubLoginButton.js)은 기존의 useState로 관리했던 loginStatus가 필요가 없으므로 삭제한다.

리덕스를 이용하여 loginStatus를 확인해서 status 상태에 따라 로그인 / 로그아웃 버튼을 렌더링한다.

로그아웃은 로컬 스토리지를 삭제하고 dispatchlogout 액션을 호출하면 된다.

import React from "react";

import { useSelector, useDispatch } from "react-redux";
import * as actions from "../actions/loginAction";

...

const GitHubLoginButton = () => {
  const loginStatus = useSelector((state) => state.loginStatus);
  const dispatch = useDispatch();

  const logout = () => {
    localStorage.removeItem("LOGIN_INFO");
    dispatch(actions.logout());
  };

  return (
    <div>
      <Box sx={{ m: 2 }}>
        {loginStatus.status === undefined && (
          <div>
            <Stack direction="row" spacing={2}>
              <Avatar alt="GitHub Login" src="/static/images/avatar/1.jpg" />
              <Button variant="outlined" color="secondary" href={loginURL}>
                로그인
              </Button>
            </Stack>
          </div>
        )}
        {loginStatus.status === true && (
          <div>
            <Stack direction="row" spacing={2}>
              <Avatar
                alt="GitHub Login"
                src={localStorage.getItem("AVATAR_URL")}
              />
              <Button variant="outlined" color="error" onClick={logout}>
                로그아웃
              </Button>
            </Stack>
          </div>
        )}
      </Box>
    </div>
  );
};

export default GitHubLoginButton;

 

최종적으로 Router1, 2loginStatus는 필요가 없으므로 아래와 같이 깔끔하게 코드가 변하게 된다.

즉, 원하는 컴포넌트에서 useSelector를 이용하여 로그인 상태를 알 수 있게 되었다.

import React from "react";
import GitHubLoginButton from "./GitHubLoginButton";

const Router1= () => {
  return (
    <div>
      <p>Router 1</p>
      <GitHubLoginButton/>
    </div>
  );
};

export default Router1;

 

이제 로그인 정보가 Component 내에서 리덕스로 공유되고, 새로고침을 해도 상태가 유지된다.

 

나머지는 전체 코드를 참고하자.

 

src/store/store.js

import { legacy_createStore as createStore } from "redux";
import rootReducer from "../reducer/rootReducer";

const store = createStore(rootReducer);

export default store;

 

src/reducer/rootReducer.js

import loginStatus from "./loginReducers";
import { combineReducers } from "redux";

const rootReducer = combineReducers({ loginStatus });

export default rootReducer;

 

src/reducer/loginReducers.js

const loginStatus = (state = {}, action) => {
  switch (action.type) {
    case "LOGIN":
      return { ...state, loginInfo: action.loginInfo, status: true };
    case "LOGOUT":
      return { ...state, loginInfo: "", status: undefined };
    default:
      return state;
  }
};

export default loginStatus;

 

src/actions/loginAction.js

export const login = (loginInfo) => {
  return {
    type: "LOGIN",
    loginInfo,
  };
};

export const logout = () => {
  return {
    type: "LOGOUT",
  };
};

 

src/LoginStatus.js

import { useEffect } from "react";
import { useDispatch } from "react-redux";

import * as actions from "./actions/loginAction";

const LoginStatus = () => {
  const dispatch = useDispatch();

  useEffect(() => {
    let check = localStorage.getItem("LOGIN_INFO");
    if (check === null) return;

    let loginInfo = JSON.parse(check);

    console.log({ loginInfo });

    dispatch(actions.login(loginInfo));
  }, []);

  return null;
};

export default LoginStatus;

 

index.js

//index.js
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { BrowserRouter } from "react-router-dom";
import LoginStatus from "./LoginStatus";
import { Provider } from "react-redux";
import store from "./store/store";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <BrowserRouter basename={process.env.PUBLIC_URL}>
    <Provider store={store}> 
      <LoginStatus />
      <App />
    </Provider>
  </BrowserRouter>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

 

GitHubLoginCallback.js

import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useDispatch } from "react-redux";
import * as actions from "../actions/loginAction";

import axios from "axios";
import queryString from "query-string";

const clientID = process.env.REACT_APP_CLIENT_ID;

const GitHubLoginCallback = () => {
  const navigate = useNavigate();
  const dispatch = useDispatch();

  const getAccessToken = async (code) => {
    try {
      let server = `http://192.168.55.120:3002`;
      let accessInfo = await axios.get(
        `${server}/githubLogin?code=${code}&clientID=${clientID}`
      );

      let token = accessInfo.data.token;

      const userResponse = await axios.get("https://api.github.com/user", {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });

      const userData = userResponse.data;
      const avatarUrl = userData.avatar_url;
      const loginID = userData.login;

      console.log(userData);
      console.log(avatarUrl);
      console.log(loginID);

      let loginInfo = {
        avatarUrl, loginID
      }

      dispatch(actions.login(loginInfo));
      localStorage.setItem("LOGIN_INFO", JSON.stringify(loginInfo));     
      navigate('/',  { replace: true }); 
    } catch (e) {
      console.log("error ", e);
    }
  };

  const getCode = () => {
    let qs = queryString.parse(window.location.search);
    let code = qs.code;
    getAccessToken(code);
  };

  useEffect(() => getCode(), []);

  return <div>{"로그인 시도 중..."}</div>;
};

export default GitHubLoginCallback;

 

GitHubLoginButton.js

import React from "react";

import { useSelector, useDispatch } from "react-redux";
import * as actions from "../actions/loginAction";

import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Avatar from "@mui/material/Avatar";

const clientID = process.env.REACT_APP_CLIENT_ID;
const callbackURL = "http://localhost:3000/callback";
const loginURL = `https://github.com/login/oauth/authorize?client_id=${clientID}&scope=repo:status read:repo_hook user:email&redirect_uri=${callbackURL}`;

const GitHubLoginButton = () => {
  const loginStatus = useSelector((state) => state.loginStatus);
  const dispatch = useDispatch();

  const logout = () => {
    localStorage.removeItem("LOGIN_INFO");
    dispatch(actions.logout());
  };

  return (
    <div>
      <Box sx={{ m: 2 }}>
        {loginStatus.status === undefined && (
          <div>
            <Stack direction="row" spacing={2}>
              <Avatar alt="GitHub Login" src="/static/images/avatar/1.jpg" />
              <Button variant="outlined" color="secondary" href={loginURL}>
                로그인
              </Button>
            </Stack>
          </div>
        )}
        {loginStatus.status === true && (
          <div>
            <Stack direction="row" spacing={2}>
              <Avatar
                alt="GitHub Login"
                src={localStorage.getItem("AVATAR_URL")}
              />
              <Button variant="outlined" color="error" onClick={logout}>
                로그아웃
              </Button>
            </Stack>
          </div>
        )}
      </Box>
    </div>
  );
};

export default GitHubLoginButton;

 

Router1.js

import React from "react";
import GitHubLoginButton from "./GitHubLoginButton";

const Router1= () => {
  return (
    <div>
      <p>Router 1</p>
      <GitHubLoginButton/>
    </div>
  );
};

export default Router1;

 

Router2.js

import React from "react";
import GitHubLoginButton from "./GitHubLoginButton";

const Router2 = () => {
  return (
    <div>
      <p>Router 2</p>
      <GitHubLoginButton />
    </div>
  );
};

export default Router2;
반응형

댓글