모든 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를 추가하면:
- 기본 사용자
user생성 (비밀번호는 시작 로그에 출력) - 모든 HTTP 요청에 인증 필요
- 폼 로그인 페이지 자동 생성 (
/login) - 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 등을 담당한다.
SecurityFilterChainBean으로 이 체인을 커스터마이징한다.
비유: Spring Security의 필터 체인은 공항 보안 검색대와 같다. 여권 확인(인증) → 금속 탐지기(추가 검증) → 탑승권 확인(권한) 순서로 통과해야 비행기(컨트롤러)에 탈 수 있다. 어느 단계에서든 실패하면 이전 단계로 돌아가거나 공항 밖으로 쫓겨난다(401/403 응답).
내부 메커니즘: 요청이 들어오면 FilterChainProxy가 SecurityFilterChain에 등록된 필터들을 순서대로 실행한다. 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의
SecurityContext에Authentication객체를 설정하면, 이후 필터와 컨트롤러에서 인증된 사용자로 처리된다.
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는 기본적으로 모든 요청을 차단한다.
SecurityFilterChainBean으로 접근 규칙을 명시적으로 설정해야 한다. - 인증(Authentication)과 인가(Authorization)를 구분한다. 401(인증 실패)과 403(권한 부족)을 올바르게 반환한다.
UserDetailsService로 사용자 인증 로직을 커스터마이징한다.BCryptPasswordEncoder로 비밀번호를 안전하게 해싱.- JWT로 stateless 인증을 구현한다. 필터에서 토큰을 검증하고
SecurityContext에 인증 정보를 저장한다. - OAuth2 클라이언트로 소셜 로그인을 쉽게 통합한다. Google, GitHub 등의 프로바이더를
application.yml한 줄로 추가. - Spring Security 7.x는 레거시 API를 완전히 제거했다. 람다 DSL(
authorizeHttpRequests,requestMatchers)만 사용한다.
생각해 볼 문제
- REST API에서 CSRF를 비활성화해도 되는 이유는? 폼 기반 앱에서는 왜 활성화해야 하는가?
- JWT 토큰 만료 시간을 설정할 때 고려할 점은? 너무 짧으면? 너무 길면?
SecurityContextHolder의SecurityContext가 thread local에 저장되는 이유는? Virtual Thread에서 문제가 되는가?- Spring Security 7에서
@PreAuthorize("hasRole('ADMIN')")를 쓰려면 어떤 설정이 필요한가? - OAuth2 인가 코드 그랜트 플로우(authorization code grant)의 각 단계를 설명하라.
- Spring Security 7에서
SecurityFilterChain이 람다 DSL만 지원하는 것이 사용자에게 미치는 영향은?
참고
- Spring Boot 4.0 - Security - 접근 2026-07-10
- Spring Security 7 Reference - 접근 2026-07-10
- Spring Boot 4.0 - OAuth2 - 접근 2026-07-10
- Spring Security 7 - Method Security - 접근 2026-07-10
- Spring Boot 4.0과 이전 버전(Spring Boot 3.x)의 가장 큰 차이점은 무엇인가? 마이그레이션 시 주의할 점은?
- 이 글에서 다룬 개념을 실제 프로젝트에 적용할 때 가장 먼저 고려해야 할 것은?
- Spring Framework 7 Reference - 접근 2026-07-10
- Jakarta EE 11 Specification - 접근 2026-07-10
- Spring Boot 4.0 Reference - 접근 2026-07-10
'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 |
