Nginx API Gateway 실습 시리즈

1. Spring Boot 마이크로서비스 구성하기

2. Nginx API Gateway 설정하기 (현재 문서)

3. Docker Compose로 전체 시스템 실행하기

이전 편에서 만든 3개의 Spring Boot 서비스를 하나로 묶어줄 관문이 필요하다. 이번 편에서는 Nginx를 API Gateway로 설정해서, URL 경로에 따라 적절한 서비스로 라우팅하고 Rate Limiting까지 적용한다. 테스트 대시보드 프론트엔드도 함께 구성한다.

왜 Nginx 설정을 직접 작성하는가

Nginx를 Docker에서 쓸 때 기본 이미지에 이미 설정 파일이 포함되어 있다. 하지만 API Gateway로 쓰려면 라우팅 규칙, 프록시 설정, Rate Limiting을 직접 정의해야 한다. 기본 설정은 정적 파일 서빙만 하기 때문이다.

이 편에서 만드는 파일은 세 가지다.

nginx/
├── Dockerfile              ← Nginx 이미지 빌드
├── conf/api-gateway.conf   ← 핵심: 라우팅 + Rate Limiting 설정
└── html/
    ├── index.html           ← 테스트 대시보드 UI
    ├── script.js            ← API 테스트 로직
    └── style.css            ← 대시보드 스타일

사전 준비

프로젝트 루트에 nginx 폴더를 만들고 하위 디렉토리를 구성한다.

mkdir -p nginx/conf nginx/html

API Gateway 설정 파일 작성하기

단계 1 — upstream 정의

먼저 Nginx가 요청을 보낼 백엔드 서비스의 주소를 등록한다. nginx/conf/api-gateway.conf 파일을 만든다.

# 백엔드 서비스 정의
upstream user-service {
    server user-service:8081;
}

upstream order-service {
    server order-service:8082;
}

upstream payment-service {
    server payment-service:8083;
}

server user-service:8081에서 user-service는 Docker Compose의 서비스 이름이다. Docker가 이 이름을 컨테이너 IP로 해석해준다. 아직 Docker Compose를 작성하지 않았지만, 이름을 미리 맞춰두는 것이다.

단계 2 — Rate Limiting 규칙 선언

upstream 위에 Rate Limiting 규칙을 선언한다. limit_req_zone은 server 블록 바깥에 위치해야 한다.

# Rate Limiting 설정
limit_req_zone $binary_remote_addr zone=users_limit:10m rate=20r/m;
limit_req_zone $binary_remote_addr zone=orders_limit:10m rate=15r/m;
limit_req_zone $binary_remote_addr zone=payments_limit:10m rate=10r/m;

# Rate Limiting 에러 코드 설정(429)
limit_req_status 429;

$binary_remote_addr클라이언트 IP별로 요청을 구분한다. 서비스별로 zone을 분리해서 차등 제한을 건다.

limit_req_status 429는 기본 503 대신 429를 반환하도록 하는 설정이다. 이 한 줄이 없으면 Rate Limit 초과 시 "서비스 장애"와 "요청 초과"를 구분할 수 없다.

단계 3 — server 블록과 location 작성

server 블록 안에 각 URL 패턴에 대한 처리를 정의한다.

server {
    listen 80;
    server_name localhost;

    # 메인 페이지 (테스트 UI)
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
    }

    # 정적 파일 (CSS, JS, 이미지)
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg)$ {
        root /usr/share/nginx/html;
    }

    # API Gateway 헬스체크
    location /health {
        add_header Content-Type application/json;
        return 200 '{"status":"UP","service":"api-gateway","message":"API Gateway is healthy"}';
    }

/health는 백엔드로 프록시하지 않고 Nginx가 직접 JSON을 반환한다. Gateway 자체의 상태를 확인하는 엔드포인트다.

