📌 이번 글에서는 로그인 이후 과정인 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의 동작 원리에 초점을 맞추기 위함입니다.
- 참고로 저희는 처음 만들 때 따로 권한 등을 설정하지 않았습니다. 따라서 이 api는 무조건 접근을 실패하게 될 것입니다.
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개월이나 걸렸네요. 중간에 이직과 해외 이사 등 매우 바빴습니다. 그래도 뿌듯합니다. 그리고 아마 이 글까지 따라오신 분들이라면 마지막 글에서 느끼셨을 많은 생략도 충분히 이해하고 따라 하실 수 있으리라 생각합니다. 만약 애매모호하거나 모르는 게 있다면 댓글 남겨주시면 감사드리겠습니다. 지적도 환영입니다. 긴 글 읽어주셔서 감사합니다!