최근 리액트와 Redux를 이용한 사이드 프로젝트를 진행하다가 좋은 아이디어가 생겨서 프로젝트의 전반적인 구조를 바꾸게되면서 사용자 인증 관련 로직들 또한 재구성해야 했고, 그 과정에서 겪었던 여러가지 문제들과 새로 배웠던 것들을 기록해보려한다.
API를 사용하는 웹 서비스를 개발 시 사용자 인증이 필요한 시점에 이를 해결해줄 여러 방법들을 많이 들어보았을 것이다. 그리고 여기서 토큰(Token)을 이용한 인증 방식은 상당히 모던하고, 간편한 방법이다. 서버단에서 저장하고, 관리해야했던 세션 기반의 인증과는 달리 토큰 기반의 인증방식은 stateless하다는 것이 가장 큰 특징이다. 즉 상태가 존재하지않기에 유저의 인증 정보를 서버나 세션에 담아두지 않으며, 클라이언트단에서 관리하기때문에 트래픽 대한 부담이 낮고 플랫폼 확장성 유의 및 REST 서비스로 제공 가능하다는 이점이 있다.
JWT는 속성 정보(Claim)를 JSON 데이터 구조로 표현한 Claim기반의 토큰으로 RFC7519 웹표준 이다. JWT은 토큰 타입과 해싱 알고리즘이 담겨져 있는 Header와 도메인 및 토큰에 담을 정보가 담겨져있는 payload, 해싱에 필요한 secret키가 담겨져있는 signature로 구성되어 있으며, 서버와 클라이언트 간 정보를 주고 받을 시 요청 헤더에 JSON 웹 토큰을 넣은 후 서버는 별도의 인증 과정없이 헤더에 포함되어 있는 JWT토큰을 변환하여 변환 된 정보를 가입된 유저 정보와의 비교를 통해 사용자 인증을 진행하게된다.
사용자 인증의 전반적인 구조이다. (세세한 부분은 다이어그램 공간 부족으로 아래에서 추가적으로 서술할 예정이다.) 먼저 접속자가 최초로 보게 되는 Landing page에서 localStorage에 'CURRENT_USER'라는 키가 존재하는지 확인한다.
해당 키 여부에따라 lobby로 이동 여부가 결정되는데 우선 해당 부분은 이미 로그인한 유저가 Landing page로 진입했을때 자동으로 lobby로 진입시키기 위함이며, 굳이 토큰 인증을 담당하는 리덕스 액션을 사용하는 대신 localStorage를 사용한 이유는 라우팅을 이용한 페이지 이동은 순식간에 처리되어서 자연스럽게 보여하지만, 아무래도 사용자 인증시 액션을 통해서 토큰인증을 담당하는 api를 거치기까지 시간이 좀 소요되다보니 순간적으로 깜빡이면서 lobby로 진입되는 모습이 부자연스럽다고 느꼈기때문이다.
{ ... }
// ====================
//Login container
// ====================
const { auth, enteredUserInformation, error } = useSelector(
({ authReduce }) => {
return {
auth: authReduce.userAuth?.isAuth,
enteredUserInformation: authReduce.userAuth?.enteredUserInformation,
error: authReduce.error,
};
},
);
useEffect(() => {
if (!auth) {
dispatch(authUser());
}
if (error) {
history.push('/');
}
}, [dispatch, error]);
useEffect(() => {
if (auth && !enteredUserInformation) {
history.push('/agreement');
}
}, [enteredUserInformation, history, auth]);
useEffect(() => {
if (!localStorage.getItem('CURRENT_USER')) {
history.push('/');
}
}, [auth, history]);
{ ... }
Landing page에서 처음 로그인 시엔
oAuthLoginHandler
함수를 통해loginUser
액션을 디스 패치하게되며, 성공시 해당 리듀서 state에 loginSuccess : true가 추가되며, true일시 loginSuccess를 두번째 인자로 받고있는 useEffect Hook이 변화를 감지하고 토큰 인증액션인authUser
로 디스패치를 하게된다. 그리고 성공시 isAuth : true가 추가되고, 이를 인자로 받는 하단의 useEffect가 인증이 끝난 사용자를/lobby
로 이동시키게된다.
{ ... }
// ====================
// Lobby container
// ====================
const LobbyContainer = ({ history }) => {
const dispatch = useDispatch();
const { loginSuccess, auth, enteredUserInformation, error } = useSelector(
({ authReduce }) => {
return {
auth: authReduce.userAuth?.isAuth,
loginSuccess: authReduce.loginSuccess,
enteredUserInformation: authReduce.userAuth?.enteredUserInformation,
error: authReduce.error,
};
},
);
useEffect(() => {
if (!auth) {
dispatch(authUser());
}
if (error) {
history.push('/');
}
}, [dispatch, error]);
useEffect(() => {
if (auth && !enteredUserInformation) {
history.push('/agreement');
}
}, [enteredUserInformation, history, auth]);
useEffect(() => {
if (!localStorage.getItem('CURRENT_USER')) {
history.push('/');
}
}, [loginSuccess, history]);
const logoutHandler = () => {
dispatch(logOutUser());
};
{ ... }
로비 페이지로 이동 후에는 조금더 복잡해지는데, 이유는 만일 추가 정보를 입력하는 과정을 거치지 않은 유저라면
/agreement
페이지로 이동시켜야 하기 때문이다.
첫번째 useEffect는 auth리듀서의 auth여부에 따라 유저 인증 액션 수행 여부를 결정 및 인증 실패 시 Landing page로 보내주는 기능을 담당하고있으며, 만일 사용자가 lobby단에서 새로 고침을 할시 리덕스 스토어의 정보는 초기화 되므로, 페이지 새로고침 시 재인증을 진행한뒤 만일 인증에 실패하면 Landing page로 돌아가게된다.
두번째 useEffect는 인증은 되었으나, 추가 정보 입력을 하지않은 사용자일시 /agreement로 보내기는 기능을 담당하고있다. enteredUserInformation는 db에서 갖고있는 값이며, authUser 액션 수행 시 서버에서 응답해주는 유저 객체에 포함되어있다. 만일 추가정보 입력을 한 유저는 해당 값이 true로 변하게된다.
세번째 useEffect는 로그아웃 시 유저를 Landing page로 보내주는 기능을 담당하고있다. 로그아웃 액션이 수행되면 서버의 로그아웃 api요청 및 localStorage의 값도 삭제하기때문에, 더이상 조회가 불가능 하기때문이다. 두번째 인자로 리덕스 스토어의 auth
값을 넣어줬으므로 auth가이 변화할 때마다 이를 기점으로 검사를 진행한다.
(임의적으로 쿠키에 저장된 유저 토큰을 삭제하면 로그아웃처리가 된다.)