본문 바로가기
[Spring]/Spring Security

당신의 첫 프로젝트를 위한 스프링 시큐리티(完) - JWT 인증 API, 토큰 재발행, 로그아웃 구현

by 팡펑퐁 2025. 8. 2.
728x90
📌 이번 글에서는 로그인 이후 과정인 JWT 인증 API, 토큰 재발행, 로그아웃 구현

🤗 저의 스프링 시큐리티 구현은 아래와 같은 시나리오를 기준으로 합니다.
- 프론트 엔드와 백엔드가 나뉘어 진행되는 프로젝트를 기반으로 하여 스프링 시큐리티 설정에서 로그인 페이지에 대한 설정을 따로 하지 않음
- JWT 토큰 인증 방식을 사용함
- 토큰 관리에 redis를 이용함

 

🙉 이전 글 보기

첫 번째 글부터 정독하시면 보다 쉽게 이해하실 수 있습니다!

https://suzuworld.tistory.com/438 - 당신의 첫 프로젝트를 위한 스프링 시큐리티 톺아보기

이전 글

https://suzuworld.tistory.com/457 - 로그인 시 JWT 발급과 redis 저장 구현하기

 

📖 목차

스프링 시큐리티 톺아보기

SecurityConfig 구성하기

인증 방식 개념과 AuthenticationFilter 

AuthenticationManager, AuthenticationProvider, UserDetailsService, UserDetails

로그인 테스트 및 JWT, redis 개념 정리

JwtTokenizer와 Redis 클래스 작성하기

로그인 시 JWT 발급과 redis 저장 구현하기

JWT 인증 API, 토큰 재발행, 로그아웃 구현 (현재 글)

 

지난 시간까지의 내용 정리

  • 지난 시간에는 로그인 시 JWT를 발급하도록 수정하고, 발급한 토큰 중에 refresh token을 redis에 저장하는 부분까지 구현했습니다.

 

⏰ 이번 시간 할 일

  • 드디어 긴 여정의 마지막 시간입니다!
  • 이번 시간에 구현할 부분은 크게 3 가지입니다.
    • 인증이 필요한 API의 요청 헤더에 AccessToken을 넣으면 인증이 통과하도록 테스트 코드 구현
    • Access Token이 만료되었을 경우 재발급할 수 있는 로직 구현
    • 로그아웃 구현

 

🙇🏻‍♂️ 들어가기 전에

  • 이 연재 글은 스프링 시큐리티를 이해하는 것에 초점이 맞추려고 합니다.
  • 따라서 토큰 재발행 및 로그아웃 구현은 설명에 생략이 많습니다.

 

🔹 JWT 검증 구현

SpringConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

	...
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    	
        ...
        
        .authorizeHttpRequests((authorizeRequests) -> authorizeRequests
                .requestMatchers("/login", "/enroll", "/h2-console/**").permitAll()
                .anyRequest().authenticated())
        ...
        
    }
}
  • 지난 시간에 설정해 둔 SecurityConfig를 살펴봅시다.

requestMatchers().permitAll()

  • "/login", "/enroll", "/h2-console/**"는 JWT 인증 없이 요청을 받아들일 수 있게 설정해 두었습니다.

anyRequest().authenticated()

  • 이 외의 모든 요청은 인증 토큰을 필요하도록 설정했습니다.
  • 따라서 앞으로 추가 API를 생성한다면 이 요청은 반드시 로그인 후 Access Token을 가지고 있는 사용자만이 정상적으로 요청을 전송할 수 있을 것입니다.

 

MemberController

  • 저희는 현재 로그인을 하면 Access Token과 Refresh Token을 발급하도록 구현했습니다.
  • 이제 이 토큰을 가지고 인증이 필요한 API에 싣고 요청을 보내어 정상 작동시키는 테스트를 만들어보겠습니다.
  • 토큰이 필요한 API에는 어떤 게 있을까요?
    • 예를 들어 폐쇄적인 앱을 생각해 봅시다. "홍길동"이란 이름을 가진 유저는 앱 내의 회원 목록을 보고 싶습니다.
    • 앱 정책에 의해 앱의 회원만이 회원 목록을 조회할 수 있다고 합시다.
    • "localhost:8080/membrers"라는 url 주소로 접속을 한 경우
      • 로그인하지 않은 사용자는 요청이 권한 없음으로 실패할 것입니다.
      • 로그인한 사용자는 유효한 Access Token이 있음으로 요청이 성공할 것입니다.
  • 위와 같이 MemberController에 테스트용 멤버 조회 API를 생성합니다.

 

