모든 API가 401을 반환할 때 — Spring Security 기초와 필터 체인

Spring Boot에 spring-boot-starter-security를 추가했다. 모든 API가 즉시 401 Unauthorized를 반환한다 — Spring Security가 기본적으로 모든 요청에 인증을 요구하기 때문이다. "Hello World" API도 접근할 수 없다.

이 글은 Spring Security의 기본 동작, SecurityFilterChain 구성, 폼 로그인, JWT, OAuth2 클라이언트 설정을 풀어간다. (Spring Boot Reference - Security)

Spring Security의 기본 동작 — "모든 것을 차단"

spring-boot-starter-security를 추가하면:

  1. 기본 사용자 user 생성 (비밀번호는 시작 로그에 출력)
  2. 모든 HTTP 요청에 인증 필요
  3. 폼 로그인 페이지 자동 생성 (/login)
  4. CSRF 보호 기본 활성화
// Spring Boot 4 / Java 25 — Security 설정 해제 (개발용)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()   // 공개 API
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()                     // 나머지 인증 필요
            )
            .csrf(csrf -> csrf.disable())   // REST API는 CSRF 비활성화
            .sessionManagement(session -> session.sessionCreationPolicy(
                SessionCreationPolicy.STATELESS));   // JWT용 stateless

        return http.build();
    }
}

SecurityFilterChain — 요청이 통과하는 필터들의 사슬

flowchart TD
    REQ["HTTP 요청"] --> F1["SecurityContextPersistenceFilter"]
    F1 --> F2["UsernamePasswordAuthenticationFilter"]
    F2 --> F3["BasicAuthenticationFilter"]
    F3 --> F4["AuthorizationFilter<br/>(권한 검사)"]
    F4 --> CTRL["Controller"]
    F4 -.->|"권한 없음"| RESP403["403 Forbidden"]
    F2 -.->|"인증 없음"| RESP401["401 Unauthorized"]

Spring Security는 여러 필터가 순차적으로 실행되는 필터 체인(filter chain) 구조다. 각 필터가 인증, 권한, CSRF, CORS 등을 담당한다. SecurityFilterChain Bean으로 이 체인을 커스터마이징한다.

비유: Spring Security의 필터 체인은 공항 보안 검색대와 같다. 여권 확인(인증) → 금속 탐지기(추가 검증) → 탑승권 확인(권한) 순서로 통과해야 비행기(컨트롤러)에 탈 수 있다. 어느 단계에서든 실패하면 이전 단계로 돌아가거나 공항 밖으로 쫓겨난다(401/403 응답).

내부 메커니즘: 요청이 들어오면 FilterChainProxySecurityFilterChain에 등록된 필터들을 순서대로 실행한다. UsernamePasswordAuthenticationFilter는 폼 로그인 데이터를 처리하고, BearerTokenAuthenticationFilter(JWT용)는 Authorization 헤더에서 토큰을 추출한다. 마지막 AuthorizationFilter가 "이 사용자가 이 URL에 접근할 권한이 있는가?"를 최종 판단한다. 판단 결과는 SecurityContextHolder(ThreadLocal)에 저장되어 컨트롤러에서도 접근할 수 있다.

인증(Authentication) vs 인가(Authorization)

개념 질문 담당 실패 시
인증(Authentication) "너 누구야?" 인증 필터 401 Unauthorized
인가(Authorization) "이거 해도 돼?" AuthorizationFilter 403 Forbidden

사용자 정의 — UserDetailsService

// Spring Boot 4 / Java 25 — 커스텀 사용자 인증
import org.springframework.security.core.userdetails.*;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.password.PasswordEncoder;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository repo;
    private final PasswordEncoder encoder;

    public CustomUserDetailsService(UserRepository repo, PasswordEncoder encoder) {
        this.repo = repo;
        this.encoder = encoder;
    }

    @Override
    public UserDetails loadUserByUsername(String email) {
        User user = repo.findByEmail(email)
            .orElseThrow(() -> new UsernameNotFoundException("사용자 없음: " + email));

        return org.springframework.security.core.userdetails.User
            .withUsername(user.getEmail())
            .password(user.getPassword())   // 암호화된 비밀번호
            .roles("USER")   // 권한
            .build();
    }
}

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

JWT (JSON Web Token) 인증

// Spring Boot 4 / Java 25 — JWT 필터 (개념)
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.*;

public class JwtAuthFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws IOException, ServletException {
        String token = request.getHeader("Authorization");
        if (token != null && token.startsWith("Bearer ")) {
            String jwt = token.substring(7);
            // JWT 검증 + 사용자 정보 추출
            // Authentication 객체 생성 + SecurityContext에 저장
        }
        chain.doFilter(request, response);
    }
}

// SecurityFilterChain에 JWT 필터 추가
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .addFilterBefore(new JwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    return http.build();
}

JWT는 stateless 인증의 표준이다 — 서버가 세션을 저장하지 않고, 토큰 자체에 사용자 정보를 포함한다. Spring Security의 SecurityContextAuthentication 객체를 설정하면, 이후 필터와 컨트롤러에서 인증된 사용자로 처리된다.

OAuth2 클라이언트 — 소셜 로그인

# Spring Boot 4 / Java 25 — application.yml (OAuth2)
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: your-client-id
            client-secret: your-client-secret
            scope: profile, email
// Spring Boot 4 / Java 25 — OAuth2 로그인 활성화
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/", "/login**").permitAll()
            .anyRequest().authenticated()
        )
        .oauth2Login(Customizer.withDefaults());   // Google, GitHub 등 소셜 로그인
    return http.build();
}

Spring Boot 4.0 / Spring Security 7.x

