문제: MSA에서 전체 빌드의 비효율
마이크로서비스 아키텍처(MSA)에서 서비스 수가 늘어나면 CI/CD의 비효율이 급격히 커진다.
예를 들어 20개 서비스가 있는 모노레포에서 1개 서비스의 코드 1줄을 수정했는데, CI가 20개 서비스를 전부 빌드한다면?
| 항목 | 전체 빌드 | 변경분만 빌드 |
|---|---|---|
| 빌드 대상 | 20개 전부 | 1~2개 |
| 소요 시간 | 30~50분 | 5~10분 |
| 레지스트리 트래픽 | 20개 push | 1~2개 push |
변경된 서비스만 감지해서 빌드하면 빌드 자원을 약 85~90% 절감할 수 있다.
전체 아키텍처
┌──────────────────────────────────────────┐
│ GitLab CI Pipeline │
│ │
│ Stage 1: detect-changes │
│ ┌────────────────────────────────────┐ │
│ │ git diff → 변경 서비스 감지 │ │
│ │ → child-pipeline.yml 동적 생성 │ │
│ └────────────────────────────────────┘ │
│ │
│ Stage 2: trigger-build │
│ ┌────────────────────────────────────┐ │
│ │ child pipeline 트리거 │ │
│ │ → parallel:matrix로 병렬 빌드 │ │
│ │ → Harbor 레지스트리 push │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
1단계: Makefile로 변경 서비스 감지
git diff를 사용해서 마지막 커밋에서 변경된 파일 목록을 추출하고, 어떤 서비스 디렉토리에 속하는지 매핑한다.
# Makefile
detect-changed:
@$(MAKE) _detect-changed-services
_detect-changed-services:
@CHANGED_SERVICES=""; \
# Frontend 서비스 변경 감지
for dir in frontend/*/; do \
if [ -d "$$dir" ]; then \
name=$$(basename "$$dir"); \
if git diff --name-only HEAD~1 HEAD | grep -E "^frontend/$$name/" >/dev/null 2>&1; then \
CHANGED_SERVICES="$$CHANGED_SERVICES $$name"; \
fi; \
fi; \
done; \
# Java 서비스 변경 감지
for dir in micro-services/java/*/; do \
if [ -d "$$dir" ]; then \
name=$$(basename "$$dir"); \
if git diff --name-only HEAD~1 HEAD | grep -E "^micro-services/java/$$name/" >/dev/null 2>&1; then \
CHANGED_SERVICES="$$CHANGED_SERVICES $$name"; \
fi; \
fi; \
done; \
# Python 서비스 변경 감지
for dir in micro-services/python/*/; do \
if [ -d "$$dir" ]; then \
name=$$(basename "$$dir"); \
if git diff --name-only HEAD~1 HEAD | grep -E "^micro-services/python/$$name/" >/dev/null 2>&1; then \
CHANGED_SERVICES="$$CHANGED_SERVICES $$name"; \
fi; \
fi; \
done; \
# 결과를 JSON 배열로 변환 (GitLab CI Matrix에서 사용)
if [ -z "$$CHANGED_SERVICES" ]; then \
echo "[]"; \
else \
echo "$$CHANGED_SERVICES" | tr ' ' '\n' | grep -v '^$$' | jq -R . | jq -s .; \
fi
실행 결과 예시:
$ make detect-changed
["authentication", "notification"]
2단계: GitLab CI에서 동적 Child Pipeline 생성
핵심은 detect-changes 스테이지에서 child-pipeline.yml을 동적으로 생성하고, trigger-build 스테이지에서 이를 실행하는 것이다.
# .gitlab-ci.yml
stages:
- detect-changes
- trigger-build
variables:
DOCKER_REGISTRY: 'harbor.example.com/myproject'
# Stage 1: 변경 서비스 감지 → child pipeline YAML 동적 생성
detect-changed-services-dev:
stage: detect-changes
image: python:3.9-slim
before_script:
- apt-get update -y
- apt-get install -y git jq make
script: |
SERVICES_JSON=$(make detect-changed)
if [ "$SERVICES_JSON" = "[]" ]; then
MATRIX="[]"
else
# JSON 배열 → parallel:matrix 형식으로 변환
MATRIX=$(echo "$SERVICES_JSON" | jq -c 'map({"SERVICE_NAME": .})')
fi
# child-pipeline.yml을 동적으로 생성
cat << EOF > child-pipeline.yml
build-service:
stage: build
image: docker:23.0.0-dind
services:
- docker:23.0.0-dind
variables:
DOCKER_REGISTRY: '$DOCKER_REGISTRY'
HARBOR_USERNAME: '$HARBOR_USERNAME'
HARBOR_PASSWORD: '$HARBOR_PASSWORD'
before_script:
- apk add --update make
- echo "\$HARBOR_PASSWORD" | docker login --username "\$HARBOR_USERNAME" --password-stdin \$DOCKER_REGISTRY
script: |
make dev-build-service \$SERVICE_NAME
make dev-push-service \$SERVICE_NAME
make dev-clean-service \$SERVICE_NAME
docker logout \$DOCKER_REGISTRY
parallel:
matrix: $(echo "$MATRIX")
EOF
artifacts:
paths:
- child-pipeline.yml
rules:
- if: '$CI_COMMIT_REF_NAME == "dev"'
# Stage 2: 생성된 child pipeline 트리거
trigger-child-pipeline-dev:
stage: trigger-build
trigger:
include:
- artifact: child-pipeline.yml
job: detect-changed-services-dev
needs:
- detect-changed-services-dev
rules:
- if: '$CI_COMMIT_REF_NAME == "dev"'
동작 흐름
detect-changed-services-dev가 실행되면make detect-changed로 변경 서비스 목록을 JSON으로 추출- 변경 서비스가
["auth", "notification"]이면,parallel:matrix에 이 2개만 들어간child-pipeline.yml생성 trigger-child-pipeline-dev가 이 YAML을 child pipeline으로 트리거- GitLab이
auth,notification2개의 병렬 job을 실행
3단계: 환경별 분기 (dev/prod)
동일한 패턴으로 prod 환경도 구성한다.
# prod 브랜치 감지
detect-changed-services-prod:
stage: detect-changes
# ... (위와 동일한 구조, prod 빌드 명령 사용)
rules:
- if: '$CI_COMMIT_REF_NAME == "main"'
trigger-child-pipeline-prod:
stage: trigger-build
trigger:
include:
- artifact: child-pipeline.yml
job: detect-changed-services-prod
needs:
- detect-changed-services-prod
rules:
- if: '$CI_COMMIT_REF_NAME == "main"'
| 브랜치 | 환경 | 빌드 명령 | 이미지 태그 |
|---|---|---|---|
| dev | 개발 | make dev-build-service |
registry/dev/service:latest |
| main | 운영 | make prod-build-service |
registry/service:latest |
4단계: 서비스별 빌드/푸시 Makefile 타겟
# 개발 환경 빌드
dev-build-service:
@SERVICE_NAME=$(filter-out $@,$(MAKECMDGOALS)); \
docker compose -f docker-compose.dev.yml build $$SERVICE_NAME
# 개발 환경 푸시
dev-push-service:
@SERVICE_NAME=$(filter-out $@,$(MAKECMDGOALS)); \
docker compose -f docker-compose.dev.yml push $$SERVICE_NAME
# 개발 환경 이미지 정리
dev-clean-service:
@SERVICE_NAME=$(filter-out $@,$(MAKECMDGOALS)); \
docker compose -f docker-compose.dev.yml rm $$SERVICE_NAME
# 운영 환경도 동일 패턴
prod-build-service:
@SERVICE_NAME=$(filter-out $@,$(MAKECMDGOALS)); \
docker compose -f docker-compose.yml build $$SERVICE_NAME
사용법:
make dev-build-service authentication # authentication만 빌드
make prod-push-service notification # notification만 레지스트리에 push
효과 정리
| 항목 | Before (전체 빌드) | After (동적 감지) |
|---|---|---|
| 빌드 대상 | 20개+ 전체 | 변경된 1~3개 |
| 파이프라인 시간 | 30~50분 | 5~10분 |
| 레지스트리 트래픽 | 20개 이미지 push | 1~3개 push |
| 빌드 자원 | 100% | ~10~15% |
| 절감률 | 약 85~90% |
주의사항
- 공유 라이브러리 변경 시: shared/ 디렉토리가 변경되면 이를 사용하는 모든 서비스를 빌드 대상에 포함시켜야 한다
- 인프라 설정 변경: docker-compose.yml, Makefile 등이 변경되면 전체 빌드가 필요할 수 있다
- git diff 범위:
HEAD~1은 마지막 1커밋만 비교한다. squash merge를 사용하면 괜찮지만, 여러 커밋이 한 번에 push되면$CI_COMMIT_BEFORE_SHA를 사용하는 것이 안전하다
참고 자료
- GitLab Downstream Pipelines (Child Pipelines) 공식 문서
- GitLab Harbor Registry 연동
- Dynamic Pipeline Generation on GitLab — Infinite Lambda
- Smart CI/CD: Building Only What You Change