Postman 요청해 보기

  • 현재 어떠한 변경사항 없이 멤버 목록을 조회하는 API를 요청하면 403 Forbidden이 발생합니다.
  • 이 이유는 위의 SecurityConfig에서 지정한 일부 엔드포인트("/login", "/enroll", "/h2-console/**")에 해당하지 않은 경로이기 때문에 권한 없는 요청은 모두 403 처리가 되어 응답되는 것입니다.
    • 또한 현재 권한이 있는지(유효한 AccessToken을 요청 헤더에 담았는지) 검증하는 어떠한 코드도 구현되어있지 않습니다.
  • 이 응답은 크게 두 가지 케이스로 나눌 수 있습니다.
    • 첫 번째는 로그인을 하지 않은 상태에서 url로 접근하는 것이고, 다른 하나는 로그인한 사용자이나 접근 권한이 없는 경우입니다.
    • 우선 이 두 가지 케이스에 해당하는 에러 응답을 커스텀하게 설정해 봅시다.

 

CustomAccessDeniedHandler

@Slf4j
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {

        log.warn("Access denied: {}", accessDeniedException.getMessage());

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");

        response.getWriter().write("{\"message\": \"접근이 제한되었습니다.\"}");
    }
}
  • 이 클래스는  권한이 없는 사용자가 요청을 했을 때인 403 Forbidden 상황에서 실행되는 커스텀 응답 처리기입니다.
    • 클라이언트에게 권한이 없어 접근이 제한되었음을 알립니다.

 

CustomAuthenticationEntryPoint

@Slf4j
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {

        log.warn("Unauthorized access: {}", authException.getMessage());

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
        response.setContentType("application/json;charset=UTF-8");

        response.getWriter().write("{\"message\": \"로그인 후 이용해주세요.\"}");
    }
}
  • 이 클래스는 로그인을 안 한 사용자가 접근 시도할 때, JSON 형식으로 "로그인 후 이용해주세요." 메시지를 반환하는 클래스입니다.
    • 접근 권한이 없는 요청과 로그인하지 않은 채 요청하는 것을 구분하기 위해 401 응답으로 변경하여 응답하게 됩니다.
  • 이 두 클래스를 먼저 적용해 봅시다.

 

SecurityConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

	...
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; // 추가
    private final CustomAccessDeniedHandler customAccessDeniedHandler; // 추가
    
    ...


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    
    http
            ...
        
            .sessionManagement(sessionManagement -> sessionManagement
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // <- 세미콜론(;) 제거

            // 인증, 인가 거부 핸들링(추가)
            .exceptionHandling((exception)-> exception.authenticationEntryPoint(customAuthenticationEntryPoint).accessDeniedHandler(customAccessDeniedHandler));
  • SecurityConfig에 위에서 커스텀하게 만든 인증, 인가 핸들러를 적용합니다.

 

Postman에서 로그인 없이 다시 요청해 보기

  • 위와 같이 CustomAuthenticationEntryPoint에 의해 403 -> 401로 변경됨이 확인됩니다.

 

접근 권한 필터링

.authorizeHttpRequests((authorizeRequests) -> authorizeRequests
        .requestMatchers("/login", "/enroll", "/h2-console/**").permitAll()
        .anyRequest().authenticated())
  • 그런데, 로그인을 하여 Token을 싣고 요청을 해도 같은 결과가 나옵니다.
  • CustomAccessDeniedHandler에 의해 403이 발생하여 "접근이 제한되었습니다"라는 응답이 나오는 경우는 어떤 것일까요?
    • 이는 유저의 권한으로, 권한에 따른 분기처리가 필요합니다.
    • 이를 위해 api를 하나 더 만들어 봅니다.

 

MemberController

    @GetMapping("/admin")
    public ResponseEntity<?> getAdmin() {

        log.info("get admin");

        return new ResponseEntity<>("", HttpStatus.OK);
    }
  • 로그 한 줄 출력하는 간단한 api입니다.
  • 이를 운영자만이 접근할 수 있는 관리 페이지에 접근 요청하는 api라고 가정합시다.

 

SecurityConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

	...
    
    http
        ...

        .authorizeHttpRequests((authorizeRequests) -> authorizeRequests
                .requestMatchers("/login", "/enroll", "/h2-console/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN") // 추가
                .anyRequest().authenticated())
                
    	...
    }
}
  • admin이 포함된 요청은 Role이 ADMIN이 아니면 거절하도록 합니다.

 

  • 이제 로그인을 하여도 접근 권한이 없으므로 CustomAccessDeniedHandler 클래스를 타게 됩니다.
    • 참고로 저희는 처음 만들 때 따로 권한 등을 설정하지 않았습니다. 따라서 이 api는 무조건 접근을 실패하게 될 것입니다.
      • 권한보다는 Spring Security의 동작 원리에 초점을 맞추기 위함입니다.

 

