Spring

[Spring] 쉽게 이해하는 Spring Security + JWT 로그인 구현기

soro.k 2023. 5. 12. 00:10

 

들어가기 전에

프로젝트에서 로그인 구현 방법으로 Spring Security와 JWT를 사용하기로 했다. 많은 방법이 있는데 그중 이 두 가지를 선택한 이유를 간단하게 적어보겠다.

 

01. 토큰 기반 인증 방식을 선택한 이유

면접 스터디에서 세션 기반 인증과 토큰 기반 인증 방식에 대해 공부했을 때 가장 중점적으로 봤던 게 "어떤 이유로 토큰 기반 인증 방식이 생겼는가"였다. 토큰 기반 인증 방식이 생긴 이유는 기존의 세션 기반 인증 방식은 서버 측에서 세션 저장소를 두고 정보를 저장하여 사용하기 때문에 사용하는 메모리가 계속해서 증가되고, 쿠키를 지원하지 않는 브라우저 혹은 모바일 환경에서 사용하기 어려워서 확장성의 문제가 있기 때문이다. 그래서 나는 별도의 세션 저장소를 사용하지 않고 세션 저장소를 통해 유저의 정보를 가져와야 하는 인증 프로세스를 간소화할 수 있는 토큰 기반 인증 방식을 선택했다.

 

02. JWT를 선택한 이유

JWT를 선택한 이유는 암호화 방식으로 토큰을 신뢰할 수 있게 도와주는 프로세스를 가지고 있는 것도 좋았고, self-contained token이기 때문에 보안에 예민한 정보가 아닌 유저의 정보를 토큰에 담아 활용하기 용이할 것 같았기 때문이다. 그리고 인프런에서 정수원님의 Spring Security OAuth2 강의를 조금씩 보면서 인증을 위한 OIDC 프로토콜에서도 토큰을 JWT로 사용한다는 게 그만큼 토큰 자체에 대한 정보 신뢰성이 있어서이지 않겠나 생각했었다. 물론 토큰 탈취에 대해 신경써야하지만 말이다.

 

시작하기에 앞서 구현 전에도, 구현 중에도 많은 자료들을 참고했다. 인프런에서 정은구님의 Spring Boot JWT Tutorial 강의를 들었고, 최주호님의 스프링부트 시큐리티 & JWT 강의에서는 필요한 부분들을 참고했다. 또 유튜브에서는 Amigoscode 채널의 Spring boot 3 + Spring Security 6 - JWT Authentication and Authorization [NEW] [2023] 강의를 봤다. 그리고 수많은 블로그 자료들과 프로젝트 레포들을 봤는데 flow는 같지만 각자의 구현 방식이 다르기 때문에 여기서 이게 왜 필요한지, 없다면 어떻게 되는지를 직접 해보면서 구현하려고 노력했다.

 

 

인증 절차 이해하기

[Spring Security]

 

➊ Client가 어플리케이션에 요청을 보내면, Servlet Filter에 의해서 Security Filter로 Security 작업이 위임되고 여러 Security Filter 중에서 UsernamePasswordAuthenticationFilter(Username and Password Authentication 방식에서 사용하는 AuthenticationFilter)에서 인증을 처리한다.

AuthenticationFilter(UsernamePasswordAuthenticationFilter인데 지금부터 AuthenticationFilter라고 부름)는 Servlet 요청 객체(HttpServletRequest)에서 username과 password를 추출해 UsernameAuthenticationToken(이하 인증 객체)을 생성한다.

AuthenticationFilterAuthenticationManager(구현체 : ProviderManager)에게 인증 객체를 전달한다.ProviderManager는 인증을 위해 AuthenticationProvider에게 인증 객체를 전달한다.

AuthenticationProvider는 전달받은 인증 객체의 정보(일반적으로 사용자 아이디)를 UserDetailsService에 넘겨준다.UserDetailsService는 전달 받은 사용자 정보를 통해 DB에서 알맞는 사용자를 찾고 이를 기반으로 UserDetails객체를 만든다.

➎ 사용자 정보와 일치하는 UserDetails객체를 AuthenticationProvider에 전달합니다.AuthenticationProvider은 전달받은 UserDetails를 인증해 성공하면 ProviderManager에게 권한(Authorities)을 담은 검증된 인증 객체를 전달한다.

