Spring Security + JWT 예외 처리 리팩토링 & 설정 분리 (테스트까지)

오병택·2026년 2월 5일
post-thumbnail

1. 문제의 시작: 만료 시간 단위 버그(ms vs minutes)

Date#getTime()은 밀리초(ms) 기준이기 때문에, 분(minute) 단위를 그대로 더하면 만료 시간이 의도와 다르게 계산될 수 있다.

잘못된 예

now.getTime() + expireMinutes (expireMinutes가 “분”이면 단위 불일치)

해결: ms로 변환해서 더하기 또는 아예 java.time 사용

최종적으로 Duration/Instant 기반으로 변경

Date now = new Date();
Instant instant = now.toInstant().plus(Duration.ofMinutes(props.accessToken().expireMinutes()));
Date exp = Date.from(instant);

2. TimeUnit vs Duration vs Instant 역할 정리

TimeUnit

  • 시간 단위 변환/동시성 API 단위 지정용(enum)

Duration

  • “얼마나”(기간) 자체를 표현하는 시간량

Instant

  • “언제”(시점) 자체를 표현하는 UTC 타임라인의 한 시점

-> 토큰 만료처럼 “시점 + 기간” 계산은 Instant + Duration이 가장 읽기 좋다.

3. JWT 예외 분기 위치: EntryPoint vs Filter

JWT 인증 실패를 어디서 분기할지 고민했다.

선택지 A) EntryPoint에서 JWT 라이브러리 예외를 직접 분기

단점

  • io.jsonwebtoken.* 예외 타입을 EntryPoint가 알아야 해서 라이브러리 결합이 커짐

  • 예외 종류가 늘수록 EntryPoint 분기 로직이 복잡해짐

선택지 B) Filter에서 분기

단점

  • 필터가 ExpiredJwtException, MalformedJwtException… catch가 늘어날 수 있음

결론

  • JWT 라이브러리 예외를 “Parser/Provider 내부에서” 우리 예외로 변환하고, Filter는 단일 타입만 잡도록 설계
    → “분기는 도메인(토큰 처리) 레이어에서”, “흐름 제어는 필터에서”가 목표

4. 핵심 리팩토링: 예외를 AuthenticationException으로 수렴

Spring Security에서 “인증 실패”는 보통 AuthenticationException 계열로 흘러가야 한다.

그래서 JWT 관련 오류를 담는 커스텀 예외를 만들었다.

JwtAuthenticationException 코드

public class JwtAuthenticationException extends AuthenticationException {
  private final ErrorCode errorCode;

  public JwtAuthenticationException(Throwable cause, ErrorCode errorCode) {
    super(errorCode.getMessage(), cause);
    this.errorCode = errorCode;
  }

  public JwtAuthenticationException(ErrorCode errorCode) {
    super(errorCode.getMessage());
    this.errorCode = errorCode;
  }
}

cause는 필수는 아니지만,

  • 디버깅/로그에서 원인 추적이 쉬워서 있는 케이스는 넣는 쪽 추천

  • 응답에는 cause를 노출하지 않고, ErrorCode 기반으로만 응답하도록 설계

5. 왜 “필터에서 EntryPoint를 직접 호출”했나?

JWT 필터는 일반적으로 UsernamePasswordAuthenticationFilter보다 앞단에 놓인다.
그 경우 Spring의 ExceptionTranslationFilter가 자동으로 EntryPoint를 호출해주지 못하는 케이스가 생길 수 있다(필터 순서 문제).

그래서 JWT 필터에서 인증 예외가 발생하면 직접 EntryPoint를 호출하고 즉시 종료하도록 했다.

JwtFilter 코드

try {
  String token = tokenExtractor.extract(request);

  if (StringUtils.hasText(token)) {
    Authentication authentication = tokenProvider.getAuthentication(token);
    SecurityContextHolder.getContext().setAuthentication(authentication);
  }

  filterChain.doFilter(request,response);
} catch (AuthenticationException e) {
  SecurityContextHolder.clearContext();
  authenticationEntryPoint.commence(request, response, e);
  return;
}

return;은 catch 뒤에 더 실행할 코드가 없으면 “필수”는 아니지만,

나중에 코드가 추가될 때 실수 방지용으로 남겨두는 게 안전하다.

6. “AuthenticationException이면 JwtException도 잡히나?”

아니다.

AuthenticationException은 Spring Security 계열

JwtException, IllegalArgumentException은 별개 런타임 예외

따라서 필터에서 catch (AuthenticationException)만 잡고 싶다면,

  • JwtException/IllegalArgumentException을 Parser/Provider에서 JwtAuthenticationException으로 변환해야 한다.

7. JWT Parser에서 라이브러리 예외를 ErrorCode로 매핑

JwtTokenParser.extractClaims() 코드

