Spring BootSpring SecurityJWTAuthentication

Spring Security 7 마이그레이션 — 설정을 통째로 뜯어고치며 배운 것들

JWT 기반 API 서버를 Spring Security 7로 올리면서 만난 함정들. deprecated 경고를 지우는 단순 작업이 인가 버그 발견과 보안 설정 전체 재점검으로 이어진 경험을 공유합니다.

Srue2026년 4월 29일
Spring Security 7 마이그레이션 — 설정을 통째로 뜯어고치며 배운 것들

JWT 인증 기반의 API 서버를 운영하고 있었습니다. Spring Boot를 올리면서 Security도 7로 함께 올라갔고, 빌드를 돌리자마자 SecurityFilterChain 설정 코드에서 deprecated 경고가 쏟아졌습니다. 일부는 아예 컴파일이 안 됐습니다.

"어차피 lambda DSL은 이미 쓰고 있으니 금방 끝나겠지." 그때는 진심으로 그렇게 생각했습니다. 결론부터 말하면, 이 작업은 이틀이 걸렸고, 그중 하루 반은 코드를 바꾸는 게 아니라 바꾼 코드가 만든 버그를 찾는 데 썼습니다.

배경 — deprecated가 필수가 되기까지

Spring Security는 5.x 시절부터 설정 방식을 현대화하겠다는 방향을 밀어왔습니다. WebSecurityConfigurerAdapter를 버리고 SecurityFilterChain 빈으로 전환한 것이 시작이었고, 6.x에서는 lambda DSL을 권장하면서 기존 체이닝 방식에 deprecated를 붙였습니다.

7.0에서는 그 deprecated들이 실제로 제거됐습니다. "권장"이 "필수"가 된 거죠.

방향 자체는 납득이 갔습니다. .and()로 설정 블록을 이어 붙이는 방식은 코드가 길어지면 "이 .and()가 어디로 돌아가는 거지?" 하고 흐름을 잃기 쉬웠으니까요. 그런데 솔직히 말하면, http.cors().disable() 같은 한 줄짜리 설정까지 http.cors(cors -> cors.disable())로 감싸야 하는 건 처음에 약간 과하다고 느꼈습니다. 나중에야 일관성을 위한 선택이라는 걸 이해했지만, 그 순간에는 "이걸 왜 굳이?" 하는 생각이 먼저 들었습니다.

기계적인 전환 — 그리고 함정

코드를 바꾸는 작업 자체는 기계적이었습니다. .csrf().disable().csrf(csrf -> csrf.disable())로, antMatchers()requestMatchers()로, authorizeRequests()authorizeHttpRequests()로. IDE의 Find & Replace와 정규식으로 대부분 처리할 수 있었습니다.

전환 후의 SecurityFilterChain은 이런 모양이 됐습니다.

SecurityConfig.java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(session ->
            session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .requestMatchers("/api/auth/**").permitAll()
            .anyRequest().authenticated()
        )
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
 
    return http.build();
}

.and()가 사라지고 각 설정 블록의 범위가 lambda 중괄호로 명확해졌습니다. 여기까지는 순조로웠습니다. 문제는 이 다음부터였습니다.

금요일 오후, /api/admin이 열려 있었다

금요일 오후 4시쯤이었습니다. 마이그레이션을 마치고 스테이징 환경에 배포한 뒤, 습관적으로 Postman으로 몇 개 API를 찔러보고 있었습니다.

/api/admin/users에 토큰 없이 GET 요청을 보냈는데, 200이 돌아왔습니다.

처음에는 Postman에 이전 토큰이 남아 있나 확인했습니다. 아니었습니다. 헤더에 Authorization이 아예 없었는데 응답이 왔습니다. 등에서 식은땀이 났습니다.

원인은 requestMatchers 순서였습니다. 기존 코드를 lambda DSL로 전환하면서 경로 규칙 순서를 재배치했는데, 실수로 permitAll()이 걸린 와일드카드 패턴을 더 구체적인 admin 경로보다 앞에 놓았습니다.

// 이렇게 쓰면 /api/admin도 permitAll에 걸린다
auth.requestMatchers("/api/**").permitAll()
    .requestMatchers("/api/admin/**").hasRole("ADMIN");
 
// 좁은 범위를 먼저 선언해야 한다
auth.requestMatchers("/api/admin/**").hasRole("ADMIN")
    .requestMatchers("/api/auth/**").permitAll()
    .anyRequest().authenticated();