ProviderManager는 검증된 인증 객체를 AuthenticationFilter에게 전달합니다. (event 기반 으로 전달)

AuthenticationFilter는 검증된 인증 객체를 SecurityContextHolderSecurityContext에 저장합니다.

출처 : https://imbf.github.io/spring/2020/06/29/Spring-Security-with-JWT.html

 

 

시작하기

01. 프로젝트 스펙

  • Java 17
  • Spring Boot 3.0.4
  • Gradle
  • MyBatis

 

02. 기본 준비

1. Member 클래스 생성

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseTime {

    private Long tid;
    private String email;
    private String password;
    private String name;
    private Role role;
    private ActiveType activeType;

    public static Member of(String email, String password, String name, Role role, ActiveType activeType) {
        Member member = new Member();
        member.email = email;
        member.password = password;
        member.name = name;
        member.role = role;
        member.activeType = activeType;
        return member;
    }

}

 

 

2. Role Enum 클래스 생성

public enum Role {
    MENTEE, COACH
}

 

 

3. application.yml 설정

spring:
  datasource:
    driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
    url: [DB_URL]
    username: [DB_USERNAME]
    password: [DB_PASSWORD]
    

jwt:
  header: Authorization
  secret: [SECRET_KEY]
  token-validity-in-seconds: [토큰지속시간(초)]

Secret Key는 구글에 secret key generate site를 검색했을 때 나오는 사이트들 중에 Encryption Key Generator를 통해 생성해서 활용했다. 

 

[사용 방법]

❶ Encryption Key Tab을 선택한다.

❷ Security level은 JWT가 필요로 하는 256비트 이상으로 선택한다. (여기서는 512-bit를 선택했다.)

❸ Hex 방식을 선택한다.

 

 

 

4. build.gradle

마지막으로 JWT를 사용하기 위해 의존성을 추가해준다.

implementation 'io.jsonwebtoken:jjwt:0.9.1'

 

03. 구현하기

최종적으로 만들어져야 하는 클래스는 다음과 같다.

  • PrincipalDetails : 인증된 사용자 객체를 반환하기 위해 UserDetails를 구현한 클래스
  • PrincipalDetailsService : 전달 받은 사용자 정보로 DB에서 사용자를 조회해 PrincipalDetails를 생성하고 반환하기 위한 UserDetailsService를 구현한 클래스
  • JwtAuthenticationEntryPoint : 인증되지 않은 사용자가 보호된 리소스에 접근할 시 처리하기 위한 클래스
  • JwtAccessDeniedHandler : 리소스에 접근할 수 있는 권한이 아닐 시 처리하기 위한 클래스
  • JwtTokenProvider : 토큰의 검증 / 생성, PK 추출 등을 책임지는 클래스
  • JwtAuthenticationFilter : 토큰을 검증하고 인증 객체를 만들어 SecurityContextHolder에 저장하기 위한 클래스
  • SecurityConfig : 스프링 시큐리티 설정을 위한 클래스

 

1. PrincipalDetails

인증 프로세스에서 전달된 정보로 사용자 객체를 얻기 위해서 UserDetails를 구현한 PrincipalDetails를 만들어준다. 보통은 CustomUserDetails라는 이름으로 많이 사용하는데 내 경우에는 User라는 이름을 안 쓰기도 해서 아예 Principal이라는 단어를 붙여서 생성했다. 그리고 Memver의 각 필드들을 하나씩 가지고 있게 하지 않고 컴포지션 방식으로 객체를 참조하게 했다. 

