본문으로 바로가기

Refresh Token + Access Token + BlackList 전략으로 로그인 인증 구현하기

 

저는 이전에 진행 중이던 프로젝트에서 JWT 토큰 인증 방식을 선택해 프로젝트를 진행하였습니다. 프로젝트를 진행하면서 팀원들과 약속했던 것들 중의 하나가 사용자가 점점 늘어난다는 가정 하에, Scale Out을 고려하여 프로젝트를 설계하자는 것이 가장 첫번째의 약속이었기 때문입니다. 토큰 인증 방식이 세션 인증 방식에 비해 확장에 용이하기 때문에, 이러한 점을 고려해 토큰 인증 방식을 선택하였습니다.

 

구현에 앞서서 제가 생각한 인증의 흐름은 이러합니다.

그러나 저는 이러한 흐름에서 문득 이러한 문제에 도달했습니다. 토큰은 Stateless, 즉 무상태이기때문에 로그아웃한 유저의 토큰이나 회원탈퇴한 유저의 토큰으로 서비스에 요청하려고하는 것을 막을 수 없다는 것이었습니다.

이러한 문제를 해결하기 위해서 BlackList 기법을 도입해 로그아웃 / 회원탈퇴한 유저의 토큰을 별도로 관리하여 접근을 막는 형식으로 해결했지만, 이것이 토큰 기반 인증 방식의 장점인 무상태성을 해치는 것이 아닌가에 대한 고민은 아직까지도 남아있습니다.


SpringBoot 환경설정

 

SpringBoot 2.5.2 / Gradle 7.1.1 버전에서 작업한 예제임을 미리 말씀드립니다. 구체적인 작성 코드는 버전에 따라서 달라질 수 있습니다.

JWT 인증 방식 구현 코드의 일부는 인프런의 SpringBoot jwt Tutorial 강좌를 참고하였습니다.

 

환경설정

Gradle

    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'

 

application.yml

jwt:
  header: Authorization
  secret:{secret key}
  access-token-validity-in-seconds: 1800 # 초 단위
  refresh-token-validity-in-seconds: 604800

SpringBoot 구현 코드

 

 

 

 

