Spring Security + JWT 설계 회고

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

1) JwtFilter를 @Bean으로 등록할 때: 메서드 파라미터 주입 vs 설정 클래스 필드 주입

메서드 파라미터로 받는 방식(추천되는 경우가 많음)

@Bean
public Filter jwtFilter(RequestMatcher publicEndPoints,
                        JwtTokenExtractor jwtTokenExtractor,
                        JwtTokenProvider jwtTokenProvider) {
    return new JwtFilter(publicEndPoints, jwtTokenExtractor, jwtTokenProvider);
}
  • 스프링이 @Bean 메서드 파라미터 타입을 보고 빈을 찾아 주입

장점

  • 해당 빈이 어떤 의존성이 필요한지 시그니처에 드러나서 명확

  • 설정 클래스가 불필요한 필드를 들고 있지 않아 결합도 감소

  • @Qualifier 등도 파라미터에서 처리하기 쉬움

설정 클래스가 의존성을 “필드/생성자 주입으로 보관”하고 @Bean에서 사용하는 방식

@Bean
public Filter jwtFilter() {
    return new JwtFilter(publicEndPoints, jwtTokenExtractor, jwtTokenProvider);
}

동작은 동일하게 가능

  • 다만 설정 클래스가 커질수록 “보관하는 의존성”이 늘어 설정 클래스 비대화가 생길 수 있음

  • jwtFilter()만 보면 무엇이 필요한지 덜 명확해질 수 있음

2) filterChain() 안에서 jwtFilter(publicEndpoints()) 직접 호출이 늘어나도 되나?

초기에 흔히 이렇게 쓰게 됨

.addFilterBefore(jwtFilter(publicEndpoints()), UsernamePasswordAuthenticationFilter.class);

하지만 “스프링 주입”을 제대로 활용하면 더 깔끔해짐.

권장 형태(빈을 파라미터로 받기)

@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
                                       RequestMatcher publicEndpoints,
                                       Filter jwtFilter) throws Exception {

    	~~
        .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

    return http.build();
}
  • publicEndpoints() 메서드를 여러 번 호출해서 조립하는 느낌이 아니라

  • 스프링이 이미 만든 “하나의 빈”을 주입받아 일관되게 사용

JWT 예외 처리: “설정 단계”와 “런타임 단계”는 완전히 다르다

3) jwtSigningKey()에서 Base64 + 키 길이 검증을 명시적으로 하라

원래 코드

@Bean
public SecretKey jwtSigningKey(JwtProperties props) {
    byte[] bytes = Base64.getDecoder().decode(props.secret().key());
    return Keys.hmacShaKeyFor(bytes);
}

문제

  • Base64가 깨지면 IllegalArgumentException

  • 키가 짧으면 WeakKeyException (JJWT 내부에서)

  • 원인 파악이 애매해지고, 설정 오류가 런타임/라이브러리 예외로 흩어짐

개선(설정 단계에서 fail-fast):

@Bean
public SecretKey jwtSigningKey(JwtProperties props) {
    byte[] bytes;
    try {
        bytes = Base64.getDecoder().decode(props.secret().key());
    } catch (IllegalArgumentException e) {
        throw new IllegalStateException("JWT secret must be Base64-encoded", e);
    }

    if (bytes.length < 32) { // HS256 최소 256-bit
        throw new IllegalStateException("JWT secret is too short: minimum 32 bytes required");
    }

    return Keys.hmacShaKeyFor(bytes);
}

4) “WeakKeyException으로 처리하면 안 되나?”

가능. 그리고 이 위치(@Bean 생성)에서 터지면 WeakKeyException도 애플리케이션 시작이 실패하니까 fail-fast 자체는 동일

그럼에도 IllegalStateException으로 바꾼 이유

  • 이건 “요청 처리 중 인증 실패”가 아니라 애플리케이션 설정이 잘못된 상태

  • IllegalStateException이 의미적으로 더 맞음(상태/환경이 잘못됨)

  • 메시지를 우리 기준으로 고정해서 “원인 파악”이 쉬움

  • 라이브러리 예외 타입에 덜 의존하는 구조가 됨

결론: fail-fast 여부가 아니라, “설정 오류를 설정 단계에서 명확히 표준화한다”는 의미가 크다.

Spring Security에서 “인증 실패를 EntryPoint로 중앙화”할 때 가장 중요한 것

5) ExceptionTranslationFilter(ETF)와 필터 순서 문제

많이 오해하는 포인트

“JwtFilter에서 AuthenticationException 던지면 EntryPoint로 가겠지?”

조건이 있음

