Access Token만 사용할때의 문제점
Access Token은 사용자 인증과 인가에 직접 사용되므로 상대적으로 짧은 만료 기간(30분 ~ 1시간)을 갖는다.
이렇게 짧은 유효기간을 유지하는 이유는 토큰을 탈취당했을 경우 피해를 최소화 할 수 있기 때문이다.
만약 Access Token의 유효 기간을 길게 설정하게 되면 탈취된 토큰을 활용할 수 있는 시간도 늘어나게 된다.
이렇게 Access Token의 짧은 유효기간은 보안상 좋은점도 있지만, 사용자가 자주 로그인을 해야 하는 불편이 있다.
만약 짧은 유효기간의 Access Token만을 사용하게 되면 사용자가 서비스를 사용중에 갑자기 로그아웃 되거나 오류 메시지를 보게 되어 곤란한 상황이 생길 수 있다.
Refresh Token의 활용
이러한 문제를 Refresh Token을 함께 발급하여 해결할 수 있다.
Refresh Token은 보통 7일 이상의 긴 유효기간을 가지며, Access Token이 만료되면 클라이언트에서 Refresh Token을 사용하여 새로운 Access Token을 발급 받을 수 있다.
이렇게 되면 사용자는 반복 로그인 없이 일정 기간 서비스를 지속적으로 이용할 수 있게된다.
Refresh Token 구현
구현 내용
- 사용자 로그인
- 회원정보 DB에서 조회
- Refresh Token(TTL 7일) Redis에 저장
- Access Token과 Refresh Token을 클라이언트에게 반환
Refresh Token을 Redis에 저장하는 이유
- Redis는 키 단위로 TTL을 설정할 수 있어, 만료된 토큰이 자동으로 삭제 되지만 RDB에 저장할 경우 만료된 토큰을 찾아 삭제하는 추가 작업이 필요함.
- 인메모리 방식의 빠른 조회 성능
- 휘발성 메모리에 리프레시 토큰을 저장하다가 데이터가 전부 지워져도 모든 유저가 재로그인을 하면 되고, 그렇게 크리티컬한 오류가 아니기 때문
<RefreshToken>
package com.example.RedisPractice.api.members.jwt;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;
@RedisHash(value = "token")
@AllArgsConstructor
@Getter
@ToString
public class RefreshToken {
@Id
private Long id;
private String refreshToken;
@TimeToLive
private Long expiration;
}
<TokenRepository>
Redis 저장소에 RefreshToken 객체를 쉽게 저장하고 조회할 수 있도록 지원하는 Spring Data Repository
package com.example.RedisPractice.api.members.repository;
import com.example.RedisPractice.api.members.jwt.RefreshToken;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TokenRepository extends CrudRepository<RefreshToken, Long> {
}
<JwtTokenProvider>
Access Token, Refresh Token 발급 및 Redis에 Refresh Token 저장
package com.example.RedisPractice.api.members.jwt;
import com.example.RedisPractice.api.members.entity.Role;
import com.example.RedisPractice.api.members.repository.TokenRepository;
import com.example.RedisPractice.common.exception.UnauthorizedException;
import com.example.RedisPractice.common.response.ErrorStatus;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.Optional;
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private final TokenRepository tokenRepository;
@Value("${jwt.secret}")
private String secretKeyString;
@Value("${jwt.expiration}")
private long expiration;
private Key getSigningKey() {
return Keys.hmacShaKeyFor(secretKeyString.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(String email, Role role) { //사용자 이메일과 Role정보를 포함한 JWT 토큰 생성
Date now = new Date();
Date expiry = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(email) // sub
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public String getEmail(String token) { //JWT 토큰에서 이메일 정보 추출
return Jwts.parser()
.setSigningKey(getSigningKey())
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public String getRole(String token) { //JWT 토큰에서 Role 정보 추출
return Jwts.parser()
.setSigningKey(getSigningKey())
.parseClaimsJws(token)
.getBody()
.get("role", String.class);
}
public boolean validateToken(String token) { //토큰 유효성 검사
try {
Jwts.parser()
.setSigningKey(getSigningKey())
.parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
System.out.println("Expired JWT token: " + e.getMessage());
throw new UnauthorizedException(ErrorStatus.UNAUTHORIZED_TOKEN_EXPIRED.getMessage());
} catch (MalformedJwtException e) {
System.out.println("Malformed JWT token: " + e.getMessage());
throw new UnauthorizedException(ErrorStatus.UNAUTHORIZED_INVALID_TOKEN.getMessage());
} catch (UnsupportedJwtException e) {
System.out.println("Unsupported JWT token: " + e.getMessage());
throw new UnauthorizedException(ErrorStatus.UNAUTHORIZED_UNSUPPORTED_TOKEN.getMessage());
} catch (IllegalArgumentException e) {
System.out.println("Empty JWT token: " + e.getMessage());
throw new UnauthorizedException(ErrorStatus.UNAUTHORIZED_EMPTY_TOKEN.getMessage());
}
}
public String generateRefreshToken(String email) {
Date now = new Date();
long refreshExpiration = 7 * 24 * 60 * 60 * 1000L; // 7일 밀리초 단위
Date expiry = new Date(now.getTime() + refreshExpiration);
return Jwts.builder()
.setSubject(email) // 토큰 대상자 이메일 정보
.setIssuedAt(now)
.setExpiration(expiry)
// 서명 키는 기존 액세스 토큰 서명키와 동일하게 사용
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
/**
* 리프레시 토큰 저장
* @param id 토큰의 고유 식별자 (예: 사용자 ID)
* @param refreshToken 실제 리프레시 토큰 문자열
* @param expirationSeconds 만료 시간(초)
*/
public void saveRefreshToken(Long id, String refreshToken, Long expirationSeconds) {
RefreshToken token = new RefreshToken(id, refreshToken, expirationSeconds);
tokenRepository.save(token);
}
/**
* 리프레시 토큰 조회
* @param id 토큰 고유 식별자
* @return RefreshToken 엔티티, 없으면 null 반환
*/
public RefreshToken getRefreshToken(Long id) {
Optional<RefreshToken> tokenOptional = tokenRepository.findById(id);
return tokenOptional.orElse(null);
}
/**
* 리프레시 토큰 삭제
* @param id 토큰 고유 식별자
*/
public void deleteRefreshToken(Long id) {
tokenRepository.deleteById(id);
}
}
<MemberService>
- 로그인 시 Refresh Token, Access Token 생성, 반환
- Refresh Token은 Redis에 저장
- Access Token 만료 시, Refresh Token 유효성 검사를 통해 새로운 Access Token 발급
package com.example.RedisPractice.api.members.service;
import com.example.RedisPractice.api.members.dto.LoginRequestDto;
import com.example.RedisPractice.api.members.dto.SignupRequestDto;
import com.example.RedisPractice.api.members.entity.Member;
import com.example.RedisPractice.api.members.jwt.JwtTokenProvider;
import com.example.RedisPractice.api.members.jwt.RefreshToken;
import com.example.RedisPractice.api.members.repository.MemberRepository;
import com.example.RedisPractice.api.members.repository.TokenRepository;
import com.example.RedisPractice.common.exception.BaseException;
import com.example.RedisPractice.common.exception.UnauthorizedException;
import com.example.RedisPractice.common.response.ErrorStatus;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import static com.example.RedisPractice.api.members.entity.Role.ROLE_MEMBER;
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final JwtTokenProvider jwtTokenProvider;
private final TokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public void signUpMember(SignupRequestDto signupRequestDto){
if(memberRepository.findByEmail(signupRequestDto.getEmail()).isPresent()){
throw new BaseException(ErrorStatus.BAD_REQUEST_DUPLICATE_EMAIL.getHttpStatus(),ErrorStatus.BAD_REQUEST_DUPLICATE_EMAIL.getMessage());
}
if(memberRepository.findByNickname(signupRequestDto.getNickname()).isPresent()){
throw new BaseException(ErrorStatus.BAD_REQUEST_DUPLICATE_NICKNAME.getHttpStatus(),ErrorStatus.BAD_REQUEST_DUPLICATE_NICKNAME.getMessage());
}
String encodedPassword = passwordEncoder.encode(signupRequestDto.getPassword());
Member member = signupRequestDto.toEntity(encodedPassword);
member.setRole(ROLE_MEMBER);
memberRepository.save(member);
}
@Transactional
public Map<String, Object> loginMember(LoginRequestDto loginRequestDto){
Member member = memberRepository.findByEmail(loginRequestDto.getEmail())
.orElseThrow(()->new UnauthorizedException(ErrorStatus.UNAUTHORIZED_EMAIL_OR_PASSWORD.getMessage()));
if(!passwordEncoder.matches(loginRequestDto.getPw(),member.getPassword())){
throw new UnauthorizedException(ErrorStatus.UNAUTHORIZED_EMAIL_OR_PASSWORD.getMessage());
}
String token = jwtTokenProvider.generateToken(member.getEmail(),member.getRole());
String refreshToken = jwtTokenProvider.generateRefreshToken(member.getEmail());
// 리프레시 토큰 저장
jwtTokenProvider.saveRefreshToken(member.getId(), refreshToken, 7 * 24 * 60 * 60L); // 7일 만료
Map<String, Object> response = new HashMap<>();
response.put("token",token);
response.put("refreshToken", refreshToken);
response.put("role",member.getRole());
return response;
}
public String reissueAccessToken(String refreshToken){
if(!jwtTokenProvider.validateToken(refreshToken)){
throw new BaseException(ErrorStatus.UNAUTHORIZED_REFRESH_TOKEN_EXPIRED.getHttpStatus(),ErrorStatus.UNAUTHORIZED_EMPTY_TOKEN.getMessage());
}
String email = jwtTokenProvider.getEmail(refreshToken);
Member member = memberRepository.findByEmail(email)
.orElseThrow(()->new UnauthorizedException("회원 정보를 찾을 수 없습니다."));
//Redis에서 회원 ID로 저장된 리프레시 토큰 조회
RefreshToken storedRefreshToken = tokenRepository.findById(member.getId())
.orElseThrow(()-> new UnauthorizedException("리프레시 토큰을 찾을 수 없습니다."));
// 전달받은 리프레시 토큰과 Redis에서 조회한 토큰 일치 여부 확인
if(!storedRefreshToken.getRefreshToken().equals(refreshToken)){
throw new UnauthorizedException("유효하지 않은 리프레시 토큰입니다.");
}
// 새로운 액세스 토큰 생성하여 반환
return jwtTokenProvider.generateToken(email,member.getRole());
}
}
로그인 후 Redis CLI를 통해 확인해보면 TTL이 약 7일로 잘 설정 되어있고
토큰 값도 잘 저장되어 있는것도 확인 가능하다.
Access Token 재발급 API에 Refresh Token을 넣어서 테스트하면 새로운 Access Token이 발급되는 것도 확인 가능하다.
'BackEnd > Redis' 카테고리의 다른 글
[Redis] 캐싱을 이용한 간단한 성능 개선 테스트(Apache JMeter) (0) | 2025.08.25 |
---|---|
Redis DB서버 구성 (0) | 2025.08.22 |
Redis(Remote Dictionary Server)란? (0) | 2025.08.21 |