이어서 각 서비스로의 라우팅을 추가한다.

    # User Service 라우팅 (분당 20 요청 제한)
    location /api/users {
        limit_req zone=users_limit burst=5 nodelay;

        proxy_pass http://user-service;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        add_header X-Gateway-Service "user-service" always;
        add_header X-Rate-Limit "20/min" always;
    }

    # Order Service 라우팅 (분당 15 요청 제한)
    location /api/orders {
        limit_req zone=orders_limit burst=3 nodelay;

        proxy_pass http://order-service;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        add_header X-Gateway-Service "order-service" always;
        add_header X-Rate-Limit "15/min" always;
    }

    # Payment Service 라우팅 (분당 10 요청 제한)
    location /api/payments {
        limit_req zone=payments_limit burst=3 nodelay;

        proxy_pass http://payment-service;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        add_header X-Gateway-Service "payment-service" always;
        add_header X-Rate-Limit "10/min" always;
    }
}

세 location의 구조가 동일하다. 달라지는 부분은 zone 이름, burst 값, proxy_pass 대상, 커스텀 헤더 값뿐이다.

각 location 안에서 일어나는 일을 순서대로 정리하면 이렇다.

sequenceDiagram autonumber participant C as 클라이언트 participant N as Nginx participant B as 백엔드 서비스 C->>N: GET /api/users rect rgb(255, 243, 224) Note over N: 1. limit_req 검사
→ 허용 범위 내인지 확인 end rect rgb(232, 248, 232) Note over N: 2. proxy_set_header
→ 원본 클라이언트 정보 보존 N->>B: GET /api/users
+ Host, X-Real-IP 등 B-->>N: 200 OK + JSON end rect rgb(240, 248, 255) Note over N: 3. add_header
→ 응답에 Gateway 정보 추가 N-->>C: 200 OK + JSON
+ X-Gateway-Service
+ X-Rate-Limit end

주황 영역에서 Rate Limiting 검사를 먼저 한다. 통과하면 초록 영역에서 프록시 헤더를 붙여 백엔드에 전달한다. 파란 영역에서 응답에 커스텀 헤더를 추가한 뒤 클라이언트에게 돌려준다.

Nginx Dockerfile 작성하기

단계 1 — 기본 이미지와 설정 교체

FROM nginx:alpine

LABEL maintainer="Codeit"
LABEL description="API Gateway using Nginx for Microservices"
LABEL version="1.0.0"

RUN apk add --no-cache curl

RUN rm /etc/nginx/conf.d/default.conf

COPY conf/api-gateway.conf /etc/nginx/conf.d/

nginx:alpine은 경량 Linux 기반의 Nginx 이미지다. curl은 헬스체크에 필요하므로 설치한다. 기본 설정 파일인 default.conf를 삭제하고, 직접 작성한 api-gateway.conf를 복사한다.

기본 설정을 삭제하는 이유는 충돌 방지다. default.conf에도 80번 포트를 사용하는 server 블록이 있어서, 우리 설정과 충돌한다.

단계 2 — 정적 파일과 헬스체크

COPY html/ /usr/share/nginx/html/

RUN mkdir -p /var/log/nginx && \
    chown -R nginx:nginx /var/log/nginx

EXPOSE 80

HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
    CMD curl -f http://localhost/health || exit 1

CMD ["nginx", "-g", "daemon off;"]

html/ 폴더 전체를 Nginx의 기본 웹 루트에 복사한다. daemon off;는 Nginx를 포그라운드로 실행하라는 뜻이다. Docker 컨테이너는 메인 프로세스가 종료되면 컨테이너도 종료되기 때문에, 백그라운드 데몬으로 돌리면 안 된다.

테스트 대시보드 만들기

테스트 대시보드는 브라우저에서 API Gateway의 동작을 확인하는 프론트엔드다. 서비스 헬스체크, API 호출 테스트, Rate Limiting 스트레스 테스트 기능을 제공한다.

단계 1 — HTML 구조