ExceptionTranslationFilter는 필터 체인에서 발생한 AuthenticationException을 잡아 EntryPoint로 위임함

그런데 ETF가 잡을 수 있는 건 ‘자기 뒤에서 발생한 예외’인 경우가 일반적임

즉,

  • JwtFilter를 UsernamePasswordAuthenticationFilter 앞에 두면,

  • 보통 ETF보다도 앞쪽에 위치하게 되는 경우가 많아서

  • JwtFilter에서 던진 예외가 ETF까지 “자동으로” 안 들어가서 EntryPoint가 안 탈 수 있음

선택지 2개

A) JwtFilter에서 EntryPoint를 직접 호출 (순서에 덜 민감)

  • 예외 발생 시 entryPoint.commence(...) 호출하고 return

B) JwtFilter를 ETF “뒤”로 배치

예: addFilterAfter(jwtFilter, ExceptionTranslationFilter.class)

  • ETF가 JwtFilter를 감싸는 형태면 AuthenticationException을 잡아 EntryPoint로 넘김

결론: “EntryPoint를 쓰겠다”면 필터 순서가 설계의 일부다.

BadCredentialsException / AuthenticationException 관련 질문 정리

6) “BadCredentialsException 던지면 AuthenticationException으로 변환되는 거야?”

상황 설명

Authentication 예외를 발생시키면 entrypoint에서 처리되지 않을까에서 찾아보다가 BadCredentialsException을 던지는 코드가 있길래 궁금해졌음

JwtFilter 코드

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

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

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

			filterChain.doFilter(request,response);
		} catch (JwtException | IllegalArgumentException e) {
			throw new BadCredentialsException("JWT_INVALID", e);
		}
	}

“변환”이 아니라 상속 관계

BadCredentialsExceptionAuthenticationException의 하위 클래스

따라서 throw new BadCredentialsException(...) 하면 이미 “AuthenticationException 계열”이 던져지는 것

7) “그럼 AuthenticationException을 직접 던지면 되잖아?”

안 됨.

  • AuthenticationException은 abstract(추상 클래스)라 직접 new 할 수 없음

  • 그래서 구체 하위 클래스(대표적으로 BadCredentialsException)를 사용하거나,

  • 아예 커스텀 AuthenticationException 하위 클래스를 만들기도 함

ErrorCode로 JWT 에러를 통일할 수 있나?

8) 가능 다만 “필터 단계”라서 처리 위치가 중요

원한 것

  • JWT 만료/서명불일치/형식오류 등을 프로젝트 표준 응답(ErrorCode)으로 내려주기

가능한 방식 2개

방식 1) JwtFilter에서 직접 응답 작성

  • 가장 직관적

  • 대신 Filter가 “응답 작성 책임”까지 가져서 관심사 분리가 약해질 수 있음

방식 2) AuthenticationEntryPoint로 중앙화

  • Spring Security 표준 패턴에 가까움

  • 인증 실패 응답을 한 곳에서 통일하기 좋음

다만 “JwtFilter에서 발생한 예외가 EntryPoint로 잘 전달되도록(필터 순서/위임 방식)” 설계해야 함

JwtFilter의 작은 설계 고민: extractToken은 Filter 책임인가?

9) extractToken(Authorization 헤더 파싱)을 JwtFilter 안에 둘까, 분리할까?

분리(Extractor 컴포넌트로)

  • SRP(단일 책임) 관점에서 좋음

  • 재사용 가능(다른 필터/컨트롤러/테스트 등)

  • 단위 테스트가 쉬움

예: JwtTokenExtractor.extract(request)

Filter 내부 private 메서드로 유지

  • 정말 재사용이 없고 단순함을 최우선으로 하면 OK

  • 대신 테스트/재사용성/책임 분리 측면은 약해짐

프로젝트가 이미 Parser/Provider를 분리한 구조라면 Extractor 분리가 일관성이 좋다

로그인 시 UserDetails 흐름 정리

10) loadUserByUsername(email) vs loadUserById(id) 역할 분리

로그인 API에서 AuthenticationManager.authenticate(...)

  • 내부적으로 Provider가 loadUserByUsername(email) 호출

  • PasswordEncoder로 비밀번호 검증

  • 성공 시 Authentication 반환

로그인 후 “토큰을 들고 들어오는 요청”

  • JwtFilter가 토큰 검증

  • 토큰에서 userId 추출

  • loadUserById(id)UserDetails 만들고

  • SecurityContextHolder에 Authentication 세팅

즉,

loadUserByUsername

로그인(비번 검증) 중심

loadUserById

JWT 인증(요청 처리 중 principal 세팅) 중심

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

0개의 댓글