앞 글 Spring Security 6 + JWT 은 단일 서비스(모놀리식) 기준이었다. 하지만 서비스가 여러 개로 쪼개진 MSA에서는 “토큰을 어디서 검증하고, 권한을 어떻게 각 서비스까지 전달할 것인가”가 새로운 문제가 된다. 이 글은 실제 사이드 프로젝트(주식 MSA)에서 쓴 패턴 — 게이트웨이에서 JWT를 한 번만 검증하고, 신뢰 헤더로 변환해 각 서비스에 전파한 뒤, 서비스에서는
@PreAuthorize로 권한을 확인하는 구조를 정리한다.
문제 — MSA에서 인증을 어디서 하나?
서비스가 여러 개일 때, 모든 서비스가 각자 JWT를 파싱·검증하게 만들면
- 비밀키가 모든 서비스에 흩어지고,
- 토큰 구조가 바뀌면 전 서비스를 고쳐야 하고,
- 같은 검증 로직이 N번 중복된다.
그래서 흔히 쓰는 방식이 “게이트웨이에서 토큰을 한 번만 검증하고, 검증 결과(사용자·권한)를 내부 신뢰 헤더로 바꿔 뒤쪽 서비스에 넘기는” 것이다.
[외부] Authorization: Bearer <JWT>
│
▼
[Gateway] JWT 검증 (여기서만!)
│ → X-User-Id, X-User-Role 헤더로 변환
│ → X-Gateway-Request: <내부 비밀키> 동봉 (이 헤더가 "게이트웨이가 보증함"의 표시)
▼
[각 서비스] 헤더를 신뢰해 SecurityContext 복원
│ → @PreAuthorize("hasRole('ADMIN')") 등으로 권한 확인
▼
[비즈니스 로직]
각 서비스는 JWT를 직접 파싱하지 않는다. 게이트웨이가 보증한 헤더만 믿는다. 대신 그 “믿어도 되는가”를 보장하는 장치가 필요한데, 그게 아래의 신뢰 헤더 + 내부 비밀키 조합이다.
1. 게이트웨이 — JWT를 한 번만 검증하고 헤더로 변환
Spring Cloud Gateway(WebFlux)의 GlobalFilter로 구현한다. 핵심은 세 가지다.
- 외부에서 들어온 신뢰 헤더를 무조건 먼저 제거 — 공격자가
X-User-Role: ROLE_ADMIN을 직접 붙여 보내는 스푸핑을 막는다. - JWT를 검증(여기서는 HS512)하고,
- 성공하면
X-User-Id,X-User-Role, 그리고 “게이트웨이가 보증함”을 뜻하는X-Gateway-Request: <내부 비밀키>를 붙여 전달한다.
@Component
public class JwtGlobalFilter implements GlobalFilter, Ordered {
private final ReactiveJwtDecoder reactiveDecoder;
@Value("${INTERNAL_SECRET}")
private String internalSecret;
public JwtGlobalFilter(@Value("${jwt.secret}") String base64Secret) {
byte[] keyBytes = Base64.getDecoder().decode(base64Secret);
SecretKey key = new SecretKeySpec(keyBytes, "HmacSHA512");
this.reactiveDecoder = NimbusReactiveJwtDecoder
.withSecretKey(key)
.macAlgorithm(MacAlgorithm.HS512)
.build();
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// ① 외부가 보낸 신뢰 헤더는 무조건 제거 (스푸핑 차단)
ServerWebExchange stripped = exchange.mutate()
.request(exchange.getRequest().mutate()
.headers(h -> {
h.remove("X-Gateway-Request");
h.remove("X-User-Id");
h.remove("X-User-Role");
})
.build())
.build();
if (isOpenPath(path)) { // 로그인/회원가입 등 인증 불필요 경로
return chain.filter(stripped);
}
String authHeader = stripped.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
String token = authHeader.substring(7);
// ② JWT 검증 (MSA에서 토큰 검증은 여기 한 곳에서만)
return reactiveDecoder.decode(token)
.flatMap(jwt -> {
String userId = jwt.getSubject();
String role = jwt.getClaimAsString("role");
// ③ 검증 결과를 신뢰 헤더로 변환 + 게이트웨이 보증 표시
ServerHttpRequest mutated = stripped.getRequest().mutate()
.header("X-Gateway-Request", internalSecret)
.header("X-User-Id", userId)
.header("X-User-Role", role)
.build();
return chain.filter(stripped.mutate().request(mutated).build());
})
.onErrorResume(ex -> { // 만료/위조 → 401
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
});
}
@Override
public int getOrder() { return -1; } // 가장 먼저 실행
}
📌 포인트: 스웨거(
/v3/api-docs,/swagger-ui)는 open 경로라도 외부에 그냥 노출하면 위험하므로, 게이트웨이에서 별도로 “항상 JWT 필요”로 막아두는 것이 좋다.
2. 각 서비스 — 헤더로 SecurityContext 복원
게이트웨이가 넘긴 헤더를 받아 SecurityContext에 인증 정보를 채우는 OncePerRequestFilter다.
여기서 SecurityContext를 채워줘야 뒤에서 @PreAuthorize가 동작한다.
핵심 보안 규칙: X-Gateway-Request 값이 내부 비밀키와 정확히 일치할 때만 X-User-* 헤더를 신뢰한다.
public class InternalUserFilter extends OncePerRequestFilter {
private final String internalSecret;
public InternalUserFilter(String internalSecret) {
if (internalSecret == null || internalSecret.isBlank()) {
throw new IllegalArgumentException("internalSecret required");
}
this.internalSecret = internalSecret;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String userId = request.getHeader("X-User-Id");
String userRole = request.getHeader("X-User-Role");
String gatewayRequest = request.getHeader("X-Gateway-Request");
// 게이트웨이가 보증한 요청인지 확인 (단순 "true" 같은 값은 거부, 키 정확 매칭만 신뢰)
boolean isGatewayVerified = internalSecret.equals(gatewayRequest);
if (isGatewayVerified && userId != null) {
var authorities = List.of(
new SimpleGrantedAuthority(userRole != null ? userRole : "ROLE_USER"));
// principal 에 userId 를 담아두면 컨트롤러에서 auth.getPrincipal() 로 바로 꺼내 쓸 수 있다
var authentication = new UsernamePasswordAuthenticationToken(
Long.valueOf(userId), null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
3. 공통 보안 설정 + @PreAuthorize
서비스마다 똑같은 설정을 반복하지 않도록, 공통 모듈에 SecurityFilterChain을 두고 위 필터를 등록한다.
@EnableMethodSecurity 가 있어야 @PreAuthorize 가 활성화된다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // ← 이게 있어야 @PreAuthorize 동작
public class InternalSecurityConfig {
@Value("${INTERNAL_SECRET}")
private String internalSecret;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/**", "/internal/**").permitAll() // URL은 풀고, 권한은 @PreAuthorize로
.anyRequest().authenticated())
.addFilterBefore(new InternalUserFilter(internalSecret),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
이제 컨트롤러에서는 메서드/클래스 단위로 어노테이션 한 줄이면 권한이 걸린다. URL 규칙이 아니라 어노테이션으로 권한을 표현하므로, 비즈니스 코드 옆에서 권한 정책이 한눈에 보인다.
@RestController
@RequestMapping("/analysis")
public class AiController {
@GetMapping("/chat")
@PreAuthorize("isAuthenticated()") // 로그인만 되어 있으면 OK
public Mono<String> chat(...) { ... }
@PostMapping("/telegram")
@PreAuthorize("hasAnyRole('PREMIUM', 'ADMIN')") // 프리미엄/관리자만
public Mono<String> send(Authentication auth, ...) {
Long userId = (Long) auth.getPrincipal(); // 필터에서 넣어준 userId
...
}
}
// 클래스 전체에 걸 수도 있다
@RestController
@RequestMapping("/admin")
@PreAuthorize("hasRole('ADMIN')") // 이 컨트롤러 전부 ADMIN 전용
public class AdminController { ... }
📌
hasRole('ADMIN')은 내부적으로ROLE_ADMIN권한을 확인한다. 그래서 게이트웨이가 넘기는 role claim, 필터가 만드는 권한 문자열을ROLE_ADMIN처럼ROLE_접두어 포함으로 맞춰야 한다.
4. 서비스 간 직접 호출 — 내부 비밀키
게이트웨이를 거치지 않고 서비스가 서비스를 직접 호출(예: AI 서비스 → Auth 서비스)하는 경우도 있다.
이때는 사용자 토큰이 없으므로, 호출하는 쪽이 X-Internal-Request: <내부 비밀키> 를 실어 보내고,
받는 쪽은 그 값이 일치하면 ROLE_ADMIN 시스템 호출로 처리한다.
// 받는 서비스의 필터 (InternalUserFilter 안에 함께 둘 수도 있음)
String internalRequest = request.getHeader("X-Internal-Request");
if (internalSecret.equals(internalRequest)) {
var auth = new UsernamePasswordAuthenticationToken(
0L, null, List.of(new SimpleGrantedAuthority("ROLE_ADMIN")));
SecurityContextHolder.getContext().setAuthentication(auth);
}
여기서 중요한 건 "true" 같은 단순 리터럴이 아니라 실제 비밀키 값과 정확히 일치할 때만 신뢰한다는 점이다.
Docker 내부 네트워크에 침투한 공격자가 헤더만 흉내 내 측면 이동(lateral movement)하는 것을 막는다.
5. /internal 전용 엔드포인트 가드
배치 트리거, 캐시 초기화 같은 내부 전용 엔드포인트는 /internal/** 로 모아두고,
X-Internal-Request 비밀키가 맞지 않으면 403으로 막는 전용 필터를 둔다.
public class InternalEndpointGuardFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
if (!req.getRequestURI().startsWith("/internal/")) {
chain.doFilter(req, res);
return;
}
if (!internalSecret.equals(req.getHeader("X-Internal-Request"))) {
res.setStatus(HttpServletResponse.SC_FORBIDDEN); // 비밀키 불일치 → 차단
return;
}
// 통과 시 ROLE_ADMIN 으로 처리
...
}
}
6. 이 패턴의 보안 포인트 정리
| 항목 | 왜 중요한가 |
|---|---|
| 외부 신뢰 헤더 선제거 | 게이트웨이가 X-User-*/X-Gateway-Request를 무조건 지운 뒤 자기가 다시 세팅 → 외부 스푸핑 원천 차단 |
| 비밀키 정확 매칭만 신뢰 | "true" 같은 값 거부, INTERNAL_SECRET 정확 일치만 통과 → 내부망 침투자의 측면 이동 차단 |
| 토큰 검증은 게이트웨이 한 곳 | 비밀키·검증 로직 중앙화. 서비스는 헤더만 신뢰 |
| @EnableMethodSecurity + @PreAuthorize | URL 매칭이 아니라 메서드 옆에 권한 선언 → 정책 가시성↑ |
| STATELESS | 세션 없이 요청마다 헤더로 인증 → 수평 확장에 유리 |
| 문서 경로 보호 | 스웨거/api-docs는 게이트웨이에서 별도 JWT 필요 처리 |
7. 주의할 점
- 비밀키가 모든 신뢰의 근거다.
INTERNAL_SECRET이 새면 누구나 게이트웨이를 사칭할 수 있으므로, 환경변수/시크릿 매니저로 관리하고 환경별로 다르게 둔다. - 헤더만 신뢰하므로, 서비스 포트가 외부에 직접 노출되면 안 된다. 외부 트래픽은 반드시 게이트웨이만 거치도록 네트워크를 닫아야 이 모델이 성립한다.
- 권한 변경 즉시 반영이 안 된다. 권한이 토큰/헤더에 실려 오므로, 사용자의 role을 바꿔도 기존 토큰이 만료될 때까지는 옛 권한이 유지된다. 즉시 무효화가 필요하면 짧은 만료 + 리프레시 토큰이나 블랙리스트를 병행한다.
- 로컬 개발 편의. 로컬에서는 게이트웨이/헤더 없이 직접 호출하고 싶을 때가 많은데,
@Profile("local")로permitAll인 별도SecurityFilterChain을 두면 개발이 편하다. (운영 프로파일에만 헤더 검증 적용)
8. 결론
모놀리식에서는 한 앱이 토큰 발급·검증·권한 확인을 다 했지만, MSA에서는 그 책임을 나눈다.
게이트웨이는 “검증과 신뢰 헤더 발급”, 각 서비스는 “헤더로 SecurityContext 복원 + @PreAuthorize 권한 확인” 으로 역할을 쪼개면,
비밀키와 검증 로직은 한 곳에 모이고 서비스 코드는 어노테이션 한 줄로 권한을 표현할 수 있다.
관건은 결국 “그 헤더를 믿어도 되는가” 를 보장하는 것 — 외부 헤더 선제거 + 내부 비밀키 정확 매칭이 그 답이다.
