[Spring Boot] [성능 개선] Spring Boot Validation에 걸릴 경우 Filter가 2번 작동하는 문제

2025. 3. 3. 21:39·Troubleshooting

 

1. 문제상황

@Valid 어노테이션을 사용하여 DTO에 유효성 검사를 적용하면, 정상적인 요청에서는 문제가 발생하지 않았지만,

유효성 검사에 실패하는 경우, JWT를 검증하는 필터가 한 번 더 실행되어 불필요한 로직이 수행되는 문제가 발생했다.

 

예시)

POST
http://localhost:8080/api/v1/auth/login
{
    "username" : "tes", << 아이디는 4자 이상이지만 벨리데이션 테스트를 위해 3자리로 요청
    "password" : "1234"
}
2025-03-03T18:39:03.379+09:00  WARN 25300 --- [springboot3template] [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<?> com.example.springboot3template.auth.presentation.controller.AuthController.login(com.example.springboot3template.auth.application.dto.req.LoginReq,jakarta.servlet.http.HttpServletResponse): [Field error in object 'loginReq' on field 'username': rejected value [tes]; codes [Size.loginReq.username,Size.username,Size.java.lang.String,Size]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [loginReq.username,username]; arguments []; default message [username],10,4]; default message [사용자 이름(username)은 4자리 이상 10자리 이하로 입력해야 합니다.]] ]
2025-03-03T18:39:03.380+09:00  WARN 25300 --- [springboot3template] [nio-8080-exec-2] c.e.s.common.security.JwtFilter          : Authorization 쿠키가 요청에 포함되지 않았습니다.
2025-03-03T18:39:03.380+09:00  INFO 25300 --- [springboot3template] [nio-8080-exec-2] c.e.s.common.security.JwtFilter          : 요청에서 추출된 토큰: null
2025-03-03T18:39:03.380+09:00  WARN 25300 --- [springboot3template] [nio-8080-exec-2] c.e.s.common.security.JwtFilter          : 토큰이 요청에 포함되지 않았습니다.

(회원가입/로그인의 경우 토큰을 발급하기 전이기 때문에 Jwt 검증없이 JwtFilter를 통과해야 하지만 MethodArgumentNotValidException 예외가 발생한 이후 필터 내부의 log들이 출력됐다.)

 

2. 원인

기존 JwtFilter의 경우 일반적인 서블릿 필터인 Filter 인터페이스를 통해 구현했다. 이 서블릿 필터는 Spring Security의 필터 체인과 별도로 동작하고 요청이 들어올 때마다 실행된다.

 

Spring은 내부적으로 예외 처리(Spring MVC의 영역 : ExceptionHandlerExceptionResolver)를 수행할 때 기존 요청을 그대로 다시 실행하는 것이 아닌 새로운 내부 요청을 실행하는 방식으로 동작한다. 즉 기존 요청이 아닌 새로운 '예외 처리 요청'이다. (내부 요청이란 완전히 새로운 요청은 아니기 때문에 이미 인증/인가를 끝낸 Security Filter Chain은 예외 처리 과정에서 다시 실행되지 않는다.)

 