ublic class PrincipalDetails implements UserDetails {

    private Member member;

    public PrincipalDetails(Member member) {
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singleton(new SimpleGrantedAuthority(member.getRole().name()));
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getEmail();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

 

2. PrincipalDetailsService

전달 받은 email 정보로 DB에서 사용자를 찾아서 PrincipalDetails 객체를 생성해 반환한다.

@Service
public class PrincipalDetailsService implements UserDetailsService {

    private final MemberMapper memberMapper;

    public PrincipalDetailsService(MemberMapper memberMapper) {
        this.memberMapper = memberMapper;
    }

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member member = memberMapper.findByEmail(email).orElseThrow(() ->
            new NotExistedMemberException(ErrorMessage.NOT_EXISTED_MEMBER));

        return new PrincipalDetails(member);
    }
}

 

 

3. AuthenticationEntryPoint

인증되지 않은 유저가 보호된 리소스에 접근하려고 할 때 401 UNAUTHORIZED 코드를 반환하기 위해 AuthenticationEntryPoint를 구현한 JwtAuthenticationEntryPoint 클래스를 생성한다. 생성하지 않았을 때 같은 문제가 발생해도 응답 코드로 403 FORBIDDEN이 반환된다.

@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    public JwtAuthenticationEntryPoint(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException authException) throws IOException {
        log.error("JWT - returns 401 unauthorized. Message : {}", authException.getMessage());
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        objectMapper.writerWithDefaultPrettyPrinter().writeValue(response.getOutputStream(),
            ErrorResponse.builder()
                .status(ErrorMessage.UNAUTHORIZED_TOKEN.getStatus().toString())
                .message(ErrorMessage.UNAUTHORIZED_TOKEN.getMessage())
                .build());
    }
}

 

참고로 나는 ErrorResponse 객체를 따로 사용하고 있는 중이어서 통일성 있는 반환 메시지를 위해 ObjectMapper를 사용했고 행이 구분되게 표현하기 위해 writeValue()가 아닌 writerWithDefaultPrettyPrinter()를 사용했다. 

writeValue() 사용 시
writeerWithDefaultPrettyPrinter() 사용 시

 

 

4. JwtAccessDeniedHandler

필요한 권한이 존재하지 않는 경우 403 FORBIDDEN 코드를 반환하기 위해 AccessDeniedHandler를 구현한 JwtAccessDeniedHandler 클래스를 생성한다.

@Slf4j
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    public JwtAccessDeniedHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
        AccessDeniedException accessDeniedException) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        log.error("JWT - returns 403 forbidden. Message : {}", accessDeniedException.getMessage());

        objectMapper.writerWithDefaultPrettyPrinter().writeValue(response.getOutputStream(),
            ErrorResponse.builder()
                .status(ErrorMessage.FORBIDDEN_TOKEN.getStatus().toString())
                .message(ErrorMessage.FORBIDDEN_TOKEN.getMessage())
                .build());
    }
}

 

 

5. JwtTokenProvider

토큰 생성 및 검증, PK 값 추출 등 토큰 관련 처리를 하기 위해 JwtTokenProvider 클래스를 생성한다. 각각의 메소드의 역할은 아래 자세히 설명한다.

@Slf4j
@Component
public class JwtTokenProvider {

    private String secretKey;
    private long tokenValidityInMilliseconds;
    private final PrincipalDetailsService principalDetailsService;

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String AUTHORIZATION_TYPE = "Bearer";
    private static final String ROLE = "role";

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey,
        @Value("${jwt.token-validity-in-seconds}") long tokenValidityInMilliseconds,
        PrincipalDetailsService principalDetailsService) {
        this.secretKey = secretKey;
        this.tokenValidityInMilliseconds = tokenValidityInMilliseconds;
        this.principalDetailsService = principalDetailsService;
    }

    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    public String generateAccessToken(String email, Role role) {
        Claims claims = Jwts.claims().setSubject(email);
        claims.put(ROLE, role);

        Date now = new Date();
        Date validity = new Date(now.getTime() + this.tokenValidityInMilliseconds);

        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(validity)
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
    }

    public Authentication getAuthentication(String token) {
        UserDetails userDetails = principalDetailsService.loadUserByUsername(getMemberEmail(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getMemberEmail(String token) {
        return Jwts.parser()
            .setSigningKey(secretKey)
            .parseClaimsJws(token)
            .getBody().getSubject();
    }

    public String getRole(String token) {
        return (String)Jwts.parser()
            .setSigningKey(secretKey)
            .parseClaimsJws(token)
            .getBody().get(ROLE);
    }

    public Optional<String> resolveToken(HttpServletRequest request) {
        return Optional.ofNullable(request.getHeader(AUTHORIZATION_HEADER))
            .filter(StringUtils::hasText)
            .filter(header -> header.startsWith(AUTHORIZATION_TYPE))
            .map(header -> header.substring(7));
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return true;
        } catch (RuntimeException exception) {
            log.error("Invalid token : {} ", token, exception);
        }
        return false;
    }
}
  • JwtTokenProvider 생성자 
    • 설정 값들을 코드로 노출하지 않기 위해 application.yml에 설정했던 정보를 주입받아 사용한다.
  • init()
    • @PostConstruct 어노테이션을 사용해서 인스턴스의 초기화 작업 시에 Secret Key를 인코딩함으로서 보안성을 높여 사용할 수 있게 한다.
  • generateAccessToken(String email, Role role)
    • 로그인 시 사용되는 이메일과 role 정보를 전달받아 claim에 정보를 저장하고 토큰을 생성하여 반환한다.
  • getAuthentication(String token)
    • 토큰을 전달 받아 토큰에 저장된 사용자 정보를 토대로 인증 객체를 생성하여 반환한다.
  • getMemberEmail(String token) / getRole(String token)
    • 토큰을 전달 받아 토큰에 저장된 사용자 정보를 토대로 Email과 Role 정보를 반환한다.
  • resolveToken(HttpServletRequest request)
    • AUTHORIZATION 헤더에 담긴 정보가 있는지 확인하고 Bearer 타입으로 시작한다면 토큰 정보를 가져온다.
  • validateToken(String token)
    • 토큰의 유효성 검증을 담당하여 문제가 발생하면 예외 상황을 알린다. 여기서는 하위 클래스를 나누지 않고 RuntimeException 하나로 통일해서 사용했으며, e.getMessage로 충분히 토큰이 Invalid한 이유를 파악할 수 있었다.
    • e.g. 토큰 형식이 맞지 않을 때

 

 

참고로 resolveToken(HttpServletRequest request) 메소드를 보면 Optional을 이용해서 토큰 정보를 반환하는 것을 알 수 있는데 초기 코드는 이렇지 않았다. 

public String resolveToken(HttpServletRequest request) {
    String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
        return bearerToken.substring(7);
    }
    return null;
}

 

