2차 프로젝트📚 전체 맵심화 커리큘럼
3차 프로젝트 · Spring Security

03. SPRING SECURITY

Spring Security로 인증/권한 체계화 — 관리자 기능 추가, Filter Chain 통합

이 파일의 학습 목적
왜 2차에서 3차로 바뀌었나?

2차의 JwtFilter는 직접 만든 임시방편이었다. 3차는 Spring Security라는 표준 보안 프레임워크를 도입해서 인증·권한·CORS를 체계적으로 관리한다. 코드는 줄고, 보안은 강해진다.

📍 전체 프로젝트 진화 흐름 — 지금 여기
✅ 완료 · 1차
Thymeleaf + Session
서버 렌더링
HttpSession 인증
인증 코드 반복
✅ 완료 · 2차
React + JWT
React 분리
JwtFilter 수동 등록
권한 관리 없음
📌 지금 여기 · 3차
Spring Security
Security FilterChain
ROLE 기반 권한 제어
@AuthenticationPrincipal
→ 보안 체계화!
❌ 2차의 문제점 (해결 대상)
JwtFilter를 CorsConfig에 수동 등록 — 비표준 방식
request.getAttribute("loginUser") 반복 — 지저분함
역할(ROLE) 기반 권한 관리 없음 — 누구나 모든 API 접근 가능
✅ 3차의 해결책
SecurityConfig에서 FilterChain 통합 관리
@AuthenticationPrincipal로 자동 주입 — 코드 단순화
hasRole("ADMIN")으로 URL별 권한 한 줄로 설정
Overview
2차와 무엇이 달라졌나
항목2차 (JWT 직접 구현)3차 (Spring Security)
필터 방식JwtFilter 수동 등록 (CorsConfig)JwtAuthenticationFilter → Security Filter Chain 통합
로그인 사용자 가져오기request.getAttribute("loginUser")@AuthenticationPrincipal CustomUserDetails
권한 관리없음ROLE_USER / ROLE_ADMIN — SecurityConfig에서 URL별 제어
관리자 기능없음AdminController + AdminService + AdminPage.jsx 추가
인증 정보 저장request.setAttribute()SecurityContextHolder (Spring Security 표준)
포트백엔드 8082 / 프론트 3001백엔드 8083 / 프론트 3002
💡 Spring Security Filter Chain이란?

HTTP 요청이 Controller에 도달하기 전에 거치는 보안 필터들의 연속이다. Spring Security는 기본으로 수십 개의 필터를 제공하고, 우리는 그 중간에 JwtAuthenticationFilter를 끼워넣는다.

2차 방식 (임시방편) — JwtFilter를 직접 만들어서 CorsConfig의 FilterRegistrationBean으로 수동 등록. Spring Security와 무관하게 따로 동작.

3차 방식 (표준) — SecurityConfig의 addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)로 Security FilterChain 안에 통합. 토큰 검증 후 SecurityContextHolder에 인증 정보 저장 → Spring Security가 모든 권한 체크를 자동으로 처리.

