Spring Security를 실무 흐름으로 이해하기
Spring Boot 기반 Spring Security 6로 JWT 인증, OAuth2 소셜 로그인, Role 기반 인가, Method Security, CSRF 방어, 테스트까지 실전 보안 아키텍처를 구성합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
Spring Boot 기반 Spring Security 6로 JWT 인증, OAuth2 소셜 로그인, Role 기반 인가, Method Security, CSRF 방어, 테스트까지 실전 보안 아키텍처를 구성합니다.
Spring Boot 기반 Spring Security 6로 JWT 인증, OAuth2 소셜 로그인, Role 기반 인가, Method Security, CSRF 방어, 테스트까지 실전 보안 아키텍처를 구성합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
문법보다 요청이 들어와 검증, 처리, 저장, 응답으로 이어지는 경계를 먼저 잡습니다.
글로 읽은 내용을 머릿속에 오래 남기려면 먼저 흐름을 그림으로 잡는 편이 좋습니다. 아래 두 그림은 Spring Security를 학습할 때 계속 되돌아볼 수 있는 기준 지도입니다.
Spring Security를 처음 펼칠 때는 세부 명령보다 큰 그림이 먼저입니다. 이 섹션에서는 앞으로 배울 개념들이 어떤 문제를 풀기 위해 등장했는지부터 잡아봅니다.
| 개념 | 설명 |
|---|---|
| Authentication | 사용자가 누구인지 확인 (로그인, JWT 검증) |
| Authorization | 어떤 자원에 접근할 수 있는지 결정 (Role, Permission) |
| SecurityFilterChain | HTTP 요청마다 실행되는 보안 필터 체인 |
| UserDetails | 사용자 정보를 Spring Security에 전달하는 인터페이스 |
여기서는 Spring Boot 프로젝트 설정을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
plugins {
id("java")
id("org.springframework.boot") version "3.3.5"
id("io.spring.dependency-management") version "1.1.6"
}
java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
}여기서는 SecurityFilterChain 구성을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
var provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
}여기서는 JWT 필터 구현을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
final String username = jwtService.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
var authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}여기서는 UserDetailsService을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("사용자 없음: " + email));
}
}
// User Entity — UserDetails 구현
@Entity @Table(name = "users")
@Getter @Builder @NoArgsConstructor @AllArgsConstructor
public class User implements UserDetails {
@Id @GeneratedValue(strategy = IDENTITY) private Long id;
private String name;
@Column(unique = true) private String email;
private String password;
@Enumerated(STRING) private Role role;
@Override public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}
@Override public String getUsername() { return email; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return true; }
}여기서는 Method Security을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@Service
@PreAuthorize("isAuthenticated()")
public class PostService {
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public List<Post> findByUser(Long userId) { ... }
@PreAuthorize("hasRole('ADMIN')")
public void deleteAll() { ... }
@PostAuthorize("returnObject.author == authentication.name")
public Post findById(Long id) { ... }
}여기서는 OAuth2 소셜 로그인을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: openid, profile, email@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(request);
String email = oAuth2User.getAttribute("email");
String name = oAuth2User.getAttribute("name");
User user = userRepository.findByEmail(email)
.orElseGet(() -> userRepository.save(
User.builder().email(email).name(name).role(Role.USER).build()
));
return new DefaultOAuth2User(
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole())),
oAuth2User.getAttributes(), "email"
);
}
}여기서는 보안 테스트을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@SpringBootTest
@AutoConfigureMockMvc
class SecurityTest {
@Autowired MockMvc mvc;
@Test
void 인증_없이_보호된_경로는_401() throws Exception {
mvc.perform(get("/api/users"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void 일반_유저는_사용자_목록_조회_가능() throws Exception {
mvc.perform(get("/api/users"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void 일반_유저는_어드민_엔드포인트_접근_불가() throws Exception {
mvc.perform(delete("/api/admin/users/1"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void 어드민은_삭제_가능() throws Exception {
mvc.perform(delete("/api/admin/users/1"))
.andExpect(status().isNoContent());
}
}