Spring Security 동작 원리 완전 해부 — Filter Chain · SecurityContext · UserDetails · @AuthenticationPrincipal
시리즈 2/5 · 05a 개요 파악 후 읽기 권장
코드를 보기 전에 "어떻게 동작하는지"를 먼저 이해해야 코드가 보입니다. 이 파일에서는 Spring Security의 내부 동작 원리를 시각화하면서 설명합니다. 개념을 제대로 잡고 나면 05c(백엔드 코드)가 훨씬 자연스럽게 읽힙니다.
| # | 주제 | 핵심 질문 |
|---|---|---|
| 1 | Spring Security가 뭔가 | 왜 직접 만든 Filter 대신 Security를 써야 하나 |
| 2 | Filter Chain 동작 순서 | 요청 하나가 들어오면 실제로 뭐가 실행되나 |
| 3 | JWT + Security 결합 흐름 | JWT 토큰을 Security가 어떻게 인식하나 |
| 4 | UserDetails / UserDetailsService | 인터페이스를 왜 구현해야 하나 |
| 5 | SecurityContext / Authentication | 인증 정보는 어디에 어떻게 저장되나 |
| 6 | @AuthenticationPrincipal | SecurityContext에서 어떻게 꺼내서 주입해주나 |
| 7 | hasRole vs hasAuthority | ROLE_ 접두사가 어디서 붙나 |
| 8 | OncePerRequestFilter | 왜 Filter를 직접 extends 안 하고 이걸 쓰나 |
Spring Security는 Spring 공식 보안 프레임워크입니다. "모든 HTTP 요청을 가로채서 인증·권한을 처리하는 필터들의 집합"이라고 보면 됩니다. 직접 만든 Filter와 다른 점은, Security가 이미 검증된 표준 패턴을 제공한다는 것입니다.
spring-boot-starter-security 의존성을 추가하는 순간:
① 모든 URL에 인증 요구를 자동으로 걸어버립니다. 로그인 없이 접근하면 403 자동 반환.
② 기본 로그인 페이지(/login)를 자동 생성합니다 (우리는 React 쓰므로 비활성화).
③ SecurityFilterChain을 자동 구성합니다.
이 기본 동작을 우리 프로젝트에 맞게 커스텀하는 것이 SecurityConfig.java의 역할입니다.
SecurityConfig에서 "로그인·회원가입은 누구나 접근 가능, 나머지는 JWT 인증 필요, /api/admin/** 은 ADMIN만"을 선언합니다.
| 항목 | 2차 직접 만든 JwtFilter | 3차 Spring Security 방식 |
|---|---|---|
| 인증 정보 저장 | request.setAttribute("loginUser", user)요청 객체에 직접 저장 — 비표준 |
SecurityContextHolder.getContext().setAuthentication(auth)Security 표준 저장소 사용 |
| 로그인 유저 꺼내기 | (User) request.getAttribute("loginUser")Controller마다 직접 꺼내고 null 체크 |
@AuthenticationPrincipal CustomUserDetailsSpring이 자동 주입 — null 체크 불필요 |
| URL 권한 설정 | Controller 메서드마다 if(loginUser == null) return 401 |
SecurityConfig 한 곳에서 .authorizeHttpRequests()로 선언 |
| 역할 기반 제어 | 직접 role 체크 코드 작성해야 함 | .hasRole("ADMIN") 선언 하나로 끝 |
| 표준화 | 우리만 아는 방식 | Spring 표준 — 다른 개발자도 바로 이해 가능 |
React에서 POST /api/board/write 요청이 오면, Controller의 write() 메서드에 도달하기 전에
아래의 Filter들이 순서대로 실행됩니다.
filterChain.doFilter(request, response)를 호출해야 다음 Filter로 넘어갑니다.
corsConfigurationSource() 설정을 읽어서 CORS 처리.Authorization: Bearer <token> 헤더 읽기jwtUtil.validateToken())loadUserByUsername())UsernamePasswordAuthenticationToken 생성 → SecurityContextHolder에 저장filterChain.doFilter()로 다음 Filter에 넘김/api/admin/** 요청인데 ROLE_ADMIN이 없으면 → 403 Forbidden 자동 반환
SecurityConfig에서 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
라고 설정합니다.
UsernamePasswordAuthenticationFilter는 Security 기본 폼 로그인 처리 Filter인데,
우리는 폼 로그인 대신 JWT를 쓰므로 그 앞에 우리 JwtAuthenticationFilter를 실행시킵니다.
즉, JWT 검증 → SecurityContext 저장이 이뤄진 다음에 Security 기본 인증 처리로 넘어가는 구조.
Spring Security는 유저 정보를 다루는 표준 인터페이스를 정의해놨습니다. Security가 내부적으로 이 인터페이스 타입으로만 유저를 다루기 때문에, 우리 User 엔티티를 이 표준에 맞게 포장해주는 클래스가 필요합니다.
// Spring Security가 정의한 표준 인터페이스 public interface UserDetails { // 이 유저가 가진 권한 목록 (예: [ROLE_USER] 또는 [ROLE_ADMIN]) Collection<? extends GrantedAuthority> getAuthorities(); // 비밀번호 (BCrypt로 암호화된 것) String getPassword(); // 유저 식별자 (우리는 username, 즉 아이디) String getUsername(); // 계정 만료 여부 — false면 Security가 로그인 차단 boolean isAccountNonExpired(); // 계정 잠금 여부 — false면 로그인 차단 boolean isAccountNonLocked(); // 비밀번호 만료 여부 — false면 비밀번호 변경 요구 boolean isCredentialsNonExpired(); // 계정 활성화 여부 — false면 로그인 차단 boolean isEnabled(); } // 우리 프로젝트에서는 계정 상태 관리 기능 미구현 → 4개 boolean 전부 true 반환
User 엔티티에 implements UserDetails를 직접 붙일 수도 있습니다.
하지만 그렇게 하면 DB 테이블 구조(엔티티)와 Security 표준(UserDetails)이 결합됩니다.
예를 들어 나중에 Security에서 분리하거나 엔티티를 바꾸면 연쇄 수정이 생깁니다.
CustomUserDetails로 User 엔티티를 안에 품어서 포장하면:
① 엔티티는 순수하게 DB 매핑 역할만 담당
② Security 관련 로직은 CustomUserDetails에서만 관리
③ userDetails.getUser()로 실제 User 엔티티에 접근 가능
이게 책임 분리(SRP)의 원칙입니다.
// Spring Security가 유저를 로딩할 때 사용하는 표준 인터페이스 public interface UserDetailsService { // username으로 유저를 찾아서 UserDetails로 반환 // 없으면 UsernameNotFoundException 던짐 (Security가 이걸 기대함) UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; } // 우리가 만드는 CustomUserDetailsService가 이걸 implements // JwtAuthenticationFilter에서: // String username = jwtUtil.getUsername(token); // UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); // Security 표준대로 호출하므로 나중에 구현체를 바꿔도 필터 코드는 그대로
JwtAuthenticationFilter가 SecurityContext에 저장하는 UsernamePasswordAuthenticationToken 객체 안에 뭐가 들어있는지 살펴봅니다.
userDetailsuserDetails.getUser() → 실제 User 엔티티userDetails.getUsername() → "hong"userDetails.getAuthorities() → [ROLE_USER]
userDetails.getAuthorities()에서 가져옴. AuthorizationFilter가 이 목록으로 권한 체크.
// JwtAuthenticationFilter에서 (토큰 검증 성공 시) // ① UserDetails 로딩 (DB 조회) UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); // ② Authentication 객체 생성 // (유저정보, null, 권한목록) — 3인자 생성자 → authenticated=true UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, // principal null, // credentials (이미 검증됨) userDetails.getAuthorities() // 권한 목록 ); // ③ SecurityContext에 저장 ← 핵심! // 이제부터 이 요청 처리 내내 이 인증 정보를 꺼낼 수 있음 SecurityContextHolder .getContext() .setAuthentication(authentication); // ④ 다음 Filter로 넘김 filterChain.doFilter(request, response); // ⑤ Controller에서 꺼내기 // @AuthenticationPrincipal이 SecurityContext에서 principal을 자동으로 꺼냄 public ResponseEntity<?> write(..., @AuthenticationPrincipal CustomUserDetails userDetails) { // userDetails = 위의 ① CustomUserDetails 객체 그대로! userDetails.getUser(); // User 엔티티 }
서버는 여러 요청을 동시에 처리합니다. 요청 A의 인증 정보와 요청 B의 인증 정보가 섞이면 안 되죠.
SecurityContextHolder는 Thread-Local을 사용합니다.
Thread-Local은 "각 스레드가 자기만의 변수를 따로 갖는 것" — 같은 변수명이지만 스레드마다 다른 값.
요청이 들어오면 → 해당 스레드에서만 SecurityContext가 보임
요청이 끝나면 → SecurityContextHolderFilter가 자동으로 SecurityContext를 비움
→ 다른 요청의 인증 정보가 절대 섞이지 않음.
// ===== 2차 방식 — request 속성에서 직접 꺼내기 ===== @PostMapping("/api/board/write") public ResponseEntity<?> write( @RequestBody BoardDto boardDto, HttpServletRequest request) { // request 받아야 함 User loginUser = (User) request.getAttribute("loginUser"); // 꺼내기 if (loginUser == null) // null 체크 필수! return ResponseEntity.status(401).build(); boardService.write(boardDto, loginUser); return ResponseEntity.ok(...); } // ===== 3차 방식 — @AuthenticationPrincipal로 자동 주입 ===== @PostMapping("/write") public ResponseEntity<?> write( @RequestBody BoardDto boardDto, @AuthenticationPrincipal CustomUserDetails userDetails) { // 자동 주입! // null 체크 불필요: SecurityConfig에서 이미 인증 요구 설정함 // SecurityConfig: .anyRequest().authenticated() → 미인증 시 Security가 먼저 차단 boardService.write(boardDto, userDetails.getUser()); // User 엔티티 꺼내기 return ResponseEntity.ok(Map.of("message", "글쓰기 성공")); } // @AuthenticationPrincipal 내부 동작: // SecurityContext.getAuthentication().getPrincipal() // → UsernamePasswordAuthenticationToken.principal // → CustomUserDetails 객체 (JwtAuthenticationFilter에서 저장한 것)
// SecurityConfig에서 권한 설정 .requestMatchers("/api/admin/**").hasRole("ADMIN") // hasRole("ADMIN") → 내부적으로 "ROLE_ADMIN"을 찾음 // "ROLE_" 접두사를 자동으로 붙여서 비교 .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN") // hasAuthority("ROLE_ADMIN") → "ROLE_ADMIN"을 그대로 찾음 // ROLE_ 접두사 자동 추가 없음 — 있는 그대로 비교 // 우리 CustomUserDetails.getAuthorities(): return List.of(new SimpleGrantedAuthority(user.getRole())); // user.getRole() = "ROLE_ADMIN" (DB에 ROLE_ 포함해서 저장) // 따라서: // hasRole("ADMIN") → "ROLE_" + "ADMIN" = "ROLE_ADMIN" → ✅ 매칭 // hasAuthority("ROLE_ADMIN") → "ROLE_ADMIN" → ✅ 매칭 (둘 다 결과 같음) // hasAuthority("ADMIN") → "ADMIN" → ❌ 매칭 안됨! (ROLE_ 없으니)
| 메서드 | 인자 | 내부 비교 대상 | 우리 DB 역할값 | 결과 |
|---|---|---|---|---|
hasRole("ADMIN") |
"ADMIN" | "ROLE_ADMIN" (자동 추가) | "ROLE_ADMIN" | ✅ 매칭 |
hasAuthority("ROLE_ADMIN") |
"ROLE_ADMIN" | "ROLE_ADMIN" (그대로) | "ROLE_ADMIN" | ✅ 매칭 |
hasAuthority("ADMIN") |
"ADMIN" | "ADMIN" (그대로) | "ROLE_ADMIN" | ❌ 불일치 |
// javax.servlet.Filter를 직접 구현하면: public class MyFilter implements Filter { @Override public void doFilter(...) { ... } // 문제: 같은 요청에서 forward/include가 발생하면 // 이 filter가 여러 번 실행될 수 있음 → JWT 검증 중복 실행 위험 } // OncePerRequestFilter를 extends하면: public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(...) { ... } // 같은 요청에서 무조건 딱 한 번만 실행 보장 // JWT 검증은 요청당 1회만 해야 하므로 이게 안전함 } // 이름 그대로: Once (한 번) Per Request (요청당) Filter