Nginx API Gateway (7/7)

이전 편: [스프링] 6. Nginx API Gateway 설정하기

이 문서가 시리즈의 마지막 편입니다.

Nginx API Gateway 실습 시리즈

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

2. Nginx API Gateway 설정하기

3. Docker Compose로 전체 시스템 실행하기 (현재 문서)

[!QUESTION] 면접 Q&A

Q. Nginx의 limit_req에서 burst와 nodelay를 쓰지 않으면 어떻게 되는가?

burst 없이는 rate를 초과하는 모든 요청이 즉시 거부된다. rate=20r/m이면 3초 안에 2번째 요청이 오면 바로 429다. burst를 쓰면 초과 요청을 대기열에 넣어 순차 처리한다. nodelay까지 쓰면 대기열의 요청을 지연 없이 즉시 처리하되, burst 슬롯만 소모시킨다. 현실적으로 burst + nodelay 조합이 가장 많이 쓰인다.

Q. Docker Compose의 depends_on과 healthcheck의 차이는?

함정이 있는 질문이다. depends_on은 컨테이너의 시작 순서만 보장한다. 컨테이너 프로세스가 뜨면 바로 다음 서비스를 시작한다. Spring Boot가 초기화를 끝내기 전에 Gateway가 올라오면 502가 발생한다. depends_oncondition: service_healthy를 추가하면 healthcheck까지 통과한 후에 다음 서비스를 시작하므로, 실제 서비스 준비 완료까지 기다릴 수 있다.

Q. proxy_set_header에서 X-Forwarded-For와 X-Real-IP의 차이는?

X-Real-IP직전 클라이언트의 IP 하나만 담는다. X-Forwarded-For는 요청이 거쳐온 모든 프록시 체인의 IP 목록을 담는다. 프록시가 하나뿐이면 값이 같지만, CDN → Nginx → App처럼 다단 프록시 환경에서는 X-Forwarded-For에 여러 IP가 쉼표로 나열된다. 단, 이 헤더는 클라이언트가 위조할 수 있으므로, 신뢰할 수 있는 프록시가 추가한 부분만 믿어야 한다.

이전 편에서 Spring Boot 서비스 3개와 Nginx API Gateway를 각각 만들었다. 이번 편에서는 Docker Compose로 이 4개의 컨테이너를 한 번에 띄우고, 테스트 대시보드로 전체 시스템이 정상 동작하는지 검증한다.

왜 Docker Compose가 필요한가

4개의 서비스를 각각 docker build하고 docker run으로 실행하려면, 매번 8개의 명령어를 타이핑해야 한다. 네트워크 설정, 환경 변수, 실행 순서까지 신경 쓰면 관리가 복잡해진다.

Docker Compose는 이 모든 것을 하나의 YAML 파일로 정의한다. docker compose up 한 줄이면 빌드부터 네트워크 연결까지 전부 처리된다.

docker-compose.yml 작성하기

단계 1 — 백엔드 서비스 정의

프로젝트 루트에 docker-compose.yml을 만든다. 백엔드 3개 서비스부터 작성한다.

