Security FilterChain📚 전체 맵Security 프론트
05c · 3차 프로젝트 · Spring Security

Backend Code

백엔드 전체 코드 완전 해부 — Entity · Security · Config · Service · Controller 한 줄씩

시리즈 3/5 · 05b(Filter Chain 원리) 이해 후 읽기 권장

이 파일의 학습 계획
05c에서 다루는 코드 순서

Security 관련 파일을 의존 순서대로 읽는 것이 중요합니다. User 엔티티(기반) → CustomUserDetails(포장) → CustomUserDetailsService(로딩) → JwtUtil(토큰) → JwtAuthenticationFilter(검증+저장) → SecurityConfig(설정) 순서입니다. 그 다음에 일반 Controller/Service를 봅니다.

순서파일핵심 역할상태
1entity/User.javarole 필드 추가, @PrePersist에서 기본 role 설정수정
2dto/UserDto.javarole, adminCode 필드 추가수정
3security/CustomUserDetails.javaUser 엔티티를 UserDetails 표준으로 포장신규
4security/CustomUserDetailsService.javausername으로 DB 조회 → CustomUserDetails 반환신규
5util/JwtUtil.javarole 클레임 추가, getRole() 메서드 추가수정
6security/JwtAuthenticationFilter.java토큰 검증 → SecurityContext에 인증 저장신규
7config/SecurityConfig.javaCSRF·세션·CORS·URL권한·필터 설정 전체신규
8service/UserService.javaadminCode 처리, login 반환타입 Map으로 변경수정
9controller/BoardController.java@AuthenticationPrincipal 적용, HttpServletRequest 제거수정
10controller/AdminController.java관리자 전용 API — 유저/게시글 목록·삭제신규
11service/AdminService.java관리자 비즈니스 로직 — Stream + @Transactional신규
12repository/BoardRepository.javadeleteByUser() — JPA 메서드 네이밍 규칙수정
Code 01
수정 entity/User.java

role 필드 추가, @PrePersist에서 기본 ROLE_USER 설정.

entity/User.java
@Entity
@Table(name = "users")          // DB 테이블명 users
@Getter @Setter @NoArgsConstructor  // Lombok: getter/setter/기본생성자 자동 생성
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // AUTO_INCREMENT
    private Long id;

    @Column(unique = true, nullable = false)  // UNIQUE NOT NULL 제약
    private String username;

    @Column(nullable = false)
    private String password;   // BCrypt 암호화된 값 저장

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

    @Column(nullable = false)
    private String role;   // ← 3차에서 추가! "ROLE_USER" 또는 "ROLE_ADMIN"
                           // ROLE_ 접두사를 포함한 문자열로 DB에 저장

    private LocalDateTime createdAt;

    @PrePersist  // INSERT 직전에 자동 실행
    public void prePersist() {
        this.createdAt = LocalDateTime.now();
        if (this.role == null)     // role이 null이면 (코드에서 명시 안 한 경우)
            this.role = "ROLE_USER"; // 기본값 ROLE_USER 자동 설정
        // UserService에서 adminCode 일치 시 "ROLE_ADMIN" 세팅 후 save()
        // → adminCode 맞으면 role="ROLE_ADMIN", @PrePersist에서 if(null) 조건 통과 안 함
    }
}
📌 @PrePersist란

JPA 라이프사이클 콜백입니다. userRepository.save(user)가 실제 INSERT 쿼리를 실행하기 직전에 이 메서드를 자동 호출합니다.

흐름: save(user) 호출 → @PrePersist 실행(createdAt·role 세팅) → INSERT 쿼리 실행

왜 생성자에서 안 하고 @PrePersist에서 하나? 객체를 만들 때(new User()) 가 아니라, DB에 저장하기 직전에 처리해야 "저장 시점의 정확한 시간"과 "기본값 보장"이 되기 때문입니다.

📌 GenerationType.IDENTITY란

PK(id)를 DB의 AUTO_INCREMENT에 맡기는 전략입니다.
MySQL에서 AUTO_INCREMENT와 동일. INSERT 후 DB가 자동으로 id를 1, 2, 3... 순서로 채워줍니다.
JPA가 다른 전략(SEQUENCE, TABLE)도 지원하지만 MySQL 환경에서는 IDENTITY가 가장 자연스럽습니다.

Code 02
신규 security/CustomUserDetails.java

