Skip to Content
WebReactReact 실무07. 로그인 폼과 토큰 보관 구현

이번 편의 결과물: /login 화면에서 로그인하면 인증 상태가 유지되고, 로그아웃 버튼으로 초기화됩니다. · 다루는 개념: 로그인 폼(mock 인증 API), 토큰 저장소 선택 적용(메모리 + refetch 패턴), 로그아웃

02편에서 토큰을 어디에 둘지 트레이드오프를 정리했습니다. localStorage는 XSS에 노출되면 그대로 탈취되고, httpOnly 쿠키는 이 과목의 mock 서버 구조상 적용이 번거롭습니다. 이 과목은 메모리 보관을 택합니다. 새로고침하면 토큰이 사라지는 대신, 도난 위험이 가장 낮습니다. 이 편에서 로그인 폼과 토큰 저장 모듈, 인증 상태를 공유하는 Context를 만듭니다.

이 편에서 만드는 파일

bookshelf/src/ ├── api/ │ └── auth.js + (login, getToken, setToken, clearToken) ├── context/ │ └── AuthContext.jsx + (인증 상태 Provider) ├── pages/ │ └── LoginPage.jsx + (로그인 폼) ├── router.jsx ~ (/login 라우트 추가) └── main.jsx ~ (AuthProvider로 감싸기)

개념 정리

저장 위치장점단점
메모리(모듈 변수·state)XSS로 스크립트가 실행돼도 새로고침 전까지만 노출, 탈취 범위가 작음새로고침하면 로그아웃된다
localStorage새로고침해도 유지어떤 스크립트든 localStorage.getItem으로 읽을 수 있어 XSS에 그대로 노출
httpOnly 쿠키자바스크립트가 값을 읽을 수 없어 XSS에 강함서버가 쿠키를 내려줘야 하고 CSRF 대비가 별도로 필요

이 과목은 메모리 보관을 택하고, 새로고침으로 로그아웃되는 단점은 “재조회 패턴”으로 보완합니다. 사용자가 새로고침하면 로그인 화면으로 돌아가고 다시 로그인하는 흐름을 그대로 받아들입니다. 실무에서는 여기에 httpOnly 리프레시 쿠키를 더해 자동 재로그인을 구현하지만, 이 과목의 범위 밖입니다.

json-server는 별도의 로그인 API를 제공하지 않습니다. 대신 db.jsonusers 배열을 GET /users?email=...로 조회해 이메일이 일치하는 계정을 찾고, 비밀번호는 응답받은 값과 클라이언트에서 비교합니다. 비밀번호까지 쿼리 문자열에 실어 보내면 서버 로그·브라우저 히스토리에 평문 비밀번호가 그대로 남을 수 있어, 이 과목은 이메일만 쿼리로 보냅니다. 실제 서버라면 비밀번호 비교 자체를 서버 쪽에서 해시로 수행하지만, 이 과목의 mock 환경에서는 흐름을 보여주는 데 집중합니다.

실습

1. 토큰 저장 모듈과 로그인 함수 작성

// bookshelf/src/api/auth.js const API_BASE = 'http://localhost:3001' let authToken = null export function getToken() { return authToken } export function setToken(token) { authToken = token } export function clearToken() { authToken = null } export async function login(email, password) { const response = await fetch(`${API_BASE}/users?email=${encodeURIComponent(email)}`) if (!response.ok) { throw new Error('로그인 요청에 실패했습니다') } const users = await response.json() const user = users[0] if (!user || user.password !== password) { throw new Error('이메일 또는 비밀번호가 올바르지 않습니다') } setToken(`mock-token-${user.id}`) return { id: user.id, email: user.email, name: user.name } }

authToken은 모듈 스코프 변수입니다. React 상태가 아니므로 값이 바뀌어도 화면이 다시 렌더되지 않습니다. 화면 갱신은 다음 단계의 AuthContext가 맡습니다.

2. 인증 상태를 공유하는 Context 작성

// bookshelf/src/context/AuthContext.jsx import { createContext, useContext, useState } from 'react' import { login as loginRequest, clearToken, getToken } from '../api/auth.js' const AuthContext = createContext(null) export function AuthProvider({ children }) { const [user, setUser] = useState(null) async function login(email, password) { const loggedInUser = await loginRequest(email, password) setUser(loggedInUser) return loggedInUser } function logout() { clearToken() setUser(null) } const value = { user, token: getToken(), isAuthenticated: Boolean(user), login, logout, } return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> } export function useAuth() { const context = useContext(AuthContext) if (!context) { throw new Error('useAuth는 AuthProvider 안에서만 사용할 수 있습니다') } return context }

tokengetToken()을 그대로 읽기만 합니다. 화면이 다시 렌더되는 트리거는 user state입니다. login이 성공하면 setUser가 호출되어 헤더 등에서 로그인 여부를 즉시 반영할 수 있습니다.

