Nginx API Gateway (1/7)

다음 편: [스프링] 2. Nginx 리버스 프록시의 동작 원리

Nginx API Gateway 개념 시리즈

1. 마이크로서비스와 API Gateway (현재 문서)

2. Nginx 리버스 프록시의 동작 원리

3. Rate Limiting과 트래픽 제어

4. Docker로 마이크로서비스 배포하기

백엔드를 하나의 거대한 애플리케이션으로 만들면, 사용자 기능 하나를 수정할 때 결제 기능까지 함께 빌드하고 배포해야 한다. 서비스가 커질수록 이 구조는 발목을 잡는다. 마이크로서비스와 API Gateway는 이 문제를 어떻게 해결하는지 알아본다.

모놀리식 아키텍처의 한계

전통적인 웹 애플리케이션은 하나의 프로젝트에 모든 기능을 담는다. 사용자 관리, 주문 처리, 결제 로직이 전부 같은 코드베이스에 있고, 하나의 JAR로 빌드되어 하나의 서버에서 돌아간다. 이런 구조를 모놀리식 아키텍처라고 부른다.

작은 프로젝트에서는 문제가 없다. 오히려 단순해서 좋다. 하지만 서비스가 커지면 병목이 드러난다.

  • 결제 로직을 한 줄 고쳤는데, 사용자 관리 코드까지 포함해서 전체를 다시 빌드하고 배포해야 한다
  • 주문 서비스에 트래픽이 몰릴 때, 주문 서비스만 따로 스케일 아웃할 수 없다
    • 서버를 늘리려면 전체 애플리케이션을 통째로 복제해야 한다
  • 하나의 서비스에서 메모리 누수가 발생하면, 다른 모든 기능이 함께 죽는다

모놀리식에서는 모든 기능이 하나의 프로세스 안에서 메서드 호출로 연결되어 있다. 이 단단한 결합이 초기에는 편리하지만, 규모가 커지면 변경의 비용을 기하급수적으로 높인다.

block-beta columns 3 Client["클라이언트"]:3 block:monolith["모놀리식 서버"]:3 columns 3 User["사용자 모듈"] Order["주문 모듈"] Payment["결제 모듈"] end DB[("단일 DB")]:3 Client --> monolith monolith --> DB style Client fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style User fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style Order fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style Payment fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style DB fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style monolith fill:#FFF0F0,stroke:#F44336

위 구조에서 보이듯, 모든 모듈이 하나의 서버 안에 묶여 있다. 분홍 영역 전체가 하나의 배포 단위다.

마이크로서비스 아키텍처란

마이크로서비스는 이 문제에 대한 대답이다. 하나의 큰 애플리케이션을 비즈니스 기능 단위로 쪼개서, 각각 독립된 서비스로 만드는 것이다.

사용자 관리는 user-service, 주문 처리는 order-service, 결제는 payment-service로 분리한다. 각 서비스는 자기만의 코드베이스, 자기만의 빌드 파이프라인, 자기만의 배포 주기를 가진다.

핵심 원칙은 세 가지다.

  • 독립 배포 : user-service를 수정해도 order-service는 건드리지 않는다. 각 서비스를 독립적으로 빌드하고 배포할 수 있다.
  • 독립 확장 : 주문에 트래픽이 몰리면 order-service만 3대로 늘리면 된다. payment-service는 1대를 유지해도 상관없다.
  • 장애 격리 : payment-service가 죽어도 user-service와 order-service는 정상 동작한다. 하나의 장애가 전체 시스템을 마비시키지 않는다.

서비스 간 통신은 메서드 호출 대신 HTTP API 또는 메시지 큐로 이루어진다. 같은 프로세스 안이 아니라 네트워크를 통해 대화하는 것이다.

block-beta columns 3 Client["클라이언트"]:3 space:3 block:user["user-service"]:1 columns 1 UC["Controller"] US["Service"] end block:order["order-service"]:1 columns 1 OC["Controller"] OS["Service"] end block:payment["payment-service"]:1 columns 1 PC["Controller"] PS["Service"] end Client --> user Client --> order Client --> payment style Client fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style UC fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style US fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style OC fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style OS fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style PC fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style PS fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style user fill:#f0f8f0,stroke:#4CAF50 style order fill:#fff8f0,stroke:#FF9800 style payment fill:#faf0fa,stroke:#9C27B0

이제 각 서비스가 독립된 배포 단위다. 초록, 주황, 보라 영역이 각각 별도로 빌드되고 배포된다. 하지만 이 그림에는 문제가 하나 있다. 클라이언트가 모든 서비스의 주소를 직접 알아야 한다는 것이다.

클라이언트가 서비스를 직접 호출하면 생기는 문제

마이크로서비스로 쪼개면 클라이언트 입장에서는 갑자기 상대해야 할 서버가 여러 개가 된다. 사용자 정보를 가져오려면 user-service:8081에, 주문을 넣으려면 order-service:8082에, 결제를 하려면 payment-service:8083에 각각 요청해야 한다.

