Spring Cloud Gateway, 언제 도입하고 언제 피해야 하나

글쓴이: JAVA/Spring Boot 백엔드 개발 10년차. SI로 시작해 현재는 자사 서비스 백엔드를 맡고 있다.

2022년 봄, 우리 팀은 Spring Cloud Gateway(이하 SCG)를 도입했다. 도입 3주 만에 점심 피크에 전체 API가 4분 동안 멈췄다. 게이트웨이 CPU는 20%도 안 됐고 뒷단 서비스들도 한가했다. 요청은 게이트웨이 안에서 그냥 멈춰 있었다.

이 글은 그 장애를 포함해 SCG를 1년 반 운영하면서 겪은 일을 적은 기록이다. 결론부터 말하면 SCG는 좋은 도구였지만 우리가 처음 기대한 방식으로 좋았던 건 아니다. 그리고 같은 회사의 다른 프로젝트에서는 SCG를 넣지 않고 Nginx 설정 몇 줄로 끝냈다. 둘 다 옳은 결정이었다고 지금도 생각한다. 언제 넣고 언제 넣지 말아야 하는지, 내가 기준을 세운 과정을 순서대로 적는다.

서비스가 일곱 개가 되자 인증 코드가 일곱 벌이 됐다

당시 커머스 백엔드는 모놀리식에서 서비스를 하나씩 떼어 내는 중이었다. 주문, 상품, 회원, 결제, 정산, 쿠폰, 알림까지 일곱 개가 됐다. 앱과 웹은 서비스 주소를 각자 설정 파일에 들고 있었고, 서비스마다 JWT를 검증하는 필터가 따로 들어 있었다. 서비스 간 호출을 정리하던 이야기는 MSA 서비스 간 호출 방식 비교에 따로 적었다.

문제가 된 건 토큰 검증 로직이 조금씩 달랐다는 점이다. 누군가 만료 시간 여유(clock skew)를 30초로 두면 다른 서비스는 0초였다. 쿠폰 서비스는 탈퇴 회원 확인을 빼먹었다. 보안 점검에서 “탈퇴한 계정의 토큰으로 쿠폰 목록이 조회된다”는 지적을 받았고, 고치려고 보니 일곱 군데를 다 봐야 했다. 토큰 재발급 정책을 바꿀 때마다 같은 일이 반복됐다(재발급 이야기는 JWT Refresh Token 재발급 실전 참고).

팀 회의에서 게이트웨이 이야기가 나왔다. 인증을 한 곳에서 하고, 클라이언트는 주소 하나만 알면 되고, 나중에 Rate Limit도 붙일 수 있다. 팀원 모두 Spring에 익숙했으니 Kong보다 SCG가 자연스러워 보였다. 여기까지는 교과서 그대로였다.

도입 첫 주: 인증 필터 하나로 일곱 벌을 지웠다

처음 만든 건 글로벌 인증 필터였다. 토큰을 검증하고 사용자 ID를 헤더로 바꿔 뒷단에 넘긴다. 뒷단 서비스는 더 이상 JWT 라이브러리를 몰라도 되고, X-User-Id 헤더만 믿으면 된다. 대신 뒷단은 게이트웨이를 거치지 않은 요청을 받지 않도록 보안 그룹으로 막았다. 이걸 빼먹으면 누구나 헤더를 위조해서 뒷단을 직접 부를 수 있다.

@Component
public class JwtAuthFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = resolveToken(exchange.getRequest());
        if (token == null || !jwtProvider.isValid(token)) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        ServerHttpRequest mutated = exchange.getRequest().mutate()
                .headers(h -> h.remove("X-User-Id"))          // 클라이언트가 보낸 값은 버린다
                .header("X-User-Id", jwtProvider.getUserId(token))
                .build();
        return chain.filter(exchange.mutate().request(mutated).build());
    }

    @Override
    public int getOrder() { return -100; }
}

