← 참고자료📚 전체 맵참고자료 →
Spring Framework

Spring Security

왜 필요한가부터 JWT 인증 구현까지 — 빠짐없이 담은 완전 가이드

Part 1 — 개념 · Filter Chain · 인증 vs 인가 · UserDetails · JWT 흐름
Part 2 — JwtUtil · JwtFilter · 로그인 구현 · 권한 · CORS · 실전 코드
Spring Security가 왜 필요한가

보안을 직접 구현하면 실수가 너무 많다. Spring Security는 인증/인가에 필요한 것들을 프레임워크 레벨에서 제공한다.

보안을 직접 구현하면 생기는 문제들
비밀번호를 평문으로 DB에 저장하는 실수
로그인 여부 체크를 일부 API에서 빠뜨리는 실수
권한 체크 로직이 Controller마다 중복됨
토큰 위조/변조 검증 로직 직접 구현하면 허점 생기기 쉬움
CSRF, XSS 등 공격 대응을 매번 직접 해야 함
Spring Security가 해결해주는 것들
비밀번호 암호화 (BCrypt) 기본 제공
모든 요청에 인증 필터 자동 적용
URL별, 메서드별 권한 제어 한 곳에서 관리
JWT, OAuth2 등 다양한 인증 방식 지원
CSRF, 세션 고정 공격 등 기본 방어 내장
Spring Security의 핵심 두 가지

인증(Authentication) — 이 사람이 누구인가? (로그인)
인가(Authorization) — 이 사람이 이걸 해도 되나? (권한 체크)

동작 원리 — Filter Chain

Spring Security는 Filter 기반으로 동작한다. 요청이 Controller에 도달하기 전에 여러 Filter를 거친다.

요청 처리 흐름
클라이언트 HTTP 요청
Security Filter Chain
SecurityContextPersistenceFilter — SecurityContext 불러오기/저장
UsernamePasswordAuthenticationFilter — 폼 로그인 처리
JwtAuthenticationFilter (커스텀) — JWT 토큰 검증
ExceptionTranslationFilter — 인증/인가 예외를 401/403으로 변환
FilterSecurityInterceptor — URL별 권한 최종 체크
↓ 모든 Filter 통과하면
DispatcherServlet → Controller
Filter vs Interceptor 차이

Filter → 서블릿 레벨. DispatcherServlet 앞에서 동작. Spring Security가 여기서 동작.
Interceptor → 스프링 MVC 레벨. DispatcherServlet 이후, Controller 전에 동작.
Security는 반드시 Filter에서 처리해야 Controller에 도달 전에 막을 수 있음.

FilterChainProxy — 스프링 시큐리티의 중심
Filter Chain 동작 방식
// 클라이언트 요청 → 서블릿 컨테이너
// → DelegatingFilterProxy → FilterChainProxy
// → SecurityFilterChain (여러 Filter들의 체인)
// → DispatcherServlet → Controller

// 개발자가 직접 만드는 커스텀 필터 위치 지정 예시
http.addFilterBefore(
    jwtFilter,
    UsernamePasswordAuthenticationFilter.class
);
// UsernamePasswordAuthenticationFilter 앞에 JwtFilter 끼워넣기
의존성 추가 + 기본 설정
build.gradle
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'

// JWT 라이브러리 (jjwt)
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly    'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly    'io.jsonwebtoken:jjwt-jackson:0.11.5'
⚠️ 의존성 추가하면 즉시 일어나는 일

spring-boot-starter-security를 추가하는 순간 모든 API가 자동으로 막힘.
기본 로그인 페이지가 자동 생성되고, user / 랜덤 비밀번호로만 접근 가능.
→ SecurityConfig를 만들어서 직접 설정해야 함.

SecurityConfig 작성

Spring Security 3.x (Spring Boot 3.x) 기준. WebSecurityConfigurerAdapter는 deprecated 됐고 SecurityFilterChain Bean을 직접 등록하는 방식을 씀.

