런닝 코스 공유 서비스

[런닝 코스 공유 서비스] - 15. Refresh Token

sson-coding 2026. 1. 11. 15:25

🏅오늘의 목표

  • Refresh Token 도입
  • 로그아웃

진행한 작업

  • Refresh Token 도입
  • 로그아웃

📃 개발내용

Refresh Token 도입 이유

  • 현재 Access Token 만 있고 , Refresh Token 은 없음
  • Refresh Token 이 없으면, 사용자는 자주 로그인 해야 함
  • 보안성과 사용자 경험을 둘 다 챙길 수 있음

Access Token vs Refresh Token

구분 Access Token Refresh Token
목적 API 요청 인증 Access Token 재발급
사용 위치 매 API 요청 시 Access Token 만료 시
유효 기간 짧음 (보통 5~30분) (보통 7일~30일)
노출 위험 상대적으로 높음 상대적으로 낮아야 함
저장 위치 클라이언트 메모리 / 쿠키 서버(DB/Redis) + HttpOnly 쿠키
서버 저장 여부 ❌ 보통 저장 안 함 ✅ 저장하는 경우 많음
탈취 시 피해 제한적 (짧은 시간) 큼 (토큰 재발급 가능)
폐기 방식 만료 대기 서버에서 즉시 무효화 가능

Redis 설정

Refresh Token 을 Redis 에 저장하는 이유

Refresh Token은 다음과 같은 요구사항이 있다.

  • 로그아웃 시 즉시 무효화
  • 토큰 탈취 시 서버에서 강제 차단
  • 만료 시간을 서버 기준으로 명확히 관리

이 요구사항을 만족하기 위해 Redis + TTL 구조를 선택했다.

Redis Template 설정

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String,String> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String,String> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        return template;
    }
}
  • Spring Boot 는 RedisTemplate 을 기본으로 자동 구성해줌
  • 그럼에도 직접 설정한 이유
    • 제네릭 타입 지정 가능 : 명확하게 타입 지정해 형변환 실수 방지
    • 문자열뿐 아니라 객체 저장 시 Jackson,Json 등 지정 가능
    • 커스텀 설정을 분리 관리 : 나중에 Redis 구성을 중앙에서 관리 가능

opsForValue()

Redis 에서 opsForValue() 는 String 자료구조를 다루기 위한 API 이다.
redisTemplate.opsForValue() 를 사용하면 Redis 의 String 타입 (key-value) 전용 작업 객체를 꺼낸다.

즉, 이제부터 String 타입 Redis 값을 다룬다는 뜻이다.

구현

JwtRefreshTokenService

@Service
@Slf4j
@RequiredArgsConstructor
public class JwtRefreshTokenService {

    private final RedisTemplate<String, String> redisTemplate;
    private final JWTUtil jwtUtil;
    private final String PREFIX = "refresh:user"; // Redis Key 충돌 방지용

    // Refresh Token 을 Redis 에 저장하면서 TTL 설정
    public void save(UUID userUuId, String token, long duration, TimeUnit timeUnit) {
        redisTemplate.opsForValue().set(PREFIX + userUuId, token, duration, timeUnit);
    }

    // 특정 사용자에게 저장된 Refresh Token 조회
    public String get(UUID userUuId) {
        return redisTemplate.opsForValue().get(PREFIX+userUuId);
    }

    // Refresh Token 삭제
    public void delete(UUID userUuId) {
        redisTemplate.delete(PREFIX+userUuId);
    }

    // 현재 유효한 토큰인지 검사
    public boolean isValid(UUID userUuId, String token) {
        String saved = get(userUuId);
        return saved != null && saved.equals(token) && jwtUtil.validateToken(saved);
    }
}

JwtRefreshTokenService

  • Refresh Token 의 저장, 조회, 삭제, 유효성 검증 담당

Redis Key 설계

  • 사용자 단위로 Refresh Token 1개만 유지
  • 중복 로그인 시 이전 Refresh Token 자동 무효화
  • Key 충돌 방지 및 관리 용이

