프로젝트/풀스택 프로젝트

4-3. [React + Node.js Express] 유저 인증 기능

찰리-누나 2022. 12. 5.

 


 

예를 들어 쇼핑몰 사이트가 있다고 해 보자. 쇼핑몰 웹사이트는 장바구니 기능을 이용하기 위해 '현재 누가 로그인 되어있는지, 또는 로그인되어있지 않은 상태인지' 를 각 상품 주소를 이동할 때 마다 알아내야 한다. 또한 로그인되어 있는 상태라면 해당 유저의 정보를 매번 불러와야 한다.

 

지난 시간에 로그인을 구현하면서, 로그인을 하면 jwt 토큰을 발급해 쿠키에 저장하기로 했다. jwt 토큰은 암호화된 토큰인데, 반대로 말하면 복호화했을 경우 처음 토큰으로 이용된 값을 알아낼 수 있다. 우리는 user_id를 이용해 jwt토큰을 만들었으므로, 토큰을 복호화하면 user의 _id가 나오게 된다. 따라서 매 페이지를 이동할 때 마다 토큰을 복호화하여 db에 등록된 _id인지를 검사하면 로그인된 유저의 정보를 알아낼 수 있다.

 

따라서 이번에는 토큰을 이용한 유저 인증 기능을 만들어본다. api와 데이터의 흐름은 다음과 같다.

 


프론트에서 유저 인증 api 호출 -> 쿠키에 담긴 토큰을 백엔드로 보냄 -> 백엔드에서 쿠키 속 토큰을 복호화 함 ->
복호화 한 토큰을 DB와 비교 -> 해당 토큰을 가진 유저가 있다면 유저 정보로그인 상태 값으로 true를 반환함 ->
프론트는 유저 정보를 받아 해당 데이터를 Redux에 저장 ->
프론트는 모든 페이지에서 유저인증 API를 호출해 TRUE를 반환받을 때만 로그인 유저로 인정

 


 

Fontend

 

 

리액트는 기본적으로 Single Page Application이다. 즉, 모든 페이지 라우터는 App.js에 모여있고, 라우터는 페이지 주소에 따라 보여줄 컴포넌트를 결정한다. 모든 페이지에서 api를 불러올 필요 없이, App.js에서 현재 유저의 상태를 불러오는 api를 작성해 각 컴포넌트로 props를 사용해 상속해준다. 로그인 된 유저 인증을 요청하는 api는 여러번 반복될 필요 없이 컴포넌트가 마운트 될 때만 실행되면 되기 때문에, useEffect()안에 작성한다. 

 

 

axios.get을 이용해 유저 인증인 auth api를 요청하되, 어떤 유저가 로그인 한 상태일 경우 데이터의 전역적인 사용을 위해 Redux에 토큰과 함께 사이트 내에서 반복되어 사용될 수 있는 유저의 정보를 저장한다. Redux는 새로고침 할 경우 초기화되는 특성이 있으므로 저장된 정보를 버그 없이 불러오려면 리덕스에 저장한 정보들이 localStorage, 또는 sessionSotrage에 저장되는 redux-persist 라이브러리를 사용한다. (새로고침 없이 AJAX로 이동하는게 원칙이지만 사용자 중 누군가는 새로고침을 해버릴 수도 있으니까...)

https://www.npmjs.com/package/redux-persist

 

redux-persist

persist and rehydrate redux stores. Latest version: 6.0.0, last published: 3 years ago. Start using redux-persist in your project by running `npm i redux-persist`. There are 928 other projects in the npm registry using redux-persist.

www.npmjs.com

 

 

나는 좀 더 사용하기 간편한 리덕스 툴킷을 사용할 것이다. 모든 redux 정보가 한 파일에 있으면 나중에 관리할때 몹시 불편하기 때문에, user에 대한것은 user파일에, token에 대한 것은 token 파일에 따로 저장하기 위해 redux-toolkit의 CombinReducers를 사용할 것이다. CombinReducers를 사용하면 파일 단위로 분리한 여러 리듀서들을 최상위의 루트 리듀서 파일에서 묶어줄 수 있다. App.js에서 컴포넌트들을 import 하듯이 사용한다고 생각하면 이해하기 쉬울 듯 하다.

 

나중에 로그인을 손볼 때 쿠키에 저장했던 JWT 토큰을 로컬 스토리지에 저장하도록 바꾸어 줄 건데... 연습용으로 토큰 리듀서를 만들어 Token은 Redux에만, 수시로 불러와야하는 유저 정보(이름, 닉네임 등)는 Redux와 localStorage에 모두 저장하도록 설정한다. (토큰 리듀서는 지워도 됨)

 

 

Redux Toolkig & Redux Persist

 