3. 로그인 페이지 작성

// bookshelf/src/pages/LoginPage.jsx import { useState } from 'react' import { useNavigate } from 'react-router' import { useAuth } from '../context/AuthContext.jsx' export default function LoginPage() { const navigate = useNavigate() const { login } = useAuth() const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) async function handleSubmit(event) { event.preventDefault() setError('') setIsSubmitting(true) try { await login(email, password) navigate('/') } catch (err) { setError(err.message) } finally { setIsSubmitting(false) } } return ( <form onSubmit={handleSubmit}> <h1>로그인</h1> <label htmlFor="email">이메일</label> <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required /> <label htmlFor="password">비밀번호</label> <input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required /> <button type="submit" disabled={isSubmitting}> {isSubmitting ? '로그인 중...' : '로그인'} </button> {error && <p role="alert">{error}</p>} </form> ) }

4. 라우트와 Provider 연결

react_2가 남긴 router.jsxcreateBrowserRouter로 라우트 배열을 정의합니다(App.jsx는 03편부터 쓰이지 않습니다). 기존 라우트 배열에 /login만 추가합니다.

// bookshelf/src/router.jsx (일부 — 나머지 라우트 정의는 react_2 코드 유지) import { createBrowserRouter } from 'react-router' import BookListPage from './pages/BookListPage.jsx' import NewBookPage from './pages/NewBookPage.jsx' import LoginPage from './pages/LoginPage.jsx' // ...기존 BookDetailPage·중첩 라우트·RouteErrorPage import 유지 export const router = createBrowserRouter([ { path: '/', element: <BookListPage /> }, { path: '/books/new', element: <NewBookPage /> }, // ...기존 /books/:id 중첩 라우트 유지 { path: '/login', element: <LoginPage /> }, ])
// bookshelf/src/main.jsx import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { RouterProvider } from 'react-router' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { AuthProvider } from './context/AuthContext.jsx' import { router } from './router.jsx' import './styles/index.css' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30 * 1000, retry: 1, }, }, }) createRoot(document.getElementById('root')).render( <StrictMode> <QueryClientProvider client={queryClient}> <AuthProvider> <RouterProvider router={router} /> </AuthProvider> </QueryClientProvider> </StrictMode>, )

5. 실행과 확인

npx json-server db.json --port 3001 npm run dev
  • /login으로 이동해 zeno@example.com / password123을 입력하고 로그인하면 /로 이동한다.
  • 틀린 비밀번호를 입력하면 “이메일 또는 비밀번호가 올바르지 않습니다”가 화면에 보인다.
  • 헤더(또는 임시로 추가한 로그아웃 버튼)에서 logout()을 호출하면 다시 미인증 상태로 돌아간다.
  • 새로고침하면 authToken이 초기화되어 로그아웃 상태가 된다. 08편에서 이 상태로 접근을 막습니다.

직접 해보기

  1. LoginPage 근처에 로그인 성공 시 user.name을 표시하는 작은 컴포넌트를 만들어 보세요.
  2. useAuth()AuthProvider 바깥에서 호출하면 어떤 에러가 나는지 확인해 보세요.

정답 보기

// src/components/UserBadge.jsx import { useAuth } from '../context/AuthContext.jsx' export default function UserBadge() { const { user, logout } = useAuth() if (!user) return null return ( <span> {user.name}님 <button type="button" onClick={logout}> 로그아웃 </button> </span> ) }

AuthProvider 바깥에서 useAuth()를 부르면 contextnull이라 “useAuth는 AuthProvider 안에서만 사용할 수 있습니다” 에러가 던져집니다. 이 가드는 Provider를 빼먹은 실수를 개발 중에 바로 드러냅니다.

자주 하는 실수

증상원인고치는 법
로그인해도 헤더가 안 바뀜token만 바뀌고 user state는 안 바뀜login 함수 안에서 반드시 setUser를 호출한다
로그인 성공 후에도 이전 페이지의 에러 메시지가 남음handleSubmit에서 setError('') 초기화를 생략매 제출 시작 시 에러 상태를 먼저 비운다
useAuth 호출 시 에러AuthProvider로 감싸지 않은 컴포넌트 트리에서 사용main.jsx의 Provider 순서와 위치를 확인한다

확인 문제

문제 14지선다
이 과목이 토큰을 메모리에 보관하기로 한 이유는
문제 24지선다
AuthContext에서 token은 getToken()으로 읽지만 화면 갱신은 user state가 트리거한다. 그 이유는
문제 34지선다
이 편에서 GET /users?email=...로 조회한 뒤 비밀번호는 클라이언트에서 비교하는 이유는
문제 44지선다
로그인 폼 제출 중 버튼에 disabled를 거는 주된 이유는

참고 자료

Last updated on