이 예외 처리 요청은 path가 기존 회원가입 요청과 다르다고 인식되기 때문에  isAuthorizationPassRequest(url) 가 실행되지 않는다.

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter2 implements Filter {

    @Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
    private String secretKey;

    private final UserDetailsService userDetailsService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;
        String url = req.getRequestURI();

        if (isAuthorizationPassRequest(url)) {
            // 회원가입, 로그인 관련 API 는 인증 필요없이 요청 진행
            chain.doFilter(req, res); // 다음 Filter 로 이동
            return;
        }

        // 나머지 API 요청은 인증 처리 진행
        // 토큰 확인
        String token = getTokenFromRequest(req);
        log.info("요청에서 추출된 토큰: {}", token);

        if (StringUtils.hasText(token)) {
            SecretKey key = getSecretKey();

            if (!validateToken(token, key)) {
                throw new IllegalArgumentException("Token Error");
            }

            // JWT에서 사용자 정보 추출
            Claims claims = getUserInfoFromToken(token, key);
            String username = claims.getSubject();
            String role = claims.get("auth", String.class);

            log.info("JWT에서 추출된 사용자 정보 - username: {}, role: {}", username, role);

            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);

            log.info("인증 완료: {}", SecurityContextHolder.getContext().getAuthentication());
        } else {
            log.warn("토큰이 요청에 포함되지 않았습니다.");
        }
        chain.doFilter(request, response);
    }

    // HttpServletRequest 에서 Cookie Value : JWT 가져오기
    public String getTokenFromRequest(HttpServletRequest req) {
        Cookie[] cookies = req.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                log.info("쿠키 이름: {}, 쿠키 값: {}", cookie.getName(), cookie.getValue());
                if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
                    String cookieValue = cookie.getValue();
                    if (cookieValue != null) {
                        // 그냥 토큰을 그대로 사용합니다. URLDecoder.decode는 필요없음
                        log.info("디코딩된 토큰: {}", cookieValue);  // 디코딩 없이 그대로 사용
                        return cookieValue;
                    } else {
                        log.error("쿠키 값이 null 입니다.");
                    }
                }
            }
        }
        log.warn("Authorization 쿠키가 요청에 포함되지 않았습니다.");
        return null;
    }

    // 토큰 검증
    public boolean validateToken(String token, SecretKey key) {
        if (token == null || token.isEmpty()) {
            log.error("토큰 값이 null 또는 비어있습니다.");
            return false;
        }
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            log.info("토큰 검증 성공");
            return true;
        } catch (SecurityException | MalformedJwtException | SignatureException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

    // 토큰에서 사용자 정보 가져오기
    public Claims getUserInfoFromToken(String token, SecretKey key) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }

    // 인증 통과 url
    private boolean isAuthorizationPassRequest(String path) {
        return path.startsWith("/api/v1/auth/login") || path.startsWith("/api/v1/auth/sign-up");
    }

    private SecretKey getSecretKey() {
        if (secretKey == null || secretKey.isEmpty()) {
            throw new IllegalStateException("JwtFilter에 secretKey가 null입니다.");
        }
        return Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
    }
}

 

1. 정상적인 회원가입 요청

  1. 클라이언트 요청 ->
  2. DispatcherServlet 실행 ->
  3. Security Filter Chain 실행(requestMatchers().permitAll()에 의해 통과) -> 
  4. JwtFilter 검증 실행(isAuthorizationPassRequest에 의해 통과) ->
  5. controller 실행 ->
  6. 응답반환
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/sign-up").permitAll()

 

if (isAuthorizationPassRequest(url)) {
    // 회원가입, 로그인 관련 API 는 인증 필요없이 요청 진행
    chain.doFilter(req, res); // 다음 Filter 로 이동
    return;
}

// 인증 통과 url
private boolean isAuthorizationPassRequest(String path) {
    return path.startsWith("/api/v1/auth/login") || path.startsWith("/api/v1/auth/sign-up");
}

 

2. validation에 걸리는 Bad Request

  1. 클라이언트 요청 ->
  2. DispatcherServlet 실행 ->
  3. Security Filter Chain 실행(`requestMatchers().permitAll()`에 의해 통과) ->
  4. JwtFilter 검증 실행(`isAuthorizationPassRequest(url)`에 의해 통과) ->
  5. controller 실행 ->
  6. DTO validation에서 MethodArgumentNotValidException 예외 발생 ->
  7. Spring의 ExceptionHandlerExceptionResolver가 예외 감지 ->
  8. Spring MVC 예외 처리 과정 실행 (Security Filter Chain은 실행되지 않지만 서블릿 필터 재실행) ->
  9. 기존의 요청과 path가 다르다고 인식하기 때문에 isAuthorizationPassRequest가 실행되지 않음 ->
  10. 이 요청은 token이 없기 때문에 아래 log를 출력하게 된다.
// 나머지 API 요청은 인증 처리 진행
// 토큰 확인
String token = getTokenFromRequest(req);
log.info("요청에서 추출된 토큰: {}", token);
else {
    log.warn("토큰이 요청에 포함되지 않았습니다.");
}

 

3. 문제 해결

기존 서블릿 필터가 아닌 Security Filter Chain 내부에서 동작하는 OncePerRequestFilter를 사용하면 해결되고, 불필요한 로직을 줄여 성능을 개선할 수 있다.

