[인턴] GitHub Actions로 ECR 멀티 서비스 빌드 파이프라인 구성하기
GitHub Actions로 ECR 멀티 서비스 빌드 파이프라인 구성하기
들어가며
이전 글에서 EC2 + nginx 구조의 헬스체크 문제를 해결하기 위해 ECR + ECS로 마이그레이션했다는 내용을 정리했다.
그런데 ECS로 넘어오면서 자연스럽게 새로운 과제가 생겼다.
기존에는 배포를 할 때 EC2에 SSH로 들어가서 git pull하고 프로세스를 재시작했다.
ECS에서는 새 이미지를 ECR에 푸시해야 배포가 가능하다.
그리고 우리 서비스는 모노레포에 여러 앱이 함께 있는 구조였다.
apps/
├── backend/
├── frontend/
├── proxy-a/
├── proxy-b/
└── web-browser/
packages/
└── shared/ # 공통 패키지
문제는 이랬다.
frontend만 수정했는데backend까지 빌드해야 할까?- 매번 전체를 빌드하면 시간이 너무 오래 걸린다.
- 그렇다고 수동으로 “이번엔 프론트만 빌드해”라고 관리하면 실수가 생긴다.
이 문제를 해결하기 위해 변경된 서비스만 선택적으로 빌드하는 GitHub Actions 파이프라인을 만들었다. 이번 글은 그 과정에서 고민했던 것들과 결과를 정리한 기록이다.
전체 파이프라인 구조
파이프라인은 크게 4단계로 구성된다.
Step 1: detect-changes # 어떤 파일이 바뀌었는지 감지
Step 2: build-common # 공통 베이스 이미지 빌드 (필요한 경우에만)
Step 3: build-* # 각 앱 이미지 병렬 빌드 (변경된 것만)
Step 4: notify-slack # 결과 Slack 알림
각 단계를 하나씩 살펴보자.
Step 1: 변경 감지 (detect-changes)
핵심은 “어떤 서비스가 바뀌었는지”를 먼저 파악하는 것이다.
이를 위해 dorny/paths-filter 액션을 사용했다.
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
build_common:
- 'Dockerfile'
- 'package.json'
- 'yarn.lock'
- 'packages/**'
- 'apps/*/package.json'
backend:
- 'apps/backend/**'
frontend:
- 'apps/frontend/**'
이 액션은 현재 push에서 변경된 파일 목록을 분석해서, 각 필터에 해당하는 변경이 있었는지 true/false로 반환한다.
그리고 이 결과를 outputs으로 노출해서 이후 job들이 조건부로 실행될 수 있게 한다.
outputs:
build_common: $
backend: $
frontend: $
# ...
build_common 필터가 하는 역할이 중요하다.
Dockerfile, package.json, yarn.lock, 공통 패키지 등이 변경되면 사실상 모든 앱 이미지를 다시 빌드해야 한다.
이 경우를 별도로 감지해서 모든 앱 빌드를 트리거하는 신호로 사용한다.
Step 2: 공통 베이스 이미지 (build-common)
이 단계가 이 파이프라인에서 가장 흥미로운 부분이었다.
왜 build-common이 필요한가?
우리 프로젝트 루트의 Dockerfile은 공통 빌드 레이어를 만드는 용도다.
yarn install처럼 시간이 오래 걸리는 작업을 미리 처리해서, 각 앱 빌드 시 이 레이어를 재사용할 수 있게 한다.
루트 Dockerfile (build-common) : yarn install + 공통 패키지 빌드
apps/backend/Dockerfile : FROM build-common:latest → backend 빌드
apps/frontend/Dockerfile : FROM build-common:latest → frontend 빌드
앱별 Dockerfile이 FROM build-common:latest를 베이스로 시작하면, Docker 레이어 캐시 덕분에 yarn install을 다시 실행하지 않아도 된다.
“항상 실행, 하지만 빌드는 조건부”
build-common job은 항상 실행되지만, 실제 빌드 작업은 필요한 경우에만 수행한다.
왜 항상 실행해야 하냐면, 이후 단계에서 build-common:latest 이미지가 ECR에 있는지 없는지를 먼저 확인해야 하기 때문이다.
ECR_OUTPUT=$(aws ecr describe-images \
--repository-name my-build-common \
--image-ids imageTag=latest 2>&1) && IMAGE_EXISTS=true || IMAGE_EXISTS=false
if [ "$CHANGED" == "true" ] || [ "$IMAGE_EXISTS" == "false" ]; then
NEEDED=true
else
NEEDED=false
fi
echo "needed=$NEEDED" >> $GITHUB_OUTPUT
build_common필터가true이면 → 빌드 필요- ECR에 이미지가 없으면 → 빌드 필요 (처음 실행하는 경우)
- 둘 다 아니면 → 기존 이미지를 그대로 사용
이 판단 결과를 steps.check.outputs.needed로 저장하고, 이후 step들이 이 값을 조건으로 사용한다.
- name: Free disk space
if: steps.check.outputs.needed == 'true'
uses: jlumbroso/free-disk-space@v1.3.1
- name: Checkout repository
if: steps.check.outputs.needed == 'true'
uses: actions/checkout@v4
빌드가 필요 없을 때는 checkout조차 하지 않는다. GitHub Actions의 runner는 디스크가 제한적이기 때문에, 불필요한 작업을 줄이는 것이 의외로 중요하다.
이미지 태그 전략
이미지를 두 개의 태그로 푸시한다.
docker build \
-t $ECR_REGISTRY/my-build-common:latest \
-t $ECR_REGISTRY/my-build-common:$IMAGE_TAG \
.
IMAGE_TAG는 $(date +%Y%m%d).$ 형태로 생성된다.
예: 20260401.42
latest태그: 항상 가장 최신 이미지를 가리킴. 이후 앱 빌드 step에서 pull할 때 사용.- 날짜.번호 태그: 어느 빌드에서 만들어진 이미지인지 추적 가능. 롤백 시 유용.
Step 3: 앱별 병렬 빌드
각 앱의 빌드 job은 아래 두 조건을 AND로 연결한다.
build-backend:
needs: [detect-changes, build-common]
if: |
needs.build-common.result == 'success' &&
(needs.detect-changes.outputs.backend == 'true' ||
needs.detect-changes.outputs.build_common == 'true')
build-common이 성공해야 한다 (베이스 이미지가 준비됐다는 전제)apps/backend/파일이 변경됐거나, 공통 레이어가 변경됐거나
두 번째 조건에서 build_common == 'true'가 포함된 이유:
베이스 이미지가 바뀌면, 그걸 사용하는 모든 앱 이미지도 다시 빌드해야 하기 때문이다.
각 앱 빌드의 흐름
앱 빌드 job들은 거의 동일한 구조를 가진다.
steps:
- name: Checkout repository
- name: Configure AWS credentials (OIDC)
- name: Login to Amazon ECR
- name: Set image tag
- name: Pull build-common # ← ECR에서 베이스 이미지 가져오기
- name: Build & push {app} # ← 앱 이미지 빌드 & 푸시
Pull build-common step이 핵심이다.
docker pull $ECR_REGISTRY/my-build-common:latest
docker tag $ECR_REGISTRY/my-build-common:latest build-common:latest
ECR에서 공통 이미지를 받아서 로컬에 build-common:latest라는 이름으로 태그한다.
앱 Dockerfile에서 FROM build-common:latest를 선언했다면, 이 이미지를 베이스로 사용하게 된다.
각 앱 job은 독립적인 VM에서 실행되기 때문에, 앱 간 빌드가 완전히 병렬로 진행된다.
build-common 완료 후
├── build-backend (병렬)
├── build-frontend (병렬)
├── build-proxy-a (병렬)
└── build-proxy-b (병렬)
OIDC 기반 AWS 인증
예전에는 AWS 인증을 위해 AWS_ACCESS_KEY_ID와 AWS_SECRET_ACCESS_KEY를 GitHub Secrets에 저장했다.
이 방식은 키가 유출될 위험이 있고, 주기적으로 교체해야 하는 번거로움이 있다.
OIDC (OpenID Connect) 방식은 이 문제를 해결한다.
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecr
aws-region: ap-northeast-2
동작 원리는 이렇다.
GitHub Actions runner
→ GitHub OIDC Provider에 토큰 요청
→ JWT 토큰 발급
→ AWS STS에 AssumeRoleWithWebIdentity 요청 (JWT 포함)
→ IAM이 신뢰 정책(Trust Policy) 검증
→ 임시 자격증명 발급 (최대 1시간)
장점:
- 장기 자격증명 없음: Access Key 대신 단기 임시 자격증명 사용
- 자동 만료: 사용 후 자동으로 만료되므로 유출돼도 피해가 제한됨
- IAM Trust Policy로 제어: 어느 레포지토리, 어느 브랜치에서만 이 Role을 assume할 수 있는지 제한 가능
IAM Trust Policy 예시:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/*"
}
}
}]
}
StringLike와 * 와일드카드를 사용하면 어떤 브랜치에서도 이 Role을 assume할 수 있다.
StringEquals로 특정 브랜치만 허용하는 것도 가능하다.
Concurrency: 중복 실행 방지
같은 브랜치에 연속으로 push가 들어오면, 이전 실행이 완료되기 전에 새 실행이 시작된다. 두 실행이 동시에 ECR에 push하면 의도치 않은 순서 문제가 생길 수 있다.
이를 방지하기 위해 concurrency 설정을 사용했다.
concurrency:
group: build-ecr-$
cancel-in-progress: true
- 같은 브랜치(
github.ref_name)에서 실행 중인 워크플로우가 있으면, 새 실행이 시작될 때 기존 실행을 자동으로 취소한다. group을 브랜치 이름 기반으로 설정했기 때문에, 서로 다른 브랜치의 실행은 영향을 주지 않는다.
Step 4: Slack 알림
모든 빌드 job이 끝나면 결과를 Slack으로 알린다.
notify-slack:
needs: [build-common, build-backend, build-frontend, ...]
if: always() # 성공/실패/스킵 상관없이 항상 실행
if: always()가 없으면, 이전 job들 중 하나라도 실패했을 때 이 job이 실행되지 않는다.
실패했을 때도 알림을 받아야 하므로 always()를 명시했다.
셸 인젝션 방지
알림 메시지에서 각 job의 결과를 표시할 때, 처음에는 이렇게 작성하려고 했다.
# ❌ 이렇게 하면 안 됨
run: |
if [[ "$" == "failure" ]]; then
...
fi
GitHub Actions 표현식을 직접 쉘 스크립트에 삽입하면, job 결과값에 예상치 못한 문자(따옴표, 세미콜론 등)가 포함될 경우 셸 인젝션 취약점이 생길 수 있다.
이를 방지하기 위해 env를 통해 환경변수로 분리했다.
- name: Determine overall status
id: status
env:
R_COMMON: $
R_BACKEND: $
R_FRONTEND: $
run: |
ALL="$R_COMMON $R_BACKEND $R_FRONTEND"
if echo "$ALL" | grep -q "failure"; then
echo "result=failure" >> $GITHUB_OUTPUT
fi
GitHub 표현식 결과를 먼저 환경변수(R_COMMON, R_BACKEND 등)에 담고,
쉘 스크립트에서는 그 환경변수만 참조한다.
환경변수는 셸 파싱 전에 치환되기 때문에, 인젝션 위험이 없다.
Slack 메시지 구조
Slack 알림은 Block Kit 포맷으로 구성했다.
┌────────────────────────────────────────┐
│ ✅ ECR Build success │
│ │
│ Branch: main Actor: kim │
│ Commit: [abc1234] │
│ │
│ Image Results │
│ • build-common — success │
│ • backend — success │
│ • frontend — skipped │
│ • proxy-a — success │
│ │
│ [View Run] │
└────────────────────────────────────────┘
각 job의 결과가 success, failure, skipped, cancelled 중 하나로 표시된다.
특히 skipped는 path filter에 의해 빌드가 실행되지 않은 경우인데, 이를 통해 “이번 빌드에서 어떤 서비스가 포함됐는지”를 한눈에 확인할 수 있다.
전체 실행 흐름 정리
apps/backend/ 파일만 수정해서 push한 경우를 예시로 보면:
push 발생
│
├─ detect-changes
│ backend: true ✅
│ frontend: false ❌
│ build_common: false ❌
│
├─ build-common
│ ECR에 이미지 있음 + build_common=false → 빌드 스킵
│ (AWS 인증 + ECR 확인만 하고 종료)
│
├─ build-backend ← backend=true → 실행
├─ build-frontend ← frontend=false → 스킵
├─ build-proxy-a ← 변경 없음 → 스킵
└─ build-proxy-b ← 변경 없음 → 스킵
│
└─ notify-slack
build-common: success
backend: success
frontend: skipped
proxy-a: skipped
packages/shared/ 파일을 수정했다면:
build_common: true → build-common 실행
backend: false → 하지만 build_common=true이므로 build-backend도 실행
frontend: false → build_common=true이므로 build-frontend도 실행
...모든 앱 빌드 실행
마치며
이 파이프라인을 구성하면서 가장 많이 고민했던 건 “무엇을 기준으로 빌드를 트리거할 것인가” 였다.
단순히 “push마다 전부 빌드”는 너무 느리고, “개발자가 직접 판단해서 빌드”는 실수가 생긴다.
path filter를 활용해서 변경된 파일 기반으로 자동 판단하고, build-common이라는 공통 레이어 개념을 추가해서 의존 관계를 명시적으로 처리한 것이 핵심이었다.
부수적으로 배운 것들도 있었다.
- OIDC 인증은 처음 설정이 복잡하지만, 장기 자격증명 관리의 부담이 없어서 장기적으로 훨씬 깔끔하다.
- GitHub Actions의
concurrency는 단순해 보이지만, 실제로 연속 push 상황에서 이상한 결과를 막아주는 중요한 설정이다. - 셸 인젝션 방지를 위해
env로 분리하는 패턴은 처음에는 번거롭게 느껴졌지만, 보안 측면에서 올바른 습관이라는 걸 이해하고 나서는 자연스럽게 따르게 됐다.
ECS 마이그레이션으로 인프라 구조가 바뀌었고, 이 파이프라인으로 배포 흐름도 자동화됐다. “코드를 push하면 이미지가 ECR에 올라간다”는 단순한 흐름이 되기까지 꽤 많은 것들이 맞물려 있었다는 걸, 직접 구성해보고 나서야 제대로 이해했다.
Leave a comment