[GooJakGyo] Redis 기반 Refresh Token 관리 + HTTPOnly Cookie 적용

이재·2025년 11월 5일
post-thumbnail

📌 개요

Access Token 만료 시 자동 로그인을 유지하기 위해 Refresh Token을 도입했고,
보안 강화를 위해 Refresh Token을 Redis 저장 + HttpOnly Cookie에 저장하도록 개선했다.

서버에만 Refresh Token 원본을 보관하고,
사용자는 암호화된 쿠키에만 저장

🔍 Refresh Token이란?

사용자는 한번 로그인한 사이트에서는 계속 로그인이 유지되길 바란다.
자주 방문하는 사이트를 매번 로그인해야 한다면 사용자 경험의 저하로 이어진다.
허나 이를 위해 Access Token의 유효기간을 길게 설정하면, 제 3자가 Access Token을 탈취해서 악의적인 사용을 할 수 있는 위험성이 생긴다.

이를 해결하기 위해서 Access Token의 유효기간은 짧게 설정하고 유효 기간이 지나서 만료 되었을 때 로그인 없이 새롭게 Access Token을 발급받을 수 있게 도와주는 역할을 Refresh Token이 한다.

Refresh Token은 Access Token 보다 더 긴 유효기간을 가지고 있으며 이 유효기간 동안 만료된 Access Token을 새롭게 발급받을 수 있게 한다.

🔁 동작 과정

  1. 사용자가 로그인에 성공하면 Access Token과 Refresh Token을 발급해서 전달한다. 이때 Refresh Token은 사용자한테는 쿠키에 담아서 저장해서 보내주고 서버는 Redis에 저장한다.

  2. 이후 사용자의 Access Token이 만료되면, 사용자는 보유하고 있는 쿠키 안의 Refresh Token을 서버에 전달하고 서버는 해당 Refresh Token을 처음 발급해줄 때 서버에 저장해놓은 Refresh Token과 비교 및 검증을 한다.

  3. 검증을 통과하면 사용자 측으로 새 Access Token과 Refresh Token을 다시 발급한다.

  4. Refresh Token도 만료되었다면, 다시 로그인을 하도록 요청한다.

  5. 사용자가 로그아웃을 하면, Refresh Token을 삭제해서 사용할 수 없게 한다.

🚫 적용한 탈취 대응 패턴

Access Token

TTL을 짧게 설정

  • Access Token은 원칙상 서버에서 검증(서명 + 만료)하므로
    TTL을 30분으로 짧게 설정해서 피해를 제한

Refresh Token

Refresh Token Rotation

  • Access Token 재발급 시 Refresh Token도 새롭게 발급 및 저장
  • 이전 Refresh Token 즉시 폐기
  • 탈취한 이전 토큰으로 요청이 들어오면 차단

왜 1회용이어야 할까?

Access Token을 재발급 할 때 Refresh Token도 갱신을 해주지 않는다면
공격자가 탈취한 Refresh Token으로 만료될 때까지 새 Access Token을 계속 발급 받을 수 있음

🍪 왜 쿠키에 Refresh Token을 저장할까?

Refersh Token은 긴 수명을 갖고 Access Token을 무한대로 재발급할 수 있는 권한을 가지고 있다.

탈취되면 계정이 그대로 털리는 수준..

그래서 최대한 안전한 곳에 저장해야 한다.

1. Local Storage

  • XSS(스크립트 공격)으로 탈취에 취약

2. Session Storage

  • XSS 공격 대상

3. JS 변수 메모리

  • XSS 공격 대상

4. HttpOnly Cookie

  • JS 접근 불가 -> XSS 방어 가능

HttpOnly 속성 덕분에 브라우저 기본 보안 정책으로 JS가 접근할 수 없게 보호됨

브라우저가 자동으로 서버로 전송

API 호출 시 매번 Refresh Token을 헤더 / 바디에 싣지 않아도 된다.

  • 실수 방지 + 편의성 + 일관된 보안 흐름

특히 Access Token 만료 시

  • 사용자는 Refresh Token을 몰라도 됨
  • 서버가 쿠키에서 자동으로 읽어 처리 가능

Refresh Token은 프론트가 다루면 안되는 위험한 정보이기 때문에
서버가 내부적으로 처리하는게 가장 안전하다.

CSRF 대비 설정 가능

Cookie는 CSRF 공격에 취약할 수 있지만

  • SameSite Lax or Strict 설정
  • CORS 설정으로 Origin 검증

을 통해 충분히 방어할 수 있다.

🔍 왜 Redis에 저장해?

MySQL 같은 데이터베이스에 Refresh Token을 저장해도 되지만
왜 굳이 Redis에 저장해서 관리할까?

Redis는 In-Memory 데이터베이스이다.
프로세서가 직접 접근할 수 있는 RAM에 데이터를 저장한다.
HDD나 SSD같은 디스크를 사용하면 요청이 있을 시 데이터를 디스크에서 Ram으로 불러와서 처리하는 과정이 추가되기 때문에
Redis를 사용하면 보조기억장치에서 데이터를 가져오는 비용이 절약된다.
즉, 읽고 쓰는 연산의 속도가 훨씬 빨라진다.

Refresh Token 관리에 적합한 이유

