반응형

redux, redux-saga, jwt, refresh token, 서버사이드렌더링을 이용해서 로그인 유지 기능을 구현해보겠습니다.

 

로그인을 유지하기 위한 과정은 다음과 같습니다.

1. 로그인을 하면, 액세스 토큰과 리프레쉬 토큰을 httpOnly 쿠키에 받아옴. 리덕스에 유저 아이디값도 저장.

2. 리덕스의 유저 아이디 값을 통해 각 페이지 로그인 여부 확인

3. 새로고침하면, 리덕스가 초기화되므로 유저 정보(아이디)값을 새로 불러와야함

4. 서버사이드에서 렌더링할 때, 만료되지않은 액세스 토큰이 있으면, 사용자 정보 요청 후 리덕스에 담아줌

5. 액세스 토큰이 이미 만료되어서 없고, 만료되지 않은 리프레쉬 토큰이 남아있으면, 토큰 재발급 요청과 동시에 사용자 정보 재요청

6. 이 때, 서버사이드에서 받은 쿠키는 브라우저로 전달되지 않으므로 응답 헤더의 'Set-Cookie' 응답에 담아줌

7. 액세스 토큰과 리프레쉬 토큰이 유효하지않은 토큰이라면 그냥 로그아웃 처리

 

 

 

import React from "react";
import AppLayout from "../components/AppLayout";
import wrapper from "../store/configureStore";
import { stayLoggedIn } from "../auth/auth";

const Index = () => {
  return (
    <>
      <AppLayout>
        <div>시작 페이지입니다.</div>;
      </AppLayout>
    </>
  );
};

export const getServerSideProps = wrapper.getServerSideProps(
  async (context) => {
    await stayLoggedIn(context);
  }
);

export default Index;

페이지를 시작할 때, getServerSideProps에서 stayLoggedIn을 실행해줍니다.

해당 소스코드는 위와 같습니다.

 

import { END } from "redux-saga";
import cookie from "cookie";
import { loadMeRequest, setToken, refreshTokenRequest } from "../reducers/user";

export const stayLoggedIn = async (context) => {
  const parsedCookies = context.req
    ? cookie.parse(context.req.headers.cookie || "")
    : ""; // 서버라면 쿠키 확인
  if (parsedCookies) {
    if (parsedCookies["access-token"]) {
      // 액세스 토큰이 있으면 액세스 토큰으로 사용자 정보 불러옴
      context.store.dispatch(
        loadMeRequest({
          accessToken: parsedCookies["access-token"],
        })
      );
    } else if (parsedCookies["refresh-token"]) {
      // refresh 토큰만 있으면 토큰 재발급 및 사용자 정보 재요청
      context.store.dispatch(
        refreshTokenRequest({
          refreshToken: parsedCookies["refresh-token"],
        })
      );
    }
  }
  context.store.dispatch(END);
  await context.store.sagaTask.toPromise(); // 기다림
  const {
    id,
    accessToken,
    refreshToken,
    setCookie,
  } = context.store.getState().user;
  if (id) { // 사용자 정보 재요청된 경우
    if (accessToken && refreshToken) {
      // refresh한 경우 쿠키 다시 세팅
      context.res.setHeader("Set-Cookie", setCookie); // setCookie는 refresh 요청 이후 리덕스에 저장해둠
    } else {
      // access token으로 요청한 경우 현재 토큰 리덕스에 담아둠
      context.store.dispatch(
        setToken({
          accessToken: parsedCookies["access-token"],
          refreshToken: parsedCookies["refresh-token"],
        })
      );
    }
    context.store.dispatch(END);
    await context.store.sagaTask.toPromise();
  }
};

쿠키의 토큰 값을 확인하고, 해당 토큰으로 자신의 정보를 불러옵니다.

위 함수들의 action은 다음과 같습니다.

서버사이드에서 응답 요청 받은 헤더 값은 브라우저로 연달아 전달되지 않기 때문에,

백엔드에서 응답 받는 쿠키 값을 따로 저장해놓고, 브라우저의 헤더 값으로 다시 등록해주었습니다.

 

