Spring Security 6 (Spring Boot 3)에서 Spring Security JWT 인증

Spring Boot 3와 Spring Security 6.x가 정식 릴리스되면서 JWT 기반 인증 구현 방식도 상당히 달라졌다. 기존 WebSecurityConfigurerAdapter를 상속하는 방식은 완전히 제거되었고, 람다 DSL 방식의 SecurityFilterChain 빈 등록으로 전환되었다. 이번 포스팅에서는 Spring Boot 3와 Spring Security 6.x 환경에서 Spring Security JWT 인증을 의존성 추가부터 JwtService, JwtAuthenticationFilter, 회원가입·로그인 REST API까지 실제 동작하는 코드 기준으로 구현한다.


토큰이 요청을 타고 흐르는 방식

코드를 보기 전에 요청이 어떤 순서로 처리되는지 짚고 넘어가자.

  1. 클라이언트가 /api/v1/auth/login 엔드포인트로 이메일과 비밀번호를 전송한다.
  2. 서버는 AuthenticationManager를 통해 자격 증명을 검증하고, 성공 시 JWT 액세스 토큰을 응답으로 반환한다.
  3. 이후 클라이언트는 보호된 API를 호출할 때마다 HTTP 헤더에 Authorization: Bearer {token}을 포함하여 요청한다.
  4. JwtAuthenticationFilter가 모든 요청에 앞서 실행되어 토큰의 서명과 만료 여부를 검증한다.
  5. 검증에 성공하면 SecurityContextHolder에 인증 정보를 저장하고 이후 필터 체인과 컨트롤러가 정상 동작한다.

이 흐름을 기억해두면 아래에서 설명할 각 컴포넌트의 역할이 훨씬 선명하게 들어온다.


1. pom.xml에 무엇을 넣어야 하나

Spring Boot 3.x 기반 프로젝트에 아래 의존성을 추가한다. JWT 라이브러리는 JJWT 0.11.5 기준으로 작성한다. 0.12.x는 API 메서드명이 일부 변경되었으므로 버전을 정확히 확인하고 사용해야 한다.

<!-- Spring Boot Starter -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- JJWT 0.11.5 — JWT 생성 및 파싱 라이브러리 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

<!-- Lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
XML

jjwt-api는 컴파일 타임 의존성으로, 실제 구현체인 jjwt-impl은 런타임 scope으로 분리하는 것이 JJWT 공식 가이드의 권장 방식이다. jjwt-jackson은 JWT의 JSON 직렬화를 담당하므로 함께 추가해야 한다.


2. 시크릿 키는 코드 밖으로

JWT 시크릿 키와 토큰 만료 시간을 외부 설정으로 분리한다. 시크릿 키는 반드시 256비트(32바이트) 이상의 Base64 인코딩 문자열을 사용해야 HS256 알고리즘 요구사항을 만족한다.

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/jwt_demo
    username: root
    password: password
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

application:
  security:
    jwt:
      # 256비트 이상의 Base64 인코딩 시크릿 키 (운영환경에서는 환경변수로 주입)
      secret-key: <secret-key>
      expiration: 86400000      # 액세스 토큰 만료: 24시간 (밀리초)
      refresh-token:
        expiration: 604800000   # 리프레시 토큰 만료: 7일 (밀리초)
YAML

secret-key 값을 코드에 하드코딩하면 절대 안 된다. 운영 환경에서는 환경변수 APPLICATION_SECURITY_JWT_SECRET_KEY로 주입하고 .gitignore에 실제 설정 파일을 제외해야 한다.


3. WebSecurityConfigurerAdapter는 없다 — SecurityFilterChain으로 전환