SecurityConfig.java
@Configuration
@EnableWebSecurity        // Spring Security 활성화
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtFilter jwtFilter;
    private final CustomAuthenticationEntryPoint authEntryPoint;
    private final CustomAccessDeniedHandler accessDeniedHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
            // REST API는 CSRF 비활성화 (쿠키/세션 안 쓰니까)
            .csrf(csrf -> csrf.disable())

            // JWT 쓰면 세션 불필요 → STATELESS
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // URL별 권한 설정
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()   // 로그인/회원가입은 허용
                .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자만
                .anyRequest().authenticated()               // 나머지는 로그인 필요
            )

            // 401/403 커스텀 처리
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(authEntryPoint)    // 401
                .accessDeniedHandler(accessDeniedHandler))   // 403

            // CORS 설정
            .cors(Customizer::withDefaults)

            // JWT 필터 등록 — UsernamePasswordAuthenticationFilter 앞에
            .addFilterBefore(jwtFilter,
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}
설정의미
csrf.disable()REST API + JWT 방식에서는 CSRF 토큰 불필요. 비활성화.
SessionCreationPolicy.STATELESS서버가 세션 만들지 않음. JWT 방식이니 세션 필요 없음.
permitAll()로그인 없이도 접근 가능
authenticated()로그인한 사람만 접근 가능
hasRole("ADMIN")ADMIN 권한 있는 사람만 접근 가능
인증 vs 인가
인증 (Authentication) — 이 사람이 누구인가?
로그인 = 인증
"아이디/비밀번호가 맞으면 당신이 라희씨군요"
성공하면 토큰(JWT) 발급
이후 요청마다 토큰으로 "나 라희야" 증명
인가 (Authorization) — 이걸 해도 되나?
권한 체크 = 인가
"라희씨는 USER 권한이니 관리자 페이지 접근 불가"
인증 이후에 일어남
URL별, 메서드별로 권한 체크
일상 비유

인증 = 회사 출입증 발급 ("당신이 직원임을 확인했습니다")
인가 = 출입증으로 들어갈 수 있는 구역 체크 ("당신은 연구소 구역은 못 들어갑니다")

HTTP Basic / Form 로그인 vs JWT — 왜 JWT를 쓰나
HTTP BasicForm 로그인 (세션)JWT ⭐
방식 매 요청마다 ID/PW를 Base64로 인코딩해서 헤더에 넣어 보냄 로그인하면 서버가 세션 생성, 클라이언트는 세션ID 쿠키로 보냄 로그인하면 토큰 발급, 이후 요청마다 토큰을 헤더에 넣어 보냄
서버 상태 Stateless (매번 인증) Stateful (세션 저장) Stateless (토큰으로 증명)
서버 확장 가능 서버 여러 대면 세션 공유 필요 서버 여러 대여도 문제 없음
보안 매번 PW 노출 위험 세션 하이재킹 위험 서명으로 위변조 방지
모바일/앱 불편 쿠키 처리 복잡 헤더에 토큰만 넣으면 됨
실무 거의 안 씀 모놀리식 웹에서 씀 REST API 표준
REST API + 모바일 앱 + MSA → JWT가 표준

서버가 상태를 저장하지 않아도 됨 (Stateless). 서버 여러 대 운영해도 세션 동기화 불필요.
클라이언트가 토큰을 들고 다니며 매 요청마다 자신을 증명. REST 원칙과 완벽히 일치.

UserDetails / UserDetailsService

Spring Security가 사용자 정보를 다루는 방식. DB에서 사용자를 어떻게 가져오는지 직접 구현해야 한다.

UserDetails — Spring Security가 인식하는 사용자 객체
UserDetails 구현
// Spring Security는 User 객체를 직접 모름
// UserDetails 인터페이스를 구현한 객체만 이해함

@Getter
public class CustomUserDetails implements UserDetails {

    private final User user;  // 우리 User 엔티티를 감쌈

    public CustomUserDetails(User user) {
        this.user = user;
    }

    // 권한 목록 반환
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
        // ROLE_USER, ROLE_ADMIN 형식이어야 함
    }

    @Override
    public String getPassword() { return user.getPassword(); }

    @Override
    public String getUsername() { return user.getEmail(); }
    // 이메일을 username으로 씀 (아이디 역할)

    // 계정 상태 관련 — 일단 전부 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; }

    // 우리 User 엔티티 꺼내기
    public User getUser() { return user; }
    public int getUserId() { return user.getId(); }
}
UserDetailsService — DB에서 사용자 꺼내오기
UserDetailsService 구현
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email)
            throws UsernameNotFoundException {

        User user = userRepository.findByEmail(email)
            .orElseThrow(() ->
                new UsernameNotFoundException("없는 사용자: " + email));

        return new CustomUserDetails(user);
    }
}

// Spring Security가 로그인 처리할 때 이 메서드를 자동 호출함
// 이메일로 DB에서 사용자 찾아서 UserDetails로 감싸서 반환
// 없으면 UsernameNotFoundException → 자동으로 401 처리
PasswordEncoder — 비밀번호 암호화
⚠️ 비밀번호를 절대 평문으로 저장하면 안 됨

DB가 해킹당했을 때 비밀번호가 그대로 노출됨.
BCrypt 같은 단방향 해시 알고리즘으로 암호화해서 저장해야 함.
단방향이라 복호화 불가 → 로그인할 때 입력한 PW를 같은 방식으로 해시해서 비교.

