Refresh Token은 어떻게 관리할까 (JWT)

2025. 7. 1. 16:01·Spring/JWT

서론

최근에 웹서비스를 개발할때는 REST API를 중심으로 클라이언트와 서버 간 상태를 최대한 분리하는 것을 지향한다. 이 과정에서 토큰 기반 인증(Token-based Authentication) 은 서버가 세션 상태를 유지하지 않으면서도, 클라이언트의 인증 정보를 안전하게 검증할 수 있는 방법인데, 오늘은 정말 여러가지 방법이 있는 Refresh Token 관리 방법에 대해서 다뤄보려고한다

출처 : LocalStorage vs Cookies All You Need To Know About Storing JWT Tokens Securely in The Front-End


 

토큰 기반 인증과 관리의 중요성

토큰 기반 인증의 핵심은 “Access Token”랑 “Refresh Token”인데, 간단하게만 설명하자면

  • Access Token: 비교적 짧은 유효기간(예: 5분~1시간)을 갖고, API 호출 시 Authorization: Bearer <accessToken> 헤더를 통해 서버가 클라이언트를 검증
  • Refresh Token: Access Token랑 비교했을땐 긴 유효기간을 부여해, Access Token이 만료되었을 때 재발급을 요청할 수 있는 수단

정해진 것은 아니지만, 보통은 위처럼 구현되어 JWT 토큰 관리라는 이름으로 운영된다. 위 JWT기반 인증 방식은 세션 기반 인증이랑 비교했을떈 다음과 같은 장점들을 가진다

  1. 무상태성(Stateless): 서버가 세션 데이터를 보관하지 않아 확장성이 뛰어남
  2. 유연한 만료 관리: Access/Refresh 토큰별로 유효기간을 분리해 보안, 편의성을 모두 충족
  3. 다양한 클라이언트 지원: 모바일, SPA 등등 여러 환경에서 일관된 인증 흐름으로 구현 가능

위와 같은 장점을 가짐과 동시에, 토큰 관리가 부실하면 탈취, 재사용 공격에 취약해진다. 물론 Refresh Token은 탈취당했을 때를 대비해 Token 소유자의 Username, 발급 당시 Ip 등 다양한 정보를 함께 DB에 저장한다는 이야기가 있지만, 개인적으로 생각했던건

 앞서 말했듯이 Refresh Token의 경우, 유효시간이 길기도하고 Access Token을 재발급 받을 수 있는 수단이 되기 때문에 탈취당하면 똑같이 치명적이라고 생각했다

 

그렇다면 Refresh Token은 어떻게 관리해주는 것이 좋을까 ? 일단 Refresh Token의 좀 더 자세한 개념과 특징부터 알아보자


본문

Refresh Token의 개념

Refresh Token이란?
Access Token이 짧은 수명으로 설계되는 이유는, 탈취 시 피해를 최소화하기 위함입니다. 그러나 짧은 만료 기간 때문에 사용자는 자주 로그인하거나 인증 절차를 밟아야 하는 불편을 겪습니다. 이때 Refresh Token은 “Access Token을 재발급할 수 있는 권한”을 가지는 긴 수명의 토큰으로, 사용자가 다시 로그인하지 않아도 매번 새로운 Access Token을 받아올 수 있게 해 줍니다.

주요 특징으론, 일반적으로 Access Token의 5~10배 이상의 긴 유효기간을 가지며, Refresh Token이 유효할 때만 Access Token 재생성해주도록 권한 검사를 해주어야합니다

XSS와 CSRF

일단 먼저, Access Token을 우리는 쿠키에 저장하는 방식을 선택했었는데, 먼저 쿠키의 가장 큰 장점 중에 하나는, 설명할 XSS 공격에 강하다는 점이였다. HttpOnly 옵션을 사용하면 JavaScript로 쿠키에 접근할 수 없으므로, 악성 스크립트에 의한 토큰 탈취를 방지할 수 있다.

public void setCookie(String name, String value, HttpServletResponse response) {
        ResponseCookie cookie = ResponseCookie.from(name, value)
                .maxAge(cookieMaxAge)
                .path(cookiePathOption)
                .secure(secureOption)
                .httpOnly(true)
                .sameSite("Lax")
                .build();

        response.setHeader("Set-Cookie", cookie.toString());
}


여기서 XSS란, Crose Site Scripting의 줄임말로 악의적인 사용자가 웹 애플리케이션에 악성 스크립트를 삽입하여 사용자 브라우저에서 실행되도록 하는 공격 기법이다. 

 

