[React] AWS Cognito 로그인 적용기 (with. redux & redux-persist)

이나원·2022년 10월 17일
2

개발일지

목록 보기
9/22

AWS Cognito를 접하게된 계기..
회사에서 요즘 한창 관리자 페이지를 담당하고 있는데, 로그인 과정을 코그니토 서비스를 이용해서 구현해 놓으셨더라.. 회사 개발 스택이 현재 Vue 인 관계로, 이를 한번 응용해 React에 붙여보는 작업? 실습?을 진행해보기로 했다,, 시작..!!!

프로젝트 흐름

사용자 관점에서의 과정

  • 관리자 페이지 진입 시 코그니토에서 제공해주는 로그인 페이지가 뜨고 -> 사용자는 로그인을 진행 -> 로그인 성공 시 메인 페이지로 이동 / 로그인 실패 시 계속 로그인 페이지에 머무름

상세한 개발 과정

  • 로그인에 성공하면 code 값을 추출하고 -> code 값을 이용해 사용자 token을 발급받고 -> 해당 token 값을 Redux store에 저장하는 프로세스

프로젝트 환경 및 저장소

🌱 프로젝트 환경 - React + TypeScript
🌳 저장소 - Github 링크

프로젝트 구조

📦src
 ┣ 📂api
 ┃ ┗ 📜cognito.ts
 ┣ 📂modules
 ┃ ┣ 📂actions
 ┃ ┃ ┣ 📜actionTypes.ts
 ┃ ┃ ┗ 📜user.ts
 ┃ ┣ 📂reducers
 ┃ ┃ ┣ 📜index.ts
 ┃ ┃ ┗ 📜user.ts
 ┣ 📂pages
 ┃ ┣ 📂Login
 ┃ ┃ ┗ 📜Login.tsx
 ┃ ┗ 📂Main
 ┃ ┃ ┗ 📜MainPage.tsx
 ┣ 📂types
 ┃ ┗ 📜types.ts
 ┣ 📜App.js
 ┣ 📜Router.tsx
 ┣ 📜index.js
 ┗ 📜store.ts

1. Cognito 로그인 페이지 띄우기

  • 제일 먼저 Cognito가 제공하는 로그인 페이지를 화면에 띄우는 작업부터 해보자!
const replaceLoginPage = useCallback(() => {
    window.location.replace(
      process.env.REACT_APP_ADMIN_COGNITO_HOST +
        sprintfjs.vsprintf(process.env.REACT_APP_ADMIN_COGNITO_LOGIN, [
          process.env.REACT_APP_ADMIN_COGNITO_CLIENT_ID,
          process.env.REACT_APP_ADMIN_COGNITO_REDIRECT_URI_LOGIN,
        ])
    );
  }, []);

window.location.replace 를 이용해서 화면을 코그니토 서비스가 제공하는 로그인 페이지로 이동시키는 것이다.
(env 환경 변수 처리된 부분은 회사에서 실제 사용했던 값이기도 하고, 원래에도 보여져서는 안되는 부분이므로 환경 변수 처리를 반드시 해주자!)

2. 사용자 code 값 얻기

로그인 페이지에서 사용자가 로그인을 시도할 것이고, 로그인에 성공 했다면 code 값을 얻을 수 있다고 했다. 해당 code 값을 이용해 사용자 token 값을 얻을 수 있게 추출하는 작업이 필요하다.

  • code 값은 코그니토 서비스 로그인 페이지로 이동하는 링크 속에서 얻을 수 있다.
  • 즉, 링크 url 속에 들어있는 code 파라미터 값을 추출해 내야하는 것!

React에서 파라미터 값 추출하는 방법
react-router-dom이 제공하는 useLocation hook 이용!

import { useLocation } from "react-router-dom";

const { search } = useLocation(); // 쿼리 파라미터 추출
// 쿼리 파라미터 속 사용자 로그인 code 값 추출
const getCode = useCallback(() => {
  return search.split("=")[1];
}, [search]);

useLocation은 현재 url의 파라미터 값을 추출할 수 있게 해주기 때문에, 그 안에 들어있는 code 값을 추출해주기 위해 위와 같은 함수를 작성해 주었다.

3. Cognito 사용자 토큰 발급

위의 과정에서 얻었던 code 값을 가지고 사용자 token 값을 발급하는 과정이다.

import axios from "axios";

const qs = require("qs");

// AWS Cognito 로그인 인증 API 호출
export function GetNewTokens(code) {
  return axios.post(
    process.env.REACT_APP_ADMIN_COGNITO_HOST + "/oauth2/token",
    qs.stringify({
      client_id: process.env.REACT_APP_ADMIN_COGNITO_CLIENT_ID,
      grant_type: "authorization_code",
      code: code,
      redirect_uri: process.env.REACT_APP_ADMIN_COGNITO_REDIRECT_URI_LOGIN,
    }),
    {
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Authorization: process.env.REACT_APP_ADMIN_COGNITO_AUTHORIZATION,
      },
    }
  );
}

