Spring Security 정리

StrayCat·2026년 2월 22일

[Spring Boot] Spring Security (Form Login → JWT 인증/인가)

Spring Security의 개념부터 Form Login, JWT 기반 인증/인가, 권한 제어, Validation까지
한 번에 흐름을 잡을 수 있도록 정리합니다.


📌 목차

  1. Spring Security란?
  2. SecurityContextHolder & Authentication
  3. Spring Security 기본 설정 (Form Login)
  4. 로그인 처리 흐름 이해
  5. UserDetailsService & UserDetails 구현
  6. @AuthenticationPrincipal
  7. JWT 기반 로그인 - Security Filter 적용
  8. 접근 불가 페이지 & 권한(Authority) 제어
  9. Bean Validation - 데이터 검증

1. Spring Security란?

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 → ControllerClient → SecurityFilterChain → Controller
인증 실패 시직접 처리 필요Security가 자동으로 Error Response 반환
로그인 관리직접 구현Security 기본 제공

2. SecurityContextHolder & Authentication

Spring Security는 인증된 사용자 정보를 SecurityContextHolder에 저장하고 관리합니다.

SecurityContextHolder
  └── SecurityContext
        └── Authentication
              ├── principal   (사용자 식별 정보 - UserDetails)
              ├── credentials (비밀번호 - 인증 후 비워짐)
              └── authorities (권한 목록 - GrantedAuthority)

Authentication 객체 구성 요소:

필드설명
principal사용자를 식별. Username/Password 방식에서는 UserDetails 인스턴스
credentials비밀번호. 인증 후에는 보안을 위해 비워짐
authoritiesGrantedAuthority로 추상화된 사용자 권한 목록

SecurityContext에 인증 객체 저장 예시:

// 인증 객체 생성 및 SecurityContext에 저장
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
context.setAuthentication(authentication); // SecurityContext에 저장
SecurityContextHolder.setContext(context);

UsernamePasswordAuthenticationTokenAbstractAuthenticationToken의 하위 클래스로,
인증 객체를 생성하는 데 사용됩니다.


3. Spring Security 기본 설정 (Form Login)

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

4. 로그인 처리 흐름 이해

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가 저장되어 있어야 합니다.


5. UserDetailsService & UserDetails 구현

Spring Security에 우리 서비스의 회원 정보를 연결하기 위해 두 인터페이스를 직접 구현합니다.

UserDetailsService 구현

@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);
    }
}

UserDetails 구현

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; }
}

UserDetailsServiceUserDetails를 직접 구현하면 Security의 default 로그인 기능을 대체하게 됩니다.
이후 인증이 완료되면 UserDetailsImplSecurityContextAuthentication에 담깁니다.


6. @AuthenticationPrincipal

인증 완료 후 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";
    }
}

7. JWT 기반 로그인 - Security Filter 적용

Form Login + Session 방식 대신, JWT를 이용한 Stateless 인증 방식으로 전환합니다.
JWT 처리를 위한 2개의 필터를 직접 구현합니다.

7-1. JwtAuthenticationFilter - 로그인 처리 & JWT 발급

@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);
    }
}

7-2. JwtAuthorizationFilter - API 요청 시 JWT 검증 & 인가

@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());
    }
}

7-3. WebSecurityConfig - JWT 필터 등록

@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()
        );

        // ✅ 필터 순서 등록 (중요!)
        // JwtAuthorizationFilterJwtAuthenticationFilterUsernamePasswordAuthenticationFilter
        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를 추가하겠다는 의미입니다.


8. 접근 불가 페이지 & 권한(Authority) 제어

UserRoleEnum - 권한 정의

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";
    }
}

@Secured - API별 권한 제어

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();
}

9. Bean Validation - 데이터 검증

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-validation'

주요 Validation 어노테이션

어노테이션설명
@NotNullnull 불가
@NotEmptynull, "" 불가
@NotBlanknull, "", " " (공백) 불가
@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로 인증 사용자 정보 사용

💡 추가로 알아두면 좋은 것

OncePerRequestFilter vs Filter

JwtAuthorizationFilterFilter가 아닌 OncePerRequestFilter를 상속합니다.

구분FilterOncePerRequestFilter
실행 횟수요청 전달 시마다 실행될 수 있음요청당 정확히 1번만 실행 보장
적합한 용도일반 필터링JWT 검증처럼 중복 실행 방지가 필요한 경우

STATELESS 세션 설정의 의미

sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)

JWT 방식은 서버가 세션을 저장하지 않아도 됩니다. 이 설정을 하면 Spring Security가 세션을 생성하거나 사용하지 않습니다.
덕분에 서버 확장(Scale-out) 시에도 세션 공유 문제가 발생하지 않습니다.

profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글