BindProject

서버 구축 과정 Docker-Compose와 Nginx로 시작하는 마이크로서비스 아키텍처

dding-shark 2025. 8. 4. 16:11
728x90

 


Docker-Compose와 Nginx로 시작하는 마이크로서비스 아키텍처

저희 팀은 확장성과 유지보수성을 높이기 위해 MSA를 도입하기로 결정했고, 그 기반을 다지기 위해 Docker와 Nginx를 선택했습니다.

이 글에서는 여러 개의 Docker Compose 파일을 활용하여 인프라 서비스와 애플리케이션 서비스를 분리하고, Nginx를 리버스 프록시(Reverse Proxy) 로 설정하여 모든 요청을 단일 창구에서 관리하는 방법을 다룹니다. 그리고 마지막으로, Docker 환경에서 거의 반드시 마주치게 되는 localhost 네트워크 트러블슈팅 경험을 자세히 공유해 드립니다.

 

 

 


 

 

1단계: 서비스의 분리 - infra.yml vs apps.yml

MSA는 여러 서비스의 집합입니다. 이 서비스들은 크게 두 종류로 나눌 수 있습니다.

  1. 인프라 서비스 (Infrastructure Services): 데이터베이스(MySQL), 캐시(Redis), 메시지 큐(RabbitMQ, Kafka) 등 애플리케이션이 동작하기 위해 의존하는 기반 서비스들입니다. 이들은 상대적으로 변경 주기가 깁니다.
  2. 애플리케이션 서비스 (Application Services): 인증, 상품, 주문 등 실제 비즈니스 로직을 수행하는 서비스들입니다. 이들은 개발이 진행되면서 빈번하게 빌드되고 재시작됩니다.

이 두 그룹을 하나의 거대한 docker-compose.yml 파일로 관리하는 것은 비효율적입니다. 인프라는 그대로 둔 채 애플리케이션만 재시작하고 싶을 때도 전체 파일을 다뤄야 하기 때문입니다.

그래서 저희는 두 개의 파일로 분리했습니다.

docker-compose.infra.yml: 우리의 든든한 기반

이 파일은 데이터베이스, 캐시, 메시징 시스템 등 핵심 인프라를 정의합니다.

version: '3.7'

services:
  mysql:
    image: mysql:8
    container_name: mysql
    environment:
      # ... (DB 환경변수 설정) ...
    ports:
      - "3306:3306"
    networks:
      - app-network

  redis:
    image: redis:7
    container_name: redis
    ports:
      - "6380:6379" # 로컬 6380 -> 컨테이너 6379
    networks:
      - app-network

  rabbitmq:
    image: rabbitmq:3-management-alpine
    container_name: rabbitmq
    ports:
      - "5672:5672"
      - "15672:15672" # 관리자 UI 포트
    networks:
      - app-network

  # ... (Kafka, Zookeeper 등 다른 인프라 서비스) ...

# 모든 컨테이너가 소속될 공용 네트워크
networks:
  app-network:
    external: true # 이 네트워크는 외부에서 미리 생성된 것을 사용

핵심 포인트:

  • networks: app-network: 모든 인프라 서비스는 app-network라는 공용 네트워크에 연결됩니다. 이 네트워크를 통해 다른 컨테이너들이 서비스 이름(e.g., mysql, redis)으로 서로를 찾아 통신할 수 있게 됩니다.
  • external: true: docker-compose.infra.yml에서 이 네트워크를 사용하기 전에, 개발자가 직접 docker network create app-network 명령으로 미리 생성해두어야 한다는 의미입니다. 이는 여러 docker-compose 파일이 동일한 네트워크를 공유하기 위한 표준적인 방법입니다.

docker-compose.apps.yml: 실제 비즈니스가 동작하는 곳

이 파일은 우리가 개발하는 실제 애플리케이션 서비스와, 모든 요청의 관문이 될 Nginx를 정의합니다.

version: '3.7'

services:
  nginx:
    image: nginx:latest
    container_name: nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - auth-service
    networks:
      - app-network

  auth-service:
    build:
      context: service/auth # service/auth 폴더의 Dockerfile로 빌드
      dockerfile: Dockerfile
    container_name: auth-service
    ports:
      - "9001:9001"
    environment:
      # DB 접속 주소로 localhost 대신 컨테이너 이름(mysql)을 사용
      - SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/auth_db
      # Redis 접속 호스트로 컨테이너 이름(redis)을 사용
      - SPRING_DATA_REDIS_HOST=redis
    networks:
      - app-network

# 이 파일도 동일한 외부 네트워크를 참조
networks:
  app-network:
    external: true

이제 docker-compose -f docker-compose.infra.yml up -d 명령으로 인프라를 띄우고, 개발 중에는 docker-compose -f docker-compose.apps.yml up -d --build 명령으로 애플리케이션만 빠르게 재빌드/재시작할 수 있는 환경이 갖춰졌습니다.

 

 

 

 


 

 

 

2단계: 시스템의 얼굴, Nginx 리버스 프록시 이해하기

마이크로서비스들은 각자 다른 포트(9001, 9002 등)에서 실행됩니다. 클라이언트가 이 모든 포트를 기억하고 직접 접속하는 것은 비효율적이고 보안에도 좋지 않습니다. 이때 필요한 것이 바로 리버스 프록시입니다.

