[Spring] JWT 토큰 사용 장단점 & AT + RT 인증 구조 완성하기

이지연·2026년 1월 27일

개요

JWT 단독으로는 토큰 유출 시 서버에서 폐기 불가능이라는 치명적 단점이 있다.
이를 해결하기 위해 Access Token(AT) + Refresh Token(RT) 조합을 사용하는 방법을 실제 코드 구조까지 정리해보자.


토큰 사용 장단점

장점

  • 서버 메모리 절약: 세션처럼 서버에 상태 저장 X
  • 확장성 향상: 로드밸런싱/MSA에서 서버 간 세션 공유 필요 없음

단점 & 해결책

토큰 유출 시 → 서버에서 강제 폐기 불가능 ❌
↓
AT(짧은 유효기간) + RT(Redis 저장) 조합 사용 ✅

JWT 토큰 인증 구조 (AT + RT)

1. 토큰 역할 구분

토큰역할유효기간저장 위치특징
ATAPI 요청 시 권한 검증30분 (짧게)클라이언트 (로컬스토리지)서버/DB 저장 X
RTAT 만료 시 재발급용7일 (길게)Redis (TTL 7일)서버에서 삭제 가능

![AT-RT 구조 이미지]


2. 전체 인증 워크플로우

1️⃣ Login → AT(30분) + RT(7일) 발급 + Redis 저장(RT)
   ↓
2️⃣ API 요청 → AT 첨부 → Filter 검증 → 성공/401
   ↓ (AT 만료)
3️⃣ FE → RT 첨부 → /refresh 요청
   ↓
4️⃣ 서버 → Redis RT 조회 → 일치 → 새 AT 발급

실제 코드 구현

1. 로그인 API (AT + RT 동시 발급)

@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;
}

2. JwtTokenFilter (AT만 검증)

@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);
    }
}

3. AT 재발급 API (/refresh)

@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());
}

4. Redis 서비스 (RT 관리)

@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);
    }
}

5. JwtTokenProvider 확장 (AT/RT 분리)

@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(안전) 조합으로 보안 + 성능을 잡을 수 있다.

profile
Eazy하게

0개의 댓글