BackEND/Java

Spring Boot java11 환경에서 JWT 인증 + Refresh Token 적용하기

mingmingIT 2025. 4. 17. 10:24

SPA나 모바일 환경에서 보안성과 확장성을 위해 세션 기반 인증 대신 JWT를 사용하는 경우가 많습니다.
이번 포스팅에서는 기본적인 Access Token 인증 방식에 더해 Refresh Token을 이용한 재인증 처리까지 함께 구현하는 방법을 소개합니다.


JWT + Refresh Token 인증 흐름

 

📦 JWT 구성 요소 정리

항목 설명
Access Token 인증 후 API 호출 시 사용되는 JWT (짧은 유효시간)
Refresh Token AccessToken이 만료되었을 때 재발급을 위해 사용하는 토큰 (긴 유효시간)
저장소 RefreshToken은 Redis 또는 DB에 저장하여 유효성을 관리하는 것이 일반적

1. 의존성 추가 (build.gradle)

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'org.springframework.boot:spring-boot-starter-data-redis' // Redis 사용 시
 

2. JWT 유틸 클래스 수정 (Access/Refresh 구분)

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secretKey;

    private final long ACCESS_EXP = 1000L * 60 * 15; // 15분
    private final long REFRESH_EXP = 1000L * 60 * 60 * 24 * 7; // 7일

    public String createAccessToken(String username) {
        return createToken(username, ACCESS_EXP);
    }

    public String createRefreshToken(String username) {
        return createToken(username, REFRESH_EXP);
    }

    private String createToken(String username, long expiry) {
        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiry))
                .signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS256)
                .compact();
    }

    public boolean isTokenValid(String token) {
        try {
            Jwts.parserBuilder()
                .setSigningKey(secretKey.getBytes())
                .build()
                .parseClaimsJws(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }

    public String extractUsername(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(secretKey.getBytes())
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }
}

3. Refresh Token 저장 방식 (예: Redis)

@Service
public class RefreshTokenService {

    private final RedisTemplate<String, String> redisTemplate;

    public void saveRefreshToken(String username, String refreshToken) {
        redisTemplate.opsForValue().set("RT:" + username, refreshToken, Duration.ofDays(7));
    }

    public String getRefreshToken(String username) {
        return redisTemplate.opsForValue().get("RT:" + username);
    }

    public void deleteRefreshToken(String username) {
        redisTemplate.delete("RT:" + username);
    }
}

4. 로그인 시 Access + Refresh 토큰 발급

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthenticationManager authManager;
    private final JwtUtil jwtUtil;
    private final RefreshTokenService tokenService;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest dto) {
        Authentication auth = authManager.authenticate(
            new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword())
        );

        String username = auth.getName();
        String accessToken = jwtUtil.createAccessToken(username);
        String refreshToken = jwtUtil.createRefreshToken(username);

        tokenService.saveRefreshToken(username, refreshToken);

        return ResponseEntity.ok(Map.of(
            "accessToken", accessToken,
            "refreshToken", refreshToken
        ));
    }
}

5. RefreshToken으로 AccessToken 재발급

@PostMapping("/refresh")
public ResponseEntity<?> refresh(@RequestHeader("Authorization") String header) {
    String refreshToken = header.replace("Bearer ", "");

    if (!jwtUtil.isTokenValid(refreshToken)) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

    String username = jwtUtil.extractUsername(refreshToken);
    String savedToken = tokenService.getRefreshToken(username);

    if (!refreshToken.equals(savedToken)) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

    String newAccessToken = jwtUtil.createAccessToken(username);
    return ResponseEntity.ok(Map.of("accessToken", newAccessToken));
}

6. Security 필터에서 JWT 검증 처리

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        String header = request.getHeader("Authorization");

        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);
            if (jwtUtil.isTokenValid(token)) {
                String username = jwtUtil.extractUsername(token);
                UserDetails user = userDetailsService.loadUserByUsername(username);

                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());

                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        chain.doFilter(request, response);
    }
}
 

🧪 테스트 시나리오 요약

단계 설명
1️⃣ 로그인 (/auth/login) AccessToken + RefreshToken 발급
2️⃣ API 요청 시 (/api/**) AccessToken 검증 후 인증 처리
3️⃣ AccessToken 만료 /auth/refresh로 RefreshToken 전송
4️⃣ 재발급 성공 새로운 AccessToken 반환
5️⃣ 로그아웃 시 Redis에서 RefreshToken 제거 (선택 구현)

🎯 정리

  • JWT 인증은 Stateless 인증으로서 API 기반 시스템에 적합
  • Refresh Token을 이용하면 토큰 갱신을 구현하여 UX 개선 및 보안 강화 가능
  • Redis를 활용하면 RefreshToken 관리가 유연하며 로그아웃/블랙리스트 처리에도 유리함
댓글수2