JwtVerificationFilter

@Slf4j
@RequiredArgsConstructor
public class JwtVerificationFilter extends OncePerRequestFilter {

    private final JwtTokenizer jwtTokenizer;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String authorization = request.getHeader("Authorization");
        log.info("Authorization : {}", authorization);

        // accessToken이 없으면 건너뛴다.
        return authorization == null || !authorization.startsWith("Bearer ");
    }

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

        log.info("JwtVerificationFilter doFilterInternal : {}", request.getRequestURI());
        // 해시값 검증, 이 부분이 통과되면 토큰 검증에 성공한 것임.
        Claims claims = jwtTokenizer.verifyAccessJws(request);

        log.info("claims : {}", claims);

        setAuthenticationToContext(claims);
        filterChain.doFilter(request, response);
    }

    private void setAuthenticationToContext(Claims claims) {

        String memberId = claims.get("memberId").toString();
        String username = claims.get("sub").toString();

        MemberDetailsService.MemberDetails memberDetails = MemberDetailsService.MemberDetails.createForAuthentication(memberId, username);
        // 생성한 memberDetails로 UsernamePasswordAuthenticationToken 인스턴스 생성
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                memberDetails, null, memberDetails.getAuthorities());
        log.info("authentication.isAuthenticated() : {}", authentication.isAuthenticated());

        // SecurityContext에 setting하여 이후 이 인증 객체를 활용하여 권한 등 자격 검증 실행
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}
  • 위에서 두 가지 실패에 대한 응답 처리를 성공적으로 마쳤습니다.
  • 이제 우리는 필터를 추가하여 로그인 권한을 가지고 하는 모든 접근에 대한 필터 처리를 추가해야 합니다.
  • 이 필터는 클라이언트의 요청 헤더에서 JWT Access Token을 꺼내서 유효성 검증을 한 뒤, 성공하면 해당 사용자를 Spring Security의 인증된 사용자로 등록하는 역할을 합니다.

OncePerRequestFilter

  • Spring Security에서 사용하는 커스텀 필터 베이스 클래스입니다.
  • 이름처럼 요청(Request) 당 한 번만 실행됩니다.

shouldNotFilter()

  • 이 요청에 대해 필터를 탈지 말지(여기서는 JWT 검증을 건너뛸지 말지) 여부를 결정합니다.
  • true를 반환하면 doFilterInternal()을 실행하지 않고 필터 자체를 스킵합니다.
  • return 문을 보시면 아시겠지만 Authorization 헤더가 비어있거나 Bearer 토큰 형식이 아니면 검증할 필요 없으므로 필터를 스킵하도록 처리했습니다.

doFilterInternal()

  • 필터가 작동해야 할 경우, 이 메서드에서 실제 토큰 검증 → 인증 객체 생성 및 등록 → 다음 필터로 이동 작업을 수행합니다.