nginx/html/index.html을 작성한다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, initial-scale=1.0">
    <title>API Gateway 테스트 대시보드</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
    <header>
        <h1>API Gateway 마이크로서비스 테스트</h1>
        <p class="subtitle">Nginx를 통한 API 라우팅 및 Rate Limiting 테스트</p>
    </header>

    <main>
        <section class="status-section">
            <h2>서비스 상태 확인</h2>
            <div class="service-grid">
                <div class="service-card">
                    <h3>API Gateway</h3>
                    <div class="status-indicator" id="gateway-status">
                        확인 중...</div>
                    <button onclick="checkGatewayHealth()">상태 확인</button>
                </div>
                <div class="service-card">
                    <h3>User Service</h3>
                    <div class="status-indicator" id="user-status">
                        확인 중...</div>
                    <button onclick="checkServiceHealth('users')">
                        상태 확인</button>
                </div>
                <div class="service-card">
                    <h3>Order Service</h3>
                    <div class="status-indicator" id="order-status">
                        확인 중...</div>
                    <button onclick="checkServiceHealth('orders')">
                        상태 확인</button>
                </div>
                <div class="service-card">
                    <h3>Payment Service</h3>
                    <div class="status-indicator" id="payment-status">
                        확인 중...</div>
                    <button onclick="checkServiceHealth('payments')">
                        상태 확인</button>
                </div>
            </div>
        </section>

        <section class="test-section">
            <h2>API 기능 테스트</h2>

            <div class="test-group">
                <h3>User Service (Rate Limit: 20/분)</h3>
                <div class="test-controls">
                    <button onclick="testAPI('/api/users', 'GET')">
                        전체 사용자 조회</button>
                    <button onclick="testAPI('/api/users/1', 'GET')">
                        사용자 상세 조회</button>
                    <button onclick="testUserCreate()">
                        사용자 생성 테스트</button>
                    <button onclick="stressTest('users', 20)">
                        Rate Limit 테스트 (20회)</button>
                </div>
            </div>

            <div class="test-group">
                <h3>Order Service (Rate Limit: 15/분)</h3>
                <div class="test-controls">
                    <button onclick="testAPI('/api/orders', 'GET')">
                        전체 주문 조회</button>
                    <button onclick="testOrderCreate()">
                        주문 생성 테스트</button>
                    <button onclick="stressTest('orders', 15)">
                        Rate Limit 테스트 (15회)</button>
                </div>
            </div>

            <div class="test-group">
                <h3>Payment Service (Rate Limit: 10/분)</h3>
                <div class="test-controls">
                    <button onclick="testAPI('/api/payments', 'GET')">
                        전체 결제 조회</button>
                    <button onclick="testPaymentCreate()">
                        결제 생성 테스트</button>
                    <button onclick="stressTest('payments', 10)">
                        Rate Limit 테스트 (10회)</button>
                </div>
            </div>
        </section>

        <section class="result-section">
            <h2>테스트 결과</h2>
            <div class="result-container">
                <div class="result-header">
                    <button onclick="clearResults()">결과 지우기</button>
                </div>
                <div id="test-results" class="result-content">
                    <p class="placeholder">
                        테스트를 실행하면 결과가 여기에 표시됩니다.</p>
                </div>
            </div>
        </section>
    </main>
</div>

<script src="script.js"></script>
</body>
</html>

각 서비스 카드에 헬스체크 버튼API 테스트 버튼이 있다. "Rate Limit 테스트" 버튼은 지정한 횟수만큼 동시 요청을 보내서 429 응답이 오는지 확인한다.

단계 2 — JavaScript 핵심 로직

nginx/html/script.js에서 API 호출과 테스트 로직을 구현한다. 핵심 클래스인 APIGatewayTester의 주요 메서드를 살펴본다.

class APIGatewayTester {
    constructor() {
        this.baseURL = '';
        this.testResults = document.getElementById('test-results');
        this.init();
    }

    init() {
        this.checkAllServices();
    }

    async makeRequest(url, method = 'GET', data = null) {
        const options = {
            method: method,
            headers: { 'Content-Type': 'application/json' }
        };
        if (data && method !== 'GET') {
            options.body = JSON.stringify(data);
        }

        try {
            const response = await fetch(url, options);
            const responseData = await response.json();
            return {
                status: response.status,
                ok: response.ok,
                data: responseData,
                headers: Object.fromEntries(response.headers.entries())
            };
        } catch (error) {
            return { status: 0, ok: false, error: error.message };
        }
    }
}

