본문 바로가기
Framework/Spring Boot

[Spring Boot] Spring 클라우드 게이트웨이(Cloud Gateway)

하나의 거대한 프로젝트(Monolithic)로 시작했던 쇼핑몰이 대박을 터뜨렸습니다. 개발자가 100명이 넘어가자, 코드가 꼬이고 배포 한 번 하려면 30분이 걸립니다. 결국 넷플릭스(Netflix)나 우아한형제들처럼 시스템을 '회원(Auth)', '상품(Product)', '주문(Order)' 서버로 잘게 쪼개는 마이크로서비스 아키텍처(MSA, Microservice Architecture)로 전환했습니다. 그런데 문제가 생겼습니다. 프론트엔드 앱이 회원가입을 하려면 auth.api.com을 찔러야 하고, 상품을 조회하려면 product.api.com을 찔러야 합니다. 클라이언트가 수십 개의 분산된 서버 주소를 전부 외우고 다녀야 하는 끔찍한 상황입니다. 게다가 JWT 토큰 검증은 30개의 서버마다 각각 중복해서 코드를 짜야 할까요? "클라이언트는 딱 하나의 주소(api.com)만 호출해! 내가 주소를 보고 알맞은 서버로 토스해 줄게. 그리고 토큰 검사 같은 공통 작업도 내가 여기서 한 번에 다 처리해 주마!"

 

이 위대한 단일 진입점(Single Point of Entry)의 역할을 수행하는 것이 바로 Spring Cloud Gateway(SCG)입니다.

본 글에서는 "MSA의 필수 관문!" SCG의 라우팅(Routing)과 글로벌 필터(Filter) 아키텍처에 대하여 다뤄보겠습니다.

1. 라우팅(Routing): 트래픽의 교통경찰

Spring Cloud Gateway 프로젝트를 새로 하나 만듭니다. (이 서버에는 WebFlux가 내장되어 있어, 수만 건의 트래픽을 멈춤(Blocking) 없이 비동기로 아주 가볍게 중계할 수 있습니다.) 이 서버의 역할은 비즈니스 로직이 아니라 오직 '길 안내'입니다. application.yml에 교통 규칙만 적어주면 됩니다.