User 엔티티를 Spring Security 표준(UserDetails)으로 포장. @AuthenticationPrincipal로 꺼내면 이 객체가 나옴.

security/CustomUserDetails.java
@Getter                        // Lombok: getter 자동 생성 (getUser() 포함)
@RequiredArgsConstructor       // Lombok: final 필드 생성자 자동 생성
public class CustomUserDetails implements UserDetails {
// UserDetails: Spring Security 표준 유저 인터페이스
// implements → 7개 메서드를 모두 @Override 해야 함

    private final User user;
    // final + @RequiredArgsConstructor → 생성자 파라미터로 주입
    // new CustomUserDetails(user) 형태로 생성
    // @Getter가 getUser() 자동 생성 → Controller에서 userDetails.getUser()로 실제 User 엔티티 접근

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // Security가 권한 체크할 때 이 목록을 봄
        return List.of(new SimpleGrantedAuthority(user.getRole()));
        // user.getRole() = "ROLE_USER" 또는 "ROLE_ADMIN"
        // SimpleGrantedAuthority: 문자열로 권한을 표현하는 가장 단순한 구현체
        // List.of(): 불변 리스트 (권한 목록은 바뀌지 않으므로)
    }

    @Override
    public String getPassword() { return user.getPassword(); }
    // BCrypt 암호화된 비밀번호 반환
    // Security가 로그인 처리 시 사용 (우리 프로젝트는 Security의 로그인 폼 미사용이므로 직접 호출은 없음)

    @Override
    public String getUsername() { return user.getUsername(); }
    // 유저 식별자 (아이디) 반환
    // CustomUserDetailsService.loadUserByUsername() 에서 이 값으로 조회

    // 아래 4개는 계정 상태 관련 — 우리 프로젝트는 별도 상태 관리 없으므로 전부 true
    @Override public boolean isAccountNonExpired()   { return true; } // 계정 만료 없음
    @Override public boolean isAccountNonLocked()    { return true; } // 계정 잠금 없음
    @Override public boolean isCredentialsNonExpired(){ return true; } // 비밀번호 만료 없음
    @Override public boolean isEnabled()               { return true; } // 모든 계정 활성화
    // false 반환하는 항목이 있으면 Security가 자동으로 로그인 차단
    // (실제 서비스에서는 DB에 상태 필드를 두고 관리)
}
📌 @AuthenticationPrincipal 사용 시 이 객체가 나오는 이유

JwtAuthenticationFilter에서:
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities())
— 첫 번째 인자(principal)가 CustomUserDetails userDetails입니다.

@AuthenticationPrincipal은 SecurityContext.getAuthentication().getPrincipal()을 꺼냅니다.
@AuthenticationPrincipal CustomUserDetails userDetails로 선언하면 이 객체가 바로 주입됩니다.

Code 03
신규 security/CustomUserDetailsService.java
security/CustomUserDetailsService.java
@Service                   // Spring 빈으로 등록 + Service 계층임을 명시
@RequiredArgsConstructor   // UserRepository 생성자 주입 자동화
public class CustomUserDetailsService implements UserDetailsService {
// UserDetailsService: Security가 유저 로딩 시 호출하는 표준 인터페이스
// 우리가 implements하면 Security가 자동으로 이 구현체를 사용

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        // Security 표준 — username으로 유저 조회해서 UserDetails 반환
        // 여기서 username은 실제 "아이디" (우리 DB의 username 컬럼)

        User user = userRepository.findByUsername(username)
            .orElseThrow(() ->
                new UsernameNotFoundException("없는 유저입니다: " + username));
        // orElseThrow: Optional이 비어있으면 예외 발생
        // UsernameNotFoundException: Security 표준 예외 (Security가 이 예외를 인식함)
        // 일반 IllegalArgumentException을 쓰면 Security가 처리 못할 수 있음

        return new CustomUserDetails(user);
        // User 엔티티를 CustomUserDetails로 포장해서 반환
        // JwtAuthenticationFilter가 이 반환값을 Authentication의 principal로 사용
    }
}

// 호출 흐름:
// JwtAuthenticationFilter:
//   String username = jwtUtil.getUsername(token);  // 토큰에서 추출
//   UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); // 여기 호출
//   → DB 조회 → CustomUserDetails 반환 → SecurityContext에 저장
💡 매 요청마다 DB를 조회하는 이유