axios를 이용해 토큰을 발급해주는 API를 POST 방식으로 호출해야한다.
필요한 쿼리 파라미터들을 붙여주고 호출하면, token 값을 포함한 response(응답값)가 내려온다.

토큰 값을 받았으니 Redux를 이용해 상태 값으로 저장해보자!

4. Redux를 이용한 사용자 token 값 저장

Redux는 크게 개념이 Action, Reducer, Store 3가지이다.
각각에 해당하는 코드를 작성해주고 그들을 이어주는 작업이 필요하다.

  • Action
// Action Type
import { GET_TOKEN } from "./actionTypes.ts";

// Token Type
import { CognitoTokenType } from "../../types/types";

// token 저장 action
export const getToken = (token: CognitoTokenType) => {
  return {
    type: GET_TOKEN,
    token,
  };
};

우선 액션 함수를 작성해주자. 액션은 함수 형태로, 해당 액션의 타입과 담고 싶은 데이터를 반환하면 된다.

액션 타입은 단순하게 string 타입의 상수로, 원하는 이름을 지정해주면 된다. 나 같은 경우에는 따로 상수들을 관리할 수 있는 파일을 만들어 따로 빼서 관리하였고, token 정보를 상태 값으로 저장하고 싶으므로 token 값을 파라미터로 받아 반환할 수 있도록 적어주었다.

  • Reducer
// Action Type
import { GET_TOKEN } from "../actions/actionTypes.ts";

// State Type
import { UserStateType } from "../../types/types";

// 상태 초기 값
const initialState = {
  access_token: "",
  expires_in: 0,
  id_token: "",
  refresh_token: "",
  token_type: "",
};

// action 타입에 따른 state 값 리턴
export const user = (
  state: UserStateType = { token: initialState },
  action
) => {
  switch (action.type) {
    case GET_TOKEN:
      return { ...state, token: action.token };
    default:
      return state;
  }
};

리듀서는 액션이 일어났을 때, 액션이 가지고 있는 타입 별로 어떤 일들을 수행할 것인가를 적어주면된다.
액션의 타입을 가지고 switch문을 사용해 해당 타입일 경우 state를 변경하는 작업을 수행한다.
위에서 액션은 타입과 데이터를 담고있는 객체를 반환한다고 했었다. 리듀서는 해당 반환 값을 가지고 작업을 수행하는 것이다.

GET_TOKEN이라는 상수 값과 액션의 타입이 일치한다면, state의 token 값에 액션이 반환해준 token 데이터가 들어갈 것이다.

리듀서 함수를 통해 반환한 값이 새로운 상태가 되는데, 이때 절대!! state를 직접 수정해서는 안된다! 이는 redux의 3가지 원칙 중 하나인 State is read-only에 해당하는 사항으로, 반드시 새로운 값을 만들어 반환해주어야한다.

  • Store
// Redux
import { createStore } from "redux";
import { user } from "./modules/reducers/user.ts";

// redux store
const store = createStore(user);

export default store;

스토어는 redux에서 제공하는 createStore를 사용하면 되는데, createStore에 취소선이 그어져있어서 사용을 못하는건가..? 생각했는데, 검색결과 무시하고 사용해도 잘 동작한다고 해서 그대로 진행했다.
createStore안에는 위에서 작성한 reducer를 인자로 넣어주어야 스토어가 생성된다.

  • Store index.js에 적용
// React
import React from "react";
import ReactDOM from "react-dom/client";

// Library
import { Provider } from "react-redux";

// Component
import App from "./App";

// Store
import store from "./store.ts";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

Provider를 이용해 App 컴포넌트를 매핑해주고, store에 생성한 스토어를 import 한 뒤 적어주면 연결이 완료된다.

5. 생성한 Store에 token 값 저장하기 (dispatch)

상태 관리에 필요한 액션, 리듀서, 스토어 모두 생성했으니 이제 액션을 일으킬 차례이다..

import { useDispatch } from "react-redux";
const dispatch = useDispatch(); // 액션 디스패치
await dispatch(getToken(tokenData));

react-redux에서 제공하는 useDispatch hook을 이용해, 불러 일으킬 액션을 인자로 넣어주면 액션이 발동되면서 리듀서가 액션에 맞는 상태 변경 과정을 진행해준다.

6. Store에서 상태 값 가져오기

액션을 일으켜 상태 변경을 해주었으니 이제 변경된 상태를 컴포넌트에 가져오는 방법을 알아보자!

import { useDispatch, useSelector } from "react-redux";
  const userState = useSelector((state: StoreStateType) => state.user); // 스토어에 저장된 상태 값

dispatch와 마찬가지로 react-redux에서 제공하는 useSelector hook을 이용해서 스토어에 저장된 상태 값을 불러올 수가 있다.

7. Store에서 가져온 상태 값 이용해 페이지 분기 처리

위에서 작성한 코드들을 가지고 최종적으로 로그인 페이지 컴포넌트 코드를 작성하였다.

