Spring Boot로 끝내는 JWT 기반 REST API 보안

Spring Boot로 끝내는 JWT 기반 REST API 보안

Spring Boot로 끝내는 JWT 기반 REST API 보안

서론: 왜 지금 JWT인가

모바일·SPA 중심의 현대 애플리케이션에서 REST API는 무상태(stateless) 통신이 기본입니다. 세션 저장소 의존을 최소화하고 수평 확장을 용이하게 하는 토큰 기반 인증의 표준이 바로 JWT(JSON Web Token)입니다. 하지만 JWT는 만료·재발급·키 관리까지 전 주기를 설계하지 않으면 보안의 구멍이 되기 쉽습니다. 이 글은 Spring BootSpring Security를 사용해 JAVA로 JWT 인증/인가를 통합하는 법을 소개하고, 실무에서 마주치는 위협과 대응, 성능 및 운영 팁을 제공합니다.

JWT의 기본 개념과 구조

  • 형태: 헤더(Header)·페이로드(Payload)·서명(Signature) 3부 구성. 헤더에는 알고리즘(HS256/RS256), kid 등이, 페이로드에는 표준 클레임(iss, sub, aud, exp, iat)과 커스텀 클레임(roles, scopes)이 담깁니다.
  • 특징: 서버 세션 상태 없이도 권한 전파가 가능하여 수평 확장에 유리합니다. 단, 만료/철회 전략을 반드시 갖춰야 안전합니다.
  • 전달: 보편적으로 Authorization: Bearer <token> 헤더를 사용합니다(쿠키 사용 시 HttpOnly, Secure, SameSite 설정 필수).