spring:
  cloud:
    gateway:
      routes:
        # 1. 주문 서비스 라우팅
        - id: order-service
          uri: http://localhost:8082 # 클라이언트가 여기로 가길 원하면, 진짜 목적지는 8082(주문 서버)다!
          predicates:
            - Path=/order/** # 만약 주소가 "/order/"로 시작한다면 일로 보내라 (조건)
            
        # 2. 회원 서비스 라우
        - id: auth-service
          uri: http://localhost:8081
          predicates:
            - Path=/auth/**

이제 클라이언트는 http://gateway.com/order/1 하나만 찌르면, Gateway가 알아서 8082 포트에 떠 있는 주문 서버로 요청을 토스해 줍니다.

 

2. 글로벌 필터(Global Filter): 중복 코드의 구원자

MSA의 가장 큰 골칫거리는 '인증(Authentication)'입니다. 사용자가 물건을 사려면 JWT 토큰이 유효한지 검사해야 합니다. 30개의 서버 각각에 Spring Security를 달고 토큰 검증 코드를 복붙해야 할까요? 아닙니다. 클라이언트의 요청이 무조건 통과하는 유일한 관문, Gateway에 딱 한 번만 필터(Filter)를 달아서 검사하면 됩니다.

// Gateway에서 동작하는 커스텀 인가 필터 (WebFlux 기반이라 HttpServletRequest 대신 ServerWebExchange 사용)
@Component
@Slf4j
public class JwtAuthorizationFilter extends AbstractGatewayFilterFactory<JwtAuthorizationFilter.Config> {

    public JwtAuthorizationFilter() { super(Config.class); }
    public static class Config { } // 필터 설정값 넣는 용도

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();

            // 1. HTTP 헤더에서 Authorization 토큰 꺼내기
            if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
                return onError(exchange, "토큰이 없습니다!", HttpStatus.UNAUTHORIZED);
            }

            String token = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);

            // 2. 토큰 위조/만료 검증 (예: JwtUtil.validateToken(token))
            if (!isValid(token)) {
                return onError(exchange, "가짜 토큰입니다!", HttpStatus.UNAUTHORIZED);
            }

            // 3. 통과! 이제 뒷단의 진짜 서버(주문, 상품 등)로 요청을 넘겨준다.
            return chain.filter(exchange); 
        };
    }
}

위 필터를 만들고 yml 설정에 filters: - JwtAuthorizationFilter 한 줄만 추가하면, 뒤에 숨은 30개의 마이크로서비스는 "내게 오는 요청은 이미 Gateway가 검사 끝낸 안전한 요청이야!"라고 믿고 오직 비즈니스 로직에만 집중할 수 있습니다.

 

3. 마무리 및 주요 발전 방향

지금까지 수십 개로 찢어진 마이크로서비스 환경에서 클라이언트에게는 단일 API 주소의 편리함을 제공하고, 서버 측에는 인가(Auth), 로깅, Rate Limit(트래픽 제한) 같은 공통 관심사를 한 방에 해결해 주는 MSA의 절대 군주, Spring Cloud Gateway의 원리에 대해 아주 치밀하게 다루어 보았습니다. 이제 거대한 아키텍처의 교통정리가 완료되었습니다.

그런데 엄청난 문제가 하나 남아있습니다. Gateway의 yml 설정 파일에 uri: http://192.168.0.10:8082 처럼 뒷단 서버의 IP를 하드코딩해 두었습니다. 클라우드(AWS, K8s) 시대에는 주문 서버에 트래픽이 몰리면 자동으로 주문 서버가 2대, 3대, 10대로 늘어납니다(오토스케일링). 각 서버는 켜질 때마다 IP 주소가 랜덤으로 바뀌는데, Gateway는 어떻게 새로 켜진 서버의 IP를 알아내서 트래픽을 분산(로드밸런싱)해 줄까요? 매번 yml 파일을 고치고 Gateway를 재시작해야 할까요? 본 설명에서는 "MSA 서버들의 실시간 전화번호부!" IP가 수시로 바뀌는 클라우드 환경에서 살아남기 위한 Eureka(유레카) Service Discovery 아키텍처 완벽 가이드에 대해 아주 심도 있게 살펴보겠습니다.


마이크로서비스 아키텍처(MSA)를 도입하고 API Gateway까지 앞에 세웠습니다. 이제 클라이언트의 모든 트래픽은 Gateway가 yml 설정에 적힌 IP(192.168.0.10:8082)를 보고 주문 서버로 넘겨줍니다. 그런데 오늘 블록버스터급 할인 이벤트가 터지면서 주문 서버 CPU가 99%를 치기 시작했습니다! 다급해진 인프라 팀이 AWS 오토스케일링(Auto Scaling)을 돌려 주문 서버를 순식간에 5대로 늘렸습니다. 새로 켜진 서버 4대는 각자 랜덤한 새 IP(예: 192.168.0.11 ~ 14)를 발급받았습니다. 자, Gateway는 새로 켜진 4대의 서버 IP를 어떻게 알고 트래픽을 골고루 나눠줄(Load Balancing) 수 있을까요? yml 파일에 직접 적혀있지 않으니 Gateway는 새 서버들의 존재조차 모릅니다. 매번 서버가 늘어날 때마다 사람이 Gateway의 설정을 고치고 재시작해야 할까요? 이 '동적인 IP 주소 관리' 문제를 완벽하게 해결해 주는 넷플릭스(Netflix)의 천재적인 오픈소스, Eureka(유레카) Service Discovery가 등장합니다.

본 글에서는 "MSA의 살아 움직이는 전화번호부!" Eureka Server 구축과 클라이언트 연동 실무에 대하여 다뤄보겠습니다.

1. Eureka의 핵심: 전입신고와 전화번호부

개념은 구청의 전입신고와 똑같습니다.

  • Eureka Server (전화번호부 관리자): 중앙에 우두커니 떠 있는 하나의 독립된 스프링 부트 서버입니다. 누가 살아있는지 리스트만 관리합니다.
  • Eureka Client (각종 마이크로서비스들): '주문 서버', '회원 서버' 같은 녀석들입니다. 얘네들은 서버가 딱 켜지는 순간, Eureka Server에 찾아가서 "제 애플리케이션 이름(spring.application.name)은 ORDER-SERVICE고요, IP는 이거예요!" 하고 전입신고를 합니다. 그리고 30초마다 "나 아직 안 죽고 살아있어~" 라며 심장 박동(Heartbeat) 신호를 보냅니다.

만약 주문 서버 중 1대가 에러로 뻗어서 심장 박동이 끊기면, Eureka Server는 "어? 얘 죽었네. 전화번호부에서 삭제(Evict)!" 해버립니다.


2. Gateway의 진화: IP가 아닌 '이름'으로 찾기

이제 가장 중요한 Gateway의 변화입니다. 이전 단계에서 Gateway는 uri: http://192.168.0.10:8082 처럼 IP를 하드코딩했습니다. 하지만 Gateway도 Eureka Client로 등록해 두면, 놀라운 마법이 일어납니다.

spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          #  핵심! 더 이상 http://IP가 아니라 lb://(애플리케이션이름) 을 사용합니다!
          uri: lb://ORDER-SERVICE 
          predicates:
            - Path=/order/**

lb://ORDER-SERVICE의 의미는 이렇습니다. "Eureka 전화번호부에서 ORDER-SERVICE라는 이름을 가진 서버들의 IP 리스트를 다 가져와. 그중에서 안 바쁜 서버 하나 골라서(Load Balancing, lb) 트래픽을 넘겨줘!"
이제 서버가 100대로 늘어나든, 죽어서 1대로 줄어들든 Gateway 설정은 단 한 글자도 바꿀 필요가 없습니다. 모든 것이 완벽하게 자동화되었습니다.


3. MSA의 그림자: 분산 시스템의 복잡성

Gateway와 Eureka를 조합하여 IP를 모르더라도 동적으로 라우팅하는 완벽한 MSA 트래픽 인프라가 완성되었습니다. 하지만 이 아름다운 MSA 아키텍처 이면에는 아주 무서운 그림자가 존재합니다.

만약 '회원 서버'에 문제가 생겨서 DB 조회가 10초씩 걸리기 시작했다고 가정해 봅시다. 클라이언트가 Gateway를 통해 '주문 서버'에 요청을 보냅니다. '주문 서버'는 사용자의 등급을 알기 위해 내부적으로 '회원 서버'를 호출합니다(Feign Client 등). 회원 서버가 응답을 안 하니 주문 서버도 10초 동안 대기(Blocking)합니다. 주문 서버가 대기하니 그 앞의 Gateway도 10초 대기합니다! 결국 회원 서버 하나가 아픈 것뿐인데, 도미노가 쓰러지듯(Cascading Failure) 전체 쇼핑몰의 모든 서비스가 올스톱되는 대참사가 벌어집니다.


4. 마무리 및 주요 발전 방향

지금까지 수시로 IP가 바뀌는 클라우드 환경에서 하드코딩의 저주를 풀고, 마이크로서비스들이 자율적으로 전입신고를 하며 lb://이름 기반의 다이내믹 라우팅(Dynamic Routing)을 완성하는 Eureka Service Discovery의 아키텍처에 대해 상세히 다루어 보았습니다. 이제 진정한 클라우드 네이티브(Cloud Native) 환경에 한 발짝 다가섰습니다.

그런데 방금 언급한 '도미노 붕괴(Cascading Failure)' 현상은 MSA에서 회사가 망할 수도 있는 가장 치명적인 약점입니다. 회원 서버가 죽었으면, 주문 서버는 회원 서버를 기다리다 같이 죽지 말고 "어? 회원 서버 아프네? 기다리지 말고 그냥 비회원 취급해서 빨리 결제나 넘겨버려!"라며 연결(회로)을 스스로 끊어버려야(차단) 합니다.

 

다음 설명에서는 "장애가 번지는 것을 물리적으로 끊어버리는 MSA 최후의 방어막!" Resilience4j(리질리언스4j)를 활용한 서킷 브레이커(Circuit Breaker) 패턴과 Fallback 실무 가이드에 대해 아주 심도 있게 살펴보겠습니다.