App.jsx의 AdminRoute부터 Login의 role 저장, Register의 adminCode, BoardList의 조건부 렌더링, 신규 AdminPage.jsx까지 — 백엔드와 어떻게 연결되는지 함께 설명한다.
React 핵심 개념 복습 포인트: useState, useEffect, useNavigate, useParams, 조건부 렌더링, 컴포넌트 분리 이유
import axios from 'axios'; const api = axios.create({ baseURL: 'http://localhost:8083', // 3차는 포트 8083 // 모든 API 요청의 기본 URL // api.get('/api/board/list') → 실제로는 http://localhost:8083/api/board/list }); // 요청 interceptor: 요청이 서버로 나가기 직전에 실행 api.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; // 토큰이 있으면 모든 요청에 Authorization 헤더 자동 추가 // Spring Security의 JwtAuthenticationFilter가 이 헤더를 읽음 // 개발자가 각 API 호출마다 헤더 추가할 필요 없음 } return config; // 수정된 config 반환 (반드시 return!) }); // 응답 interceptor: 서버 응답이 올 때 실행 api.interceptors.response.use( response => response, // 성공 응답은 그대로 통과 error => { if (error.response?.status === 401) { // 401 Unauthorized: 토큰 만료 또는 미인증 // Spring Security가 인증 실패 시 자동으로 401 반환 localStorage.removeItem('token'); localStorage.removeItem('role'); // 3차에서는 role도 제거 window.location.href = '/login'; } return Promise.reject(error); // 에러를 호출한 곳으로 전달 } ); export default api;
interceptor = 가로채기. 요청이 서버로 나가기 전, 또는 응답이 컴포넌트에 도달하기 전에 중간에서 가로채서 공통 처리를 한다. 모든 API 호출에 헤더를 추가하거나, 모든 401 응답에 로그아웃 처리를 하는 것을 각 컴포넌트에서 반복하지 않고 여기 한 곳에서만 처리한다.
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import AdminPage from './pages/AdminPage'; // 신규 import // 2차에서 있던 컴포넌트 — 변경 없음 const PrivateRoute = ({ children }) => { const token = localStorage.getItem('token'); return token ? children : <Navigate to="/login" />; // token 있으면: 자식 컴포넌트 렌더링 (children = WrappedComponent) // token 없으면: /login으로 리다이렉트 // 삼항 연산자: 조건 ? 참일때 : 거짓일때 }; // 3차에서 추가 — 관리자 전용 라우트 ↓ const AdminRoute = ({ children }) => { const role = localStorage.getItem('role'); // Login.jsx에서 저장한 role 값 읽기 // "ROLE_ADMIN" 또는 "ROLE_USER" return role === 'ROLE_ADMIN' ? children : <Navigate to="/board/list" />; // ROLE_ADMIN이면: 관리자 페이지 렌더링 // 아니면: 게시판 목록으로 리다이렉트 (401이 아닌 목록으로 보내는 것이 UX상 나음) // 이중 보안: 백엔드(SecurityConfig)가 API를 막고, 프론트(AdminRoute)가 화면을 막음 }; function App() { return ( <BrowserRouter> <Routes> <Route path="/login" element={<Login />} /> <Route path="/register" element={<Register />} /> <Route path="/board/list" element={<PrivateRoute><BoardList /></PrivateRoute>} /> <Route path="/board/write" element={<PrivateRoute><BoardWrite /></PrivateRoute>} /> <Route path="/board/detail/:id" element={<PrivateRoute><BoardDetail /></PrivateRoute>} /> <Route path="/board/edit/:id" element={<PrivateRoute><BoardEdit /></PrivateRoute>} /> // 3차에서 추가 — AdminRoute로 감싼 AdminPage ↓ <Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} /> // AdminRoute: role === 'ROLE_ADMIN' 체크 // AdminPage: 관리자 화면 컴포넌트 // 비로그인 → PrivateRoute(없음)지만 어차피 API가 막힘 // 일반 유저 → AdminRoute에서 /board/list로 redirect <Route path="/" element={<Navigate to="/login" />} /> </Routes> </BrowserRouter> ); } export default App;
백엔드(SecurityConfig): /api/admin/**을 hasRole("ADMIN")으로 막음 → 실제 데이터 보호
프론트(AdminRoute): /admin 라우트를 ROLE_ADMIN만 접근 → 화면 접근 제한
백엔드만 막아도 데이터는 안전하지만, 일반 유저가 /admin URL로 직접 접근하면 빈 화면이나 에러가 보인다.
프론트에서도 막아야 사용자 경험이 자연스럽다.
반대로 프론트만 막으면 API 직접 호출로 우회 가능하므로 반드시 백엔드에서도 막아야 한다.
import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import api from '../api/api'; function Login() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const navigate = useNavigate(); const handleLogin = async () => { try { const response = await api.post('/api/user/login', { username, password }); // UserController → UserService.login() 호출 // 응답: { token: "eyJ...", role: "ROLE_USER" } localStorage.setItem('token', response.data.token); // 2차와 동일: JWT 토큰 저장 localStorage.setItem('role', response.data.role); // 3차에서 추가: role 저장 // "ROLE_USER" 또는 "ROLE_ADMIN" // AdminRoute, BoardList의 관리자 메뉴 표시에 사용 // 로그아웃 시 제거 필요 (BoardList에서 처리) navigate('/board/list'); } catch (e) { setError(e.response?.data?.message || '로그인 실패'); // e.response?.data?.message: 옵셔널 체이닝 // e.response가 없거나 data가 없으면 undefined → || 오른쪽 기본값 사용 // Spring이 반환한 { message: "없는 아이디입니다." } 등의 메시지 표시 } }; return ( <div> <input type="text" value={username} onChange={e => setUsername(e.target.value)} /> <input type="password" value={password} onChange={e => setPassword(e.target.value)} /> {error && <p style={{color:'red'}}>{error}</p>} // error가 빈 문자열('')이면 && 오른쪽 렌더링 안 됨 (조건부 렌더링) <button onClick={handleLogin}>로그인</button> </div> ); } export default Login;
function Register() { const [form, setForm] = useState({ username: '', password: '', nickname: '', adminCode: '' // 3차에서 추가 — 비워두면 ROLE_USER, "ADMIN2024" 입력하면 ROLE_ADMIN }); const handleChange = e => { setForm({ ...form, [e.target.name]: e.target.value }); // 스프레드 연산자 (...): 기존 form 객체를 복사 // [e.target.name]: 동적 키 — 어떤 input이든 name 속성으로 자동 매핑 // e.g. name="adminCode"가 변경되면 form.adminCode만 업데이트 // 이 하나의 핸들러로 모든 input 처리 가능 }; const handleRegister = async () => { try { await api.post('/api/user/register', form); // form 객체 전체 전송: { username, password, nickname, adminCode } // Spring의 @RequestBody UserDto에 매핑됨 // UserService에서 adminCode === "ADMIN2024" 이면 ROLE_ADMIN 부여 navigate('/login'); } catch (e) { setError(e.response?.data?.message || '회원가입 실패'); } }; return ( <div> <input type="text" name="username" placeholder="아이디" value={form.username} onChange={handleChange} /> <input type="password" name="password" placeholder="비밀번호" value={form.password} onChange={handleChange} /> <input type="text" name="nickname" placeholder="닉네임" value={form.nickname} onChange={handleChange} /> // 3차에서 추가된 관리자 코드 입력란 ↓ <input type="text" name="adminCode" placeholder="관리자 코드 (관리자만 입력)" value={form.adminCode} onChange={handleChange} /> // name="adminCode" → handleChange에서 form.adminCode 업데이트 // 일반 유저는 비워두면 됨 → UserService에서 ROLE_USER로 처리 // 실제 운영에서는 이 필드를 UI에 노출하지 않고 // 별도 관리자 가입 페이지나 DB 직접 수정 방식을 쓰기도 함 <button onClick={handleRegister}>회원가입</button> </div> ); }
const role = localStorage.getItem('role'); // Login.jsx에서 저장한 role 읽기 // 컴포넌트 렌더링 시마다 localStorage에서 최신값 읽음 return ( <div> // ... 게시글 목록 ... // 관리자일 때만 관리자 페이지 링크 표시 ↓ {role === 'ROLE_ADMIN' && ( <Link to="/admin" style={{ color: 'red' }}>관리자 페이지</Link> )} // && 연산자 조건부 렌더링: // role === 'ROLE_ADMIN' 이 true → 오른쪽 JSX 렌더링 // false → null (렌더링 안 됨) // Link: react-router-dom의 클라이언트 사이드 링크 (페이지 새로고침 없음) <button onClick={handleLogout}>로그아웃</button> </div> ); const handleLogout = () => { localStorage.removeItem('token'); localStorage.removeItem('role'); // 3차에서 추가 — role도 제거 navigate('/login'); // role을 제거하지 않으면: // 로그아웃 후 다른 사람이 같은 브라우저로 일반 유저로 로그인해도 // role이 ROLE_ADMIN으로 남아있어서 관리자 링크가 보임 (보안 취약점) };
import { useState, useEffect } from 'react'; import api from '../api/api'; function AdminPage() { const [users, setUsers] = useState([]); const [boards, setBoards] = useState([]); const [activeTab, setActiveTab] = useState('users'); // useState([]): 초기값 빈 배열 // useState('users'): 현재 탭, 'users' 또는 'boards' // 탭 전환 시 setActiveTab('boards') 호출 → 리렌더링 useEffect(() => { fetchUsers(); fetchBoards(); }, []); // useEffect(콜백, 의존성배열) // 의존성 배열이 [] (빈 배열): 컴포넌트가 처음 마운트될 때 한 번만 실행 // 컴포넌트가 화면에 표시되면 유저목록과 게시글목록을 각각 API로 가져옴 const fetchUsers = async () => { const res = await api.get('/api/admin/users'); setUsers(res.data); // api.js의 interceptor가 Authorization 헤더 자동 추가 // SecurityConfig: /api/admin/** → hasRole("ADMIN") 체크 // ROLE_ADMIN이면 AdminController.getAllUsers() 실행 // 반환: List→ JSON 배열 → res.data }; const fetchBoards = async () => { const res = await api.get('/api/admin/boards'); setBoards(res.data); }; const handleDeleteUser = async (userId) => { if (!window.confirm('유저를 삭제하시겠습니까?')) return; // window.confirm(): 브라우저 확인 대화상자 // OK → true, 취소 → false // !confirm() 이 true (취소)이면 return으로 함수 종료 await api.delete(`/api/admin/users/${userId}`); // 템플릿 리터럴로 URL에 userId 삽입 // AdminController.deleteUser(userId) 호출 // AdminService: 게시글 먼저 삭제 → 유저 삭제 (트랜잭션) fetchUsers(); // 삭제 후 목록 새로고침 — fetchUsers() 재호출 → API 재요청 → setUsers() → 리렌더링 }; const handleDeleteBoard = async (boardId) => { if (!window.confirm('게시글을 삭제하시겠습니까?')) return; await api.delete(`/api/admin/boards/${boardId}`); fetchBoards(); }; return ( <div> <h1>관리자 페이지</h1> // 탭 버튼 ↓ <div> <button onClick={() => setActiveTab('users')}>유저 관리</button> <button onClick={() => setActiveTab('boards')}>게시글 관리</button> </div> // 탭 내용 조건부 렌더링 ↓ {activeTab === 'users' && ( <table> <thead><tr><th>아이디</th><th>닉네임</th><th>역할</th><th>삭제</th></tr></thead> <tbody> {users.map(user => ( <tr key={user.id}> // key: React가 목록 렌더링 시 각 항목을 구분하기 위해 사용 // 고유값(id)이어야 함 — 없으면 경고, 재렌더링 성능 저하 <td>{user.username}</td> <td>{user.nickname}</td> <td>{user.role}</td> <td> {user.role !== 'ROLE_ADMIN' && ( <button onClick={() => handleDeleteUser(user.id)}> 삭제 </button> )} // 관리자(ROLE_ADMIN)는 삭제 버튼 표시 안 함 // 실수로 관리자를 삭제하는 것 방지 </td> </tr> ))} </tbody> </table> )} {activeTab === 'boards' && ( <table> <thead><tr><th>제목</th><th>작성자</th><th>조회수</th><th>삭제</th></tr></thead> <tbody> {boards.map(board => ( <tr key={board.id}> <td>{board.title}</td> <td>{board.nickname}</td> <td>{board.viewCount}</td> <td> <button onClick={() => handleDeleteBoard(board.id)}> 삭제 </button> </td> </tr> ))} </tbody> </table> )} </div> ); } export default AdminPage;
React 프론트엔드 전체 코드 해부 완료. api.js interceptor, App의 AdminRoute, Login의 role 저장, Register의 adminCode, BoardList의 조건부 렌더링, AdminPage 신규 구현까지 완료.
→ 06e_security_full_flow.html
마지막. 실제 요청이 들어왔을 때 React → Filter → SecurityConfig → Controller → Service 전체 흐름을 시나리오별로 따라가고, 1차~3차 전체 비교표로 마무리한다.