baseURL이 빈 문자열이라는 점에 주목해야 한다. 대시보드 HTML이 Nginx에서 서빙되기 때문에, fetch 요청은 같은 호스트(Nginx)로 전송된다. 별도의 API 서버 주소를 지정할 필요가 없다.

응답 헤더를 Object.fromEntries(response.headers.entries())로 변환하는 부분도 중요하다. 이렇게 해야 X-Gateway-ServiceX-Rate-Limit 같은 커스텀 헤더를 읽을 수 있다.

Rate Limiting 스트레스 테스트 함수는 Promise.all로 병렬 요청을 보낸다.

async stressTest(serviceName, requestCount) {
    const endpoint = `/api/${serviceName}`;
    let successCount = 0;
    let rateLimitCount = 0;

    const requests = Array.from(
        {length: requestCount},
        (_, i) => this.makeRequest(endpoint).then(result => {
            if (result.status === 429) rateLimitCount++;
            else if (result.ok) successCount++;
            return result;
        })
    );

    await Promise.all(requests);
    // 결과: successCount + rateLimitCount 표시
}

동시에 모든 요청을 발사하므로 Rate Limiting이 잘 걸리는지 확인할 수 있다. status === 429인 응답의 수가 곧 Rate Limiting이 차단한 요청 수다.

코드 뜯어보기

api-gateway.conf의 설정 흐름

설정 파일의 지시어들이 어떤 순서로 처리되는지 정리하면 전체 그림이 보인다.

flowchart TD REQ(["클라이언트 요청"]) REQ --> LOC{"location 매칭"} LOC -->|"/api/users"| RL1["limit_req 검사
users_limit"] LOC -->|"/api/orders"| RL2["limit_req 검사
orders_limit"] LOC -->|"/api/payments"| RL3["limit_req 검사
payments_limit"] LOC -->|"/"| STATIC["정적 파일 서빙"] LOC -->|"/health"| HEALTH["직접 200 반환"] RL1 -->|"허용"| PROXY1["proxy_pass
→ user-service:8081"] RL1 -->|"초과"| REJECT["429 거부"] RL2 -->|"허용"| PROXY2["proxy_pass
→ order-service:8082"] RL2 -->|"초과"| REJECT RL3 -->|"허용"| PROXY3["proxy_pass
→ payment-service:8083"] RL3 -->|"초과"| REJECT style REQ fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style LOC fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style RL1 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style RL2 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style RL3 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style PROXY1 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style PROXY2 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style PROXY3 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style STATIC fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style HEALTH fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style REJECT fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000

테스트 대시보드의 검증 포인트

대시보드에서 확인해야 할 것들을 정리한다.

  • 헬스체크 : 4개 서비스 모두 "정상" 상태인지
  • 라우팅 : 응답의 X-Gateway-Service 헤더가 올바른 서비스 이름을 가리키는지
  • Rate Limiting : 스트레스 테스트에서 429 응답이 발생하는지
  • CRUD : 생성/조회 API가 정상 동작하는지

자주 하는 실수

default.conf를 삭제하지 않기

Nginx 기본 이미지에 포함된 default.conf도 80번 포트를 사용한다. 이 파일을 삭제하지 않으면 우리 설정과 충돌해서 예상과 다른 동작이 발생한다. Dockerfile에서 RUN rm /etc/nginx/conf.d/default.conf를 반드시 포함해야 한다.

[!DANGER] limit_req_zone을 server 블록 안에 넣기

limit_req_zone은 http 블록 레벨에 위치해야 한다. server나 location 블록 안에 넣으면 Nginx가 설정 오류를 발생시킨다. conf.d/ 디렉토리의 파일은 http 블록 안에서 include되므로, 파일 최상위에 쓰면 된다.

[!DANGER] 프론트엔드에서 CORS 문제가 생기는 경우

대시보드가 Nginx에서 서빙되고 API도 같은 Nginx를 통해 요청하므로, 이 실습에서는 CORS 문제가 발생하지 않는다. 하지만 별도 도메인에서 프론트엔드를 서빙하는 경우 Nginx에 CORS 헤더를 추가해야 한다. 이 점은 운영 환경에서 주의해야 한다.