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

당신의 첫 프로젝트를 위한 스프링 시큐리티(6) - 로그인 시 JWT 발급과 redis 저장 구현하기

by 팡펑퐁 2025. 3. 9.
728x90
📌 이번 글에서는 지난 시간에 이어 로그인 시 jwt를 발급하여 응답 헤더에 싣고, 토큰을 redis에 저장하는 과정을 구현해 보겠습니다.

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

 

🙉 이전 글 보기

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

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

이전 글

https://suzuworld.tistory.com/446 - JwtTokenizer와 Redis 클래스 작성하기 

 

📖 목차

스프링 시큐리티 톺아보기

SecurityConfig 구성하기

인증 방식 개념과 AuthenticationFilter 

AuthenticationManager, AuthenticationProvider, UserDetailsService, UserDetails

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

JwtTokenizer와 Redis 클래스 작성하기

로그인 시 JWT 발급과 redis 저장 구현하기 (현재 글)

 

👣 지난 시간까지의 내용 정리

  • 우리는 스프링 시큐리티를 이용하여 로그인을 구현했습니다.
  • 구현한 로그인 과정은 다음과 같습니다.
    • 클라이언트(web, app)에서 입력받은 id, password가 http 요청 바디에 실려 서버로 넘어올 때 서블릿 필터(AuthenticationFilter)에 의해 필터링되어 로그인 처리가 시작됩니다.
    • 이후 AuthenticationManager, AuthenticationProvider, UserDetailsService를 거쳐 데이터베이스 내에 있는 id, password와 비교를 하여 로그인 성공 여부가 결정됩니다.
  • 그런데 로그인 시 입력받은 id, password와, 데이터베이스 내 id, password를 비교하는 것으로 로그인이 성공했다!, 실패했다! 이 외에 어떤 작업도 하지 않았습니다.
  • 그래서 이번 시간에는 로그인 성공 시 클라이언트에게 보낼 응답 헤더에 access token과 refresh token을 담아 보내고, redis에 토큰을 관리하는 부분을 구현하려고 합니다.

 

지난 시간까지의 포스트맨 테스트 성공 예시

 

서버 로그

  • 위에서 설명한 바와 같이 지난 시간까지의 구현은 그저 로그로 성공과 실패를 표시하는 것 외에는 아무것도 없었습니다.
  • 그리고 우리는 바로 이전 글에 JwtTokeninzer 클래스와, redis 관련 클래스를 생성했습니다.

 

🙂 지난 시간까지의 로그인 구현 내용  간단 복습

JwtAuthenticationFilter

  • 요청을 가로챈 필터에서 로그인 DTO를 생성하여 AuthenticationManager에게 로그인 위임을 처리합니다.

 

SecurityConfig

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

    ...
 
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        // 로그인 필터
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager());
        jwtAuthenticationFilter.setFilterProcessesUrl("/login");
        jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
        jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());
        ...
        
     }
}
  • 위의 JwtAuthenticationFilter가 클라이언트로부터의 로그인 요청을 가로챌 수 있는 이유는 SecurityConfig 클래스에서 "/login"이라는 엔드포인트를 로그인용 엔드포인트로 설정해 뒀기 때문입니다.
  • 이후 로그인 성공 여부에 따라 내부적으로 성공 시에는 SuccessHandler로, 실패 시에는 FailureHandler로 가도록 설정해 두었습니다.

 

MemberAuthenticationSuccessHandler

@Slf4j
public class MemberAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        log.info("로그인 성공!");
    }
}

 

MemberAuthenticationFailureHandler

@Slf4j
public class MemberAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        log.info("로그인 실패!");
    }
}

 

서버 로그

  • 위 두 핸들러에 의해 로그가 남았던 것이죠.
  • 이 두 동작 외에 로그인 성공 시 어떠한 변화도 없는 것이 현재 상태입니다.

 

☝🏻 해야 할 일

 이제부터는 로그인 성공 시에는 jwt를 발급하여 클라이언트에게 전달하고, redis에 토큰을 저장하는 것을 구현해 보겠습니다.

 

JwtAuthenticationFilter 수정

  • 기존 JwtAuthenticationFilter에 successfulAuthentication 메서드를 추가합니다.

 

  • 인텔리제이에서 Mac 기준 cmd + n 명령어로 쉽게 오버라이드할 메서드를 추가할 수 있습니다.

AbstractAuthenticationProcessingFilter

  • successfulAuthentication 메서드는 AbstractAuthenticationProcessingFilter에서 구현된 메서드로 인증 성공 시 자동으로 호출되도록 합니다.
  • 이를 오버라이딩하여 사용하는 것입니다.

 

SecurityConfig 수정

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

    private final JwtTokenizer jwtTokenizer; // 추가
    private final RedisService redisService; // 추가
    ...
 
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        // 로그인 필터
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager(), jwtTokenizer, redisService); // 수정
        jwtAuthenticationFilter.setFilterProcessesUrl("/login");
        jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
        jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());
        ...
        
     }
}
  • SpringConfig에 JwtTokenizer와 RedisService를 의존성 추가하고,
  • new 키워드로 생성할 JwtAuthenticationFilter 파라미터에 jwtTokenizer와 redisService를 추가합니다.

 

