[Spring] JWT 리프레시 토큰 도입: Redis로 RT 관리

이지연·2026년 2월 2일

리프레시 토큰 도입 전략

AT: 유효기간 짧음, DB 저장X→ 얘만 쓰면 폐기처분이 안됨
RT : 유효기간 김, DB 저장O

물론 RT 써도 폐기처분 안됨 하지만 AT의 유효기간을 짧게 가져가서 검증하는 로직을 추가해주고, RT는 DB에 적재시켜 필요 시 제거하여 토큰 탈취를 대응하는 전략

FE에서는 RT로 AT를 재발급 받는 로직을 짜야한다.


RT & AT 워크플로우

1) 로그인 및 정상 API 호출

  1. FE → 서버: 로그인 요청
  2. 서버 → FE: AT 발급(필요 시 RT도 함께 발급/저장)
  3. FE → 서버: API 요청(AT 포함)

2) AT 만료 시(자동갱신)

  1. 서버 → FE: 401 반환(AT 만료/유효하지 않음)
  2. FE → 서버: AT 재발급 요청(RT로 refresh)
  3. 서버 → FE: 새 AT 발급(성공 시)
  4. FE → 서버: 원래 실패했던 API 요청 재시도(새 AT로)

3) RT 만료 시(자동 로그아웃/재인증)

  • 전제: RT 기간 만료로 DB의 RT가 삭제됨(또는 서버 검증에서 무효 처리)
  1. 서버 → FE: 401 반환(refresh 요청 자체가 실패하거나, refresh 결과가 401)
  2. FE: 자동 처리 필요
  • 사용자 세션 정리(저장된 AT/RT 삭제)
  • 로그인 화면 리다이렉트(또는 “세션 만료, 다시 로그인” 안내)
  • 진행 중이던 요청들은 실패 처리(재시도 금지)

FE에서 자동화 포인트(핵심)

  • 401을 받았을 때 “원 요청인지 / refresh 요청인지” 구분
  • 원 요청 401 → refresh 시도 → 성공하면 원 요청 1회 재시도
  • refresh도 401 → RT 만료로 판단 → 토큰 제거 + 로그아웃/재로그인 유도

예시로 Axios 인터셉터를 쓴다면 “요청에 AT 자동 첨부”, “응답 401 시 refresh 단 한 번만 시도”, “refresh 실패 시 전역 로그아웃” 같은 규칙으로 묶는 식으로 구현


Redis 특징

Redis의 특징

Redis는 인메모리 DB(램)라서 성능이 좋다가 핵심

key-value 의 해시테이블의 구조로 보통 조회/저장이 O(1) 수준으로 빠르다

그리고 애초에 성능이 매우 빨라서 멀티스레드 구조로 설계될 필요가 없어 싱글 스레드 구조로 설계된다


레디스를 왜 쓰냐?

  • 빠른 성능 필요할 때(캐싱, 카운트, 토큰, 세션, 랭킹 같은 거)

게시글 상세조회 캐싱은 아래와 같은 흐름이다

  • 사용자 A가 10번 글 처음 조회 → Redis에 없으니 RDB에서 select * from post where id=10 조회
  • 조회 결과를 Redis에 post:10 -> {id:10, title:..., contents:...} 이런 식으로 저장(보통 JSON 문자열)
  • 그 다음 B가 10번 글 조회 → Redis에서 바로 꺼내오니까 빨라짐

“상세 vs 목록” 중 뭐가 캐싱에 더 적합하냐?

목록은 select * from post 같은 형태로 결과가 자주 바뀌고(페이지/정렬/필터까지 걸리면 경우의 수가 더 폭발함), 캐시 키를 어떻게 잡아도 무효화가 너무 빡세서 유지가 어렵다.

반면 상세 조회는 키도 명확하고(post:10처럼), 변경 범위도 그 글 하나라 캐싱 효율이 좋다.

캐싱의 함정은 데이터가 바뀔 때다. 예를 들어 게시글 수정이 발생하면 Redis에 있던 값은 구버전이 된다. 이때는 전략을 정해야 한다.

  • 수정 시점에 Redis 값도 같이 업데이트(또는 삭제해서 다음 조회 때 RDB에서 다시 채우게)
  • 만료 시간을 둬서(TTL) “어차피 일정 시간 뒤엔 사라지게” 해서 최신성 문제를 완화

Redis의 value는 문자열만 되는 게 아니라 “문자열 + 다양한 자료구조”를 지원한다는 게 강점이다.


Redis value의 자료구조

Redis value는 문자열만 되는 게 아니라 자료구조를 여러 개 지원함. 상황에 맞게 고르면 됨.

