Security 백엔드📚 전체 맵Security 전체흐름
05d · 3차 Spring Security · React 프론트엔드 코드 해부

REACT FRONTEND

App.jsx의 AdminRoute부터 Login의 role 저장, Register의 adminCode, BoardList의 조건부 렌더링, 신규 AdminPage.jsx까지 — 백엔드와 어떻게 연결되는지 함께 설명한다.

이 파일의 학습 계획
05d에서 다루는 것 — React 파일 순서와 이유
05a개요 & 기술스택 ✓
05bFilter Chain & 동작 원리 ✓
05c백엔드 코드 전체 해부 ✓
05d ← 지금React 프론트 코드 해부
05e전체 흐름 + 비교 완결
📋 05d 코드 해부 순서
  1. api/api.js — axios 인스턴스 + interceptor (변경 없지만 동작 원리 재확인)
  2. App.jsx — PrivateRoute + AdminRoute 추가 (라우팅 권한 체계)
  3. Login.jsx — role 저장 추가 (백엔드가 role을 같이 반환하게 됐으니)
  4. Register.jsx — adminCode 입력 필드 추가
  5. BoardList.jsx — 관리자 메뉴 조건부 렌더링 + 로그아웃 시 role 삭제
  6. AdminPage.jsx — 신규, 탭 전환 관리자 화면 전체 해부

React 핵심 개념 복습 포인트: useState, useEffect, useNavigate, useParams, 조건부 렌더링, 컴포넌트 분리 이유

API Layer
api/api.js — axios 공통 설정 (변경 없음)
유지
src/api/api.js
api.js — 변경 없음, 동작 원리 재확인
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 개념

interceptor = 가로채기. 요청이 서버로 나가기 전, 또는 응답이 컴포넌트에 도달하기 전에 중간에서 가로채서 공통 처리를 한다. 모든 API 호출에 헤더를 추가하거나, 모든 401 응답에 로그아웃 처리를 하는 것을 각 컴포넌트에서 반복하지 않고 여기 한 곳에서만 처리한다.

Routing
App.jsx — PrivateRoute + AdminRoute
수정
src/App.jsx
App.jsx — AdminRoute 추가 부분 중심으로
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 직접 호출로 우회 가능하므로 반드시 백엔드에서도 막아야 한다.

Login
Login.jsx — role 저장 추가
수정
src/pages/Login.jsx
Login.jsx — 변경 부분 중심
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;
Register
Register.jsx — adminCode 입력 필드 추가
수정
src/pages/Register.jsx
Register.jsx — adminCode 추가 부분
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>
    );
}
BoardList
BoardList.jsx — 관리자 메뉴 조건부 렌더링 + 로그아웃
수정
src/pages/BoardList.jsx
BoardList.jsx — 변경 부분
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으로 남아있어서 관리자 링크가 보임 (보안 취약점)
};
Admin Page — 신규
AdminPage.jsx — 탭 전환 관리자 화면 전체 해부
신규
src/pages/AdminPage.jsx
AdminPage.jsx — 전체 코드 + 해부
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 핵심 패턴 정리 — AdminPage에서 쓰인 것들
  • useState([]): 빈 배열로 초기화 → API 응답으로 채움 → 리렌더링
  • useEffect(() → {}, []): 마운트 시 1회 실행 → 초기 데이터 로드
  • async/await: API 호출은 비동기 → await로 완료 대기
  • && 조건부 렌더링: 탭 전환, 삭제 버튼 숨김 등에 사용
  • .map(): 배열 → JSX 목록 변환, key 필수
  • onClick={() => fn(param)}: 파라미터 있는 이벤트 핸들러는 화살표 함수로 감쌈
✅ 05d 완료 — 다음으로 넘어가기

React 프론트엔드 전체 코드 해부 완료. api.js interceptor, App의 AdminRoute, Login의 role 저장, Register의 adminCode, BoardList의 조건부 렌더링, AdminPage 신규 구현까지 완료.

→ 06e_security_full_flow.html
마지막. 실제 요청이 들어왔을 때 React → Filter → SecurityConfig → Controller → Service 전체 흐름을 시나리오별로 따라가고, 1차~3차 전체 비교표로 마무리한다.

Security 백엔드📚 전체 맵Security 전체흐름