Claims claims = jwtTokenizer.verifyAccessJws(request);
  • 요청에서 Access Token을 꺼내서 JwtTokenizer를 이용해 검증합니다.
  • 서명이 유효하면 Claims 객체 반환 (JWT의 payload에 들어있던 사용자 정보들)하므로 이를 이용하여 인증 객체를 생성하고 등록하게 됩니다.
    • claims가 정상적으로 반환되어 객체에 담긴다는 것 자체가 서명이 검증되었다는 의미입니다.
filterChain.doFilter(request, response);
  • 다음 필터로 요청을 넘김. 이걸 호출하지 않으면 요청 흐름이 멈춥니다.

 

setAuthenticationToContext(Claims claims)

  • JWT로부터 추출한 클레임 정보를 이용해서 Spring Security에서 사용할 인증 객체(Authentication)를 생성하고 등록하는 핵심 메서드입니다.
  • 인증 객체를 등록하지 않으면 스프링에서 해당 JWT 인증 정보가 필터에 의해 통과되었는지 아닌지 확인할 수가 없습니다.
// 생성한 memberDetails로 UsernamePasswordAuthenticationToken 인스턴스 생성
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                memberDetails, null, memberDetails.getAuthorities());
  • 이 인증 객체를 만들 때 getAuthorities가 유저에 대한 권한을 가지고 있을 수 있도록 구현할 수 있습니다.
    • 이에 대한 설정은 MemberDetailsService에서 해야 합니다.
    • 권한 설정 부분은 위에서 언급했듯이 핵심 내용은 아니라고 생각하여 본 글에서 제외했습니다.
log.info("authentication.isAuthenticated() : {}", authentication.isAuthenticated());
  • 인증 여부는 테스트 시 위의 로그를 통해 확인해 보겠습니다.

 

SecurityConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

	...
    
    // 검증 필터
        JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer); // 추가

        http
                .addFilter(jwtAuthenticationFilter)
                .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class) // 추가
                
	...
}
  • SecurityConfig에 필터를 추가합니다. addFilterAfter를 통해 필터 간 순서를 지정합니다.
  • 두 번째 파라미터는 필터 순서에 대한 설정으로 해당 필터 바로 앞에 있는 필터가 무엇인지를 지정하는 것입니다.

 

접근 권한이 필요한 Url 요청 테스트

로그인하지 않은 상태로 접근

  • 비로그인 상태에서 "/members"로 회원 목록 조회를 해봅니다.
  • CustomAuthenticationEntryPoint를 타고 접근이 정상적으로 거절됩니다.

 

서버 로그

2025-08-02T21:54:13.178+09:00  INFO 17031 --- [spring-security] [nio-8080-exec-3] c.t.s.s.filter.JwtVerificationFilter     : Authorization : null
2025-08-02T21:54:13.180+09:00  WARN 17031 --- [spring-security] [nio-8080-exec-3] c.t.s.s.h.CustomAuthenticationEntryPoint : Unauthorized access: Full authentication is required to access this resource

 

로그인은 했지만 접근 권한이 없는 경우

  • 접근 권한이 없는 상태로  "/admin"으로 요청을 해봅니다.
  • CustomAccessDeniedHandler를 타고 접근 권한이 없음을 알립니다.

 

로그

2025-08-02T21:56:57.376+09:00  INFO 17031 --- [spring-security] [nio-8080-exec-8] c.t.s.s.filter.JwtVerificationFilter     : JwtVerificationFilter doFilterInternal : /admin
2025-08-02T21:56:57.387+09:00  INFO 17031 --- [spring-security] [nio-8080-exec-8] c.t.s.s.filter.JwtVerificationFilter     : claims : {sub=홍길동, memberId=aaa, iat=1754139406, exp=1754140006}
2025-08-02T21:56:57.387+09:00  INFO 17031 --- [spring-security] [nio-8080-exec-8] c.t.s.s.filter.JwtVerificationFilter     : authentication.isAuthenticated() : true
2025-08-02T21:56:57.389+09:00  WARN 17031 --- [spring-security] [nio-8080-exec-8] c.t.s.s.h.CustomAccessDeniedHandler      : Access denied: Access Denied
  • verifyAccesJws()을 통해 토큰 검증은 성공하여 is.Authenticated()는 true로 되었지만 접근 권한이 없어 거절당한 것을 확인할 수 있습니다.
    • "is.Authenticated() : true"는 setAuthenticationToContext(Claims claims)에서 생성한 인증 객체(UsernamePasswordAuthenticationToken)의 메서드로, 인증이 통과되었는지 여부를 출력한 로그입니다.

 