하지만 내가 걱정했던 내용은 쿠키에 Access Token을 저장하는 방식이 단점으로는 HttpOnly 옵션을 걸어도, 동일 출처 정책이 풀려 있는 곳에서 공격자가 쿠키 기반 요청을 조작할 수 있다는 점이였다. CSRF란 쿠키의 이러한 허점을 이용한 공격인데, Cross-Site Request Forgery의 약자로, 간단하게 얘기하면 공격자가 다른 사이트에서 사용자가 의도하지 않은 요청을 보내는 공격 방식이다.

쿠키저장 방식에서 해당 공격에 취약한 이유는 유저가 공격자가 만든 임의에 B 사이트 잘못 접근하게 되면, 쿠키의 자동 전송 특성을 이용해서, 공격자가 사용자의 원래 사이트의 쿠키를 탈취할 수 있게 되는 것이다

 

이와 관련해서 같이 프로젝트를 진행하는 클라이언트 분인 재훈이형의 의견을 물어봤었는데

LOCOCO Slack에서 대화한 내용

결론적으로 Access Token의 탈취 문제는, 클라분들이 meta tag에 CSP 보안설정을 해두고, 서버에서 response header에 CSP 설정을 같이 해주는 방식을 고려해서 설계하기로 했었다. 다시 본론으로 돌아와서 그렇다면, Refresh Token은 어떻게 관리하기로 했는지 얘기해보려 한다


 

Redis 도입 배경

목차에서도 알 수 있듯이, 앞서말한 공격들의 취약점을 보완하기 위해서 인-메모리 DB인 Redis에 Refresh Token을 저장하기로 했다. 일단 도입한 이유는 첫번째로 Redis는 브라우저 영역을 벗어나 서버 측 Redis에 저장하는 것만으로 탈취 가능성을 줄여줄 수 있고, 두번째로는 TTL을 설정해서, 만료 정책으로 추가적인 삭제가 불필요하여 Refresh Token 수명과 동기화된 효율적인 관리가 가능해진다

 

아래는 전체적인 구현한 내용의 플로우를 정리해봤다

  1. 가입(or 로그인) 시 Access + Refresh Token 발급 → Redis에 refreshToken:{userId}:{tokenId} 형태로 저장
  2. 클라이언트는 헤더에만 Refresh Token을 담아 전송 (HttpOnly 쿠키가 아닌 순수 헤더 방식을 채택)
  3. 서버에서 헤더로부터 토큰을 추출 → Redis에서 조회하여 값 일치 확인
  4. 토큰 만료 여부 확인 (JWT 내장 만료 클레임 + Redis TTL)
  5. 검증 통과 시
    • 기존 Redis 키 삭제 → 새로운 Access + Refresh Token 생성
    • Access Token은 HttpOnly 쿠키에, Refresh Token은 응답 헤더에 전달
  6. 검증 실패 시 401 에러 반환

결론

실제 구현한 내용의 PR


 

실제 구현 : Spring Boot + Redis

아래는 실제 구현한 코드의 일부라서 참고만 해주면 좋을거같다


Auth Controller

@PostMapping("/refresh")
@Operation(summary = "RefreshToken 재발급")
public ApiResponse<Void> reissueRefreshToken(
        @RequestHeader("Refresh-Token") String refreshToken,
        HttpServletResponse response) {
    JwtTokenDto jwtTokenDto = jwtService.reissueJwtToken(refreshToken);

    // 새 Access Token → HttpOnly 쿠키 저장
    cookieUtil.setCookie("Access-Token", jwtTokenDto.getAccessToken(), response);
    // 새 Refresh Token → 응답 헤더 전달
    response.setHeader("Refresh-Token", jwtTokenDto.getRefreshToken());

    return ApiResponse.success(HttpStatus.OK, "Refresh 토큰 재발급 완료");
}

JwtService 

@Service @RequiredArgsConstructor
public class JwtService {
    private static final String PREFIX = "refreshToken:";
    private final JwtProvider provider;
    private final JwtExtractor extractor;
    private final RedisUtil redisUtil;

    @Value("${jwt.refresh.expiration}")
    private long refreshExp;

    // 1) 최초 발급
    public JwtTokenDto generate(GenerateDto dto) {
        String tokenId = UUID.randomUUID().toString();
        String access = provider.generateAccessToken(dto.getId(), dto.getRole());
        String refresh = provider.generateRefreshToken(dto.getId(), dto.getRole(), tokenId);

        String key = PREFIX + dto.getId() + ":" + tokenId;
        redisUtil.set(key, refresh, refreshExp);
        return new JwtTokenDto(access, refresh, tokenId);
    }

