Spring Security의 개념부터 Form Login, JWT 기반 인증/인가, 권한 제어, Validation까지
한 번에 흐름을 잡을 수 있도록 정리합니다.
Spring Security는 Spring 서버에 필요한 인증(Authentication)과 인가(Authorization) 기능을 제공하는 보안 프레임워크입니다.
마치 Spring이 웹 서버 구현의 편의를 제공하듯, Spring Security는 보안 처리의 수고를 크게 덜어줍니다.
의존성 추가 (build.gradle)
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
Spring Security를 활성화하면 모든 요청이 Security 필터를 거치게 됩니다.
Spring Security 적용 전/후 차이:
| 구분 | Security 적용 전 | Security 적용 후 |
|---|---|---|
| 요청 흐름 | Client → Controller | Client → SecurityFilterChain → Controller |
| 인증 실패 시 | 직접 처리 필요 | Security가 자동으로 Error Response 반환 |
| 로그인 관리 | 직접 구현 | Security 기본 제공 |
Spring Security는 인증된 사용자 정보를 SecurityContextHolder에 저장하고 관리합니다.
SecurityContextHolder
└── SecurityContext
└── Authentication
├── principal (사용자 식별 정보 - UserDetails)
├── credentials (비밀번호 - 인증 후 비워짐)
└── authorities (권한 목록 - GrantedAuthority)
Authentication 객체 구성 요소:
| 필드 | 설명 |
|---|---|
principal | 사용자를 식별. Username/Password 방식에서는 UserDetails 인스턴스 |
credentials | 비밀번호. 인증 후에는 보안을 위해 비워짐 |
authorities | GrantedAuthority로 추상화된 사용자 권한 목록 |
SecurityContext에 인증 객체 저장 예시:
// 인증 객체 생성 및 SecurityContext에 저장
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
context.setAuthentication(authentication); // SecurityContext에 저장
SecurityContextHolder.setContext(context);
UsernamePasswordAuthenticationToken은AbstractAuthenticationToken의 하위 클래스로,
인증 객체를 생성하는 데 사용됩니다.
WebSecurityConfig 클래스를 생성하여 Security 설정을 커스터마이징합니다.
package com.mystudy.springauth.config;
...
@Configuration
@EnableWebSecurity // Spring Security 지원 활성화
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 비활성화 (REST API는 Session을 사용하지 않으므로)
http.csrf((csrf) -> csrf.disable());
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
// 정적 리소스(CSS, JS, 이미지 등)는 인증 없이 접근 허용
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
// /api/user/ 로 시작하는 요청(회원가입, 로그인)은 인증 없이 접근 허용
.requestMatchers("/api/user/**").permitAll()
// 그 외 모든 요청은 인증 필요
.anyRequest().authenticated()
);
// Form Login 설정
http.formLogin((formLogin) ->
formLogin
.loginPage("/api/user/login-page") // 커스텀 로그인 페이지 (GET)
.loginProcessingUrl("/api/user/login") // 로그인 처리 URL (POST)
.defaultSuccessUrl("/") // 로그인 성공 후 이동 URL
.failureUrl("/api/user/login-page?error") // 로그인 실패 후 이동 URL
.permitAll()
);
return http.build();
}
}
주요 설정 메서드 설명:
| 메서드 | 설명 |
|---|---|
csrf.disable() | CSRF 방어 비활성화. REST API + JWT 방식에서는 불필요 |
permitAll() | 해당 URL은 인증 없이 누구나 접근 가능 |
authenticated() | 해당 URL은 인증된 사용자만 접근 가능 |
loginProcessingUrl() | POST 요청을 받아 로그인 처리를 진행할 URL |
Spring Security의 Form Login 기반 인증 처리 흐름입니다.
Client
│ (1) POST /api/user/login (username, password)
↓
UsernamePasswordAuthenticationFilter
│ (2) UsernamePasswordAuthenticationToken 생성
↓
AuthenticationManager
│ (3) UserDetailsService에게 username 전달
↓
UserDetailsService (직접 구현)
│ (4) DB에서 회원 조회 → UserDetails 반환
↓
AuthenticationManager
│ (5) 클라이언트 password(평문) vs UserDetails password(암호문) 비교
│ → BCryptPasswordEncoder로 암호화 후 비교
├── 인증 실패 → Error 발생 (401)
└── 인증 성공
↓
SecurityContextHolder에 Authentication 저장
세션에 로그인 정보 저장 (Form Login의 경우)
핵심: Spring Security가 password 비교 시 평문을 암호화해서 비교합니다.
따라서 DB에 반드시 암호화된 password가 저장되어 있어야 합니다.
Spring Security에 우리 서비스의 회원 정보를 연결하기 위해 두 인터페이스를 직접 구현합니다.
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// DB에서 회원 조회
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
// UserDetails 구현체로 변환하여 반환
return new UserDetailsImpl(user);
}
}
public class UserDetailsImpl implements UserDetails {
private final User user; // 실제 User 엔티티를 감쌈
public UserDetailsImpl(User user) {
this.user = user;
}
// ✅ 커스텀 메서드 - User 엔티티 직접 반환
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
// ✅ 사용자 권한 반환 - UserRoleEnum에서 authority 값을 동적으로 가져옴
@Override
public Collection getAuthorities() {
UserRoleEnum role = user.getRole();
String authority = role.getAuthority(); // ex) "ROLE_USER", "ROLE_ADMIN"
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
// 아래 4개는 계정 상태 관련 메서드 - 실습에서는 모두 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; }
}
UserDetailsService와UserDetails를 직접 구현하면 Security의 default 로그인 기능을 대체하게 됩니다.
이후 인증이 완료되면UserDetailsImpl이SecurityContext의Authentication에 담깁니다.
인증 완료 후 Controller에서 인증된 사용자 정보를 꺼낼 때 사용합니다.
@Controller
@RequestMapping("/api")
public class ProductController {
@GetMapping("/products")
public String getProducts(@AuthenticationPrincipal UserDetailsImpl userDetails) {
// Authentication의 Principal에 저장된 UserDetailsImpl을 바로 주입받음
User user = userDetails.getUser();
System.out.println("user.getUsername() = " + user.getUsername());
return "redirect:/";
}
}
메인 페이지에서 사용자 이름 표시 예시:
@Controller
public class HomeController {
@GetMapping("/")
public String home(Model model, @AuthenticationPrincipal UserDetailsImpl userDetails) {
// Model에 username을 담아 Thymeleaf 등 View에 전달
model.addAttribute("username", userDetails.getUser().getUsername());
return "index";
}
}
Form Login + Session 방식 대신, JWT를 이용한 Stateless 인증 방식으로 전환합니다.
JWT 처리를 위한 2개의 필터를 직접 구현합니다.
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/user/login"); // 로그인 처리 URL 지정
}
// ① 로그인 시도: 클라이언트가 보낸 username, password로 인증 시도
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
log.info("로그인 시도");
try {
// Request Body에서 LoginRequestDto 파싱
LoginRequestDto requestDto = new ObjectMapper()
.readValue(request.getInputStream(), LoginRequestDto.class);
// AuthenticationManager에게 인증 위임
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
requestDto.getPassword(),
null // 인증 전이므로 authorities는 null
)
);
} catch (IOException e) {
throw new RuntimeException(e.getMessage());
}
}
// ② 로그인 성공: JWT 생성 후 쿠키에 담아 응답
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authResult) {
log.info("로그인 성공 및 JWT 생성");
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String token = jwtUtil.createToken(username, role);
jwtUtil.addJwtToCookie(token, response); // 쿠키에 JWT 저장
}
// ③ 로그인 실패: 401 반환
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) {
log.info("로그인 실패");
response.setStatus(401);
}
}
@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter { // 요청당 1번만 실행되는 필터
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain)
throws ServletException, IOException {
String tokenValue = jwtUtil.getTokenFromRequest(req); // 쿠키에서 JWT 추출
if (StringUtils.hasText(tokenValue)) {
// "Bearer " 접두어 제거
tokenValue = jwtUtil.substringToken(tokenValue);
// 토큰 유효성 검증
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Token Error");
return;
}
// 토큰에서 사용자 정보 추출
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
try {
// SecurityContext에 인증 객체 등록
setAuthentication(info.getSubject());
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
}
// 인증 처리: SecurityContext에 Authentication 저장
public void setAuthentication(String username) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(username);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
// 인증 객체 생성
private Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService,
AuthenticationConfiguration authenticationConfiguration) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
this.authenticationConfiguration = authenticationConfiguration;
}
// AuthenticationManager Bean 등록
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
// JwtAuthenticationFilter Bean 등록 (AuthenticationManager 주입 필요)
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
// JwtAuthorizationFilter Bean 등록
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 비활성화
http.csrf((csrf) -> csrf.disable());
// JWT 방식은 Session을 사용하지 않으므로 STATELESS 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers("/api/user/**").permitAll()
.anyRequest().authenticated()
);
http.formLogin((formLogin) ->
formLogin.loginPage("/api/user/login-page").permitAll()
);
// ✅ 필터 순서 등록 (중요!)
// JwtAuthorizationFilter → JwtAuthenticationFilter → UsernamePasswordAuthenticationFilter
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
필터 실행 순서 요약:
요청
↓
JwtAuthorizationFilter ← 매 API 요청마다 JWT 검증 및 인가 처리
↓
JwtAuthenticationFilter ← 로그인 요청 시에만 동작 (JWT 발급)
↓
UsernamePasswordAuthenticationFilter (Security 기본)
↓
Controller
addFilterBefore(A, B)는 B 앞에 A를 추가하겠다는 의미입니다.
public enum UserRoleEnum {
USER(Authority.USER), // 일반 사용자
ADMIN(Authority.ADMIN); // 관리자
private final String authority;
UserRoleEnum(String authority) {
this.authority = authority;
}
public String getAuthority() {
return this.authority;
}
// ✅ 권한 이름 규칙: 반드시 "ROLE_" 접두어로 시작해야 함
public static class Authority {
public static final String USER = "ROLE_USER";
public static final String ADMIN = "ROLE_ADMIN";
}
}
Controller 메서드에 @Secured 어노테이션으로 특정 권한을 가진 사용자만 접근 가능하도록 설정합니다.
// WebSecurityConfig에 @Secured 활성화 어노테이션 추가 필요
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true) // ← @Secured 활성화
public class WebSecurityConfig { ... }
@Secured(UserRoleEnum.Authority.ADMIN) // 관리자 권한을 가진 사용자만 접근 가능
@GetMapping("/products/secured")
public String getProductsByAdmin(@AuthenticationPrincipal UserDetailsImpl userDetails) {
System.out.println("userDetails.getUsername() = " + userDetails.getUsername());
for (GrantedAuthority authority : userDetails.getAuthorities()) {
System.out.println("authority = " + authority.getAuthority());
}
return "redirect:/";
}
일반 사용자가 관리자 전용 API에 접근 시 403 Forbidden 페이지로 리다이렉트 합니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// ... 기존 설정 ...
// ✅ 접근 불가 페이지 URL 설정
http.exceptionHandling((exceptionHandling) ->
exceptionHandling
.accessDeniedPage("/forbidden.html") // 403 발생 시 이동할 페이지
);
return http.build();
}
implementation 'org.springframework.boot:spring-boot-starter-validation'
| 어노테이션 | 설명 |
|---|---|
@NotNull | null 불가 |
@NotEmpty | null, "" 불가 |
@NotBlank | null, "", " " (공백) 불가 |
@Size(min, max) | 문자열 길이 범위 지정 |
@Max(n) | 최대값 |
@Min(n) | 최소값 |
@Positive | 양수만 허용 |
@Negative | 음수만 허용 |
@Email | 이메일 형식 |
@Pattern(regexp) | 정규 표현식 검증 |
1. DTO에 어노테이션 적용
@Getter
public class ProductRequestDto {
@NotBlank // null, "", " " 모두 불가
private String name;
@Email // 이메일 형식 검증
private String email;
@Positive(message = "양수만 가능합니다.") // 0보다 커야 함
private int price;
@Negative(message = "음수만 가능합니다.") // 0보다 작아야 함
private int discount;
@Size(min = 2, max = 10) // 2자 이상 10자 이하
private String link;
@Max(10) // 최대값 10
private int max;
@Min(2) // 최소값 2
private int min;
}
2. Controller에서 @Valid 적용
@PostMapping("/validation")
@ResponseBody
public ProductRequestDto testValid(@RequestBody @Valid ProductRequestDto requestDto) {
// @Valid가 있어야 DTO에 선언한 Validation 어노테이션이 동작함
return requestDto;
}
3. 회원가입 DTO 예시
@Getter
@Setter
public class SignupRequestDto {
@NotBlank
private String username;
@NotBlank
private String password;
// 정규 표현식으로 이메일 형식 검증
@Pattern(regexp = "^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$")
@NotBlank
private String email;
private boolean admin = false;
private String adminToken = "";
}
💡
@Valid어노테이션이 없으면 DTO에 선언한 Validation이 동작하지 않습니다!
[회원가입]
Client → UserController.signup() → BCrypt 암호화 후 DB 저장
[로그인 - JWT 방식]
Client (POST /api/user/login)
→ JwtAuthenticationFilter.attemptAuthentication()
→ AuthenticationManager → UserDetailsServiceImpl.loadUserByUsername()
→ DB 조회 → UserDetailsImpl 반환
→ password 비교 (BCrypt)
→ 성공: successfulAuthentication() → JWT 생성 → Cookie에 저장
→ 실패: 401 반환
[API 요청 - JWT 인가]
Client (GET /api/products + Cookie의 JWT)
→ JwtAuthorizationFilter.doFilterInternal()
→ 쿠키에서 JWT 추출 → 유효성 검증
→ Claims에서 username 추출 → UserDetailsServiceImpl.loadUserByUsername()
→ SecurityContextHolder에 Authentication 저장
→ Controller 진입
→ @AuthenticationPrincipal로 인증 사용자 정보 사용
JwtAuthorizationFilter는 Filter가 아닌 OncePerRequestFilter를 상속합니다.
| 구분 | Filter | OncePerRequestFilter |
|---|---|---|
| 실행 횟수 | 요청 전달 시마다 실행될 수 있음 | 요청당 정확히 1번만 실행 보장 |
| 적합한 용도 | 일반 필터링 | JWT 검증처럼 중복 실행 방지가 필요한 경우 |
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
JWT 방식은 서버가 세션을 저장하지 않아도 됩니다. 이 설정을 하면 Spring Security가 세션을 생성하거나 사용하지 않습니다.
덕분에 서버 확장(Scale-out) 시에도 세션 공유 문제가 발생하지 않습니다.