BCryptPasswordEncoder 사용법
// SecurityConfig에서 Bean 등록 (Part 1 ④에서 이미 등록)
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

// 회원가입할 때 — 비밀번호 암호화해서 저장
@Transactional
public User 회원가입(UserCreateRequest request) {
    User user = new User();
    user.setPassword(passwordEncoder.encode(request.getPassword()));
    // "1234" → "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
    return userRepository.save(user);
}

// 로그인할 때 — Spring Security가 자동으로 비교
// passwordEncoder.matches("입력한PW", "DB에저장된해시값")
// → true / false
// 직접 호출할 일은 거의 없음. AuthenticationManager가 자동으로 처리.
SecurityContext / SecurityContextHolder

인증된 사용자 정보가 어디에 저장되는지, 그리고 코드에서 현재 로그인한 사용자를 어떻게 꺼내는지.

인증 정보 저장 구조
SecurityContextHolder
ThreadLocal — 현재 스레드에 저장
SecurityContext
Authentication
principal (UserDetails) + authorities (권한목록) + credentials
현재 로그인한 사용자 꺼내기
// 방법 1 — SecurityContextHolder에서 직접 꺼내기
public User getCurrentUser() {
    Authentication auth = SecurityContextHolder
                            .getContext()
                            .getAuthentication();

    CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal();
    return userDetails.getUser();
}

// 방법 2 — Controller에서 @AuthenticationPrincipal 사용 (더 편리)
// → ⑩에서 설명

// JwtFilter에서 인증 정보 저장하는 방법
UsernamePasswordAuthenticationToken authentication =
    new UsernamePasswordAuthenticationToken(
        userDetails,    // principal
        null,            // credentials (JWT 방식에선 null)
        userDetails.getAuthorities()  // 권한 목록
    );

SecurityContextHolder.getContext().setAuthentication(authentication);
// 이걸 설정해야 이후 Filter/Controller에서 인증된 사용자로 인식됨
ThreadLocal — SecurityContext가 저장되는 곳

SecurityContextHolder는 기본적으로 ThreadLocal에 저장함.
ThreadLocal = 각 스레드마다 독립적인 저장공간.
A 사용자의 요청 스레드와 B 사용자의 요청 스레드는 서로 다른 SecurityContext를 가짐.
→ 다른 사용자의 인증 정보가 섞이지 않음.

JWT 기반 인증 흐름

JWT가 뭔지, 어떻게 생겼는지, 전체 인증 흐름이 어떻게 되는지.

JWT 구조
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJyYWhlZUBnbWFpbC5jb20iLCJpYXQiOjE2OTQ....SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header
알고리즘 정보
{ "alg": "HS256" }
Payload
실제 데이터 (Claims)
{ "sub": "이메일", "exp": 만료시간, "role": "USER" }
Signature
Header + Payload를 SecretKey로 서명
위변조 방지
⚠️ Payload는 암호화가 아님 — Base64 인코딩

Payload는 누구나 디코딩해서 내용을 볼 수 있음.
비밀번호 같은 민감한 정보를 Payload에 넣으면 안 됨.
Signature로 위변조를 막는 것이지, 내용을 숨기는 게 아님.

전체 JWT 인증 흐름
로그인 → 토큰 발급 → 이후 요청
① 로그인 (토큰 발급)
POST /api/auth/login {email, password}
AuthenticationManager가 UserDetailsService 호출
DB에서 사용자 조회 + BCrypt 비밀번호 비교
맞으면 JWT 토큰 생성해서 응답
{ accessToken: "eyJ...", refreshToken: "eyJ..." }
② 이후 요청 (토큰 검증)
GET /api/users — Header: Authorization: Bearer eyJ...
JwtFilter에서 토큰 꺼내기
토큰 서명 검증 + 만료 여부 확인
토큰에서 이메일 꺼내서 DB 조회
SecurityContext에 인증 정보 저장 → Controller 진입
Access Token vs Refresh Token
Access TokenRefresh Token
용도API 요청할 때마다 사용Access Token 만료됐을 때 재발급 요청
유효 기간짧게 (30분 ~ 1시간)길게 (7일 ~ 30일)
저장 위치클라이언트 메모리HttpOnly Cookie 또는 DB
이유탈취당해도 짧게 만료매번 로그인 안 해도 되도록
개념 · Filter Chain · 인증/인가 · UserDetails · JWT 흐름 완료
Part 2에서 JwtUtil · JwtFilter · 로그인 구현 · 권한 · CORS · 실전 코드를 이어서 설명합니다
← 참고자료📚 전체 맵참고자료 →