// 로그인한 사용자의 토큰 값 발급해 store에 저장
const getUserToken = useCallback(
  async (code) => {
    if (!code) return;
    await GetNewTokens(code)
      .then(async (res) => {
      const tokenData = cloneDeep(res.data);
      await dispatch(getToken(tokenData));
    })
      .catch((error) => console.error(error));
  },
  [dispatch]
);

// code 값 없다면 로그인 페이지 띄움, code 값이 있다면 이미 로그인 한 사용자 이므로 token 판별 메서드 호출
useEffect(() => {
  const code = getCode();
  if (!code) {
    replaceLoginPage();
  } else {
    getUserToken(code);
  }
}, []);

// userState 상태 변화를 감지해 token 값이 있는지 판별 후 메인 페이지 이동
useEffect(() => {
  if (!userState.token.access_token) return;
  navigate("/main", { state: userState.token });
}, [userState]);

마지막 useEffect 같은 경우에는 userState가 처음에는 설정해준 초기값을 가지고 있다가, 액션 dispatch로 인해 값이 변경될 것이므로, 상태 변화를 감지할 수 있도록 의존성 배열에 userState를 넣고, 값의 유무를 따져서 라우팅이 이동할 수 있도록 한 것이다.
(나는 이런 과정을 넣어주지 않았더니 스토어에 저장된 값이 제대로 반영되지 않는 현상이 생겨서 좀 더 확실히 하기 위해 코드를 추가해주었다.)


추가) redux-persist를 이용해 sessinStorage에 상태 값 저장하기

위의 구현을 마치고 추가적으로 redux-persist라는 것을 적용해보기 위해 추가 작업을 진행했다.

💡 redux-persist 한줄 정리!
redux로 상태 관리를 하고 새로고침을 할 경우 저장된 상태가 날라간다는 특징이 있다. redux-persist를 덧붙여서 사용하게 되면 브라우저의 로컬 스토리지 또는 세션 스토리지에 상태를 저장할 수 있는 기능을 제공하기 때문에 새로고침 하더라도 상태가 날라가지 않게 만들 수 있다!

redux-persist 적용하기

먼저 reducer 폴더에 index 파일을 하나 만들어주었다.

// Library
import { combineReducers } from "redux";
import { persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage/session";

// Reducer
import { user } from "./user.ts";

// redux-persist 설정 값
const persistConfig = {
  key: "user",
  storage,
  // whitelsit, blacklist 예시
  // whitlist: ["user"],
  // blacklist: ["user"]
};

export const rootReducer = combineReducers({
  user,
});

const persistedReducer = persistReducer(persistConfig, rootReducer);

export default persistedReducer;

persistConfig는 설정 값으로, key 값과 어떤 스토리지에 저장할 것인지를 적어주면 된다.
whitelist와 blacklist도 지정해 줄 수 있는데, whitelist에는 스토리지에 저장하고 싶은 리듀서만 배열에 담아주면 되고, 반대로 blacklist에는 스토리지 저장에서 제외하고 싶은 리듀서만 담아주면 된다.

위와 같이 만들어주었으니 이제 스토어를 수정해주어야 한다.

// Redux
import { createStore } from "redux";

// Reducer
import persistedReducer from "./modules/reducers/index.ts";

// redux store
const store = createStore(persistedReducer);

export default store;

만들어준 persistedReducer를 가지고 스토어를 생성하겠다고 수정해준다.
(처음에 rootReducer를 넣어주었다가.. 계속 빈 화면만 떠서,, 고생했다.. 반드시 persistReducer를 이용해 만들어준 리듀서를 넣어주자!)
그리고 최종적으로 index.js 파일을 수정해주어야 한다.

import React from "react";
import ReactDOM from "react-dom/client";

// Library
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { persistStore } from "redux-persist";

// Component
import App from "./App";

// Store
import store from "./store.ts";

const persistor = persistStore(store);

const root = ReactDOM.createRoot(document.getElementById("root"));

root.render(
  <Provider store={store}>
    <PersistGate loading={null} persistor={persistor}>
      <App />
    </PersistGate>
  </Provider>
);

이전에 redux만 사용했을 때에는 Provider로만 감싸주었었는데, PersistGate를 이용해 한번 더 감싸주어야 한다. loading props는 말그대로 로딩중(redux와 storage가 동기화 될 동안)에 표시할 컴포넌트를 지정해주는 것이고, persistor에는 persistStore를 이용해 persist화 된 store를 만들어 넣어주면 된다.

이렇게 cognito를 이용한 로그인 구현과 로그인 과정 중 얻을 수 있는 사용자 토큰 정보를 redux에 저장하는 과정, redux-persist를 이용해 브라우저 storage에 값을 저장하는 과정까지 해보았다.
새로운 경험이었고, 확실히 뭐든지 적용해 보면서 느는것 같다!

profile
프론트엔드 개발자로 재직 하면서 겪은 개발 과정을 기록하는 곳입니다 🙌

2개의 댓글

comment-user-thumbnail
2022년 10월 17일

항상 잘 보고 있어요 ~

1개의 답글