백엔드 전체 코드 완전 해부 — Entity · Security · Config · Service · Controller 한 줄씩
시리즈 3/5 · 05b(Filter Chain 원리) 이해 후 읽기 권장
Security 관련 파일을 의존 순서대로 읽는 것이 중요합니다. User 엔티티(기반) → CustomUserDetails(포장) → CustomUserDetailsService(로딩) → JwtUtil(토큰) → JwtAuthenticationFilter(검증+저장) → SecurityConfig(설정) 순서입니다. 그 다음에 일반 Controller/Service를 봅니다.
| 순서 | 파일 | 핵심 역할 | 상태 |
|---|---|---|---|
| 1 | entity/User.java | role 필드 추가, @PrePersist에서 기본 role 설정 | 수정 |
| 2 | dto/UserDto.java | role, adminCode 필드 추가 | 수정 |
| 3 | security/CustomUserDetails.java | User 엔티티를 UserDetails 표준으로 포장 | 신규 |
| 4 | security/CustomUserDetailsService.java | username으로 DB 조회 → CustomUserDetails 반환 | 신규 |
| 5 | util/JwtUtil.java | role 클레임 추가, getRole() 메서드 추가 | 수정 |
| 6 | security/JwtAuthenticationFilter.java | 토큰 검증 → SecurityContext에 인증 저장 | 신규 |
| 7 | config/SecurityConfig.java | CSRF·세션·CORS·URL권한·필터 설정 전체 | 신규 |
| 8 | service/UserService.java | adminCode 처리, login 반환타입 Map으로 변경 | 수정 |
| 9 | controller/BoardController.java | @AuthenticationPrincipal 적용, HttpServletRequest 제거 | 수정 |
| 10 | controller/AdminController.java | 관리자 전용 API — 유저/게시글 목록·삭제 | 신규 |
| 11 | service/AdminService.java | 관리자 비즈니스 로직 — Stream + @Transactional | 신규 |
| 12 | repository/BoardRepository.java | deleteByUser() — JPA 메서드 네이밍 규칙 | 수정 |
role 필드 추가, @PrePersist에서 기본 ROLE_USER 설정.
@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) 조건 통과 안 함 } }
JPA 라이프사이클 콜백입니다. userRepository.save(user)가 실제 INSERT 쿼리를 실행하기 직전에 이 메서드를 자동 호출합니다.
흐름: save(user) 호출 → @PrePersist 실행(createdAt·role 세팅) → INSERT 쿼리 실행
왜 생성자에서 안 하고 @PrePersist에서 하나? 객체를 만들 때(new User()) 가 아니라, DB에 저장하기 직전에 처리해야 "저장 시점의 정확한 시간"과 "기본값 보장"이 되기 때문입니다.
PK(id)를 DB의 AUTO_INCREMENT에 맡기는 전략입니다.
MySQL에서 AUTO_INCREMENT와 동일. INSERT 후 DB가 자동으로 id를 1, 2, 3... 순서로 채워줍니다.
JPA가 다른 전략(SEQUENCE, TABLE)도 지원하지만 MySQL 환경에서는 IDENTITY가 가장 자연스럽습니다.
User 엔티티를 Spring Security 표준(UserDetails)으로 포장. @AuthenticationPrincipal로 꺼내면 이 객체가 나옴.
@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에 상태 필드를 두고 관리) }
JwtAuthenticationFilter에서:
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities())
— 첫 번째 인자(principal)가 CustomUserDetails userDetails입니다.
@AuthenticationPrincipal은 SecurityContext.getAuthentication().getPrincipal()을 꺼냅니다.
즉 @AuthenticationPrincipal CustomUserDetails userDetails로 선언하면 이 객체가 바로 주입됩니다.
@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에 저장
JWT 토큰에서 username을 꺼낸 다음, 왜 DB를 다시 조회할까요? 토큰에서 바로 유저 정보를 쓰면 안될까요?
이유:
① 토큰 발급 후 유저 정보가 변경될 수 있음 (예: 관리자가 역할 변경, 비밀번호 변경 등)
② 토큰이 탈취됐을 때 서버에서 해당 유저를 비활성화하면(isEnabled=false) 차단 가능
③ Security 표준(loadUserByUsername)을 따르면 나중에 확장하기 쉬움
성능 이슈가 걱정되면 Redis 캐싱으로 DB 조회 최소화 가능 (심화 주제).
@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 = Header.Payload.Signature (3부분을 점으로 연결)
Payload 부분에 클레임들이 들어갑니다. 위 코드로 생성된 토큰의 Payload:
{"sub":"hong", "nickname":"홍길동", "role":"ROLE_USER", "iat":..., "exp":...}
Payload는 Base64URL 인코딩이라 누구나 디코딩할 수 있습니다 (암호화 아님!).
그래서 비밀번호 등 민감한 정보는 절대 JWT에 넣으면 안 됩니다.
role은 넣어도 괜찮습니다 — 역할 정보는 공개적이어도 문제없음.
3차의 핵심. 2차 JwtFilter를 Security 표준 방식으로 대체. 토큰 검증 후 SecurityContext에 인증 저장.
@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은 토큰 없어도 통과해야 하므로) } }
/api/user/login, /api/user/register는 permitAll() 설정 — 토큰 없이도 접근 가능합니다.
만약 토큰이 없을 때 filterChain.doFilter()를 호출하지 않으면, 이 Filter에서 요청이 막혀버려서
로그인조차 할 수 없게 됩니다.
따라서 토큰이 없거나 유효하지 않으면 → SecurityContext를 비워둔 채로 → 다음 Filter로 넘기고
→ AuthorizationFilter에서 URL 설정을 보고 허용/차단을 결정합니다.
3차에서 가장 중요한 설정 파일. CSRF·세션·CORS·URL권한·필터 등록을 한 곳에서 처리.
@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; } }
@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개의 키-값 쌍 } }
@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", "삭제 성공")); } }
@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 @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를 참조하는 쪽이므로) } }
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가 메서드 이름을 파싱해서 자동으로 SQL을 생성합니다:
findBy + 필드명 → WHERE 조건
deleteBy + 필드명 → DELETE WHERE 조건
OrderBy + 필드명 + Desc/Asc → ORDER BY 조건
deleteByUser(User user) → DELETE FROM board WHERE user_id = {user.id}
구현 코드를 직접 작성할 필요 없이 인터페이스에 메서드 시그니처만 선언하면 됩니다.