Flow — Login
로그인 전체 흐름
React → Security Filter Chain → Controller → JWT 발급 (token + role)
React
Login.jsx — api.post("/api/user/login")
username, password JSON 전송 (2차와 동일)
Security
SecurityConfig — permitAll() 확인
/api/user/login은 permitAll()이므로 인증 없이 통과
.requestMatchers("/api/user/login").permitAll()
Filter
JwtAuthenticationFilter — 토큰 없으므로 통과
Authorization 헤더 없음 → filterChain.doFilter() 그냥 통과
JwtAuthenticationFilter.doFilterInternal()
Controller
UserController.login()
userService.login() 호출 → Map(token, role) 반환
ResponseEntity.ok(Map.of("token", token, "role", role))
React
localStorage에 token + role 저장
2차와 달리 role도 함께 저장 → 관리자 메뉴 표시에 사용
localStorage.setItem("token", ...) localStorage.setItem("role", ...)
게시글 작성 전체 흐름 — @AuthenticationPrincipal
SecurityContextHolder에 인증 정보 저장 → @AuthenticationPrincipal로 자동 주입
React
api.post("/api/board/write") + Authorization 헤더
api.js interceptor가 토큰 자동 첨부 (2차와 동일)
Filter
JwtAuthenticationFilter — 토큰 검증 → SecurityContext 저장
토큰 추출 → CustomUserDetailsService.loadUserByUsername() 호출 → UsernamePasswordAuthenticationToken 생성 → SecurityContextHolder 저장
SecurityContextHolder.getContext().setAuthentication(authentication)
Security
SecurityConfig — authenticated() 확인
SecurityContext에 인증 정보 있음 → 통과
.anyRequest().authenticated()
Controller
BoardController.write() — @AuthenticationPrincipal
2차: request.getAttribute("loginUser") 직접 꺼냄
3차: Spring이 자동으로 주입 — 훨씬 깔끔!
@AuthenticationPrincipal CustomUserDetails userDetails
Service
BoardService.write() → DB 저장
userDetails.getUser()로 User 객체 꺼냄 → board.setUser(user)
관리자 유저 삭제 흐름 — 역할 기반 접근 제어
hasRole("ADMIN") — Security가 자동으로 권한 체크
React
AdminPage.jsx — api.delete("/api/admin/users/{id}")
관리자 페이지에서 유저 삭제 버튼 클릭
Security
SecurityConfig — hasRole("ADMIN") 확인
ROLE_ADMIN이 아니면 403 Forbidden 자동 반환 — Controller까지 안 옴
.requestMatchers("/api/admin/**").hasRole("ADMIN")
Controller
AdminController.deleteUser()
adminService.deleteUser(userId) 호출
Service
AdminService.deleteUser() — 게시글 먼저 삭제 후 유저 삭제
FK 제약 때문에 게시글 먼저 삭제, @Transactional로 원자성 보장
boardRepository.deleteByUser(user) userRepository.delete(user)
Project Structure
3차 프로젝트 파일 구조 — 2차 대비 추가/변경

2차에서 util/JwtFilter.javaconfig/CorsConfig.java가 하던 역할을 security/ 폴더와 config/SecurityConfig.java가 대체한다.

📁 변경된 파일 구조
project-root/
├─ build.gradle ← spring-boot-starter-security 추가
│
├─ src/main/java/com/example/
│  ├─ entity/
│  │  └─ User.java ← role 필드 추가
│  ├─ repository/
│  │  └─ BoardRepository.java ← deleteByUser() 추가
│  │
│  ├─ security/ ← 신규 폴더
│  │  ├─ CustomUserDetails.java
│  │  ├─ CustomUserDetailsService.java
│  │  └─ JwtAuthenticationFilter.java
│  │
│  ├─ config/
│  │  ├─ SecurityConfig.java ← 신규 (CORS + FilterChain)
│  │  └─ CorsConfig.java ← 삭제 (SecurityConfig로 통합)
│  │
│  ├─ util/
│  │  ├─ JwtUtil.java ← role 파라미터 추가
│  │  └─ JwtFilter.java ← 삭제 (JwtAuthenticationFilter로 대체)
│  │
│  ├─ service/
│  │  ├─ UserService.java ← role, adminCode 추가
│  │  ├─ BoardService.java (동일)
│  │  └─ AdminService.java ← 신규
│  └─ controller/
│     ├─ BoardController.java ← @AuthenticationPrincipal
│     ├─ UserController.java (동일)
│     └─ AdminController.java ← 신규
🔑 3차에서 핵심적으로 이해해야 할 개념 4가지
① UserDetails / CustomUserDetails — 왜 래퍼가 필요한가

Spring Security는 인증 처리 시 UserDetails 인터페이스만 다룬다. 우리가 만든 User Entity는 Security가 모른다. 그래서 CustomUserDetailsUser를 감싸서 Security가 이해하는 형태로 변환해준다.
getUser() 메서드로 원본 Entity를 꺼낼 수 있어서 Controller에서 userDetails.getUser()로 User 객체를 바로 사용한다.

② SecurityContextHolder — 인증 정보의 스레드 저장소

Spring은 HTTP 요청 하나당 스레드 하나를 배정한다. SecurityContextHolder현재 스레드에 인증 정보를 보관하는 저장소다.
JwtAuthenticationFilter에서 setAuthentication(authentication)으로 저장하면, 같은 요청을 처리하는 Controller에서 @AuthenticationPrincipal로 꺼낼 수 있다. 요청이 끝나면 자동으로 비워진다.

③ CSRF disable + STATELESS — 왜 꺼야 하나

CSRF disable — CSRF 공격은 브라우저가 자동으로 쿠키를 보내는 방식을 이용한다. JWT는 localStorage에 저장하고 직접 헤더에 첨부하므로 CSRF 위협이 없다. 그래서 꺼도 된다.
STATELESS — Spring Security는 기본적으로 세션을 만든다. JWT는 서버에 상태를 저장하지 않으므로 세션이 필요 없다. STATELESS로 설정하면 Spring이 세션을 만들지 않는다.

