본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 21. · 4 Views
Spring Cloud Gateway 필터와 인증 완벽 가이드
Spring Cloud Gateway의 필터 체계를 이해하고 실무에서 JWT 인증을 구현하는 방법을 배웁니다. GatewayFilter와 GlobalFilter의 차이부터 속도 제한까지 완벽하게 설명합니다.
목차
1. GatewayFilter 종류
어느 날 김개발 씨가 마이크로서비스 프로젝트에 투입되었습니다. 팀장님이 말씀하셨습니다.
"Gateway에 로깅 기능을 추가해야 하는데, GatewayFilter를 사용하면 됩니다." 김개발 씨는 고개를 끄덕였지만, 사실 무슨 말인지 잘 몰랐습니다.
GatewayFilter는 특정 라우트에만 적용되는 필터입니다. 마치 특정 출입구에만 설치된 보안 검색대와 같습니다.
AddRequestHeader, AddResponseHeader, RewritePath 등 다양한 종류가 있으며, 각각 고유한 역할을 수행합니다. 필요에 따라 여러 필터를 조합해서 사용할 수 있습니다.
다음 코드를 살펴봅시다.
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/api/users/**
filters:
# 요청 헤더에 인증 정보 추가
- AddRequestHeader=X-Request-Source, Gateway
# 응답 헤더 추가
- AddResponseHeader=X-Response-Time, ${responseTime}
# 경로 재작성 (/api/users/123 -> /users/123)
- RewritePath=/api/(?<segment>.*), /$\{segment}
김개발 씨는 입사 4개월 차 주니어 개발자입니다. 회사에서 마이크로서비스 아키텍처로 전환하는 큰 프로젝트를 진행 중인데, 김개발 씨도 이 프로젝트에 투입되었습니다.
팀장님이 첫 업무를 주셨습니다. "Gateway에 요청 헤더를 추가하는 기능을 만들어보세요." 김개발 씨는 Spring Cloud Gateway 문서를 열어봤지만, 너무 많은 종류의 필터가 있어서 어디서부터 시작해야 할지 막막했습니다.
옆자리 박시니어 씨에게 물어봤습니다. "선배님, GatewayFilter가 뭔가요?" 그렇다면 GatewayFilter란 정확히 무엇일까요?
쉽게 비유하자면, GatewayFilter는 마치 공항의 특정 게이트에만 설치된 보안 검색대와 같습니다. A 게이트에는 엄격한 검색대가 있고, B 게이트에는 간단한 검색대가 있듯이, 각 라우트마다 다른 필터를 적용할 수 있습니다.
이처럼 GatewayFilter도 특정 경로에 대해서만 작동하는 기능을 담당합니다. 필터가 없던 시절에는 어땠을까요?
각 마이크로서비스마다 동일한 로직을 반복해서 구현해야 했습니다. 헤더를 추가하는 코드, 경로를 변환하는 코드, 로깅하는 코드를 모든 서비스에 복사해서 붙여넣었습니다.
더 큰 문제는 정책이 바뀔 때였습니다. 10개의 서비스가 있다면 10곳을 모두 수정해야 했으니까요.
바로 이런 문제를 해결하기 위해 GatewayFilter가 등장했습니다. GatewayFilter를 사용하면 공통 로직을 한곳에 모을 수 있습니다.
또한 라우트마다 다른 정책을 적용할 수 있어 유연성이 높아집니다. 무엇보다 코드 중복을 제거하고 유지보수가 쉬워진다는 큰 이점이 있습니다.
Spring Cloud Gateway는 기본적으로 30가지가 넘는 필터를 제공합니다. 가장 많이 사용하는 AddRequestHeader는 요청에 헤더를 추가합니다.
예를 들어 모든 요청에 "이 요청은 Gateway를 통과했다"는 정보를 심어놓을 수 있습니다. AddResponseHeader는 반대로 응답에 헤더를 추가합니다.
캐시 정책이나 보안 정책을 응답 헤더에 담을 때 유용합니다. RewritePath 필터는 실무에서 정말 자주 사용됩니다.
외부에는 /api/users/123이라는 URL로 공개하지만, 내부 서비스는 /users/123으로 처리하고 싶을 때가 있습니다. 이럴 때 RewritePath를 사용하면 경로를 자동으로 변환해줍니다.
정규식을 지원하기 때문에 복잡한 패턴도 처리할 수 있습니다. StripPrefix도 유용합니다.
예를 들어 /api/v1/users라는 요청이 들어왔을 때, /api/v1 부분을 제거하고 /users만 전달하고 싶다면 StripPrefix=2를 설정하면 됩니다. 숫자는 제거할 경로 세그먼트의 개수를 의미합니다.
여러 필터를 조합할 수도 있습니다. 위의 코드를 보면 AddRequestHeader, AddResponseHeader, RewritePath 세 가지 필터를 동시에 사용하고 있습니다.
필터는 설정된 순서대로 실행되기 때문에, 순서가 중요한 경우에는 주의해야 합니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 쇼핑몰 서비스를 개발한다고 가정해봅시다. 주문 서비스는 엄격한 인증이 필요하지만, 상품 조회 서비스는 간단한 로깅만 필요합니다.
이럴 때 각 라우트마다 다른 GatewayFilter를 적용하면 효율적으로 관리할 수 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 너무 많은 필터를 적용하는 것입니다. 필터가 많아질수록 요청 처리 시간이 길어지고, 디버깅도 어려워집니다.
따라서 정말 필요한 필터만 선택적으로 사용해야 합니다. 박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다.
"아, 그래서 라우트마다 filters 섹션이 있었군요!" GatewayFilter를 제대로 이해하면 마이크로서비스 간 통신을 훨씬 효율적으로 관리할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 필터 순서가 중요한 경우 명시적으로 order를 지정하세요
- 정규식을 사용할 때는 테스트 케이스를 꼭 작성하세요
- 성능이 중요하다면 필터 개수를 최소화하세요
2. GlobalFilter 구현
김개발 씨가 GatewayFilter를 열심히 공부하던 중, 팀장님이 새로운 요구사항을 주셨습니다. "모든 요청에 대해 요청 시간을 기록해야 해요.
각 라우트마다 필터를 추가하는 건 비효율적이니까, GlobalFilter를 만들어보세요." 김개발 씨는 또 새로운 개념에 당황했습니다.
GlobalFilter는 모든 라우트에 자동으로 적용되는 필터입니다. 마치 공항 입구의 메인 보안 검색대처럼, 모든 승객이 반드시 거쳐가는 지점입니다.
Ordered 인터페이스를 구현하여 실행 순서를 제어할 수 있습니다. 커스텀 GlobalFilter를 만들어서 전역 로직을 한곳에 집중시킬 수 있습니다.
다음 코드를 살펴봅시다.
@Component
public class GlobalLoggingFilter implements GlobalFilter, Ordered {
private static final Logger log = LoggerFactory.getLogger(GlobalLoggingFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 요청 시작 시간 기록
long startTime = System.currentTimeMillis();
String path = exchange.getRequest().getPath().toString();
log.info("Request started: {}", path);
// 다음 필터 체인 실행 후 응답 처리
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
long endTime = System.currentTimeMillis();
log.info("Request completed: {} ({}ms)", path, endTime - startTime);
}));
}
@Override
public int getOrder() {
// 낮은 숫자일수록 먼저 실행됨
return -1;
}
}
김개발 씨는 어제 배운 GatewayFilter로 몇 가지 기능을 성공적으로 구현했습니다. 하지만 새로운 문제가 생겼습니다.
10개의 라우트 모두에 동일한 로깅 필터를 추가하다 보니, 설정 파일이 너무 길어졌습니다. "이렇게 반복적인 작업을 계속해야 하나?" 고민하던 중, 박시니어 씨가 지나가다 말했습니다.
"GlobalFilter를 쓰면 한 번만 작성하면 돼요." 그렇다면 GlobalFilter란 정확히 무엇일까요? 쉽게 비유하자면, GlobalFilter는 마치 아파트 단지의 정문 경비실과 같습니다.
어느 동으로 가든 무조건 정문을 거쳐야 하듯이, 모든 라우트가 GlobalFilter를 거쳐갑니다. GatewayFilter가 각 동의 입구 경비라면, GlobalFilter는 단지 전체를 관리하는 메인 경비인 셈입니다.
GlobalFilter가 없던 시절에는 어땠을까요? 공통 로직을 각 라우트마다 반복해서 설정해야 했습니다.
예를 들어 모든 요청의 처리 시간을 측정하고 싶다면, 10개 라우트가 있으면 10번 동일한 필터를 추가해야 했습니다. 나중에 로직을 수정하려면 10곳을 모두 찾아서 바꿔야 했죠.
바로 이런 문제를 해결하기 위해 GlobalFilter가 등장했습니다. GlobalFilter를 사용하면 한 번만 작성하면 모든 라우트에 자동으로 적용됩니다.
또한 @Component로 등록하기 때문에 Spring의 의존성 주입을 활용할 수 있습니다. 무엇보다 전역 로직을 한곳에 모아두니 유지보수가 훨씬 쉬워집니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 GlobalFilter 인터페이스를 구현합니다.
이것이 핵심입니다. Ordered 인터페이스도 함께 구현하면 필터의 실행 순서를 제어할 수 있습니다.
filter 메서드 안에서 요청 시작 시간을 기록하고, chain.filter()를 호출하여 다음 필터로 넘어갑니다. Mono와 then을 사용하는 부분이 중요합니다.
Spring Cloud Gateway는 리액티브 프로그래밍을 사용합니다. chain.filter()가 반환하는 Mono는 응답이 완료되었을 때 완료됩니다.
then()을 사용하면 응답이 완료된 후에 추가 작업을 할 수 있습니다. 위 코드에서는 요청 종료 시간을 기록하고 소요 시간을 계산합니다.
getOrder 메서드는 필터의 실행 순서를 결정합니다. 숫자가 낮을수록 먼저 실행됩니다.
-1을 반환하면 대부분의 다른 필터보다 먼저 실행됩니다. 만약 인증 필터보다 로깅 필터가 먼저 실행되어야 한다면, 로깅 필터의 order를 더 낮게 설정하면 됩니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 전자상거래 플랫폼을 운영한다고 가정해봅시다.
모든 API 호출에 대해 요청 추적 ID를 생성하고, 응답 시간을 모니터링하고, 에러 발생 시 알림을 보내는 기능이 필요합니다. 이런 공통 기능을 GlobalFilter로 구현하면 모든 서비스에 일관되게 적용할 수 있습니다.
여러 개의 GlobalFilter를 만들 수도 있습니다. 로깅용 GlobalFilter, 인증용 GlobalFilter, 모니터링용 GlobalFilter 등 역할별로 분리하면 코드가 깔끔해집니다.
각 필터의 order 값을 다르게 설정하여 실행 순서를 제어할 수 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 GlobalFilter에서 블로킹 작업을 하는 것입니다. Gateway는 리액티브 방식으로 동작하기 때문에, Thread.sleep()이나 블로킹 I/O를 사용하면 성능이 크게 저하됩니다.
따라서 모든 작업을 논블로킹 방식으로 처리해야 합니다. 박시니어 씨의 설명을 들은 김개발 씨는 눈이 반짝였습니다.
"아, 그러면 10개 라우트에 일일이 추가할 필요가 없겠네요!" GlobalFilter를 제대로 이해하면 중복 코드를 제거하고 깔끔한 아키텍처를 만들 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - order 값은 명확한 기준을 가지고 설정하세요 (예: 로깅 -10, 인증 0, 비즈니스 10)
- GlobalFilter에서는 절대 블로킹 작업을 하지 마세요
- 너무 많은 GlobalFilter는 성능 저하를 일으킬 수 있으니 꼭 필요한 것만 만드세요
3. 요청 로깅 필터
김개발 씨가 GlobalFilter의 개념을 이해하고 나니, 실전 과제가 주어졌습니다. "운영팀에서 모든 API 요청을 추적할 수 있는 로깅 시스템을 만들어달라고 했어요.
요청 정보를 자세히 기록하는 필터를 만들어보세요." 어디서부터 시작해야 할까요?
요청 로깅 필터는 들어오는 모든 요청의 상세 정보를 기록하는 필터입니다. 마치 건물 출입 기록부처럼, 누가 언제 어디로 들어왔는지 모두 기록합니다.
ServerHttpRequest에서 경로, 메서드, 헤더, 쿼리 파라미터 등을 추출하여 로그로 남깁니다. 운영 환경에서 문제 추적과 모니터링에 필수적인 기능입니다.
다음 코드를 살펴봅시다.
@Component
public class RequestLoggingFilter implements GlobalFilter, Ordered {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// 요청 ID 생성 (추적용)
String requestId = UUID.randomUUID().toString();
// 상세 요청 정보 로깅
log.info("====== Request Start [{}] ======", requestId);
log.info("Method: {}", request.getMethod());
log.info("Path: {}", request.getPath());
log.info("Query: {}", request.getQueryParams());
log.info("Headers: {}", request.getHeaders().toSingleValueMap());
log.info("Remote Address: {}", request.getRemoteAddress());
// 요청 ID를 헤더에 추가하여 downstream 서비스로 전달
ServerHttpRequest mutatedRequest = request.mutate()
.header("X-Request-ID", requestId)
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build())
.doFinally(signalType -> {
log.info("====== Request End [{}] - {} ======", requestId, signalType);
});
}
@Override
public int getOrder() {
return -10; // 가장 먼저 실행
}
}
김개발 씨는 이제 GlobalFilter를 만들 줄 알게 되었습니다. 하지만 막상 "요청을 로깅하라"는 과제를 받으니 막막했습니다.
어떤 정보를 기록해야 할까요? 어떻게 기록해야 나중에 문제를 추적할 수 있을까요?
고민하던 김개발 씨는 박시니어 씨가 작성한 기존 코드를 열어봤습니다. 그렇다면 요청 로깅 필터는 정확히 무엇을 기록해야 할까요?
쉽게 비유하자면, 요청 로깅은 마치 택배 물류 시스템의 추적 기록과 같습니다. 택배가 언제 어디서 출발했고, 어느 경로를 거쳐, 몇 시에 도착했는지 모두 기록됩니다.
나중에 문제가 생기면 이 기록을 보고 어디서 문제가 생겼는지 추적할 수 있습니다. 요청 로깅도 마찬가지로 API 호출의 모든 과정을 기록하는 역할을 합니다.
로깅이 없던 시절에는 어땠을까요? 사용자가 "결제가 안 돼요"라고 신고하면, 개발자는 막막했습니다.
어떤 요청이 들어왔는지, 어느 서비스에서 실패했는지 알 수 없었으니까요. 로그를 찾으려면 10개 마이크로서비스를 모두 뒤져야 했고, 대부분 시간 낭비였습니다.
바로 이런 문제를 해결하기 위해 요청 로깅 필터가 필수가 되었습니다. 요청 로깅 필터를 사용하면 모든 요청에 대해 일관된 형식으로 로그가 남습니다.
또한 요청 ID를 생성하여 분산 시스템에서도 하나의 요청을 추적할 수 있습니다. 무엇보다 문제가 발생했을 때 빠르게 원인을 파악할 수 있다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 UUID로 고유한 요청 ID를 생성합니다.
이 ID는 이 요청이 어떤 서비스를 거쳐가든 계속 따라다닙니다. ServerHttpRequest에서 메서드, 경로, 쿼리 파라미터, 헤더 등 모든 정보를 추출합니다.
이 정보들이 나중에 문제를 해결하는 열쇠가 됩니다. request.mutate() 부분이 흥미롭습니다.
ServerHttpRequest는 불변 객체라서 직접 수정할 수 없습니다. mutate()를 사용하면 빌더 패턴으로 새로운 요청 객체를 만들 수 있습니다.
여기서는 X-Request-ID 헤더를 추가하여 하위 서비스들도 이 요청 ID를 사용할 수 있도록 했습니다. doFinally는 응답이 완료된 후 실행됩니다.
성공이든 실패든 에러든 상관없이 무조건 실행됩니다. signalType 파라미터로 정상 종료인지 취소인지 에러인지 알 수 있습니다.
이를 활용하면 요청의 최종 결과까지 기록할 수 있습니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 핀테크 서비스를 운영한다고 가정해봅시다. 사용자가 "계좌이체가 안 돼요"라고 신고하면, 요청 ID로 전체 과정을 추적합니다.
Gateway 로그, 인증 서비스 로그, 계좌 서비스 로그를 모두 확인하여 어디서 실패했는지 정확히 파악할 수 있습니다. 프로덕션 환경에서는 더 많은 정보를 기록합니다.
사용자 IP, 사용자 에이전트, 요청 본문의 크기, 응답 상태 코드, 응답 시간 등을 모두 기록합니다. 다만 민감한 정보는 마스킹해야 합니다.
비밀번호나 신용카드 번호가 로그에 그대로 남으면 보안 사고입니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 요청 본문을 통째로 로깅하는 것입니다. 요청 본문을 읽으면 스트림이 소비되어 실제 서비스에서 본문을 읽을 수 없게 됩니다.
따라서 본문을 로깅하려면 별도의 데코레이터 패턴을 사용해야 합니다. 박시니어 씨의 코드를 본 김개발 씨는 감탄했습니다.
"아, 요청 ID를 헤더에 넣어서 전달하는 거였군요!" 요청 로깅 필터를 제대로 구현하면 운영 환경에서 문제 해결 시간을 획기적으로 줄일 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 민감한 정보(비밀번호, 토큰 등)는 절대 로깅하지 마세요
- 요청 ID는 UUID 대신 Snowflake ID 같은 분산 ID 생성기를 사용하면 더 좋습니다
- 로그 레벨을 환경별로 다르게 설정하세요 (개발: DEBUG, 운영: INFO)
4. JWT 토큰 검증
김개발 씨가 로깅 필터를 성공적으로 구현하고 나니, 더 어려운 과제가 기다리고 있었습니다. "이제 인증 기능을 추가해야 해요.
JWT 토큰을 검증하는 필터를 만들어주세요." 김개발 씨는 JWT에 대해 들어본 적은 있지만, 실제로 구현해본 적은 없었습니다.
JWT 토큰 검증은 클라이언트가 보낸 토큰이 유효한지 확인하는 과정입니다. 마치 공연장 입구에서 티켓의 진위를 확인하는 것과 같습니다.
서명 검증, 만료 시간 확인, 클레임 검증을 통해 토큰의 무결성을 보장합니다. 검증에 실패하면 401 Unauthorized 응답을 반환하여 접근을 차단합니다.
다음 코드를 살펴봅시다.
@Component
@RequiredArgsConstructor
public class JwtValidationFilter implements GlobalFilter, Ordered {
private static final Logger log = LoggerFactory.getLogger(JwtValidationFilter.class);
private final JwtTokenProvider jwtTokenProvider;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// Authorization 헤더에서 토큰 추출
String token = extractToken(request);
if (token == null) {
return onError(exchange, "Token not found", HttpStatus.UNAUTHORIZED);
}
try {
// JWT 토큰 검증 및 파싱
Claims claims = jwtTokenProvider.validateAndGetClaims(token);
// 사용자 정보를 헤더에 추가하여 downstream으로 전달
ServerHttpRequest mutatedRequest = request.mutate()
.header("X-User-Id", claims.getSubject())
.header("X-User-Role", claims.get("role", String.class))
.build();
log.info("JWT validated for user: {}", claims.getSubject());
return chain.filter(exchange.mutate().request(mutatedRequest).build());
} catch (ExpiredJwtException e) {
return onError(exchange, "Token expired", HttpStatus.UNAUTHORIZED);
} catch (JwtException e) {
return onError(exchange, "Invalid token", HttpStatus.UNAUTHORIZED);
}
}
private String extractToken(ServerHttpRequest request) {
String bearerToken = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
private Mono<Void> onError(ServerWebExchange exchange, String message, HttpStatus status) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(status);
log.warn("JWT validation failed: {}", message);
return response.setComplete();
}
@Override
public int getOrder() {
return 0;
}
}
김개발 씨는 인증에 대해 배운 적이 있었습니다. 하지만 이론과 실무는 달랐습니다.
"JWT 토큰을 검증한다"는 말은 들어봤지만, 실제로 어떻게 검증하는지, 어떤 예외 상황을 처리해야 하는지 막막했습니다. 박시니어 씨에게 물어봤습니다.
"선배님, JWT 검증이 정확히 뭐예요?" 그렇다면 JWT 토큰 검증이란 정확히 무엇일까요? 쉽게 비유하자면, JWT 검증은 마치 위조지폐 감별기와 같습니다.
지폐의 워터마크, 홀로그램, 일련번호를 확인하여 진짜인지 가짜인지 판별합니다. JWT도 마찬가지로 서명, 만료 시간, 발행자 등을 확인하여 신뢰할 수 있는 토큰인지 검증합니다.
토큰 검증이 없던 시절에는 어땠을까요? 누구나 마음대로 사용자 정보를 조작할 수 있었습니다.
"나는 관리자다"라고 주장하면 그대로 믿었으니까요. 해커들은 이를 악용하여 다른 사람의 계정에 접근하거나 권한을 상승시켰습니다.
보안이 무너지면 서비스 전체가 무너집니다. 바로 이런 문제를 해결하기 위해 JWT 토큰 검증이 필수가 되었습니다.
토큰 검증을 통해 요청이 신뢰할 수 있는 출처에서 왔는지 확인할 수 있습니다. 또한 토큰이 변조되지 않았는지 서명을 통해 보장받습니다.
무엇보다 만료 시간을 확인하여 오래된 토큰은 자동으로 거부할 수 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 Authorization 헤더에서 토큰을 추출합니다. 일반적으로 "Bearer {token}" 형식으로 전달되기 때문에 "Bearer " 접두사를 제거합니다.
토큰이 없으면 즉시 401 응답을 반환합니다. jwtTokenProvider.validateAndGetClaims 메서드가 핵심입니다.
이 메서드는 토큰의 서명을 검증하고, 만료 시간을 확인하고, 클레임을 파싱하여 반환합니다. 내부적으로는 비밀키로 서명을 재계산하여 토큰의 서명과 일치하는지 확인합니다.
조금이라도 다르면 토큰이 변조된 것입니다. ExpiredJwtException과 JwtException을 분리하여 처리합니다.
토큰이 만료된 경우와 서명이 잘못된 경우는 다른 상황입니다. 만료된 경우 클라이언트에게 "토큰을 갱신하세요"라고 알려줄 수 있습니다.
서명이 잘못된 경우는 해킹 시도일 가능성이 높으므로 경고 로그를 남깁니다. 검증에 성공하면 사용자 정보를 헤더에 추가합니다.
X-User-Id와 X-User-Role 헤더를 추가하여 하위 서비스들이 사용자 정보를 알 수 있도록 합니다. 하위 서비스는 JWT를 다시 파싱할 필요 없이 헤더만 읽으면 됩니다.
이렇게 하면 성능도 좋아지고 코드도 간결해집니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 SNS 서비스를 운영한다고 가정해봅시다. 사용자가 게시글을 작성할 때 JWT 토큰을 함께 보냅니다.
Gateway가 토큰을 검증하여 실제 로그인한 사용자인지 확인합니다. 검증에 성공하면 게시글 서비스로 요청을 전달하고, 실패하면 로그인 페이지로 리다이렉트합니다.
토큰 갱신 로직도 중요합니다. JWT는 일반적으로 짧은 유효 시간을 갖습니다.
15분에서 1시간 정도입니다. 만료되면 Refresh Token으로 새로운 Access Token을 발급받습니다.
Gateway에서는 Access Token만 검증하고, Refresh Token 처리는 인증 서비스가 담당합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 비밀키를 코드에 하드코딩하는 것입니다. 비밀키가 노출되면 누구나 유효한 토큰을 만들 수 있습니다.
따라서 환경 변수나 Key Management Service를 사용하여 비밀키를 안전하게 관리해야 합니다. 박시니어 씨의 설명을 들은 김개발 씨는 이제 이해가 되었습니다.
"아, 서명 검증이 이렇게 중요한 거였군요!" JWT 토큰 검증을 제대로 구현하면 안전하고 확장 가능한 인증 시스템을 만들 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 비밀키는 절대 코드에 하드코딩하지 말고 환경 변수로 관리하세요
- 토큰 만료 시간은 보안과 사용자 경험 사이의 균형을 고려하여 설정하세요
- Refresh Token은 Access Token보다 훨씬 긴 유효 시간을 가져야 합니다
5. 인증 필터 구현
김개발 씨가 JWT 검증 필터를 성공적으로 만들었지만, 새로운 요구사항이 생겼습니다. "모든 API에 인증이 필요한 건 아니에요.
회원가입이나 로그인은 인증 없이 접근할 수 있어야 하죠. 경로별로 인증 여부를 제어하는 필터를 만들어보세요." 김개발 씨는 어떻게 구현해야 할지 고민에 빠졌습니다.
인증 필터는 특정 경로에만 선택적으로 인증을 적용하는 필터입니다. 마치 건물의 층별 출입 통제처럼, 로비는 누구나 들어갈 수 있지만 사무실은 직원만 들어갈 수 있습니다.
화이트리스트로 인증이 필요 없는 경로를 정의하고, 나머지는 모두 인증을 요구합니다. 유연한 접근 제어로 보안과 사용성의 균형을 맞춥니다.
다음 코드를 살펴봅시다.
@Component
@RequiredArgsConstructor
public class AuthenticationFilter implements GlobalFilter, Ordered {
private static final Logger log = LoggerFactory.getLogger(AuthenticationFilter.class);
private final JwtTokenProvider jwtTokenProvider;
// 인증이 필요 없는 경로들 (화이트리스트)
private static final List<String> PUBLIC_PATHS = Arrays.asList(
"/api/auth/login",
"/api/auth/signup",
"/api/public/**",
"/actuator/health"
);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getPath().toString();
// 공개 경로인지 확인
if (isPublicPath(path)) {
log.debug("Public path accessed: {}", path);
return chain.filter(exchange);
}
// 인증 필요 - JWT 토큰 검증
String token = extractToken(exchange.getRequest());
if (token == null) {
return unauthorizedResponse(exchange, "Missing authentication token");
}
try {
Claims claims = jwtTokenProvider.validateAndGetClaims(token);
// 인증 정보를 exchange에 저장 (downstream에서 사용 가능)
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.header("X-Auth-User-Id", claims.getSubject())
.header("X-Auth-User-Role", claims.get("role", String.class))
.header("X-Auth-User-Email", claims.get("email", String.class))
.build();
log.info("User authenticated: {} for path: {}", claims.getSubject(), path);
return chain.filter(exchange.mutate().request(mutatedRequest).build());
} catch (Exception e) {
log.warn("Authentication failed for path: {} - {}", path, e.getMessage());
return unauthorizedResponse(exchange, "Invalid or expired token");
}
}
private boolean isPublicPath(String path) {
return PUBLIC_PATHS.stream()
.anyMatch(pattern -> pathMatches(path, pattern));
}
private boolean pathMatches(String path, String pattern) {
// 간단한 와일드카드 매칭 (실제로는 AntPathMatcher 사용 권장)
if (pattern.endsWith("/**")) {
String prefix = pattern.substring(0, pattern.length() - 3);
return path.startsWith(prefix);
}
return path.equals(pattern);
}
private String extractToken(ServerHttpRequest request) {
String bearerToken = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, String message) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
String body = String.format("{\"error\": \"Unauthorized\", \"message\": \"%s\"}", message);
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return 0;
}
}
김개발 씨는 JWT 검증 로직을 완벽하게 구현했습니다. 하지만 테스트를 해보니 문제가 생겼습니다.
회원가입 API도 토큰을 요구하는 것입니다. "회원가입할 때는 아직 토큰이 없는데..." 김개발 씨는 당황했습니다.
박시니어 씨가 웃으며 말했습니다. "당연하죠.
공개 경로를 따로 설정해야죠." 그렇다면 인증 필터는 어떻게 경로를 구분해야 할까요? 쉽게 비유하자면, 인증 필터는 마치 백화점의 출입 시스템과 같습니다.
1층 로비와 푸드코트는 누구나 들어갈 수 있지만, VIP 라운지는 회원 카드가 있어야 들어갈 수 있습니다. 출입구마다 다른 정책을 적용하는 것처럼, API 경로마다 다른 인증 정책을 적용하는 것입니다.
모든 경로에 인증을 강제하면 어떻게 될까요? 사용자가 회원가입을 하려고 해도 토큰이 없어서 막힙니다.
로그인을 하려고 해도 토큰이 없어서 막힙니다. 이건 닭이 먼저냐 달걀이 먼저냐의 문제입니다.
토큰을 받으려면 로그인해야 하는데, 로그인하려면 토큰이 필요한 상황이니까요. 바로 이런 문제를 해결하기 위해 화이트리스트 개념이 필요합니다.
화이트리스트에 등록된 경로는 인증 없이 접근할 수 있습니다. 회원가입, 로그인, 비밀번호 찾기 같은 기본 기능들이 여기에 속합니다.
또한 헬스 체크나 메트릭 조회 같은 운영 API도 화이트리스트에 포함됩니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 PUBLIC_PATHS 상수로 화이트리스트를 정의합니다. /api/auth/** 패턴을 사용하면 인증 관련 모든 경로를 한 번에 허용할 수 있습니다.
isPublicPath 메서드로 현재 요청 경로가 화이트리스트에 있는지 확인합니다. 와일드카드 매칭이 중요합니다.
/api/public/** 패턴은 /api/public/으로 시작하는 모든 경로를 의미합니다. 코드에서는 간단하게 구현했지만, 실제로는 Spring의 AntPathMatcher를 사용하는 것이 좋습니다.
더 복잡한 패턴도 지원하고, 테스트도 충분히 되었기 때문입니다. 공개 경로가 아니면 JWT 검증을 수행합니다.
이 부분은 앞에서 배운 JWT 검증 로직과 동일합니다. 차이점은 검증에 성공하면 더 많은 정보를 헤더에 추가한다는 것입니다.
사용자 ID뿐만 아니라 역할과 이메일까지 전달하여 하위 서비스가 풍부한 컨텍스트를 갖도록 합니다. unauthorizedResponse 메서드는 JSON 형태의 에러 응답을 반환합니다.
단순히 401 상태 코드만 반환하는 것보다, 에러 메시지를 포함한 JSON을 반환하면 클라이언트가 사용자에게 적절한 안내를 할 수 있습니다. "토큰이 없습니다"와 "토큰이 만료되었습니다"는 다르게 처리해야 하니까요.
실제 현업에서는 어떻게 활용할까요? 예를 들어 온라인 쇼핑몰을 운영한다고 가정해봅시다.
상품 목록 조회는 누구나 할 수 있지만, 장바구니 담기는 로그인이 필요합니다. 주문하기는 로그인뿐만 아니라 본인 인증까지 필요할 수 있습니다.
이렇게 경로별로 다른 인증 수준을 요구할 수 있습니다. 화이트리스트는 설정 파일로 관리하는 것이 좋습니다.
코드에 하드코딩하면 경로를 추가할 때마다 코드를 수정하고 배포해야 합니다. application.yml에 화이트리스트를 정의하고 @ConfigurationProperties로 읽어오면, 설정만 변경하고 재시작하면 됩니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 화이트리스트를 너무 넓게 설정하는 것입니다.
/api/**를 공개하면 모든 API가 인증 없이 접근 가능해집니다. 최소 권한 원칙에 따라 꼭 필요한 경로만 화이트리스트에 추가해야 합니다.
박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 화이트리스트를 조심히 관리해야 하는군요!" 인증 필터를 제대로 구현하면 보안과 사용자 경험 사이의 균형을 맞출 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 화이트리스트는 설정 파일로 관리하여 코드 수정 없이 변경 가능하게 하세요
- 경로 매칭에는 Spring의 AntPathMatcher를 사용하는 것이 안전합니다
- 최소 권한 원칙에 따라 꼭 필요한 경로만 화이트리스트에 추가하세요
6. 속도 제한 필터
김개발 씨가 인증 필터까지 완벽하게 구현했다고 생각하던 중, 운영팀에서 긴급 연락이 왔습니다. "특정 IP에서 초당 1000건의 요청이 들어와서 서버가 다운될 뻔했어요.
DDoS 공격 같은데, 속도 제한 기능을 추가해주세요." 김개발 씨는 또 새로운 도전에 직면했습니다.
속도 제한 필터는 특정 시간 동안 요청 횟수를 제한하는 필터입니다. 마치 놀이공원의 탑승 대기줄처럼, 한 번에 너무 많은 사람이 몰리지 않도록 조절합니다.
Token Bucket 알고리즘을 사용하여 IP별 또는 사용자별로 요청 속도를 제한합니다. DDoS 공격을 방어하고 서버 리소스를 보호하는 필수 기능입니다.
다음 코드를 살펴봅시다.
@Component
public class RateLimitFilter implements GlobalFilter, Ordered {
private static final Logger log = LoggerFactory.getLogger(RateLimitFilter.class);
// IP별 Rate Limiter 저장소 (실제로는 Redis 사용 권장)
private final Map<String, TokenBucket> rateLimiters = new ConcurrentHashMap<>();
// 초당 10개 요청, 버스트로 20개까지 허용
private static final int RATE_LIMIT = 10;
private static final int BURST_CAPACITY = 20;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String clientIp = getClientIp(exchange.getRequest());
// IP별 Rate Limiter 가져오기 (없으면 생성)
TokenBucket bucket = rateLimiters.computeIfAbsent(
clientIp,
k -> new TokenBucket(RATE_LIMIT, BURST_CAPACITY)
);
// 토큰 소비 시도
if (!bucket.tryConsume()) {
log.warn("Rate limit exceeded for IP: {}", clientIp);
return rateLimitExceededResponse(exchange);
}
log.debug("Request allowed for IP: {} (remaining: {})", clientIp, bucket.getAvailableTokens());
return chain.filter(exchange);
}
private String getClientIp(ServerHttpRequest request) {
// X-Forwarded-For 헤더 확인 (프록시를 거친 경우)
String xff = request.getHeaders().getFirst("X-Forwarded-For");
if (xff != null && !xff.isEmpty()) {
return xff.split(",")[0].trim();
}
// 직접 연결된 경우
InetSocketAddress remoteAddress = request.getRemoteAddress();
return remoteAddress != null ? remoteAddress.getAddress().getHostAddress() : "unknown";
}
private Mono<Void> rateLimitExceededResponse(ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
String body = "{\"error\": \"Too Many Requests\", \"message\": \"Rate limit exceeded. Please try again later.\"}";
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return -5; // 인증 필터보다 먼저 실행
}
// Token Bucket 알고리즘 구현
private static class TokenBucket {
private final int capacity;
private final int refillRate;
private double availableTokens;
private long lastRefillTime;
public TokenBucket(int refillRate, int capacity) {
this.refillRate = refillRate;
this.capacity = capacity;
this.availableTokens = capacity;
this.lastRefillTime = System.nanoTime();
}
public synchronized boolean tryConsume() {
refill();
if (availableTokens >= 1) {
availableTokens -= 1;
return true;
}
return false;
}
private void refill() {
long now = System.nanoTime();
double elapsedSeconds = (now - lastRefillTime) / 1_000_000_000.0;
double tokensToAdd = elapsedSeconds * refillRate;
availableTokens = Math.min(capacity, availableTokens + tokensToAdd);
lastRefillTime = now;
}
public int getAvailableTokens() {
return (int) availableTokens;
}
}
}
김개발 씨는 인증 시스템을 완벽하게 구축했다고 안심하고 있었습니다. 그런데 어느 날 새벽, 모니터링 알람이 울렸습니다.
CPU 사용률이 100%에 도달했고, 서버가 응답하지 않았습니다. 로그를 확인해보니 특정 IP에서 초당 수천 건의 요청이 쏟아지고 있었습니다.
"이게 DDoS 공격인가?" 김개발 씨는 당황했습니다. 그렇다면 속도 제한이란 정확히 무엇일까요?
쉽게 비유하자면, 속도 제한은 마치 고속도로의 톨게이트와 같습니다. 차량이 너무 많이 몰리면 정체가 생기니까, 톨게이트에서 통과 속도를 조절합니다.
한 번에 너무 많은 차가 지나가지 못하도록 제한하여 전체 교통 흐름을 원활하게 유지하는 것입니다. 속도 제한이 없던 시절에는 어땠을까요?
악의적인 공격자가 서버에 무한정 요청을 보낼 수 있었습니다. 정상 사용자들은 서비스를 이용할 수 없었고, 서버는 다운되었습니다.
심지어 악의가 없어도 문제가 생겼습니다. 버그가 있는 클라이언트가 무한 루프로 요청을 보내면 마찬가지로 서버가 마비되었으니까요.
바로 이런 문제를 해결하기 위해 Rate Limiting이 필수가 되었습니다. Rate Limiting을 사용하면 비정상적인 트래픽을 자동으로 차단할 수 있습니다.
또한 공정한 리소스 분배가 가능합니다. 한 사용자가 모든 리소스를 독점하는 것을 막고, 모든 사용자에게 공평한 기회를 제공하는 것입니다.
Token Bucket 알고리즘이 어떻게 작동하는지 살펴보겠습니다. 양동이에 토큰이 담겨있다고 상상해보세요.
요청이 들어올 때마다 토큰을 하나씩 소비합니다. 토큰이 없으면 요청을 거부합니다.
시간이 지나면 토큰이 자동으로 채워집니다. 예를 들어 초당 10개씩 토큰이 생기면, 초당 10개 요청까지 허용하는 것입니다.
Burst Capacity는 순간적인 트래픽 증가를 처리합니다. 평소에는 초당 10개 요청만 오지만, 갑자기 20개가 동시에 올 수도 있습니다.
이럴 때 버스트 용량이 20이면 일시적으로 허용할 수 있습니다. 양동이가 20개까지 토큰을 담을 수 있다고 생각하면 됩니다.
위의 코드를 한 줄씩 살펴보겠습니다. ConcurrentHashMap으로 IP별 TokenBucket을 관리합니다.
IP가 키이고, TokenBucket이 값입니다. computeIfAbsent를 사용하면 해당 IP의 버킷이 없을 때만 새로 생성하고, 있으면 기존 것을 재사용합니다.
getClientIp 메서드가 중요합니다. 프록시나 로드밸런서를 거친 경우 실제 클라이언트 IP는 X-Forwarded-For 헤더에 들어있습니다.
이 헤더를 먼저 확인하고, 없으면 직접 연결된 주소를 사용합니다. IP를 정확히 파악해야 속도 제한이 제대로 작동합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 API 서비스를 제공하는 회사라고 가정해봅시다.
무료 사용자는 분당 60건, 유료 사용자는 분당 1000건까지 허용할 수 있습니다. 사용자별로 다른 속도 제한을 적용하여 공정한 서비스를 제공하는 것입니다.
프로덕션 환경에서는 Redis를 사용합니다. 위 코드는 메모리에 Rate Limiter를 저장하기 때문에, 여러 Gateway 인스턴스가 있으면 각각 독립적으로 동작합니다.
Redis를 사용하면 모든 인스턴스가 동일한 Rate Limiter를 공유하여 정확한 제한이 가능합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 너무 낮은 제한을 설정하는 것입니다. 너무 엄격하면 정상 사용자도 막히게 됩니다.
실제 트래픽 패턴을 분석하여 적절한 값을 설정해야 합니다. 또한 경로별로 다른 제한을 적용하는 것도 고려해야 합니다.
박시니어 씨의 설명을 들은 김개발 씨는 안도했습니다. "아, 이제 DDoS 공격도 막을 수 있겠네요!" 속도 제한 필터를 제대로 구현하면 서버를 보호하고 안정적인 서비스를 제공할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 프로덕션에서는 반드시 Redis 같은 외부 저장소를 사용하여 분산 환경에서도 정확한 제한이 가능하도록 하세요
- 경로별, 사용자 등급별로 다른 속도 제한을 적용하세요
- 속도 제한에 걸린 요청은 Retry-After 헤더를 포함하여 언제 다시 시도할 수 있는지 알려주세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
관찰 가능한 마이크로서비스 완벽 가이드
마이크로서비스 환경에서 시스템의 상태를 실시간으로 관찰하고 모니터링하는 방법을 배웁니다. Resilience4j, Zipkin, Prometheus, Grafana, EFK 스택을 활용하여 안정적이고 관찰 가능한 시스템을 구축하는 실전 가이드입니다.
Prometheus 메트릭 수집 완벽 가이드
Spring Boot 애플리케이션의 메트릭을 Prometheus로 수집하고 모니터링하는 방법을 배웁니다. Actuator 설정부터 PromQL 쿼리까지 실무에 필요한 모든 내용을 다룹니다.
스프링 관찰 가능성 완벽 가이드
Spring Boot 3.x의 Observation API를 활용한 애플리케이션 모니터링과 추적 방법을 초급 개발자 눈높이에서 쉽게 설명합니다. 실무에서 바로 적용할 수 있는 메트릭 수집과 분산 추적 기법을 다룹니다.
Zipkin으로 추적 시각화 완벽 가이드
마이크로서비스 환경에서 분산 추적을 시각화하는 Zipkin의 핵심 개념과 활용 방법을 초급자도 쉽게 이해할 수 있도록 실무 스토리로 풀어낸 가이드입니다. Docker 실행부터 UI 분석까지 단계별로 배웁니다.
Micrometer Tracing 완벽 가이드
분산 시스템에서 요청 흐름을 추적하는 Micrometer Tracing의 핵심 개념과 실전 활용법을 초급 개발자도 쉽게 이해할 수 있도록 실무 스토리와 비유로 풀어낸 완벽 가이드입니다.