1. npm install @reduxjs/toolkit react-redux로 redux toolkit을 설치한다.

2. npm install redux-persist로 persist 라이브러리를 설치한다.

3. 프론트의 src 폴더 내에 Store 폴더를 만들어준다. 

4. Store 폴더에 최상위 루트 스토어가 될 store.js와 user관련 리덕스인 setUser.js, 토큰 관련 리덕스인 setToken.js를 만든다.

Store 폴더 내 구성요소

 

5. index.js에 아래와 같이 store와 redux-persist 설정을 해준다. store.js를 import하고, persist라이브러리를 불러넣어준다. 그 다음 App 컴포넌트를 감싸도록 설정해준다.

import { Provider } from "react-redux";
import store from './Store/store.js'

import { PersistGate } from 'redux-persist/integration/react';
import { persistStore } from 'redux-persist';


export let persisotr = persistStore(store)

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(  

  <React.StrictMode>
    <CookiesProvider>
    <Provider store={store}>
      <PersistGate loading={null} persistor={persisotr}>
   <BrowserRouter>
       <App />
     </BrowserRouter>
     </PersistGate>
    </Provider>
    </CookiesProvider>
  </React.StrictMode>
);

 

6. setUser.js를 작성한다. 회원 정보란에서 현재 user의 이메일, 이름, 닉네임을 알아내 편집할 수 있어야 하므로 세 항목을 불러와 저장할 것이다. Redux는 한번에 여러개의 항목에 초기값을 지정하고, 개발자가 만든 reducers 함수로 메소드가 호출되었을 때 파라미터로 보내온 값을 받아 Redux 변수에 넣어줄 수 있다. action.payload에는 메소드를 호출했을 때 파라미터로 담아 보내준 값이 들어있는데, 만일 여러값을 보내고 싶다면 키:값 쌍인 Json 형태로 보내야 한다.

import { createSlice } from '@reduxjs/toolkit';

export const userSlice = createSlice({
    name: 'authUser',
    initialState: {
        u_email: null,
        u_name: null,
        u_nik: null,
    },
    reducers: {
        GET_USER: (state, action) => {
            state.u_email = action.payload.email
            state.u_name = action.payload.name
            state.u_nik = action.payload.nikname
        },
        DELETE_USER: (state) => {
            state.u_email = null
            state.u_name = null            
            state.u_nik = null
        },
    }
})

export const { GET_USER, DELETE_USER } = userSlice.actions;

export default userSlice.reducer;

위에서  state.u_email = action.payload.email 에서 페이로드에 담겨있는 email값을 가져와 u_email의 값에 넣어주라는 뜻이다. 이 때 값을 셋팅하는 GET_USER가 호출된 곳에서는 GET_USER({name:'이름'}) 과 같은 형식으로 파라미터를 넣어주어야 한다.

 

모든 리듀서는 export 해야 store에서 합쳐줄 수 있으므로, 반드시 export 하는것을 잊지 않도록 한다.

 

 

 

7. 토큰과 관련된 setToken.js를 setUser.js와 같은 형식으로 작성한다.

import { createSlice } from '@reduxjs/toolkit';

export const tokenSlice = createSlice({
    name: 'authToken',
    initialState: {
        authenticated: false,
        w_auth: null,
    },
    reducers: {
        GET_TOKEN: (state, action) => {
            state.authenticated = true;
            state.w_auth = action.payload;
        },
        DELETE_TOKEN: (state) => {
            state.authenticated = false;
            state.w_auth = null;
        },
    }
})

export const { GET_TOKEN, DELETE_TOKEN } = tokenSlice.actions;

export default tokenSlice.reducer;

 

 

8. 루트 리듀서가 될 store.js를 작성한다. 짧지만 복잡하고, 핵심적인 내용이 많다. 먼저 전체 코드는 아래와 같다.

import { combineReducers, configureStore } from '@reduxjs/toolkit';
import setToken from './setToken';
import setUser from './setUser';
import storage from 'redux-persist/lib/storage'
import storageSession from 'redux-persist/lib/storage/session'
import { persistReducer } from 'redux-persist'


const rootReducer = combineReducers({
    setToken:setToken,
    setUser:setUser
  });

const persistConfig = {
    key:'root',
    // 로컬 스토리지에 저장할 경우 storage:storage, 세션에 저장할 경우 storage:storageSession
    storage:storage,
    // whitelist : ['적용대상목록'] 
    // blacklist : ['미적용대상목록']
    whitelist: ['setUser'],
    blacklist: ['setToken']
}

const persistedReducer = persistReducer(persistConfig, rootReducer)

const store = configureStore({
    reducer:persistedReducer
})

export default store;
// export default configureStore({
//     reducer: {
//         setToken: setToken,
//         setUser:setUser
//     },
// });

 

