React 애플리케이션에 로그인 인증 추가하는 방법
저자는 '기부를 위한 글쓰기' 프로그램의 일부로 기부를 받기 위해 크리에이티브 커먼즈를 선택했습니다.
소개
React 애플리케이션에서 인증은 사용자의 보호된 페이지 및 리소스 접근을 관리합니다. 사용자가 로그인하면 애플리케이션은 인증 자격 증명(보통 토큰)을 저장하고, 개인 경로를 렌더링하기 전에 이를 확인합니다. 인증 상태는 페이지 새로고침 시에도 유지되어야 하므로, 컴포넌트 상태가 아니라 브라우저 저장소에 토큰을 저장해야 합니다.
인증 상태가 유지되는 방식: 토큰을 localStorage 또는 sessionStorage에 저장하면 페이지를 새로고침하거나 브라우저를 재시작해도 (localStorage의 경우) 토큰이 유지됩니다. 이는 앱이 마운트될 때 기존 토큰을 확인하고 사용자가 새로 로그인하지 않고도 자동으로 인증할 수 있음을 의미합니다. 지속적인 저장소가 없다면 사용자는 페이지를 새로고침하거나 새 탭을 열 때마다 로그인해야 합니다.
이 튜토리얼에서는 보호된 라우트를 위해 React Router v6를 사용하여 토큰 기반 인증이 적용된 React 애플리케이션을 구축하는 과정을 안내합니다. 인증 토큰을 반환하는 모의 API를 생성하고, 토큰을 가져오고 저장하는 로그인 페이지를 구현하며, 사용자가 원래 의도한 경로를 유지하도록 인증 검사를 설정합니다. 이 튜토리얼에서는 토큰 저장 방법(localStorage, sessionStorage)과 보안상의 의미를 다루고, 로그아웃 기능과 오류 처리 방법을 구현하며, JWT 토큰을 디코딩하여 만료 여부를 확인하는 방법을 보여줍니다.
인증 로직이 개별 컴포넌트에 있으면 안 되는 이유: 인증 로직을 커스텀 훅이나 React 컨텍스트에 중앙 집중화하면 앱 전체에서 일관된 동작을 보장하고, 인증 흐름 업데이트가 쉬워지며, 코드 중복을 방지할 수 있습니다. 인증 로직이 컴포넌트에 흩어져 있으면 토큰 검사 불일치, 예외 처리 누락, 유지보수 문제 등의 위험이 있습니다.
이 튜토리얼을 완료하면 React 애플리케이션에서 안전한 인증을 구현하는 방법, 보안 및 사용자 경험 요구 사항에 따라 적절한 토큰 저장 전략을 선택하는 방법, 보호된 경로, 토큰 만료, 로그아웃을 포함한 일반적인 인증 시나리오를 처리하는 방법을 이해하게 될 것입니다.
리액트 프로젝트를 빠르게 배포해야 하나요? Linux-Console.net 앱 플랫폼을 확인하고 GitHub에서 리액트 프로젝트를 몇 분 만에 직접 배포하세요.
핵심 내용
localStorage또는sessionStorage에 저장될 경우 인증 상태가 페이지 새로고침 시에도 유지됩니다. 컴포넌트 상태(useState)에 저장된 토큰은 새로고침 시 사라져 사용자가 다시 로그인해야 합니다. 브라우저 저장소 API는 세션 간에도 토큰을 유지하여 자동 재인증이 가능하게 합니다.React Router v6는
Switch대신Routes를 사용하고, children 대신element속성을 요구합니다. 새로운 API는 더 선언적이며 타입 안전성을 제공합니다. 보호된 라우트는 렌더링 전에 인증을 확인하는 래퍼 컴포넌트를 사용하여 구현됩니다.인증 로직을 커스텀 훅 또는 React Context에 중앙 집중화하세요. 개별 컴포넌트에 인증 로직을 두면 중복과 일관되지 않은 동작이 발생할 수 있습니다. 커스텀
useToken훅이나AuthContext는 인증 상태에 대한 단일 소스의 진실을 제공합니다.localStorage는 브라우저 세션 간에 데이터를 유지하고,sessionStorage는 탭별로 데이터를 저장합니다. ‘로그인 상태 유지’ 기능에는localStorage를 사용하고, 더 안전한 세션 전용 인증에는sessionStorage를 사용하세요. 두 가지 모두 XSS 공격에 취약하므로 항상 사용자 입력을 검증해야 합니다.JWT 토큰은 서버를 거치지 않고도 클라이언트에서 만료 여부를 확인할 수 있습니다. 페이로드에는
exp클레임(유닉스 타임스탬프)이 포함되어 있으며, 이를 현재 시간과 비교할 수 있습니다. 그러나 항상 서버에서 토큰을 검증해야 하며, 클라이언트 측 검사는 사용자 경험(UX)용일 뿐입니다.쿠키 기반 인증을 사용할 때 CSRF 보호는 매우 중요합니다. 쿠키는 요청 시 자동으로 전송되기 때문에 교차 사이트 요청 위조 공격에 취약합니다. CSRF 토큰, SameSite 쿠키 속성을 사용하고 서버에서 Origin 헤더를 검증하세요.
전제 조건
Node.js 버전 18.x와 npm 버전 9.x가 실행되는 개발 환경입니다. macOS 또는 Ubuntu에 Node.js를 설치하려면 macOS에서 Node.js를 설치하고 로컬 개발 환경을 만드는 방법 또는 Ubuntu에서 Node.js를 설치하는 방법에 따라 단계를 진행하세요.
-
Create React App으로 설정된 React 개발 환경으로, 불필요한 기본 코드는 제거되어 있습니다. 이를 설정하려면, 'React 클래스 컴포넌트에서 상태를 관리하는 방법' 튜토리얼의 1단계 — 빈 프로젝트 생성을 따라하세요. 이 튜토리얼에서는 프로젝트 이름으로
auth-tutorial을 사용합니다. React에서 API로부터 데이터를 가져오는 것에 익숙함. API 작업에 대해 더 알아보려면, React에서 useEffect 훅을 사용하여 웹 API를 호출하는 방법을 참조하세요.
자바스크립트, HTML, CSS의 기본 지식. HTML로 웹사이트 만들기 시리즈, CSS로 HTML 스타일링하기, 자바스크립트 코딩하기에서 더 배워보세요.
1단계 — 로그인 페이지 만들기
로그인 페이지는 인증되지 않은 사용자가 보호된 경로에 도달하기 전에 이를 차단합니다. 사용자를 전용 로그인 경로로 리디렉션하는 대신(이 경우 사용자의 의도한 목적지를 잃게 됨), 인증 토큰이 없을 때 어느 경로에서든 로그인 폼을 렌더링합니다. 이렇게 하면 로그아웃 상태에서 /dashboard를 방문하려는 사용자의 탐색 의도가 유지되고, 로그인 폼을 본 후 인증을 마치면 다른 곳으로 리디렉션되지 않고 /dashboard에 도착하게 됩니다.
왜 모든 경로에서 로그인 화면을 렌더링하는가: 사용자가 인증되지 않은 상태에서 /dashboard와 같은 보호된 경로를 방문할 때, 같은 경로에서 로그인 폼을 표시하면 사용자의 탐색 컨텍스트를 유지할 수 있습니다. 로그인 후 사용자는 추가 리디렉션 없이 이미 의도한 목적지에 있게 됩니다. 이는 /login으로 리디렉션한 후 다시 탐색 단계를 거쳐야 하는 것보다 더 원활한 사용자 경험을 제공합니다.
npm을 사용하여 React Router를 설치하세요. 두 가지 버전이 있습니다: React 애플리케이션용 웹 버전과 React Native용 네이티브 버전. 웹 버전을 설치하려면:
npm install react-router-dom설치가 다음과 유사한 출력과 함께 완료됩니다:
...
+ react-router-dom@6.28.0
added 11 packages from 6 contributors, removed 10 packages and audited 1945 packages in 12.794s
...다음으로, Dashboard와 Preferences라는 두 개의 컴포넌트를 만들어 비공개 페이지 역할을 하도록 합니다. 이들은 사용자가 애플리케이션에 성공적으로 로그인할 때까지 볼 수 없어야 하는 컴포넌트를 나타냅니다.
먼저 디렉토리를 만드세요:
mkdir src/components/Dashboard
mkdir src/components/Preferences텍스트 편집기에서 Dashboard.js를 열세요:
nano src/components/Dashboard/Dashboard.js
Dashboard.js 안에, 내용이 Dashboard인 <h2> 태그를 추가하세요:
import React from 'react';
export default function Dashboard() {
return(
<h2>Dashboard</h2>
);
}파일을 저장하고 닫으세요.
Preferences에 대해서도 같은 단계를 반복하세요. 구성 요소를 엽니다:
nano src/components/Preferences/Preferences.js내용을 추가하세요:
import React from 'react';
export default function Preferences() {
return(
<h2>Preferences</h2>
);
}파일을 저장하고 닫으세요.
이제 일부 컴포넌트를 갖추었으므로, 컴포넌트를 가져오고 App.js 안에서 라우트를 생성해야 합니다. React 애플리케이션에서 라우팅을 다루는 방법에 대한 전체 소개는 튜토리얼 'How To Handle Routing in React Apps with React Router'를 참고하세요. React 애플리케이션 배포에 대해서는 'How To Deploy a React Application with Nginx on Ubuntu'를 참조하세요.
먼저, App.js를 엽니다:
nano src/components/App/App.js그런 다음 다음 하이라이트된 코드를 추가하여 Dashboard와 Preferences를 가져옵니다:
import React from 'react';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Preferences from '../Preferences/Preferences';
function App() {
return (
<></>
);
}
export default App;다음으로, react-router-dom에서 BrowserRouter, Routes, Route를 가져옵니다. React Router v6에서는 Switch 대신 Routes를 사용하고, 라우트는 children 대신 element 속성을 사용합니다:
import React from 'react';
import './App.css';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import Dashboard from '../Dashboard/Dashboard';
import Preferences from '../Preferences/Preferences';
function App() {
return (
<></>
);
}
export default App;애플리케이션의 템플릿으로 사용할 <div>를 wrapper라는 className과 함께 추가하고, <h1> 태그를 넣으세요. 스타일을 적용할 수 있도록 App.css를 반드시 import하세요.
다음으로, Dashboard와 Preferences 컴포넌트를 위한 라우트를 생성하세요. BrowserRouter를 추가한 후, 자식으로 Routes 컴포넌트를 추가합니다. Routes 안에는 각 컴포넌트마다 path와 element 속성을 가진 Route를 추가하세요:
import React from 'react';
import './App.css';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import Dashboard from '../Dashboard/Dashboard';
import Preferences from '../Preferences/Preferences';
function App() {
return (
<div className="wrapper">
<h1>Application</h1>
<BrowserRouter>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/preferences" element={<Preferences />} />
</Routes>
</BrowserRouter>
</div>
);
}
export default App;파일을 저장하고 닫으세요.
마지막 단계는 주요 <div>에 여백(padding)을 추가하여 컴포넌트가 브라우저 가장자리에 직접 닿지 않도록 하는 것입니다. 이를 위해 CSS를 변경하면 됩니다.
App.css를 엽니다:
nano src/components/App/App.css다음과 같이 padding이 20px인 .wrapper 클래스의 내용으로 교체하세요:
.wrapper {
padding: 20px;
}파일을 저장하고 닫습니다. 브라우저가 자동으로 다시 로드되며 기본 구성 요소가 표시됩니다:
대시보드 페이지를 보려면 http://localhost:3000/dashboard를 방문하세요:
라우트는 작동하지만, /dashboard와 /preferences는 현재 인증되지 않은 사용자를 포함한 누구나 접근할 수 있습니다. 이 경로들은 보호된 경로여야 합니다.
라우트를 보호하는 두 가지 접근 방식:
전용 로그인 경로로 리디렉션:
/login경로를 만들고 인증되지 않은 사용자를 그곳으로 리디렉션하세요. 이 방법은 작동하지만, 사용자는 원래 가려던 목적지를 잃게 되며 로그인 후 다시 돌아가야 합니다.모든 경로에서 로그인 폼 렌더링 (이 튜토리얼에서 사용됨): 토큰이 없을 때 현재 경로에 로그인 폼을 표시합니다. 인증 후 사용자는 원래 가고자 했던 목적지에 머물게 됩니다. 예를 들어, 로그아웃 상태에서
/dashboard를 방문하면 로그인 폼이 표시되고, 로그인 후 사용자는 바로/dashboard로 이동하게 됩니다.
이 튜토리얼은 더 나은 사용자 경험을 위해 두 번째 방식을 사용합니다.
먼저, Login 컴포넌트를 위한 새 디렉터리를 만드세요:
mkdir src/components/Login그 다음, 텍스트 편집기에서 Login.js 파일을 여세요:
nano src/components/Login/Login.js사용자 이름과 비밀번호를 입력할 수 있는 <input>와 제출용 <button>이 있는 기본 폼을 만드세요. 비밀번호 입력란의 타입은 반드시 password로 설정하세요:
import React from 'react';
export default function Login() {
return(
<form>
<label>
<p>Username</p>
<input type="text" />
</label>
<label>
<p>Password</p>
<input type="password" />
</label>
<div>
<button type="submit">Submit</button>
</div>
</form>
)
}React에서 폼에 대해 더 알아보려면 'React에서 폼 만드는 방법' 튜토리얼을 확인하세요. React 컴포넌트에서 상태를 관리하는 방법을 배우고 싶다면 'React 컴포넌트에서 훅으로 상태 관리하기'를 참고하세요.
다음으로, 사용자가 로그인하도록 요청하는 <h1> 태그를 추가합니다. <form>과 <h1>을 className이 login-wrapper인 <div>로 감쌉니다. 마지막으로 Login.css를 가져옵니다:
import React from 'react';
import './Login.css';
export default function Login() {
return(
<div className="login-wrapper">
<h1>Please Log In</h1>
<form>
<label>
<p>Username</p>
<input type="text" />
</label>
<label>
<p>Password</p>
<input type="password" />
</label>
<div>
<button type="submit">Submit</button>
</div>
</form>
</div>
)
}파일을 저장하고 닫으세요.
로그인 폼을 가운데로 정렬하기 위한 스타일을 추가하세요. Login.css를 열어보세요:
nano src/components/Login/Login.css페이지에서 컴포넌트를 중앙에 배치하려면 display를 flex로 설정한 다음, flex-direction을 column으로 설정하여 요소를 수직으로 정렬하고, align-items를 center로 추가하여 컴포넌트를 브라우저에서 중앙에 위치시키세요:
.login-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}Flexbox 사용에 대한 자세한 정보는 우리 CSS Flexbox 치트시트를 참조하세요.
파일을 저장하고 닫으세요.
인증 토큰이 없을 때 App.js에서 로그인 컴포넌트를 렌더링하세요. App.js를 열어주세요:
nano src/components/App/App.js3단계에서는 지속적인 토큰 저장을 구현합니다. 현재로서는 useState 훅을 사용하여 컴포넌트 상태에 토큰을 저장하고 로그인 흐름을 테스트하세요. 컴포넌트 상태에 저장된 토큰은 페이지를 새로 고침하면 사라진다는 점에 유의하세요.
react에서 useState를 가져온 다음, useState를 호출하고 반환 값을 token과 setToken에 설정합니다:
import React, { useState } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Preferences from '../Preferences/Preferences';
function App() {
const [token, setToken] = useState();
return (
<div className="wrapper">
<h1>Application</h1>
<BrowserRouter>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/preferences" element={<Preferences />} />
</Routes>
</BrowserRouter>
</div>
);
}
export default App;Login 컴포넌트를 가져옵니다. token이 falsy일 경우 Login을 표시하도록 조건문을 추가합니다.
setToken 함수를 Login 컴포넌트에 전달하세요:
import React, { useState } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';
function App() {
const [token, setToken] = useState();
if(!token) {
return <Login setToken={setToken} />
}
return (
<div className="wrapper">
<h1>Application</h1>
<BrowserRouter>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/preferences" element={<Preferences />} />
</Routes>
</BrowserRouter>
</div>
);
}
export default App;아직 토큰이 없기 때문에 로그인 폼이 모든 경로에 표시됩니다. 다음 단계에서는 인증이 성공하면 토큰을 반환하는 API 엔드포인트를 만들 것입니다.
파일을 저장하고 닫습니다. 브라우저는 자동으로 다시 로드되어 모든 경로에서 로그인 페이지를 보여줍니다. 로그인 양식이 나타나는지 확인하려면 대시보드 페이지를 방문하세요:
인증 토큰이 없을 때 모든 경로에 표시되는 로그인 폼을 만들었습니다. 이 폼은 인증이 성공할 때까지 /dashboard와 /preferences와 같은 보호된 경로에 접근하는 것을 막습니다. 다음 단계에서는 인증 토큰을 반환하는 모의 API 서버를 만들 것입니다.
2단계 — 토큰 API 생성
모의 API(Mock API)는 개발 중에 실제 서버 없이 백엔드 인증을 시뮬레이션합니다. Express는 프로덕션 API가 사용할 형식과 동일한 토큰을 반환하는 개발 서버를 간단하게 만들 수 있는 방법을 제공합니다. 이를 통해 인증 흐름을 테스트하고, API 응답을 처리하며, 프런트엔드를 독립적으로 개발할 수 있습니다.
모의 API를 사용하는 이유: 개발 중에는 백엔드가 준비되지 않았거나, 실제 서버에 접속하지 않고 인증 흐름을 테스트하고 싶을 수 있습니다. 모의 API는 예측 가능한 응답을 반환하므로 오류 처리, 로딩 상태, 토큰 관리를 테스트하기가 더 쉽습니다. 실제 애플리케이션에서는 모의 API를 실제 인증 엔드포인트로 교체해야 합니다.
토큰을 반환하는 간단한 Express 서버를 만들어보세요. Express에 대한 자세한 소개는 Node.js의 기본 Express 서버(Basic Express Server)를 참고하세요. 전체 스택 애플리케이션을 구축하는 경우, 전체 예제를 위해 Django와 React를 사용하여 할 일(To-Do) 애플리케이션을 만드는 방법을 참고해 보세요.
우선, express를 설치하세요. 서버는 최종 빌드의 필수 요구 사항이 아니므로 devDependency로 설치해야 합니다.
또한 cors를 설치해야 합니다. 이 라이브러리는 모든 경로에서 교차 출처 자원 공유를 가능하게 합니다.
경고: 운영 환경의 애플리케이션에서는 모든 경로에 대해 CORS를 활성화하지 마세요. 이는 보안 취약점을 초래할 수 있습니다.
npm install --save-dev express cors설치는 다음과 유사한 출력과 함께 완료됩니다:
...
+ cors@2.8.5
+ express@4.17.1
removed 10 packages, updated 2 packages and audited 2059 packages in 12.597s
...다음으로, 애플리케이션의 루트에 server.js라는 새 파일을 엽니다. 최종 빌드에 포함되지 않도록 이 파일을 /src 디렉토리에 추가하지 마세요.
nano server.jsexpress를 가져온 다음 express()를 호출하여 새 앱을 초기화하고 결과를 app이라는 변수에 저장하세요:
const express = require('express');
const app = express();app을 생성한 후, cors를 미들웨어로 추가합니다. 먼저 cors를 가져오고, 그 다음 app에서 use 메서드를 호출하여 애플리케이션에 추가합니다:
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());/login에 대한 라우트 핸들러를 추가하여 토큰을 반환하세요. 핸들러는 req(요청 데이터)와 res(응답 객체)를 받습니다. res.send를 사용하여 토큰을 포함한 자바스크립트 객체를 반환하세요:
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
app.use('/login', (req, res) => {
res.send({
token: 'test123'
});
});마지막으로, app.listen을 사용하여 포트 8080에서 서버를 실행합니다:
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
app.use('/login', (req, res) => {
res.send({
token: 'test123'
});
});
app.listen(8080, () => console.log('API is running on http://localhost:8080'));파일을 저장하고 닫습니다. 새로운 터미널 창이나 탭에서 서버를 시작합니다:
node server.js서버가 시작되고 다음을 표시합니다:
API is running on http://localhost:8080/login브라우저에서 로그인 페이지를 방문하여 JSON 객체 응답을 확인하세요:
참고: 브라우저에서 URL을 방문하면 GET 요청이 전송되지만, 로그인 폼은 POST 요청을 전송합니다. 현재 라우트 핸들러는 두 가지 방법 모두를 허용합니다. 실제 운영 환경에서는 app.post()를 사용하여 POST 요청에 대해, app.get()을 사용하여 GET 요청에 대해 라우트를 특정 HTTP 메서드로 제한하세요. 자세한 내용은 Express 라우팅 문서를 참조하세요.
이제 실행 중인 API 서버가 있으므로 로그인 페이지에서 요청을 해야 합니다. Login.js를 열어보세요:
nano src/components/Login/Login.js이전 단계에서 Login 컴포넌트에 setToken이라는 새 prop을 전달했습니다. 새 prop에서 PropType을 추가하고 props 객체를 구조분해하여 setToken prop을 꺼내세요.
import React from 'react';
import PropTypes from 'prop-types';
import './Login.css';
export default function Login({ setToken }) {
return(
<div className="login-wrapper">
<h1>Please Log In</h1>
<form>
<label>
<p>Username</p>
<input type="text" />
</label>
<label>
<p>Password</p>
<input type="password" />
</label>
<div>
<button type="submit">Submit</button>
</div>
</form>
</div>
)
}
Login.propTypes = {
setToken: PropTypes.func.isRequired
}다음으로, Username와 Password를 저장할 로컬 상태를 만드세요. 데이터를 수동으로 설정할 필요가 없으므로 <inputs>를 비제어 컴포넌트로 만드세요. 비제어 컴포넌트에 대한 자세한 정보는 'React에서 폼 만들기'에서 확인할 수 있습니다.
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './Login.css';
export default function Login({ setToken }) {
const [username, setUserName] = useState();
const [password, setPassword] = useState();
return(
<div className="login-wrapper">
<h1>Please Log In</h1>
<form>
<label>
<p>Username</p>
<input type="text" onChange={e => setUserName(e.target.value)}/>
</label>
<label>
<p>Password</p>
<input type="password" onChange={e => setPassword(e.target.value)}/>
</label>
<div>
<button type="submit">Submit</button>
</div>
</form>
</div>
)
}
Login.propTypes = {
setToken: PropTypes.func.isRequired
};서버에 POST 요청을 보내는 함수를 작성하세요. 큰 규모의 애플리케이션에서는 API 호출을 별도의 서비스 디렉토리에 배치하세요. 이 튜토리얼에서는 함수가 컴포넌트 파일에 정의되어 있습니다. React에서 API 호출에 대해 더 알고 싶다면, React에서 useEffect 훅으로 웹 API를 호출하는 방법을 참고하세요.
loginUser라는 async 함수를 만드세요. 이 함수는 credentials를 인자로 받고, 이후 POST 옵션을 사용하여 fetch 메서드를 호출합니다:
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './Login.css';
async function loginUser(credentials) {
return fetch('http://localhost:8080/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
})
.then(data => data.json())
}
export default function Login({ setToken }) {
...마지막으로, username과 password를 사용하여 loginUser를 호출할 handleSubmit이라는 폼 제출 핸들러를 만듭니다. 성공적인 결과가 나오면 setToken을 호출합니다. <form>의 onSubmit 이벤트 핸들러를 사용하여 handleSubmit을 호출하세요:
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './Login.css';
async function loginUser(credentials) {
return fetch('http://localhost:8080/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
})
.then(data => data.json())
}
export default function Login({ setToken }) {
const [username, setUserName] = useState();
const [password, setPassword] = useState();
const handleSubmit = async e => {
e.preventDefault();
const token = await loginUser({
username,
password
});
setToken(token);
}
return(
<div className="login-wrapper">
<h1>Please Log In</h1>
<form onSubmit={handleSubmit}>
<label>
<p>Username</p>
<input type="text" onChange={e => setUserName(e.target.value)} />
</label>
<label>
<p>Password</p>
<input type="password" onChange={e => setPassword(e.target.value)} />
</label>
<div>
<button type="submit">Submit</button>
</div>
</form>
</div>
)
}
Login.propTypes = {
setToken: PropTypes.func.isRequired
};참고: 실제 애플리케이션에서는 비동기 작업 중 컴포넌트가 언마운트되는 경우를 처리해야 합니다. 사용자가 Promise가 완료되기 전에 다른 페이지로 이동하면 요청을 취소하거나 응답을 무시해야 할 수도 있습니다. 정리 패턴에 대해서는 React에서 useEffect 훅으로 웹 API를 호출하는 방법을 참고하세요.
파일을 저장하고 닫으세요. API 서버가 여전히 실행 중인지 확인한 후, 브라우저에서 http://localhost:3000/dashboard를 방문하세요.
로그인 페이지가 나타납니다. 아무 사용자 이름과 비밀번호를 입력한 다음, 폼을 제출하세요. API가 토큰을 반환하면 앱이 대시보드로 리디렉션됩니다.
로그인 플로우는 정상적으로 작동합니다: 폼을 제출하면 API가 호출되고 반환된 토큰이 저장됩니다. 그러나 토큰이 컴포넌트 상태(JavaScript 메모리)에 저장되기 때문에 페이지를 새로 고치거나 새 탭을 열면 사라집니다. 사용자는 매번 새로 고침할 때마다 다시 로그인해야 합니다.
문제: 컴포넌트 상태(useState)는 페이지를 새로고침해도 유지되지 않습니다. 브라우저가 새로고침되면 React가 컴포넌트를 다시 초기화하고, 메모리에 저장된 모든 상태가 사라집니다.
다음 단계에서는 브라우저 저장소 API(localStorage 또는 sessionStorage)를 사용하여 토큰을 지속적으로 저장하도록 구현하여 페이지 새로고침 및 브라우저 세션 간에도 인증이 유지되도록 합니다.
3단계 — sessionStorage와 localStorage로 사용자 토큰 저장하기
토큰 저장 위치는 세션 지속성과 보안 트레이드오프를 결정합니다. localStorage는 사용자가 브라우저 세션과 탭을 넘어 로그인 상태를 유지하도록 해주며, sessionStorage는 탭이 닫히면 지워집니다. 두 방식 모두 JavaScript에서 접근할 수 있어 XSS 공격에 취약하지만, 토큰을 컴포넌트 상태에만 저장하는 것보다 더 나은 사용자 경험을 제공합니다.
저장 옵션 비교:
- 컴포넌트 상태 (
useState): 페이지 새로고침 시 사라집니다. 지속성이 없습니다. sessionStorage: 브라우저 탭 세션 동안 지속됩니다. 탭이 닫히면 삭제됩니다. 다른 탭과 공유되지 않습니다.localStorage: 브라우저 세션과 탭을 넘어 지속됩니다. 브라우저를 다시 시작해도 유지됩니다. 동일 출처의 모든 탭에서 공유됩니다.- 쿠키: 요청 시 자동으로 전송됩니다. CSRF 보호가 필요합니다.
httpOnly로 설정할 수 있으며(자바스크립트에서 접근 불가, 더 안전함)
보안 고려 사항: 구성 요소 메모리 외부에 저장된 모든 데이터(localStorage, sessionStorage, 그리고 JavaScript에서 접근 가능한 쿠키 포함)는 크로스 사이트 스크립팅(XSS) 공격에 취약합니다. 악성 코드가 도메인에서 실행되면 이러한 저장소에 접근할 수 있습니다. 항상 사용자 입력을 정리(sanitize)하고 XSS 위험을 줄이기 위해 콘텐츠 보안 정책(CSP) 헤더를 사용하세요.
이 튜토리얼은 토큰 저장을 위한 현대적인 대안으로 sessionStorage와 localStorage를 다룹니다. 쿠키에 대한 내용은 CSRF 보호를 논의하는 8단계에서 다뤄집니다.
세션 저장소
메모리 외부에 저장하는 이점을 테스트하려면, 인메모리 저장소를 sessionStorage로 변환하세요. App.js를 열어보세요:
nano src/components/App/App.jsuseState 호출을 제거하고 setToken과 getToken이라는 두 개의 새로운 함수를 만드세요. 그런 다음 getToken을 호출하고 그 결과를 token이라는 변수에 할당하세요:
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';
function setToken(userToken) {
}
function getToken() {
}
function App() {
const token = getToken();
if(!token) {
return <Login setToken={setToken} />
}
return (
<div className="wrapper">
<h1>Application</h1>
<BrowserRouter>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/preferences" element={<Preferences />} />
</Routes>
</BrowserRouter>
</div>
);
}
export default App;동일한 함수 이름(setToken, getToken)과 변수 이름(token)을 사용하면 Login 컴포넌트와 나머지 App.js를 변경할 필요가 없습니다. 인터페이스는 동일하게 유지되지만, 구현은 이제 영구 저장소를 사용합니다.
setToken을 구현하여 토큰을 sessionStorage에 저장하세요. setItem 메서드는 키(문자열)와 값(문자열)이 필요하므로, userToken 객체를 JSON.stringify를 사용하여 JSON 문자열로 변환하세요:
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';
function setToken(userToken) {
sessionStorage.setItem('token', JSON.stringify(userToken));
}
function getToken() {
}
function App() {
const token = getToken();
if(!token) {
return <Login setToken={setToken} />
}
return (
<div className="wrapper">
<h1>Application</h1>
<BrowserRouter>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/preferences" element={<Preferences />} />
</Routes>
</BrowserRouter>
</div>
);
}
export default App;파일을 저장하세요. 브라우저가 자동으로 다시 로드됩니다. 로그인 폼을 제출한 후 토큰은 sessionStorage에 저장되지만, React가 다시 렌더링되지 않았기 때문에 로그인 페이지가 여전히 표시됩니다. 브라우저의 개발자 도구(F12)를 열고 Storage 탭을 확인하여 토큰이 저장되었는지 확인하세요. 다음 이미지는 Firefox의 개발자 도구를 보여주며, Chrome 및 다른 최신 브라우저에도 유사한 저장소 검사 도구가 있습니다.
getToken을 구현하여 sessionStorage에서 토큰을 가져오세요. getItem 메서드는 저장된 문자열을 반환하며(값이 없으면 null 반환), JSON 문자열을 다시 객체로 파싱한 후 token 속성을 반환하세요:
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';
function setToken(userToken) {
sessionStorage.setItem('token', JSON.stringify(userToken));
}
function getToken() {
const tokenString = sessionStorage.getItem('token');
if (!tokenString) {
return null;
}
const userToken = JSON.parse(tokenString);
return userToken?.token;
}
function App() {
const token = getToken();
if(!token) {
return <Login setToken={setToken} />
}
return (
<div className="wrapper">
<h1>Application</h1>
<BrowserRouter>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/preferences" element={<Preferences />} />
</Routes>
</BrowserRouter>
</div>
);
}
export default App;옵셔널 체이닝(?.)을 사용하는 이유: 첫 방문 시, sessionStorage.getItem('token')은 null을 반환하고, JSON.parse(null)도 null을 반환합니다. 옵셔널 체이닝 없이 userToken이 null일 때 userToken.token에 접근하면 TypeError가 발생합니다. ?. 연산자는 에러를 발생시키는 대신 안전하게 undefined를 반환합니다.
파일을 저장하고 닫으세요. sessionStorage에 토큰이 존재하면, 브라우저 새로고침 시 자동으로 보호된 페이지로 이동합니다:
로그인 흐름을 다시 테스트하려면 저장된 토큰을 삭제하세요. 브라우저 개발자 도구에서 Storage 탭에서 토큰을 삭제하거나 콘솔에서 sessionStorage.clear()를 실행하면 됩니다.
문제: 로그인 후 토큰이 sessionStorage에 저장되지만 여전히 로그인 페이지가 표시됩니다. 이는 React가 토큰이 저장되었음을 알지 못하고, 토큰이 변경될 때 컴포넌트를 다시 렌더링해야 하기 때문에 발생합니다.
React가 다시 렌더링되지 않는 이유: sessionStorage에 데이터를 저장해도 React는 다시 렌더링되지 않습니다. React는 상태가 변경될 때만 다시 렌더링됩니다. 이를 해결하려면 React 상태를 사용하여 토큰을 추적하고, 토큰을 저장하거나 가져올 때 해당 상태를 업데이트하세요. 이렇게 하면 다시 렌더링이 발생하고 UI가 업데이트됩니다.
해결책: 커스텀 Hook 생성을 통해 스토리지 작업과 React 상태를 결합합니다. 이렇게 하면 토큰 관리 로직을 중앙화하고 컴포넌트 전반에서 재사용할 수 있습니다.
커스텀 토큰 훅 만들기
커스텀 훅(Custom Hook)이란: 커스텀 훅은 React 훅(useState, useEffect 등)을 사용하고 재사용 가능한 로직을 캡슐화한 JavaScript 함수입니다. 관례상, 커스텀 훅은 use*로 시작합니다(e.g., useToken, useAuth). 커스텀 훅을 사용하면 컴포넌트 로직을 재사용 가능한 함수로 추출할 수 있어 코드 유지보수성 및 테스트 용이성을 높일 수 있습니다.
App 디렉토리에서 useToken.js라는 새 파일을 엽니다:
nano src/components/App/useToken.js파일 구성: 이 훅은 App.js에 직접 정의할 수도 있지만, 별도의 파일로 추출하면 커스텀 훅이 컴포넌트와 독립적으로 작동하는 방식을 보여줍니다. 애플리케이션이 커지고 여러 컴포넌트에서 이 훅을 재사용하게 되면, 별도의 파일로 유지하면 유지보수와 테스트가 더 쉬워집니다.
useToken.js에서 react로부터 useState를 가져옵니다. 이 파일에는 JSX가 포함되어 있지 않으므로 React를 가져올 필요가 없습니다. 토큰 상태를 관리하기 위해 useState를 사용하는 useToken라는 함수를 생성하고 내보내세요:
import { useState } from 'react';
export default function useToken() {
const [token, setToken] = useState();
}useToken 안에 getToken 함수를 화살표 함수로 추가하세요. 상태 선언 전에 배치한 후, useState를 getToken()으로 초기화하여 컴포넌트가 마운트될 때 기존 토큰을 불러오도록 합니다:
import { useState } from 'react';
export default function useToken() {
const getToken = () => {
const tokenString = sessionStorage.getItem('token');
if (!tokenString) {
return null;
}
const userToken = JSON.parse(tokenString);
return userToken?.token;
};
const [token, setToken] = useState(getToken());
}saveToken 함수를 추가하여 토큰을 sessionStorage와 React 상태 모두에 저장합니다. 상태에 저장하면 리렌더링이 발생하여 UI가 업데이트됩니다:
import { useState } from 'react';
export default function useToken() {
const getToken = () => {
const tokenString = sessionStorage.getItem('token');
if (!tokenString) {
return null;
}
const userToken = JSON.parse(tokenString);
return userToken?.token;
};
const [token, setToken] = useState(getToken());
const saveToken = userToken => {
sessionStorage.setItem('token', JSON.stringify(userToken));
setToken(userToken.token);
};
}token과 setToken(saveToken에 매핑됨)가 포함된 객체를 반환합니다. 배열 대신 객체를 사용하면 컴포넌트가 필요한 값만 구조 분해할 수 있습니다. 이렇게 하면 이전과 동일한 인터페이스를 유지하므로 App.js는 변경할 필요가 없습니다:
import { useState } from 'react';
export default function useToken() {
const getToken = () => {
const tokenString = sessionStorage.getItem('token');
if (!tokenString) {
return null;
}
const userToken = JSON.parse(tokenString);
return userToken?.token;
};
const [token, setToken] = useState(getToken());
const saveToken = userToken => {
sessionStorage.setItem('token', JSON.stringify(userToken));
setToken(userToken.token);
};
return {
setToken: saveToken,
token
}
}파일을 저장하고 닫으세요.
다음으로, App.js를 엽니다:
nano src/components/App/App.jsApp.js에서 getToken과 setToken 함수를 제거하고, useState 임포트도 제거하세요. 대신 useToken 훅을 임포트하여 사용하세요:
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';
import useToken from './useToken';
function App() {
const { token, setToken } = useToken();
if(!token) {
return <Login setToken={setToken} />
}
return (
<div className="wrapper">
<h1>Application</h1>
<BrowserRouter>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/preferences" element={<Preferences />} />
</Routes>
</BrowserRouter>
</div>
);
}
export default App;파일을 저장하고 닫습니다. 브라우저가 자동으로 새로고침됩니다. 로그인한 후, 앱은 보호된 페이지로 즉시 이동합니다. 이는 커스텀 훅의 useState가 토큰 상태가 업데이트될 때 리렌더를 트리거하기 때문입니다:
커스텀 훅은 토큰을 sessionStorage에 저장하고 React 상태를 관리합니다. 페이지를 새로고침해도 토큰이 sessionStorage에 남아 있어 사용자가 로그인 상태를 유지합니다. 하지만 새로운 탭에서 애플리케이션을 열면 사용자가 로그아웃됩니다. 이는 sessionStorage가 탭별로 관리되며, 탭 간 데이터가 공유되지 않고 탭이 닫히면 데이터가 삭제되기 때문입니다.
sessionStorage를 사용해야 할 때: 탭을 닫으면 사라지는 세션 전용 인증이 필요할 때 sessionStorage를 사용하세요. 이는 공동 컴퓨터에서 더 안전합니다.
localStorage를 사용할 때: 탭이나 브라우저 세션에 걸쳐 인증 정보를 유지하고 싶을 때 localStorage를 사용하세요. 이는 더 나은 사용자 경험을 제공하지만, 공용 컴퓨터에서는 사용자가 명시적으로 로그아웃해야 합니다.
창 간 데이터 저장을 위해 localStorage 사용하기
localStorage는 브라우저 세션과 탭에 걸쳐 유지됩니다. 이는 특정 탭에만 적용되는 sessionStorage와 다릅니다. 즉, 사용자는 새 탭을 열거나 브라우저를 다시 시작해도 로그인 상태를 유지할 수 있습니다.
보안 고려 사항: 공유 컴퓨터에서는 localStorage 토큰이 브라우저를 닫은 후에도 유지됩니다. 이전 사용자가 로그아웃하지 않은 경우, 다음 사용자가 로그인 없이 애플리케이션에 접근할 수 있습니다. 공유 컴퓨터에서는 다음 사항을 고려하세요:
- 세션 시간 초과 경고 추가
- 선택 사항 기능으로 '로그인 상태 유지' 구현하기
- 기본적으로
sessionStorage를 사용하고, 명시적으로 요청할 때만localStorage를 사용 - 명확한 로그아웃 기능 제공
개인용 기기에서는 적절한 XSS 보호와 함께 사용할 경우 localStorage가 최소한의 보안 위험으로 더 나은 사용자 경험을 제공합니다.
localStorage로 변환하려면 useToken.js를 엽니다:
nano src/components/App/useToken.js모든 sessionStorage 참조를 localStorage로 교체하세요. API 메서드(getItem, setItem)는 동일합니다:
import { useState } from 'react';
export default function useToken() {
const getToken = () => {
const tokenString = localStorage.getItem('token');
if (!tokenString) {
return null;
}
const userToken = JSON.parse(tokenString);
return userToken?.token;
};
const [token, setToken] = useState(getToken());
const saveToken = userToken => {
localStorage.setItem('token', JSON.stringify(userToken));
setToken(userToken.token);
};
return {
setToken: saveToken,
token
}
}파일을 저장하세요. 브라우저가 자동으로 새로고침됩니다. 다시 로그인하세요(sessionStorage에 저장되어 있던 이전 토큰은 localStorage에 있지 않았습니다). 로그인 후에는 토큰이 localStorage에 유지되므로 새 탭을 열거나 브라우저를 재시작해도 로그인 상태가 유지됩니다.
당신은 sessionStorage와 localStorage를 사용하여 지속적인 토큰 저장을 구현하고, 스토리지 작업을 React 상태 관리와 결합한 커스텀 useToken 훅을 만들었습니다. 이 훅은 토큰이 변경될 때 리렌더링을 트리거하여 UI가 정확하게 업데이트되도록 합니다. 또한 sessionStorage (탭별, 더 안전함)와 localStorage (세션 간 지속, 더 나은 UX)의 장단점에 대해서도 배우게 되었습니다.
4단계 — React Router v6로 보호된 라우트 구현하기
보호된 경로는 렌더링 전에 인증이 필요합니다. React Router v6에서는 토큰을 확인하고 인증되지 않은 사용자를 로그인 페이지로 리디렉션하는 래퍼 컴포넌트를 생성하여 사용자가 원래 가고자 했던 목적지를 유지할 수 있습니다.
ProtectedRoute 컴포넌트를 사용하는 이유: 모든 컴포넌트에서 인증을 확인하거나 App.js에서 조건부 렌더링을 사용하는 대신, 전용 ProtectedRoute 컴포넌트가 라우트 보호 로직을 중앙 집중화합니다. 이를 통해 보호된 라우트를 추가하고, 리다이렉트를 처리하며, 일관된 인증 동작을 유지하는 것이 더 쉬워집니다.
ProtectedRoute 컴포넌트를 위한 새 파일을 만드세요:
nano src/components/ProtectedRoute/ProtectedRoute.jsProtectedRoute.js 내에서 react-router-dom에서 Navigate를 가져오고 토큰을 확인하는 컴포넌트를 만듭니다:
import { Navigate } from 'react-router-dom';
export default function ProtectedRoute({ children, token }) {
if (!token) {
return <Navigate to="/login" replace />;
}
return children;
}ProtectedRoute 컴포넌트는 children(보호할 라우트 컴포넌트)과 token을 props로 받습니다. 토큰이 존재하지 않으면, 브라우저 히스토리에 항목이 추가되지 않도록 replace props와 함께 Navigate를 사용하여 /login으로 리디렉션합니다.
이제 App.js를 업데이트하여 ProtectedRoute를 사용하세요:
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';
import ProtectedRoute from '../ProtectedRoute/ProtectedRoute';
import useToken from './useToken';
function App() {
const { token, setToken } = useToken();
return (
<div className="wrapper">
<h1>Application</h1>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login setToken={setToken} />} />
<Route
path="/dashboard"
element={
<ProtectedRoute token={token}>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/preferences"
element={
<ProtectedRoute token={token}>
<Preferences />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
</div>
);
}
export default App;이 접근 방식을 사용하면, 인증되지 않은 사용자가 /dashboard 또는 /preferences를 방문할 경우 /login으로 리디렉션됩니다.
참고: React Router v6에서는 중첩 라우트 및 라우트 보호를 위해 Outlet 컴포넌트를 사용할 수도 있습니다. 더 간단한 애플리케이션에서는 여기에 보여진 ProtectedRoute 래퍼 컴포넌트만으로도 충분하며 이해하기가 더 쉽습니다.
5단계 — JWT 토큰 디코딩 및 만료 확인
JWT 토큰에는 페이로드에 만료 정보가 포함되어 있습니다. 클라이언트에서 토큰을 디코딩하여 서버 요청 없이 만료 여부를 확인할 수 있어 즉각적인 피드백을 제공하여 사용자 경험을 향상시킬 수 있습니다.
클라이언트에서 토큰을 디코딩해야 하는 이유: 토큰은 항상 서버에서 검증해야 하지만, 클라이언트 측 디코딩을 통해 API 호출 전에 만료 여부를 확인하고, 적절한 오류 메시지를 표시하며, 만료된 토큰이 있는 사용자를 자동으로 로그아웃시킬 수 있습니다.
먼저 JWT 디코딩 라이브러리를 설치하세요. JWT를 수동으로 디코딩할 수도 있지만(이들은 base64로 인코딩되어 있습니다), 라이브러리를 사용하면 예외 상황도 처리하고 더 나은 오류 처리를 제공합니다:
npm install jwt-decode토큰을 디코딩하고 검증하도록 useToken.js 훅을 업데이트하세요:
import { useState } from 'react';
import { jwtDecode } from 'jwt-decode';
export default function useToken() {
const getToken = () => {
const tokenString = localStorage.getItem('token');
if (!tokenString) {
return null;
}
const userToken = JSON.parse(tokenString);
const token = userToken?.token;
if (!token) {
return null;
}
// Decode the token to check expiration
try {
const decoded = jwtDecode(token);
const currentTime = Date.now() / 1000; // Convert to seconds
// If token is expired, remove it and return null
if (decoded.exp < currentTime) {
localStorage.removeItem('token');
return null;
}
return token;
} catch (error) {
// If decoding fails, token is invalid
localStorage.removeItem('token');
return null;
}
};
const [token, setToken] = useState(getToken());
const saveToken = userToken => {
localStorage.setItem('token', JSON.stringify(userToken));
setToken(userToken.token);
};
const removeToken = () => {
localStorage.removeItem('token');
setToken(null);
};
return {
setToken: saveToken,
token,
removeToken
};
}getToken 함수는 이제 JWT를 디코딩하고 exp 클레임(초 단위 만료 시간)을 확인합니다. 토큰이 만료되었거나 유효하지 않은 경우, 저장소에서 제거하고 null을 반환하여 사용자를 로그아웃 처리합니다.
경고: 클라이언트 측 토큰 디코딩은 UX 용도로만 사용됩니다. 토큰은 항상 서버에서 검증하세요. 악의적인 사용자는 클라이언트 측 코드를 수정하거나 가짜 토큰을 보낼 수 있습니다. 보안 관련 결정에는 클라이언트 측 검증을 절대 신뢰하지 마세요.
6단계 — 로그아웃 및 오류 처리 구현
로그아웃은 저장소에서 토큰을 제거하고 로그인 페이지로 리디렉션합니다. 오류 처리는 API 호출 실패 시 앱이 중단되는 것을 방지하고 사용자에게 피드백을 제공합니다.
로그아웃 기능을 구현해야 하는 이유: 로그아웃 기능이 없으면 사용자는 브라우저 저장소를 수동으로 지우거나 토큰이 만료될 때까지 기다려야 합니다. 로그아웃 버튼은 세션을 종료하는 명확한 방법을 제공하며, 공용 컴퓨터에서는 필수적입니다.
Dashboard 컴포넌트에 로그아웃 버튼을 포함하도록 업데이트하세요:
import React from 'react';
import { useNavigate } from 'react-router-dom';
export default function Dashboard({ removeToken }) {
const navigate = useNavigate();
const handleLogout = () => {
removeToken();
navigate('/login');
};
return (
<div>
<h2>Dashboard</h2>
<button onClick={handleLogout}>Logout</button>
</div>
);
}보호된 라우트에 removeToken을 전달하도록 App.js를 업데이트하세요:
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';
import ProtectedRoute from '../ProtectedRoute/ProtectedRoute';
import useToken from './useToken';
function App() {
const { token, setToken, removeToken } = useToken();
return (
<div className="wrapper">
<h1>Application</h1>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login setToken={setToken} />} />
<Route
path="/dashboard"
element={
<ProtectedRoute token={token}>
<Dashboard removeToken={removeToken} />
</ProtectedRoute>
}
/>
<Route
path="/preferences"
element={
<ProtectedRoute token={token}>
<Preferences />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
</div>
);
}
export default App;이제 로그인 컴포넌트에 오류 처리 기능을 추가하세요:
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './Login.css';
async function loginUser(credentials) {
const response = await fetch('http://localhost:8080/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
});
if (!response.ok) {
throw new Error(`Login failed: ${response.statusText}`);
}
const data = await response.json();
return data;
}
export default function Login({ setToken }) {
const [username, setUserName] = useState();
const [password, setPassword] = useState();
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async e => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const token = await loginUser({
username,
password
});
setToken(token);
} catch (err) {
setError(err.message || 'An error occurred during login');
} finally {
setLoading(false);
}
};
return(
<div className="login-wrapper">
<h1>Please Log In</h1>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<label>
<p>Username</p>
<input
type="text"
onChange={e => setUserName(e.target.value)}
disabled={loading}
/>
</label>
<label>
<p>Password</p>
<input
type="password"
onChange={e => setPassword(e.target.value)}
disabled={loading}
/>
</label>
<div>
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Submit'}
</button>
</div>
</form>
</div>
);
}
Login.propTypes = {
setToken: PropTypes.func.isRequired
};로그인 컴포넌트는 이제 오류를 처리하고, 로딩 상태를 표시하며, 사용자에게 피드백을 제공합니다. 오류 처리는 API 호출이 실패할 때 앱이 다운되는 것을 방지하고, 무엇이 잘못되었는지 사용자에게 알려줍니다.
7단계 — 인증을 위한 React 컨텍스트 사용
React Context는 prop drilling 없이 컴포넌트 간 인증 상태를 공유할 수 있는 방법을 제공합니다. 대규모 애플리케이션에서는 여러 컴포넌트 계층을 통해 props를 전달하는 것보다 Context가 더 확장성이 있습니다.
인증에 Context를 사용하는 이유: 인증 상태가 여러 컴포넌트(내비게이션 바, 사용자 메뉴, 보호된 라우트)에서 필요할 때, props를 전달하는 것은 번거로울 수 있습니다. Context는 컴포넌트 트리 어디에서나 인증 상태에 접근할 수 있는 깔끔한 방법을 제공합니다.
인증 컨텍스트를 생성합니다:
import React, { createContext, useContext, useState } from 'react';
import useToken from '../components/App/useToken';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const { token, setToken, removeToken } = useToken();
const [user, setUser] = useState(null);
const login = async (credentials) => {
// Login logic here
const response = await fetch('http://localhost:8080/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
setToken(data);
// Optionally decode token to set user info
return data;
};
const logout = () => {
removeToken();
setUser(null);
};
const value = {
token,
user,
login,
logout,
isAuthenticated: !!token
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}앱을 AuthProvider로 감싸세요:
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { AuthProvider } from '../../contexts/AuthContext';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';
import ProtectedRoute from '../ProtectedRoute/ProtectedRoute';
function App() {
return (
<AuthProvider>
<div className="wrapper">
<h1>Application</h1>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/preferences"
element={
<ProtectedRoute>
<Preferences />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
</div>
</AuthProvider>
);
}
export default App;ProtectedRoute를 컨텍스트를 사용하도록 업데이트하세요:
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
export default function ProtectedRoute({ children }) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}이제 모든 컴포넌트는 useAuth()를 사용하여 인증 상태에 접근할 수 있어, 여러 계층을 거쳐 props를 전달할 필요가 없습니다.
단계 8 — 쿠키 기반 인증을 위한 CSRF 보호 이해하기
CSRF(사이트 간 요청 위조) 공격은 브라우저가 요청 시 자동으로 쿠키를 전송한다는 사실을 악용합니다. 쿠키 기반 인증을 사용하는 경우, 악의적인 사이트가 사용자를 대신하여 인증된 요청을 보내지 못하도록 CSRF 보호를 구현해야 합니다.
CSRF 보호가 중요한 이유: 인증이 localStorage의 토큰 대신 쿠키에 의존하는 경우, 악성 웹사이트가 사용자를 속여 API에 인증된 요청을 보내게 할 수 있습니다. 예를 들어, 공격자는 사용자가 로그인한 상태라면 브라우저가 자동으로 인증 쿠키를 포함하여 API에 제출되는 폼을 웹사이트에 삽입할 수 있습니다.
CSRF 방어 전략:
CSRF 토큰: 각 세션마다 고유한 토큰을 생성하고 요청 시 이 토큰을 요구합니다. 토큰은 서버에 저장된 것과 일치해야 합니다.
SameSite 쿠키 속성: 쿠키를
SameSite=Strict또는SameSite=Lax로 설정하여 교차 사이트 요청 시 전송되지 않도록 합니다.출처 헤더 검증:
Origin또는Referer헤더가 도메인과 일치하는지 확인하세요.더블 서밋 쿠키: CSRF 토큰을 쿠키와 폼 필드에 모두 저장하고, 두 값이 일치해야 합니다.
다음은 Express 서버에서 CSRF 보호를 구현하는 예시입니다:
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const crypto = require('crypto');
const app = express();
app.use(cors({
origin: 'http://localhost:3000',
credentials: true // Allow cookies
}));
app.use(cookieParser());
app.use(express.json());
// Generate CSRF token
app.get('/api/csrf-token', (req, res) => {
const token = crypto.randomBytes(32).toString('hex');
res.cookie('csrf-token', token, {
httpOnly: false, // Must be accessible to JavaScript
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production'
});
res.json({ csrfToken: token });
});
// Verify CSRF token middleware
function verifyCsrfToken(req, res, next) {
const token = req.headers['x-csrf-token'];
const cookieToken = req.cookies['csrf-token'];
if (!token || !cookieToken || token !== cookieToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
next();
}
app.post('/login', verifyCsrfToken, (req, res) => {
// Login logic here
res.json({ token: 'test123' });
});
app.listen(8080, () => console.log('API is running on http://localhost:8080'));React 앱에서 인증된 요청을 하기 전에 CSRF 토큰을 가져오세요:
let csrfToken = null;
export async function getCsrfToken() {
if (!csrfToken) {
const response = await fetch('http://localhost:8080/api/csrf-token', {
credentials: 'include' // Include cookies
});
const data = await response.json();
csrfToken = data.csrfToken;
}
return csrfToken;
}
export async function loginUser(credentials) {
const token = await getCsrfToken();
return fetch('http://localhost:8080/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token
},
credentials: 'include', // Include cookies
body: JSON.stringify(credentials)
}).then(data => data.json());
}참고: localStorage에 저장된 토큰 기반 인증에서는 토큰이 요청 헤더에 명시적으로 포함되어야 하고 요청 시 자동으로 전송되지 않기 때문에 CSRF 보호가 필요하지 않습니다. 그러나 인증에 쿠키를 사용하는 경우에는 쿠키가 자동으로 전송되므로 CSRF 보호가 필수적입니다. 사용 중인 인증 방식과 관계없이 HTTPS, 입력값 검증, 속도 제한과 같은 적절한 보안 조치를 항상 구현해야 합니다.
문제 해결
페이지 새로고침 후 토큰이 유지되지 않음
문제: 로그인한 후 페이지를 새로 고치면 로그아웃됩니다.
해결책:
- 토큰이
localStorage또는sessionStorage에 저장되어 있는지 확인하세요(브라우저 개발자 도구 > 애플리케이션 > 스토리지 확인) - 컴포넌트 마운트 시
getToken()이 실행되어 상태를 올바르게 초기화하도록 합니다 - 토큰을 가져오는 것을 방해할 수 있는 브라우저 콘솔의 오류를 확인하세요
- 토큰 형식이 코드에서 예상하는 형식(토큰 속성을 가진 객체)과 일치하는지 확인하세요
‘null’의 속성 ‘token’을 읽을 수 없습니다 오류
문제: JSON.parse() 후 userToken.token에 접근할 때 오류가 발생합니다.
해결책: 파싱하기 전에 널 체크를 추가하세요:
const tokenString = localStorage.getItem('token');
if (!tokenString) return null;
const userToken = JSON.parse(tokenString);
return userToken?.token;로그인 폼이 제출되지 않습니다
문제: 제출 버튼을 클릭해도 로그인 요청이 실행되지 않습니다.
해결책:
- 폼에
onSubmit={handleSubmit}가 있고 버튼에type="submit"이 있는지 확인하세요 - API 서버가 올바른 포트(기본값: 8080)에서 실행되고 있는지 확인하세요
- 교차 출처 요청을 하는 경우 서버에서 CORS가 활성화되어 있는지 확인하세요
- 브라우저 콘솔에서 네트워크 오류나 CORS 문제를 확인하세요
보호된 경로가 작동하지 않음
문제: 사용자가 인증 없이 보호된 경로에 접근할 수 있습니다.
해결책:
ProtectedRoute컴포넌트가 렌더링 전에 토큰을 확인하는지 검증하세요- 토큰이
ProtectedRoute에 전달되거나 Context를 통해 접근되는지 확인하세요 App.js에서 라우트가 올바르게 감싸져 있는지 확인하세요- React Router v6 문법 확인 (
children가 아닌element속성)
사용자가 활동 중일 때 토큰이 만료됨
문제: 사용자가 활동 중에 예기치 않게 로그아웃됩니다.
해결책:
- 리프레시 토큰을 사용하여 토큰 갱신 로직 구현
- API 호출을 하기 전에 토큰 만료를 확인하세요
- 만료가 임박했을 때 자동 토큰 갱신 추가
- 활성 사용자에 대한 토큰 만료 기간 연장을 고려하세요
로컬 스토리지 사용 불가 또는 용량 초과
문제: 토큰이 비공개/시크릿 모드에서는 저장되지 않거나 저장 용량이 가득 찼습니다.
해결책:
localStorage사용 가능 여부 확인:if (typeof(Storage) !== "undefined")- 항목을 설정할 때
QuotaExceededError처리 sessionStorage또는 메모리 저장소로 폴백- 새 토큰을 저장하기 전에 기존 토큰을 삭제하세요
- 스토리지를 사용할 수 없는 경우 사용자에게 알리기
여러 탭에서 인증 상태가 동기화되지 않음
문제: 한 탭에서 로그아웃해도 다른 탭에서는 로그아웃되지 않습니다.
해결 방법: 탭 간 상태를 동기화하려면 storage 이벤트를 청취하세요:
useEffect(() => {
const handleStorageChange = (e) => {
if (e.key === 'token') {
// Update auth state when token changes in another tab
setToken(e.newValue ? JSON.parse(e.newValue) : null);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);API가 다른 토큰 형식을 반환합니다
문제: 귀하의 API는 { token: '...' } 대신 { accessToken: '...' }를 반환합니다.
해결 방법: 코드가 API의 응답 형식과 일치하도록 업데이트하세요:
// If API returns { accessToken: '...' }
const token = userToken?.accessToken;
// Or if API returns { data: { token: '...' } }
const token = userToken?.data?.token;401 권한 없음 오류
문제: 유효한 토큰이 있음에도 API가 401을 반환합니다.
해결책:
- 요청 헤더에 토큰이 전송되었는지 확인:
Authorization: Bearer $ {token} - 토큰 만료 확인 (JWT를 디코딩하여
exp클레임 확인) - 토큰 형식이 서버에서 요구하는 것과 일치하는지 확인하세요
- 서버가 토큰을 올바르게 검증하는지 확인
- 토큰을 갱신해야 하는지 확인하세요
자주 묻는 질문
1. React에서 라우트를 어떻게 보호하나요?
렌더링 전에 인증을 확인하는 ProtectedRoute 래퍼 컴포넌트를 사용하세요. React Router v6에서는 보호된 라우트를 토큰 존재 여부를 확인하는 컴포넌트로 감싸세요. 토큰이 없으면 Navigate를 사용하여 로그인 페이지로 리다이렉트합니다:
function ProtectedRoute({ children, token }) {
if (!token) {
return <Navigate to="/login" replace />;
}
return children;
}그런 다음 라우트에서 사용하세요:
<Route
path="/dashboard"
element={
<ProtectedRoute token={token}>
<Dashboard />
</ProtectedRoute>
}
/>2. 인증 토큰은 어디에 저장해야 하나요?
세션 간 지속성을 위해 토큰을 localStorage에 저장하거나, 특정 탭 세션을 위해 sessionStorage에 저장할 수 있습니다. 두 방법 모두 JavaScript에서 접근할 수 있어 XSS 공격에 취약하므로, 항상 사용자 입력을 검증하고 HTTPS를 사용하세요.
localStorage: 브라우저 세션과 탭 간에 데이터를 지속시킵니다. '나를 기억하기' 기능에 사용하세요.sessionStorage: 탭이 닫힐 때 삭제됩니다. 더 안전한 세션 전용 인증에 사용하세요.- 쿠키: 요청 시 자동으로 전송되며 CSRF 보호가 필요합니다. 서버 측 전용 액세스를 위해
httpOnly플래그와 함께 사용하세요.
실제 서비스 환경에서는 XSS 공격에 덜 취약한, 백엔드에서 관리하는 보안 httpOnly 쿠키를 사용하는 것을 고려하세요.
3. localStorage는 인증에 안전한가요?
localStorage는 XSS 공격에 취약하지만, 적절한 보안 대책과 함께 사용하면 많은 애플리케이션에서 사용 가능합니다. 도메인에서 실행되는 모든 자바스크립트는 localStorage에 접근할 수 있으므로, 공격자가 악성 스크립트를 삽입하면 토큰을 탈취할 수 있습니다.
완화 전략:
- 모든 사용자 입력을 정리하여 XSS를 방지하세요
- 콘텐츠 보안 정책(CSP) 헤더 사용
- 토큰 만료 및 토큰 갱신 구현
- 중간자 공격을 방지하려면 HTTPS를 사용하세요
- 민감한 애플리케이션에는 httpOnly 쿠키를 고려하세요
높은 보안이 필요한 애플리케이션(은행, 의료 등)의 경우, JavaScript로 접근할 수 없으므로 CSRF 보호 기능이 있는 httpOnly 쿠키를 사용하는 것이 좋습니다.
4. React 앱에 OAuth가 필요한가요?
OAuth는 타사 제공업체(Google, GitHub, Facebook)로 인증할 때 필요하지만, 맞춤 인증에는 필요하지 않습니다. 사용자 이름/비밀번호로 자체 인증 시스템을 구축하는 경우, 이 튜토리얼에서 보여주는 것처럼 토큰 기반 인증을 사용할 수 있습니다.
다음과 같은 경우 OAuth를 사용하세요:
- 사용자는 소셜 계정으로 로그인해야 합니다
- 인증을 신뢰할 수 있는 제공자에게 위임하고 싶습니다
- 사용자를 대신하여 서드파티 API에 접근할 필요가 있습니다
다음과 같은 경우 사용자 정의 인증 사용:
- 당신이 사용자 데이터베이스를 관리합니다
- 인증 흐름을 완전히 제어할 수 있어야 합니다
- 타사 의존성을 피하고 싶어한다
많은 애플리케이션이 혼합 방식을 사용합니다: 소셜 로그인을 위해 OAuth를 사용하고 이메일/비밀번호를 위해 맞춤 인증을 사용합니다.
5. 토큰 만료는 어떻게 처리하나요?
JWT 토큰을 클라이언트에서 디코딩하여 만료 여부를 확인한 후, 사용자 세션을 갱신하거나 로그아웃합니다. jwt-decode와 같은 라이브러리를 사용하여 exp 클레임을 읽으세요:
import { jwtDecode } from 'jwt-decode';
const decoded = jwtDecode(token);
const currentTime = Date.now() / 1000;
if (decoded.exp < currentTime) {
// Token expired, remove it and redirect to login
localStorage.removeItem('token');
navigate('/login');
}운영 환경에서는 리프레시 토큰을 구현하세요: 짧은 수명의 액세스 토큰(15-30분)과 재인증 없이 새 액세스 토큰을 얻는 데 사용할 수 있는 비교적 긴 수명의 리프레시 토큰(7-30일)입니다.
6. '자동 로그인' 기능은 어떻게 구현하나요?
세션을 지속하려면 localStorage에 토큰을 저장하고, 임시 세션의 경우 sessionStorage에 저장하세요. 로그인 폼에 체크박스를 추가하세요:
const [rememberMe, setRememberMe] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
const token = await loginUser(credentials);
if (rememberMe) {
localStorage.setItem('token', JSON.stringify(token));
} else {
sessionStorage.setItem('token', JSON.stringify(token));
}
};rememberMe가 체크되어 있으면 토큰이 브라우저 세션 간에 유지됩니다. 그렇지 않으면 탭을 닫을 때 토큰이 삭제됩니다.
결론
React 애플리케이션에서 인증은 보안, 사용자 경험, 유지보수성을 균형 있게 고려해야 합니다. 인증 로직을 커스텀 훅이나 React Context에 중앙화하고, React Router v6로 보호된 라우트를 구현하며, 적절한 토큰 저장 전략을 선택함으로써 안전하고 사용자 친화적인 인증 흐름을 구축할 수 있습니다.
기억해야 할 핵심 원칙:
- 토큰을 브라우저 저장소(
localStorage또는sessionStorage)에 저장하여 페이지 새로고침 시에도 유지되도록 합니다 - 보호된 라우트 컴포넌트를 사용하여 인증 요구 사항을 적용하세요
- JWT 토큰을 클라이언트에서 디코딩하여 만료를 확인하되, 항상 서버에서 검증하세요
- 더 나은 사용자 경험을 위해 오류 처리 및 로딩 상태를 구현하세요
- 쿠키 기반 인증을 사용할 때 CSRF 보호를 고려하세요
- 더 큰 애플리케이션에서 prop 전달을 피하기 위해 인증 상태 관리에 React Context를 사용하세요
각 스토리지 솔루션과 인증 방식에는 장단점이 있습니다. localStorage는 편리하지만 XSS 방어가 필요합니다. sessionStorage는 더 안전하지만 덜 편리합니다. 쿠키는 CSRF 방어가 필요하지만 XSS에는 덜 취약합니다. 보안 요구 사항과 사용자 경험 목표에 따라 선택하세요.
애플리케이션이 성장함에 따라 리프레시 토큰, 다중 인증, 세션 관리 기능 도입을 고려하세요. 운영 환경의 애플리케이션에서는 항상 HTTPS를 사용하고, 속도 제한을 적용하며, 보안 모범 사례를 따르세요.
더 많은 React 튜토리얼을 읽고 싶다면, 저희 React 주제 페이지를 확인하거나 Django와 React를 사용하여 할 일(To-Do) 애플리케이션을 만드는 방법과 Ubuntu에서 Nginx로 React 애플리케이션을 배포하는 방법 같은 관련 튜토리얼을 살펴보세요.
Linux-Console.net 커뮤니티와 함께 학습해 주셔서 감사합니다.