services:
  user-service:
    build:
      context: ./user-service
      dockerfile: Dockerfile
    container_name: user-service
    networks:
      - microservices-network
    environment:
      - SPRING_PROFILES_ACTIVE=docker
      - SERVER_PORT=8081
    healthcheck:
      test: ["CMD", "curl", "-f",
             "http://localhost:8081/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  order-service:
    build:
      context: ./order-service
      dockerfile: Dockerfile
    container_name: order-service
    networks:
      - microservices-network
    environment:
      - SPRING_PROFILES_ACTIVE=docker
      - SERVER_PORT=8082
    healthcheck:
      test: ["CMD", "curl", "-f",
             "http://localhost:8082/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  payment-service:
    build:
      context: ./payment-service
      dockerfile: Dockerfile
    container_name: payment-service
    networks:
      - microservices-network
    environment:
      - SPRING_PROFILES_ACTIVE=docker
      - SERVER_PORT=8083
    healthcheck:
      test: ["CMD", "curl", "-f",
             "http://localhost:8083/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3

세 서비스 모두 같은 네트워크에 연결하고, ports 매핑이 없다는 점에 주목해야 한다. 외부에서 직접 접근할 수 없게 만든 것이다. Gateway를 통해서만 접근 가능하도록 하는 것이 API Gateway 패턴의 핵심이다.

container_name을 서비스 이름과 동일하게 지정하는 이유가 있다. Nginx 설정의 server user-service:8081에서 이 이름으로 DNS 조회를 하기 때문이다.

단계 2 — API Gateway 서비스 추가

백엔드 서비스 정의 위에 Gateway를 추가한다.

services:
  api-gateway:
    build:
      context: ./nginx
      dockerfile: Dockerfile
    container_name: api-gateway
    ports:
      - "80:80"
    depends_on:
      - user-service
      - order-service
      - payment-service
    networks:
      - microservices-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/"]
      interval: 30s
      timeout: 10s
      retries: 3

유일하게 ports를 선언하는 서비스다. 호스트의 80번 포트가 Gateway 컨테이너의 80번 포트와 연결된다. 브라우저에서 localhost에 접속하면 이 Gateway로 들어온다.

depends_on으로 세 서비스가 먼저 시작되도록 한다. 하지만 앞서 개념 편에서 다뤘듯이, 이것은 시작 순서만 보장할 뿐 서비스 준비 완료를 보장하지는 않는다.

단계 3 — 네트워크 정의

파일 하단에 네트워크를 정의한다.

networks:
  microservices-network:
    driver: bridge

bridge는 Docker의 기본 네트워크 드라이버다. 같은 네트워크에 연결된 컨테이너끼리 서비스 이름으로 DNS 해석이 가능해진다. user-service라는 이름으로 요청을 보내면 Docker DNS가 해당 컨테이너의 IP를 반환한다.

Spring Boot Dockerfile 작성

각 서비스의 루트에 Dockerfile을 만든다. 세 서비스 모두 같은 구조이고 포트만 다르다.

# 빌드 스테이지
FROM eclipse-temurin:17-jdk-jammy AS builder
WORKDIR /app
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
COPY src src
RUN chmod +x ./gradlew
RUN ./gradlew clean bootJar

# 실행 스테이지
FROM eclipse-temurin:17-jre-jammy
LABEL maintainer="Codeit"
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8081
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
  CMD curl -f http://localhost:8081/actuator/health || exit 1
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC -XX:+UseStringDeduplication"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

order-service는 EXPOSE 8082, payment-service는 EXPOSE 8083으로 포트만 변경한다. HEALTHCHECK의 포트도 맞춰서 수정한다.

멀티스테이지 빌드의 효과를 다시 한번 확인해보면, 빌드 스테이지의 JDK, Gradle, 소스 코드는 최종 이미지에 포함되지 않는다. COPY --from=builder로 빌드된 JAR만 가져온다.

빌드 및 실행

단계 1 — 전체 빌드

프로젝트 루트에서 다음 명령을 실행한다.

docker compose build

4개의 이미지를 빌드한다. Spring Boot 서비스는 Gradle 빌드가 포함되므로 첫 빌드에 시간이 걸린다. 이후 소스 변경이 없으면 Docker 캐시가 작동해서 빠르게 끝난다.

단계 2 — 전체 시스템 실행

docker compose up -d

-d 플래그는 백그라운드 실행이다. 4개의 컨테이너가 순서대로 시작된다.

단계 3 — 상태 확인

docker compose ps

모든 서비스가 Up 상태이고 healthcheck가 healthy인지 확인한다. Spring Boot 서비스는 시작하는 데 10~30초 걸리므로, 처음에는 starting 상태일 수 있다.

docker compose logs -f api-gateway

특정 서비스의 로그를 실시간으로 볼 수 있다. 문제가 생기면 여기서 원인을 확인한다.

전체 시스템이 올라간 뒤의 아키텍처를 다시 한번 확인한다.

block-beta columns 4 Browser["브라우저
localhost"]:4 space:4 GW["Nginx API Gateway
:80 (외부 노출)"]:4 space:4 U("user-service
:8081"):1 space:1 O("order-service
:8082"):1 P("payment-service
:8083"):1 NET["microservices-network (bridge)"]:4 Browser --> GW GW --> U GW --> O GW --> P style Browser fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style GW fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style U fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style O fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style P fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style NET fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000

테스트 대시보드로 검증하기

단계 1 — 대시보드 접속

브라우저에서 http://localhost에 접속한다. 테스트 대시보드가 표시되면 Nginx 정적 파일 서빙이 정상이라는 뜻이다.

단계 2 — 헬스체크

4개의 "상태 확인" 버튼을 클릭한다. 모든 서비스가 "정상"으로 표시되면 라우팅이 잘 작동하는 것이다.

이 과정에서 일어나는 일을 흐름으로 보면 이렇다.

sequenceDiagram autonumber participant B as 브라우저 participant N as Nginx :80 participant U as user-service :8081 B->>N: GET /api/users/health rect rgb(255, 243, 224) Note over N: location /api/users 매칭 Note over N: limit_req 검사 → 허용 end rect rgb(232, 248, 232) N->>U: GET /api/users/health
+ proxy 헤더 Note right of U: UserController.healthCheck() U-->>N: 200 OK
{"success":true,"data":{"status":"UP"}} end rect rgb(240, 248, 255) Note over N: add_header
X-Gateway-Service: user-service N-->>B: 200 OK + Gateway 헤더 end Note over B: 대시보드에 "정상" 표시

단계 3 — API 기능 테스트

"전체 사용자 조회" 버튼을 클릭한다. 결과 영역에 5명의 샘플 사용자 데이터가 표시된다. "사용자 생성 테스트" 버튼도 눌러보고, 다시 조회해서 6명이 되는지 확인한다.

Order와 Payment 서비스도 같은 방식으로 테스트한다.

단계 4 — Rate Limiting 검증

이 실습의 하이라이트다. "Rate Limit 테스트 (20회)" 버튼을 클릭하면, 20개의 요청이 동시에 발사된다.

sequenceDiagram autonumber participant B as 브라우저 participant N as Nginx B->>N: 20개 동시 요청 (Promise.all) rect rgb(232, 248, 232) Note over N: 1~6번째: 통과
(rate 1 + burst 5) N-->>B: 200 OK (6회) end rect rgb(255, 230, 230) Note over N: 7~20번째: 거부
(burst 초과) N-->>B: 429 Too Many Requests (14회) end Note over B: 결과 표시:
성공 6회, Rate Limit 14회

초록 영역에서 6개 요청이 통과하고, 분홍 영역에서 14개가 429로 거부된다. 대시보드의 결과에 "Rate Limiting이 정상적으로 작동하고 있습니다!"가 표시되면 성공이다.

Rate Limit 테스트 재실행 시 주의

burst 슬롯은 rate 속도로 회복된다. rate=20r/m이면 3초에 1개씩 회복된다. 테스트를 바로 다시 실행하면 burst가 아직 회복되지 않아서 모든 요청이 429로 돌아올 수 있다. 최소 15초 이상 기다린 뒤 다시 테스트해야 한다.

문제 해결

502 Bad Gateway

Gateway는 올라왔는데 백엔드 서비스가 아직 준비되지 않은 경우 발생한다. docker compose logs user-service로 Spring Boot 시작 로그를 확인하고, "Started UserServiceApplication" 메시지가 보일 때까지 기다린다.

서비스 상태가 "오류"

docker compose ps로 컨테이너 상태를 확인한다. healthcheck가 실패하면 unhealthy로 표시된다. docker compose logs <서비스명>으로 에러 로그를 확인한다.

Rate Limit 테스트에서 429가 하나도 안 나올 때

limit_req_zone의 rate가 너무 높거나, burst가 충분히 크면 모든 요청이 통과할 수 있다. api-gateway.conf의 설정을 확인한다. 또한 네트워크 지연으로 요청이 동시에 도착하지 않았을 수도 있다.

전체 재빌드

설정을 수정한 뒤 변경이 반영되지 않으면 캐시를 무시하고 재빌드한다.

docker compose down
docker compose build --no-cache
docker compose up -d

전체 시스템 종합 흐름

모든 것이 정상 동작할 때, 사용자 조회 요청의 전체 흐름을 처음부터 끝까지 따라간다.

sequenceDiagram autonumber box 외부 participant B as 브라우저 end box Docker Network participant N as Nginx API Gateway participant U as user-service end B->>N: GET localhost/api/users rect rgb(255, 243, 224) Note over N: location /api/users 매칭 end rect rgb(255, 243, 224) Note over N: limit_req 검사
zone=users_limit, burst=5 end rect rgb(232, 248, 232) Note over N: proxy_set_header 설정 N->>U: GET /api/users
Host: localhost
X-Real-IP: 172.18.0.1 end rect rgb(240, 248, 255) Note over U: UserController.getAllUsers() U->>U: UserService.getAllUsers() Note over U: ConcurrentHashMap에서
전체 사용자 조회 U-->>N: 200 OK
{"success":true,"data":[...]} end rect rgb(232, 248, 232) Note over N: add_header 추가 N-->>B: 200 OK
X-Gateway-Service: user-service
X-Rate-Limit: 20/min
{"success":true,"data":[...]} end Note over B: 테스트 대시보드에
사용자 목록 표시

이 하나의 다이어그램에 시리즈 전체의 핵심이 담겨 있다. 주황 영역은 Nginx의 라우팅과 Rate Limiting, 초록 영역은 프록시 전달과 응답 가공, 파란 영역은 Spring Boot의 비즈니스 로직 처리다.

시스템 종료

작업을 마치면 모든 컨테이너를 정리한다.

docker compose down

볼륨과 네트워크까지 완전히 제거하려면 -v 플래그를 추가한다.

docker compose down -v

자주 하는 실수

docker compose up을 매번 빌드 없이 실행하기

코드를 수정한 뒤 docker compose up -d만 실행하면 이전 이미지가 재사용된다. 코드 변경을 반영하려면 docker compose up -d --build를 사용해야 한다. 또는 docker compose build를 먼저 실행하고 up한다.

[!DANGER] 컨테이너 로그를 확인하지 않기

502 에러가 나면 브라우저만 새로고침하면서 기다리는 경우가 많다. docker compose logs -f로 실시간 로그를 보면 어떤 서비스에서 문제가 생겼는지 즉시 파악할 수 있다. 로그는 디버깅의 첫 번째 도구다.

[!DANGER] 테스트 후 컨테이너를 방치하기

Docker 컨테이너는 중지해도 디스크 공간을 차지한다. 실습이 끝나면 docker compose down으로 정리한다. 시간이 지나면 이미지도 쌓이므로 주기적으로 docker system prune으로 미사용 리소스를 제거하는 습관을 들인다.