Security 개요📚 전체 맵Security 백엔드
05b · 3차 프로젝트 · Spring Security

Filter Chain

Spring Security 동작 원리 완전 해부 — Filter Chain · SecurityContext · UserDetails · @AuthenticationPrincipal

시리즈 2/5 · 05a 개요 파악 후 읽기 권장

이 파일의 학습 계획
05b에서 다루는 것들

코드를 보기 전에 "어떻게 동작하는지"를 먼저 이해해야 코드가 보입니다. 이 파일에서는 Spring Security의 내부 동작 원리를 시각화하면서 설명합니다. 개념을 제대로 잡고 나면 05c(백엔드 코드)가 훨씬 자연스럽게 읽힙니다.

#주제핵심 질문
1Spring Security가 뭔가왜 직접 만든 Filter 대신 Security를 써야 하나
2Filter Chain 동작 순서요청 하나가 들어오면 실제로 뭐가 실행되나
3JWT + Security 결합 흐름JWT 토큰을 Security가 어떻게 인식하나
4UserDetails / UserDetailsService인터페이스를 왜 구현해야 하나
5SecurityContext / Authentication인증 정보는 어디에 어떻게 저장되나
6@AuthenticationPrincipalSecurityContext에서 어떻게 꺼내서 주입해주나
7hasRole vs hasAuthorityROLE_ 접두사가 어디서 붙나
8OncePerRequestFilter왜 Filter를 직접 extends 안 하고 이걸 쓰나
Section 1
Spring Security란 무엇인가

Spring Security는 Spring 공식 보안 프레임워크입니다. "모든 HTTP 요청을 가로채서 인증·권한을 처리하는 필터들의 집합"이라고 보면 됩니다. 직접 만든 Filter와 다른 점은, Security가 이미 검증된 표준 패턴을 제공한다는 것입니다.

🔐 Security를 추가하면 Spring Boot가 자동으로 하는 일

spring-boot-starter-security 의존성을 추가하는 순간:

모든 URL에 인증 요구를 자동으로 걸어버립니다. 로그인 없이 접근하면 403 자동 반환.
② 기본 로그인 페이지(/login)를 자동 생성합니다 (우리는 React 쓰므로 비활성화).
③ SecurityFilterChain을 자동 구성합니다.

이 기본 동작을 우리 프로젝트에 맞게 커스텀하는 것SecurityConfig.java의 역할입니다.
SecurityConfig에서 "로그인·회원가입은 누구나 접근 가능, 나머지는 JWT 인증 필요, /api/admin/** 은 ADMIN만"을 선언합니다.

직접 만든 Filter vs Spring Security — 무엇이 다른가
항목2차 직접 만든 JwtFilter3차 Spring Security 방식
인증 정보 저장 request.setAttribute("loginUser", user)
요청 객체에 직접 저장 — 비표준
SecurityContextHolder.getContext().setAuthentication(auth)
Security 표준 저장소 사용
로그인 유저 꺼내기 (User) request.getAttribute("loginUser")
Controller마다 직접 꺼내고 null 체크
@AuthenticationPrincipal CustomUserDetails
Spring이 자동 주입 — null 체크 불필요
URL 권한 설정 Controller 메서드마다 if(loginUser == null) return 401 SecurityConfig 한 곳에서 .authorizeHttpRequests()로 선언
역할 기반 제어 직접 role 체크 코드 작성해야 함 .hasRole("ADMIN") 선언 하나로 끝
표준화 우리만 아는 방식 Spring 표준 — 다른 개발자도 바로 이해 가능
Section 2
Filter Chain 동작 순서 — 요청 하나가 오면 뭐가 실행되나

React에서 POST /api/board/write 요청이 오면, Controller의 write() 메서드에 도달하기 전에 아래의 Filter들이 순서대로 실행됩니다. filterChain.doFilter(request, response)를 호출해야 다음 Filter로 넘어갑니다.