JwtAuthenticationFilter 수정

  • JwtAuthenticationFilter 클래스에 마찬가지로 JwtTokenizer와 RedisService 의존성을 추가합니다.
  • 또한, successfulAuthentication에서 인증 성공 후 Jwt를 발급하여 응답 헤더에 싣고, redis에 refresh token을 저장하는 로직을 구현합니다.
  • 참고로 위 코드는 log.debug로 설정했는데 log.info로 변경하시면 별도의 yaml 파일 설정 없이 로그를 볼 수 있습니다.

 

👨🏻‍🔬 successfulAuthentication 메서드 상세 설명

MemberDetailsService

  • 지난 시간 구현한 MemberDetailsService 클래스의 loadUserByUsername 메서드를 보시면 아시겠지만, UserDetails의 구현체인 MemberDetails를 리턴하기 때문에 successfulAuthentication의 파라미터인 authResult에 MemberDetails가 포함되게 됩니다.

 

Member 객체로 캐스팅

Member member = (Member) authResult.getPrincipal();
  • MemberDetails는 Member의 자식 클래스임으로 위와 같이 Member로 캐스팅 가능합니다.
    • 리스코프 치환 원칙(LSP)이 여기서 등장합니다.

 

createAccessToken & RefreshToken

String accessToken = jwtTokenizer.createAccessToken(member);
String refreshToken = jwtTokenizer.createRefreshToken(member);

  • 이후 Member 객체를 파라미터로 하여 AccessToken과 RefreshToken을 발급합니다.
    • 자세한 로직은 지난 시간 메서드를 참고하여 따라가 보세요.

 

setRefreshToken

redisService.setRefreshToken(member.getMemberId(), refreshToken, jwtTokenizer.getRefreshTokenExpirationMinutes());

  • 발급한 refresh token을 redis에 저장합니다.

 

ValueOperations<K, V>

default void set(K key, V value, Duration timeout)
  • redis에 key로는 refresh token, value로는 member의 memberId(id)를 저장하며 자체적으로 timeout 시간을 설정할 수 있습니다.
    • 이로써 refreshToken을 검증에 사용할 때 유저의 refresh token(key)을 통해 id(value)을 꺼내어 검증할 수 있는 것입니다.
    • timeout이 설정되어 있기 때문에 시간이 지나면 redis에서 자체적으로 삭제하여 시간이 만료된 토큰은 사용이 불가능하도록 합니다.

 

redis에 refresh token만 저장하는 이유?

  1. Access Token은 자체적으로 검증 가능 → 서버에서 JWT의 서명을 확인하면 검증 끝 (DB 조회 불필요).
  2. Refresh Token은 서버에서 관리 필요 → 유효성 검사를 위해 저장하고 만료/로그아웃 시 삭제 가능.
  3. 보안 강화 → Refresh Token을 탈취당하면 위험하므로 Redis에서 관리하여 즉시 무효화 가능.
  4. 효율성 → Access Token까지 Redis에 저장하면 요청마다 조회해야 하므로 불필요한 부하 발생.

즉, Access Token은 검증만 하면 되고, Refresh Token은 관리가 필요하기 때문에 Redis에 저장하는 것입니다.

 

응답 헤더에 싣고 성공 핸들러 호출

response.addHeader("Authorization", "Bearer " + accessToken);
response.addHeader("Refresh", refreshToken);

this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
  • redis까지 저장하고 나면, 이제 응답 헤더에 토큰을 싣고 successHandler를 호출하도록 합니다.
  • 이때, AccessToken에 Bearer를 붙이는 이유는 OAuth 2.0 기반의 JWT 등의 인증 방식으로 처리한다는 관례가 있기 때문입니다.

 

👨🏻‍🔬 로그인 테스트

  • 포스트맨으로 회원가입을 진행합니다.

 

  • 그다음 로그인 API를 이용하여 로그인해 봅니다.
  • 정상적으로 로그인이 진행되며 서버로부터 받은 응답 헤더에 AccessToken(Authorization), RefreshToken(Refresh)가 실린 것을 확인할 수 있습니다.

 

🚀 redis 내 저장된 refreshToken 확인하기

// 서버 로그(log.debug로 찍힌 로그)
// debug 로그를 보려면 yaml 파일에서 별도의 수정 필요!
[ JwtAuthenticationFilter - successfulAuthentication ] refreshToken : 
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiLtmY3quLjrj5kiLCJpYXQiOjE3NDE1MjY5OTMsImV4cCI6MTc0MTUzMDU5M30.wcyAmL-ZL0_wN_VQ62iXJEBJ9gleDnOp0R7sA9U3INM

// docker 내 redis 접속하기
$ docker ps

// docker ps로 redis 컨테이너 id or name 확인하여 접속
$ docker exec -it <container id or name> redis-cli

// key(memberId)로 value(refresh token) 가져오기
127.0.0.1:6379> get "aaa"
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiLtmY3quLjrj5kiLCJpYXQiOjE3NDE1MjY5OTMsImV4cCI6MTc0MTUzMDU5M30.wcyAmL-ZL0_wN_VQ62iXJEBJ9gleDnOp0R7sA9U3INM"
  • redis에 접속하여 refreshToken이 정상적으로 저장되었는지 확인합니다.

 

🥸 다음 시간

  • 이제 거의 끝이 보입니다.
  • 해당 AccessToken으로 보안 요청을 실행하는 로직, 토큰이 만료되었을 때 재발행하는 로직, 마지막으로 로그아웃 하는 로직을 만들어보겠습니다.
728x90