개발하는 햄팡이

[Trouble][Spring] SpringSecurity, JwtFilter 예외 발생 시 공통응답객체 형태로 반환하기 본문

카테고리 없음

[Trouble][Spring] SpringSecurity, JwtFilter 예외 발생 시 공통응답객체 형태로 반환하기

hampangee 2025. 7. 29. 00:26

 

https://github.com/final-gabom/gabom-project

 

GitHub - final-gabom/gabom-project

Contribute to final-gabom/gabom-project development by creating an account on GitHub.

github.com

 

 

요즘 한달 좀 넘는 프로젝트를 진행하는 중인데

회원가입 로그인 구현 파트를 맡게 되었다.

JWT랑 SpringSecurity를 사용하는데 예외처리 이것저것 하면서 많은 문제를 겪고 있다....

 

이전 프로젝트 할 때 회원가입 로그인은 많이 구현하니깐 이전 프로젝트에서 다른 사람이 구현한 코드 그대로 가져오면 되겠지 했는데 아무래도 내배캠에서 일주일동안 구현한 프로젝트다 보니 이런저런 세밀한 부분에서 문제가 많아서 

그런 부분을 고치면서 진행하는 중이다.

 

다양한 문제가 있었는데 나중에 차차 정리하면서 올릴 예정이다.

 


 

이번에 생긴 문제는 다음 상황에서 발생했다.

 

문제 상황

우리는 프로젝트에서 ApiResponse라는 공통응답 객체를 리턴하기로 했다.

 

공통응답객체로 리턴하게되면 프론트는 일관된 응답형태를 받을 수 있고 success 여부에 따라 data를 렌더링할지, 에러 메세지를 띄울지 결정하면 된다.

그리고 Spring에서 리턴해주는 에러는 구조가 다 다르기 때문에 이걸 프론트에서 하나하나 처리하는 것보다 백엔드에서 일관된 형태로 정리해서 보내주면 훨씬 좋을 수 밖에 없다.

 

그래서 만들어진 공통응답객체 class가 ApiResponse.java이다.

 

- ApiResponse.java -

더보기
@Getter
public class ApiResponse<T> {
	private final boolean success;
	private final String message;
	private final T data;
	@JsonInclude(JsonInclude.Include.NON_NULL)	// null일 때 JSON에서 제외됨
	private final String errorCode;
	@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
	private final LocalDateTime timestamp;

	@Builder
	private ApiResponse(boolean success, String message, T data, String errorCode, LocalDateTime timestamp) {
		this.success = success;
		this.message = message;
		this.data = data;
		this.errorCode = errorCode;
		this.timestamp = timestamp;
	}

	// 성공 응답용
	public static <T> ApiResponse<T> success(String message, T data) {
		return ApiResponse.<T>builder()
						  .success(true)
						  .message(message)
						  .data(data)
						  .timestamp(LocalDateTime.now())
						  .build();
	}

	public static <T> ApiResponse<T> success(String message) {
		return ApiResponse.<T>builder()
						  .success(true)
						  .message(message)
						  .data(null)
						  .timestamp(LocalDateTime.now())
						  .build();
	}

	// 실패 응답용(메세지 커스텀)
	public static <T> ApiResponse<T> fail(String message, ErrorCode errorCode) {
		return ApiResponse.<T>builder()
						  .success(false)
						  .message(message)
						  .data(null)
						  .errorCode(errorCode.name())
						  .timestamp(LocalDateTime.now())
						  .build();
	}

	// 실패 응답용
	public static <T> ApiResponse<T> fail(ErrorCode errorCode) {
		return ApiResponse.<T>builder()
						  .success(false)
						  .message(errorCode.getMessage())
						  .data(null)
						  .errorCode(errorCode.name())
						  .timestamp(LocalDateTime.now())
						  .build();
	}
}

 

 

로그인 유저 검증 과정에서 token값을 확인하는 jwtFilter를 거치게 되는데 해당 부분은 @ControllerAdvice로 선언  GlobalExceptionHandler가 예외처리를 해줄 수 없다.

 

설명에 필요한 스프링 구조를 간단하게 살펴보면

대충 이렇게 생겼는데

 

GlobalExceptionHandler 내부에서는 @ExceptionHandler를 기반으로 동작하는데