이게 왜 문제인가?

  • 주소 관리 : 클라이언트가 모든 서비스의 호스트와 포트를 알고 있어야 한다. 서비스가 10개, 20개로 늘어나면 관리가 불가능해진다.
  • 공통 관심사 중복 : 인증, 로깅, Rate Limiting 같은 횡단 관심사를 모든 서비스에 각각 구현해야 한다. user-service에도, order-service에도, payment-service에도 같은 인증 로직이 들어간다.
  • 보안 취약점 : 모든 서비스가 외부에 직접 노출된다. 내부 서비스 간 통신까지 클라이언트가 접근할 수 있게 된다.
  • 프로토콜 결합 : 백엔드 서비스가 gRPC로 전환하려 해도, 클라이언트가 HTTP로 직접 호출하고 있으면 변경이 어렵다.

요청 흐름을 보면 문제가 명확해진다.

sequenceDiagram autonumber participant C as 클라이언트 participant U as user-service
:8081 participant O as order-service
:8082 participant P as payment-service
:8083 rect rgb(255, 230, 230) Note over C, P: 클라이언트가 모든 서비스 주소를 알아야 함 C->>U: GET /api/users (8081) U-->>C: 사용자 목록 C->>O: POST /api/orders (8082) O-->>C: 주문 생성 C->>P: POST /api/payments (8083) P-->>C: 결제 완료 end

분홍 영역을 보면, 클라이언트가 세 개의 서로 다른 주소에 직접 요청을 보내고 있다. 서비스가 늘어날 때마다 클라이언트는 새로운 주소를 알아야 하고, 각 서비스마다 인증 검증을 중복으로 수행해야 한다.

API Gateway 패턴

API Gateway는 클라이언트와 마이크로서비스 사이에 위치하는 단일 진입점이다. 클라이언트는 Gateway의 주소 하나만 알면 된다. Gateway가 요청을 분석해서 적절한 백엔드 서비스로 라우팅해준다.

건물의 안내 데스크를 떠올리면 된다. 방문객이 각 부서를 직접 찾아다니는 대신, 안내 데스크에서 "어느 부서를 찾으시나요?"라고 물어보고 안내해주는 것이다. API Gateway가 바로 이 안내 데스크 역할을 한다.

Gateway가 처리하는 횡단 관심사는 다양하다.

  • 라우팅 : /api/users 요청은 user-service로, /api/orders는 order-service로 보낸다
  • 로드 밸런싱 : 같은 서비스의 인스턴스가 여러 개일 때 트래픽을 분산한다
  • Rate Limiting : 특정 클라이언트가 과도한 요청을 보내면 차단한다
  • 인증/인가 : 모든 요청의 토큰을 검증하고, 유효하지 않으면 백엔드까지 가지 않고 차단한다
  • 로깅/모니터링 : 모든 요청을 한 곳에서 기록한다
  • 프로토콜 변환 : 클라이언트는 HTTP, 내부는 gRPC 같은 변환이 가능하다

같은 시나리오를 Gateway를 통해 처리하면 흐름이 달라진다.

sequenceDiagram autonumber participant C as 클라이언트 participant GW as API Gateway
:80 participant U as user-service
:8081 participant O as order-service
:8082 participant P as payment-service
:8083 rect rgb(232, 248, 232) Note over C, GW: 클라이언트는 Gateway만 알면 됨 C->>GW: GET /api/users GW->>U: 프록시 전달 U-->>GW: 사용자 목록 GW-->>C: 응답 end rect rgb(240, 248, 255) C->>GW: POST /api/orders GW->>O: 프록시 전달 O-->>GW: 주문 생성 GW-->>C: 응답 end rect rgb(240, 248, 255) C->>GW: POST /api/payments GW->>P: 프록시 전달 P-->>GW: 결제 완료 GW-->>C: 응답 end

초록 영역에서 보이듯, 클라이언트는 Gateway의 80번 포트 하나만 알면 된다. 8081, 8082, 8083 같은 내부 서비스의 포트는 Gateway 뒤에 숨겨진다. 인증이나 Rate Limiting 같은 공통 로직도 Gateway 한 곳에서 처리한다.

API Gateway 구현 방식

API Gateway를 구현하는 도구는 크게 세 가지 계열로 나뉜다. 각각의 특성이 다르기 때문에 상황에 맞게 선택해야 한다.

Nginx 계열

Nginx는 원래 웹서버이자 리버스 프록시다. 설정 파일 기반으로 라우팅과 Rate Limiting을 처리한다. C로 작성되어 성능이 매우 뛰어나고 리소스 사용량이 적다. 이 시리즈에서 사용하는 도구가 바로 Nginx다.

  • 정적 파일 서빙, 리버스 프록시, 로드 밸런싱을 기본으로 제공한다
  • 설정 파일만으로 동작하므로 별도의 코딩이 필요 없다
  • 단, 복잡한 비즈니스 로직 기반 라우팅에는 한계가 있다

Spring Cloud Gateway

