엔지니어링대기 PR·중복 설정·커버리지 게이트를 정리한 CI 리팩토링
· 알고수
- #ci-cd
- #github-actions
- #ai-dev
- #refactoring
참고에서 실천으로
채널톡의 「Backend CI Refactoring」 글을 처음 읽었을 때, 즉각 "알고수에도 이렇게 하면 되겠다"는 생각이 들었습니다. 36.6분짜리 CI를 15분 38초로 줄인 이야기. 방법이 구체적이고 원리가 명확했습니다.
그런데 그대로 따라할 수 없었습니다. 알고수는 채널톡과 다릅니다. 1인 개발자가 여러 AI 에이전트를 지휘하는 구조이고, 빌드는 모두 Docker 안에서만 돌며, 총괄 에이전트가 다른 에이전트에게 작업을 나눠 주는 방식으로 일합니다. 레퍼런스의 원리는 차용할 수 있었지만, 그 원리를 알고수 맥락에 맞게 번역하는 과정이 필요했습니다.
이 글은 완성된 CI 아키텍처를 소개하는 글이 아닙니다. 알고수는 스프린트(짧은 작업 주기)를 백 번 넘게 돌렸고, 이 글은 그중 102~106번째, 다섯 스프린트(S102~S106)에 걸쳐 레퍼런스를 읽고, 실험하고, 때로는 계획을 중단한 기록입니다. 특히 "구현 0줄로 끝낸" 두 작업이 왜 실패가 아니라 성과였는지를 중심으로 이야기합니다.
문제
잘 만든 CI 레퍼런스는 많았지만 1인 + AI 에이전트 구조인 우리 파이프라인에 그대로 들어맞지 않았다. 대기 PR 30건, 반복되는 준비 단계, 막지 않는 커버리지가 쌓여 있었다.
결정
레퍼런스에서 원리만 골라 4스프린트로 세운 로드맵(실제로는 남은 항목을 처리한 한 스프린트가 더해짐)으로 Auto-merge부터 측정 규칙 정하기까지 단계적으로 직접 적용했다.
결과
대기 PR은 자동 병합되고, 여러 단계를 묶어 재사용하는 Composite Action으로 중복 67줄이 사라졌고, 커버리지 게이트는 70%가 됐다. 마지막 스프린트의 과제 3개 중 2개는 구현 전 사전 검토로 "이미 처리됨"을 확인해 구현 0줄로 끝냈다.
레퍼런스가 필요했던 이유
시작 시점의 상태를 정직하게 기록하면 이렇습니다.
Before: 세 가지 문제
- 의존성 업데이트 PR을 자동으로 만들어 주는 GitHub 봇 Dependabot의 PR이 매주 쌓여, 일일이 손으로 병합해야 하는 대기 PR 30건
- Node 준비 단계(
setup-node + cache + npm ci)가 잡·서비스 조합마다 3~4벌 복사돼 있음 - 테스트 커버리지를 측정은 했지만 기준에 못 미쳐도 PR을 막지 않음 (측정만 있고 게이트 없음)
30건
Dependabot 대기 PR
매주 생성, 수동 squash-merge 누적
3~4벌
중복 준비 단계
잡×서비스마다 setup-node + cache + npm ci 반복
없음
Coverage 게이트
측정은 하지만 PR 머지 차단 안 함
이 세 문제는 독립적으로 보이지만 공통 원인이 있었습니다. "지금 당장 동작하면 됐다"는 관성. CI가 통과하면 배포했고, 반복 코드 정리는 늘 다음으로 밀렸습니다.
채널톡 글에서 차용한 원리는 세 가지입니다.
01
작은 범위 파일럿 → 확산
전체 서비스에 한 번에 적용하지 않고 가장 단순한 서비스에서 먼저 검증한 뒤 확장한다
02
워크플로 + 저장소 설정 결합
GitHub Actions 파일만 만들면 반쪽이다. 저장소 설정이 뒷받침되어야 실효된다
03
중복 제거는 목적이 아니라 결과
중복을 없애려고 공통 모듈을 만드는 게 아니라, 좋은 추상이 자연스럽게 중복을 없앤다
차용하지 않은 원리도 있습니다. 채널톡 글의 두 기법, 즉 준비 단계 결과를 S3에 올려 두고 폴링해 초기화를 겹치는 방식과 테스트를 동적 큐로 나눠 돌리는 방식은 알고수에는 과했습니다. GitHub Actions(GHA)의 artifact로 충분했고, 테스트가 적어서, 나눠 돌리는 비용이 얻는 이득보다 컸습니다. 레퍼런스를 번역할 때는 차용하지 않을 것을 고르는 것도 작업입니다.
알고수만의 사정이 하나 더 있었습니다. 총괄 에이전트가 작업을 나눠 주는 구조라서, "파일럿 후 확산"을 스프린트 단위로 적용하면서도 매 스프린트 시작 전에 어느 에이전트에게 맡길지를 다시 확인해야 했습니다. 첫 스프린트에서 CI 작업을 인증·보안 담당 에이전트에게 잘못 맡겼다가 인프라 담당 에이전트로 바꾼 일이 있었습니다. 여러 에이전트와 일할 때 역할 경계 확인은 코드 작성만큼 중요합니다.
4스프린트 로드맵
채널톡 글을 읽고 세운 로드맵은 4스프린트(S102~S105)였고, 실제로는 남은 항목을 처리하는 S106이 추가됐습니다. 각 스프린트의 목적과 실제 산출물은 다음과 같습니다.
S102 · 운영 자동화
Dependabot 그룹화 + Auto-merge + Branch Protection(main 병합 조건 설정)
S103 · Composite 파일럿
setup-node-service 신설 + github-worker 한정 적용 + Coverage Gate 60%
S104 · 확산
전 Node 서비스 Composite 확산 (67줄 삭제) + AI Coverage 통합
S105 · 측정 규칙 정하기
rebuild_all(전체 서비스 강제 재빌드) 런북 + github-worker 실측 + commitlint 동적 scope
S106 · 이월 처리
Coverage 70% 달성 (실구현) + L2 캐시·빌드 최적화는 사전 검토로 조기 종결
각 스프린트는 독립적이었지만, 앞 스프린트의 교훈이 다음 설계로 이어졌습니다. "CI를 고친 PR은 자기 효과를 측정하지 못한다"는 교훈은 전체 서비스를 강제로 다시 빌드하는 옵션(rebuild_all)의 사용 규칙으로 이어졌고, 구현 전에 다른 에이전트에게 먼저 필요성을 묻는 사전 검토 습관이 마지막 스프린트의 "구현 0줄" 결론을 가능하게 했습니다.
S102: 자동 병합 설정
첫 번째 문제는 Dependabot 대기 PR 30건이었습니다. 매주 생성되는 patch/minor 업데이트를 손으로 검토하고 병합하는 일이 밀린 결과였습니다.
해결 방향은 두 단계였습니다: dependabot.yml 그룹화로 개별 PR 수를 줄이고, 조건을 통과하면 PR을 자동으로 병합하는 auto-merge 워크플로로 patch/minor를 처리한다.
Dependabot 그룹화
# .github/dependabot.yml (발췌)
updates:
- package-ecosystem: npm
directory: /services/gateway
schedule:
interval: weekly
groups:
gateway-minor-patch:
update-types:
- minor
- patch8개 ecosystem(Node 서비스 5개 + Frontend + Blog + Python)에 {service}-minor-patch 그룹을 추가했습니다. Docker 이미지 업데이트 2건은 보안 영향이 클 수 있어 그룹에서 제외했습니다.
Auto-merge 워크플로
# .github/workflows/dependabot-automerge.yml (핵심 발췌)
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Fetch Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
- name: Auto-merge patch and minor
if: |
steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
steps.metadata.outputs.update-type == 'version-update:semver-minor'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Skip major updates
if: steps.metadata.outputs.update-type == 'version-update:semver-major'
run: |
echo "Major update detected — skipping auto-merge."
exit 0여기서 중요한 결정이 있었습니다. pull_request 대신 pull_request_target을 트리거로 선택했습니다. Dependabot이 만드는 PR은 포크 PR과 유사한 컨텍스트를 가지며, secrets에 접근하려면 pull_request_target이 필요합니다. 단, 코드 인젝션 위험을 막기 위해 actions/checkout을 완전히 제거했습니다. PR 코드를 실행하는 단계가 없으면 인젝션 경로도 없습니다.
job-level if: github.actor == 'dependabot[bot]', step-level metadata type 검증, actions/checkout 제거의 3중 방어입니다.
저장소 설정 결합
워크플로를 만들고 나서 auto-merge가 동작하지 않았습니다. 이유를 찾아보니 저장소 설정에 있었습니다.
GitHub의 auto-merge는 두 가지 전제가 필요합니다:
- 저장소의
allow_auto_merge: true설정 - Branch Protection의 required status checks가 통과
총괄 에이전트가 gh API로 직접 설정을 추가했습니다. Branch Protection은 main 브랜치에 병합되기 전 통과해야 할 조건을 거는 GitHub 설정입니다.
# 저장소 설정 (gh api 직접 설정)
allow_auto_merge: true
delete_branch_on_merge: true
# main Branch Protection
strict: true
required_checks: ["Secret & Env Scan", "Detect Changed Services"]
allow_force_pushes: false
allow_deletions: false
required_conversation_resolution: truerequired checks를 최소화한 이유가 있습니다. quality-nestjs, test-node 같은 잡은 변경된 서비스가 없으면 skip됩니다. skip 상태를 required check에 등록하면 해당 잡이 실행되지 않을 때 PR이 merge되지 않습니다. 항상 실행되는 Secret & Env Scan과 Detect Changed Services만 등록했습니다.
결과: Dependabot 대기 PR 30건 → 2건. 28건이 그룹 PR 7개로 재편되어 auto-merge 예약됐습니다. 예상치 못한 보너스도 있었습니다. dependabot이 grouping을 감지하자 기존 개별 PR을 자동으로 close하고 그룹 PR로 재생성했습니다. 수동으로 close할 필요도 없었습니다.
이 작업 PR의 검증 결과:
| 검증 항목 | 결과 |
|---|---|
| 작업 PR CI 전체 | ✅ 26 success / 10 skipped / 0 failure (매트릭스로 펼친 잡 수 기준) |
| Auto-merge 워크플로 실행 | ✅ 7/7 success (그룹 PR 7개) |
| 실제 자동 병합 실증 | ✅ github-worker 그룹 PR(업데이트 3건), app/github-actions가 병합한 것 확인 |
| Branch Protection 실효 | ✅ main 직접 push 차단, strict 모드에서 최신 base 요구 |
S103~4: 파일럿과 확산
두 번째 문제는 setup-node + cache + npm ci 3단계 반복이었습니다. 해법은 composite action, 즉 여러 단계를 하나로 묶어 재사용하는 GitHub Actions 기능입니다. quality-nestjs, audit-npm, test-node 3개 잡의 5개 서비스 매트릭스마다 동일한 준비 단계가 반복됐습니다.
채널톡 레퍼런스의 "작은 서비스에서 먼저 검증 후 확산" 원리를 스프린트 단위로 적용했습니다. S103에서 github-worker 한 곳에만 파일럿, S104에서 전 서비스로 확산.
S103: Composite Action 파일럿
github-worker를 파일럿 서비스로 고른 이유는 명확합니다. 5개 Node 서비스 중 가장 단순한 구조(순수 Node.js, NestJS 미사용)라서 패턴 변경의 부작용을 최소 범위에서 검증할 수 있었습니다.
# .github/actions/setup-node-service/action.yml
name: 'Setup Node Service'
description: 'setup-node + lockfile cache + conditional install for a single service'
inputs:
service-path:
description: 'Relative path to the service directory'
required: true
node-version:
description: 'Node.js version'
default: '20'
install-command:
description: 'Install command to run'
default: 'npm ci'
runs:
using: composite
steps:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
- name: Cache node_modules
id: cache
uses: actions/cache@v5
with:
path: ${{ inputs.service-path }}/node_modules
key: node-${{ inputs.node-version }}-${{ hashFiles(format('{0}/package-lock.json', inputs.service-path)) }}
restore-keys: |
node-${{ inputs.node-version }}-
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
shell: bash
run: ${{ inputs.install-command }}
working-directory: ${{ inputs.service-path }}중요한 설계 결정이 하나 있었습니다: actions/checkout을 composite에 포함하지 않았습니다. checkout은 각 잡의 공통 첫 단계이고 서비스 경로와 무관하게 항상 동일합니다. composite는 "서비스별 분기점"인 setup-node + cache + install만 추출했습니다.
이 결정으로 composite의 범용성이 높아졌습니다. 각 잡에서 checkout 방식(예: sparse-checkout)을 자유롭게 선택할 수 있고, composite은 그 결과물(체크아웃된 파일)을 그냥 사용합니다. audit-npm 잡에서는 install-command: 'npm ci --ignore-scripts'를 전달해 보안 스캔 정책을 유지했습니다.
S103: Coverage Gate
Coverage 측정은 이미 하고 있었지만, 임계치 미달 시 PR을 막지 않았습니다. scripts/check-coverage.mjs를 새로 작성해서 이 공백을 채웠습니다.
// scripts/check-coverage.mjs (핵심 로직 발췌)
import { readdirSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
function parseLcov(content) {
let lh = 0, lf = 0, brh = 0, brf = 0;
for (const line of content.split('\n')) {
if (line.startsWith('LH:')) lh += parseInt(line.slice(3));
else if (line.startsWith('LF:')) lf += parseInt(line.slice(3));
else if (line.startsWith('BRH:')) brh += parseInt(line.slice(4));
else if (line.startsWith('BRF:')) brf += parseInt(line.slice(4));
}
return { lh, lf, brh, brf };
}
// coverage/ 디렉토리 존재 여부 가드
if (!existsSync(coverageDir)) {
process.stdout.write('No coverage artifacts found. Skipping gate.\n');
process.exit(0);
}
// 재귀 탐색 — 서비스 추가 시 스크립트 무수정
function findLcovFiles(dir) { /* ... */ }외부 npm 패키지 없이 직접 작성해 공급망(supply chain) 위험을 없앴습니다. 로직도 단순합니다: 모든 lcov.info를 재귀 탐색 → 줄(LH/LF)과 분기(BRH/BRF)의 '통과 수/전체 수' 합산 → lines AND branches 동시 검증 → 임계치 미달 시 exit 1.
글로벌 60% 임계치로 시작했습니다. 각 서비스의 줄(lines) 커버리지 기준(Node 97~98%, Python 98%, Frontend 83%)가 이미 60%를 크게 초과하므로, 글로벌 60%는 "새 서비스 추가 시 바닥 방어"용이었습니다.
S104: 전 서비스 확산
확산은 단순했습니다. matrix.service != 'github-worker' 조건을 제거하고 전 서비스가 composite를 경유하도록 했습니다.
# ci.yml (확산 후 — 이 패턴이 quality-nestjs / audit-npm / test-node 3개 잡에 모두 적용)
- name: Setup Node service
uses: ./.github/actions/setup-node-service
with:
service-path: services/${{ matrix.service }}
# audit-npm 잡에서는:
# install-command: 'npm ci --ignore-scripts'inline 3스텝(Setup Node + Cache + Install) × 3잡 = ci.yml 67줄 삭제, 약 25% 압축. 성능 측정보다 먼저 체감할 수 있는 유지보수성 개선이었습니다.
S105: 측정 규칙 자동화
S105는 4스프린트 로드맵의 마감 스프린트였습니다. 세 가지 과제를 묶었습니다.
[A] rebuild_all 운영 규칙
앞의 함정(CI를 고친 PR은 자기 실측을 만들지 못함)의 해결책은 이미 ci.yml에 있었습니다. 수동 실행 때 전체 서비스를 강제로 다시 빌드하는 옵션 workflow_dispatch.inputs.rebuild_all=true입니다. 파일럿 때부터 있었지만 언제, 누가, 어떻게 쓰는지 규칙이 없었습니다.
추가 코드 없이 운영 규칙 문서화만으로 해결했습니다. docs/runbook/ci-rebuild-all.md를 신규 작성하고, .github/pull_request_template.md에 체크박스를 추가했습니다.
발동 조건 3개를 명문화했습니다:
.github/workflows/*.yml단독 변경 PR.github/actions/**composite 변경 PRscripts/check-coverage.mjs등 CI 공통 스크립트 변경 PR
런북이 의미 있으려면 작성 직후 리허설이 필요합니다. [A] 런북은 머지 2시간 내에 [B] 실측에서 실제로 사용됐습니다.
[B] github-worker 실측: 사전 검토로 N=1
먼저 실측을 어떻게 할지가 문제였습니다. 원안은 변경 후 표본 5건이었습니다. 실행하기 전에 다른 에이전트에게 이 측정 계획의 통계 검토를 먼저 맡겼습니다. 이 글에서 사전 검토라고 부르는 단계입니다. 이후 사전 검토는 늘 이 에이전트(이하 분석 담당)가 맡았습니다.
아래 답변의 MDE는 검출 가능한 최소 차이, 즉 이 표본으로 "달라졌다"고 말할 수 있는 가장 작은 시간 차이입니다. 분석 담당의 답변 핵심: "Welch-Satterthwaite 공식에서 Pre n=4가 자유도(df)를 4로 고정합니다. Post n을 2→6으로 늘려도 MDE 개선이 0.8s에 불과합니다. 원안 N=5는 오버엔지니어링입니다. N=1로 충분합니다." 쉽게 말해, 변경 전 표본이 4건뿐이라 변경 후 표본을 늘려도 검출력이 거의 오르지 않는다는 뜻입니다.
CI 실행 시간(runner-minutes) 낭비를 미리 막은 것입니다. 한 PR에 더미 주석을 넣어 detect-changes가 github-worker를 감지하게 했고, 그렇게 생긴 런 2건과 rebuild_all=true로 돌린 런 1건을 합쳐 변경 후 표본 n=3을 확보했습니다.
결과:
| 잡 | Before (n=4) | After (n=3) | 차이 |
|---|---|---|---|
Quality — github-worker | 22.2s (σ 5.8s) | 22.3s (σ 2.5s) | +0.1s (+0.4%) |
Audit — github-worker | 19.8s (σ 3.7s) | 18.0s (σ 3.0s) | -1.8s (-8.9%) |
| Test GitHub Worker | 19.2s (σ 1.9s) | 20.0s (σ 1.0s) | +0.8s (+3.9%) |
3개 잡 모두 ±10% 실용 임계 이내. Welch t-test 결과 |t_obs| < 0.7 (t_crit=2.776). composite action 확산은 github-worker 개별 잡 런타임에 측정 가능한 영향을 미치지 않음이 확인됐습니다.
사전 검토 덕분에 계획보다 적은 실행으로 결론을 낼 수 있었습니다. "계획 승인 → 즉시 실행"이 항상 최적이 아니라는 것을 처음으로 체감했습니다.
[C] commitlint 동적 scope-enum
커밋 메시지 형식을 검사하는 commitlint의 scope 오류로 CI가 실패한 적이 있었습니다. ci(actions)와 ci(coverage)는 자연스러워 보이지만 commitlint.config.mjs의 허용 목록(scope-enum)에 없어서 PR CI에서야 발견됐습니다. 로컬 pre-commit 훅이 없으면 scope 오류는 PR 제출 후에야 알 수 있습니다.
두 가지를 동시에 해결했습니다: git 훅 도구 husky로 로컬 검증 앞당기기, scope-enum 동적 생성으로 수동 유지 제거.
root package.json에 husky를 추가하고 commit-msg 훅을 설정했습니다.
// package.json (root — 신규 생성)
{
"devDependencies": {
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"husky": "^9.0.0"
},
"scripts": {
"prepare": "husky"
}
}# .husky/commit-msg
npx --no -- commitlint --edit "$1"root package.json을 추가해도 기존 CI 잡에는 영향이 없습니다. 각 잡은 working-directory를 서비스 디렉토리로 지정하거나 composite action을 경유하기 때문입니다. 해당 PR의 CI 전체 잡이 SUCCESS로 검증됐습니다.
// commitlint.config.mjs
import { readdirSync } from 'fs';
// services/ 디렉토리 자동 스캔 — 새 서비스 추가 시 자동 등록
const dynamicScopes = readdirSync('./services', { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
const staticScopes = [
'ci', 'docs', 'blog', 'frontend', 'infra',
'deps', 'security', 'adr', 'e2e', 'runbook',
];
export default {
extends: ['@commitlint/config-conventional'],
rules: {
'scope-enum': [
2,
'always',
[...new Set([...dynamicScopes, ...staticScopes])].sort(),
],
},
};이제 services/에 새 디렉토리를 만들면 scope가 자동으로 등록됩니다. 반복됐던 "새 디렉토리 추가 시 scope-enum도 함께 등록하세요" 피드백이 구조적으로 해소됐습니다. 사람 의존 피드백을 시스템 자동화로 승격한 사례입니다.
S106: "구현 0줄" 결정
마지막 스프린트는 그동안 넘어온 세 항목을 처리했습니다. 아래 표에서는 각 과제를 트랙이라 부릅니다. 결과적으로 3 트랙 중 하나만 실구현됐고, 둘은 구현 없이 종결됐습니다.
| 트랙 | 내용 | 결과 |
|---|---|---|
| [A] Coverage 70% 상향 | Frontend branches 69.55% → 71%+ 달성 + 글로벌 게이트 70% | ✅ 실구현 (PR 2개) |
| [B] L2 캐시 레이어 | Docker 빌드 40% 단축 목표 | ❌ 미도입 (사전 검토에서 중단) |
| [C] Frontend 빌드 최적화 | swcMinify · optimizePackageImports · sourceMaps 3개 | ❌ 미도입 (사전 검토에서 중단) |
[A] Coverage 70%: 병목은 Frontend branches 단독
글로벌 coverage-gate를 60%에서 70%로 올리려면 먼저 병목을 찾아야 했습니다. 전 서비스 weighted 집계 branches는 약 82%로 이미 70%를 초과하고 있었습니다. 그런데 왜 70% 게이트를 올리지 못하고 있었을까요?
사전 검토에서 구조적 이유가 드러났습니다. check-coverage.mjs는 path-filter로 감지된 서비스의 lcov.info만 집계합니다. frontend-only PR에서는 frontend 단독 branches만 집계됩니다. 그리고 frontend branches는 69.55%였습니다.
"전 서비스 통합 branches = 82%, 글로벌 게이트 70% → 통과"라는 직관이 틀립니다. frontend-only PR을 내면 69.55%만 집계되어 70% 게이트에서 막힙니다. path-filter 기반 CI 설계에서 coverage-gate는 "어떤 lcov 셋으로 동작하는가"를 PR 범위별로 명시해야 합니다.
병목 해소: Frontend branches를 71%까지 올리는 신규 테스트 작성.
분석 담당의 gap 분석:
| 목표 | 필요 branches hit | 현재 | Gap |
|---|---|---|---|
| 71.0% (이번 스프린트 목표) | 1,330 | 1,302 | +28 |
| 72.0% (안전 버퍼) | 1,348 | 1,302 | +46 |
권장 파일 3개(lib/feedback.ts, components/ui/CodeBlock.tsx, components/providers/EventTracker.tsx)에 약 120~190 LOC 신규 테스트 작성으로 달성 가능.
실제 결과: 테스트 PR에서 77개 테스트 추가, branches 69.55% → 76.42% (+6.87pp, 목표 71% 대비 +5.42pp 초과). 보수적으로 설계된 시나리오였습니다.
69.55%
Frontend branches (Before)
1302 / 1872 branches hit
76.42%
Frontend branches (After)
목표 71% 대비 +5.42pp 초과 달성
그리고 별도의 게이트 PR에서 ci.yml의 coverage threshold를 60 → 70으로 바꿨습니다. 여기서는 순서가 중요했습니다. 테스트 PR이 CI를 통과해 먼저 머지된 뒤에만 게이트 PR을 머지해야 합니다. 게이트 PR을 먼저 머지하면 그 즉시 frontend-only PR이 coverage-gate에서 막힙니다. 분석 담당의 이 경고를 게이트 PR 본문에 적어 순서를 강제했습니다.
[B] L2 캐시: Docker buildkit이 이미 L2였다
계획은 빌드 결과물을 GHA 캐시에 한 번 더 저장하는 추가 캐시 층(L2 캐시)을 두는 것이었습니다. 이번에도 구현 전에 분석 담당에게 먼저 물었습니다.
분석 담당의 발견은 충격적이었습니다. docker/build-push-action에 이미 --cache-from=type=gha,mode=max가 설정되어 있고, mode=max는 빌더 스테이지의 모든 중간 레이어를 GHA cache에 저장합니다.
# NestJS 빌드 레이어 — mode=max 캐시 커버 범위
Layer 1: FROM node:22-alpine AS builder → 캐시 저장
Layer 2: COPY package*.json ./ → package.json 미변경 시 HIT
Layer 3: RUN npm ci → package.json 미변경 시 HIT
Layer 4: COPY . . → 소스 변경 시 MISS
Layer 5: RUN npm run build ← dist/ 생성 → Layer 4 MISS 시 재실행RUN npm run build(= dist/ 생성 단계)가 이미 GHA cache 레이어로 저장되고 있었습니다. 외부 GHA 파일시스템 캐시 추가는 100% 중복이었습니다.
추가 발견도 있었습니다. ci.yml에 Frontend .next/cache GHA 캐시 단계가 이미 있었는데, 이것도 동작하지 않던 코드였습니다. build-frontend 잡은 host-side npm run build 없이 Docker 빌드만 하기 때문에, .next/cache가 host filesystem에 생성되지 않습니다. restore해도 save할 게 없고, save해도 빈 디렉토리만 저장되는 구조였습니다. Docker 전용 파이프라인 전환 이전 유산이었습니다.
더 근본적인 발견: 전 빌드 잡에서 host filesystem에서 npm run build를 실행하는 잡이 단 하나도 없었습니다. 모든 TypeScript/Next.js 컴파일은 Docker 컨테이너 내부에서만 발생합니다. GHA 파일시스템 캐시는 host filesystem에만 적용되므로, 현 Docker 전용 아키텍처에서는 활용 경로 자체가 없었습니다.
결론: L2 캐시 도입 중단. 코드 변경 없음. 동작하지 않던 단계는 별도 PR로 제거.
[C] Frontend 최적화: 세 항목 모두 "이미 처리됨"
세 가지 Next.js 최적화 옵션을 적용하려 했습니다. [C]에는 빌드 단계 시간을 기록하는 작업도 있었지만, 빌드가 모두 Docker 안에서만 돌아 따로 잴 대상이 없었습니다. 분석 담당이 node_modules/를 직접 확인한 결과:
| 항목 | 계획 | 실측 결과 | 판정 |
|---|---|---|---|
swcMinify: true | 명시 설정 | Next.js 15.5.15 config-schema.js에서 완전 제거 (z.strictObject 위반) | HARD BLOCK |
optimizePackageImports | @radix-ui/react-*, lucide-react 추가 | 와일드카드 미지원 + lucide-react 기본 포함 목록에 이미 존재 | 제외 |
productionBrowserSourceMaps: false | 명시 설정 | config-schema.js에서 기본값이 이미 false | 제외 |
swcMinify는 Next.js 14.x 기준으로 플랜을 작성할 때는 유효한 옵션이었습니다. 그러나 15.5.15에서 config 스키마에서 완전 제거됐고, z.strictObject() 검증 때문에 추가하면 빌드가 깨집니다. 플랜 수립 후 라이브러리 버전이 올라간 경우, 공식 문서만으로는 부족합니다. node_modules/ 소스 레벨 직접 실측이 정확도 보증의 유일한 방법입니다.
결론: 세 항목 모두 적용 불가 또는 이미 기본값. 코드 변경 없음.
사전 검토는 구현 필요성 게이트
돌아본 원칙 4가지
5스프린트를 돌아보면 네 가지 원칙이 반복해서 등장했습니다.
(i) 파일럿 → 확산이 가설 검증의 기본 단위
github-worker 한 곳에서 파일럿을 하고 다음 스프린트에 전 서비스로 확산했습니다. 단순해 보이지만 이 구조 덕분에 "checkout 미포함" 설계 결정과 "인프라 PR 실측 불가" 문제를 작은 범위에서 발견하게 해줬습니다. 전 서비스에 한 번에 적용했다면 같은 문제를 훨씬 큰 범위에서 마주쳤을 겁니다.
파일럿을 고를 때는 "가장 단순한 서비스"를 먼저 선택하는 게 맞습니다. github-worker가 NestJS 없는 순수 Node.js 서비스라서 부작용이 최소화됐습니다. 파일럿 결과가 성공이면 확산하고, 실패하면 수정해서 재파일럿합니다. 스프린트 단위 분리가 이 사이클을 자연스럽게 강제했습니다.
(ii) 워크플로는 저장소 설정과 짝일 때만 작동
auto-merge 작업에서 몸으로 배운 원칙입니다. GitHub Actions 파일 하나로 "자동화 완성"이라고 생각하기 쉽지만, allow_auto_merge: true와 required status checks 없이는 워크플로가 실행되어도 auto-merge가 예약되지 않습니다.
이 원칙은 CI 자동화 전반에 적용됩니다. Branch Protection 없는 required checks는 의미 없고, coverage-gate도 Branch Protection에 등록돼야 PR 머지를 실제로 차단합니다. "워크플로를 만들었다"는 것과 "자동화가 작동한다"는 것 사이에 항상 설정 레이어가 있습니다.
(iii) 인프라 PR은 자기 자신의 실측을 만들지 못한다
composite action 파일럿과 확산에서 빠진 함정입니다. CI 파이프라인을 수정하는 PR은 그 파이프라인의 실측 데이터를 만들지 못합니다. path-filter가 서비스 코드 변경을 감지하지 못하면 서비스 잡 전체가 skip됩니다.
이 구조적 공백의 해결책은 rebuild_all=true workflow_dispatch였습니다. 이것을 써야 하는 조건을 문서로 정한 것이 S105 [A]의 핵심이었습니다. 코드 변경 없이 런북 하나로 두 스프린트 동안 쌓인 측정 공백을 메웠습니다.
(iv) 사전 검토는 "구현 필요성 게이트"다. 구현 0줄도 결론이 된다
끝나지 않은 이야기
5스프린트가 끝났지만, 한 가지 구조적 제약이 해소되지 않았습니다. 알고수의 모든 빌드 잡은 Docker 전용입니다. host-side에서 npm run build를 실행하는 잡이 단 하나도 없습니다.
이 제약이 마지막 스프린트에서 L2 캐시([B])와 빌드 타이밍 측정([C])을 함께 막았습니다. 두 항목이 같은 근본 원인에서 나왔다는 발견이 다음 방향을 정하는 핵심 단서입니다.
앞으로의 길:
| 방안 | 설명 | 예상 효과 |
|---|---|---|
| Blog host-side SSG 빌드 | CI에서 out/ 빌드 on host → GHA cache → Docker는 COPY 전용 | 캐시가 없을 때(캐시 미스) 40~60% 단축 |
| Frontend host-side 빌드 | .next/standalone on host → GHA cache → Docker COPY only | 캐시가 없을 때(캐시 미스) 40~60% 단축 |
| 서비스별 독립 coverage 게이트 | check-coverage.mjs per-service threshold, path-filter 오해 구조 해소 | 가시성 향상 |
host-side 빌드 전환은 단순 최적화 PR이 아닙니다. Dockerfile과 ci.yml 양쪽을 동시에 수정해야 하는 아키텍처 의사결정입니다. 채널톡이 준비 단계를 S3로 분리한 것처럼, 알고수도 빌드 단계를 host-side로 분리하는 방향으로 나아가고 있습니다. 다만 그 결정을 섣불리 하지 않고, 현재 아키텍처의 한계를 먼저 명확히 이해하는 것이 이번 5스프린트의 진짜 기여입니다.
5스프린트를 정리하면 이렇습니다.
30→ 2
Dependabot PR
S102: 그룹화 + auto-merge
-67줄
CI 중복 코드
S103~S104: Composite action
69.55→ 76.42%
Frontend branches
S106: 77개 테스트 추가
2건
미구현 결정
S106: 사전 검토로 조기 종결
채널톡의 「Backend CI Refactoring」 글을 다시 읽어보면, 그 글이 정답을 알려준 게 아니라 질문을 던진 것이었습니다. "알고수 파이프라인에서는 무엇을 고칠 수 있지?" 그 질문을 알고수 맥락으로 옮겨 걸어 본 길이 이 다섯 스프린트였습니다.
레퍼런스는 방향을 가리켰고, 나머지는 직접 걸었습니다.