이는 Spring MVC의 DispatcherServletController나 HandlerAdapter에서 발생한 예외를 처리하는 기능을 맡고있다.

 

그런데 Filter는 DispatcherServlet보다 앞단에 위치하기 때문에 Filter에서 터진 exception은 SpringContext에 위치한 Interceptor가 처리하지 못한다고 한다.

 

 

원래 validateToken에서 try-catch로 분기해 줬는데 아래의 exception이 공통응답객체로 나오지 않는 상황

(원래 globalExceptionHandler에서 공통응답객체로 만들어서 응답을 보내주니깐..)

public boolean validateToken(String token) {
    log.debug("JWT 유효성 검증 시작");
    try {
        if (StringUtils.hasText(token)) {
            // 1. 서명을 검증하고 파싱 시도 (예외 발생 시 false)
            Jwts.parser().verifyWith(this.key).build().parseSignedClaims(token);
            return true;
        }
    } catch (MalformedJwtException ex) {
        // JWT 형식이 잘못된 경우 (구조가 비정상)
        log.error("Invalid JWT token : {}", token, ex);
        throw new CustomException(ErrorCode.INVALID_TOKEN);
    } catch (ExpiredJwtException ex) {
        // 토큰이 만료된 경우
        log.error("Expired JWT token : {}", token, ex);
        throw new CustomException(ErrorCode.EXPIRED_TOKEN);
    } catch (UnsupportedJwtException ex) {
        // 지원하지 않는 JWT 형식인 경우
        log.error("Unsupported JWT token : {}", token, ex);
        throw new CustomException(ErrorCode.UNSUPPORTED_TOKEN);
    } catch (IllegalArgumentException ex) {
        // Claims 문자열이 비어 있거나 null인 경우
        log.error("JWT claims string is empty. : {}", token, ex);
        throw new CustomException(ErrorCode.EMPTY_TOKEN);
    } catch (Exception ex) {
        // 기타 예외 (예: 서명 실패 등)
        log.error("Invalid JWT token : {}", token, ex);
        throw new CustomException(ErrorCode.SIGNATURE_INVALID);
    }
    return false;
}

 

 

저렇게 하면 filter에서 validateToken메소드를 호출했을때 CustomException이 뜨고

→ Filter에서 또 캐치하여 공통응답 객체를 리턴하면 될 것 이라고 생각해서 Filter를 아래 처럼 구현했다.

더보기
/**
 * HTTP 요청을 처리하는 필터로, 요청에 포함된 JWT 토큰을 검증하고 유효한 경우 인증 정보를 SecurityContext에 설정합니다.
 * <p>
 * 이 메서드는 Spring Security에서 요청을 처리하기 전에 호출되며, 사용자의 인증 상태를 설정합니다.
 * 유효한 JWT 토큰이 있을 경우, 해당 토큰에서 사용자 ID를 추출하고, 이를 통해 사용자의 정보를 로드한 후 인증 정보를 설정합니다.
 * </p>
 *
 * @param request HTTP 요청 객체
 * @param response HTTP 응답 객체
 * @param filterChain 필터 체인, 다음 필터를 호출할 때 사용
 * @throws ServletException 서블릿 처리 중 발생한 예외
 * @throws IOException 입출력 관련 예외
 */
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                FilterChain filterChain) throws ServletException, IOException {
    try{
        log.debug("🔐 JWT 인증 필터 시작");
        // 요청에서 JWT 토큰을 추출
        String token = this.getJwtFromRequest(request);

        // 토큰이 있으면 유효성 검증 후 인증 처리
        if (jwtProvider.validateToken(token)) {
            // 유효한 토큰이라면, 토큰에서 사용자 ID를 추출
            String userId = jwtProvider.getUserIdFromToken(token);
            // 사용자 ID를 기반으로 사용자 정보를 로드
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(userId);
            // 사용자 정보와 권한을 기반으로 인증 토큰 생성
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities());
            // 인증 토큰에 요청 정보를 설정 (추후 세션에서 사용할 수 있도록 설정)
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            // SecurityContext에 인증 정보를 설정하여 후속 요청에서 사용할 수 있도록 함
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            log.debug("✅ 인증 완료 - userId: {}", userId);
        }
        filterChain.doFilter(request, response);
    } catch (CustomException e) {
        setErrorResponse(response, e.getErrorCode());
    } catch (Exception e) {
        setErrorResponse(response, INTERNAL_SERVER_ERROR);
    }
}

