Spring

로그인 이전에도 접근 허용했던 API가 인증 단계에서 막히는 문제 해결하기

천방지축 개발노트 2025. 12. 29. 00:56

내가 열심히 만든 코드도 1달만 지나도 기억이 나지 않음을 자책하면서 정리해놔야겠다는 생각이 드는 요즘..😣

 

인증 토큰 없이도 실행되어야 하는 일명 '공개 API'가 특정 상황에서 실행되지 않는 문제가 발생했다.

Security 설정을 두 개의 SecurityFilterChain으로 나눠 사용하다가,

문제 해결을 위해 단일 체인 구조로 변경했고,

그 과정에서 예상하지 못했던 또 다른 문제를 겪게 되어, 그 원인과 해결 과정을 정리해 보려고 한다.


SecurityFilterChain을 두 개로 나누어 사용했을 때의 문제점

서비스는 인증 수단으로 JWT를 사용하고 있었고, 토큰 없이도 호출되어야 하는 "공개 API"인증을 거쳐야만 실행 가능한 "보호 API"가 공존하는 구조였다. 그래서 처음에는 보안 설정 자체를 구조적으로 분리하는 방향을 선택했다.

  1. 공개 API → publicSecurity() 체인으로 매칭 → JWT 필터 없음 → 항상 통과
  2. 나머지 API → protectedSecurity() 체인으로 매칭 → JWT 필터 있음 → 인증/인가 수행

의도 자체는 공개 API가 토큰 문제로 호출이 실패하는 상황을 원천 차단하고 싶었다.

@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(JwtProperties.class)
public class SecurityConfig {
  ...
  ......
  /** Public API 전용 SecurityFilterChain. */
  @Bean
  @Order(1)
  SecurityFilterChain publicSecurity(HttpSecurity http) throws Exception {
    
    String[] publicPaths = publicPathMatcher.getPublicPathPatterns().toArray(String[]::new);
    RequestMatcher publicMatcher = buildPublicRequestMatcher(publicPaths);
    
    http
      .securityMatcher(publicMatcher)
      .csrf(csrf -> csrf.disable())
      .formLogin(form -> form.disable())
      .httpBasic(basic -> basic.disable())
      .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
    return http.build();
  }
...
객체 역할 내용
JwtAuthenticationFilter 인증 요청 헤더의 JWT를 검증
JwtAuthenticationEntryPoint 인증 실패 처리 보호된 API에 인증되지 않은 요청이 들어왔을 때, 401 에러 응답처리
AuthorizationManager 인가 사용자 권한·시스템 구분값 등을 기준으로 요청 허용 여부를 판단
AuthorizationAccessDeniedHandler 인가 실패 처리 인증은 되었지만 권한이 부족한 경우에 권한 실패 처리(403)
PublicPathMatcher 공개 API YML 에서 정의한 공개 API 경로 패턴을 담은 컴포넌트

일반적으로 인증/인가 역할을 하는 필터에서 보이는 주요 객체들을 정리해 봤다.

예시로는 아래 protectedSecurity 메서드를 보면 되겠다.

/** Protected(Security) 전용 SecurityFilterChain.*/
@Bean
@Order(2)
SecurityFilterChain protectedSecurity(HttpSecurity http) throws Exception {

  http
    .csrf(csrf -> csrf.disable())
    .formLogin(form -> form.disable())
    .httpBasic(basic -> basic.disable())
    .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    .exceptionHandling(ex -> ex .authenticationEntryPoint(jwtAuthenticationEntryPoint)
    .accessDeniedHandler(authorizationAccessDeniedHandler))
    .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
    .authorizeHttpRequests(auth -> auth.anyRequest().access(authorizationManager));
    
  return http.build();
}

그런데 여러 API를 호출해 보며 테스트해 보던 중에 문제를 발견했다.

배너처럼 로그인 전에도 누구나 볼 수 있어야 되는 것들은 공개 API들로써 호출돼야 하면서도, 로그인을 했다면 그 토큰 정보에 따라 권한 등의 조회 조건이 추가돼야 했다. 그니까 공개 API면서도 토큰이 있으면 활용해야 했는데, 이게 되지 않았다.

공개 API가 대략 아래와 같은 형태로 작성되어 있다고 봐보자.

@PostMapping("/main/contents")
public MainPortalContentsResponse getMainPortalContents(
    @UserPrincipal UserPrincipalInfo loginUser,
    @RequestBody List picks) {

    final String sysSeCd = (loginUser != null) ? loginUser.getSysSeCd() : null;

    ...
}

위 코드에서 @UserPrincipal는 @AuthenticationPrincipal을 감싼 애노테이션으로써 토큰 정보를 Controller에서 받을 수 있게 해둔 것으로, 'loginUser'는 API 요청 시의 AccessToken 정보이다.

중요한 점은 'loginUser'가 채워지려면, 요청이 인증 필터를 거쳐야 한다.

그런데 2체인 분리 구조에서는 위 요청이 '인증 필터(JwtAuthenticationFilter)'가 없는 publicSecurity 체인으로 매칭되었기 때문에, 아무리 토큰을 실어 API 요청을 보내도 필터는 아예 인식하지 않는다.

즉, 로그인 후 요청임에도 불구하고 컨트롤러에게는 항상 '익명 사용자'처럼 보이는 문제가 발생했다.

 

"공개 경로에서는 인증 정보를 사용하지 않는다"라는 보안 설정과 "로그인 후에는 토큰 기반 처리가 필요하다"라는 요구가 충돌했던 것이었다. 정리하면 처음 설계한 2체인 방식은 "공개 API는 절대 인증을 보지 않는다"일 경우에만 적합했던 것으로 수정이 필요했다.

 

처음엔 위 요구를 만족시키기 위한 방법으로 비로그인·로그인용 API를 분리하는 방법이 있을 것 같다는 생각을 했다. 인증 전 요청이 필요한 API가 많이 있지 않기도했고... 필요하면 비로그인용 API를 만드는 것으로, 용도에 따라 API를 나누는 명확한 정책이 있으면 장애 분석이 쉬울 수도 있는 장점이 있어서였다. 근데 좀 고민해 보니 같은 역할을 하는 API가 두 벌이 될 수 있다는 전제 자체가 별로여서 다른 방법으로 해결했다.

 

나누어져 있던 인증/인가 filterChain을 하나의 체인으로 합쳐서 문제 해결하기

간단하게 같은 엔드포인트 API를 쓰되, 토큰이 있으면 읽고 없으면 무시하게 만들고 싶었다. 그래서 단일 SecurityFilterChain으로 합치는 것으로 결정했다. 다음은 필터의 개선 방향이다.

