티스토리 뷰

개요

  • 마이크로서비스 아키텍쳐에서 API Gateway는 실제 서비스 요청에 대한 진입점으로서 중요한 역할을 수행한다. API Gateway를 통해 실제 요청에 대한 로드 밸런싱, 인증, 스로틀링, 모니터링 등을 수행할 수 있다. 오픈 소스 진영에서는 전통적으로 HAProxy, NGINX가 대표적이며, 보다 애플리케이션에 특화된 Kong이 존재한다.
  • 한편, Netflix ZuulJava 언어로 개발된 API Gateway이자 HTTP Reverse Proxy이다. 덕분에 JVM 환경에 친숙한 개발자들은 입맛에 맞게 필요한 기능을 자유롭게 개발할 수 있다. Netflix는 수년간 AWS ELBNetflix Zuul의 조합으로 자사의 서비스를 안정적으로 운영하고 있다.
  • 추가적으로 Netflix Zuul을 랩핑한 Spring Cloud Starter Netflix Zuul 기반으로 개발할 경우 완전한 Spring Boot 생태계 안에서 개발할 수 있다는 장점이 있다. 이번 글에서는 Spring Cloud Starter Netflix Zuul를 이용한 API Gateway 개발 방법을 간단히 소개하고자 한다.

API Gateway이 필요한 이유

  • 하루가 다르게 변화무쌍한 IT 환경에서 불멸의 키워드 중 하나가 바로 중복의 제거로 인한 관리 안정성의 확보이다. API Gateway 패턴을 도입하면 마이크로서비스 간에 반복적으로 발생하는 인증과 로그 모니터링과 같은 공통 기능을 중앙으로 단일화할 수 있다.
  • API Gateway는 회사 내부의 마이크로서비스 간에만 필요한 것이 아니다. 외부 써드파티와 연동시 기존 마이크로서비스의 수정 없이 제공할 API만 선별하여 노출시킬 수 있다. 즉, 서로를 직접 노출 없이 격리함으로서 보안 안정성을 높일 수 있다.

Netflix Zuul vs Amazon API Gateway

  • AWS 기반의 인프라 구성시 Amazon API Gateway는 가장 손쉬운 대안이다. 관리 콘솔에서 마우스 클릭 몇번으로 인스턴스를 생성하고 설정할 수 있다. 그렇다면 Netflix Zuul과 비교시 어떤 장단점이 있을까?
  • Amazon API Gateway은 월 단위로 요청 건수에 대해 과금한다. 100만 건당 3.50 USD가 과금된다. 예상 트래픽을 감안하여 Netfilx ZuulEC2에 멀티 인스턴스로 운영하고 앞에 로드 밸런서로 ELB까지 두는 방법과 비용에 대해 고민해볼 수 있다. (c5.xlarge 4코어, 8GB 인스턴스 운용시 매일 4.608 USD 과금)
  • Amazon API Gateway은 초당 최대 5,000건(추가 버스트 사용시 10,000건)의 요청 한도 제한이 걸려있다. 한도 초과시 429 Too Many Requests 오류를 응답하는데 한도를 높이려면 AWS 지원 센터에 문의하는 절차가 필요하다. 즉, 고부하 상황에서 즉시 대응이 불가능하다. 이런 이유로 배달의민족의 경우 Netfilx Zuul로 전환했다고 테크 블로그에서 밝힌 바 있다. [관련 링크]
  • Amazon API Gateway은 파일 업로드 요청시 최대 10MB로 제한된다. 한도 증가는 불가능하다.
  • Netfilx Zuul의 절대 강점은 JVM 생태계로 인한 무한한 기능 구현의 자유도이다. 또한, 플랫폼 독립적인 애플리케이션이므로 클라우드 이중화시 동기화가 쉽다는 장점도 있다.

Netflix Zuul의 실운용 사례

  • Netflix Zuul은 넷플릭스가 2013년부터 성공적으로 운용하고 있어 프로덕션 환경에서의 안정성은 입증된 상태이다. 보다 구체적인 사례로는 RIOT GAMES가 대단위로 운용한 실측 결과를 공개한 바 있다. [관련 링크] 이에 따르면 Netflix ZuulCPU 소모적인 성향이 있어 1코어 기준 분당 40,000건(초당 667건)을 처리할 수 있다고 한다. 4코어의 경우 분당 160,000건(초당 2,667건)을 처리할 수 있는 셈이다. 메모리 사용량은 1인스턴스 당 2.5GB 수준에 머물렀다고 한다. 이를 토대로 판단하면 Amazon EC2 기준으로는 c5.xlarge(4코어, 8GB, 시간당 0.192 USD 과금) 인스턴스가 적합하다.

build.gradle

  • Spring Initializr에 방문하여 Gradle 기반의 기본 프로젝트를 생성 후 프로젝트 루트의 build.gradle 파일에 아래 내용을 추가한다.