/**
 * HTTP 요청에서 JWT 토큰을 추출하는 메서드입니다.
 * <p>
 * Authorization 헤더에서 "Bearer"로 시작하는 토큰을 추출하여 반환합니다.
 * 만약 "Bearer"로 시작하지 않거나 헤더가 없으면 null을 반환합니다.
 * </p>
 *
 * @param request HTTP 요청 객체
 * @return JWT 토큰 문자열, 없으면 null
 */
private String getJwtFromRequest(HttpServletRequest request) {
    String bearerToken = request.getHeader("Authorization");
    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
        return bearerToken.substring(7);
    }
    return null;
}

 

 

 

이전까지는 상황설명이었고

여기서부터 본격적인 문제가 터진다.

 

저렇게 해준 다음 테스트를 해봤는데 이상한 토큰을 집어 넣었을 때 (그때 상황엔 Bearer을 넣지 않은 상태였음.)

이상한 토큰이므로 INVALID_TOKEN 에러코드가 뜨고 400이 떠야하는데

 

일단 공통응답객체가 아니고 -> 403으로 뜨는 중...

그리고 뭐 인증이 안됐다 쳐도 이상한 토큰이면 401로 떠야하는데 403으로 뜬다는게 이상한 상황....

 

 

 

 

원인

코드를 열심히 뜯어보고 로그를 찍어보니

이상한 토큰이 들어갔을때 Filter에 있는 private메소드인 getJwtFromRequest에서 Header 를 가져와서 빈 토큰인지 Bearer로 시작하는지 검증같은 검증을 한번 하는데,

이때, 이상한 토큰이 들어올 경우 null로 반환하는 상태.

 

null값이 validateToken으로 전달되면서 try-catch안에 들어가는데 StringUtils.hasText(token)에서 token이 null이라서 그대로 false를 반환함...

 

그리고 이 false를 받은 filter는 그냥 문제 없이 doFilter로 빈 토큰 상태를 넘기게 됨.

그래서 인증은 통과되어 버림..

그러나 권한이 필요한(로그인 한 사람만 접근 가능) 페이지 이므로 인증은 통과 되고 인가에서 exception이 터져 403이 뜨고 있었던 것.

 

 

 

그래서 validateToken에서 토큰이 null이 들어오면 exception이 터지게 했는데

이러니깐 로그인이 필요하지 않은 페이지도 Exception이 터져서 접근할 수 없게 되어버렸다...

 

 

해결방안

원인을 찾는건 힘들었지만 해결은 그래도 간단했다.

 

getJwtFromRequest가 좀 문제였는데,

원래 1. Header에 Authorization이 있고2.토큰 형태가 정상일 때 파싱을 했었는데

헤더가 없는 경우, null을 반환(로그인 안해도 되는 기능을 사용하기 위해)하고

헤더가 있는 경우, 로그인을 했는데 토큰 값이 이상하다는 뜻이므로 Exception발생 시켰다.

private String getJwtFromRequest(HttpServletRequest request) {
    String bearerToken = request.getHeader("Authorization");

    // 1. Authorization 헤더가 아예 없는 경우
    if (!StringUtils.hasText(bearerToken)) {
        return null;
    }

    // 2. 형식이 잘못된 경우 → 예외 발생
    if (!bearerToken.startsWith("Bearer ")) {
        throw new CustomException(INVALID_TOKEN);
    }

    // 3. 정상적인 Bearer 토큰이면 파싱
    return bearerToken.substring(7);
}

 

 


위의 문제는 해결이 됐지만 또 다른 문제를 겪게 되는데...

이건 다음 글에 포스팅을 하려고 한다.

 

위의 문제를 해결하고 공통 응답객체가 잘 나오는 것을 확인했는데

다른 기능을 구현하고 테스트를 해보는 중 HttpMethod를 잘못 넣어 로그에는 HttpRequestMethodNotSupportedException이 발생했다고 나오는데 실제 결과는 401 로그인이 필요합니다가 뜨게 된다.....

 

To Be Continue...