Spring Boot로 끝내는 JWT 기반 REST API 보안
Spring Boot로 끝내는 JWT 기반 REST API 보안
서론: 왜 지금 JWT인가
모바일·SPA 중심의 현대 애플리케이션에서 REST API는 무상태(stateless) 통신이 기본입니다. 세션 저장소 의존을 최소화하고 수평 확장을 용이하게 하는 토큰 기반 인증의 표준이 바로 JWT(JSON Web Token)입니다. 하지만 JWT는 만료·재발급·키 관리까지 전 주기를 설계하지 않으면 보안의 구멍이 되기 쉽습니다. 이 글은 Spring Boot와 Spring 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 통합: 단계별 로드맵
- 보안 파이프라인 구성:
SecurityFilterChain을 STATELESS로 설정하고,/auth/**는 개방, 그 외는 보호. - 토큰 발급: 로그인 성공 시 짧은 Access(예: 15분)와 긴 Refresh(예: 14일) 발급.
- 요청 검증: 커스텀
JwtAuthenticationFilter가 토큰을 검증하고SecurityContext에 인증 주입. - 인가 설계:
URI × HTTP METHOD × ROLE/SCOPE매트릭스로 권한을 명확히 정의하고@PreAuthorize로 보강. - 운영: 실패율/지연시간/재발급 성공률 모니터링, 키 롤테이션(
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 Boot와 Spring Security로 JWT 기반 REST API 보안을 설계부터 운영까지 살펴봤습니다. 핵심은 STATELESS 구성, 짧은 Access/긴 Refresh, 권한 매트릭스, 키 롤테이션, 로그·지표입니다. 다음 단계로는 OIDC(외부 IdP) 연동, JWKS 캐싱 도입, 재발급/키 교체 리허설 자동화를 추천합니다. 더 깊게 보려면 RFC 7519(JWT), Spring Security Reference, OAuth 2.1 BCP 문서를 함께 읽어보세요.
댓글
댓글 쓰기