Context를 사용한 로그인 관리
✒️ 2025-05-28 10:35 내용 수정
- 팀 프로젝트에서 각 페이지마다 로그인 여부를 확인하고, 그 정보를 가져와 사용하는 기능들이 있어 페이지 전체적으로 로그인한 사용자 정보를 관리할 필요가 있었다.
- 매 Component 파일마다 각각 API를 호출하기엔 코드도 비효율적이고 함수를 계속 추가하기 때문에 이를 통합 관리하기 위한
Context를 작성했다. - React에서 로그인 여부에 따른 리렌더링을 처리할 방법을 검색해봤는데, React에서 로그인을 유지시키는 방법은
state로 이를 관리하는 것이었다. - 팀 프로젝트에선
express-mysql-session을 사용하여 로그인 세션을 관리했다. cookie 대신 session을 db에 저장하다보니 처음 코드 작성때부터 새로고침을 할 때마다(렌더링때마다) 로그인 정보를 가져오는 API를 요청하는 것이 요청 수를 과하게 늘이는 것이 아닐지 고민했다.- 만약 cookie와 jwt를 사용했다면 두 번째 참고자료의 내용대로 작성하는 것이 요청을 줄이면서 클라이언트에 정보를 저장할 수 있는 좋은 방법이었을 것이다.
- db에 세션을 저장하는 형태로는 첫 번째 참고자료의 내용처럼 서버에 요청을 날려 정보를 가져와야 했기에 초기에 작성했던 코드 구조를 그대로 사용하기로 결정했다.
1. Context 파일 작성
- 로그인 정보를 가져오고
state로 저장하여 자식 Component에 넘겨줄 Context를 만든다.
import { createContext, useCallback, useContext, useMemo, useState, useEffect } from "react";
import { getLoginUser, login, logout } from "../api/login";
import { getShortNotification } from "../api/alert";
import { useLocation } from "react-router-dom";
// 로그인 유저 context 생성
const LoginUserContext = createContext();
// provider
function AuthProvider({children}) {
const [user, setUser] = useState(null); // 로그인 한 사용자
const [notification, setNotification] = useState([]); // 현재 사용자의 알림 목록 최신순 10개
const location = useLocation(); // location 객체
async function getUser() { // 현재 로그인 한 사용자 가져오기
const res = await getLoginUser(); // API 호출
setUser(res);
}
async function getUserNotification() { // 현재 로그인 한 사용자의 알림 최신순 10개만 가져오기
const res = await getShortNotification();
setNotification(res);
}
useEffect(()=>{ // 요청이 바뀔때마다 사용자 가져오기
getUser(); // 서버에서 로그인 한 사용자 정보를 가져오는 API를 호출
}, [location]);
useEffect(()=>{
if (user) { // 로그인 해야만 알림 가져옴
getUserNotification();
}
}, [user]);
// 로그인 처리 - 이 당시 최적화를 위해 useCallback을 사용한 것으로 추정
const handleLogin = useCallback(async (email, password)=> {
email = email.trim(); // 공백을 제거해준다
const res = await login(email, password); // API 호출
if (res.message == 'success') {
setUser(res.user);
}
return res.message;
}, []);
// 로그아웃 처리 - 이 당시 최적화를 위해 useCallback을 사용한 것으로 추정
const handleLogout = useCallback(async () => {
const res = await logout(); // API 호출
setUser();
}, []);
// 사용자와 함수를 memo로 처리해서 렌더링 최적화(?)
const userContextValue = useMemo(()=>({
user,
setUser,
handleLogin,
handleLogout,
notification,
setNotification,
getUserNotification
}), [user, setUser, handleLogin, handleLogout, notification, setNotification, getUserNotification]);
// 이제 위의 value를 Component에 전달해주기 위한 Provider를 작성한다.
return (
<LoginUserContext.Provider value={userContextValue}>
{children}
</LoginUserContext.Provider>
);
}
export function useAuth() { // 자식 요소에선 이 Hook를 사용하면 user, setUser 등 사용 가능
return useContext(LoginUserContext);
}
export default AuthProvider;
useEffect부분에서 의존성을location으로 설정했는데, 여기엔 두 가지 이유가 있다.user를 의존성으로 추가할 경우,getUser()로 인해user가 바뀌고, 의존성때문에 다시 렌더링하면서getUser()가 호출되서user가 바뀌고, 결국 무한히 렌더링 되는 문제가 발생한다.- 의존성 배열에 빈 값을 두는
useEffect(()=>{},[])로 사용했었으나, 이후 사용자 페이지나 다른 곳에서 로그아웃 및 기타 동작으로 인해 사용자가 바뀌는 경우에 리렌더링이 일어나지 않아 새로운 정보를 업데이트 하지 않았다. 그래서 url 및 query string 등의 변경에 따라 사용자 정보를 다시 가져오도록 설정했다.
useEffect(()=>{ // 요청이 바뀔때마다 사용자 가져오기
getUser();
}, [location]);
2. 부모 Component에서 Context 설정
- 작성한 Context를 App.js에 적용하여 모든 Component에서 사용할 수 있도록 작성했다.
- App.js의 구조가 아래처럼 전체를 감싸는 div,
<Header/>와<Footer/>를 제외한Route를 감싸는 div로 나눈 이유는<Footer/>를 뷰포트 높이에 상관없이 하단에 고정시키기 위한 css를 적용하기 위해서이다. - css 내용은 Footer 하단에 고정하기 참고.
- App.js의 구조가 아래처럼 전체를 감싸는 div,
import AuthProvider from './contexts/LoginUserContext.js';
import Header from './components/Header.js';
import Footer from './components/Footer.js';
import Main from './pages/Main.js'
function App() {
return (
{/* AuthProvider를 전체에 감싸기 */}
<AuthProvider>
<div className='app-wrapper'>
<Header/>
<div className='content-wrapper'>
<Routes>
<Route path="/" element={<Main/>}/>
{/* 다른 라우트는 생략 */}
</Routes>
</div>
<Footer/>
</div>
</AuthProvider>
);
}
export default App;
3. 자식 Component에서 Context로 받은 value 사용
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/LoginUserContext.js';
function Header() {
const { user, handleLogout } = useAuth(); // 로그인 한 사용자 정보 가져오기
const navigate = useNavigate();
async function handleLogoutClick() { // 로그아웃 처리
const res = await handleLogout(); // 로그아웃 API 호출(Context 파일에 함수 있음)
navigate(0); // 새로고침 실행
}
return (
<>
<div>
{/* 사용자의 로그인 여부에 따라 표시할 내용을 선택 */}
{
!user ?
<>
<Link to="/login">로그인</Link>
<Link to="/join">회원가입</Link>
</>
:
<>
<span onClick={handleLogoutClick}>로그아웃</span>
<Link to="/mypage/">마이페이지</Link>
</>
}
</div>
</>
);
}
export default Header;
- 로그인 했을 때와 안 했을 때 출력되는 내용이 다르게 되어 있다.
- 위 코드는 프로젝트에서
useContext를 사용한 핵심 부분만 정리했기에 사진에 있는 검색 기능과 관련된 코드는 제외했다.
- 위 코드는 프로젝트에서
Context와 loading state를 사용한 로그인 유지(JWT 사용)
- 시작하기에 앞서 이 부분은 Spring boot, React, JWT를 사용한 개인 프로젝트 진행 중에 발생한 문제를 정리했다.
- 개인 프로젝트 내용은 서버 체크리스트를 참고.
- Spring boot와 React를 사용한 내용은 Spring boot와 React 연계를 참고.
- Spring Security는 Spring Security의 튜토리얼 정리 부분을 참고.
- JWT는 JWT(Json Web Token)와 JWT Access Token과 Refresh Token 참고.
새로 고침과 게스트 모드 문제
- 개인 프로젝트에서
Context로 로그인을 유지하는 위의 방법을 사용했을 때 이미 로그인 한 사용자가 그 페이지에서 새로 고침을 하면 사용자의 정보를 저장한 state가 초기 값으로 설정되면서 사용자 정보를 가져올 수 없는 문제가 발생했다. - 이미 로그인한 사용자의 새로 고침 동작 시 사용자 정보를 다시 가져오고, 로그인을 안 한 게스트 상태에선 보호된 라우트에 접근하지 못하도록 설정하는 방법이 필요했다.
- 사용자 정보 확인 페이지가 이에 해당한다.
- 사용자 정보 확인 페이지가 이에 해당한다.
처음 시도한 해결 방법과 그로 인한 문제점
user
가 없을 때(새로 고침, 게스트 모드)일 때 원래는 각 하위 Component에서 사용자 정보 요청 함수getUserInfo()를 호출해서 사용자 정보를 다시 가져오거나 로그인으로 이동 시켰다.- 하지만
App.js에서도getUserInfo()를 호출하기에 중복 호출 문제가 있었고, 보호된 라우트 설정을 해야 하는 페이지가 너무 많았다.- Network에서 확인했을 때 사용자 정보 요청을 단 한번만 수행해서 그 내용을 가져오도록 설정하고 싶었다. 너무 요청이 자주 가면 서버와 DB에도 영향이 갈 가능성이 높았다.
- Network에서 확인했을 때 사용자 정보 요청을 단 한번만 수행해서 그 내용을 가져오도록 설정하고 싶었다. 너무 요청이 자주 가면 서버와 DB에도 영향이 갈 가능성이 높았다.
- 그리고 각 하위 Component에서
getUserInfo()를 호출해서user를 다시 받아오는 경우, 아예 처음부터 게스트인 사람이 보호된 라우트로 접근하는 것을 막을 방법이 없었다.- 이를 막으려고
user의 null 여부로 접근 허가 여부를 설정하면 이미 로그인 한 사용자도 새로 고침 직후엔user가 null이라서 접근이 불가능했다.
- 이를 막으려고
- 결국 사용자의 정보를 얻어오는
getUserInfo()의 비동기 동작으로 인해 렌더링 내용 생성이 비동기 결과 반영보다 더 빨라서 발생한 문제로 보였다. - 하위 페이지에서
getUserInfo()를 각각 호출할 때 loading state를 각 하위 Component나App.js에서 관리하여 새로 고침 시 데이터를 가져올 때까지 기다리는 로딩 화면을 표시 했었다.- 다만 이 역시 보호할 라우트 페이지 마다 각각의 loading state를 생성하고 이를 수정해야 했기에 효율이 떨어졌다.
새 해결 방법
- 로그인 유지 문제에 필요한 핵심을 정리하면 아래 3가지로 정리할 수 있다.
- 이미
App.js에서getUserInfo()를 호출하기에 이를 활용하는 것 - 게스트 사용자가 보호된 라우트에 접근하려 하면 로그인 페이지로 강제 이동
- 로그인한 사용자가 보호된 라우트 내에서 새로 고침 시 로그인 상태를 유지하는 것
- 이미
- 이를 위해 ChatGPT를 사용하며 프로젝트 상황 및 동작에 맞게 조정하였다.
Context에서 loading state를 생성 및 관리하고,refreshToken과 사용자 정보 요청 함수getUserInfo()에서 loading state를 변경한다.- 보호할 라우트에 적용할 Component를 생성하여 게스트와 로그인 사용자의 접근을 제어한다.
App.js에서getUserInfo()를 호출하여 웹 페이지 최상위에서 로그인한 사용자의 정보를 요청하고 라우트 접근을 제어한다.
1. Context 설정 및 loading state 추가
- 현재 프로젝트의 사용자를 관리하는
Context는 아래와 같다.- 실제 프로젝트의
Context는 내용이 길어 여기선 로그인 유지와 관련된 부분만 추려서 정리했다. - 전체 코드는 서버 체크리스트에 있는 Github에 프로젝트를 참고.
- 실제 프로젝트의
- 이 Context에 loading state를 추가하여 보호할 route들에서 동일한 loading state를 참고하고 사용하도록 설정한다.
import axios from 'lib/axios';
import { createContext, useContext, useState } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { getImage } from 'api/image';
const AuthContext = createContext();
// 사용자 인증 및 사용자 정보 관리
function AuthProvider({children}) {
const [user, setUser] = useState(null); // 로그인 한 사용자
const [loading, setLoading] = useState(true); // 로딩 상태
const navigate = useNavigate();
const location = useLocation();
// header 설정
function setHeader(response) {
// 응답에서 Access Token 가져와 로컬 변수에 저장
const {access_token} = response.data;
// Access Token을 axios의 header의 Authorization Bearer Schema에 적용
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
}
// ... 생략
// 재발급
async function refreshToken() {
try {
const res = await axios.post(
'/auth/refresh-token',
{},
{withCredentials: true}
);
if (res.status === 200) {
setHeader(res);
}
return res.status;
} catch (error) {
}
setLoading(false);
}
// access token 검증 후 axios 요청
async function authFetch(action) {
// token이 있을때만 요청
if (axios.defaults.headers.common['Authorization']) {
action();
} else {
// 재발급
const token_status = await refreshToken();
if (token_status === 200) {
action();
} else {
alert("로그인 후 이용해주세요");
navigate("/login");
}
}
}
// 사용자 정보 가져오기
async function getUserInfo() {
// 사용자 정보 가져오기
async function getUser() {
try {
const res = await axios.get(`/user/current-user`);
if (res.status === 200) {
setUser(res.data);
}
} catch (error) {
} finally {
setLoading(false);
}
}
// token이 있을때만 요청
if (axios.defaults.headers.common['Authorization']) {
getUser();
} else {
// 재발급
const token_status = await refreshToken();
if (token_status === 200) {
getUser();
}
}
}
return(
<AuthContext.Provider
value={{user, login, loading, register, refreshToken, logout,
getUserInfo, patchUser, patchUserPwd, deleteUser}}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
return useContext(AuthContext);
}
export default AuthProvider;
- JWT 재발급
- cookie에 저장된 Refresh Token으로 Access Token과 Refresh Token을 재발급 받는 부분이다.
- Token 재발급이 끝나면
setLoading(false)로 수정하여 게스트가 보호된 라우트에 접근 시 무한 로딩으로 표시되지 않고 로그인 페이지로 접근하도록 한다.
async function refreshToken() {
try {
const res = await axios.post(
'/auth/refresh-token',
{},
{withCredentials: true}
);
if (res.status === 200) {
setHeader(res);
}
return res.status;
} catch (error) {
}
setLoading(false);
}
- 사용자 정보 가져오기
- 현재 로그인 한 사용자의 정보를 가져오는 부분이다.
- 사용자 정보를 가져오면 이 동작의 성공 실패 유무와 관계 없이
setLoading(false)로 로딩 상태를false로 수정한다. - Header에 Access Token이 존재하면 바로 요청을 보내고, 없는 경우엔
refreshToken()으로 Token을 재발급한 후에 사용자 정보 요청을 보낸다.- 여기서
refreshToken()에setLoading(false)를 넣지 않으면 게스트 모드에선refreshToken()이 Error가 발생하여getUser()가 호출되지 않으므로 loading state가true인 상태로 남는다. - 이로 인해 게스트 모드에서 보호된 라우트에 접근 시 무한 로딩 페이지가 뜨기 때문에
refreshToken()에setLoading(false)를 추가했다.
- 여기서
// 사용자 정보 가져오기
async function getUserInfo() {
// 사용자 정보 가져오기
async function getUser() {
try {
const res = await axios.get(`/user/current-user`);
if (res.status === 200) {
setUser(res.data);
}
} catch (error) {
} finally {
setLoading(false);
}
}
// token이 있을때만 요청
if (axios.defaults.headers.common['Authorization']) {
getUser();
} else {
// 재발급
const token_status = await refreshToken();
if (token_status === 200) {
getUser();
}
}
}
2. ProtectedRoute Component 추가
- ProtectedRoute를 추가하는 것으로
user가 아직 없는 새로 고침 직후 초기 상태일 때 렌더링을 로딩 중으로 표시하도록 수정했다.- 이를 하위 Component들에 적용하여
App.js에서 페이지 최초 렌더링 시getUserInfo()를 호출하여 loading state와user를 수정하고, 이 Component에서 loading state와user를 확인하여 로딩 표시 및 페이지 접근을 제어한다. <Spinner>는 bootstrap에서 가져왔다.
- 이를 하위 Component들에 적용하여
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "./AuthContext";
import { Spinner } from "react-bootstrap";
function ProtectedRoute({ loading, redirectTo = "/login"}) {
const {user} = useAuth();
// 로딩 표시
if (loading) {
return <div><Spinner/></div>
}
// user가 없으면 로그인으로 이동
if (!user) {
return <Navigate to={redirectTo} replace/>
}
return <Outlet/>
}
export default ProtectedRoute;
3. index.js와 App.js 설정
- index.js
- 사용자 정보를 관리하는 Context인
AuthContext는 index.js에서 설정했다. App.js에서AuthContext의 함수 및 state들을 사용하기 위해index.js에서 ContextProvider를 적용하였다.
- 사용자 정보를 관리하는 Context인
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 AuthProvider from 'contexts/AuthContext';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
);
reportWebVitals();
- App.js
App.js에서AuthContext로부터getUserInfo()를 가져와 페이지 최초 렌더링 시 이 함수를 호출한다.- Context에 함수 호출 내용이 반영되기에
App.js하위 Component, 즉 모든 페이지 및 Component에서 사용자 정보를 공유할 수 있다.
- Context에 함수 호출 내용이 반영되기에
- ProtectedRoute를 보호할 Component 라우트에 적용하여 게스트와 로그인 유저의 접근을 제어한다.
- 이 설정들을 적용하여 게스트 모드일 때 보호된 라우트에 접근하면 로그인 화면으로 이동하고, 로그인 한 후엔 보호된 라우트에 접근이 가능했다. 로그인한 상태로 새로고침 해도 사용자의 정보를 잘 가져왔다.
import 'bootstrap/dist/css/bootstrap.min.css';
import 'styles/common.css'
import 'App.css';
import { Route, Routes } from 'react-router-dom';
import { useAuth } from 'contexts/AuthContext';
import { useEffect, useState } from 'react';
import ProtectedRoute from 'contexts/ProtectedRoute';
function App() {
const {loading, getUserInfo} = useAuth();
useEffect(()=>{
const initializeUser = async () =>{
await getUserInfo();
}
initializeUser();
},[]);
return (
<div className='app-wrapper'>
<div className='content-wrapper'>
<Routes>
{/* 일반 사용자 접근 가능 */}
<Route path="/" Component={Main}/>
<Route path="/login" Component={Login}/>
<Route path="/register" Component={Register}/>
<Route path="/resetpwd" Component={ResetPwd}/>
{/* 보호된 라우트 */}
<Route element={<ProtectedRoute loading={loading}/>}>
<Route path="/user" Component={User}>
<Route index Component={UserInfo}/>
<Route path="edit" Component={UserEdit}/>
</Route>
</Route>
<Route path="/error" Component={Error}/>
</Routes>
</div>
</div>
);
}
export default App;