Java 생태계에서 가장 많이 쓰는 API Gateway다. Spring Boot 기반이라 Java 코드로 라우팅 규칙을 세밀하게 제어할 수 있다. 필터 체인으로 요청/응답을 가공하고, Spring Security와 자연스럽게 통합된다.

  • 비즈니스 로직 기반 라우팅이 필요할 때 적합하다
  • 단, JVM 위에서 돌아가므로 Nginx보다 리소스를 더 사용한다

클라우드 관리형 서비스

AWS API Gateway, Azure API Management 같은 서비스형 제품도 있다. 인프라 관리 없이 사용할 수 있지만, 벤더 종속성이 생기고 세밀한 제어가 어렵다.

flowchart TD Q{"API Gateway
선택 기준"} Q -->|"성능 우선
설정 기반"| N["Nginx"] Q -->|"Java 코드로
세밀한 제어"| S["Spring Cloud
Gateway"] Q -->|"인프라 관리
최소화"| C["클라우드
관리형"] N --- NP["경량, 고성능
설정 파일만으로 동작"] S --- SP["Spring 생태계 통합
필터 체인으로 확장"] C --- CP["관리 부담 없음
벤더 종속성"] style Q fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style N fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style S fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style C fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style NP fill:#f0f8f0,stroke:#4CAF50,color:#000 style SP fill:#f0f8ff,stroke:#2196F3,color:#000 style CP fill:#faf0fa,stroke:#9C27B0,color:#000

이 시리즈에서 구축할 아키텍처

이 시리즈에서는 Nginx를 API Gateway로 사용해서 3개의 Spring Boot 마이크로서비스를 묶는 구조를 만든다. Docker Compose로 전체를 컨테이너화해서 한 번에 띄우는 것이 최종 목표다.

block-beta columns 3 Client["클라이언트 (브라우저)"]:3 GW["Nginx API Gateway :80"]:3 block:user["user-service :8081"]:1 columns 1 U1["UserController"] U2["UserService"] end block:order["order-service :8082"]:1 columns 1 O1["OrderController"] O2["OrderService"] end block:payment["payment-service :8083"]:1 columns 1 P1["PaymentController"] P2["PaymentService"] end Network["Docker Bridge Network"]:3 Client --> GW GW --> user GW --> order GW --> payment style Client fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style GW fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style U1 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style U2 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style O1 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style O2 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style P1 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style P2 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Network fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style user fill:#f0f8f0,stroke:#4CAF50 style order fill:#f0f8f0,stroke:#4CAF50 style payment fill:#f0f8f0,stroke:#4CAF50

각 구성 요소의 역할을 정리하면 다음과 같다.

  • Nginx API Gateway : 80 포트로 모든 요청을 받고, URL 경로에 따라 백엔드 서비스로 라우팅한다. Rate Limiting, 프록시 헤더 설정, 정적 파일 서빙도 담당한다.
  • user-service : 사용자 CRUD API를 제공한다. 8081 포트에서 동작한다.
  • order-service : 주문 CRUD API를 제공한다. 8082 포트에서 동작한다.
  • payment-service : 결제 CRUD API를 제공한다. 8083 포트에서 동작한다.
  • Docker Bridge Network : 4개의 컨테이너가 같은 네트워크 안에서 서비스 이름으로 서로를 찾는다. user-service:8081처럼 DNS 이름으로 통신한다.
DB 없이 ConcurrentHashMap을 사용하는 이유

이 실습에서 각 서비스는 데이터베이스 대신 메모리 저장소를 사용한다. API Gateway의 라우팅과 Rate Limiting 동작을 확인하는 것이 목적이므로, DB 설정이라는 부수적인 복잡성을 제거한 것이다.

자주 하는 실수

모든 서비스를 외부에 노출시키기

API Gateway를 도입했는데도 각 마이크로서비스의 포트를 외부에 열어두는 경우가 있다. Gateway의 핵심 가치 중 하나는 내부 서비스를 외부로부터 격리하는 것이다. Docker Compose에서 백엔드 서비스의 ports 매핑을 제거하고, Gateway만 80 포트를 노출해야 한다.

[!DANGER] Gateway에 비즈니스 로직을 넣기

API Gateway는 라우팅, 인증, Rate Limiting 같은 횡단 관심사만 처리해야 한다. "주문 생성 시 사용자 잔액을 확인한다" 같은 비즈니스 로직을 Gateway에 넣으면, Gateway가 비대해지고 각 서비스의 독립성이 무너진다. 이런 로직은 반드시 개별 서비스 안에 있어야 한다.

[!DANGER] 마이크로서비스를 무조건 좋은 것으로 생각하기

마이크로서비스는 분산 시스템의 복잡성을 수반한다. 네트워크 장애, 데이터 일관성, 서비스 간 디버깅 등 모놀리식에서는 없던 문제가 생긴다. 팀 규모가 작거나 서비스가 단순하다면, 모놀리식이 올바른 선택일 수 있다. MSA는 복잡성을 감수할 만큼 서비스가 크고 팀이 여러 개일 때 빛난다.