SecurityConfig.java

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    public SecurityConfig(
        TokenProvider tokenProvider,
        CorsFilter corsFilter,
        JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
        JwtAccessDeniedHandler jwtAccessDeniedHandler
    ) {
        this.tokenProvider = tokenProvider;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
    }

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

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/api/docs/**");
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
            // token을 사용하는 방식이기 때문에 csrf를 disable합니다.
            .csrf().disable()

            .cors().configurationSource(corsConfigurationSource())
            .and()
            .exceptionHandling()
            .authenticationEntryPoint(jwtAuthenticationEntryPoint)
            .accessDeniedHandler(jwtAccessDeniedHandler)

            // 세션을 사용하지 않기 때문에 STATELESS로 설정
            .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

            .and()
            .authorizeRequests()
            .antMatchers("/api/authenticate").permitAll()
            .antMatchers("/api/reissue").permitAll()
            .antMatchers("/api/docs/api-doc.html").permitAll()

            .anyRequest().authenticated()

            .and()
            .apply(new JwtSecurityConfig(tokenProvider));
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.addAllowedOrigin("{hostURL:frontEndPort}");
        configuration.addAllowedHeader("*");
        configuration.addAllowedMethod("*");
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", configuration);
        return source;
    }
}

 

SecurityConfig.java 내부 상세설명

 

 

@EnableWebSecurity
  • 스프링 시큐리티 사용을 위한 어노테이션
  • WebSecurityConfigurerAdapter를 상속받은 config 클래스에 이 어노테이션이 사용되면, SpringSecurityFilterChain이 자동으로 포함됩니다.
@EnableGlobalMethodSecurity(prePostEnabled = true)

스프링 공식문서 참조

https://docs.spring.io/spring-security/site/docs/current/reference/html5/#enableglobalmethodsecurity

 

Spring Security Reference

In Spring Security 3.0, the codebase was sub-divided into separate jars which more clearly separate different functionality areas and third-party dependencies. If you use Maven to build your project, these are the modules you should add to your pom.xml. Ev

docs.spring.io

@Configuration이 붙은 인스턴스 중 어디든 이 어노테이션을 붙이면 어노테이션 기반 보안 기능 활성화가 가능합니다.

secureEnabled, jsr250Enabled, prePostEnabled 세 가지 속성을 갖고 있으며,

  • secureEnabled
    • @Secured 어노테이션을 사용할 수 있도록 함 (Spring 지원)
  • jsr250Enabled
    • @RolesAllowed 어노테이션을 사용할 수 있도록 함(자바 표준에서 지원하는 어노테이션)
  • prePostEnabled
    • @PreAuthorize(메서드 호출 전), @PostAuthorize(메서도 호출 후) 권한 확인
    • SpEL 지원, Spring에서 지원하는 어노테이션
    • @PostAuthorize의 특이점은, 메서드의 리턴 값을 기반으로 인가처리가 가능하다는 점입니다. (returnObject로 참조)

저는 프로젝트에 @PreAuthorize 어노테이션을 활용해 권한에 따른 API 요청을 달리하기 위해 prePostEnabled 를 설정하였습니다.

 

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/api/docs/**");
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

정적 리소스나 HTML 문서 등의 보안 예외처리를 위한 configure 메서드.

 @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
            .csrf().disable() // Rest API 서버이기때문에 CSRF 처리를 해제합니다.
            .cors().configurationSource(corsConfigurationSource()) // CORS 환경설정 적용
            .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class) // Custom Jwt 토큰 필터를 filter chain의 UsernamePasswordAuthenticationFilter 앞에 세팅
            .exceptionHandling() // 예외처리 기능 수행
            .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 인증 실패 진입점
            .accessDeniedHandler(jwtAccessDeniedHandler) // 인가 실패 진입점

            .and() // 세션을 사용하지 않으므로 STATELESS로 설정
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

            .and() // 로그인, refreshToken을 통한 AccessToken 재발급 요청, api 명세서 관련을 제외하고 모든 요청은 인증이 필요합니다.
            .authorizeRequests()
            .antMatchers("/api/authenticate").permitAll()
            .antMatchers("/api/reissue").permitAll()
            .antMatchers("/api/docs/api-doc.html").permitAll()
            .anyRequest().authenticated()

            .and()
            .apply(new JwtSecurityConfig(tokenProvider));
    }

security 관련 환경설정을 위한 메서드.

 

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.addAllowedOrigin("{hostURL:frontEndPort}");
        configuration.addAllowedHeader("*");
        configuration.addAllowedMethod("*");
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", configuration);
        return source;
    }

CORS 환경설정 메서드

 

번외 ) CORS 

CORS는 리소스를 요청하는 서버의 도메인, 프로토콜 또는 포트가 다를 경우에 cross-origin HTTP Request 요청을 실행합니다.

보안 상의 이유로 브라우저에서는 cross-origin HTTP Request에 대해 Same-Origin Policy를 적용합니다. (같은 도메인의 서버인 경우에 정상 동작)

이외의 경우에는, CORS Header 설정을 해주어야 정상적인 요청이 이루어집니다.

https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS

참고 내용

 

Cross-Origin Resource Sharing (CORS) - HTTP | MDN

Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading resources. CORS also relies on a mechanism by which

developer.mozilla.org

번외) CORS 요청 종류

Simple request

Simple request 는 Preflight 체크를 하지 않으며, 클라이언트와 서버간에 한 번만 요청과 응답을 주고 받습니다. Simple request 는 아래의 조건들을 만족하면 요청하게 됩니다.

 

- 요청 메서드

 

  • GET
  • HEAD
  • POST

- 커스텀 헤더 전송 허용되지 않음.

- 허용 헤더

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type
  • Last-Event-ID
  • DPR
  • Save-Data
  • Viewport-Width
  • Width

- Content-Type 헤더의 허용 Value

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

 

Preflight request

 

Simple request 의 조건에 만족하지 않으면 Preflight request 방식으로 요청합니다. Preflight request 는 다른 도메인에 HTTP request 를 전송하기 전에 OPTIONS 메서드로 사전 요청을 통해 서버로부터 안전한 요청인지 응답을 받고, 본 요청을 수행합니다.

 

 

jwtAccessDeniedHandler.java

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

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

        response.sendError(HttpServletResponse.SC_FORBIDDEN); // 403에러
    }
}

인가실패 시의 처리를 위한 클래스

 

jwtAuthenticationEntryPoint.java

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

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

        response.sendError(HttpServletResponse.SC_FORBIDDEN); // 403에러
    }
}

인증 실패 시의 처리를 위한 클래스

 

jwtFilter.java

public class JwtFilter extends GenericFilterBean {

    private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);

    public static final String AUTHORIZATION_HEADER = "Authorization";

    private TokenProvider tokenProvider;

    public JwtFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    /*
     * filter를 통해 JWT토큰이 유효한지 검증하는 메서드
     */
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
        FilterChain filterChain)
        throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String jwt = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();
        logger.debug("doFilter 들어옴");
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(),
                requestURI);
        } else {
            logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

    /*
     * HTTP Request 헤더에서 토큰만 추출하기 위한 메서드
     */
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

부연 설명

Filter

  • javax.servlet-api / tomcat-embed-core 사용 시 제공되는 Servlet Filter Interface
  • 사용자의 서블릿 리퀘스트를 가장 앞단에서 처리한다.

GenericFilterBean

  • Filter를 확장하여 Spring의 설정 정보를 가져올 수 있게 확장된 추상 클래스

 

 

 

 

TokenProvider.java

@Component
public class TokenProvider implements InitializingBean {

    private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);

    private static final String AUTHORITIES_KEY = "role";

    private final String secret;
    private final long accessTokenValidityInMilliseconds;
    private final long refreshTokenValidityInMilliseconds;
    private final RedisUtil redisUtil;

    private Key key;

    private final UserRepository userRepository;

    public TokenProvider(
        @Value("${jwt.secret}") String secret,
        @Value("${jwt.access-token-validity-in-seconds}") long accessTokenValidityInSeconds,
        @Value("${jwt.refresh-token-validity-in-seconds}") long refreshTokenValidityInSeconds,
        UserRepository userRepository, RedisUtil redisUtil) {
        this.secret = secret;
        this.accessTokenValidityInMilliseconds = accessTokenValidityInSeconds * 1000;
        this.refreshTokenValidityInMilliseconds = refreshTokenValidityInSeconds * 1000;
        this.userRepository = userRepository;
        this.redisUtil = redisUtil;
    }

    /*
     * 시크릿 키 설정
     */
    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    /*
     * 검증된 이메일에 대해 토큰을 생성하는 메서드
     * AccessToken의 Claim으로는 email과 nickname을 넣습니다.
     */
    public TokenDto createToken(String email,
        String authorities) {
        long now = (new Date()).getTime();
        UserBase user = userRepository.findByEmail(email) // princial.toSTring()
            .orElseThrow(() -> new UserNotFoundException("해당하는 이메일이 존재하지 않습니다."));

        String accessToken = Jwts.builder()
            .claim("email", user.getEmail())
            .claim("nickname", user.getNickname())
            .claim(AUTHORITIES_KEY, authorities)
            .setExpiration(new Date(now + accessTokenValidityInMilliseconds))
            .signWith(key, SignatureAlgorithm.HS512)
            .compact();

        String refreshToken = Jwts.builder()
            .claim(AUTHORITIES_KEY, authorities)
            .claim("email", user.getEmail())
            .claim("nickname", user.getNickname())
            .setExpiration(new Date(now + refreshTokenValidityInMilliseconds))
            .signWith(key, SignatureAlgorithm.HS512)
            .compact();

        return new TokenDto(accessToken, refreshToken);
    }

    /*
     * 권한 가져오는 메서드
     */
    public Authentication getAuthentication(String token) {
        Claims claims = getClaims(token);

        Collection<? extends GrantedAuthority> authorities =
            Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
        return new UsernamePasswordAuthenticationToken(claims.get("email"), null, authorities);
    }

    /*
     * 토큰 유효성 검사하는 메서드
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            logger.info("validate 들어옴");
            if (redisUtil.hasKeyBlackList(token)) {
                throw new UnauthorizedException("이미 탈퇴한 회원입니다");
            }
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            logger.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            logger.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            logger.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            logger.info("JWT 토큰이 잘못되었습니다.");
        } catch (UnauthorizedException e) {
            logger.info("이미 탈퇴한 회원입니다.");
        }
        return false;
    }

    /*
     * 토큰에서 Claim 추츨하는 메서드
     */
    public Claims getClaims(String token) {
        try {
            return Jwts
                .parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

부연 설명

createToken()

  • 토큰을 생성하는 메서드입니다. accessToken과 refreshToken의 지속시간에는 정해진 시각은 없지만, accessToken은 짧은 지속시간을, refreshToken에는 긴 지속시간을 주는 방식을 선택합니다. 이것을 통해 잦은 통신이 이루어지는 accessToken의 탈취가 이루어지더라도 빠르게 expire되기 때문에 보안성을 확보할 수 있습니다.

getAuthentication()

  • 토큰에 세팅한 claims에서 권한을 가져오는 메서드입니다. 이 메서드를 통해 Authentication을 가져옵니다. 이 메서드는 jwtFilter에서 사용되는데, 사용자 인증에 성공할 경우 이 메서드를 통해 토큰에서 인증권한을 추출하고, Authentication 객체를 만들어서 그것을 SecurityContext에 저장합니다.
  • 이 과정을 통해 추후 Controller에서 @AuthenticalPrincipal 어노테이션으로 SecurityContext에 있는 유저 정보를 가져와 Email을 추출해낼 수 있습니다.(Request의 파라미터로 별도의 Email이나 ID와 같은 식별 정보를 가져오지 않아도 됩니다)

validateToken()

  • 토큰의 유효성을 검증하는 메서드입니다.

 

이와 같이 설정된다면, 이제 로그인과 로그아웃 로직이 있다면 JWT 로그인을 구현할 수 있게 됩니다.

 

 

 

AuthApiController.java

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class AuthApiController {

    private final AuthService authService;

    /*
     * 로그인을 했을때 토큰(AccessToken, RefreshToken)을 주는 메서드
     */
    @PostMapping("/authenticate")
    public ResponseEntity<TokenDto> login(
        @RequestBody @Valid AuthDto.LoginDto loginDto) {
        TokenDto tokenDto = authService.authorize(loginDto.getEmail(), loginDto.getPassword());
        return ResponseEntity.ok(tokenDto);
    }

    /*
     * AccessToken이 만료되었을 때 토큰(AccessToken , RefreshToken)재발급해주는 메서드
     */
    @PostMapping("/reissue")
    public ResponseEntity<TokenDto> reissue(
        @RequestBody @Valid TokenDto requestTokenDto) {
        TokenDto tokenDto = authService
            .reissue(requestTokenDto.getAccessToken(), requestTokenDto.getRefreshToken());
        return ResponseEntity.ok(tokenDto);
    }

    /*
       로그아웃을 했을 때 토큰을 받아 BlackList에 저장하는 메서드
     */
    @DeleteMapping("/authenticate")
    public ResponseEntity<Void> logout(
        @RequestBody @Valid TokenDto requestTokenDto) {
        authService.logout(requestTokenDto.getAccessToken(), requestTokenDto.getRefreshToken());
        return OK;
    }
}

인증 실패 시의 처리를 위한 클래스

 

TokenDto.java

    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public static class TokenDto {

        private String accessToken;
        private String refreshToken;

        @Builder
        public TokenDto(String accessToken, String refreshToken) {
            this.accessToken = accessToken;
            this.refreshToken = refreshToken;
        }
    }

인증 실패 시의 처리를 위한 클래스

 

 

AuthService.java

@Service
@Transactional
@RequiredArgsConstructor
public class AuthService {

    private final TokenProvider tokenProvider;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final RedisUtil redisUtil;

    // 로그인 관련 메서드
    public TokenDto authorize(String email, String password) {
        UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(email, password);

        Authentication authentication = authenticationManagerBuilder.getObject()
            .authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        String authorities = getAuthorities(authentication);

        return tokenProvider.createToken(authentication.getName(), authorities);
    }

    // 토큰 재발급 관련 메서드
    public TokenDto reissue(String requestAccessToken, String requestRefreshToken) {
        if (!tokenProvider.validateToken(requestRefreshToken)) {
            throw new UnauthorizedException("유효하지 않은 RefreshToken 입니다");
        }
        Authentication authentication = tokenProvider.getAuthentication(requestAccessToken);

        UserBase principal = (UserBase) authentication.getPrincipal();

        SecurityContextHolder.getContext().setAuthentication(authentication);
        String authorities = getAuthorities(authentication);

        return tokenProvider.createToken(principal.getEmail(), authorities);
    }

    // 권한 가져오기
    public String getAuthorities(Authentication authentication) {
        return authentication.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));
    }

    public void logout(String accessToken, String refreshToken) {
        redisUtil.setBlackList(accessToken, "accessToken", 1800);
        redisUtil.setBlackList(refreshToken, "refreshToken", 60400);
    }
}

로그아웃을 하는 경우 Redis로 올라가있는 BlackList 저장소에 그 토큰들을 저장하여, 추후 그 토큰들을 재사용하는 취약점을 막는 구조로 설계했습니다. 

 

RedisUtil.java

/*
    Redis 저장소 메서드들을 보다 편하게 사용하기 위한 Util
 */


@Component
public class RedisUtil {

    private final RedisTemplate<String, Object> redisTemplate;
    private final RedisTemplate<String, Object> redisBlackListTemplate;

    RedisUtil(
        RedisTemplate<String, Object> redisTemplate,
        @Qualifier("redisBlackListTemplate") RedisTemplate<String, Object> redisBlackListTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisBlackListTemplate = redisBlackListTemplate;
    }

    public void set(String key, Object o, int minutes) {
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer(o.getClass()));
        redisTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
    }

    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public boolean delete(String key) {
        return redisTemplate.delete(key);
    }

    public boolean hasKey(String key) {
        return redisTemplate.hasKey(key);
    }

    public void setBlackList(String key, Object o, int minutes) {
        redisBlackListTemplate.setValueSerializer(new Jackson2JsonRedisSerializer(o.getClass()));
        redisBlackListTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
    }

    public Object getBlackList(String key) {
        return redisBlackListTemplate.opsForValue().get(key);
    }

    public boolean deleteBlackList(String key) {
        return redisBlackListTemplate.delete(key);
    }

    public boolean hasKeyBlackList(String key) {
        return redisBlackListTemplate.hasKey(key);
    }
}

 

 

'프로젝트 > O-GYM' 카테고리의 다른 글

SpringBoot 프로젝트에 Redis Cache 적용하기  (1) 2021.10.03