④ addFilterBefore — 필터 순서 지정

addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
Security의 기본 로그인 필터(UsernamePasswordAuthenticationFilter) 앞에 JwtAuthenticationFilter를 끼워넣는다는 뜻이다. JWT 검증이 먼저 실행되어야 SecurityContext에 인증 정보가 들어가고, 그 다음 권한 체크가 이루어질 수 있기 때문이다.

Full Code — 변경/추가 파일
전체 코드 (2차 대비 변경/추가된 파일)
build.gradle 수정
build.gradle
dependencies {
    implementation "org.springframework.boot:spring-boot-starter-data-jpa"
    implementation "org.springframework.boot:spring-boot-starter-webmvc"
    // ↓ Spring Security 추가 (spring-security-crypto는 제거 — Security에 포함됨)
    implementation "org.springframework.boot:spring-boot-starter-security"
    implementation "io.jsonwebtoken:jjwt-api:0.12.3"
    runtimeOnly "io.jsonwebtoken:jjwt-impl:0.12.3"
    runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.12.3"
    compileOnly "org.projectlombok:lombok"
    runtimeOnly "org.mariadb.jdbc:mariadb-java-client"
    annotationProcessor "org.projectlombok:lombok"
    testImplementation "org.springframework.boot:spring-boot-starter-test"
    testImplementation "org.springframework.boot:spring-boot-starter-security"
}
application.properties 수정
application.properties
spring.datasource.url=jdbc:mariadb://localhost:3306/boarddb
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
jwt.secret=mySecretKeyForJWTTokenGenerationAndValidation2024
jwt.expiration=86400000

# 관리자 코드 추가
admin.code=ADMIN2024
entity/User.java 수정 — role 필드 추가
entity/User.java
@Entity @Table(name = "users")
@Getter @Setter @NoArgsConstructor
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false) private String username;
    @Column(nullable = false) private String password;
    @Column(unique = true, nullable = false) private String nickname;

    @Column(nullable = false)
    private String role;  // "ROLE_USER" 또는 "ROLE_ADMIN" ← 추가!

    private LocalDateTime createdAt;

    @PrePersist
    public void prePersist() {
        this.createdAt = LocalDateTime.now();
        if (this.role == null) this.role = "ROLE_USER"; // 기본값
    }
}
security/CustomUserDetails.java 신규
security/CustomUserDetails.java
@Getter @RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
    private final User user; // 우리가 만든 User Entity를 감싸는 래퍼

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // ROLE_USER 또는 ROLE_ADMIN을 Spring Security 권한으로 변환
        return List.of(new SimpleGrantedAuthority(user.getRole()));
    }

    @Override public String getPassword() { return user.getPassword(); }
    @Override public String getUsername() { return user.getUsername(); }
    @Override public boolean isAccountNonExpired() { return true; }
    @Override public boolean isAccountNonLocked() { return true; }
    @Override public boolean isCredentialsNonExpired() { return true; }
    @Override public boolean isEnabled() { return true; }
}
💡 isAccountNonExpired / isAccountNonLocked 등 — 왜 전부 true?

Spring Security의 UserDetails 인터페이스는 계정 만료, 잠금, 비밀번호 만료, 활성화 여부를 각각 메서드로 제공한다. 우리 프로젝트는 이런 세밀한 계정 관리를 구현하지 않으므로 전부 true로 반환해서 항상 정상 계정으로 취급한다. 실무에서는 DB의 계정 상태 컬럼을 보고 동적으로 반환한다.

security/CustomUserDetailsService.java 신규
security/CustomUserDetailsService.java
@Service @RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() ->
                new UsernameNotFoundException("없는 유저입니다: " + username));
        return new CustomUserDetails(user); // UserDetails로 감싸서 반환
    }
}
security/JwtAuthenticationFilter.java 신규
security/JwtAuthenticationFilter.java
@Component @RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            if (jwtUtil.validateToken(token)) {
                String username = jwtUtil.getUsername(token);
                UserDetails userDetails =
                    customUserDetailsService.loadUserByUsername(username);

                // 2차 JwtFilter와의 차이: request.setAttribute 대신 SecurityContext에 저장
                UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities()
                    );
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        filterChain.doFilter(request, response);
    }
}
💡 2차 JwtFilter vs 3차 JwtAuthenticationFilter — 무엇이 다른가