ext {
    set('springCloudVersion', 'Greenwich.RELEASE')
}

dependencies {
    implementation('org.springframework.cloud:spring-cloud-starter-netflix-zuul') {
        exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
    }
    implementation('org.springframework.boot:spring-boot-starter-undertow')
    implementation('org.springframework.cloud:spring-cloud-starter-netflix-ribbon')
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}
  • spring-cloud-starter-netflix-zuul 아티팩트는 zuul-core 1.3.1(2017-11-01 출시) 버전에 의존적이다. 관리 주체인 Spring Cloud 팀은 향후 zuul-core 2 버전을 지원하지 않을 예정으로 해당 모듈을 자사의 spring-cloud-gateway로 변경할 것을 권장하고 있다. 아마 Spring Cloud 팀이 Netflix Zuul을 경쟁자로 보기 시작하면서 자체적인 생태계를 구축하려는 시도로 보인다.
  • 한편, 분산 스로틀링을 기본 기능을 제공하지 않는데 필요한 경우 써드파티 모듈인 spring-cloud-zuul-ratelimit 아티팩트를 추가하면 된다. [관련 링크]

@EnableZuulProxy

@SpringBootApplication
@EnableZuulProxy
public class ZuulDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(ZuulDemoApplication.class, args);
    }
}
  • @SpringBootApplication이 명시된 애플리케이션 시작점 역할을 수행하는 클래스 레벨에 추가로 @EnableZuulProxy를 명시해주면 애플리케이션이 Netflix Zuul이 내장된 API Gateway이자 Reverse Proxy로 변신한다.

application.yml

spring:
  application:
    name: api-gateway-demo

server:
  port: 8080

ribbon:
  eureka:
    enabled: false

