엔지니어링AI 코드 검증을 위한 15개 job CI 파이프라인 구축
· 알고수
- #ai-dev
- #ci-cd
- #github-actions
- #gitops
AI 코드 직배포의 위험
한번은 AI가 생성한 코드를 커밋하고, git push를 눌렀습니다. 코드 자체는 멀쩡했어요. 테스트도 통과했고, 린트도 깨끗했습니다. 그런데 한 가지 문제가 있었습니다. git commit --amend로 커밋을 수정한 뒤 force-push를 했거든요.
CI가 돌았지만, 아무것도 빌드되지 않았습니다. 변경된 파일이 분명 있는데, CI는 "변경 없음"으로 판단했습니다. 바뀐 파일 경로를 보고 어느 서비스가 변경됐는지 가려내는 GitHub Action dorny/paths-filter가 force-push된 커밋의 diff를 정상적으로 감지하지 못한 거예요. 배포 파이프라인의 첫 번째 관문인 변경 감지가 뚫리면, 그 뒤의 모든 체크는 실행조차 되지 않습니다.
AI가 코드를 빠르게 만들수록, 사람이 한 줄 한 줄 다 볼 수는 없습니다. 그래서 검증은 사람 눈이 아니라 파이프라인이 맡아야 했는데, 그 파이프라인의 첫 관문이 force-push 한 번에 뚫렸던 거예요. 이 사건 이후로 force-push(amend)는 프로젝트 전체에서 금지됐고, 검증 파이프라인을 더 촘촘하게 쌓기 시작했습니다.
문제
AI가 빠르게 만드는 코드를 사람이 다 볼 수 없어 검증을 CI에 맡겨야 했는데, force-push 한 번에 CI의 첫 관문(변경 감지)이 뚫려 아무것도 빌드되지 않았다.
결정
보안부터 GitOps 락(배포 설정 저장소를 거쳐야만 배포되는 구조)까지 8단계 15 jobs로 안전망을 단계적으로 쌓았다.
결과
시크릿·품질·테스트·의존성·이미지 취약점·배포 경로를 모두 통과해야 프로덕션에 닿는다. 실제로 겪은 사고마다 규칙을 하나씩 더했다.
CI 15개 job 전체 지도
알고수의 CI 파이프라인은 15개의 job으로 구성되어 있습니다. 이 job들은 독립적으로 실행되는 게 아니라, 엄격한 의존관계를 가지고 있어요. 하나라도 실패하면 후속 단계가 차단되는 구조입니다.
Trigger
push / PR → main
Security Gate
gitleaks · paths-filter · .env 차단
Quality
ESLint+tsc × 5 / ruff / next-lint
Test
Jest × 5 / pytest / Vitest (coverage)
Audit + Build
npm audit × 6 → Docker ARM64 × 8
Trivy Scan
CRITICAL/HIGH × 8 이미지 → SARIF
Deploy
배포 설정 저장소(aether-gitops) 태그 → ArgoCD sync
Notify
Grafana deployment annotation (배포 시점을 Grafana 대시보드에 표시)
이 상자는 실행 흐름을 여덟 묶음으로 나눈 것이고, 아래 본문의 1~8단계는 설명 순서입니다. 그래서 상자와 본문의 단계 번호는 서로 맞지 않습니다.
CI 15 jobs 의존 그래프
1단계: 시크릿 스캔
파이프라인의 첫 번째 관문은 보안입니다. 모든 push와 PR에 대해, 코드가 테스트되기도 전에 보안 스캔이 실행됩니다.
gitleaks: 시크릿 유출 탐지
- name: Run gitleaks secret scan
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
gitleaks detect --source . --config .gitleaks.toml \
--log-opts "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" \
--verbose
else
gitleaks detect --source . --config .gitleaks.toml --verbose
fi시크릿 탐지 도구 gitleaks는 Git 히스토리 전체를 스캔해서 API 키, 비밀번호, 토큰 같은 시크릿이 커밋에 포함되었는지를 검사합니다. PR인 경우에는 변경된 커밋 범위만 스캔하고, push인 경우에는 전체를 스캔합니다.
AI가 코드를 생성할 때 가장 흔한 실수 중 하나가 예제 코드에 하드코딩된 토큰을 남기는 것입니다. 사람이라면 "아, 이건 예제니까 실제 토큰 넣으면 안 되지"라고 생각하겠지만, AI는 그런 맥락 판단을 놓칠 수 있어요. gitleaks가 이런 실수를 잡아줍니다.
.env 파일 커밋 차단
- name: Reject committed .env files
run: |
VIOLATIONS=$(git ls-files | grep -E '(^|/)\.env($|\..*)' | grep -v '\.env\.example' || true)
if [ -n "$(echo "$VIOLATIONS" | tr -d ' ')" ]; then
echo "::error::SECURITY VIOLATION: .env files detected in Git"
exit 1
fi.env 파일은 .gitignore에 들어있어야 하지만, 실수로 git add -A를 하거나 .gitignore를 수정하면 슬쩍 들어갈 수 있습니다. 이 체크는 Git 인덱스에 .env 파일이 존재하는지를 명시적으로 검사합니다. .env.example은 허용하고, 나머지는 모두 차단합니다.
왜 이게 secret-scan과 별도로 필요할까요? gitleaks는 파일 내용의 패턴을 검사하지만, .env 파일 자체의 존재 여부는 검사하지 않습니다. 환경변수 파일이 커밋되면 시크릿이 들어있든 없든 위험하기 때문에, 파일 존재 자체를 차단하는 것입니다.
2단계: 변경 감지
알고수는 모노레포입니다. 6개 백엔드 서비스 + 1개 프론트엔드 + 1개 블로그(결정 기록을 웹페이지로 보여 주는 알고수 내부 블로그)가 한 레포에 있어요. 매번 모든 서비스를 빌드하면 비용이 낭비되니까, 앞서 말한 dorny/paths-filter로 실제로 변경된 서비스만 감지합니다.
filters: |
gateway:
- 'services/gateway/**'
identity:
- 'services/identity/**'
# ... (8개 서비스/모듈에 대해 각각 경로 정의)이 job의 출력값(gateway: true/false, identity: true/false 등)이 이후 모든 job의 if 조건에 사용됩니다. 변경되지 않은 서비스의 테스트, 린트, 빌드는 전부 스킵됩니다.
여기서 중요한 점이 하나 있습니다. workflow_dispatch로 수동 실행할 때는 변경 여부와 상관없이 전체 서비스를 강제로 다시 빌드하는 옵션(rebuild_all)을 제공합니다. 인프라 변경, 베이스 이미지 업데이트처럼 모든 서비스를 다시 빌드해야 하는 상황에 대비한 안전장치입니다.
3단계: 품질 게이트
변경이 감지된 서비스에 대해 린트와 타입 체크가 실행됩니다. 세 가지 트랙으로 나뉩니다.
Node.js 서비스 (5개, matrix 전략)
quality-nestjs는 matrix 전략(같은 job을 서비스별 설정으로 복제해 돌리는 방식)으로 5개 서비스를 병렬 실행합니다.
- Gateway, Identity, Submission, Problem: ESLint + TypeScript
tsc --noEmit - GitHub Worker: ESLint (
src/**/*.ts) + TypeScripttsc --noEmit
각 서비스마다 has_eslint와 lint_glob 옵션이 다를 수 있도록 matrix를 유연하게 설계했습니다.
Python 서비스 (ai-analysis)
quality-python은 ruff를 사용합니다. 린트(ruff check)와 포맷 검사(ruff format --check)를 모두 수행합니다. Python 프로젝트에서 ruff는 flake8 + black + isort를 하나로 합친 것과 같아서, 도구 관리가 훨씬 깔끔합니다.
Frontend (Next.js)
quality-frontend는 next lint + tsc --noEmit을 실행합니다. Next.js의 빌트인 린터가 React 관련 규칙까지 포함하고 있어서, 별도의 ESLint 설정 없이도 충분합니다.
4단계: 테스트 매트릭스
품질 게이트를 통과해야 테스트가 실행됩니다. needs: [detect-changes, quality-nestjs] 같은 의존성이 걸려 있어서, 린트가 실패하면 테스트는 시작조차 하지 않습니다.
test-node (Jest x 5)
NestJS 서비스 4개와 순수 Node.js로 만든 GitHub Worker, 모두 5개가 matrix로 병렬 테스트됩니다. 모든 서비스에 --coverage --ci 플래그가 붙어 있고, 커버리지 결과는 아티팩트로 업로드됩니다.
strategy:
fail-fast: false
matrix:
include:
- service: gateway
test_args: '--coverage --ci'
- service: identity
test_args: '--coverage --ci --passWithNoTests'
- service: submission
test_args: '--coverage --ci --forceExit'
# ...fail-fast: false가 중요합니다. 하나의 서비스 테스트가 실패해도 나머지 서비스는 계속 테스트합니다. 한 번에 모든 실패를 확인해야 디버깅이 효율적이기 때문이에요.
test-ai-analysis (pytest)
FastAPI 서비스는 pytest로 테스트하고, --cov=src --cov-report=xml로 커버리지를 XML 형식으로 출력합니다.
test-frontend (Vitest)
프론트엔드는 Vitest로 테스트합니다. --ci --coverage 플래그로 CI 환경에 최적화된 설정을 적용합니다.
5단계: 의존성 감사
테스트와 병렬로 audit-npm이 실행됩니다. 6개 Node.js 프로젝트(백엔드 5 + 프론트엔드 1)에 대해 npm audit을 수행합니다.
- name: npm audit (high/critical)
run: npm audit --audit-level=critical --omit=dev--audit-level=critical로 critical 수준의 취약점만 차단합니다. 단계 이름에는 high가 남아 있지만, 실제로 막는 기준은 critical입니다. --omit=dev로 개발 의존성은 제외합니다. 프로덕션에 배포되지 않는 devDependency의 취약점까지 차단하면 CI가 너무 자주 깨지기 때문이에요.
6단계: 핵심인 빌드 게이트
빌드 job의 조건을 보면 이 파이프라인의 철학이 드러납니다.
build-services:
needs: [detect-changes, test-node, test-ai-analysis, audit-npm, secret-scan]
if: |
!cancelled() &&
(needs.test-node.result == 'success' || needs.test-node.result == 'skipped') &&
(needs.test-ai-analysis.result == 'success' || needs.test-ai-analysis.result == 'skipped') &&
(needs.audit-npm.result == 'success' || needs.audit-npm.result == 'skipped') &&
needs.secret-scan.result == 'success'이 조건이 의미하는 것:
- secret-scan은 무조건 성공해야 합니다.
skipped도 허용하지 않습니다. 시크릿 스캔은 어떤 상황에서도 건너뛸 수 없습니다. - test, audit는 성공 또는 스킵이어야 합니다. 변경되지 않은 서비스의 테스트는 스킵되니까,
skipped를 허용합니다. 하지만failure는 절대 허용하지 않습니다. !cancelled()가 맨 앞에 있습니다. GitHub Actions는 기본적으로needs에 적은 job이 하나라도 실패하거나 스킵되면 뒤 job을 건너뜁니다.!cancelled()를 붙이면 앞 job이 스킵돼도 이 조건식으로 직접 판단해 실행하고, 사용자가 실행을 취소했을 때만은 돌지 않습니다.
프론트엔드 빌드도 동일한 패턴입니다.
build-frontend:
needs: [detect-changes, test-frontend, audit-npm, secret-scan]
if: |
!cancelled() &&
needs.detect-changes.outputs.frontend == 'true' &&
needs.test-frontend.result == 'success' &&
(needs.audit-npm.result == 'success' || needs.audit-npm.result == 'skipped') &&
needs.secret-scan.result == 'success'프론트엔드는 테스트가 반드시 success여야 합니다. skipped도 허용하지 않아요. 변경이 감지되었는데 테스트가 스킵되는 것은 비정상 상황이기 때문입니다.
ARM64 크로스 빌드
빌드된 Docker 이미지는 linux/arm64 플랫폼으로 빌드됩니다. 배포 서버가 오라클 클라우드(OCI)의 ARM 인스턴스(VM.Standard.A1.Flex)이기 때문이에요. GitHub Actions는 x86 런너에서 실행되지만, QEMU + buildx로 ARM64 이미지를 크로스 빌드합니다.
이미지 태그는 main-{git-sha} 형식입니다. latest 태그는 절대 사용하지 않습니다. 어떤 커밋의 이미지가 배포되었는지를 항상 추적할 수 있어야 하기 때문이에요.
7단계: 이미지 취약점 스캔
빌드된 이미지는 바로 배포되지 않습니다. 컨테이너 이미지 취약점 스캐너 Trivy로 trivy-scan이 8개 이미지(백엔드 6 + 프론트엔드 + 블로그) 전부를 스캔합니다.
trivy image \
--platform linux/arm64 \
--severity CRITICAL,HIGH \
--exit-code 1 \
--ignore-unfixed \
--format table \
"${{ env.IMAGE_PREFIX }}-${{ matrix.service }}:main-${{ github.sha }}"CRITICAL과 HIGH 수준의 취약점이 발견되면 exit code 1을 반환해서 파이프라인을 차단합니다. --ignore-unfixed로 아직 패치가 나오지 않은 취약점은 제외합니다. 결과는 정적 분석 결과의 표준 형식인 SARIF로도 생성되어 GitHub Security 탭에 업로드됩니다.
8단계: GitOps 락
GitOps 락이란, 배포 설정 저장소를 거쳐야만 배포가 일어나도록 묶어 둔 구조를 말합니다.
모든 스캔을 통과한 이미지만 배포됩니다. 그런데 알고수에서 "배포"란 이미지를 서버에 직접 올리는 게 아닙니다.
deploy:
needs:
- secret-scan
- detect-changes
- trivy-scan
- build-services
- build-frontend
- build-blog
if: |
github.ref == 'refs/heads/main' && !cancelled() &&
needs.secret-scan.result == 'success' &&
needs.trivy-scan.result != 'failure' &&
(needs.build-services.result == 'success' ||
needs.build-frontend.result == 'success' ||
needs.build-blog.result == 'success')deploy job이 하는 일은 단 하나입니다. 배포 설정만 따로 모아 둔 저장소(aether-gitops)에서, 서비스별 이미지 태그를 적어 두는 kustomization.yaml을 업데이트하는 것입니다. 그러면 Git 저장소의 설정을 클러스터에 자동으로 맞춰 주는 도구 ArgoCD가 변경을 감지해 k3s(경량 쿠버네티스) 클러스터에 동기화합니다. 이렇게 Git에 적힌 내용대로 배포가 이뤄지는 방식을 GitOps라고 부릅니다.
# aether-gitops/algosu/overlays/prod/kustomization.yaml
for SVC in $UPDATED; do
python3 -c "
import yaml
with open('kustomization.yaml', 'r') as f:
data = yaml.safe_load(f)
for img in data.get('images', []):
if 'algosu-${SVC}' in img.get('name', ''):
img['newTag'] = 'main-${SHA}'
with open('kustomization.yaml', 'w') as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
"
done이 구조의 장점은 소스 코드 레포와 배포 레포가 분리되어 있다는 것입니다. 소스 레포에서 아무리 실수를 해도, 배포 설정 저장소를 직접 건드리지 않는 한 프로덕션 배포에는 영향을 주지 않습니다.
배포 서버의 순차 롤아웃
평소 배포는 ArgoCD가 맡습니다. 서비스를 아래 Layer 순서대로 하나씩 올리는 방식은, ArgoCD를 들이기 전 배포 서버(k3s 클러스터)에서 직접 돌리던 deploy.sh 스크립트에 담겨 있던 순서입니다.
L0 인프라
PostgreSQL · Redis · RabbitMQL1 Identity
인증 (다른 서비스의 의존성)L2 비즈니스
Problem + SubmissionL3 비동기
GitHub Worker + AI AnalysisL4 Gateway
라우팅 + 외부 진입L5 Frontend
Frontend + Ingress
각 Layer는 이전 Layer의 롤아웃이 완료되어야 시작됩니다. 서비스가 실패하면 자동 롤백(kubectl rollout undo)이 실행됩니다. 인프라(Layer 0)는 롤백이 불가능하기 때문에, 실패 시 수동 개입이 필요하다는 경고를 출력합니다.
실제로 겪은 사고
1. amend + force-push의 함정
앞서 말한 것처럼, git commit --amend 후 force-push를 하면 dorny/paths-filter가 변경을 감지하지 못합니다. paths-filter는 이전 커밋과 현재 커밋의 diff를 비교합니다. 그런데 amend하면 커밋이 새 SHA로 바뀌어, paths-filter가 비교 기준으로 삼는 이전 커밋이 원격 브랜치에서 사라지기 때문이에요.
해결: force-push를 프로젝트 전체에서 금지했습니다. 수정이 필요하면 새 커밋을 만듭니다. 깔끔한 커밋 히스토리를 포기하는 비용은 있지만, 안전한 CI가 깨끗한 히스토리보다 중요합니다.
2. 배포 설정 저장소 수동 태그 변경의 위험
한번은 급하게 배포해야 해서 배포 설정 저장소의 kustomization.yaml을 수동으로 편집했습니다. 이미지 태그를 직접 변경했는데, 그 태그에 해당하는 이미지가 GitHub의 컨테이너 이미지 저장소(GHCR)에 존재하지 않았습니다. ArgoCD는 매니페스트를 동기화했지만, k3s가 이미지를 풀링하지 못해서 Pod가 ImagePullBackOff(이미지를 받지 못해 재시도만 반복하는 상태)에 빠졌습니다.
해결: 배포 설정 저장소를 수동으로 변경할 때는 반드시 GHCR에 해당 이미지가 존재하는지 먼저 확인합니다.
3. 환경변수가 실제로 주입되었는지 확인하기
매니페스트를 변경하고 배포했는데, 서비스가 이상하게 동작했습니다. 확인해보니 소스 레포의 k8s 매니페스트에는 새 환경변수를 추가했지만, 배포 설정 저장소에는 반영하지 않은 거예요. ArgoCD가 동기화하는 건 배포 설정 저장소의 매니페스트이니까, 소스 레포만 변경하면 소용이 없습니다.
해결: 배포 후에는 항상 kubectl exec -- printenv로 환경변수가 실제로 주입되었는지 검증합니다. 그리고 소스 레포의 매니페스트를 변경할 때는 반드시 배포 설정 저장소도 함께 갱신합니다.
4. 최소 권한 원칙
CI 파이프라인의 최상위에 이런 설정이 있습니다.
permissions: {}기본 권한을 제로로 설정하고, 각 job에서 필요한 권한만 명시적으로 선언합니다. secret-scan은 contents: read만, build-services는 contents: read와 packages: write만. 이렇게 하면 특정 job이 탈취되더라도 피해 범위가 제한됩니다.
AI와 CI의 균형
알고수 프로젝트에서 AI는 코드의 대부분을 생성했습니다. 12개의 에이전트가 역할을 나누어 작업했고, 총괄 에이전트가 전체를 조율했습니다. 하지만 아무리 정교한 에이전트 체계를 갖추어도, 최종 검증은 사람이 아닌 자동화된 파이프라인이 맡아야 했습니다.
15개의 CI job은 AI가 생성한 코드에 대해 이런 질문들을 던졌습니다:
- 시크릿이 유출되었나? (secret-scan)
- 코드 품질이 기준을 충족하나? (quality-nestjs, quality-python, quality-frontend)
- 테스트가 통과하나? (test-node, test-ai-analysis, test-frontend)
- 의존성에 취약점이 있나? (audit-npm)
- 빌드된 이미지에 취약점이 있나? (trivy-scan)
- 배포 경로가 안전한가? (deploy → 배포 설정 저장소 → ArgoCD)
이 모든 관문을 통과해야 코드가 프로덕션에 도달할 수 있었습니다.
AI가 빠르게 코드를 만들어줄수록, 개발자의 역할은 코드를 작성하는 것에서 코드를 검증하는 시스템을 설계하는 것으로 옮겨갔습니다. 빠르게 만들수록 검증은 더 철저해야 했고, 사람의 리뷰만으로는 한계가 분명했기에 자동화된 파이프라인이 필수였습니다.
15 jobs가 많다고 느낄 수도 있지만, 이 중 하나라도 없었다면 프로덕션에서 사고가 났을 겁니다. 안전망은 촘촘할수록 좋다는 걸 몸으로 배웠습니다.