  1. JWT 인증필터는 항상 태운다
  2. 토큰이 있으면 인증 컨텍스트를 채운다.
  3. 토큰이 없으면 익명으로 진행한다.
  4. 그 외는 커스텀 인가 로직 적용.

이렇게 하면 같은 공개 API라도 로그인 전 → 익명 처리, 로그인 후 → 인증 정보 기반 분기 처리가 자연스럽게 가능해졌다. 수정한 코드를 보면 대략 아래와 같다.

@Bean
@Order(1)
SecurityFilterChain security(HttpSecurity http) throws Exception {

  String[] publicPaths = publicPathMatcher.getPublicPathPatterns().toArray(String[]::new);

  http
    .csrf(csrf -> csrf.disable())
    .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    .exceptionHandling(ex -> ex
    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
    .accessDeniedHandler(authorizationAccessDeniedHandler))
    .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
    .authorizeHttpRequests(auth -> auth
    .requestMatchers(publicPaths).permitAll()
    .anyRequest().access(authorizationManager));

  return http.build();
}
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
..
  @Override
  protected void doFilterInternal(HttpServletRequest request, ...) {
    String jwtToken = resolveToken(request);
    if (!StringUtils.hasText(jwtToken)) {
    // 토큰 없으면 그냥 익명으로 진행(공개 자원 가정)
    filterChain.doFilter(request, response);
    return;
    }
  ..
  }
..

어떠한 경로로 요청이 들어와도 위 체인이 선택되도록 했다. 그리고 체인에 포함된 인증 필터가 토큰이 있으면 loginUser를 채우고 토큰이 없으면 null로 리턴하여 익명 처리가 가능하도록 했다. 이로써 하나의 API가 인증으로부터 의도한 분기 처리가 가능해졌다.

 

단일 체인으로 합쳐서 생겼던 부작용 발생

401 Unauthorized
401 인증 실패

그런데 또 다른 문제가 발생했다. 단일 체인으로 합친 이후, 잘 되는 것 처럼 보였던 API가 갑자기 특정 상황에서는 제대로 동작하지 않았다. 공개 API 요청임에도 불구하고 아예 호출이 안 되는 것을 확인했었는데.. 원인을 추적해 보니, 공개 API인데 Authorization 헤더를 "잘못된 값"으로 보내면 인증 필터가 검증을 시도하다가 401로 차단할 수 있다는 사실을 발견했다.

정리를 해보면, 문제 원인의 핵심은 공개 API임에도 불구하고 토큰을 헤더에 포함해 요청을 보낸 경우였다.

 

여기서 중요한 개념이 바로 'permitAll'에 대한 오해였다. `permitAll()`은 인가 단계에서 접근을 허용한다는 옵션이었는데, 인증을 건너뛰는 옵션으로 잘못 이해하고 있었다. 내가 놓치고 있었던 부분을 정리해 봤다.

  • 인증 필터는 요청 경로와 관계없이 항상 실행되고
  • Authorization 헤더가 존재하는 순간 토큰 검증을 시도하며
  • 토큰이 없거나, 만료되었거나, 잘못된 값이면 공개 API라도 필터 단계에서 요청이 차단될 수 있다.

Authorization 헤더에 값이 없으면 인증 필터는 인증 시도 자체를 하지 않고 그대로 다음 단계로 넘기게 되지만, 헤더가 존재하는 순간부터는 공개 API 여부와 무관하게 토큰 검증을 시도하게 되고, 이 과정에서 값이 없거나 토큰 만료 등 유효하지 않은 형태일 경우 공개 API라도 필터 단계에서 요청이 차단됐던 것이었다. 실제로 확인해 보니 업무단에서 API 호출 시 헤더에 빈값? 등을 담아 API 요청을 하고 있었다.

 

결국 백엔드에서 어떤 방식으로 손을 보더라도, 프론트에서는 인증이 필요한 요청과 아닌 요청을 구분해 API를 호출해야 했다. 그래서 프론트와 규칙을 다시 합의했다. 공개 API 요청에는 Authorization 헤더를 아예 포함하지 않고, 인증이 필요한 요청에서만 AccessToken을 전송하도록 통일했다(빈 값/임의 값 금지). 적용 이후 공개 API가 간헐적으로 401로 막히던 현상은 재현되지 않았다.

 

어떻게보면 간단한 위 문제 때문에 삽질을 꽤 하게 됐었는데... 필터 설정 자체보다 헤더를 어떻게 다뤄야 하는지에 대한 기준을 명확히 이해하지 못했고, 그 기준을 프론트와 합의된 규칙으로 제안하지 못한 점이 아쉬움으로 남았다.

반응형