Spring Security + JWT 인증/인가 + Refresh Token 도입 삽질 기록

E.NO·2026년 2월 12일

오늘은 결제 프로젝트에서 Spring Security를 JWT 기반(stateless)으로 구성하고, Access Token / Refresh Token을 도입하면서 겪었던 이슈들과 해결 과정을 정리했다.


1. 목표: 세션 없이 JWT로 인증하기 (Stateless)

핵심 설정

  • CSRF 비활성화 (JWT는 보통 쿠키 세션 기반이 아니라서)
  • SessionCreationPolicy.STATELESS
  • /api/**는 기본 인증 필요
  • 로그인/회원가입/리프레시는 permitAll
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
    .requestMatchers(HttpMethod.POST, "/api/login", "/api/signup", "/api/refresh").permitAll()
    .requestMatchers("/api/**").authenticated()
    .anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

2. JWT 인증 흐름 정리

로그인 시

  1. AuthenticationManager.authenticate()로 인증 시도
  2. 성공하면 principal에서 MemberUserDetails 꺼냄
  3. Access/Refresh 토큰 발급
  4. Refresh 토큰은 DB에 저장(원문보단 해시 저장 추천)
Authentication authentication = authenticationManager.authenticate(
    new UsernamePasswordAuthenticationToken(email, password)
);

MemberUserDetails userDetails = (MemberUserDetails) authentication.getPrincipal();
String accessToken = jwtProvider.createAccessToken(userDetails.getUsername(), userDetails.getMember().getRole());
String refreshToken = jwtProvider.createRefreshToken(userDetails.getUsername());

3. UserDetails / UserDetailsService 붙이기

왜 붙였나?

기존에는 로그인 시 내가 직접 memberRepository.findByEmail 후 passwordEncoder.matches 했는데 Spring Security 정석 흐름으로 가려면 UserDetailsService가 필요하다.

  • UserDetailsService.loadUserByUsername(email)에서 회원 조회
  • UserDetails에서 password/authorities 제공
  • AuthenticationManager가 알아서 비밀번호 검증까지 처리

배운 점: UserDetailsService에서는 “Security 표준 예외”를 던져야 한다

처음에 회원 없을 때 내가 만든 예외(ServiceErrorException)을 던졌더니…

로그인 호출 시 아래 예외로 감싸져서 올라오며 500이 됨:

  • InternalAuthenticationServiceException
  • caused by ServiceErrorException

-> 결론: UserDetailsService에서 회원 없으면 UsernameNotFoundException을 던지는 게 정석.


4. JwtAuthenticationFilter 구성 + Refresh Token 인증 방지

필터의 역할

  • 요청에서 Authorization: Bearer 추출
  • 토큰 검증
  • 토큰에서 email 꺼내기
  • UserDetailsService로 유저 조회
  • SecurityContextHolder에 Authentication 세팅
if (token != null && jwtProvider.validateToken(token)) {

    // refresh token은 인증용이 아님
    if (jwtProvider.isRefreshToken(token)) {
        filterChain.doFilter(request, response);
        return;
    }

    String email = jwtProvider.getClaims(token).getSubject();
    UserDetails userDetails = userDetailsService.loadUserByUsername(email);

    UsernamePasswordAuthenticationToken authentication =
        new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
    SecurityContextHolder.getContext().setAuthentication(authentication);
}

또 하나 배운 점

  • 필터에서 에러 응답 내려주면 반드시 return 해야 한다
    (filterChain.doFilter까지 타버리면 응답이 섞이거나 이상해질 수 있음)

5. “ServiceErrorException인데 왜 500이 떠요?” 원인 파악

내가 원했던 기대

memberRepository.findByEmail(email).orElseThrow(() -> new ServiceErrorException(...));

이러면 GlobalExceptionHandler에서 잡혀서 400으로 떨어지길 기대했다.

실제 원인

  • 예외가 컨트롤러/서비스가 아니라 Security 내부 인증 과정(AuthenticationManager) 에서 터짐
  • DaoAuthenticationProvider가 InternalAuthenticationServiceException으로 감싸서 던짐
  • 그래서 내가 만든 ServiceErrorException 핸들러가 아니라, Exception.class 핸들러로 떨어지거나 예상치 못한 코드로 응답이 나감

-> 해결 방향

  • UserDetailsService에서 UsernameNotFoundException 사용
  • 또는 GlobalExceptionHandler에서 AuthenticationException 계열을 별도로 처리

7. 오늘 얻은 결론 / 체크리스트

JWT + Security 구성 체크리스트

  • SecurityConfig permitAll 경로가 실제 컨트롤러 경로와 일치하는가?
  • Authorization 헤더는 항상 Bearer prefix가 붙는가?
  • Refresh Token은 인증 필터에서 막고 있는가?
  • UserDetailsService는 UsernameNotFoundException 등 표준 예외를 쓰는가?
  • 필터에서 에러 응답 시 return으로 체인을 끊는가?

0개의 댓글