1
SecurityContextHolderFilter Security 기본 제공
SecurityContext(신분증 보관함)를 초기화합니다.
매 요청마다 빈 SecurityContext를 만들어 SecurityContextHolder에 세팅. 요청이 끝나면 자동으로 비워서 다음 요청과 섞이지 않게 합니다.
→ JWT는 StateLess — 매 요청마다 새로 인증하므로 이전 요청 정보가 남아있으면 안 됨
2
CorsFilter Security 기본 제공
React(localhost:3000)에서 온 요청을 허용합니다.
SecurityConfig의 corsConfigurationSource() 설정을 읽어서 CORS 처리.
허용되지 않은 Origin이면 여기서 차단. Controller까지 가지 않습니다.
→ 2차에서는 별도 CorsConfig.java에서 처리했지만 3차에서는 SecurityConfig에 통합
3
JwtAuthenticationFilter 우리가 만든 것
JWT 토큰을 검증하고 SecurityContext에 인증 정보를 저장합니다.
Authorization: Bearer <token> 헤더 읽기
② 토큰 유효성 검증 (jwtUtil.validateToken())
③ 토큰에서 username 추출 → DB에서 유저 조회 (loadUserByUsername())
UsernamePasswordAuthenticationToken 생성 → SecurityContextHolder에 저장
filterChain.doFilter()로 다음 Filter에 넘김
토큰이 없거나 유효하지 않으면 SecurityContext에 저장하지 않고 그냥 넘김 (다음 Filter가 처리)
4
AuthorizationFilter Security 기본 제공
SecurityConfig의 설정대로 권한을 체크합니다.
SecurityContext에 Authentication이 있으면 인증된 유저로 판단.
/api/admin/** 요청인데 ROLE_ADMIN이 없으면 → 403 Forbidden 자동 반환
인증 정보가 아예 없는데 인증이 필요한 URL이면 → 401 Unauthorized 자동 반환
Controller까지 가지 않고 여기서 차단됩니다 — 이게 2차보다 훨씬 깔끔한 이유
5
DispatcherServlet → Controller
모든 Filter를 통과한 요청만 Controller에 도달합니다.
SecurityContext에 저장된 인증 정보를 @AuthenticationPrincipal로 꺼내 메서드 파라미터에 주입.
💡 addFilterBefore — 우리 Filter를 어디에 끼워 넣나

SecurityConfig에서 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) 라고 설정합니다.

UsernamePasswordAuthenticationFilter는 Security 기본 폼 로그인 처리 Filter인데, 우리는 폼 로그인 대신 JWT를 쓰므로 그 앞에 우리 JwtAuthenticationFilter를 실행시킵니다.
즉, JWT 검증 → SecurityContext 저장이 이뤄진 다음에 Security 기본 인증 처리로 넘어가는 구조.

Section 3
UserDetails / UserDetailsService — 왜 인터페이스를 구현해야 하나

Spring Security는 유저 정보를 다루는 표준 인터페이스를 정의해놨습니다. Security가 내부적으로 이 인터페이스 타입으로만 유저를 다루기 때문에, 우리 User 엔티티를 이 표준에 맞게 포장해주는 클래스가 필요합니다.

UserDetails 인터페이스 — Security의 유저 표준
UserDetails.java (Spring Security 제공 인터페이스 — 우리가 만드는 게 아님)
// 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 엔티티를 직접 UserDetails로 쓰지 않고 CustomUserDetails로 포장하나

User 엔티티에 implements UserDetails를 직접 붙일 수도 있습니다.
하지만 그렇게 하면 DB 테이블 구조(엔티티)와 Security 표준(UserDetails)이 결합됩니다.
예를 들어 나중에 Security에서 분리하거나 엔티티를 바꾸면 연쇄 수정이 생깁니다.

CustomUserDetails로 User 엔티티를 안에 품어서 포장하면:
① 엔티티는 순수하게 DB 매핑 역할만 담당
② Security 관련 로직은 CustomUserDetails에서만 관리
userDetails.getUser()로 실제 User 엔티티에 접근 가능
이게 책임 분리(SRP)의 원칙입니다.

UserDetailsService 인터페이스 — Security의 유저 로딩 표준
UserDetailsService.java (Spring Security 제공 인터페이스)
// 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 표준대로 호출하므로 나중에 구현체를 바꿔도 필터 코드는 그대로
Section 4
SecurityContext & Authentication — 인증 정보가 어디에 어떻게 저장되나
Authentication 객체 구조

JwtAuthenticationFilter가 SecurityContext에 저장하는 UsernamePasswordAuthenticationToken 객체 안에 뭐가 들어있는지 살펴봅니다.

UsernamePasswordAuthenticationToken (Authentication 구현체)
Object
principal
CustomUserDetails 객체userDetails
User 엔티티를 품고 있는 포장 객체. @AuthenticationPrincipal로 꺼내면 바로 이 객체가 나옴.
userDetails.getUser() → 실제 User 엔티티
userDetails.getUsername() → "hong"
userDetails.getAuthorities() → [ROLE_USER]
Object
credentials
null
원래 비밀번호를 담는 자리. JWT 방식에서는 이미 토큰으로 검증했으므로 null 전달.
Collection
authorities
[SimpleGrantedAuthority("ROLE_USER")] 또는 [SimpleGrantedAuthority("ROLE_ADMIN")]
userDetails.getAuthorities()에서 가져옴. AuthorizationFilter가 이 목록으로 권한 체크.
boolean
authenticated
true
authorities를 넘기는 3인자 생성자를 쓰면 authenticated=true 자동 설정. Security가 인증된 유저로 인식.
SecurityContextHolder — 저장 흐름
// 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 엔티티
}
📌 SecurityContextHolder가 Thread-Local을 쓰는 이유

서버는 여러 요청을 동시에 처리합니다. 요청 A의 인증 정보와 요청 B의 인증 정보가 섞이면 안 되죠.
SecurityContextHolder는 Thread-Local을 사용합니다.
Thread-Local은 "각 스레드가 자기만의 변수를 따로 갖는 것" — 같은 변수명이지만 스레드마다 다른 값.

요청이 들어오면 → 해당 스레드에서만 SecurityContext가 보임
요청이 끝나면 → SecurityContextHolderFilter가 자동으로 SecurityContext를 비움
→ 다른 요청의 인증 정보가 절대 섞이지 않음.

Section 5
@AuthenticationPrincipal — SecurityContext에서 자동 주입
2차 vs 3차 — 로그인 유저 꺼내는 방식 비교
// ===== 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에서 저장한 것)
Section 6
hasRole vs hasAuthority & OncePerRequestFilter
hasRole vs hasAuthority — ROLE_ 접두사의 진실
권한 설정 방식 비교
// 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" ❌ 불일치
OncePerRequestFilter — 왜 Filter를 직접 안 쓰나
OncePerRequestFilter vs Filter
// 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
✅ 05b 정리 — 05c(백엔드 코드) 읽기 전 체크리스트
  • Filter Chain이 어떤 순서로 실행되는지 이해했다
  • JwtAuthenticationFilter가 SecurityContext에 Authentication을 저장하는 이유를 안다
  • UserDetails / UserDetailsService 인터페이스를 왜 구현해야 하는지 안다
  • @AuthenticationPrincipal이 SecurityContext.getAuthentication().getPrincipal()을 꺼내준다는 걸 안다
  • hasRole("ADMIN")이 내부적으로 "ROLE_ADMIN"을 찾는다는 걸 안다
  • OncePerRequestFilter가 요청당 1회만 실행을 보장한다는 걸 안다
Security 개요📚 전체 맵Security 백엔드