항목 Spring Boot 3 (Security 6) Spring Boot 4 (Security 7)
authorizeRequests deprecated 제거 (authorizeHttpRequests 사용)
antMatchers deprecated 제거 (requestMatchers 사용)
One-Time Token 추가 (JEP-330 기반 패스키스/매직 링크)
기본 구성 XML 방식 제거 완료 람다 DSL만 지원

Spring Security 7.x는 레거시 API(antMatchers, authorizeRequests)를 완전히 제거했다. 람다 DSL(http.authorizeHttpRequests(auth -> ...))만 지원한다. (Spring Security Reference)

실습 — 인증 없음 vs 인증됨

// Spring Boot 4 / Java 25 — SecurityPatternDemo.java
import java.util.*;

public class SecurityPatternDemo {
    public static void main(String[] args) {
        Set<String> publicPaths = Set.of("/api/public/health", "/api/public/info");
        Map<String, String> users = Map.of("alice", "ADMIN", "bob", "USER");

        // 시나리오 1: 인증 없음
        testAccess("/api/public/health", null, publicPaths, users);
        testAccess("/api/users", null, publicPaths, users);

        // 시나리오 2: USER 인증
        testAccess("/api/users", "bob", publicPaths, users);
        testAccess("/api/admin/dashboard", "bob", publicPaths, users);

        // 시나리오 3: ADMIN 인증
        testAccess("/api/admin/dashboard", "alice", publicPaths, users);
    }

    static void testAccess(String path, String user, Set<String> publicPaths,
                           Map<String, String> users) {
        if (publicPaths.contains(path)) {
            System.out.println(path + " (user=" + user + "): 200 OK (공개)");
        } else if (user == null) {
            System.out.println(path + " (user=없음): 401 Unauthorized");
        } else {
            String role = users.get(user);
            if (path.startsWith("/api/admin") && !"ADMIN".equals(role)) {
                System.out.println(path + " (user=" + user + "): 403 Forbidden");
            } else {
                System.out.println(path + " (user=" + user + ", role=" + role + "): 200 OK");
            }
        }
    }
}
java SecurityPatternDemo.java
/api/public/health (user=null): 200 OK (공개)
/api/users (user=없음): 401 Unauthorized
/api/users (user=bob): 200 OK
/api/admin/dashboard (user=bob): 403 Forbidden
/api/admin/dashboard (user=alice): 200 OK

확인할 것: 공개 경로는 인증 없이 접근 가능. 인증이 필요한 경로는 401. 권한이 부족하면 403.

메서드 수준 보안 — @PreAuthorize

// Spring Boot 4 / Java 25 — 메서드 수준 권한
@Configuration
@EnableMethodSecurity   // Spring Security 6+ (기본적으로 prePostEnabled=true)
public class SecurityConfig { }

@Service
public class AdminService {

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteUser(Long id) { ... }

    @PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
    public UserDto getUserProfile(Long userId) {
        // 자신의 프로필이거나 ADMIN만 접근 가능
    }

    @PostAuthorize("returnObject.owner == authentication.principal.username")
    public Document getDocument(Long id) {
        // 반환 후 검사: 문서 소유자만 접근
    }
}

@PreAuthorize로 메서드 실행 전 권한을 검사한다. SpEL(Spring Expression Language)로 복잡한 조건을 표현할 수 있다 — "본인이거나 ADMIN", "특정 IP 대역" 등.

요약 — 이 글의 결론

  • Spring Security는 기본적으로 모든 요청을 차단한다. SecurityFilterChain Bean으로 접근 규칙을 명시적으로 설정해야 한다.
  • 인증(Authentication)과 인가(Authorization)를 구분한다. 401(인증 실패)과 403(권한 부족)을 올바르게 반환한다.
  • UserDetailsService로 사용자 인증 로직을 커스터마이징한다. BCryptPasswordEncoder로 비밀번호를 안전하게 해싱.
  • JWT로 stateless 인증을 구현한다. 필터에서 토큰을 검증하고 SecurityContext에 인증 정보를 저장한다.
  • OAuth2 클라이언트로 소셜 로그인을 쉽게 통합한다. Google, GitHub 등의 프로바이더를 application.yml 한 줄로 추가.
  • Spring Security 7.x는 레거시 API를 완전히 제거했다. 람다 DSL(authorizeHttpRequests, requestMatchers)만 사용한다.

생각해 볼 문제

  1. REST API에서 CSRF를 비활성화해도 되는 이유는? 폼 기반 앱에서는 왜 활성화해야 하는가?
  2. JWT 토큰 만료 시간을 설정할 때 고려할 점은? 너무 짧으면? 너무 길면?
  3. SecurityContextHolderSecurityContext가 thread local에 저장되는 이유는? Virtual Thread에서 문제가 되는가?
  4. Spring Security 7에서 @PreAuthorize("hasRole('ADMIN')")를 쓰려면 어떤 설정이 필요한가?
  5. OAuth2 인가 코드 그랜트 플로우(authorization code grant)의 각 단계를 설명하라.
  6. Spring Security 7에서 SecurityFilterChain이 람다 DSL만 지원하는 것이 사용자에게 미치는 영향은?

참고

  1. Spring Boot 4.0과 이전 버전(Spring Boot 3.x)의 가장 큰 차이점은 무엇인가? 마이그레이션 시 주의할 점은?
  2. 이 글에서 다룬 개념을 실제 프로젝트에 적용할 때 가장 먼저 고려해야 할 것은?

'Develop Artifacts > Spring Boot' 카테고리의 다른 글

SpringBoot - 09. config profile  (0) 2026.07.12
SpringBoot - 08. actuator  (0) 2026.07.12
SpringBoot - 06. testing  (0) 2026.07.12
SpringBoot - 05. spring-data-jpa  (0) 2026.07.12
SpringBoot - 4. rest api  (0) 2026.07.12