주석 달린 한 줄은 코드 리뷰에서 선배가 짚어 준 부분이다. 클라이언트는 X-User-Id를 직접 넣어 보낼 수 있다. 스프링 버전에 따라 header()가 값을 덮어쓰기도 하고 덧붙이기도 해서, 버전을 믿지 말고 기존 값을 먼저 지우자는 이야기였다. 값이 두 개 붙은 채로 넘어가면 뒷단이 첫 번째 값을 읽는 순간 위조가 된다.

일주일 만에 서비스 일곱 개에서 인증 필터를 걷어 냈다. 삭제한 코드가 2천 줄이 넘었다. 탈퇴 회원 확인도 게이트웨이 한 곳에만 넣으면 됐다. 문제는 바로 이 “탈퇴 회원 확인”을 어떻게 넣었느냐였다.

3주차 점심, 4분 동안 모든 요청이 멈췄다

탈퇴 여부는 토큰 안에 없었다. 그래서 필터 안에서 회원 DB를 조회했다. 팀에서 늘 쓰던 방식 그대로 JdbcTemplate을 주입해서 한 줄로 조회했다. 로컬에서도, 스테이징에서도 아무 문제가 없었다.

// 3주 동안 운영에 있던 코드. 이게 원인이었다.
String status = jdbcTemplate.queryForObject(
        "SELECT status FROM member WHERE id = ?", String.class, userId);
if ("WITHDRAWN".equals(status)) {
    exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
    return exchange.getResponse().setComplete();
}

그날 점심에 회원 DB에서 배치 작업이 테이블 락을 오래 잡았다. 조회 한 번이 평소 3ms에서 수 초로 늘었다. 톰캣 기반 서비스였다면 스레드 200개 중 일부가 기다리는 정도로 끝났을 것이다. 하지만 SCG는 Netty 위에서 돈다. 요청을 처리하는 이벤트 루프 스레드는 CPU 코어 수만큼, 우리 서버에서는 4개뿐이었다. 그 4개가 전부 DB 응답을 기다리며 멈췄고, 그동안 게이트웨이는 어떤 요청도 받지 못했다. 회원 조회와 전혀 상관없는 상품 목록 API까지 전부 타임아웃이 났다.

처음에는 원인을 못 찾았다. CPU도 메모리도 멀쩡했고 뒷단 로그에는 요청이 아예 없었다. 20분쯤 지나 스레드 덤프를 떴더니 reactor-http-nio-1부터 -4까지 전부 SocketInputStream.read에서 멈춰 있었다. 그 스택 맨 아래에 우리가 만든 JwtAuthFilter가 있었다. 예전에 RestTemplate 타임아웃 누락으로 서버가 멈춘 날과 원인이 같았다. 다만 이번에는 스레드가 200개가 아니라 4개라서 훨씬 빨리, 훨씬 넓게 터졌다.

실패한 첫 수습: 스레드만 늘리면 될 줄 알았다

당일 저녁 첫 번째 대응은 이벤트 루프 스레드 수를 늘리는 것이었다. reactor.netty.ioWorkerCount를 16으로 올렸다. 부하 테스트에서 DB에 인위적으로 2초 지연을 넣어 보니 버티는 시간이 조금 늘 뿐 결국 똑같이 멈췄다. 블로킹 호출이 있는 한 스레드를 몇 개로 늘려도 동시 요청이 그 수를 넘는 순간 끝이다. 톰캣으로 돌아가는 것과 다를 게 없었고, 오히려 Netty의 장점만 버리는 셈이었다.

두 번째로 Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())로 JDBC 호출을 별도 스레드 풀로 넘겼다. 이벤트 루프는 더 이상 멈추지 않았다. 하지만 DB가 느려지면 boundedElastic 풀과 대기 큐가 차오르고, 결국 인증이 필요한 모든 요청이 DB 속도에 묶인다는 구조는 그대로였다. 게이트웨이가 회원 DB의 장애를 전체 서비스로 퍼뜨리는 통로가 되어 있었다.