좋아요 예시로 보면:

  • “store1 좋아요 수”만 보면 누가 눌렀는지 모름
  • “좋아요 한 사람 리스트”를 List로 하면 중복/성능이 별로라서 Set이 맞음
  • sadd likes:posting:1 h1
  • sadd likes:posting:1 h2
  • 결과: likes:posting:1 -> {h1,h2,...} (중복도 자동 방지)

시나리오별 자료구조 감 잡기:

  • 좋아요: Set
  • 재고/카운터: String(숫자)
  • 캐싱(상세 데이터): JSON(String)
  • 최근 본 상품: List(중복 허용), 중복 제거 필요하면 Set
  • “시간정보/랭킹/주식가격처럼 정렬 필요”: ZSet
  • 특정 필드만 자주 수정: Hash(근데 운영 단순하게 하려면 그냥 JSON 캐시하고 invalidate 하는 경우도 많음)

Redis 유실이 무섭다?

그러면 아예 Redis랑 RDB를 같이 쓰면 됨.

  • 좋아요 누름: Redis add + RDB insert
  • 좋아요 확인: Redis만 조회
  • 좋아요 취소: Redis remove + RDB delete

이렇게 하면 Redis가 날아가도 RDB가 백업 역할을 해줌.


코드 관점에서 정리

  1. 로그인시 AT와 RT 동시 생성하여 지급
  2. RT는 DB(Redis)에 저장
  3. AT 갱신 API를 추가하여 RT를 통해 AT를 갱신

이 때 RT 검증 방법으로는 두가지가 있고, req dto는 RT가 res dto에는 AT가 된다.

  1. 토큰 자체 검증
  2. DB 조회

1. Docker를 통해 Redis 설치

Redis는 리눅스 기반이기 때문에 윈도우에서는 사용되지 않고, 이 때 이미지를 사용하여(설치파일) 도커허브에서의 이미지를 찾아서 다운로드 후 실행시킴

  • docker run -d --name my-redis -p 6379:6379 redis : -d 백그라운드로 실행되도록 하는 옵션, -p 포트:포트, 프로그램 이름(redis)

  • docker exec -it 컨테이너ID redis-cli : 레디스 접속

  • select 0(디비번호) : 레디스 DB 선택

  • keys * : key값 확인


  • redis는 0번부터 9번까지의 (10개)DB가 시작되고, 이 때 모두 key:value 형식으로 저장되어있다

2. Spring에서 Redis 접속(연결)

  • redis 관련된 의존성 추가 필요 : implementation 'org.springframework.boot:spring-boot-starter-data-redis'

  • redis에 연결할 싱글톤 객체(빈 객체)를 만들어야함

package com.beyond.order_system.common.config;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    // 연결 빈객체 (DB 0)
    @Bean
    @Qualifier("rtInventory")
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        configuration.setHostName(host);
        configuration.setPort(port);
        configuration.setDatabase(0);
        return new LettuceConnectionFactory(configuration);
    }

    // 템플릿 빈객체 (DB 0)
    @Bean
    @Qualifier("rtInventory")
    public RedisTemplate<String, String> redisTemplate(
            @Qualifier("rtInventory") RedisConnectionFactory redisConnectionFactory
    ) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        return redisTemplate;
    }

    // 연결 빈객체 (DB 1)
    @Bean
    @Qualifier("stockInventory")
    public RedisConnectionFactory redisStockConnectionFactory() {
        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        configuration.setHostName(host);
        configuration.setPort(port);
        configuration.setDatabase(1);
        return new LettuceConnectionFactory(configuration);
    }

    // 템플릿 빈객체 (DB 1)
    @Bean
    @Qualifier("stockInventory")
    public RedisTemplate<String, String> redisStockTemplate(
            @Qualifier("stockInventory") RedisConnectionFactory redisConnectionFactory
    ) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        return redisTemplate;
    }
}

RedisTemplate은 key/value serializer를 설정할 수 있다.


JwtTokenProvider에 RT 생성 로직 추가

package com.beyond.order_system.common.auth;

import com.beyond.order_system.member.domain.Member;
import com.beyond.order_system.member.repository.MemberRepository;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.crypto.spec.SecretKeySpec;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
import java.util.concurrent.TimeUnit;

@Component
public class JwtTokenProvider {
    /* *********************** JWT 설정 *********************** */
    @Value("${jwt.secretKey}")
    private String st_secret_key;

    @Value("${jwt.expiration}")
    private int exp_minuet;

    private Key secret_key;

    @Value("${jwt.secretKeyRt}")
    private String st_secret_key_rt;

