Spring Security로 인증/권한 체계화 — 관리자 기능 추가, Filter Chain 통합
2차의 JwtFilter는 직접 만든 임시방편이었다. 3차는 Spring Security라는 표준 보안 프레임워크를 도입해서 인증·권한·CORS를 체계적으로 관리한다. 코드는 줄고, 보안은 강해진다.
request.getAttribute("loginUser") 반복 — 지저분함@AuthenticationPrincipal로 자동 주입 — 코드 단순화hasRole("ADMIN")으로 URL별 권한 한 줄로 설정
| 항목 | 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 |
HTTP 요청이 Controller에 도달하기 전에 거치는 보안 필터들의 연속이다. Spring Security는 기본으로 수십 개의 필터를 제공하고, 우리는 그 중간에 JwtAuthenticationFilter를 끼워넣는다.
2차 방식 (임시방편) — JwtFilter를 직접 만들어서 CorsConfig의 FilterRegistrationBean으로 수동 등록. Spring Security와 무관하게 따로 동작.
3차 방식 (표준) — SecurityConfig의 addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)로 Security FilterChain 안에 통합. 토큰 검증 후 SecurityContextHolder에 인증 정보 저장 → Spring Security가 모든 권한 체크를 자동으로 처리.
2차에서 util/JwtFilter.java와 config/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 ← 신규
Spring Security는 인증 처리 시 UserDetails 인터페이스만 다룬다. 우리가 만든 User Entity는 Security가 모른다. 그래서 CustomUserDetails가 User를 감싸서 Security가 이해하는 형태로 변환해준다.
getUser() 메서드로 원본 Entity를 꺼낼 수 있어서 Controller에서 userDetails.getUser()로 User 객체를 바로 사용한다.
Spring은 HTTP 요청 하나당 스레드 하나를 배정한다. SecurityContextHolder는 현재 스레드에 인증 정보를 보관하는 저장소다.
JwtAuthenticationFilter에서 setAuthentication(authentication)으로 저장하면, 같은 요청을 처리하는 Controller에서 @AuthenticationPrincipal로 꺼낼 수 있다. 요청이 끝나면 자동으로 비워진다.
CSRF disable — CSRF 공격은 브라우저가 자동으로 쿠키를 보내는 방식을 이용한다. JWT는 localStorage에 저장하고 직접 헤더에 첨부하므로 CSRF 위협이 없다. 그래서 꺼도 된다.
STATELESS — Spring Security는 기본적으로 세션을 만든다. JWT는 서버에 상태를 저장하지 않으므로 세션이 필요 없다. STATELESS로 설정하면 Spring이 세션을 만들지 않는다.
addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
Security의 기본 로그인 필터(UsernamePasswordAuthenticationFilter) 앞에 JwtAuthenticationFilter를 끼워넣는다는 뜻이다. JWT 검증이 먼저 실행되어야 SecurityContext에 인증 정보가 들어가고, 그 다음 권한 체크가 이루어질 수 있기 때문이다.
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"
}
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 @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"; // 기본값 } }
@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; } }
Spring Security의 UserDetails 인터페이스는 계정 만료, 잠금, 비밀번호 만료, 활성화 여부를 각각 메서드로 제공한다. 우리 프로젝트는 이런 세밀한 계정 관리를 구현하지 않으므로 전부 true로 반환해서 항상 정상 계정으로 취급한다. 실무에서는 DB의 계정 상태 컬럼을 보고 동적으로 반환한다.
@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로 감싸서 반환 } }
@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 — 토큰 검증 후 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)
@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; } }
// 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 @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()); } }
@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())); } }
@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 @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); } }
public interface BoardRepository extends JpaRepository<Board, Long> { Page<Board> findAllByOrderByCreatedAtDesc(Pageable pageable); void deleteByUser(User user); // 추가! — 유저 삭제 시 게시글 먼저 삭제 }
JPA는 메서드 이름 규칙만 지키면 쿼리를 자동으로 만들어준다.
deleteByUser(User user) → DELETE FROM board WHERE user_id = ?
왜 게시글을 먼저 삭제해야 하나?
Board 테이블에는 user_id FK(외래 키)가 있다. 유저를 먼저 지우면 참조 무결성 제약 위반으로 DB 오류가 발생한다. 그래서 AdminService.deleteUser()에서 boardRepository.deleteByUser(user)를 먼저 호출하고 그 다음 userRepository.delete(user)를 호출한다.
@Transactional로 묶어서 두 작업이 하나의 트랜잭션으로 처리된다 — 중간에 실패하면 전체 롤백.
// 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 — localStorage에 token 있으면 통과. 없으면 /login으로 이동. 로그인 여부만 체크.
AdminRoute — localStorage의 role이 "ROLE_ADMIN"이면 통과. 아니면 /board/list로 이동. 관리자 여부 체크.
⚠️ 중요 — 프론트 체크만으로는 보안이 안 된다!
localStorage의 role 값은 사용자가 개발자 도구로 직접 수정할 수 있다. 그래서 백엔드 SecurityConfig의 .requestMatchers("/api/admin/**").hasRole("ADMIN")이 반드시 필요하다. 프론트의 AdminRoute는 UI 편의용이고, 실제 보안은 백엔드 SecurityConfig가 담당한다.
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"); };
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"); };
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) 계정은 삭제 버튼 미표시 }