로그인하고, 접근 권한이 있는 요청의 경우

  • 회원 목록이 정상적으로 조회됨을 확인할 수 있습니다.

 

로그

2025-08-02T22:00:52.363+09:00  INFO 17031 --- [spring-security] [nio-8080-exec-4] c.t.s.s.filter.JwtVerificationFilter     : JwtVerificationFilter doFilterInternal : /members
2025-08-02T22:00:52.374+09:00  INFO 17031 --- [spring-security] [nio-8080-exec-4] c.t.s.s.filter.JwtVerificationFilter     : claims : {sub=홍길동, memberId=aaa, iat=1754139406, exp=1754140006}
2025-08-02T22:00:52.374+09:00  INFO 17031 --- [spring-security] [nio-8080-exec-4] c.t.s.s.filter.JwtVerificationFilter     : authentication.isAuthenticated() : true
2025-08-02T22:00:52.394+09:00  INFO 17031 --- [spring-security] [nio-8080-exec-4] c.t.s.Member.MemberController            : get members
  • 이걸로 로그인 구현은 끝이 났습니다.

 

🔹 Access, Refresh Token 재발행 구현

  • 토큰의 유효 기간은 길지 않게 하는 것이 보안에 좋습니다.
  • 따라서 토큰을 계속 재발행하는 방식을 사용합니다.

지난 글에서 설명한 RTR 방식으로 AccessToken과 RefreshToken을 모두 재발행하는 코드를 추가합니다.

 

JwtReissueToken

@Slf4j
@RequiredArgsConstructor
public class JwtReissueFilter extends OncePerRequestFilter {

    private final JwtTokenizer jwtTokenizer;
    private final MemberRepository memberRepository;
    private final RedisService redisService;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String authorization = request.getHeader("Authorization");
        String uri = request.getRequestURI();
        log.info("Authorization : {}", authorization);
        log.info("Request URI : {}", uri);

        // "/reissue" 만 필터 타게 하고 나머지는 건너뜀
        return !uri.equals("/reissue");
    }

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

        // 요청에서 꺼낸 jws 검증으로 검증이 성공하면 claims가 추출됨
        Claims claims = jwtTokenizer.verifyAccessJws(request);

        // claims에서 이메일 꺼내기
        String email = claims.get("memberId", String.class);

        // 해당 이메일로 Member 찾기
        Member member = memberRepository.findByMemberId(email)
                .orElseThrow(() -> new UsernameNotFoundException(email));

        // 해당 Member로 AccessToken과 RefreshToken 재발급
        String reissueAccessToken = jwtTokenizer.createAccessToken(member);
        String reissueRefreshToken = jwtTokenizer.createRefreshToken(member);

        log.info("Reissue AccessToken : {}", reissueAccessToken);
        log.info("Reissue RefreshToken : {}", reissueRefreshToken);
        
        // 기존 Refresh Token 삭제
        String oldRefreshToken = request.getHeader("Refresh");
        redisService.deleteRefreshToken(oldRefreshToken);

        // 새로운 Refresh Token 저장
        redisService.setRefreshToken(email, reissueRefreshToken, jwtTokenizer.getRefreshTokenExpirationMinutes());

        // 응답 헤더에 싣기
        response.addHeader("Authorization", "Bearer " + reissueAccessToken);
        response.addHeader("Refresh", reissueRefreshToken);
    }
}
  • "/reissue"인 경우에만 해당 필터를 타게 됩니다.
  • JwtVerificationFilter와 마찬가지로 verifyAccessJws를 통해 검증을 한 후 claims에서 이메일을 추출합니다.
  • RTR을 구현할 것이므로 해당 이메일로 member를 데이터베이스에서 가져와 토큰을 모두 재발행합니다.
  • redis에 저장된 기존 RefreshToken을 제거하고
  • 클라이언트에 새 토큰들을 응답 헤더에 싣고 보냅니다.

 

