서비스가 3개를 넘어가면서 문제가 두 가지 생겼다.
문제 1: 포트 파편화
POST :8081/orders ← 주문
POST :8085/auth/login ← 인증
GET :8086/products ← 상품
GET :8087/settlements ← 정산
서비스 하나가 추가될 때마다 포트가 늘어난다. 외부에 서비스 포트를 전부 노출하는 것도 보안상 좋지 않다.
문제 2: JWT 검증의 분산
common-auth 모듈로 JWT 코드는 공통화했지만, 각 서비스가 직접 토큰을 검증하는 구조 자체는 여전히 분산된 상태였다. 인증 방식이 바뀌면 모든 서비스를 수정해야 한다.
선택지는 세 가지였다.
| nginx | Spring Cloud Gateway | Spring Cloud Gateway MVC | |
|---|---|---|---|
| 기반 | C | WebFlux (비동기) | Spring MVC (서블릿) |
| JWT 검증 코드 관리 | 어려움 | 가능 (재작성 필요) | 가능 (기존 필터 재사용) |
| 기존 서블릿 필터 재사용 | 불가 | 불가 | 가능 |
nginx는 JWT 검증 같은 로직을 코드로 관리하기 어렵다.
WebFlux 기반 Spring Cloud Gateway는 처리량이 높지만 이미 서블릿 기반으로 작성된 common-auth 필터를 그대로 쓸 수 없다. 재작성해야 한다.
Spring Cloud Gateway MVC를 선택했다. OncePerRequestFilter를 그대로 사용할 수 있어서 common-auth의 기존 코드를 재사용할 수 있었다.
클라이언트 → :8080 (단일 진입점)
→ GatewayJwtFilter
├── 공개 경로면 그대로 통과
├── JWT 토큰 검증
├── Redis 블랙리스트 체크 (로그아웃된 토큰 차단)
└── X-User-Id, X-User-Role 헤더 추가
→ 내부 서비스로 전달
각 서비스: 헤더를 읽어서 SecurityContext 세팅
(JWT 재검증 없음)
게이트웨이에서 한 번 검증하면 내부 서비스는 헤더만 믿으면 된다.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class GatewayJwtFilter(
private val jwtTokenProvider: JwtTokenProvider,
private val redisTemplate: StringRedisTemplate
) : OncePerRequestFilter() {
companion object {
private val PUBLIC_PATHS = listOf(
"/api/auth/login",
"/api/auth/signup",
"/api/auth/refresh",
"/api/auth/password",
"/api/payments/webhook",
"/actuator"
)
}
override fun doFilterInternal(...) {
// 공개 경로는 그대로 통과
if (isPublicPath(request)) {
filterChain.doFilter(request, response)
return
}
val token = resolveToken(request)
?: return response.sendError(401, "Missing token")
if (!jwtTokenProvider.validateToken(token))
return response.sendError(401, "Invalid or expired token")
if (jwtTokenProvider.getTokenType(token) != "access")
return response.sendError(401, "Access token required")
// 블랙리스트 체크 (로그아웃된 토큰)
if (redisTemplate.hasKey("blacklist:$token") == true)
return response.sendError(401, "Token has been revoked")
// 검증 통과 → 헤더에 사용자 정보 추가
val userId = jwtTokenProvider.getUserId(token)
val role = jwtTokenProvider.getRole(token)
val mutatedRequest = MutableHttpServletRequest(request)
mutatedRequest.addHeader("X-User-Id", userId.toString())
mutatedRequest.addHeader("X-User-Role", role.name)
filterChain.doFilter(mutatedRequest, response)
}
private fun isPublicPath(request: HttpServletRequest): Boolean {
val uri = request.requestURI
if (PUBLIC_PATHS.any { uri == it || uri.startsWith("$it/") }) return true
// 상품 목록/상세 조회는 비로그인 허용 (GET만, UUID 형식만)
val uuidPattern = Regex("/api/products/[0-9a-fA-F-]{36}")
if (request.method == "GET" && (uri == "/api/products" || uri.matches(uuidPattern)))
return true
return false
}
}
MutableHttpServletRequest로 요청을 감싸서 헤더를 추가한다. HttpServletRequest는 불변이라 직접 헤더를 추가할 수 없기 때문이다. HttpServletRequestWrapper를 상속해서 헤더를 추가할 수 있도록 했다.
블랙리스트 체크도 게이트웨이에서 처리한다. 로그아웃 시 user-service가 Redis에 blacklist:{token} 키를 저장한다. 게이트웨이가 이 키를 확인해서 로그아웃된 토큰을 차단한다.
게이트웨이가 검증을 마친 후 X-User-Id, X-User-Role 헤더를 붙여서 전달한다. 각 서비스는 이 헤더를 읽어서 SecurityContext를 세팅하면 된다. JWT를 다시 검증할 필요가 없다.
common-auth에 GatewayAuthFilter를 만들어서 각 서비스가 가져다 쓰도록 했다.
class GatewayAuthFilter : OncePerRequestFilter() {
override fun doFilterInternal(...) {
val userId = request.getHeader("X-User-Id")
val role = request.getHeader("X-User-Role")
if (userId != null && role != null) {
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(
UUID.fromString(userId),
null,
listOf(SimpleGrantedAuthority("ROLE_$role"))
)
}
filterChain.doFilter(request, response)
}
}
각 서비스의 SecurityConfig에서 이 필터만 등록하면 된다. JWT 검증 코드가 각 서비스에 없다.
spring:
cloud:
gateway:
mvc:
routes:
- id: order-service
uri: http://order-service:8081
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1 # /api 제거 후 전달
- id: user-service
uri: http://user-service:8085
predicates:
- Path=/api/auth/**,/api/users/**
filters:
- StripPrefix=1
- id: product-service
uri: http://product-service:8086
predicates:
- Path=/api/products/**
filters:
- StripPrefix=1
- id: settlement-service
uri: http://settlement-service:8087
predicates:
- Path=/api/settlements/**
filters:
- StripPrefix=1
StripPrefix=1로 /api prefix를 제거하고 각 서비스로 전달한다. /api/orders/123으로 들어온 요청이 order-service에는 /orders/123으로 전달된다.
Gateway를 추가하면서 docker-compose를 수정하다가 이 에러가 생겼다.
ERROR: Network 'eventful-network' needs to be recreated
Docker 신버전에서 브리지 네트워크에 enable_ipv4 옵션이 추가됐다. 기존에 생성된 네트워크 설정과 불일치해서 발생하는 문제였다. docker network rm으로 지우고 다시 올리면 해결되지만 매번 번거로웠다.
networks:
eventful-network:
driver: bridge
driver_opts:
com.docker.network.enable_ipv4: "true"
명시적으로 선언하면 재생성 시에도 동일한 설정으로 만들어져서 불일치가 사라진다.
common-auth의 기존 서블릿 필터 코드를 재작성 없이 그대로 재사용했다