    @Value("${jwt.expirationRt}")
    private int exp_minuet_rt;

    private Key secret_key_rt;

    /* *********************** DI 주입 *********************** */
    private final RedisTemplate<String, String> redisTemplate;
    private final MemberRepository memberRepository;

    @Autowired
    public JwtTokenProvider(
            @Qualifier("rtInventory") RedisTemplate<String, String> redisTemplate,
            MemberRepository memberRepository
    ) {
        this.redisTemplate = redisTemplate;
        this.memberRepository = memberRepository;
    }

    @PostConstruct
    public void init() {
        secret_key = new SecretKeySpec(
                Base64.getDecoder().decode(st_secret_key),
                SignatureAlgorithm.HS512.getJcaName()
        );
        secret_key_rt = new SecretKeySpec(
                Base64.getDecoder().decode(st_secret_key_rt),
                SignatureAlgorithm.HS512.getJcaName()
        );
    }

    public String createAtToken(Member member) {
        Claims claims = Jwts.claims().setSubject(String.valueOf(member.getId())); // email에서 id로 개선
        claims.put("role", member.getRole().toString());

        Date now = new Date();

        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + exp_minuet * 60 * 1000L))
                .signWith(secret_key)
                .compact();
        return token;
    }

    public String createRtToken(Member member) {
        // 1. 유효 기간이 긴 RT 토큰 생성
        Claims claims = Jwts.claims().setSubject(String.valueOf(member.getId()));
        claims.put("role", member.getRole().toString());

        Date now = new Date();

        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + exp_minuet_rt * 60 * 1000L))
                .signWith(secret_key_rt)
                .compact();

        // 2. 생성한 RT토큰 Redis에 저장
        // opsForSet, opsForZset, opsForList 등 value에 들어올 수 있는 자료구조들이 있다.
        // opsForValue는 일반 String 자료구조를 저장할 때 사용
        redisTemplate.opsForValue().set(
                String.valueOf(member.getId()),
                token,
                exp_minuet_rt,
                TimeUnit.MINUTES
        );
        return token;
    }

    public Member validateRt(String refreshToken) {
        Claims claims;
        try {
            claims = Jwts.parserBuilder()
                    .setSigningKey(secret_key_rt)
                    .build()
                    .parseClaimsJws(refreshToken)
                    .getBody();
        } catch (Exception e) {
            throw new IllegalArgumentException("잘못된 토큰입니다.");
        }

        Long id = Long.valueOf(claims.getSubject());
        Member member = memberRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("entity not found"));

        String redisRt = (String) redisTemplate.opsForValue().get(String.valueOf(id));
        if (redisRt != null && !redisRt.equals(refreshToken)) {
            throw new IllegalArgumentException("잘못된 토큰입니다.");
        }
        return member;
    }
}

parserBuilder().setSigningKey(key).build().parseClaimsJws(jwt) 형태로 파싱/검증이 가능하다.


로그인 API 내 토큰 생성 로직 완성

@PostMapping("/doLogin")
public ResponseEntity<?> login(@RequestBody @Valid MemberLoginReqDto dto) {
    Member member = memberService.login(dto);

    String accessToken = jwtTokenProvider.createAtToken(member);
    // refresh token 생성 및 저장
    String refreshToken = jwtTokenProvider.createRtToken(member);

    MemberLoginResDto tokenDto = MemberLoginResDto.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build();

    return ResponseEntity.status(HttpStatus.OK).body(tokenDto);
}

refresh-at API 추가하여 RT 검증 로직 수행

@PostMapping("/refresh-at")
public ResponseEntity<?> refreshAt(@RequestBody RefreshTokenReqDto dto){
    // 1. RT 검증 (validateRt: 토큰 자체검증 -> Redis 조회 검증)
    // - 현재 Redis에서 TTL 작업을 수행하지 않았기 때문에 토큰 자체 검증까지 수행하는것이다. (물론 TTL 설정도 할것이다)
    // - 이 때 member 객체를 만들기 위해 claims 를 꺼내면서 토큰은 자체검증이 됨
    Member member = jwtTokenProvider.validateRt(dto.getRefreshToken());

    // 2. AT 신규 생성
    String accessToken = jwtTokenProvider.createAtToken(member);

    MemberLoginResDto tokenDto = MemberLoginResDto.builder()
            .accessToken(accessToken)
            .refreshToken(dto.getRefreshToken()) // null로 둬도 됨
            .build();
    return ResponseEntity.status(HttpStatus.OK).body(tokenDto);
}
profile
Eazy하게

0개의 댓글