    // 2) 재발급
    public JwtTokenDto reissueJwtToken(String refreshToken) {
        Long userId = extractor.getId(refreshToken);
        String tokenId = extractor.getTokenId(refreshToken);
        String key = PREFIX + userId + ":" + tokenId;
        String stored = redisUtil.get(key);

        // 토큰 일치·만료 검증
        if (!MessageDigest.isEqual(
                stored.getBytes(), refreshToken.getBytes())) {
            throw new TokenInvalidException();
        }
        if (extractor.isExpired(refreshToken)) {
            throw new TokenExpiredException();
        }

        // 재발급 전 기존 토큰 삭제
        redisUtil.delete(key);

        // 새 토큰 발급
        return generate(new GenerateDto(userId, extractor.getRole(refreshToken)));
    }
}

위 코드 내용처럼처럼 Redis에 저장된 키랑 JWT 내장 tokenId를 매치해 관리해서, 클라이언트가 URL이나 바디에 민감한 식별자를 들고 다닐 필요 없이, 서버에서 안전하게 리프레시 기능을 수행하도록 설계했다

JwtProvider 일부 

@Component
public class JwtProvider {
    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String REFRESH_TOKEN_HEADER = "RefreshToken";
    private static final String ID_CLAIM = "id";
    private static final String ROLE_CLAIM = "role";
    private static final String ROLE_PREFIX = "ROLE_";

    private final Key key;
    private final long accessTokenExpiration;
    private final long refreshTokenExpiration;

    public JwtProvider(
            @Value("${lokoko.jwt.key}") String secretKey,
            @Value("${lokoko.jwt.access.expiration}") long accessTokenExpiration,
            @Value("${lokoko.jwt.refresh.expiration}") long refreshTokenExpiration
    ) {
        this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
        this.accessTokenExpiration = accessTokenExpiration;
        this.refreshTokenExpiration = refreshTokenExpiration;
    }

    public String generateAccessToken(Long userId, String role) {
        return Jwts.builder()
                .setSubject(userId.toString())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis()
                        + accessTokenExpiration))
   ...
}

 

 

참고한 자료

https://velog.io/@debug/Spring-security-JWT-Redis-5-Refresh-token

https://dev.to/cotter/localstorage-vs-cookies-all-you-need-to-know-about-storing-jwt-tokens-securely-in-the-front-end-15id

'Spring > JWT' 카테고리의 다른 글

userId를 매번 입력받지않고도 로그인한 유저 정보를 가져올 수 있을까 ? (JWT)  (0) 2025.09.19
P-프로젝트(JWT 를 통한 인증/인가, Spring)  (0) 2025.02.13
'Spring/JWT' 카테고리의 다른 글
  • userId를 매번 입력받지않고도 로그인한 유저 정보를 가져올 수 있을까 ? (JWT)
  • P-프로젝트(JWT 를 통한 인증/인가, Spring)
huncozyboy
huncozyboy
이지훈
  • huncozyboy
    열정을 기록하기
    huncozyboy
  • 전체
    오늘
    어제
    • 분류 전체보기 (63)
      • Spring (26)
        • JWT (3)
        • 무한 스크롤 (1)
        • 매칭 로직 (2)
        • OAuth (4)
        • 자동화 (1)
        • 캐싱 (1)
        • AOP (2)
        • Swagger (1)
        • S3 (1)
        • CORS (1)
        • Spring Retry (0)
        • Webhook (2)
        • Grapheme Cluster (1)
        • 연관관계 (1)
        • CS 개념 (5)
      • DevOps (13)
        • 스왑 메모리 (1)
        • Blue Green (2)
        • Docker (7)
        • Route 53 (1)
        • 리버스 프록시 (2)
      • AI (2)
        • Claude Code (1)
        • Copilot (1)
      • CS (4)
        • JAVA (4)
      • Github (1)
        • Conflict (1)
      • Python (4)
        • Langchain (3)
        • Crawling (1)
      • 일상 (3)
        • 회고록 (1)
      • 알고리즘 (10)
        • 투포인터 (0)
        • 슬라이딩 윈도우 (0)
        • 정렬 (0)
        • 이분 탐색 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    DevOps
    도커
    LangChain
    java
    자바
    코딩테스트
    aws
    redis
    JWT
    OAuth
    EC2
    코테
    Spring
    스프링
    수도코드
    Docker
    프로그래머스
    https
    알고리즘
    백준
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
huncozyboy
Refresh Token은 어떻게 관리할까 (JWT)
상단으로

티스토리툴바