Spring Security 6.x에서 보안 설정의 진입점은 SecurityFilterChain 빈이다. WebSecurityConfigurerAdapter는 완전히 사라졌으므로 아래 패턴으로 작성한다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // JWT 기반 Stateless 방식에서는 CSRF 보호가 불필요
            .csrf(csrf -> csrf.disable())
            .cors(Customizer.withDefaults())
            .authorizeHttpRequests(auth -> auth
                // 인증 없이 접근 가능한 경로 허용
                .requestMatchers("/api/v1/auth/**").permitAll()
                // 관리자 전용 경로 — getAuthorities()에서 "ADMIN"을 직접 반환하므로 hasAuthority() 사용
                .requestMatchers("/api/v1/admin/**").hasAuthority("ADMIN")
                // 그 외 모든 요청은 인증 필요
                .anyRequest().authenticated()
            )
            // 세션을 생성하지 않는 Stateless 정책
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authenticationProvider(authenticationProvider)
            // UsernamePasswordAuthenticationFilter 앞에 JWT 필터 삽입
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}
Java

.csrf(csrf -> csrf.disable())는 JWT Stateless 방식에서 세션 쿠키를 사용하지 않기 때문에 CSRF 공격 벡터가 존재하지 않아 비활성화한다. .sessionManagement()에서 STATELESS를 지정하면 Spring Security가 세션을 생성하거나 사용하지 않으므로 서버가 어떠한 인증 상태도 메모리에 유지하지 않는다. addFilterBefore()JwtAuthenticationFilter를 기본 인증 필터보다 앞에 등록하여 모든 요청의 토큰을 먼저 검증한다.


4. 토큰을 발급하고 읽는 핵심 로직

JwtService는 JWT 토큰 생성, 파싱, 유효성 검사를 담당한다. JJWT 0.11.5 기준 전체 구현이다.

@Service
public class JwtService {

    @Value("${application.security.jwt.secret-key}")
    private String secretKey;

    @Value("${application.security.jwt.expiration}")
    private long jwtExpiration;

    /** UserDetails만으로 기본 토큰 생성 */
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    /** 추가 클레임을 포함한 토큰 생성 (예: userId, roles) */
    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return buildToken(extraClaims, userDetails, jwtExpiration);
    }

    private String buildToken(
            Map<String, Object> extraClaims,
            UserDetails userDetails,
            long expiration
    ) {
        return Jwts.builder()
                .setClaims(extraClaims)
                // subject에 사용자 식별자(이메일) 저장
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                // 현재 시각 + 만료 시간으로 exp 클레임 설정
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                // HMAC-SHA256 알고리즘으로 서명
                .signWith(getSignInKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    /** 토큰 유효성 검증 — 사용자명 일치 + 만료 여부 확인 */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSignInKey())
                .build()
                // 서명 검증 후 페이로드(Claims) 반환
                .parseClaimsJws(token)
                .getBody();
    }

    private Key getSignInKey() {
        // Base64 디코딩하여 HMAC 키 생성
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}
Java

buildToken() 메서드는 Jwts.builder()를 통해 헤더·페이로드·서명 세 부분으로 구성된 JWT를 생성하고, compact()로 최종 문자열을 반환한다. extractAllClaims()에서 parseClaimsJws()를 호출하면 JJWT가 내부적으로 서명을 검증하며, 서명이 올바르지 않거나 토큰 형식이 잘못된 경우 JwtException을 던진다.


5. 모든 요청 앞에 서는 필터

모든 HTTP 요청에서 Authorization 헤더를 확인하고 JWT를 검증하는 필터이다. OncePerRequestFilter를 상속하면 하나의 요청에서 필터가 단 한 번만 실행됨을 보장한다.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {

        final String authHeader = request.getHeader("Authorization");

        // Authorization 헤더가 없거나 Bearer 스킴이 아닌 경우 건너뜀
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // "Bearer " 이후 7자리부터 실제 토큰 문자열 추출
        final String jwt = authHeader.substring(7);
        final String userEmail;

        try {
            userEmail = jwtService.extractUsername(jwt);
        } catch (JwtException e) {
            // 만료·변조·형식 오류 토큰: 인증 없이 다음 필터로 전달 → Spring Security가 401 처리
            filterChain.doFilter(request, response);
            return;
        }

        // 사용자명이 존재하고 아직 SecurityContext에 인증이 없는 경우에만 처리
        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);

            if (jwtService.isTokenValid(jwt, userDetails)) {
                // 인증 토큰 생성 — credentials는 null (JWT 방식에서는 불필요)
                UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities()
                        );
                authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );
                // SecurityContext에 인증 정보 등록 — 이후 컨트롤러에서 @AuthenticationPrincipal로 접근 가능
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}
Java