2차 JwtFilter — 토큰 검증 후 request.setAttribute("loginUser", user)로 저장. Controller에서 직접 꺼내야 함. Spring Security와 완전히 무관하게 동작.

3차 JwtAuthenticationFilter — 토큰 검증 후 UsernamePasswordAuthenticationToken을 만들어서 SecurityContextHolder에 저장. 이 한 줄 덕분에 Spring Security 전체 기능(권한 체크, @AuthenticationPrincipal 주입 등)을 쓸 수 있게 된다.

new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities())
→ 첫 번째 인자: 인증 주체 (CustomUserDetails)
→ 두 번째 인자: credentials (비밀번호 — JWT 방식에서는 null)
→ 세 번째 인자: 권한 목록 (ROLE_USER / ROLE_ADMIN)

config/SecurityConfig.java 신규
config/SecurityConfig.java
@Configuration @EnableWebSecurity @RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/user/login", "/api/user/register")
                    .permitAll()              // 누구나
                .requestMatchers("/api/admin/**")
                    .hasRole("ADMIN")         // ROLE_ADMIN만
                .anyRequest().authenticated() // 나머지는 로그인 필요
            )
            .addFilterBefore(jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:3000"));
        config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","PATCH"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}
util/JwtUtil.java 수정 — role 추가
util/JwtUtil.java
// createToken() — role 파라미터 추가
public String createToken(String username, String nickname, String role) {
    return Jwts.builder()
        .subject(username)
        .claim("nickname", nickname)
        .claim("role", role)  // role 추가!
        .issuedAt(new Date())
        .expiration(new Date(System.currentTimeMillis() + expiration))
        .signWith(getSigningKey())
        .compact();
}

// getRole() 추가
public String getRole(String token) {
    return getClaims(token).get("role", String.class);
}
// getUsername, validateToken, getClaims 는 2차와 동일
service/UserService.java 수정 — role, adminCode
service/UserService.java
@Service @RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final JwtUtil jwtUtil;

    @Value("${admin.code}")
    private String adminCode;

    public void register(UserDto userDto) {
        if (userRepository.existsByUsername(userDto.getUsername()))
            throw new IllegalArgumentException("이미 사용중인 아이디입니다.");
        if (userRepository.existsByNickname(userDto.getNickname()))
            throw new IllegalArgumentException("이미 사용중인 닉네임입니다.");

        User user = new User();
        user.setUsername(userDto.getUsername());
        user.setPassword(bCryptPasswordEncoder.encode(userDto.getPassword()));
        user.setNickname(userDto.getNickname());
        // 관리자 코드 확인 후 role 부여
        if (adminCode.equals(userDto.getAdminCode()))
            user.setRole("ROLE_ADMIN");
        else
            user.setRole("ROLE_USER");
        userRepository.save(user);
    }

    // 반환타입 변경: String → Map<String, String> (role 포함)
    public Map<String, String> login(String username, String password) {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 틀렸습니다."));
        if (!bCryptPasswordEncoder.matches(password, user.getPassword()))
            throw new IllegalArgumentException("아이디 또는 비밀번호가 틀렸습니다.");
        String token = jwtUtil.createToken(
            user.getUsername(), user.getNickname(), user.getRole()
        );
        return Map.of("token", token, "role", user.getRole());
    }
}
controller/BoardController.java 수정 — @AuthenticationPrincipal
controller/BoardController.java
@RestController @RequiredArgsConstructor
@RequestMapping("/api/board")
public class BoardController {
    private final BoardService boardService;

    @GetMapping("/list")
    public ResponseEntity<?> list(@RequestParam(defaultValue="0") int page) {
        return ResponseEntity.ok(boardService.getBoardList(page));
    }

    // 2차: User loginUser = (User) request.getAttribute("loginUser")
    // 3차: @AuthenticationPrincipal 로 Spring이 자동 주입!
    @PostMapping("/write")
    public ResponseEntity<?> write(
            @RequestBody BoardDto boardDto,
            @AuthenticationPrincipal CustomUserDetails userDetails) {
        boardService.write(boardDto, userDetails.getUser());
        return ResponseEntity.ok(Map.of("message", "글쓰기 성공"));
    }

