Docker-Compose와 Nginx로 시작하는 마이크로서비스 아키텍처
저희 팀은 확장성과 유지보수성을 높이기 위해 MSA를 도입하기로 결정했고, 그 기반을 다지기 위해 Docker와 Nginx를 선택했습니다.
이 글에서는 여러 개의 Docker Compose 파일을 활용하여 인프라 서비스와 애플리케이션 서비스를 분리하고, Nginx를 리버스 프록시(Reverse Proxy) 로 설정하여 모든 요청을 단일 창구에서 관리하는 방법을 다룹니다. 그리고 마지막으로, Docker 환경에서 거의 반드시 마주치게 되는 localhost 네트워크 트러블슈팅 경험을 자세히 공유해 드립니다.
1단계: 서비스의 분리 - infra.yml vs apps.yml
MSA는 여러 서비스의 집합입니다. 이 서비스들은 크게 두 종류로 나눌 수 있습니다.
- 인프라 서비스 (Infrastructure Services): 데이터베이스(MySQL), 캐시(Redis), 메시지 큐(RabbitMQ, Kafka) 등 애플리케이션이 동작하기 위해 의존하는 기반 서비스들입니다. 이들은 상대적으로 변경 주기가 깁니다.
- 애플리케이션 서비스 (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 자체는 문제가 없습니다. test의 curl 명령어는 auth-service 컨테이너 내부에서 실행되므로, localhost는 auth-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-service가 user-profile-service를 호출해야 한다면, 접속 주소를 localhost가 아닌 서비스 이름인 user-profile-service로 변경해야 합니다.
// AuthServiceImpl.java (올바른 예시)
// application.yml 등에서 외부 주입 받는 것이 가장 좋다.
private final WebClient webClient = WebClient.create("http://user-profile-service:9003");
이는 docker-compose.apps.yml의 environment 설정에서 mysql이나 redis 같은 인프라 서비스에 접속할 때 localhost가 아닌 서비스 이름을 사용했던 것과 정확히 동일한 원리입니다.
마치며
Docker와 Nginx를 통해 MSA의 기반을 구축하는 과정은 서비스의 구조를 명확히 하고, 네트워크의 흐름을 깊이 이해하는 좋은 경험이었습니다. 특히 docker-compose 파일을 분리하여 관리 효율성을 높이고, Nginx 리버스 프록시로 단일 진입점을 마련한 것은 시스템의 안정성과 확장성을 크게 향상시켰습니다.
그리고 localhost와의 사투를 통해 얻은 교훈은, Docker 환경에서의 네트워킹은 로컬 개발 환경과 다르다는 점을 항상 명심해야 한다는 것입니다. 컨테이너 간 통신은 반드시 Docker가 제공하는 서비스 디스커버리(서비스 이름)를 통해 이루어져야 합니다. 이 글이 MSA 도입을 준비하는 다른 개발자분들의 삽질을 조금이나마 줄여줄 수 있기를 바랍니다.
'BindProject' 카테고리의 다른 글
| 메시징 플레이그라운드 만들기 (4) | 2025.08.12 |
|---|---|
| 구현된 모든 서비스를 컨테이너화 시키는 여정기 (7) | 2025.08.05 |
| 유저 닉네임 캐시전략 및 캐시데이터 무결성 (3) | 2025.08.02 |
| AOP와 ThreadLocal로 구현하는 로그 추적기 (MSA 환경 완벽 대비) (3) | 2025.08.02 |
| AOP 도입하기 (4) | 2025.08.01 |