JwtVerificationFilter

@Slf4j
@RequiredArgsConstructor
public class JwtVerificationFilter extends OncePerRequestFilter {

	...
    
    // "/reissue"는 필터를 타지 않도록 변경
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String authorization = request.getHeader("Authorization");
        String uri = request.getRequestURI();
        log.info("Authorization : {}", authorization);
        log.info("Request URI : {}", uri);

        // accessToken이 없으면 건너뛴다.
        return uri.equals("/reissue") || authorization == null || !authorization.startsWith("Bearer ");
    }
    
    ...
}
  • "/reissue"의 경우 필터 자체에서 AccessToken을 검증하므로 Verification 필터를 타지 않도록 하여 중복 검증을 피합니다.
    • return 문에 "/reissue"일 경우 필터를 타지 않도록 변경하였습니다.

 

SecurityConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

	...
    private final MemberRepository memberRepository; // 추가
    
    ...
    
    
   // 재발행 필터 추가
    JwtReissueFilter jwtReissueFilter = new JwtReissueFilter(jwtTokenizer, memberRepository, redisService);

    http
            .addFilter(jwtAuthenticationFilter)
            .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class)
            .addFilterAfter(jwtReissueFilter, JwtVerificationFilter.class) // 추가
    
    ...
    }
}
  • 재발행 필터를 SecurityConfig에 추가합니다.
  • 자세한 설명은 JwtVerificationFilter에서 했으므로 생략합니다.

 

재발행 테스트

  • 응답 헤더로 AccessToken과 RefreshToken을 받았음을 확인할 수 있습니다.

 

서버 로그

2025-08-02T22:31:02.125+09:00  INFO 18504 --- [spring-security] [nio-8080-exec-4] c.t.s.security.filter.JwtReissueFilter   : Request URI : /reissue
2025-08-02T22:31:02.182+09:00  INFO 18504 --- [spring-security] [nio-8080-exec-4] c.t.s.security.filter.JwtReissueFilter   : Reissue AccessToken : eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiLtmY3quLjrj5kiLCJtZW1iZXJJZCI6ImFhYSIsImlhdCI6MTc1NDE0MTQ2MiwiZXhwIjoxNzU0MTQyMDYyfQ.ZZtMZtH69K52xBfv1NSWwJuhSCibRc4Okepbh3-rFdQ
2025-08-02T22:31:02.182+09:00  INFO 18504 --- [spring-security] [nio-8080-exec-4] c.t.s.security.filter.JwtReissueFilter   : Reissue RefreshToken : eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiLtmY3quLjrj5kiLCJpYXQiOjE3NTQxNDE0NjIsImV4cCI6MTc1NDE0NTA2Mn0.MLMF0RnGBJ_CO0uOrF8paQMFyMzWQckGRzzmCi7HSTE

 

 

🔹 로그아웃 구현

  • 마지막으로 로그아웃을 구현합니다.
  • 로그아웃의 경우 스프링 시큐리티에서 어느 정도 지원을 해주는 부분이 있습니다.
  • 이에 맞춰 구현합니다.

 

CustomLogoutHandler

@Slf4j
@Component
@RequiredArgsConstructor
public class CustomLogoutHandler implements LogoutHandler {

    private final JwtTokenizer jwtTokenizer;
    private final RedisService redisService;

    /*
     * 로그아웃 시 실행되는 메서드이다.
     * 기존 RefreshToken은 redis에서 제거한다.
     */
    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {

        // Refresh Token 검증
        Claims claims = jwtTokenizer.verifyRefreshJws(request);

        String email = claims.getSubject();

        // 기존 Refresh Token 삭제
        redisService.deleteRefreshToken(email);
    }
}
  • LogoutHandler의 경우 로그아웃이 진행될 때의 로직을 넣을 수 있습니다.
  • 이를 커스텀하게 구현하였습니다.
  • 사용자로부터 RefreshToken을 받고 검증하여 기존 redis에 저장된 RefreshToken을 삭제하는 방식으로 로그아웃 처리를 진행합니다.
  • Redis에 AccessToken을 등록하여 BlackList로 활용하는 방법도 있으나 생략하였습니다.
    • BlackList 로직을 추가하면 AccessToken이 로그아웃했음에도 재사용되는 상황을 막을 수 있습니다.
    • JwtVerificationFilter에 매 검증 시 AccessToken을 받고 BlackList에 들어있는 토큰인지 확인하는 방식을 추가하여 사용할 수 있습니다.

 

 