JWT 토큰에서 username을 꺼낸 다음, 왜 DB를 다시 조회할까요? 토큰에서 바로 유저 정보를 쓰면 안될까요?

이유:
토큰 발급 후 유저 정보가 변경될 수 있음 (예: 관리자가 역할 변경, 비밀번호 변경 등)
토큰이 탈취됐을 때 서버에서 해당 유저를 비활성화하면(isEnabled=false) 차단 가능
③ Security 표준(loadUserByUsername)을 따르면 나중에 확장하기 쉬움

성능 이슈가 걱정되면 Redis 캐싱으로 DB 조회 최소화 가능 (심화 주제).

Code 04
수정 util/JwtUtil.java — role 클레임 추가
util/JwtUtil.java
@Component   // Spring 빈으로 등록
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;
    // application.properties의 jwt.secret 값 자동 주입
    // 하드코딩 금지 — 코드에 비밀키가 노출되면 보안 위협

    @Value("${jwt.expiration}")
    private Long expiration;
    // 86400000 = 24시간(밀리초) — 토큰 유효기간

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes());
        // secret 문자열을 HMAC-SHA 알고리즘용 키 객체로 변환
        // HMAC: Hash-based Message Authentication Code — 서명 알고리즘
    }

    // ===== 3차 변경: role 파라미터 추가 =====
    public String createToken(String username, String nickname, String role) {
        // 2차: createToken(username, nickname)
        // 3차: createToken(username, nickname, role) ← role 추가
        return Jwts.builder()
            .subject(username)          // JWT sub 클레임 — 토큰 주체 (아이디)
            .claim("nickname", nickname) // 커스텀 클레임 — 닉네임
            .claim("role", role)         // ← 3차 추가! 역할 정보
            .issuedAt(new Date())        // 발급 시각 (iat 클레임)
            .expiration(new Date(        // 만료 시각 (exp 클레임)
                System.currentTimeMillis() + expiration))
            .signWith(getSigningKey())  // 서명 — 위변조 방지
            .compact();                  // 최종 JWT 문자열 생성
    }

    public String getUsername(String token) {
        return getClaims(token).getSubject(); // sub 클레임 꺼내기
    }

    // ===== 3차 추가: role 꺼내기 =====
    public String getRole(String token) {
        return getClaims(token).get("role", String.class);
        // get("role", String.class): "role" 키로 클레임을 꺼내고 String으로 캐스팅
    }

    public boolean validateToken(String token) {
        try {
            getClaims(token); // 파싱 성공하면 유효한 토큰
            return true;
        } catch (Exception e) {
            return false; // 만료, 서명 불일치, 형식 오류 등 → false
        }
    }

    private Claims getClaims(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey()) // 같은 키로 서명 검증
            .build()
            .parseSignedClaims(token)  // 파싱 + 서명 검증
            .getPayload();              // Claims 객체 (모든 클레임 포함)
        // 서명이 다르거나 만료됐으면 예외 발생 → validateToken()에서 false 반환
    }
}
📌 JWT 토큰 구조 — role이 어디에 들어가나

JWT = Header.Payload.Signature (3부분을 점으로 연결)

Payload 부분에 클레임들이 들어갑니다. 위 코드로 생성된 토큰의 Payload:
{"sub":"hong", "nickname":"홍길동", "role":"ROLE_USER", "iat":..., "exp":...}

Payload는 Base64URL 인코딩이라 누구나 디코딩할 수 있습니다 (암호화 아님!).
그래서 비밀번호 등 민감한 정보는 절대 JWT에 넣으면 안 됩니다.
role은 넣어도 괜찮습니다 — 역할 정보는 공개적이어도 문제없음.

Code 05
신규 security/JwtAuthenticationFilter.java

3차의 핵심. 2차 JwtFilter를 Security 표준 방식으로 대체. 토큰 검증 후 SecurityContext에 인증 저장.

security/JwtAuthenticationFilter.java
@Component              // Spring 빈 등록 — SecurityConfig에서 주입받음
@RequiredArgsConstructor // final 필드 생성자 주입
public class JwtAuthenticationFilter extends OncePerRequestFilter {
// OncePerRequestFilter: 요청당 딱 한 번만 실행 보장
// extends: 부모 클래스 상속 — doFilterInternal() 하나만 구현하면 됨

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;

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

        // ① Authorization 헤더 읽기
        String authHeader = request.getHeader("Authorization");
        // React의 axios interceptor가 자동 첨부: "Bearer eyJhbGciOi..."

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            // Bearer 스킴이 있을 때만 처리 (없으면 토큰 없는 요청)