SecurityContextHolder.getContext().getAuthentication() == null 조건을 반드시 확인해야 한다. 이 조건이 없으면 이미 인증된 요청에서도 불필요하게 데이터베이스 조회가 발생한다. UsernamePasswordAuthenticationToken의 세 번째 인자인 authorities를 전달하는 것이 중요하며, 이를 생략하면 isAuthenticated()false로 처리된다.


6. 스프링이 인증 파이프라인을 조립하는 방법

UserDetailsService, AuthenticationProvider, PasswordEncoder를 빈으로 등록하는 설정 클래스이다.

@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {

    private final UserRepository repository;

    @Bean
    public UserDetailsService userDetailsService() {
        // 이메일(username)로 사용자를 조회하는 람다 구현
        return username -> repository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService());
        // BCrypt 해시 비교를 통한 비밀번호 검증
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
            throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCrypt strength 기본값 10 — 운영 환경에서 충분한 해시 강도
        return new BCryptPasswordEncoder();
    }
}
Java

DaoAuthenticationProviderUserDetailsService로 사용자를 조회한 뒤 PasswordEncoder로 입력된 비밀번호를 검증하는 Spring Security 기본 인증 제공자이다. AuthenticationManagerAuthService에서 로그인 처리 시 직접 호출하므로 빈으로 노출해야 한다.


7. 엔티티가 UserDetails를 직접 구현하면 생기는 일

JPA 엔티티에서 UserDetails를 직접 구현한다.

@Entity
@Table(name = "users")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(of = "id")  // JPA 프록시 안전 — id 기준으로만 동등성 비교
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String firstname;
    private String lastname;

    @Column(unique = true, nullable = false)
    private String email;

    private String password;

    @Enumerated(EnumType.STRING)
    private Role role;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // Role enum 값을 GrantedAuthority로 변환
        return List.of(new SimpleGrantedAuthority(role.name()));
    }

    @Override
    public String getUsername() {
        // Spring Security의 username 기준으로 이메일 반환
        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; }
}
Java

엔티티가 UserDetails를 직접 구현하면 별도의 UserDetailsImpl 래퍼 클래스가 필요 없어 코드가 단순해진다. getUsername()이 이메일을 반환하도록 오버라이드하는 것이 핵심이며, UserDetailsService.loadUserByUsername()JwtService.extractUsername()의 기준 식별자가 일치해야 한다.


8. 회원가입부터 토큰 발급까지

AuthControllerAuthService로 인증 엔드포인트를 구성한다. 구조는 단순하다.

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService service;

    @PostMapping("/register")
    public ResponseEntity<AuthResponse> register(@RequestBody RegisterRequest request) {
        return ResponseEntity.ok(service.register(request));
    }

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> authenticate(@RequestBody AuthRequest request) {
        return ResponseEntity.ok(service.authenticate(request));
    }
}
Java