export const initialState = {
  id: 0,
  uid: "",
  accessToken: "",
  refreshToken: "",
  setCookie: [],
  ...
};

export const REFRESH_TOKEN_REQUEST = "REFRESH_TOKEN_REQUEST";
export const REFRESH_TOKEN_SUCCESS = "REFRESH_TOKEN_SUCCESS";
export const REFRESH_TOKEN_FAILURE = "REFRESH_TOKEN_FAILURE";
export const LOAD_ME_REQUEST = "LOAD_ME_REQUEST";
export const LOAD_ME_SUCCESS = "LOAD_ME_SUCCESS";
export const LOAD_ME_FAILURE = "LOAD_ME_FAILURE";
export const SET_TOKEN = "SET_TOKEN";
// 액션

...

export const refreshTokenSuccess = (payload) => ({
  type: REFRESH_TOKEN_SUCCESS,
  payload,
});

export const refreshTokenFailure = () => ({
  type: REFRESH_TOKEN_FAILURE,
});

export const loadMeRequest = (payload) => ({
  type: LOAD_ME_REQUEST,
  payload,
});

export const loadMeSuccess = (payload) => ({
  type: LOAD_ME_SUCCESS,
  payload,
});

export const loadMeFailure = () => ({
  type: LOAD_ME_FAILURE,
});

export const setToken = (payload) => ({
  type: SET_TOKEN,
  payload,
});

...

const reducer = (state = initialState, action) =>
  produce(state, (draft) => {
    switch (action.type) {
      case REFRESH_TOKEN_REQUEST:
        break;
      case REFRESH_TOKEN_SUCCESS: // 요청 성공 시 회원 정보, 토큰값, 응답 쿠키값 저장
        draft.id = action.payload.info.id;
        ...
        draft.accessToken = action.payload.accessToken;
        draft.refreshToken = action.payload.refreshToken;
        draft.setCookie = action.payload.setCookie;
        break;
      case REFRESH_TOKEN_FAILURE: // 실패시 로그아웃 처리..
        draft.id = 0;
        draft.uid = "";
        draft.username = "";
        draft.nickname = "";
        draft.createdAt = "";
        draft.modifiedAt = "";
        draft.accessToken = "";
        draft.refreshToken = "";
        break;
      case LOAD_ME_REQUEST:
        break;
      case LOAD_ME_SUCCESS: // 사용자 정보 저장
        draft.id = action.payload.info.id;
        draft.uid = action.payload.info.uid;
        draft.username = action.payload.info.username;
        draft.nickname = action.payload.info.nickname;
        draft.createdAt = action.payload.info.createdAt;
        draft.modifiedAt = action.payload.info.modifiedAt;
        break;
      case LOAD_ME_FAILURE:
        break;
      case SET_TOKEN: // 토큰 값 저장
        draft.accessToken = action.payload.accessToken;
        draft.refreshToken = action.payload.refreshToken;
      default:
        break;
    }
  });

export default reducer;

action이 성공하면 토큰 또는 유저 정보 값을 리덕스에 채워줍니다.

각 action들의 saga 코드는 다음과 같습니다.

// import 생략..

...

function refreshTokenAPI({ refreshToken }) {
  return axios.post(
    "/api/sign/refresh-token",
    {},
    {
      headers: {
        Authorization: refreshToken,
      },
    }
  );
}

function* refreshToken(action) {
  try {
    const result = yield call(refreshTokenAPI, action.payload);
    const { accessToken, refreshToken, info } = result.data.data;
    const setCookie = result.headers["set-cookie"]; // 응답 쿠키 저장
    yield put(
      refreshTokenSuccess({ accessToken, refreshToken, info, setCookie })
    );
  } catch (err) {
    yield put(refreshTokenFailure());
  }
}

function loadMeAPI({ accessToken }) {
  return axios.get("/api/users/me", {
    headers: {
      Authorization: accessToken,
    },
  });
}

function* loadMe(action) {
  try {
    const result = yield call(loadMeAPI, action.payload);
    const info = result.data.data;
    yield put(loadMeSuccess({ info }));
  } catch (err) {
    yield put(loadMeFailure());
  }
}


