💻 [Spring 기반 구조] JWT 인증 구조 확장기 - Refresh Token, 암호화, 예외 커스터마이징
2025. 5. 15. 11:28

🎯 개요

Spring Security + JWT 조합으로 기본 인증 구조를 만들었다면,
실무에서는 다음 기능들을 반드시 고려해야 합니다:

  • Access Token 만료 이후 자동 로그아웃 방지 (Refresh Token)
  • 사용자 비밀번호를 안전하게 암호화 (BCrypt)
  • 인증/인가 실패 시 클라이언트가 파싱 가능한 JSON 형태로 응답 처리

이번 글에서는 위 3가지 기능을 하나씩 직접 구현해보며,
JWT 인증 시스템을 실무 수준으로 확장해보았습니다.


✔ 주요 기능 요약

  • ✔ 로그인 시 Access + Refresh Token 동시 발급
  • ✔ Refresh Token 유효 시 Access Token 재발급
  • ✔ 회원가입 시 비밀번호 암호화 저장 (BCryptPasswordEncoder)
  • ✔ 인증 실패(401), 인가 실패(403) 시 JSON 응답 메시지 제공

💻 예제 코드

1️⃣ Refresh Token 발급 및 재발급

  • 로그인 시 Access + Refresh 동시 발급
  • /reissue API로 Access Token 재발급 처리
@PostMapping("/login")
public Map<String, String> login(@RequestBody AuthRequest req) {
    Authentication auth = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword()));

    String accessToken = jwtTokenProvider.generateAccessToken(req.getUsername(), "ROLE_USER");
    String refreshToken = jwtTokenProvider.generateRefreshToken(req.getUsername());
    refreshTokenStore.save(req.getUsername(), refreshToken);

    return Map.of("accessToken", accessToken, "refreshToken", refreshToken);
}
@PostMapping("/reissue")
public Map<String, String> reissue(@RequestHeader("Refresh-Token") String token) {
    String username = jwtTokenProvider.getUsername(token);
    if (!refreshTokenStore.validate(username, token)) throw new RuntimeException("Refresh 불일치");
    String newAccess = jwtTokenProvider.generateAccessToken(username, "ROLE_USER");
    return Map.of("accessToken", newAccess);
}

2️⃣ 비밀번호 암호화 (BCrypt)

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}
@PostMapping("/register")
public String register(@RequestBody AuthRequest req) {
    User user = User.builder()
        .username(req.getUsername())
        .password(passwordEncoder.encode(req.getPassword()))
        .role("ROLE_USER")
        .build();
    userRepository.save(user);
    return "회원가입 완료";
}

3️⃣ 예외 커스터마이징 (JSON 응답)

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    public void commence(HttpServletRequest req, HttpServletResponse res, AuthenticationException ex) throws IOException {
        res.setStatus(401);
        res.setContentType("application/json");
        res.getWriter().write("{ \"error\": \"인증이 필요한 요청입니다.\" }");
    }
}
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    public void handle(HttpServletRequest req, HttpServletResponse res, AccessDeniedException ex) throws IOException {
        res.setStatus(403);
        res.setContentType("application/json");
        res.getWriter().write("{ \"error\": \"접근 권한이 없습니다.\" }");
    }
}
.exceptionHandling(ex -> ex
    .authenticationEntryPoint(authenticationEntryPoint)
    .accessDeniedHandler(accessDeniedHandler)
)

✍ 회고 및 팁

Refresh Token 발급 구조를 직접 구현해보며,
실제 사용자 경험에서 로그인 유지 흐름이 왜 중요한지 체감할 수 있었습니다.
또한, 비밀번호 암호화를 통해 보안의 기본을 다졌고,
예외 커스터마이징으로 API 사용성을 크게 개선할 수 있었습니다.

특히 실무에서는 다음 사항도 꼭 고려해야 합니다:

  • Redis 기반 Refresh Token 저장소
  • Refresh Token Rotation 전략
  • 로그아웃 시 Refresh Token 무효화 처리
  • FilterChain 순서 유의

📚 추가 자료

전체 예제 및 실습 코드는 👉 GitHub - mingstagram/jwt-demo 에 정리되어 있습니다.