            // ② "Bearer " 제거 → 순수 토큰 추출
            String token = authHeader.substring(7);
            // "Bearer " = 7글자 → 7번째 인덱스부터 끝까지

            if (jwtUtil.validateToken(token)) {
                // ③ 토큰 유효성 검증 (서명·만료 확인)
                // 유효하지 않으면 이 블록 건너뜀 → SecurityContext 빈 채로 다음 Filter로
                // → AuthorizationFilter에서 인증 없음으로 판단 → 401 반환

                // ④ 토큰에서 username 추출
                String username = jwtUtil.getUsername(token);

                // ⑤ DB에서 유저 조회 → CustomUserDetails 생성
                UserDetails userDetails =
                    customUserDetailsService.loadUserByUsername(username);

                // ⑥ Authentication 객체 생성
                //    3인자 생성자 → authenticated = true 자동 설정
                UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                        userDetails,                   // principal
                        null,                          // credentials (불필요)
                        userDetails.getAuthorities()  // 권한 목록
                    );

                // ⑦ SecurityContext에 저장 ← 이 줄이 핵심!
                SecurityContextHolder
                    .getContext()
                    .setAuthentication(authentication);
                // 이 이후로 @AuthenticationPrincipal이 userDetails를 꺼낼 수 있음
            }
        }

        // ⑧ 다음 Filter로 넘기기 (필수! 없으면 요청이 여기서 멈춤)
        filterChain.doFilter(request, response);
        // 토큰이 없거나 유효하지 않아도 doFilter() 는 호출해야 함
        // (로그인·회원가입 같은 permitAll URL은 토큰 없어도 통과해야 하므로)
    }
}
📌 토큰이 없어도 doFilter()를 호출하는 이유

/api/user/login, /api/user/registerpermitAll() 설정 — 토큰 없이도 접근 가능합니다.
만약 토큰이 없을 때 filterChain.doFilter()를 호출하지 않으면, 이 Filter에서 요청이 막혀버려서 로그인조차 할 수 없게 됩니다.

따라서 토큰이 없거나 유효하지 않으면 → SecurityContext를 비워둔 채로 → 다음 Filter로 넘기고 → AuthorizationFilter에서 URL 설정을 보고 허용/차단을 결정합니다.

Code 06
신규 config/SecurityConfig.java

3차에서 가장 중요한 설정 파일. CSRF·세션·CORS·URL권한·필터 등록을 한 곳에서 처리.