zuul:
  sensitive-headers:
  host:
    connect-timeout-millis: 600000
    socket-timeout-millis: 600000
  routes:
    foo-api:
      path: /foos/**
      url: http://localhost:8081
      stripPrefix: false
    bar-api:
      path: /bars/**
      url: http://localhost:8082
      stripPrefix: false
  • Netflix Zuul 1.x은 외부 API 호출시 클라이언트 사이드 로드 밸런서로 Netflix Ribbon를 사용한다. 또한, Netflix Ribbon는 외부 API 서비스의 물리적인 노드 정보를 발견하는 역할로 Netflix Eureka에 의존한다. 만약 Netflix Eureka(별도 독립 서비스 구축 필요)를 사용하지 않는다면 위와 같이 ribbon.eureka.enabled 옵션을 false로 설정하면 된다.
  • zuul.sensitive-headers에 특정 헤더 이름을 설정하면 라우팅 전에 해당 헤더를 제거할 수 있다. 보안 문제로 라우팅되지 말아야할 헤더가 있을 경우 활용할 수 있다.
  • zuul.host.connect-timeout-millis으로 API 요청 후 연결까지의 타임아웃을 설정할 수 있다. 설정된 타임아웃이 초과했을 경우 ZuulException(내부적으로는 java.net.ConnectException) 예외가 발생한다.
  • zuul.host.socket-timeout-millis으로 API 요청 후 응답까지의 타임아웃을 설정할 수 있다. 설정된 타임아웃이 초과했을 경우 ZuulException(내부적으로는 java.net.SocketTimeoutException) 예외가 발생한다.
  • zuul.routes.url을 직접적으로 명시하면 Netflix Ribbon을 사용하지 않는다.
  • zuul.routes.stripPrefixfalse로 설정하면 라우팅시 urlpath가 그대로 보존되어 결합된다. 인지적으로 가장 자연스러운 설정이다. true(기본값)로 설정시에는 url에서 path 부분은 제거되고 나머지 부분이 추가되어 라우팅된다.

RequestContext

  • RequestContextNetflix Zuul에서 가장 핵심이 되는 클래스이다. ThreadLocal로 정보를 생성하기 때문에 프록시가 진행 중인 쓰레드에 한하여 모든 애플리케이션 영역(주로 아래에서 설명할 ZuulFilter의 전처리 및 후처리에서 참조)에서 현재 프록시 정보를 획득하고 가공할 수 있다.
// 현재 프록시 중인 RequestContext 객체 획득
RequestContext ctx = RequestContext.getCurrentContex();

// 프록시 대상 서비스명
// application.yml에 명시된 zuul.routes.{proxy}
(String) ctx.get("proxy");

// 프록시 대상 호스트명
// application.yml에 명시된 zuul.routes.{proxy}.url
(String) ctx.get("routeHost").toString();

// 프록시 대상 URI
// application.yml에 명시된 zuul.routes.{proxy}.path
(String) ctx.get("requestURI");

// 프록시 과정에서 발생한 예외 객체
(Exception) ctx.get("throwable");

// 프록시 과정에서 발생한 루트 예외 객체 (Apache Commons Lang의 ExceptionUtils 사용)
// 연결 타임아웃시 java.net.ConnectException
// 요청 타임아웃시 java.net.SocketTimeoutException
(Exception) ExceptionUtils.getRootCause((Exception) ctx.get("throwable"));

// 프록시 결과 HTTP 응답 코드
// 연결 타임아웃시 500
// "post" 필터에서만 획득 가능
(int) ctx.get("responseStatusCode");

// 클라이언트 로부터의 요청 객체
(HttpServletRequest) ctx.get("request");

// 클라이언트에 대한 응답 객체
// "post" 필터에서만 획득 가능
(HttpServletResponse) ctx.get("response");

// 클라이언트에 대한 응답을 직접 제어
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(401);
ctx.setResponseBody("Unauthorized");
  • 만약, 실제 응답 바디를 필터 레벨에서 획득하려면 어찌 해야 할까? HttpServletResponse는 데이터의 변경이 불가능한 클래스이다. 이 경우 ctx.getResponseDataStream()으로 응답 바디의 InputStream 객체을 획득하여 필요한 작업를 수행한 후 다시 ctx.setResponseDataStream()에 설정하는 방법이 있다. (응답 바디를 획득하는 행위 자체가 성능의 저하를 일으킬 수 있어 꼭 필요한 경우에만 해야 한다.) [관련 링크]

ZuulFilter

  • 앞서의 작업으로 기본적인 API Gateway 역할은 갖추었다. 추가적으로 Zuul Filter에 대한 구현 클래스를 작성하면 커스텀 헤더의 추가 같은 특수한 가공 작업을 수행할 수 있다. [관련 링크]
@Component
public class FooFilter extends ZuulFilter {

    @Override
    public boolean shouldFilter() {

        return true;
    }

    @Override
    public int filterOrder() {

        return 0;
    }

    @Override
    public String filterType() {

        return "pre";
    }

    @Override
    public Object run() throws ZuulException {

        RequestContext requestContext = RequestContext.getCurrentContext();
        requestContext.addZuulRequestHeader("foo", "bar");

        return null;
    }
}
  • shouldFilter()는 해당 필터의 실행 여부를 의미한다. true를 반환하면 실행된다.
  • filterOrder()는 해당 필터의 실행 순서를 의미한다. 낮은 값을 가질수록 우선순위로 실행된다.
  • filterType()는 해당 필터의 타입을 의미한다. pre(라우팅 전), routing, post(라우팅 후)를 지정할 수 있다.
  • run()이 실제 필터 기능이 작성되는 부분이다. 위 예제에서는 foo 이름을 가진 bar라는 값을 요청 헤더에 추가했다.

예외 처리

  • Netflix Zuul의 프록시는 Filter 레벨에서 실행된다. 필터는 서블릿의 영역으로 Spring MVC의 영역이 아니다. 따라서 컨트롤러 레벨에서 발생하는 예외를 처리할 수 있는 @ControllerAdvice로 발생하는 예외를 처리할 수 없다. 특정 예외 처리 필터를 작성하고 순서를 -1으로 두어 처리할수도 있지만, Whitelabel Error Page 페이지 생성 로직에서 처리하는 것도 방법이 될 수 있다.
@RestController
public class ErrorHandlerController implements ErrorController {

    private static final String ERROR_MAPPING = "/error";

    @RequestMapping(value = ERROR_MAPPING)
    public ResponseEntity<String> error() {

        RequestContext ctx = RequestContext.getCurrentContext();
        Object error = ExceptionUtils.getRootCause((Exception) ctx.get("throwable"));

        // zuul.routes.{proxy}.path 에 정의되지 않은 요청일 경우 응답 처리
        if (error == null) {

            return new ResponseEntity<String>("NOT_FOUND", HttpStatus.NOT_FOUND);
        }

        if (error instanceof Exception) {

            return new ResponseEntity<String>("SERVICE_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE);
        }

        // 예상되지 않은 오류일 경우 응답 처리
        return new ResponseEntity<String>("INTERNAL_SERVER_ERROR", HttpStatus.INTERNAL_SERVER_ERROR);
    }

    @Override
    public String getErrorPath() {

        return ERROR_MAPPING;
    }
}
  • 라우팅할 프록시가 존재하지 않을 경우 곧바로 ErrorController로 요청을 처리한다. 이 경우 유의할 점은 RequestContext.getCurrentContext()로 획득 가능한 필드가 전혀 존재하지 않는다.
  • 라우팅할 프록시가 존재하고, 애플리케이션 오류(라우팅 타임아웃 등) 발생시 pre 필터 > ErrorController > post 필터 순서로 요청을 처리한다.
  • 라우팅할 프록시가 존재하고, 라우팅이 성공적일 경우 pre 필터 > post 필터 순서로 요청을 처리한다.

참고 글

댓글
댓글쓰기 폼