Spring Security의 requestMatchers는 선언 순서대로 매칭됩니다. /api/**가 먼저 매칭되면 그 아래의 /api/admin/** 규칙은 아예 평가되지 않습니다. 이건 알고 있던 사실이었는데, lambda DSL로 전환하면서 코드를 정리한다고 순서를 바꾼 게 화근이었습니다.

이전에 JWT 테스트 환경을 구축하면서 만들어 둔 통합 테스트가 이 문제를 잡아줬어야 했는데, 제가 스테이징 배포 전에 테스트를 안 돌렸습니다. 금요일이라 빨리 끝내고 싶은 마음에 "컴파일 됐으니 되겠지" 하고 넘어간 거였습니다. 결국 테스트를 돌려보니 admin 관련 케이스가 빨간불이었고, 그제서야 수정했습니다.

운영 환경에 나가기 전에 발견한 게 다행이었습니다. 만약 운영에서 이걸 발견했다면, 금요일 저녁은 없었을 겁니다.

@EnableWebSecurity를 빠뜨린 30분

두 번째 함정은 더 교묘했습니다.

마이그레이션을 마치고 테스트도 통과한 뒤, 커스텀 JWT 필터가 특정 요청에서 동작하지 않는 현상을 발견했습니다. /api/auth/refresh 엔드포인트로 요청을 보내면 JWT 필터가 타야 하는데, 필터 자체가 실행되지 않았습니다. 로그를 찍어봐도 필터의 doFilterInternal 메서드가 호출된 흔적이 없었습니다.

디버깅 포인트를 잡지 못해서 한참 헤맸습니다. 필터 코드를 보고, 설정 코드를 보고, 다시 필터 코드를 보고. "분명 addFilterBefore로 등록했는데 왜 안 타지?" 30분쯤 지나서 SecurityConfig 클래스 상단을 다시 봤습니다.

@EnableWebSecurity가 없었습니다.

Spring Boot 자동 설정이 어느 정도 커버해주기 때문에, 이 어노테이션 없이도 기본적인 동작은 했습니다. Security 6까지는 그랬습니다. 그런데 7에서는 @EnableWebSecurity 없이 등록한 커스텀 필터가 일부 상황에서 필터 체인에 제대로 포함되지 않았습니다. 정확히는, 자동 설정이 만드는 기본 필터 체인과 제가 정의한 SecurityFilterChain 빈의 우선순위가 꼬인 것이었습니다.

@EnableWebSecurity를 붙이자 바로 해결됐습니다. 30분 동안 필터 코드를 뚫어지게 쳐다봤는데, 문제는 어노테이션 하나였습니다. 이런 종류의 버그가 제일 허탈합니다.

authorizeRequests와 authorizeHttpRequests는 이름만 다른 게 아니다

기계적으로 authorizeRequests()authorizeHttpRequests()로 바꾸면서, 이 둘이 단순한 이름 변경이라고 생각했습니다. 아니었습니다.

authorizeRequests()는 내부적으로 SecurityExpressionHandler를 사용하고, authorizeHttpRequests()AuthorizationManager 기반으로 동작합니다. 인가 처리 메커니즘 자체가 다릅니다. SpEL 표현식을 쓰고 있었다면 이 차이가 드러나는데, 저희 프로젝트에서는 단순한 hasRole(), permitAll(), authenticated()만 쓰고 있어서 큰 문제는 없었습니다. 하지만 @PreAuthorize("hasRole('ADMIN') and #userId == authentication.principal.id") 같은 표현식을 쓰는 프로젝트라면 주의가 필요합니다.

OAuth2 Resource Server 설정도 같은 맥락에서 변경됐습니다. oauth2ResourceServer().jwt() 같은 인자 없는 체이닝이 제거되고, 모든 설정이 oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> ...)) 형태의 lambda로 통일됐습니다. 일부 내부 API에서 JWT 검증을 OAuth2 Resource Server에 위임하고 있었는데, 이 부분도 같은 패턴으로 전환했습니다.

shouldNotFilter — 마이그레이션이 아닌데 마이그레이션에서 발견된 것

이건 Security 7의 변경과는 직접 관련이 없습니다. 하지만 마이그레이션 과정에서 설정을 통째로 정리하다 보니 발견된 문제라 기록해둡니다.

이전에 JWT Refresh Token 전략을 구현하면서 만든 JwtAuthFilter가 있었는데, 이 필터가 로그인 경로(/api/auth/login)까지 타고 있었습니다. 로그인 요청에는 당연히 JWT가 없으니, 매번 토큰 파싱 에러가 발생하고 catch 블록에서 삼켜지고 있었습니다. 동작에는 문제가 없었지만, 에러 로그가 쌓이고 있었고, 무엇보다 의도하지 않은 코드 경로를 타고 있다는 게 찝찝했습니다.

OncePerRequestFiltershouldNotFilter를 오버라이드해서 인증이 필요 없는 경로를 명시적으로 제외했습니다. 이런 건 평소에는 놓치기 쉬운데, 설정 전체를 다시 읽어보는 기회가 되니까 비로소 눈에 들어왔습니다.

돌아보면

Spring Security 마이그레이션을 "deprecated 경고를 지우는 작업" 정도로 시작했습니다. 실제로는 보안 설정 전체를 다시 읽어보는 작업이 됐습니다.

이번 경험으로 Spring Security를 대하는 태도가 좀 달라졌습니다. 예전에는 Security 설정을 한 번 잡아놓으면 잘 안 건드렸습니다. "동작하니까 괜히 건드려서 깨뜨리지 말자"는 심리가 있었습니다. 그런데 이번에 설정을 통째로 뜯어고치면서, 오히려 정기적으로 들여다봐야 하는 코드라는 걸 느꼈습니다. 보안 규칙이 의도한 대로 동작하는지, 불필요하게 열린 경로는 없는지, 필터가 예상한 순서로 타는지. 이런 걸 확인하지 않으면, 문제가 생겨도 모르고 지나갈 수 있습니다.

그리고 가장 든든했던 건 역시 통합 테스트였습니다. 설정을 바꾸고 테스트를 돌렸을 때 실패하는 케이스가 바로 나와줬기 때문에, 인가 규칙을 안심하고 검증할 수 있었습니다. 금요일에 테스트를 안 돌리고 배포한 건 반성하지만, 테스트가 있었기에 스테이징에서라도 잡을 수 있었습니다. 테스트가 없었다면 운영에서 터졌을 겁니다.

결국 마이그레이션의 본질은 코드를 바꾸는 게 아니라, 바꾼 코드가 기존과 동일하게 동작하는지 증명하는 것이었습니다.