그럼 import부터 자세하게 살펴보자. combineReducers 는 여러 리듀서를 합쳐주는 기능이고, configureStore는 Reducer에서 반환된 state를 Store라는 객체로 정리해 관리하는 곳이다. 즉 combineReducers로 리듀서들을 모아서 => configureStore에 등록해주면 최종적으로 모든 리듀서를 사용할 수 있게 된다. 그 밑에있는 setToken, setUser는 combine을 통해 묶어줄 리듀서들을, 컴포넌트를 불러오듯이 경로를 통하여 import 해 준 것이다.

import { combineReducers, configureStore } from '@reduxjs/toolkit';
import setToken from './setToken';
import setUser from './setUser';

 

다음은 persist와 관련된 import 들이다. 맨 아래줄은 라이브러리를 불러오는 문장이고, 그 윗 두줄 storage와 stroageSessiono은 각각 localStorage, sessionStorage를 가리킨다. 로컬 스토리지는 브라우저를 종료해도 저장된 정보들이 사라지지 않지만, 세션 스토리지는 브라우저를 종료하면 저장된 정보들이 사라진다. 둘 중 끌리는 것을 선택하면 되는데, 나는 연습상 로컬 스토리지에 저장했다가 세션에 저장하는 것으로 바꾸었다. 이유는 App.js를 작성하는 과정에서 설명할 것이다.

import storage from 'redux-persist/lib/storage'
import storageSession from 'redux-persist/lib/storage/session'
import { persistReducer } from 'redux-persist'

 

이제 const rootReducer에 combineReducers를 사용하여 작성하고 import한 리듀서들을 등록해준다.

const rootReducer = combineReducers({
    setToken:setToken,
    setUser:setUser
  });

 

그리고 persistConfig 에 persist에 대한 정보들을 작성한다. sotrage에 storageSession을 주면 세션 스토리지에, storage를 주면 로컬 스토리지에 정보가 저장된다. whitelist는 작성한 리듀서들 중 persist 라이브러리를 통해 스토리지에 저장할 적용 대상을 일컫는다. blacklist는 이름답게 persist 라이브러리를 적용하지 않고, 오직 리덕스에만 저장할 대상을 가리킨다.

const persistConfig = {
    key:'root',
    // 로컬 스토리지에 저장할 경우 storage, 세션에 저장할 경우 storageSession
    storage:storage,
    // whitelist : ['적용대상목록'] 
    // blacklist : ['미적용대상목록']
    whitelist: ['setUser'],
    blacklist: ['setToken']
}

 

그 뒤 persistReducer를 연결해주고, ConfigureStore를 통해 리듀서를 등록한 후 store를 export 해주면 redux 셋팅이 끝난다.

const persistedReducer = persistReducer(persistConfig, rootReducer)

const store = configureStore({
    reducer:persistedReducer
})

export default store;

 

 

9. App.js 에 api 호출 껍데기를 작성한다. useEffect를 사용해 axios.post로 현재 user의 정보를 가져올 것이다. 만일 유저가 로그인 된 상태라면 응답으로 전달받은 유저의 데이터를 받아오고, 그렇지 않다면 아무 행동도 하지 않는다. redux에 작성한 리듀서들은 useDispatch()를 사용해야 사용할 수 있다. /api/user/auth로 유저 로그인 상태 인증을 보냈을 때 돌아온 isAuth에 담긴 답이 true라면, 콘솔에 받아온 데이터를 출력하고 Redux에 쿠키 및 유저 값을 할당한다. 

 

이때 여러 슬라이스를 작성하지 않고 한 슬라이스에 여러 항목을 기입했으므로, 키:값 쌍으로 데이터를 보내야 하는 것을 잊어서는 안된다. 코드 가로길이는 길지만 리듀서에 여러개의 actions(함수)를 작성하고, 또 여러번 불러오는 것 보다 이렇게 하는 것이 훨씬 짧고 간결하다.

 let dispatch = useDispatch()
 
 useEffect( ()=>{    
    // 현재 user상태를 가져온다.
    axios.get('/api/user/auth')
    .then(response => {
        console.log('App.js => auth정보',response.data)
        // 만일 로그인 된 상태면
        if(response.data.isAuth === true){
        console.log('리스폰스 데이터',response.data)

        // Redux에 쿠키에 저장된 토큰을 받아와 할당하라
        dispatch(GET_TOKEN(cookies.get('w_auth')))

        // res로 받아온 정보를 GET_USER에 할당하라
        dispatch(GET_USER({email:response.data.email, name:response.data.name, nikname:response.data.nikname, role:response.data.role}))
      }
        else {
          // 만일 유저가 없으면(로그인하지 않은 상태라면)
          // 아무것도 안함
        }
      })
  },[])

 

 

 

 

 