config/SecurityConfig.java
@Configuration     // 설정 클래스임을 명시 — @Bean 메서드들을 Spring이 읽어서 처리
@EnableWebSecurity // Spring Security 활성화 — Security 자동 설정 + 커스텀 허용
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    // BCryptPasswordEncoder를 Spring 빈으로 등록
    // 2차에서는 BoardApplication.java에서 했는데 3차에서 SecurityConfig로 이동
    // 이유: 보안 관련 Bean은 SecurityConfig에서 한 곳에서 관리하는 게 원칙
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // HttpSecurity: Security 설정을 위한 빌더 객체 (Spring이 자동 주입)

        http
            // 1. CSRF 비활성화
            .csrf(AbstractHttpConfigurer::disable)
            // CSRF: Cross-Site Request Forgery 방어 기법
            // 세션 기반 인증의 취약점을 막는 것 → JWT 사용 시 불필요
            // JWT는 매 요청마다 헤더에 토큰을 보내므로 CSRF 공격이 통하지 않음
            // AbstractHttpConfigurer::disable 은 메서드 레퍼런스 표현

            // 2. CORS 설정
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            // corsConfigurationSource() 메서드(아래)에서 설정한 CORS 정책 적용
            // 2차의 CorsConfig.java 역할을 SecurityConfig 안으로 통합

            // 3. 세션 정책 — STATELESS
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // JWT 사용 → 서버가 세션 만들지 않음
            // 요청마다 새로 JWT 검증 → 서버 메모리에 세션 저장 불필요

            // 4. URL별 접근 권한 설정
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/user/login", "/api/user/register")
                    .permitAll()
                // 로그인·회원가입: 미인증 상태에서도 접근 가능해야 하므로 누구나 허용

                .requestMatchers("/api/admin/**")
                    .hasRole("ADMIN")
                // /api/admin 하위 모든 URL: ROLE_ADMIN만 접근
                // hasRole("ADMIN") → 내부적으로 "ROLE_ADMIN" 찾음
                // ROLE_USER가 접근하면 → 403 Forbidden 자동 반환

                .anyRequest().authenticated()
                // 위 두 조건 외 나머지 모든 요청: 로그인 필요
                // SecurityContext에 Authentication 없으면 → 401 자동 반환
            )

            // 5. 우리 JwtAuthenticationFilter를 필터 체인에 추가
            .addFilterBefore(
                jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class
            );
            // addFilterBefore(A, B): A를 B 앞에 실행
            // UsernamePasswordAuthenticationFilter: Security 기본 폼 로그인 처리 Filter
            // 우리 JWT 필터를 그보다 먼저 실행 → JWT로 인증 처리 후 Security 기본 필터 통과

        return http.build(); // 설정 완성 후 SecurityFilterChain 객체 반환
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:3000"));
        // 허용할 출처 — React 개발 서버 주소
        // 여기 없는 Origin에서 오면 CORS 오류 발생

        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
        // 허용할 HTTP 메서드

        config.setAllowedHeaders(List.of("*"));
        // "*" = 모든 헤더 허용 (Authorization 포함 — JWT 헤더 전송 가능)

        config.setAllowCredentials(true);
        // 자격증명(쿠키, Authorization 헤더) 포함 요청 허용
        // true면 allowedOrigins에 * 사용 불가 — 명시적 Origin 필수

        UrlBasedCorsConfigurationSource source =
            new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        // /api/** 패턴의 URL에만 이 CORS 설정 적용

        return source;
    }
}
Code 07
수정 service/UserService.java — adminCode + login 반환타입 변경
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;
    // application.properties의 admin.code 값 주입
    // "ADMIN2024" — 회원가입 시 이 코드 입력하면 관리자로 등록

    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());

        // ===== 3차 추가: 관리자 코드 체크 =====
        if (adminCode.equals(userDto.getAdminCode()))
            user.setRole("ROLE_ADMIN");
        else
            user.setRole("ROLE_USER");
        // userDto.getAdminCode(): React Register.jsx에서 입력한 관리자 코드
        // adminCode.equals(): properties의 값과 일치하면 관리자
        // @PrePersist의 if(role==null) 체크 — 이미 role이 세팅됐으므로 덮어쓰지 않음

        userRepository.save(user);
    }

    // ===== 3차 변경: 반환타입 String → Map<String,String> =====
    // 2차: public String login(...)  → JWT 토큰만 반환
    // 3차: public Map login(...)     → token + 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()   // ← 3차 추가: role도 토큰에 포함
        );

        return Map.of("token", token, "role", user.getRole());
        // React에서 token은 API 인증용으로, role은 AdminRoute 판별용으로 사용
        // Map.of(): 불변 Map — 2개의 키-값 쌍
    }
}
Code 08
수정 controller/BoardController.java — @AuthenticationPrincipal 적용
controller/BoardController.java
@RestController        // @Controller + @ResponseBody — 모든 메서드가 JSON 반환
@RequiredArgsConstructor
@RequestMapping("/api/board") // 공통 URL prefix
public class BoardController {
    private final BoardService boardService;

    @GetMapping("/list")
    public ResponseEntity<?> list(@RequestParam(defaultValue = "0") int page) {
        return ResponseEntity.ok(boardService.getBoardList(page));
        // 로그인 체크 없음 — 목록은 anyRequest().authenticated() 설정으로 Security가 처리
    }

    @GetMapping("/detail/{id}")
    public ResponseEntity<?> detail(@PathVariable Long id) {
        return ResponseEntity.ok(boardService.getDetail(id));
    }

    // ===== 2차 vs 3차 비교 — 글쓰기 =====
    // 2차: write(@RequestBody BoardDto, HttpServletRequest request)
    //       User loginUser = (User) request.getAttribute("loginUser");
    //       if (loginUser == null) return ResponseEntity.status(401).build();

