이번 편의 결과물: /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.json의 users 배열을 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
}token은 getToken()을 그대로 읽기만 합니다. 화면이 다시 렌더되는 트리거는 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.jsx는 createBrowserRouter로 라우트 배열을 정의합니다(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편에서 이 상태로 접근을 막습니다.
직접 해보기
LoginPage근처에 로그인 성공 시user.name을 표시하는 작은 컴포넌트를 만들어 보세요.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()를 부르면 context가 null이라 “useAuth는 AuthProvider 안에서만 사용할 수 있습니다” 에러가 던져집니다. 이 가드는 Provider를 빼먹은 실수를 개발 중에 바로 드러냅니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 로그인해도 헤더가 안 바뀜 | token만 바뀌고 user state는 안 바뀜 | login 함수 안에서 반드시 setUser를 호출한다 |
| 로그인 성공 후에도 이전 페이지의 에러 메시지가 남음 | handleSubmit에서 setError('') 초기화를 생략 | 매 제출 시작 시 에러 상태를 먼저 비운다 |
useAuth 호출 시 에러 | AuthProvider로 감싸지 않은 컴포넌트 트리에서 사용 | main.jsx의 Provider 순서와 위치를 확인한다 |