save() - Refresh Token 을 Redis 에 저장 + TTL 설정

  • 흐름
    1. 로그인 성공
    2. Refresh Token 생성
    3. Redis 에 저장
    4. TTL 설정
  • 장점
    • 별도의 만료 스케줄러 불필요
    • Redis가 자동으로 만료 처리

get() - Refresh Token 조회

  • 재발급 요청이 들어오면 Redis 에 저장된 토큰과 비교하기 위해 사용
  • 흐름
    • 값이 null → 이미 만료되었거나 로그아웃
    • 값이 존재 → 다음 검증 단계 진행

delete() - Refresh Token 삭제

  • 로그아웃 시 Redis 에서 해당 사용자의 Refresh Token 삭제

isValid() - Refresh Token 유효성 검증

  • 흐름
    1. Redis 에 토큰 존재 여부
      1. 없으면 → 만료 또는 로그아웃
    2. Redis 에 저장된 토큰과 요청 토큰이 일치하는지
      1. 다르면 → 탈취 가능성
    3. JWT 자체가 유효한지
      1. 만료되었으면 → 재로그인

JwtUtil - RefreshToken 생성 메서드 추가

// Refresh 토큰 생성
    public String createRefreshToken(){
        return Jwts.builder()
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis()  + 1000L * 60 * 60 * 24 * 7)) // 7일
            .signWith(secretKey)
            .compact();
    }
  • Access Token 을 재발급 받기 위해 사용하므로 , Access Token 보다 수명이 길어야 한다.
  • Redis 에서 1차 검증, JWT 자체에서 2차 검증 구조

흐름

  1. 발급시간 설정
  2. 만료시간 설정
    • 1000L(밀리초)
    • 606024(하루)
    • 7(7일)
  3. 위조 방지 : 서버만 알고 있는 secretKey 로 서명

CustomUsernamePasswordAuthenticationFilter - 로그인 시 RefreshToken 발급 및 저장

// 로그인 성공 시 JWT 토큰 발급
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
        Authentication authResult) throws IOException, ServletException {
        try {
            // 사용자 정보 추출
            CustomUserDetails customUserDetails = (CustomUserDetails)authResult.getPrincipal();

            UUID userUuid = customUserDetails.getUserUuid();
            String email = customUserDetails.getEmail();
            String name = customUserDetails.getName();
            Role role = customUserDetails.getRole();

            // jwt 토큰 생성
            JWTUserDto user = new JWTUserDto(userUuid, email, name, role);

            String accessToken = jwtUtil.createAccessToken(user);
            String refreshToken = jwtUtil.createRefreshToken();

            jwtRefreshTokenService.save(userUuid, refreshToken, REFRESH_EXPIRATION_DAYS, TimeUnit.DAYS);

            // AccessToken 응답
            response.addHeader("Authorization", "Bearer " + accessToken);

            // RefreshToken 응답
            ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
                .httpOnly(true)
                .secure(false)
                .path("/")
                .maxAge(60L * 60 * 24 * REFRESH_EXPIRATION_DAYS)
                .sameSite("Lax")
                .build();
            response.addHeader("Set-Cookie", cookie.toString());

            log.info("로그인 성공 - name: {}, role: {}", name, role);
        } catch (Exception e) {
            log.error("JWT 토큰 생성 중 오류 발생", e);
        }
    }
  • Spring Security 에서 로그인 인증이 성공하면 successfulAuthentication() 이 호출됨

흐름

  1. 인증 객체에서 사용자 정보 꺼내기
  2. JWT 에 담을 사용자 DTO 구성
    1. AccessToken 에 들어갈 정보
  3. AccessToken / RefreshToken 생성
  4. RefreshToken 을 Redis 에 저장
    1. 클라이언트에만 맡기지 않고, Redis 에 저장해서 서버가 유효성을 관리
  5. AccessToken 은 Authorization 헤더로 반환
  6. RefreshToken 은 HttpOnly Cookie 로 반환
    1. 프론트 JS 에서 접근할 수 없는 HttpOnly 쿠키에 넣어 XSS 위험을 줄임
    2. httpOnly(true) : JS 로 읽기 불가
    3. samSite(”Lax”) : 기본적인 CSRF 완화
    4. maxAge() : 쿠키 만료