    @PutMapping("/edit/{id}")
    public ResponseEntity<?> edit(
            @PathVariable Long id,
            @RequestBody BoardDto boardDto,
            @AuthenticationPrincipal CustomUserDetails userDetails) {
        boardService.update(id, boardDto, userDetails.getUser());
        return ResponseEntity.ok(Map.of("message", "수정 성공"));
    }

    @DeleteMapping("/delete/{id}")
    public ResponseEntity<?> delete(
            @PathVariable Long id,
            @AuthenticationPrincipal CustomUserDetails userDetails) {
        boardService.delete(id, userDetails.getUser());
        return ResponseEntity.ok(Map.of("message", "삭제 성공"));
    }

    @GetMapping("/detail/{id}")
    public ResponseEntity<?> detail(
            @PathVariable Long id,
            @AuthenticationPrincipal CustomUserDetails userDetails) {
        return ResponseEntity.ok(boardService.getDetail(id, userDetails.getUser()));
    }
}
controller/AdminController.java 신규
controller/AdminController.java
@RestController @RequiredArgsConstructor
@RequestMapping("/api/admin") // SecurityConfig에서 ROLE_ADMIN만 접근 허용
public class AdminController {
    private final AdminService adminService;

    @GetMapping("/users")
    public ResponseEntity<List<UserDto>> getAllUsers() {
        return ResponseEntity.ok(adminService.getAllUsers());
    }

    @DeleteMapping("/users/{userId}")
    public ResponseEntity<?> deleteUser(@PathVariable Long userId) {
        adminService.deleteUser(userId);
        return ResponseEntity.ok(Map.of("message", "유저 삭제 성공"));
    }

    @GetMapping("/boards")
    public ResponseEntity<List<BoardDto>> getAllBoards() {
        return ResponseEntity.ok(adminService.getAllBoards());
    }

    @DeleteMapping("/boards/{boardId}")
    public ResponseEntity<?> deleteBoard(@PathVariable Long boardId) {
        adminService.deleteBoard(boardId);
        return ResponseEntity.ok(Map.of("message", "게시글 삭제 성공"));
    }
}
service/AdminService.java 신규
service/AdminService.java
@Service @RequiredArgsConstructor
public class AdminService {
    private final UserRepository userRepository;
    private final BoardRepository boardRepository;

    public List<UserDto> getAllUsers() {
        return userRepository.findAll().stream().map(user -> {
            UserDto dto = new UserDto();
            dto.setId(user.getId());
            dto.setUsername(user.getUsername());
            dto.setNickname(user.getNickname());
            dto.setRole(user.getRole());
            return dto;
        }).collect(Collectors.toList());
    }

    @Transactional
    public void deleteUser(Long userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new IllegalArgumentException("없는 유저입니다."));
        boardRepository.deleteByUser(user); // FK 제약 → 게시글 먼저 삭제
        userRepository.delete(user);
    }

    public List<BoardDto> getAllBoards() {
        return boardRepository.findAll().stream().map(board -> {
            BoardDto dto = new BoardDto();
            dto.setId(board.getId());
            dto.setTitle(board.getTitle());
            dto.setNickname(board.getUser().getNickname());
            dto.setViewCount(board.getViewCount());
            return dto;
        }).collect(Collectors.toList());
    }

    @Transactional
    public void deleteBoard(Long boardId) {
        Board board = boardRepository.findById(boardId)
            .orElseThrow(() -> new IllegalArgumentException("없는 게시글입니다."));
        boardRepository.delete(board);
    }
}
repository/BoardRepository.java 수정 — deleteByUser 추가
repository/BoardRepository.java
public interface BoardRepository extends JpaRepository<Board, Long> {
    Page<Board> findAllByOrderByCreatedAtDesc(Pageable pageable);
    void deleteByUser(User user); // 추가! — 유저 삭제 시 게시글 먼저 삭제
}
💡 deleteByUser() — JPA 쿼리 메서드 자동 생성

JPA는 메서드 이름 규칙만 지키면 쿼리를 자동으로 만들어준다.
deleteByUser(User user)DELETE FROM board WHERE user_id = ?

왜 게시글을 먼저 삭제해야 하나?
Board 테이블에는 user_id FK(외래 키)가 있다. 유저를 먼저 지우면 참조 무결성 제약 위반으로 DB 오류가 발생한다. 그래서 AdminService.deleteUser()에서 boardRepository.deleteByUser(user)를 먼저 호출하고 그 다음 userRepository.delete(user)를 호출한다.
@Transactional로 묶어서 두 작업이 하나의 트랜잭션으로 처리된다 — 중간에 실패하면 전체 롤백.

