JWT 단독으로는 토큰 유출 시 서버에서 폐기 불가능이라는 치명적 단점이 있다.
이를 해결하기 위해 Access Token(AT) + Refresh Token(RT) 조합을 사용하는 방법을 실제 코드 구조까지 정리해보자.
토큰 유출 시 → 서버에서 강제 폐기 불가능 ❌
↓
AT(짧은 유효기간) + RT(Redis 저장) 조합 사용 ✅
| 토큰 | 역할 | 유효기간 | 저장 위치 | 특징 |
|---|---|---|---|---|
| AT | API 요청 시 권한 검증 | 30분 (짧게) | 클라이언트 (로컬스토리지) | 서버/DB 저장 X |
| RT | AT 만료 시 재발급용 | 7일 (길게) | Redis (TTL 7일) | 서버에서 삭제 가능 |
![AT-RT 구조 이미지]
1️⃣ Login → AT(30분) + RT(7일) 발급 + Redis 저장(RT)
↓
2️⃣ API 요청 → AT 첨부 → Filter 검증 → 성공/401
↓ (AT 만료)
3️⃣ FE → RT 첨부 → /refresh 요청
↓
4️⃣ 서버 → Redis RT 조회 → 일치 → 새 AT 발급
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest dto) {
// 1. 사용자 인증
Author author = authorService.login(dto);
// 2. AT 생성 (짧은 유효기간)
String accessToken = jwtTokenProvider.createAccessToken(author);
// 3. RT 생성 (긴 유효기간)
String refreshToken = jwtTokenProvider.createRefreshToken(author.getId());
// 4. RT를 Redis에 저장 (TTL: 7일)
redisService.saveRefreshToken(author.getId(), refreshToken);
// 5. 응답
return ResponseEntity.ok(LoginResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build());
}
LoginResponse DTO
@Data
public class LoginResponse {
private String accessToken;
private String refreshToken;
}
@Component
public class JwtTokenFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String accessToken = extractToken(request); // "Bearer {AT}"
try {
// AT만 검증 (빠른 처리)
if (accessToken != null && jwtTokenProvider.validateAccessToken(accessToken)) {
Authentication auth = jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (ExpiredJwtException | InvalidJwtException e) {
// AT 만료/유효하지 않음 → 401 → FE에서 /refresh 호출 트리거
SecurityContextHolder.clearContext();
}
chain.doFilter(request, response);
}
}
@PostMapping("/refresh")
public ResponseEntity<RefreshResponse> refreshAccessToken(@RequestBody RefreshRequest request) {
String refreshToken = request.getRefreshToken();
// 1. RT에서 userId 추출
Long userId = jwtTokenProvider.getUserIdFromRefreshToken(refreshToken);
// 2. Redis에서 저장된 RT 조회
String storedRefreshToken = redisService.getRefreshToken(userId);
// 3. RT 일치 확인
if (!refreshToken.equals(storedRefreshToken)) {
redisService.deleteRefreshToken(userId); // 로그아웃 처리
throw new UnauthorizedException("Invalid refresh token");
}
// 4. 새 AT 발급
Author author = authorService.findById(userId);
String newAccessToken = jwtTokenProvider.createAccessToken(author);
return ResponseEntity.ok(RefreshResponse.builder()
.accessToken(newAccessToken)
.build());
}
@Service
public class RedisService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void saveRefreshToken(Long userId, String refreshToken) {
redisTemplate.opsForValue()
.set("rt:" + userId, refreshToken, Duration.ofDays(7));
}
public String getRefreshToken(Long userId) {
return redisTemplate.opsForValue().get("rt:" + userId);
}
public void deleteRefreshToken(Long userId) {
redisTemplate.delete("rt:" + userId);
}
}
@Component
public class JwtTokenProvider {
// AT용 짧은 키
private final Key accessSecret = Keys.secretKeyFor(SignatureAlgorithm.HS512);
// RT용 긴 키
private final Key refreshSecret = Keys.secretKeyFor(SignatureAlgorithm.HS512);
public String createAccessToken(Author author) {
return Jwts.builder()
.setSubject(author.getEmail())
.claim("role", author.getRole())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000)) // 30분
.signWith(accessSecret)
.compact();
}
public String createRefreshToken(Long userId) {
return Jwts.builder()
.setSubject(userId.toString())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000)) // 7일
.signWith(refreshSecret)
.compact();
}
}
✅ 보안
- AT 유출: 30분 후 자동 만료 → 재로그인
- RT 유출: Redis에서 즉시 삭제 → 로그아웃
✅ 성능
- 99%: AT만 검증 (서버 메모리 검증)
- 1%: RT 검증 (Redis 조회)
// 1. 로그인 성공
const { accessToken, refreshToken } = response.data;
localStorage.setItem('AT', accessToken);
localStorage.setItem('RT', refreshToken);
// 2. API 요청 시 AT 검증
api.get('/me', {
headers: { Authorization: `Bearer ${localStorage.getItem('AT')}` }
}).catch(error => {
if (error.response.status === 401) {
// 3. AT 재발급
refreshToken().then(newAT => {
localStorage.setItem('AT', newAT.accessToken);
// 재시도
api.get('/me');
});
}
});
기존 JWT 프로젝트에 RedisTemplate + Refresh API만 추가하면 유출 대응 가능한 완전한 인증 시스템 완성!
AT(빠름) + RT(안전) 조합으로 보안 + 성능을 잡을 수 있다.