Backend

 

 

 

 

 

본격적으로 유저 인증 api를 작성한다. 프론트에서 요청한 'api/user/auth' 에 응답할 api이다. /api/user/auth 는  '프론트에서 req에 담아온 정보를 사용해 db에서 해당 사용자가 있는지를 검색해, 결과 돌려주기' 라는 역할을 수행해야 한다. 나는 로그인 api를 만들 때 발급한 api 토큰을 '쿠키' 에 저장해서 브라우저로 보냈다. 따라서 auth에서도 요청(req)의 쿠키에 들어있는 토큰을 받아, 복호화한 후 현재 db에 담겨있는 값과 같은 유저가 있는지를 검사하면 현재 로그인한 유저를 찾아낼 수 있을 것이다. 

 

 

server/routes/users.js

/server/routes/users.js에 아래 router api를 추가한다. 두번째 인자로 준 auth는 곧 작성할 auth 미들웨어이다. 미들웨어는 req(요청)과 res(응답) 객체, 및 요청-응답 사이클 중간에 실행되는 함수라고 보면 된다. 즉 rotuer.post로 auth 요청을 하면, auth.js 라는 미들웨어 파일에 들러 함수의 내용을 실행하고, 다시 자신을 부른 곳으로 돌아온다. 여기에 다 적기에는 너무 길고 복잡하기 때문에 미들웨어로 빼놓았다. 미들웨어 auth.js가 처리를 마치면 성공했다는 응답과 함께, 알아낸 유저의 정보를 보낸다.

//auth.js 생성하여 등록
const { auth } = require('../middleware/auth')

// 유저 인증 처리
// auth.js로 요청을 포워딩해 로그인 시 생성되어 쿠키에 저장된 토큰과, db에 저장된 토큰을 비교한다.
router.get("/auth", auth, (req, res) => {
    res.status(200).json({
        _id: req.user._id,
        isAdmin: req.user.role === 0 ? false : true,
        isAuth: true,
        email: req.user.email,
        name: req.user.name,
        nikname: req.user.nikname,
        role: req.user.role,
        image: req.user.image,
    });    
    console.log('유저토큰 찾음')
});

 

server/middleware/auth.js

 

미들웨어 auth.js는 쿠키에 들어있는 jwt 토큰을 복호화하여 db와 비교한다. User.findByToken은 몽구스에서 제공하는 메소드이다. 해당 메소드는 첫번째 인자로 보내진 데이터를 db에서 찾는다. 첫번째 인자로 token을 보내 만일 db에 해당 토큰을 가진 유저가 있다면 그 유저의 정보를 (err,user)에서 user에 담아 반환하고, 자신을 호출한 곳으로 돌아간다.

const { User } = require('../models/User');

let auth = (req, res, next) => {
  
  // 클라이언트 쿠키에서 토큰을 가져온다.
  let token = req.cookies.w_auth;
  

  // 유저를 찾기 위해 토큰을 복호화한다.
  User.findByToken(token, (err, user) => {
    if (err) throw err;
    // 만일 유저가 없으면 false를 반환
    if (!user)
      return res.json({
        isAuth: false,
        error: true
      });

      // 만일 유저를 찾으면 토큰과 유저를 저장해 클라이언트로 전송한다.
    req.token = token;
    req.user = user;
    next();
  });
};

module.exports = { auth };

 

 

이제 프론트와 백엔드에서의 api 작성이 끝났다. 루트 디렉토리에서 npm run dev를 하여 로그인을 하고, 로컬 스토리지에 유저의 정보가 저장되었는지 확인한다. 여기까지 하면 ' 프론트에서 유저 인증 API 호출 => 백엔드에서 쿠키 토큰 복호화하여 유저 찾기 => 찾은 유저 정보 프론트로 전송 => 프론트는 해당 유저의 정보를 Redux에 저장 ' 까지의 단계가 완료된다.

 

각 단계마다 콘솔을 사용하였으니 콘솔창을 확인해본다. 또한 persist 라이브러리를 활용해 Redux정보 중 user 정보가 '로컬 스토리지에 저장되도록' 설정하였으므로, 개발자 도구에서 Application의 localStorage를 확인해보면 된다. useEffect 밖에

 let Rstore = useSelector((state)=>{return state})
 

를 선언하고 Redux안의 모든 정보를 출력하는

 console.log(Rstore)

를 작성해 Redux에 정보가 제대로 저장되었는지도 출력해 보았다.

콘솔 작성
Object는 Redux 정보를 출력하고, 아래 두 줄은 useEffect속 axios 정보를 출력한 것이다.
로컬 스토리지 내용

 

로컬 스토리지에도 정보가 제대로 저장되었다.

댓글