    // 3차: write(@RequestBody BoardDto, @AuthenticationPrincipal CustomUserDetails userDetails)
    @PostMapping("/write")
    public ResponseEntity<?> write(
            @RequestBody BoardDto boardDto,
            @AuthenticationPrincipal CustomUserDetails userDetails) {
        // @AuthenticationPrincipal: SecurityContext에서 principal을 꺼내 자동 주입
        // SecurityConfig에서 anyRequest().authenticated() → 미인증이면 여기 안 옴
        // → null 체크 불필요!
        boardService.write(boardDto, userDetails.getUser());
        // userDetails.getUser(): @Getter가 만든 메서드 → 포장된 User 엔티티 반환
        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", "삭제 성공"));
    }
}
Code 09 + 10
신규 controller/AdminController.java + service/AdminService.java
controller/AdminController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin") // SecurityConfig에서 hasRole("ADMIN")으로 보호됨
public class AdminController {
    private final AdminService adminService;

    @GetMapping("/users")
    public ResponseEntity<List<UserDto>> getAllUsers() {
        return ResponseEntity.ok(adminService.getAllUsers());
        // 반환타입: ResponseEntity<List<UserDto>> — 제네릭 타입 명시
        // ? 대신 구체 타입을 쓰면 컴파일 타임에 타입 체크 가능
    }

    @DeleteMapping("/users/{userId}")
    public ResponseEntity<?> deleteUser(@PathVariable Long userId) {
        adminService.deleteUser(userId);
        return ResponseEntity.ok(Map.of("message", "유저 삭제 성공"));
        // @AuthenticationPrincipal 없음 — 관리자 확인은 SecurityConfig가 이미 처리
        // /api/admin/** → hasRole("ADMIN") → ROLE_ADMIN 아니면 여기 못 옴
    }

    @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 @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());      // role도 포함
                return dto;
            })
            .collect(Collectors.toList());
        // findAll() → User 전체 목록 → stream()으로 파이프라인 시작
        // .map(): 각 User → UserDto 변환 (Entity → DTO 변환)
        // .collect(Collectors.toList()): Stream 결과를 List로 수집
        // password는 DTO에 넣지 않음 — 관리자에게도 비밀번호 노출 금지
    }

    @Transactional
    public void deleteUser(Long userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new IllegalArgumentException("없는 유저입니다."));

        boardRepository.deleteByUser(user);
        // ← 핵심! 유저 삭제 전에 그 유저의 게시글을 먼저 삭제
        // Board의 user_id 컬럼이 users.id를 FK로 참조하므로
        // 게시글 먼저 지우지 않으면 FK 제약 위반 오류 발생!
        // @Transactional: 두 delete가 하나의 트랜잭션 — 하나 실패 시 전체 롤백

        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());
                // board.getUser().getNickname(): @ManyToOne 연관관계로 JOIN 자동 실행
                // JPA가 Board 조회 시 연관된 User도 함께 로딩 (EAGER 또는 추가 쿼리)
                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);
        // 게시글은 Board만 지우면 됨 (Board가 User를 참조하는 쪽이므로)
    }
}
수정 repository/BoardRepository.java — deleteByUser() 추가
repository/BoardRepository.java
public interface BoardRepository extends JpaRepository<Board, Long> {

    Page<Board> findAllByOrderByCreatedAtDesc(Pageable pageable);
    // 기존: 생성일 역순 페이징 조회
    // 메서드 이름 규칙: findAll + By + OrderBy + CreatedAt + Desc
    // JPA가 → SELECT * FROM board ORDER BY created_at DESC LIMIT ? OFFSET ? 로 변환

    void deleteByUser(User user);
    // ← 3차 추가!
    // 메서드 이름 규칙: delete + By + User (User 필드로 삭제)
    // JPA가 → DELETE FROM board WHERE user_id = ? 로 변환
    // user 파라미터: User 엔티티 (JPA가 자동으로 FK인 user_id를 추출해서 조건으로 사용)
    // AdminService.deleteUser()에서 유저 삭제 전에 먼저 호출
    // @Transactional 필요 — delete 쿼리는 트랜잭션 안에서 실행
}
📌 JPA 메서드 네이밍 규칙 — 인터페이스만 선언하면 구현체 자동 생성

JPA가 메서드 이름을 파싱해서 자동으로 SQL을 생성합니다:
findBy + 필드명 → WHERE 조건
deleteBy + 필드명 → DELETE WHERE 조건
OrderBy + 필드명 + Desc/Asc → ORDER BY 조건

deleteByUser(User user)DELETE FROM board WHERE user_id = {user.id}
구현 코드를 직접 작성할 필요 없이 인터페이스에 메서드 시그니처만 선언하면 됩니다.

Security FilterChain📚 전체 맵Security 프론트