위 컨트롤러는 SecurityConfig에서 /api/v1/auth/**permitAll()로 허용했으므로 인증 없이 접근된다. 두 엔드포인트 모두 성공 시 JWT 토큰이 담긴 AuthResponse를 반환한다.

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository repository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;

    public AuthResponse register(RegisterRequest request) {
        // 이메일 중복 검사 — 409 Conflict 응답을 위해 저장 전 확인
        if (repository.findByEmail(request.getEmail()).isPresent()) {
            throw new IllegalArgumentException("이미 사용 중인 이메일입니다: " + request.getEmail());
        }
        var user = User.builder()
                .firstname(request.getFirstname())
                .lastname(request.getLastname())
                .email(request.getEmail())
                // 비밀번호는 반드시 BCrypt 해싱 후 저장
                .password(passwordEncoder.encode(request.getPassword()))
                .role(Role.USER)
                .build();
        repository.save(user);
        // 등록 즉시 JWT 발급 — 별도 로그인 불필요
        var jwtToken = jwtService.generateToken(user);
        return AuthResponse.builder().token(jwtToken).build();
    }

    public AuthResponse authenticate(AuthRequest request) {
        // 자격 증명 검증 실패 시 BadCredentialsException 발생
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getEmail(),
                        request.getPassword()
                )
        );
        var user = repository.findByEmail(request.getEmail())
                .orElseThrow(() -> new UsernameNotFoundException("인증 후 사용자를 찾을 수 없습니다: " + request.getEmail()));
        var jwtToken = jwtService.generateToken(user);
        return AuthResponse.builder().token(jwtToken).build();
    }
}
Java

authenticate() 메서드에서 authenticationManager.authenticate()가 성공하면 Spring Security가 내부적으로 UserDetailsService를 호출하고 PasswordEncoder로 비밀번호를 검증한다. 검증 실패 시 BadCredentialsException이 발생하므로 컨트롤러 레벨의 @ExceptionHandler로 401 응답을 반환하도록 처리하는 것이 좋다.


9. JJWT 0.12.x로 업그레이드하면 코드가 이렇게 바뀐다

실무에서 두 버전이 혼용되므로 주요 API 차이를 정리한다.

기능JJWT 0.11.xJJWT 0.12.x
빌더 시작Jwts.builder()Jwts.builder() (동일)
클레임 설정.setClaims(map).claims(map)
Subject 설정.setSubject(str).subject(str)
발행 시각.setIssuedAt(date).issuedAt(date)
만료 시각.setExpiration(date).expiration(date)
서명.signWith(key, algo).signWith(key)
파서 생성Jwts.parserBuilder()Jwts.parser()
클레임 파싱.parseClaimsJws(token).getBody().parseSignedClaims(token).getPayload()

0.12.x에서 메서드 이름이 setter 형식에서 빌더 형식으로 통일되고, 서명 시 알고리즘 파라미터가 제거되었다. 두 버전의 API를 혼용하면 컴파일 오류가 발생하므로 버전을 반드시 고정해야 한다.


10. Spring Security 뭐가 달라졌나

Spring Security JWT 인증 구현 시 마이그레이션에서 가장 혼선이 생기는 변경사항을 정리한다.

항목Spring Security 5.xSpring Security 6.x
설정 방식extends WebSecurityConfigurerAdapter@Bean SecurityFilterChain
인가 메서드.authorizeRequests().authorizeHttpRequests()
경로 매처.antMatchers().requestMatchers()
DSL 연결.and() 체이닝람다 DSL (.and() 제거됨)
CSRF 비활성화.csrf().disable().csrf(c -> c.disable())
메서드 보안 활성화@EnableGlobalMethodSecurity@EnableMethodSecurity
최소 Java 버전Java 8Java 17

Spring 공식 마이그레이션 가이드에 따르면 authorizeRequests()antMatchers() 사용 시 spring-security-6.x에서는 컴파일 경고가 발생하며, 이후 릴리스에서 제거될 예정이다.


11. cURL로 직접 검증해보기

cURL로 실제 동작을 확인해보자.

회원가입:

curl -X POST http://localhost:8080/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"firstname":"홍","lastname":"길동","email":"hong@example.com","password":"secret123"}'
ShellScript

응답으로 JWT 토큰 문자열이 반환된다.

로그인:

curl -X POST http://localhost:8080/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"hong@example.com","password":"secret123"}'
# 응답: {"token":"eyJhbGciOiJIUzI1NiJ9..."}
ShellScript

보호된 엔드포인트 호출:

TOKEN="eyJhbGciOiJIUzI1NiJ9..."

curl http://localhost:8080/api/v1/users/me \
  -H "Authorization: Bearer $TOKEN"
ShellScript

Authorization: Bearer {token} 헤더 없이 보호된 경로를 호출하면 Spring Security가 자동으로 HTTP 401 Unauthorized를 반환한다. 토큰이 만료되었거나 서명이 잘못된 경우도 동일하게 처리된다.


마지막으로

WebSecurityConfigurerAdapter에서 SecurityFilterChain으로의 전환은 단순한 API 변경이 아니다. 람다 DSL로 넘어오면 각 보안 설정이 명확히 분리되고, 의존성 주입 기반으로 구성되어 테스트하기도 수월해진다. 더 세부적인 옵션은 Spring Security 공식 레퍼런스JJWT GitHub에서 확인할 수 있다.