프론트엔드 — 변경/추가된 핵심 파일
src/App.jsx — AdminRoute 추가
// PrivateRoute는 2차와 동일
const PrivateRoute = ({ children }) => {
    const token = localStorage.getItem("token");
    return token ? children : <Navigate to="/login" />;
};

// 관리자 전용 Route 추가
const AdminRoute = ({ children }) => {
    const role = localStorage.getItem("role");
    return role === "ROLE_ADMIN" ? children : <Navigate to="/board/list" />;
};

// 라우트에 추가
<Route path="/admin"
    element={<AdminRoute><AdminPage /></AdminRoute>} />
💡 PrivateRoute vs AdminRoute — 프론트엔드 이중 방어

PrivateRoute — localStorage에 token 있으면 통과. 없으면 /login으로 이동. 로그인 여부만 체크.
AdminRoute — localStorage의 role이 "ROLE_ADMIN"이면 통과. 아니면 /board/list로 이동. 관리자 여부 체크.

⚠️ 중요 — 프론트 체크만으로는 보안이 안 된다!
localStorage의 role 값은 사용자가 개발자 도구로 직접 수정할 수 있다. 그래서 백엔드 SecurityConfig의 .requestMatchers("/api/admin/**").hasRole("ADMIN")이 반드시 필요하다. 프론트의 AdminRoute는 UI 편의용이고, 실제 보안은 백엔드 SecurityConfig가 담당한다.

src/pages/Login.jsx — role 저장 추가
const handleLogin = async () => {
    const res = await api.post("/api/user/login",
        { username, password });
    localStorage.setItem("token", res.data.token);
    localStorage.setItem("role", res.data.role); // 추가!
    navigate("/board/list");
};
src/pages/BoardList.jsx — 관리자 메뉴
const role = localStorage.getItem("role");

// 관리자 메뉴 링크
{role === "ROLE_ADMIN" && (
    <Link to="/admin" style={{color:"red"}}>
        관리자 페이지
    </Link>
)}

// 로그아웃 시 role도 삭제
const handleLogout = () => {
    localStorage.removeItem("token");
    localStorage.removeItem("role"); // 추가!
    navigate("/login");
};
src/pages/AdminPage.jsx — 신규
function AdminPage() {
    const [users, setUsers] = useState([]);
    const [boards, setBoards] = useState([]);
    const [activeTab, setActiveTab] = useState("users");

    useEffect(() => {
        api.get("/api/admin/users").then(res => setUsers(res.data));
        api.get("/api/admin/boards").then(res => setBoards(res.data));
    }, []);

    const handleDeleteUser = async (userId) => {
        if (window.confirm("유저를 삭제하시겠습니까?")) {
            await api.delete(`/api/admin/users/${userId}`);
            api.get("/api/admin/users").then(res => setUsers(res.data));
        }
    };

    const handleDeleteBoard = async (boardId) => {
        if (window.confirm("게시글을 삭제하시겠습니까?")) {
            await api.delete(`/api/admin/boards/${boardId}`);
            api.get("/api/admin/boards").then(res => setBoards(res.data));
        }
    };
    // 탭 전환으로 유저/게시글 관리
    // 관리자(ROLE_ADMIN) 계정은 삭제 버튼 미표시
}
Next — 3차 → 3차 심화 변경 포인트
심화과정에서 무엇이 왜 추가되나
심화과정 핵심 추가 포인트 5가지
AOP 로깅 추가 (STEP 1)
aspect/LogAspect.java 신규 생성. 기존 Service 코드 한 줄도 안 건드리고 모든 메서드에 자동 로그 + 실행시간 측정.
전역 예외처리 추가 (STEP 3)
exception/CustomException.java + GlobalExceptionHandler.java 신규 생성. 모든 Controller의 try/catch 제거. 에러 응답 형식 일관화.
@Transactional readOnly 최적화 (STEP 4)
조회 메서드(getBoardList, getDetail, login, getAllUsers, getAllBoards)에 readOnly=true 적용. 더티체킹 비활성화 → 성능 향상.
@Builder 패턴 적용 (STEP 5)
BoardDto, UserDto에 @Builder + @AllArgsConstructor 추가. BoardService.toDto(), AdminService의 DTO 변환을 Builder 방식으로 변경.
build.gradle 의존성 추가
spring-boot-starter-aop 추가 필요. 나머지 의존성은 3차와 동일.
2차 프로젝트📚 전체 맵심화 커리큘럼