null을 반환한다는 게 찝찝하기는 했지만 토큰이 없는 경우에 401 UNAUTHORIZED 코드가 반환되고, NPE 관련 이슈는 없었기에 조금은 안일한 마음으로 개선할 생각을 못했던 것 같다. 그리고 당연히도 코드 리뷰 시간에 멘토님께서 개선할 수 있는 방법으로 Optional에 대한 이야기를 해주셨고 결론적으로 가독성도 더 좋고 안전한 코드를 작성할 수 있다.

 

 

6. JwtAuthenticationFilter

GenericFilterBean을 상속받아 doFilter 메소드를 오버라이딩하고 내부에서 토큰을 검증한다. 그리고 토큰 검증이 완료되면 인증 객체를 반환하는 메소드를 호출하고, 해당 객체를 SecurityContextHolder에 저장한다. JwtTokenProvider의 resolveToken() 메소드에서 Optional로 토큰을 반환하기 때문에 ifPresent() 메소드를 활용해서 처리를 하고 있다. 

public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws
        IOException,
        ServletException {
        jwtTokenProvider.resolveToken((HttpServletRequest)request).ifPresent(token -> {
            if (jwtTokenProvider.validateToken(token)) {
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        });
        chain.doFilter(request, response);
    }
}

 

 

7. SecurityConfig