function* watchRefreshToken() {
  yield takeLatest(REFRESH_TOKEN_REQUEST, refreshToken);
}

function* watchLoadMe() {
  yield takeLatest(LOAD_ME_REQUEST, loadMe);
}

export default function* userSaga() {
  yield all([
    fork(watchRegister),
    fork(watchLogin),
    fork(watchRefreshToken),
    fork(watchLoadMe),
  ]);
}

위에서 말했지만 서버사이드 렌더링을 할 때에는, 쿠키 값이 브라우저로 전달되지 않으므로 리덕스에 저장해두고, 위 stayLoggedIn에서 직접 담아주었습니다.

 

 

const rootReducer = (state, action) => {
  switch (action.type) {
    case HYDRATE:
      return action.payload;
    default: {
      const combinedReducer = combineReducers({
        // .. reducers
      });
      return combinedReducer(state, action);
    }
  }
};

export default rootReducer;

reducer의 루트 구조는 위와 같습니다. 서버사이드렌더링이 끝나고 난 뒤의 state가 담긴 action은 HYDRATE로 옵니다. 이것을 리덕스에 담아주면 됩니다.

이젠 새로고침을 해도, 유저 정보와 토큰 값이 잘 담겨있습니다.

이어서 액세스 토큰이 만료되거나 없어졌을 때의 토큰 재발급 처리 과정을 알아보겠습니다.

현재 새로고침 시 로그인 정보를 유지하기 위한 내 정보 요청 API 또는 리프레쉬 작업은 서버사이드에서 일어나고 있습니다.

하지만, 브라우저에서 클라이언트가 API 요청을 했는데 토큰이 만료되었으면 재발급을 자동으로 수행해서 해당 작업을 다시 수행해야합니다.

이 경우에는 브라우저에서 응답 쿠키를 곧바로 받을 수 있고, 토큰을 새로 발급받자마자 원래 수행하려고 했던 작업을 수행해야합니다.

다른 글에서 setTimeout으로 구현하는 방법을 보았는데,

이 경우 토큰의 만료시간을 클라이언트에서 직접 체크해줘야할 필요가 있고,

서버사이드에서 받아온 토큰 값을 브라우저 상에서 로드되자마자 수행하기에는 까다로웠고, 서비스를 이용하지 않고 있어도 자동으로 재발급을 꼭 받아야만했습니다.

API 요청 -> 에러 응답 -> 토큰으로 인한 만료 응답인지 확인 -> refresh token으로 토큰 재발급 -> 원래 하려고 했던 작업 재요청

위 과정을 거쳐서 수행해보겠습니다.

export const initialState = [
  ...,
  refreshTokenLoading: false,
];

export const REFRESH_TOKEN_BY_CLIENT_REQUEST = "REFRESH_TOKEN_BY_CLIENT_REQUEST";
export const HANDLE_ERROR = "HANDLE_ERROR";

export const refreshTokenByClientRequest = (payload) => ({
  type: REFRESH_TOKEN_BY_CLIENT_REQUEST,
  payload,
});

export const handleError = (payload) => ({
  type: HANDLE_ERROR,
  payload,
});

...
switch(action.type) {
  ...
  case REFRESH_TOKEN_BY_CLIENT_REQUEST:
  case REFRESH_TOKEN_REQUEST:
    draft.refreshTokenLoading = true;
    break;
}

먼저 브라우저 상에서 토큰을 재발급 요청하기 위한 액션과 에러를 다루기 위한 액션을 새로 등록해줍니다.

또, 리프레쉬 요청이 이미 시작되었는지 확인하기 위해 refreshTokenLoading을 추가해주었습니다.

만약 동시에 여러 작업이 실패해서 토큰 재발급 요청이 여러 번 일어날 수도 있는 상황일 때,

REFRESH_TOKEN 요청이 들어오면, refreshTokenLoading을 true로 바꿔주고,

refreshTokenLoading이 ture라면 다른 요청들은 토큰 재발급 요청이 끝날 때 까지 기다리고 있을 것입니다.

 

 