public class JwtFilter extends OncePerRequestFilter
2025-03-03T21:36:55.638+09:00  INFO 13936 --- [springboot3template] [nio-8080-exec-2] c.e.s.common.security.JwtFilter          : 인증 제외 API 요청: /api/v1/auth/login
2025-03-03T21:36:55.763+09:00  WARN 13936 --- [springboot3template] [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<?> com.example.springboot3template.auth.presentation.controller.AuthController.login(com.example.springboot3template.auth.application.dto.req.LoginReq,jakarta.servlet.http.HttpServletResponse): [Field error in object 'loginReq' on field 'username': rejected value [tes]; codes [Size.loginReq.username,Size.username,Size.java.lang.String,Size]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [loginReq.username,username]; arguments []; default message [username],10,4]; default message [사용자 이름(username)은 4자리 이상 10자리 이하로 입력해야 합니다.]] ]
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    private final UserDetailsService userDetailsService;

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

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

        String url = request.getRequestURI();

        if (isAuthorizationPassRequest(url)) {
            log.info("인증 제외 API 요청: {}", url);
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰 확인 및 검증 로직 유지
        String token = getTokenFromRequest(request);
        log.info("요청에서 추출된 토큰: {}", token);

        if (StringUtils.hasText(token)) {
            SecretKey key = getSecretKey();

            if (!validateToken(token, key)) {
                throw new IllegalArgumentException("Token Error");
            }

            Claims claims = getUserInfoFromToken(token, key);
            String username = claims.getSubject();
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);

            log.info("인증 완료: {}", SecurityContextHolder.getContext().getAuthentication());
        } else {
            log.warn("토큰이 요청에 포함되지 않았습니다.");
        }

        filterChain.doFilter(request, response);
    }

    // 토큰 검증
    public boolean validateToken(String token, SecretKey key) {
        if (token == null || token.isEmpty()) {
            log.error("토큰 값이 null 또는 비어있습니다.");
            return false;
        }
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            log.info("토큰 검증 성공");
            return true;
        } catch (SecurityException | MalformedJwtException | SignatureException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

    // HttpServletRequest 에서 Cookie Value : JWT 가져오기
    public String getTokenFromRequest(HttpServletRequest req) {
        Cookie[] cookies = req.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                log.info("쿠키 이름: {}, 쿠키 값: {}", cookie.getName(), cookie.getValue());
                if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
                    String cookieValue = cookie.getValue();
                    if (cookieValue != null) {
                        // 그냥 토큰을 그대로 사용합니다. URLDecoder.decode는 필요없음
                        log.info("디코딩된 토큰: {}", cookieValue);  // 디코딩 없이 그대로 사용
                        return cookieValue;
                    } else {
                        log.error("쿠키 값이 null 입니다.");
                    }
                }
            }
        }
        log.warn("Authorization 쿠키가 요청에 포함되지 않았습니다.");
        return null;
    }

    // 토큰에서 사용자 정보 가져오기
    public Claims getUserInfoFromToken(String token, SecretKey key) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }

    // 인증 통과 url
    private boolean isAuthorizationPassRequest(String path) {
        return path.startsWith("/api/v1/auth/login") || path.startsWith("/api/v1/auth/sign-up");
    }

    private SecretKey getSecretKey() {
        if (secretKey == null || secretKey.isEmpty()) {
            throw new IllegalStateException("JwtFilter에 secretKey가 null입니다.");
        }
        return Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
    }

}
저작자표시 비영리 변경금지 (새창열림)

'Troubleshooting' 카테고리의 다른 글

[IntelliJ] Unable to create tempDir. java.io.tmpdir is set to  (0) 2025.03.22
[Spring Boot] @Value 어노테이션 사용시 null 이 나오는 원인 및 해결방법  (1) 2025.03.01
'Troubleshooting' 카테고리의 다른 글
  • [IntelliJ] Unable to create tempDir. java.io.tmpdir is set to
  • [Spring Boot] @Value 어노테이션 사용시 null 이 나오는 원인 및 해결방법
Yun-seul
Yun-seul
재능이 없으면 열심히라도 하자
  • Yun-seul
    윤슬
    Yun-seul
  • 전체
    오늘
    어제
    • 분류 전체보기 (24)
      • Minecraft 모드 개발 (2)
      • Java (1)
        • Java 기본 (1)
      • Spring Boot (9)
        • Sprng Boot Sample Code (9)
      • Docker (1)
      • Kubernetes (7)
        • Common (3)
        • GKE(Google Kubernetes Engin.. (4)
        • EKS(Elastic Kubernetes Serv.. (0)
      • Redis (0)
      • AWS (0)
      • Git (0)
      • Reflection (1)
      • Troubleshooting (3)
      • Performance Tuning (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    GKE
    unable to create tempdir. java.io.tmpdir is set to
    globalexception
    전역예외처리
    onceperrequestfilter
    임시디렉토리
    필터2번
    어세스토큰
    rfc6750
    kubernetes
    djava.io.tmpdir
    @value #null #어노테이션 #springboot #springioc #컨테이너
    재요청
    SpringBoot
    jwt토큰 #로그인 #회원가입 #쿠키 #보안설정
    에러코드관리
    쿠버네티스
    methodargumentnotvalidexception
    커스텀익셉션
    docker
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Yun-seul
[Spring Boot] [성능 개선] Spring Boot Validation에 걸릴 경우 Filter가 2번 작동하는 문제
상단으로

티스토리툴바