Spring Security와 JWT 통합: 단계별 로드맵

  1. 보안 파이프라인 구성: SecurityFilterChainSTATELESS로 설정하고, /auth/**는 개방, 그 외는 보호.
  2. 토큰 발급: 로그인 성공 시 짧은 Access(예: 15분)와 긴 Refresh(예: 14일) 발급.
  3. 요청 검증: 커스텀 JwtAuthenticationFilter가 토큰을 검증하고 SecurityContext에 인증 주입.
  4. 인가 설계: URI × HTTP METHOD × ROLE/SCOPE 매트릭스로 권한을 명확히 정의하고 @PreAuthorize로 보강.
  5. 운영: 실패율/지연시간/재발급 성공률 모니터링, 키 롤테이션(kid+JWKS) 정례화.

실제 구현 코드 예시 (Java)

```

1) Security 설정

@Configuration
```

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthenticationFilter;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/\*\*","/actuator/health").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(e -> e
.authenticationEntryPoint((req,res,ex)-> { res.setStatus(401); })
.accessDeniedHandler((req,res,ex)-> { res.setStatus(403); })
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}

@Bean
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
}
```

2) 토큰 유틸리티 (예: HS256, 실무에선 RS256+JWKS 권장)

@Component
```

public class JwtProvider {

private static final String SECRET = "change-this-to-strong-secret"; // 환경변수/키관리로 외부화
private static final long ACCESS\_TTL\_MS = 15 \* 60 \* 1000L;

public String generateAccessToken(UserDetails user) {
Date now = new Date();
Date exp = new Date(now\.getTime() + ACCESS\_TTL\_MS);
return Jwts.builder()
.setSubject(user.getUsername())
.claim("roles", user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList())
.setIssuedAt(now)
.setExpiration(exp)
.signWith(Keys.hmacShaKeyFor(SECRET.getBytes()), SignatureAlgorithm.HS256)
.compact();
}

public Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(SECRET.getBytes()))
.build()
.parseClaimsJws(token)
.getBody();
}
}
```

3) 요청 검증 필터

@Component
```

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtProvider jwtProvider;
private final UserDetailsService userDetailsService;

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
Claims claims = jwtProvider.parseClaims(token);
String username = claims.getSubject();
UserDetails user = userDetailsService.loadUserByUsername(username);

```
    UsernamePasswordAuthenticationToken auth =
      new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
    auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
    SecurityContextHolder.getContext().setAuthentication(auth);

  } catch (JwtException | IllegalArgumentException ex) {
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    return;
  }
}
filterChain.doFilter(request, response);
```

}
}
```
참고: 로그인 컨트롤러는 인증 성공 시 Access/Refresh를 함께 반환하고, Refresh는 DB/Redis에 저장해 재사용 탐지 및 강제 로그아웃(철회)에 활용하세요.
```

잘 알려진 보안 위협과 대응

  • 토큰 탈취(재사용): 짧은 Access, 긴 Refresh, Refresh 저장소 운영(블랙리스트 또는 issuedAfter 타임스탬프)로 차단.
  • 알고리즘 혼동 공격: 서버가 허용하는 알고리즘을 고정(예: RS256만)하고, alg=none 거부.
  • 서명 키 유출: kid 기반 키 롤테이션, HSM/KMS와 비공개 비밀 관리. 유출 가정하 훈련(runbook) 준비.
  • CORS 오구성: Origin 화이트리스트, 자격증명 플래그 제한, 민감 엔드포인트는 크로스 도메인 최소화.
  • Brute Force: 로그인/재발급 엔드포인트에 Rate Limit, 캡차/지연 삽입.

보안 Best Practices

  • 무상태 세션 정책 + 표준화된 401/403 에러 바디로 클라이언트 처리 일관성 확보.
  • 권한 매트릭스를 문서화(URI×METHOD×ROLE/SCOPE)하고 회귀 테스트에 포함.
  • 로그 공통 필드: timestamp, traceId, userId/sub, kid, aud, uri, status, latencyMs.
  • 운영 지표: 인증 실패율, 재발급 성공률, 95/99p 지연, kid별 검증 실패 분포.
  • 가능하면 RS256/ES256 + JWKS 공개로 키 교체를 무중단화.

성능 고려사항

  • JWK(JWKS)와 공개키를 캐시하여 서명 검증 비용을 절감(만료 전 갱신).
  • 필터 내 DB 조회 최소화: 토큰에 필요한 최소 클레임만 담고, 세부 정보는 lazy fetch 또는 캐시.
  • 직렬화 비용을 줄이기 위해 ObjectMapper 재사용, 스레드 안전한 키 객체 재사용.
  • 프로파일링으로 병목 파악: 로그인/재발급 엔드포인트의 95p 레이턴시를 별도 모니터링.

개발자가 흔히 하는 실수와 해결 방법

  • Access를 과도하게 길게 설정 → 5~30분 권장, 대신 Refresh로 사용자 경험 보완.
  • 쿼리스트링으로 토큰 전달 → URL 로그/리퍼러 유출 위험. 반드시 Authorization 헤더 또는 HttpOnly 쿠키.
  • 시간 동기화 미흡 → 서버 간 NTP 동기화 및 clockSkew 허용.
  • CORS * 남발 → 필요한 도메인만 허용, 메서드/헤더 스코프 최소화.
  • 비밀키 하드코딩 → 환경변수/시크릿 매니저 사용, 저장소에 커밋 금지.

결론: 요약과 다음 단계

우리는 Spring BootSpring SecurityJWT 기반 REST API 보안을 설계부터 운영까지 살펴봤습니다. 핵심은 STATELESS 구성, 짧은 Access/긴 Refresh, 권한 매트릭스, 키 롤테이션, 로그·지표입니다. 다음 단계로는 OIDC(외부 IdP) 연동, JWKS 캐싱 도입, 재발급/키 교체 리허설 자동화를 추천합니다. 더 깊게 보려면 RFC 7519(JWT), Spring Security Reference, OAuth 2.1 BCP 문서를 함께 읽어보세요.

댓글

이 블로그의 인기 게시물

안전하고 효율적인 API 인증: Spring Boot JWT 통합 가이드

안전한 서비스의 문을 여는 열쇠: 인증과 인가 기초