function* watchRefreshTokenByClient() {
  yield takeEvery(REFRESH_TOKEN_BY_CLIENT_REQUEST, refreshTokenByClient);
}

function* watchHandleError() {
  yield takeEvery(HANDLE_ERROR, errorHandling);
}

export default function* userSaga() {
  yield all([
    ... // 위에서 작성한 코드들과 동일
    fork(watchRefreshTokenByClient),
    fork(watchHandleError),
  ]);
}

saga에도 등록해서 해당 액션을 주시하고 있겠습니다.

클라이언트에서 refreshToken 작업은 한번만 수행되어야 하겠지만, 혹시나 여러 번 일어났을 경우 모든 요청에 대한 응답을 받아야 유효한 토큰 값이 저장되어있을 것이므로 takeEvery로 설정해주었습니다.

위에서 작성한 loadMe에서 에러가 발생했다면 HANDLE_ERROR 액션을 발생시키겠습니다.

function loadMeAPI({ accessToken }) {
  return axios.get("/api/users/me", {
    headers: {
      Authorization: accessToken,
    },
  });
}

function* loadMe(action) {
  try {
    const result = yield call(loadMeAPI, action.payload);
    const info = result.data.data;
    yield put(loadMeSuccess({ info }));
  } catch (err) {
    yield put(loadMeFailure());
    // 에러 캐치하면 HANDLE_ERROR 액션
    yield put(handleError({ result: err.response.data, task: action })); 
  }
}

저 같은 경우, 응답 데이터(err.response.data)에 각 요청별 에러코드를 기입해두었습니다.

응답 결과와 현재 취했던 action을 payload로 HANDLE_ERROR 액션을 발생시킵니다.

현재 취한 action을 보내주는 이유는 토큰 재발급이 끝나면 다시 수행해주기 위함입니다.

 

function* errorHandling(action) {
  const { refreshTokenLoading } = yield select((state) => state.user);
  const { result, task } = action.payload;
  const { code } = result;
  if (code === -1001 || code === -1002) {
    if (refreshTokenLoading) { // 
      yield take(REFRESH_TOKEN_SUCCESS);
      yield put(task);
    } else { // 첫 요청이 들어가면 토큰 재발급 요청
      yield put(refreshTokenByClientRequest({ task }));
    }
  }
}

HANDLE_ERROR가 발생하면, 위의 errorHandling을 수행할 것입니다.

응답코드 -1001, -1002는 토큰 만료 또는 누락으로 인한 상황을 백엔드 서버에서 미리 정의해둔 것입니다.

만약 여러 API에서 동시에 에러를 캐치하고 토큰을 재발급하는 상황이 된다면,

if(refreshTokenLoading)에서 첫 요청은 false로 들어가서 토큰 재발급 요청 액션을 보내면, refreshTokenLoading = true로 바뀌게 될 것입니다.

그리고 이어서 들어오는 뒷 요청들은 if문의 true로 들어가서,

REFRESH_TOEKN_SUCCESS가 끝날 때까지 기다리고 토큰 재발급 요청이 끝나면, 원래 수행하려고 했던 액션(task)을 다시 수행할 것입니다.

 

function refreshTokenAPI({ refreshToken }) {
  // ... 위와 동일
}

function* refreshToken(action) {
  // ... 위와 동일
}

function* refreshTokenByClient(action) {
  try {
    const { task } = action.payload;
    const user = yield select((state) => state.user);
    const result = yield call(refreshTokenAPI, user);
    const { accessToken, refreshToken, info } = result.data.data;
    yield put(refreshTokenSuccess({ accessToken, refreshToken, info }));
    yield put(task);
  } catch (err) {
    yield put(refreshTokenFailure());
  }
}

브라우저 상에서 토큰을 재발급 요청하는 코드는 다음과 같습니다.

가지고 있는 refresh token을 이용해서, 백엔드 서버에 토큰 재발급 요청이 성공하면 원래 수행하고자 했던 액션(task)을 다시 수행해줄 것입니다.

 

 

* 예전에 학습하면서 진행했던 내용이라, 부족함이 있을 수 있습니다.

반응형

+ Recent posts