public Claims extractClaims(String token) {
  try {
    return Jwts.parser()
      .verifyWith(key)
      .build()
      .parseSignedClaims(token)
      .getPayload();

  } catch (MalformedJwtException e) {
    throw new JwtAuthenticationException(e, ErrorCode.MALFORMED_JWT);
  } catch (ExpiredJwtException e) {
    throw new JwtAuthenticationException(e, ErrorCode.EXPIRED_JWT);
  } catch (UnsupportedJwtException e) {
    throw new JwtAuthenticationException(e, ErrorCode.UNSUPPORTED_JWT);
  } catch (SecurityException e) {
    throw new JwtAuthenticationException(e, ErrorCode.INVALID_SIGNATURE_JWT);
  } catch (IllegalArgumentException | JwtException e) {
    throw new JwtAuthenticationException(e, ErrorCode.INVALID_JWT);
  }
}
  • 이 구조 덕분에 Filter는 더 이상 JwtException을 몰라도 된다.
    (JWT 라이브러리 결합 제거)

주의

SecurityException은 JJWT에서 실제로 떨어지는 타입 import를 명확히 맞추는 게 좋다.

8. Provider에서 Subject 검증/변환 및 UserDetails 로드

JwtTokenProvider.getAuthentication() 코드

Claims claims = jwtTokenParser.extractClaims(accessToken);

String subject = claims.getSubject();
if (!StringUtils.hasText(subject)) {
  throw new JwtAuthenticationException(ErrorCode.INVALID_JWT_SUBJECT);
}

Long userId;
try {
  userId = Long.valueOf(subject);
} catch (NumberFormatException e) {
  throw new JwtAuthenticationException(e, ErrorCode.INVALID_JWT_SUBJECT);
}

UserDetails userDetails = customUserDetailsServiceImpl.loadUserById(userId);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
  • Subject가 비어있거나 숫자가 아니면 ErrorCode로 명확히 분기

  • 유저가 없으면 loadUserById()에서 JwtAuthenticationException으로 처리

9. CustomUserDetailsService 설계와 예외 처리

JWT 경로에서는 loadUserById()가 필요해서 커스텀 서비스에 별도 메서드를 둠.

loadUserById() 코드

public UserDetails loadUserById(Long id) {
  User foundUser = userRepository.findById(id)
    .orElseThrow(() -> new JwtAuthenticationException(ErrorCode.USERNAME_NOT_FOUND));

  return CustomUserDetails.fromJwt(foundUser);
}

loadUserByUsername() 코드

  • 로그인 흐름에서는 UsernameNotFoundException을 유지

    jwt 관련이 아니기 때문에 일단은 저렇게 처리해둠

public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
  User foundUser = userRepository.findByEmail(email)
    .orElseThrow(() -> new UsernameNotFoundException("유저가 존재하지 않습니다."));

  return CustomUserDetails.fromLogin(foundUser);
}

10. Extractor 정책: Bearer 아니면 null 반환하면 어떻게 되나?

현재 JwtTokenExtractor는 Bearer 형식이 아니면 null 반환

if (hasText && startsWith("Bearer ")) return token;
return null;

이 경우:

  • 보호된 엔드포인트: 인증이 없으니 결국 401/403로 떨어짐

  • permitAll 엔드포인트: 그냥 정상 통과

즉 “Authorization 헤더가 이상해도” 그냥 토큰 없는 요청처럼 취급한다는 정책이 된다.
엄격하게 하고 싶으면 “헤더가 있는데 Bearer 아니면 예외”로 바꿀 수도 있다.

이것도 크게 중요하지 않은 것 같아서 일단 이렇게 처리해둠

11. 설정 파일 분리: dev에는 있는데 test에는 없어서 null 바인딩 문제

jwt.* 설정이 application-dev.yml에만 있으면,
테스트에서 test 프로파일로 실행될 때 jwt.*가 없어 null 바인딩이 된다.

해결 패턴

  • 공통값은 application.yml

  • secret key는 프로파일별로(application-dev.yml, application-test.yml) 분리

application.yml (공통)
jwt:
  issuer: DeliveryPlatform
  access-token:
    expire-minutes: 10
application-dev.yml
jwt:
  secret:
    key: ${JWT_SECRET_KEY}
application-test.yml (테스트 전용 더미키)
jwt:
  secret:
    key: "~~"
  • 테스트에서 환경변수 없이도 실행 가능

  • 키 길이/형식(Base64)을 만족하도록 준비

마무리

오늘 변경의 핵심 요약

  • JWT 만료 계산을 Duration/Instant로 수정

  • JWT 라이브러리 예외를 Parser에서 ErrorCode 기반으로 JwtAuthenticationException으로 변환

  • 필터는 AuthenticationException 하나만 잡고 EntryPoint 직접 호출

  • UserDetailsService는 JWT용(id 기반) / 로그인용(email 기반) 경로를 분리

  • 테스트 환경에서 설정 누락으로 null 바인딩되는 문제를 공통/프로파일 분리 패턴으로 해결

  • 테스트 전용 Base64 secret key 준비

profile
걱정하지 말고 일단 해봐!

0개의 댓글