스프링 시큐리티 관련 설정 클래스를 생성한다. 그동안 만들었던 클래스들을 모두 적용해야 한다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    private final JwtTokenProvider jwtTokenProvider;

    public SecurityConfig(JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
        JwtAccessDeniedHandler jwtAccessDeniedHandler, JwtTokenProvider jwtTokenProvider) {
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .formLogin().disable()
            .httpBasic().disable()
            .exceptionHandling()
            .authenticationEntryPoint(jwtAuthenticationEntryPoint)
            .accessDeniedHandler(jwtAccessDeniedHandler)
            .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests(authorize ->
                authorize
                    .requestMatchers("/api/v1/members/join", "/api/v1/auth/**").permitAll()
                    .requestMatchers("/api/v1/mentees/**").hasAuthority(String.valueOf(Role.MENTEE))
                    .requestMatchers("/api/v1/coaches/**").hasAuthority(String.valueOf(Role.COACH))
                    .anyRequest().authenticated())
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

토큰을 이용하기 때문에 세션에 정보를 저장한다거나, 로그인 폼을 사용한다거나 하는 옵션들은 모두 off를 했다. 그리고 커스텀한 EntryPoint 클래스와 DeniedHandler 클래스를 지정해줬다. 그리고 각 경로에 대해 권한을 설정해줘서 접근 시에 권한을 체크할 수 있도록 설정했다.

 

마지막에 보면 addFilterBefore()로 JwtAuthenticationFilter가 Spring Security의 UsernamePasswordAuthenticationFilter 이전에 실행될 수 있도록 설정한 것을 볼 수 있다. 그래야 일반 로그인 폼을 사용하는 기존 Filter가 아닌 커스텀한 Filter로 인증 프로세스를 진행할 수 있다.

 

 

04. API  및 서비스 로직 생성하기

로그인 기능을 최종적으로 마무리하기 위해 Controller와 Service를 구현한다.

 

1. AuthenticationController

@RestController
@RequestMapping("/api/v1/auth")
public class AuthenticationController {

    private final AuthenticationService authenticationService;

    public AuthenticationController(AuthenticationService authenticationService) {
        this.authenticationService = authenticationService;
    }

    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(@RequestBody @Valid LoginRequest dto) {
        return ResponseEntity.ok(authenticationService.login(dto));
    }
}

LoginRequest 객체는 단순하게 email과 password를 전달받기 위한 DTO 객체이다.

 

 

2. AuthenticationService

@Service
public class AuthenticationService {

    private final MemberMapper memberMapper;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    private final MemberService memberService;

    public AuthenticationService(MemberMapper memberMapper, PasswordEncoder passwordEncoder,
        JwtTokenProvider jwtTokenProvider, MemberService memberService) {
        this.memberMapper = memberMapper;
        this.passwordEncoder = passwordEncoder;
        this.jwtTokenProvider = jwtTokenProvider;
        this.memberService = memberService;
    }

    public TokenResponse login(LoginRequest loginRequest) {
        Member member = memberMapper.findByEmail(loginRequest.email())
            .filter(m -> isMatchedPassword(loginRequest.password(), m.getPassword()))
            .orElseThrow(() -> new NotMatchedInformationException(ErrorMessage.NOT_MATCHED_LOGIN_INFORMATION));

        if (!memberService.isActiveMember(member)) {
            throw new NotActivatedMemberException(ErrorMessage.NOT_ACTIVATED_MEMBER);
        }

        String accessToken = jwtTokenProvider.generateAccessToken(member.getEmail(), member.getRole());

        return new TokenResponse(accessToken);
    }

    public boolean isMatchedPassword(String password, String existedPassword) {
        return passwordEncoder.matches(password, existedPassword);
    }
}

 

만날 수 있는 문제

Postman을 이용해서 로그인 테스트를 하고 있었는데 처음 보는 에러 메시지를 만나게 됐다.

찾아보니 xml 형식을 Java Object로 변환해주는 역할을 하는 클래스였는데 Java 11부터 제거가 되어서 따로 의존성을 추가해줘야한다고 한다. 아래와 같이 의존성을 추가해주면 된다.

 

implementation 'javax.xml.bind:jaxb-api:2.3.0'

 

 

마무리 

어렴풋이 로직과 기능에 대해서는 알고 있었지만 직접 구현해 본 적이 없어서 하나 하나 적어뒀는데 다행히도 하나의 글로 완성시킬 수 있었다. 참고할 수 있는 자료가 많았지만, 처음에는 인증 프로세스가 완전하게 이해되지 않아서 애를 먹었었는데 직접 구현도 여러 번 해보고 멘토님이 조언해주신대로 하나 하나씩 과정을 정리하다보니 도움이 됐다. 

 

아쉬웠던 점을 말해보자면, 이 기능 구현을 완성할 때 테스트 코드를 작성했었는데 지금 보니 정작 중요한 테스트는 하지 않은 걸 깨달았다. 단순히 로그인 기능에 대한 테스트가 아니라 TokenProvider 자체에 대한 테스트 코드가 필요했을 텐데 Controller와 Service 코드에 대한 테스트 코드만 작성했었기 때문이다. 오늘 올린 코드 대부분이 담긴 프로젝트 진행이 무산되긴 했지만 따로 테스트 코드를 작성한 다음 업로드해서 글을 완성시키는 방향으로 진행해야겠다.

 

Access Token을 구현하고 토큰 탈취의 위험성이 있기 때문에 Refresh Token도 구현하면서 로컬 캐시를 사용했는데 추후에 왜 로컬 캐시를 선택했고, 어떻게 구현했는지 글로 남겨보겠다. 

 

 

 

참고