이 글에서 얻는 것
- Spring Security의 핵심 흐름(Security Filter Chain)을 잡고, 인증/인가가 어디에서 결정되는지 설명할 수 있습니다.
- 세션 기반/토큰 기반(JWT) 인증을 구분하고, Refresh Token/회수/만료 같은 운영 이슈를 고려할 수 있습니다.
- OAuth2 Authorization Code 흐름을 이해하고, “왜 PKCE가 필요한지” 같은 실무 질문에 답할 수 있습니다.
들어가며
Spring Security는 인증(Authentication)과 인가(Authorization)를 제공하는 강력한 보안 프레임워크입니다. 이 글에서는 Security Filter Chain부터 JWT, OAuth2까지 실전에 필요한 모든 내용을 다룹니다.
난이도: ⭐⭐⭐ 심화 예상 학습 시간: 50분
1. Spring Security 아키텍처
1.1 Security Filter Chain 구조
HTTP 요청
↓
┌─────────────────────────────────────────────┐
│ Security Filter Chain (순서대로 실행) │
├─────────────────────────────────────────────┤
│ 1. SecurityContextPersistenceFilter │
│ - SecurityContext 로드/저장 │
│ │
│ 2. LogoutFilter │
│ - 로그아웃 요청 처리 │
│ │
│ 3. UsernamePasswordAuthenticationFilter │
│ - Form 로그인 처리 │
│ │
│ 4. BasicAuthenticationFilter │
│ - HTTP Basic 인증 │
│ │
│ 5. BearerTokenAuthenticationFilter │
│ - JWT 토큰 검증 │
│ │
│ 6. ExceptionTranslationFilter │
│ - 인증/인가 예외 처리 │
│ │
│ 7. FilterSecurityInterceptor │
│ - 최종 인가 결정 │
└─────────────────────────────────────────────┘
↓
Controller
1.2 핵심 컴포넌트
// 1. SecurityContext - 인증 정보 저장소
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
// 2. Authentication - 인증 주체
public interface Authentication extends Principal {
Collection<? extends GrantedAuthority> getAuthorities(); // 권한
Object getCredentials(); // 비밀번호
Object getPrincipal(); // 사용자 정보 (UserDetails)
boolean isAuthenticated();
}
// 3. UserDetails - 사용자 정보
public interface UserDetails {
String getUsername();
String getPassword();
Collection<? extends GrantedAuthority> getAuthorities();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
// 4. UserDetailsService - 사용자 조회
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
// 5. AuthenticationManager - 인증 처리
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
2. Form 로그인 구현
2.1 기본 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/signup").permitAll() // 인증 불필요
.requestMatchers("/admin/**").hasRole("ADMIN") // ADMIN 권한
.anyRequest().authenticated() // 나머지는 인증 필요
)
.formLogin(form -> form
.loginPage("/login") // 커스텀 로그인 페이지
.loginProcessingUrl("/login/process") // 로그인 처리 URL
.defaultSuccessUrl("/dashboard") // 로그인 성공 시 이동
.failureUrl("/login?error=true") // 로그인 실패 시 이동
.usernameParameter("email") // username 파라미터명 변경
.passwordParameter("pwd") // password 파라미터명 변경
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/")
.invalidateHttpSession(true) // 세션 무효화
.deleteCookies("JSESSIONID") // 쿠키 삭제
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1) // 동시 세션 1개로 제한
.maxSessionsPreventsLogin(true) // 새 로그인 차단
);
return http.build();
}
}
2.2 UserDetailsService 구현
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1. DB에서 사용자 조회
User user = userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));
// 2. UserDetails 구현체 반환
return org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPassword()) // 이미 BCrypt로 암호화됨
.roles(user.getRoles().toArray(new String[0]))
.accountExpired(false)
.accountLocked(false)
.credentialsExpired(false)
.disabled(false)
.build();
}
}
// 또는 UserDetails 직접 구현
@Entity
@Table(name = "users")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@ElementCollection(fetch = FetchType.EAGER)
@Enumerated(EnumType.STRING)
private Set<Role> roles = new HashSet<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
2.3 PasswordEncoder 설정
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt 사용 (strength: 10 ~ 12 권장)
return new BCryptPasswordEncoder(12);
}
}
// 사용 예시
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public void signup(SignupRequest request) {
// 비밀번호 암호화
String encodedPassword = passwordEncoder.encode(request.getPassword());
User user = User.builder()
.email(request.getEmail())
.password(encodedPassword)
.roles(Set.of(Role.USER))
.build();
userRepository.save(user);
}
public boolean login(LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new UsernameNotFoundException("사용자 없음"));
// 비밀번호 검증
return passwordEncoder.matches(request.getPassword(), user.getPassword());
}
}
3. JWT (JSON Web Token) 인증
3.1 JWT 구조
JWT = Header.Payload.Signature
예시:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwicm9sZXMiOlsiVVNFUiJdLCJleHAiOjE3MDYyNTYwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
디코딩:
┌─────────────────────────────────────┐
│ Header (Base64) │
│ { │
│ "alg": "HS256", │
│ "typ": "JWT" │
│ } │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Payload (Base64) │
│ { │
│ "sub": "user@example.com", │
│ "roles": ["USER"], │
│ "exp": 1706256000 │
│ } │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Signature (HMACSHA256) │
│ HMACSHA256( │
│ base64UrlEncode(header) + "." + │
│ base64UrlEncode(payload), │
│ secret │
│ ) │
└─────────────────────────────────────┘
3.2 JWT 유틸리티 클래스
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-validity}")
private long accessTokenValidityInMs; // 15분
@Value("${jwt.refresh-token-validity}")
private long refreshTokenValidityInMs; // 7일
private Key getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
// Access Token 생성
public String generateAccessToken(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenValidityInMs);
return Jwts.builder()
.setSubject(userDetails.getUsername())
.claim("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()))
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
.compact();
}
// Refresh Token 생성
public String generateRefreshToken(String username) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshTokenValidityInMs);
return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
.compact();
}
// Token에서 사용자 이름 추출
public String getUsernameFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
// Token에서 권한 추출
public List<String> getRolesFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
return claims.get("roles", List.class);
}
// Token 유효성 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.error("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.error("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.error("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.error("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
3.3 JWT Authentication Filter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
try {
// 1. Request Header에서 JWT 추출
String jwt = getJwtFromRequest(request);
// 2. Token 유효성 검증
if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
// 3. Token에서 사용자 정보 추출
String username = jwtTokenProvider.getUsernameFromToken(jwt);
// 4. UserDetails 조회
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 5. Authentication 객체 생성
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// 6. SecurityContext에 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
log.error("인증 정보를 설정할 수 없습니다", e);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // "Bearer " 제거
}
return null;
}
}
3.4 JWT Security 설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class JwtSecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // JWT 사용 시 CSRF 불필요
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용 안 함
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // 로그인/회원가입
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
// JWT 필터 추가 (UsernamePasswordAuthenticationFilter 앞에)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exception -> exception
.authenticationEntryPoint((request, response, authException) -> {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
})
);
return http.build();
}
}
3.5 로그인/토큰 발급 API
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenService refreshTokenService;
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest request) {
// 1. 인증
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
// 2. SecurityContext에 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
// 3. Access Token 생성
String accessToken = jwtTokenProvider.generateAccessToken(authentication);
// 4. Refresh Token 생성 및 저장
String refreshToken = jwtTokenProvider.generateRefreshToken(request.getEmail());
refreshTokenService.save(request.getEmail(), refreshToken);
return ResponseEntity.ok(TokenResponse.of(accessToken, refreshToken));
}
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refresh(@RequestBody RefreshTokenRequest request) {
String refreshToken = request.getRefreshToken();
// 1. Refresh Token 유효성 검증
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new InvalidTokenException("유효하지 않은 Refresh Token입니다");
}
// 2. DB에서 Refresh Token 확인
String username = jwtTokenProvider.getUsernameFromToken(refreshToken);
if (!refreshTokenService.exists(username, refreshToken)) {
throw new InvalidTokenException("만료되거나 삭제된 Refresh Token입니다");
}
// 3. 새 Access Token 발급
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
String newAccessToken = jwtTokenProvider.generateAccessToken(authentication);
return ResponseEntity.ok(TokenResponse.of(newAccessToken, refreshToken));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestBody LogoutRequest request) {
String username = SecurityContextHolder.getContext()
.getAuthentication().getName();
// Refresh Token 삭제
refreshTokenService.delete(username);
return ResponseEntity.ok().build();
}
}
3.6 Refresh Token 관리
@Entity
@Table(name = "refresh_tokens")
@Getter
@NoArgsConstructor
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false, length = 500)
private String token;
@Column(nullable = false)
private LocalDateTime expiryDate;
public static RefreshToken of(String username, String token, LocalDateTime expiryDate) {
RefreshToken refreshToken = new RefreshToken();
refreshToken.username = username;
refreshToken.token = token;
refreshToken.expiryDate = expiryDate;
return refreshToken;
}
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryDate);
}
}
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
@Value("${jwt.refresh-token-validity}")
private long refreshTokenValidityInMs;
public void save(String username, String token) {
LocalDateTime expiryDate = LocalDateTime.now()
.plusSeconds(refreshTokenValidityInMs / 1000);
RefreshToken refreshToken = RefreshToken.of(username, token, expiryDate);
// 기존 토큰 삭제 후 저장
refreshTokenRepository.deleteByUsername(username);
refreshTokenRepository.save(refreshToken);
}
public boolean exists(String username, String token) {
return refreshTokenRepository.findByUsername(username)
.map(rt -> rt.getToken().equals(token) && !rt.isExpired())
.orElse(false);
}
public void delete(String username) {
refreshTokenRepository.deleteByUsername(username);
}
// 만료된 토큰 정리 (스케줄러)
@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시
public void deleteExpiredTokens() {
refreshTokenRepository.deleteByExpiryDateBefore(LocalDateTime.now());
}
}
4. OAuth2 소셜 로그인
4.1 OAuth2 흐름 (Authorization Code Grant)
사용자 클라이언트 인증 서버 리소스 서버
│ │ │ │
├─ 1. 로그인 요청 ────→│ │ │
│ │ │ │
│←─ 2. 인증 서버 리다이렉트 ─┤ │ │
│ │ │ │
├─ 3. 로그인 & 권한 동의 ────────────────────→│ │
│ │ │ │
│←─ 4. Authorization Code ──────────────────┤ │
│ │ │ │
├─ 5. Code 전달 ──────→│ │ │
│ │ │ │
│ ├─ 6. Token 요청 ────→│ │
│ │ (Code + Secret) │ │
│ │ │ │
│ │←─ 7. Access Token ─┤ │
│ │ │ │
│ ├─ 8. 사용자 정보 요청 ─────────────────→│
│ │ (Access Token) │ │
│ │ │ │
│ │←─ 9. 사용자 정보 ─────────────────────┤
│ │ │ │
│←─ 10. 로그인 완료 ────┤ │ │
│ │ │ │
4.2 OAuth2 설정 (Google, Kakao)
# application.yml
spring:
security:
oauth2:
client:
registration:
# Google
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- email
- profile
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
# Kakao
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
scope:
- profile_nickname
- account_email
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
4.3 OAuth2 Security 설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class OAuth2SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2AuthenticationSuccessHandler successHandler;
private final OAuth2AuthenticationFailureHandler failureHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/oauth2/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService) // 커스텀 사용자 서비스
)
.successHandler(successHandler) // 성공 핸들러
.failureHandler(failureHandler) // 실패 핸들러
);
return http.build();
}
}
4.4 CustomOAuth2UserService 구현
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 1. OAuth2 사용자 정보 가져오기
OAuth2User oauth2User = super.loadUser(userRequest);
// 2. 제공자 정보 추출 (google, kakao 등)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// 3. 사용자 정보 파싱
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(
registrationId,
oauth2User.getAttributes()
);
// 4. DB에서 사용자 조회 또는 생성
User user = userRepository.findByEmailAndProvider(
userInfo.getEmail(),
AuthProvider.valueOf(registrationId.toUpperCase())
).orElseGet(() -> createUser(userInfo, registrationId));
// 5. UserPrincipal 반환
return UserPrincipal.create(user, oauth2User.getAttributes());
}
private User createUser(OAuth2UserInfo userInfo, String registrationId) {
User user = User.builder()
.email(userInfo.getEmail())
.name(userInfo.getName())
.profileImage(userInfo.getImageUrl())
.provider(AuthProvider.valueOf(registrationId.toUpperCase()))
.providerId(userInfo.getId())
.roles(Set.of(Role.USER))
.build();
return userRepository.save(user);
}
}
// OAuth2UserInfo 인터페이스
public interface OAuth2UserInfo {
String getId();
String getName();
String getEmail();
String getImageUrl();
}
// Google 구현체
public class GoogleOAuth2UserInfo implements OAuth2UserInfo {
private final Map<String, Object> attributes;
public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public String getId() {
return (String) attributes.get("sub");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
// Kakao 구현체
public class KakaoOAuth2UserInfo implements OAuth2UserInfo {
private final Map<String, Object> attributes;
public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public String getId() {
return attributes.get("id").toString();
}
@Override
public String getName() {
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
return (String) properties.get("nickname");
}
@Override
public String getEmail() {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
return (String) kakaoAccount.get("email");
}
@Override
public String getImageUrl() {
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
return (String) properties.get("profile_image");
}
}
4.5 OAuth2 성공/실패 핸들러
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication
) throws IOException {
// 1. JWT 토큰 생성
String accessToken = jwtTokenProvider.generateAccessToken(authentication);
String refreshToken = jwtTokenProvider.generateRefreshToken(
authentication.getName()
);
// 2. 프론트엔드로 리다이렉트 (토큰 포함)
String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/oauth2/redirect")
.queryParam("accessToken", accessToken)
.queryParam("refreshToken", refreshToken)
.build()
.toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
@Component
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception
) throws IOException {
String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/login")
.queryParam("error", exception.getLocalizedMessage())
.build()
.toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
5. 권한 관리 (Authorization)
5.1 Method Security
@Configuration
@EnableMethodSecurity // Spring Security 6.0+
public class MethodSecurityConfig {
// 설정 불필요 (기본 활성화)
}
@Service
public class ProductService {
// 1. @PreAuthorize - 메서드 실행 전 권한 체크
@PreAuthorize("hasRole('ADMIN')")
public void deleteProduct(Long productId) {
productRepository.deleteById(productId);
}
// 2. SpEL 표현식 사용
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public void updateUser(Long userId, UserUpdateRequest request) {
// ADMIN이거나, 본인의 정보만 수정 가능
}
// 3. @PostAuthorize - 메서드 실행 후 권한 체크
@PostAuthorize("returnObject.userId == authentication.principal.id")
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
// 조회 후 본인의 주문인지 체크
}
// 4. @Secured - 간단한 권한 체크 (SpEL 불가)
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public void approveOrder(Long orderId) {
// ADMIN 또는 MANAGER만 승인 가능
}
}
5.2 커스텀 권한 체크 (Custom Permission Evaluator)
@Component("customPermissionEvaluator")
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Autowired
private OrderRepository orderRepository;
@Override
public boolean hasPermission(
Authentication authentication,
Object targetDomainObject,
Object permission
) {
if (authentication == null || !(permission instanceof String)) {
return false;
}
String targetType = targetDomainObject.getClass().getSimpleName().toLowerCase();
return hasPrivilege(authentication, targetType, permission.toString());
}
@Override
public boolean hasPermission(
Authentication authentication,
Serializable targetId,
String targetType,
Object permission
) {
if (authentication == null || targetType == null || !(permission instanceof String)) {
return false;
}
return hasPrivilege(authentication, targetType, permission.toString());
}
private boolean hasPrivilege(Authentication auth, String targetType, String permission) {
// 커스텀 권한 로직
UserPrincipal principal = (UserPrincipal) auth.getPrincipal();
if (targetType.equals("order") && permission.equals("READ")) {
// 본인의 주문만 조회 가능
// (실제로는 targetId를 받아서 체크)
return true;
}
return false;
}
}
// 사용 예시
@Service
public class OrderService {
@PreAuthorize("hasPermission(#orderId, 'Order', 'READ')")
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
}
5.3 계층적 권한 (Role Hierarchy)
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
// ADMIN > MANAGER > USER
String hierarchy = """
ROLE_ADMIN > ROLE_MANAGER
ROLE_MANAGER > ROLE_USER
""";
roleHierarchy.setHierarchy(hierarchy);
return roleHierarchy;
}
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
RoleHierarchy roleHierarchy
) {
DefaultMethodSecurityExpressionHandler expressionHandler =
new DefaultMethodSecurityExpressionHandler();
expressionHandler.setRoleHierarchy(roleHierarchy);
return expressionHandler;
}
// 사용 예시
@PreAuthorize("hasRole('USER')")
public void viewProducts() {
// ADMIN, MANAGER, USER 모두 접근 가능 (계층 구조)
}
@PreAuthorize("hasRole('MANAGER')")
public void updateProduct() {
// ADMIN, MANAGER만 접근 가능
}
@PreAuthorize("hasRole('ADMIN')")
public void deleteProduct() {
// ADMIN만 접근 가능
}
6. CORS & CSRF 설정
6.1 CORS 설정
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(
"http://localhost:3000",
"https://example.com"
));
configuration.setAllowedMethods(List.of(
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"
));
configuration.setAllowedHeaders(List.of(
"Authorization",
"Content-Type",
"X-Requested-With"
));
configuration.setExposedHeaders(List.of(
"Authorization",
"X-Total-Count"
));
configuration.setAllowCredentials(true); // 쿠키 허용
configuration.setMaxAge(3600L); // Pre-flight 캐시 시간 (1시간)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
// Security 설정에 적용
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// ... 기타 설정
return http.build();
}
6.2 CSRF 설정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// JWT 사용 시 CSRF 비활성화
.csrf(csrf -> csrf.disable())
// 또는 특정 경로만 CSRF 비활성화
.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/**") // API는 CSRF 제외
)
// Cookie 기반 CSRF (SPA에 적합)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
7. 실전 보안 체크리스트
7.1 비밀번호 정책
@Component
public class PasswordValidator {
// 비밀번호 정책: 8~20자, 대소문자, 숫자, 특수문자 포함
private static final String PASSWORD_PATTERN =
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,20}$";
private final Pattern pattern = Pattern.compile(PASSWORD_PATTERN);
public void validate(String password) {
if (!pattern.matcher(password).matches()) {
throw new InvalidPasswordException(
"비밀번호는 8~20자, 대소문자, 숫자, 특수문자를 포함해야 합니다"
);
}
// 추가 검증: 연속 문자 방지
if (hasConsecutiveChars(password)) {
throw new InvalidPasswordException("연속된 문자는 사용할 수 없습니다");
}
}
private boolean hasConsecutiveChars(String password) {
for (int i = 0; i < password.length() - 2; i++) {
if (password.charAt(i) + 1 == password.charAt(i + 1) &&
password.charAt(i + 1) + 1 == password.charAt(i + 2)) {
return true;
}
}
return false;
}
}
7.2 Rate Limiting (로그인 시도 제한)
@Service
@RequiredArgsConstructor
public class LoginAttemptService {
private final LoadingCache<String, Integer> attemptsCache;
public LoginAttemptService() {
this.attemptsCache = CacheBuilder.newBuilder()
.expireAfterWrite(15, TimeUnit.MINUTES)
.build(new CacheLoader<String, Integer>() {
@Override
public Integer load(String key) {
return 0;
}
});
}
public void loginSucceeded(String email) {
attemptsCache.invalidate(email);
}
public void loginFailed(String email) {
int attempts = attemptsCache.getUnchecked(email);
attemptsCache.put(email, attempts + 1);
}
public boolean isBlocked(String email) {
return attemptsCache.getUnchecked(email) >= 5; // 5회 실패 시 차단
}
}
// AuthenticationFailureHandler에서 사용
@Component
@RequiredArgsConstructor
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final LoginAttemptService loginAttemptService;
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception
) throws IOException {
String email = request.getParameter("email");
loginAttemptService.loginFailed(email);
if (loginAttemptService.isBlocked(email)) {
exception = new LockedException("계정이 잠겼습니다. 15분 후 다시 시도하세요.");
}
super.onAuthenticationFailure(request, response, exception);
}
}
7.3 보안 헤더 설정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
// XSS 공격 방지
.xssProtection(xss -> xss.headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK))
// Clickjacking 방지
.frameOptions(frame -> frame.sameOrigin())
// Content-Type Sniffing 방지
.contentTypeOptions(Customizer.withDefaults())
// HTTPS 강제
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000) // 1년
.includeSubDomains(true)
)
// Content Security Policy
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
)
);
return http.build();
}
요약
Filter Chain 감각
- 요청은 Filter Chain을 순서대로 지나며 인증/인가가 결정됩니다.
- 인증(Authentication)과 인가(Authorization)를 분리해서 생각해야 설정이 덜 꼬입니다.
JWT 감각
- Access/Refresh 토큰을 분리하고, 회수/만료/탈취 대응(회전, 블랙리스트 등)을 운영까지 포함해 설계합니다.
- “stateless”는 편하지만, 즉시 무효화가 어렵다는 트레이드오프가 있습니다.
OAuth2 감각
- Authorization Code(+PKCE)는 웹/모바일에서 가장 안전한 기본값입니다.
- 리소스 서버는 토큰 검증(JWK/서명)과 scope/role 정책을 명확히 가져가야 합니다.
권한/보안 포인트
- 메서드 레벨 권한(@PreAuthorize 등)은 “마지막 방어선”으로 두면 실수에 강해집니다.
- CORS/CSRF, 보안 헤더, 레이트리밋은 “기본 보안 베이스라인”입니다.
마무리
Spring Security는 복잡하지만, Filter Chain과 Authentication/Authorization의 흐름을 이해하면 다양한 보안 요구사항을 구현할 수 있습니다. JWT와 OAuth2를 활용하여 현대적인 인증 시스템을 구축해보세요!
핵심 요약:
- Filter Chain - 요청이 순서대로 필터를 거쳐 인증/인가 처리
- JWT - Stateless 인증, Access/Refresh Token 분리 관리
- OAuth2 - 소셜 로그인, Authorization Code Grant 흐름
- Method Security - @PreAuthorize로 메서드 레벨 권한 관리
- 보안 강화 - Rate Limiting, 보안 헤더, 비밀번호 정책
다음 단계:
- Database 인덱스 최적화 학습
- Redis 캐싱 전략 실전 적용
- 실전 프로젝트에 Spring Security 적용
이 글이 도움이 되었다면, 다음 글 “Database 인덱스 최적화 가이드"도 기대해주세요! 🔒
💬 댓글