Refresh Token 재발급 API - AuthController

private final JwtRefreshTokenService jwtRefreshTokenService;
    private final UserService userService;
    private final JWTUtil jwtUtil;
    private static final long REFRESH_EXPIRATION_DAYS = 7;

    @PostMapping("/refresh")
    public ResponseEntity<ApiResponse<LoginResponse>> refresh(@Valid @RequestBody RefreshTokenRequest request){
        // Refresh 검증
        if(!jwtRefreshTokenService.isValid(request.userUuid(),request.refreshToken())){
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ApiResponse<>("Refresh Token 인증 실패"));
        }
        // 사용자 조회
        UserDto user = userService.findById(request.userUuid());

        // 재발급
        JWTUserDto jwtUserDto = new JWTUserDto(user.userUuId(),user.email(),user.name(),user.role());
        String newAccessToken = jwtUtil.createAccessToken(jwtUserDto);
        String newRefreshToken = jwtUtil.createRefreshToken();

        // Redis 저장소 교체
        jwtRefreshTokenService.save(user.userUuId(),newRefreshToken,REFRESH_EXPIRATION_DAYS, TimeUnit.DAYS);

        LoginResponse response = new LoginResponse(newAccessToken,newRefreshToken,user);
        return ResponseEntity.ok(new ApiResponse<>("Refresh Token 인증 성공",response));
    }

흐름

  1. Refresh 재발급 API 엔드포인트
    1. 클라이언트는 userUuid 와 refreshToekn 을 바디로 보냄
    2. 서버는 검증 후 새 토큰을 내려줌
  2. Refresh Token 검증
    1. isValid()
      1. Redis 에 해당 사용자 키가 존재하는지
      2. Redis 에 저장된 토큰과 요청 토큰이 일치하는지
      3. JWT 자체가 만료,위조 되지 않았는지
      4. 실패하면 401 Unauthorized 처리
  3. 사용자 조회
    1. Refresh Token 이 유효하다고 판단하면, 토큰을 담을 사용자 정보를 가져옴
  4. 새 Access Token 발급
    1. 기존 토큰과 별개로 생성
  5. 새 Refresh Token 발급 + Redis 교체
    1. 재발급이 한 번 일어난 시점부터 이전 토큰은 사실상 무효
  6. 최종 응답 구성
    1. 새 Access Token,Refresh Token 사용자 정보

그 외 코드

LoginResponse

public record LoginResponse(
    String accessToken,
    String refreshToken,
    UserDto user
) {
}

RefreshTokenRequest

public record RefreshTokenRequest(
    @NotNull UUID userUuid,
    @NotBlank String refreshToken
) {
}

로그아웃 시 RefreshToken 삭제 - AuthController

// 로그아웃
    @PostMapping("/logout")
    public ResponseEntity<ApiResponse<Void>> logout(
        @AuthenticationPrincipal CustomUserDetails customUserDetails
    ){
        jwtRefreshTokenService.delete(customUserDetails.getUserUuid());
        return ResponseEntity.ok(new ApiResponse<>("로그아웃 성공"));
    }

흐름

  1. @AuthenticationPrincipal 로 인증 사용자 식별
  2. Refresh Token 삭제 = 로그아웃
  3. Access Token 은 삭제하지 않는 이유
    1. Access Token 의 짧은 수명
    2. stateless 특성상 서버에서 개별 폐기가 어려움

📝 테스트


🤙추후 보완할 점

  1. Refresh Token 전달 방식 개선
    • Body → HttpOnly Bookie
  2. Refresh Token 에 토큰 ID 부여
  3. Refresh Token Rotation + 재사용 탐지
  4. 멀티 디바이스/멀티 세션 지원

참고자료

https://ddururiiiiiii.tistory.com/614

https://ddururiiiiiii.tistory.com/615