1. 만료된 Refresh Token의 관리

  • Redis는 TTL(Time to Live)라는 데이터 만료일을 토큰의 만료 시간과 동일하게 설정해서 만료되면 Redis에서 토큰이 삭제되도록 해서 관리의 효율성을 높인다.
  • RDBMS는 만료된 Refresh Token의 물리적 삭제 과정이 필요하다.

2. 속도적인 측면

  • Refresh Token은 사용자들이 Access Token을 재발급 받기 위해서 굉장히 많이 접근하려 하는 데이터이다.
  • 호출 빈도가 높기 때문에 읽고 쓰는 연산이 빠른 In Memory 데이터베이스를 사용하면 병목 현상을 완화할 수 있다.

☕AuthService - Token 발급 / 재발급 / 로그아웃

  // 검증 후 AccessToken, RefreshToken 발급
  public LoginResDto issueTokens(Member member) {
    // access token & refresh token 발급
    String accessToken = jwtTokenProvider.createAccessToken(member.getEmail(), member.getRole().toString());
    String refreshToken = jwtTokenProvider.createRefreshToken(member.getEmail());

    // Redis 저장
    RefreshToken redisRefreshToken = RefreshToken.builder()
        .memberId(member.getId())
        .token(refreshToken)
        .expiration(60 * 60 * 24 * 3 * 1000L)
        .build();

    refreshTokenRepository.save(redisRefreshToken);

    return LoginResDto.builder()
        .id(member.getId())
        .name(member.getName())
        .accessToken(accessToken)
        .refreshToken(refreshToken)
        .build();
  }

  // Refresh Token 유효성 검증 후 Access Token, Refresh Token 재발급
  public LoginResDto reissueTokens(String refreshToken) {
    // 유효성 검증
    if (!jwtTokenProvider.validateToken(refreshToken)) throw new IllegalArgumentException("Expired / Invalid Refresh Token");

    // email 추출 및 회원 조회
    String email = jwtTokenProvider.getEmailFromToken(refreshToken);
    Member member = memberRepository.findByEmail(email).orElseThrow(() -> new EntityNotFoundException("존재하지 않는 회원입니다."));

    // Redis에 저장되어 있는 Token과 일치하는지 검증
    RefreshToken stored = refreshTokenRepository.findById(member.getId()).orElse(null);
    if (stored == null || !stored.getToken().equals(refreshToken)) throw new IllegalArgumentException("Invalid Refresh Token");

    // 새 Access Token 및 Refresh Token 발급 및 저장
    String newAccessToken = jwtTokenProvider.createAccessToken(email, member.getRole().toString());
    String newRefreshToken = jwtTokenProvider.createRefreshToken(email);

    stored.setToken(newRefreshToken);
    stored.setExpiration(60 * 60 * 24 * 3 * 1000L);
    refreshTokenRepository.save(stored);

    return LoginResDto.builder()
        .id(member.getId())
        .name(member.getName())
        .accessToken(newAccessToken)
        .refreshToken(newRefreshToken)
        .build();
  }

  // logout시 Redis에 저장된 Refresh Token 삭제
  public void logout(Long id) {
    refreshTokenRepository.deleteById(id);
  }

issueTokens()

  • 로그인 시 Access Token과 Refresh Token 발급
  • Refresh Token은 Redis에 저장

reissueTokens()

  • 만료된 Access Token 재발급 요청 처리
  • Refresh Token Rotation 정책 적용
    • 새로운 Refresh Token 발급 및 Redis에 저장되어 있던 기존 Refresh Token 갱신

logout()

  • Redis에서 Refresh Token 즉시 무효화

☕MemberController - Refresh Token Cookie 저장

//Refresh Token 쿠키로 설정
    ResponseCookie cookie = ResponseCookie.from("refreshToken", loginResDto.getRefreshToken())
        .httpOnly(true)
        .secure(false) // https 아니면 쿠키가 안들어가므로 개발중엔 false
        .sameSite("Strict")
        .path("/")
        .maxAge(Duration.ofDays(3))
        .build();
  • Refresh Token HttpOnly Cookie에 저장

Refresh Token을 Redis + HttpOnly Cookie 기반으로 관리하고
Rotation 정책을 적용해 재발급 시마다 새로운 토큰을 발급함으로서
보안성과 사용자 경험을 모두 챙긴 인증 시스템을 구축했다.

🎯마무리

Refresh Token을 Redis에 저장하고
쿠키 기반으로 안전하게 관리할 수 있는 구조를 갖추게 되었다.
또한 Rotation 정책을 적용해 토큰 탈취 시도를 감지할 수 있는
인증 보안 체계를 완성했다.

기능이 먼저 툭 튀어 나왔을리가 없다.
왜 이런 기능이 필요하지?
기능이 동작하기 위한 원리가 뭐지?
이렇게 동작하기 위해서 어떤 개념들이 들어가야 하지?
라는 고민들이 모여 나온 것이 기능이라고 생각한다.

개발을 진행할수록 단순히 기능만 구현하는 것이 아니라
보안, 성능, 사용자 경험을 모두 고려하는 것이 중요하다는 걸 몸소 느끼는 중이다.

profile
고민을 좋아하는 개발자

0개의 댓글