제대로 된 해결: 게이트웨이에서 DB를 지웠다

결국 결론은 “게이트웨이는 라우팅과 필터만 한다”는, 공식 문서와 발표 자료에서 수없이 본 문장이었다. 이번에는 그 말이 무슨 뜻인지 알았다. 실제로 바꾼 건 세 가지다.

  • 탈퇴 확인을 토큰 수명으로 옮겼다. 액세스 토큰 유효 시간을 30분에서 10분으로 줄이고, 탈퇴 시 리프레시 토큰을 즉시 폐기했다. 탈퇴 후 최대 10분은 기존 토큰이 살아 있지만 보안팀과 협의해 이 정도는 허용하기로 했다.
  • 즉시 차단이 필요한 계정은 Redis 블랙리스트로 확인했다. 계정 정지 같은 경우만 Redis에 넣고, 게이트웨이는 리액티브 Redis 클라이언트(Lettuce)로 조회한다. Redis 조회에도 200ms 타임아웃을 걸고, 타임아웃이 나면 통과시키는 쪽을 택했다.
  • BlockHound를 테스트에 붙였다. 통합 테스트 실행 시 이벤트 루프에서 블로킹 호출이 일어나면 예외를 던지게 했다. 이후 누군가 필터에 .block()을 넣은 PR이 두 번 있었는데 둘 다 CI에서 걸렸다.
return redisTemplate.hasKey("blocked:" + userId)       // ReactiveRedisTemplate
        .timeout(Duration.ofMillis(200))
        .onErrorReturn(false)                           // Redis 장애 시 통과
        .flatMap(blocked -> blocked
                ? reject(exchange, HttpStatus.FORBIDDEN)
                : chain.filter(withUserHeader(exchange, userId)));

Redis 장애 시 통과시키는 결정은 팀에서 가장 오래 논쟁한 부분이다. 막는 쪽이 안전해 보이지만, 그러면 Redis 하나가 죽을 때 서비스 전체가 죽는다. 정지 계정이 몇 분 더 접속하는 위험과 전체 장애의 위험을 비교해서 통과를 골랐다. 이런 판단은 서비스마다 다를 수 있다. 다만 어느 쪽이든 게이트웨이 필터 안의 외부 호출에는 반드시 타임아웃과 실패 시 동작을 정해 둬야 한다.

그 뒤 1년: 게이트웨이가 제값을 한 순간들

필터에서 DB를 지운 뒤로 게이트웨이는 조용해졌다. 그리고 처음 도입할 때 막연히 기대했던 것들이 하나씩 실제 이득으로 돌아왔다.

가장 컸던 건 카나리 배포였다. 주문 서비스를 새 버전으로 바꿀 때 Weight 술어로 5%, 20%, 50% 순서로 트래픽을 옮겼다. 20% 단계에서 새 버전의 쿠폰 계산 오류를 발견했고, 설정 값 하나로 0%로 되돌렸다. 앱 배포도, 서비스 재배포도 필요 없었다. 이 한 번으로 게이트웨이 도입 비용은 충분히 돌려받았다고 생각한다.