AfterLogoutHandler

@Slf4j
@Component
public class AfterLogoutHandler implements LogoutSuccessHandler {

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
                                Authentication authentication) throws IOException {

        log.info("Logout successful");

        response.setStatus(HttpServletResponse.SC_OK); // 200
        response.setContentType("application/json;charset=UTF-8");

        response.getWriter().write("{\"message\": \"로그아웃이 완료되었습니다.\"}");
    }
}
  • LogoutSuccessHandler의 경우 로그아웃 로직이 성공 시 정해진 응답을 보낼 수 있게 할 수 있습니다.
  • 이 역시 커스텀하게 구현하였습니다.

 

SecurityConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

	...
    private final CustomLogoutHandler customLogoutHandler; // 추가
    private final AfterLogoutHandler afterLogoutHandler; // 추가
    
    ...
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    
    http
    
		...
        
        // 로그아웃 설정
                .logout((logout) -> logout
                        // logut 엔드 포인트
                        .logoutUrl("/logout")

                        // 클라이언트로부터 로그아웃 요청을 받았을 때 호출되는 핸들러(Redis 내 Access, Refresh Token 삭제)
                        .addLogoutHandler(customLogoutHandler)
                        // 로그아웃 성공 후 호출되는 핸들러(클라이언트에게 보내는 응답 메시지에 리다이렉트 엔드포인트 전달, JWT 토큰 삭제 지시)
                        .logoutSuccessHandler(afterLogoutHandler))
	
    ...
    
	}
}
  • SpringConfig에서 로그아웃 필터를 추가합니다.
  • .logoutUrl을 통해 엔드포인트를 설정할 수 있습니다.

 

로그아웃 테스트

  • 요청 헤더에 RefreshToken을 싣으면 서버에서는 redis에 저장된 RefreshToken을 지우며 로그아웃 처리를 완료합니다.
  • 아마 앱 등 프론트단에서는 자체적으로 가지고 있는 AccessTokent과 RefreshToken 등을 지우는 식으로 동작할 것 같습니다.
  • 위에서 얘기했듯이 로그아웃 시 서버에서 AccessToken을 받아 BlackList에 등록하는 방식으로 로그아웃 처리는 되었지만, 유효 시간은 남아있는 AccessToken의 사용을 막게 구현할 수도 있습니다.

 

서버 로그

2025-08-02T23:04:44.951+09:00  INFO 19683 --- [spring-security] [nio-8080-exec-4] c.t.s.s.logout.AfterLogoutHandler        : Logout successful

 

 

😵‍💫 전체 코드 확인

https://github.com/wonyongg/test/tree/main/spring-security

 

test/spring-security at main · wonyongg/test

test and summarize what i learned today. Contribute to wonyongg/test development by creating an account on GitHub.

github.com

 

✌🏻 완결 소감

 처음으로 제대로 써 본 정보 글이라 많이 부족한 것 같습니다. 사실 첫 연재 시에는 3-4 개월에 끝낼 생각이었는데 완결까지 1년 하고도 약 1개월이나 걸렸네요. 중간에 이직과 해외 이사 등 매우 바빴습니다. 그래도 뿌듯합니다. 그리고 아마 이 글까지 따라오신 분들이라면 마지막 글에서 느끼셨을 많은 생략도 충분히 이해하고 따라 하실 수 있으리라 생각합니다. 만약 애매모호하거나 모르는 게 있다면 댓글 남겨주시면 감사드리겠습니다. 지적도 환영입니다. 긴 글 읽어주셔서 감사합니다!

728x90