리버스 프록시(Reverse Proxy) 는 클라이언트의 모든 요청을 일단 자신이 받은 뒤, 요청 경로(URI)를 분석하여 내부망에 있는 올바른 마이크로서비스로 전달해주는 대리인 역할을 합니다. 저희는 이 역할에 Nginx를 사용했습니다.

Nginx 설정 파일은 다음과 같습니다.

http {
    server {
        listen 80; # 80 포트로 들어오는 모든 요청을 받음
        server_name your-domain.com; # 실제 도메인

        # '/api/auth/'로 시작하는 모든 요청은 auth-service로 전달
        location /api/auth/ {
            # 중요한 부분! localhost가 아닌 서비스 이름으로 요청을 전달한다.
            proxy_pass http://auth-service:9001/;

            # 클라이언트의 실제 IP와 프로토콜 정보를 백엔드 서버로 전달하기 위한 헤더 설정
            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;
        }

        # '/api/image/'로 시작하는 모든 요청은 image-service로 전달
        location /api/image/ {
            proxy_pass http://product-service:9100/;
            # ... (프록시 헤더 설정) ...
        }
    }
}

이제 클라이언트는 your-domain.com의 80포트 하나만 바라보면 됩니다.

  • http://your-domain.com/api/auth/login 요청 -> Nginx가 받아서 내부적으로 http://auth-service:9001/api/auth/login으로 전달
  • http://your-domain.com/api/products/1 요청 -> Nginx가 받아서 내부적으로 http://product-service:9002/api/products/1으로 전달

이로써 클라이언트는 내부 서비스들의 복잡한 구조를 전혀 알 필요가 없게 되었습니다.

 

 

 

 


 

 

 

3단계: 트러블슈팅 - localhost의 배신

모든 설정이 완벽해 보였습니다. 하지만 docker-compose로 서비스를 실행하자 auth-service가 시작과 동시에 죽어버리며 연결 오류를 뿜어냈습니다. healthcheck 설정이 문제였습니다.

# ... auth-service 설정 ...
    healthcheck:
      # 컨테이너 내부에서 localhost:9001로 Actuator health API를 호출
      test: ["CMD", "curl", "-f", "http://localhost:9001/actuator/health"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 30s

healthcheck 자체는 문제가 없습니다. testcurl 명령어는 auth-service 컨테이너 내부에서 실행되므로, localhostauth-service 컨테이너 자신을 가리키는 것이 맞습니다.

진짜 문제는 개발자가 코드를 작성할 때 발생했습니다.
만약 auth-service가 다른 서비스, 예를 들어 user-profile-service를 호출해야 한다고 가정해봅시다. 많은 개발자들이 로컬 환경에서 개발하던 습관 때문에 다음과 같이 코드를 작성합니다.

// AuthServiceImpl.java (잘못된 예시)
WebClient webClient = WebClient.create("http://localhost:9003"); // user-profile-service 포트
UserProfileDto userProfile = webClient.get()
                                .uri("/api/user/profile/{userId}", userId)
                                .retrieve()
                                .bodyToMono(UserProfileDto.class)
                                .block();

이 코드는 Docker 환경에서 100% 실패합니다.

localhost의 함정:
Docker 컨테이너에게 localhost(또는 127.0.0.1)는 외부의 호스트 머신이나 다른 컨테이너가 아닌, 컨테이너 자기 자신을 의미합니다. 따라서 auth-service 컨테이너 내부에서 localhost:9003으로 접속을 시도하는 것은, 자기 자신에게 존재하지도 않는 9003번 포트로 말을 거는 것과 같습니다. 당연히 Connection Refused 에러가 발생합니다.

올바른 해결책: 서비스 이름(Service Name) 사용
이 문제를 해결하기 위해 Docker는 docker-compose.yml에 정의된 서비스 이름(service name) 을 호스트 이름(hostname)처럼 사용할 수 있는 내부 DNS 기능을 제공합니다.

auth-serviceuser-profile-service를 호출해야 한다면, 접속 주소를 localhost가 아닌 서비스 이름인 user-profile-service로 변경해야 합니다.

// AuthServiceImpl.java (올바른 예시)
// application.yml 등에서 외부 주입 받는 것이 가장 좋다.
private final WebClient webClient = WebClient.create("http://user-profile-service:9003");

이는 docker-compose.apps.ymlenvironment 설정에서 mysql이나 redis 같은 인프라 서비스에 접속할 때 localhost가 아닌 서비스 이름을 사용했던 것과 정확히 동일한 원리입니다.

 

 

 

 


 

 

 

마치며

Docker와 Nginx를 통해 MSA의 기반을 구축하는 과정은 서비스의 구조를 명확히 하고, 네트워크의 흐름을 깊이 이해하는 좋은 경험이었습니다. 특히 docker-compose 파일을 분리하여 관리 효율성을 높이고, Nginx 리버스 프록시로 단일 진입점을 마련한 것은 시스템의 안정성과 확장성을 크게 향상시켰습니다.

그리고 localhost와의 사투를 통해 얻은 교훈은, Docker 환경에서의 네트워킹은 로컬 개발 환경과 다르다는 점을 항상 명심해야 한다는 것입니다. 컨테이너 간 통신은 반드시 Docker가 제공하는 서비스 디스커버리(서비스 이름)를 통해 이루어져야 합니다. 이 글이 MSA 도입을 준비하는 다른 개발자분들의 삽질을 조금이나마 줄여줄 수 있기를 바랍니다.

728x90