spring:
  cloud:
    gateway:
      routes:
        - id: order-v1
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
            - Weight=order, 80
        - id: order-v2
          uri: lb://order-service-v2
          predicates:
            - Path=/api/orders/**
            - Weight=order, 20

두 번째는 Rate Limit이다. 한 제휴사가 재고 조회 API를 초당 수백 번 호출해서 상품 서비스가 느려진 적이 있다. RequestRateLimiter 필터에 Redis 기반 RedisRateLimiter를 붙이고, 키는 API 키 헤더로 잡았다. 게이트웨이 인스턴스가 여러 대여도 Redis에서 토큰 버킷을 공유하니 한도가 정확히 지켜졌다. 서비스 코드는 한 줄도 고치지 않았다.

세 번째는 이중화의 필요성을 뼈저리게 배운 일이다. 처음에는 게이트웨이를 한 대만 띄웠다. 그 한 대가 JVM 업데이트 재시작으로 40초 내려갔을 때 서비스 전체가 40초 동안 404였다. 이후 최소 두 대, ALB 헬스 체크, 롤링 배포를 기본으로 했다. 게이트웨이는 모든 트래픽의 입구라서 한 대로 운영하면 도입하지 않은 것보다 위험하다.

같은 해 다른 프로젝트에서는 넣지 않았다

이듬해 사내 관리자 백오피스를 새로 만들 때 누군가 “여기도 SCG 두죠”라고 했다. 나는 반대했다. 백오피스는 Spring Boot 애플리케이션 하나와 정적 파일이 전부였다. 인증은 애플리케이션 안의 세션으로 충분했고, 트래픽은 하루 몇천 건이었다. 게이트웨이를 넣으면 인스턴스 두 대, 모니터링, WebFlux를 모르는 팀원 교육이 따라온다. 얻는 건 라우팅 하나뿐이다.

결국 Nginx 설정 몇 줄로 끝냈다. /api는 애플리케이션으로, 나머지는 정적 파일로 보냈다. 1년이 지난 지금까지 그 설정은 한 번도 바뀌지 않았다. 도입을 결정할 때 쓴 기준을 표로 정리하면 이렇다.

상황우리의 선택이유
서비스 5개 이상, 인증 로직이 서비스마다 중복SCG인증·헤더 변환을 한 곳에 모으는 이득이 운영 부담보다 컸다
카나리·버전별 라우팅이 자주 필요SCG설정 값만으로 비율을 바꾸고 되돌릴 수 있다
외부 제휴사 API 호출량 제어SCGRedis 기반 Rate Limit을 서비스 코드 수정 없이 붙였다
애플리케이션 1개 + 정적 파일Nginx라우팅 외에 할 일이 없다
팀에 WebFlux 경험자가 없음보류블로킹 호출 한 줄이 전체 장애가 된다
Java 외 언어 백엔드가 섞임Kong 검토플러그인 생태계가 언어에 묶이지 않는다

다시 도입한다면 처음부터 할 것

1년 반을 돌아보면 SCG 자체의 문제로 고생한 적은 거의 없다. 문제는 전부 “게이트웨이 안에서 하면 안 되는 일”을 한 데서 나왔다. 처음으로 돌아간다면 이 순서로 하겠다.

  • 도입 조건부터 확인한다. 인증 중앙화, 트래픽 제어, 점진 배포 중 두 가지 이상이 지금 당장 필요하지 않으면 넣지 않는다. “나중에 필요할 것 같아서”는 이유가 되지 않는다.
  • BlockHound를 첫 커밋에 넣는다. 필터 안의 JDBC, RestTemplate, .block()은 리뷰로 다 잡을 수 없다. 테스트가 잡게 해야 한다.
  • 필터의 외부 호출에는 타임아웃과 실패 정책을 반드시 적는다. 실패 시 막을지 통과시킬지는 코드를 쓰기 전에 팀이 합의한다.
  • 첫날부터 두 대로 띄운다. 한 대짜리 게이트웨이는 장애가 날 곳을 하나 더 만드는 것이다.
  • 도입 전 p99 응답 시간을 기록해 둔다. 우리는 도입 후 p99가 약 8ms 늘었다. 기준값이 없었다면 이 숫자가 괜찮은지 판단할 수 없었을 것이다.

SCG는 서비스가 많아지고 공통 관심사가 실제로 쌓였을 때 꺼내는 도구다. 그리고 꺼냈다면 게이트웨이에는 라우팅과 가벼운 필터만 둔다. 이 두 문장을 지키지 못해서 우리 팀은 점심 피크의 4분을 잃었다. 비슷한 결정을 앞둔 팀이라면 이 글이 그 4분을 아끼는 데 도움이 되면 좋겠다.

참고: Spring Cloud Gateway 공식 문서

댓글 남기기