2026년 8월 23일, CI 잡이 3초 만에 빨간불이 됐다. 로그를 열었더니 실패한 스텝이 없었다. 스텝 자체가 없었다. 계정의 GitHub Actions 무료 분 소진이었다 — 2,000분을 22일에 다 써서, 잡이 실패한 게 아니라 시작을 못 한 것이었다
하루 전에 나는 같은 문제를 이미 한 번 닫았다. 앱 레포의 CI 분을 분석하고 “푸시를 모아서 밀자”로 결론을 냈다. 그 결론이 틀렸다는 걸 다음 날 알았다
결과부터 적으면, 8월 소비는 재검산 2,001분으로 할당량의 100%였고, 지금은 월 예상 256분으로 13%다. 줄인 방법은 CI를 빠르게 만든 것도, 테스트를 줄인 것도, 푸시를 아낀 것도 아니다. 청구되는 자리에서 나온 것이다
이 글의 수는 두 시점이 섞여 있다. 차단 직후에 잰 값과 9월 1일 리셋 뒤에 다시 잰 값이고, 둘이 다르면 갈라 적는다. 관측이 아닌 해석은 문장 안에서 밝힌다
잡이 실패한 게 아니라 시작을 못 했다
처음 의심한 건 워크플로 YAML이었다. 직전에 아무것도 안 건드렸는데도 그쪽을 먼저 본 이유는, 3초에 끝나는 실패가 대개 문법 오류이기 때문이다
잡 목록을 API로 찍어 보니 모양이 달랐다
gh api repos/:owner/:repo/actions/runs/<id>/jobs \ --jq '.jobs[] | "\(.name) \(.conclusion) steps=\(.steps|length) runner=\(.runner_name)"' # → hosted failure steps=0 runner=null
conclusion은 failure인데 steps가 0이고 러너 이름이 비어 있다. 스텝이 0이면 실행 계획이 만들어지기 전에 끝난 것이다. 러너가 배정되지 않았다는 뜻이다
사유 문자열은 check-runs/<jobid>/annotations에 있었다
The job was not started because recent account payments have failed or your spending limit needs to be increased.
이 문구가 소진과 결제 실패를 안 가른다. or로 묶여 있다. 둘 중 어느 쪽인지는 Billing 화면을 봐야 알 수 있고, 거기에 2,000 / 2,000 minutes · resets Sep 1이 있었다
이 판별값은 하나로 쓰면 틀린다. 뒤에 세면서 알았는데, skipped 잡도 스텝이 0이다. 앱 레포는 main에서 자체 호스팅 잡을, dev에서 GitHub 호스팅 잡을 조건부로 건너뛰기 때문에 8월 23일 이후 스텝 0인 skipped 잡이 111건 있었다. 그래서 판별값은 항상 쌍으로 본다
결론=failure + 스텝=0 → 잡이 안 돌았다 결론=skipped + 스텝=0 → 건너뛴 것이다. 정상
그리고 잡이 안 도는 사유는 billing만이 아니다. 러너 오프라인과 YAML 오류도 화면에서 같은 모양이다
청구는 run이 아니라 job 단위 올림이다
소진처를 분석하기 전에 청구 규칙을 먼저 못 박아야 했다. 여기가 틀리면 뒤의 계산이 통째로 어긋난다
| 항목 | 값 |
|---|---|
| 무료 할당량 | 계정 합산 월 2,000분 (GitHub Free) |
| 적용 대상 | 비공개 레포만. 공개 레포의 GitHub 호스팅 러너는 무제한 무료 |
| 청구 단위 | job 단위 wall-clock을 분 올림 × OS 배수 |
| OS 배수 | ubuntu ×1 · windows ×2 · macOS ×10 |
| 자체 호스팅 러너 | 과금 체계 밖. 공개·비공개 무관 |
| 초과 시 | 지출 한도가 $0이면 과금이 아니라 차단 |
| 초기화 | 매월 1일 |
두 줄이 이 사건 전체를 지배한다
청구 단위가 run이 아니라 job이고 분 단위 올림이다. 1초를 쓴 잡도 1분으로 청구된다. 짧은 잡을 많이 돌리는 구성은 벽시계 시간보다 훨씬 많이 청구된다
자체 호스팅 러너는 과금 체계 밖이다. 소진 차단도 적용되지 않는다. 이 한 줄 때문에 러너 전환이 “다음 달 절약”이 아니라 “지금의 차단 해제”가 됐다
macOS ×10은 실제로 맞아 본 항목이다. 7월에 다른 비공개 레포에서 macOS 잡 12개가 실행 시간 47분을 470분으로 청구받았다. 그해 7월 「그 밖 레포」 몫 586분 중 470분이 이 배수였다
timing 엔드포인트가 137 run을 전부 0분이라고 했다
소진처를 세려고 GitHub이 주는 청구 시간 API를 먼저 썼다. GET /actions/runs/{id}/timing이 billable을 돌려주니 이걸 합치면 끝날 일이었다
{"billable":{"UBUNTU":{"total_ms":0,"jobs":1}},"run_duration_ms":122000}
run_duration_ms는 122초인데 billable.UBUNTU.total_ms가 0이다. 이 비공개 레포들에서 전부 0으로 왔다. 그대로 집계하면 137개 run이 모두 0.0분이 되고, “우리는 안 썼다”는 결론이 조용히 나온다
이 오류를 잡은 건 도구가 아니었다. 137개 run이 0분일 수 없다는 상식이었다. 만약 대상이 3개 run이었으면 그냥 믿었을 것이다
처방은 청구 모델을 직접 구현하는 것이다. /actions/runs/{id}/jobs에서 각 잡의 started_at과 completed_at을 받아 직접 센다
청구 분 = ceil((completed_at - started_at) / 60초) × OS 배수 skipped·스텝 0 은 0으로 센다 자체 호스팅 라벨이 붙은 job 은 청구 0
이 계산으로 8월 계정 합계가 나왔다. 차단 직후 계산이 2,002분, 리셋 뒤에 다시 잰 값이 2,001분, 청구서가 2,000분이다. 오차 0.05%로 닫혔다
검산이 닫혔다는 사실이 두 가지를 준다. 측정을 믿을 수 있게 되고, 다른 레포에 유의미한 몫이 없다는 것도 같이 닫힌다. “타 레포 소관”으로 미룰 것이 없다는 뜻이다. 실제로 처음엔 세 번째 레포 몫을 뺄셈으로 “사실상 0″이라고 추정했는데, 직접 세 보니 2분이었다. 나머지 비공개 레포 6곳은 8월 실행이 0건이었다
직접 조회한 건 비공개 레포 중 2026년에 갱신된 7곳이다. 전수가 아니다. 그런데도 「다른 레포에 몫이 없다」고 말할 수 있는 근거는 표본 크기가 아니라, 합계가 청구서와 1분 차로 닫힌다는 것이다. 0건을 주장할 때는 무엇을 봤는지 같이 적어야 한다
소진처는 습관이 아니라 앱 레포의 dev push CI였다
계정 하나에 레포가 여러 개고 할당량은 합산이다. 어디가 태웠는지부터 갈랐다
| 8월 청구 분 | job 수 | 몫 | |
|---|---|---|---|
앱 dev push | 1,149 | 198 | 57% |
앱 main 승격 | 256 | 43 | 13% |
| 서버(api) CI | 400 | 79 | 20% |
| 서버(api) Deploy | 194 | 58 | 10% |
| 다른 비공개 레포 | 2 | 1 | 0.1% |
| 합계 | 2,001 |
앱 레포 하나가 70%, 그 안에서 dev push CI 하나가 57%다
여기서 원인을 넷 찾았다. 그중 둘은 방향이 예상과 반대였다
같은 트리를 두 번 검증하고 있었다. feature → dev → main이 한 방향 --no-ff 머지라서, dev에서 통과한 트리가 그대로 main에 올라간다. 승격 1회당 dev push가 평균 2회여서 dev와 main의 실행비가 약 2:1이 됐다
소비가 습관이 아니라 집중 작업일에 몰려 있었다. 앱 소비 1,405분 중 644분(46%)이 나흘에 있다. 08-22에 214분, 08-18에 198분, 08-19에 141분, 08-20에 91분이다. 같은 달 어떤 날은 실행이 4건뿐이다
문서만 바뀐 푸시가 전체 빌드를 돌리고 있었다. main 승격 77건 중 11건(14%)이 문서 전용이었다. 이건 줄어들 성질이 아니다. 문서 주도로 작업하고 승격 후에 상태 표를 갱신하는 관례가 만드는 구조적 패턴이다
그리고 낭비는 실패가 아니었다. 앱의 실패·취소가 쓴 분은 88분, 전체의 6%다. “빨간 실행을 줄이자”는 방향은 애초에 지렛대가 아니었다
“푸시를 모아 밀기”로 닫은 게 오판이었다
차단 하루 전인 8월 22일에 나는 이미 앱 CI 분을 분석했다. 그때 결론이 푸시 횟수를 줄이자는 것이었다. 유효한 방향이라고 생각했다
소비의 46%가 나흘에 몰려 있다는 건 그때 이미 적어 뒀다. 그런데 처방을 푸시 쪽으로 돌린 채 그 함의를 읽지 않았다. 그 나흘은 습관이 나빴던 날이 아니라 작업이 몰린 날이었고, 스펙을 하루에 세 번 고치면 푸시는 세 번 나간다. 모아서 밀 수 있는 푸시가 아니다
즉 푸시 횟수 교정으로 닫을 수 있는 몫은 절반도 안 됐다. 그런데도 나는 그걸로 티켓을 닫았다. 다음 날 오전에 차단됐다
이 오판이 처방의 방향을 바꿨다. 사람의 습관을 고치는 쪽에서, 실행이 청구되지 않는 자리로 옮기는 쪽으로 갔다
한글 폴더명 때문에 문서 전용 커밋이 8건으로 보였다
문서만 바뀐 푸시를 CI에서 빼는 안은 7월에 이미 검토했고, 그때는 기각했다. 근거는 “문서 전용 커밋이 전체의 1.5%뿐이라 효과가 없다”였다
그 1.5%가 측정 버그였다
git은 기본값 core.quotepath=true에서 비ASCII 경로를 따옴표로 감싸 출력한다
$ git log --name-only --pretty=format:%H "docs/2026-07-25-actions-\355\225\240\353\213\271\353\237\211-\354\240\210\352\260\220/00-\355\232\214\354\235\230\353\241\235.md"
경로가 "로 시작한다. 내가 쓴 분류 스크립트는 ^docs/ 패턴으로 문서 경로를 걸렀는데, 첫 글자가 따옴표라서 매칭이 안 된다. 이 저장소의 문서 경로는 전부 한글 폴더명이다. 그래서 문서 커밋이 거의 전량 “코드 포함”으로 오분류됐다.
| 계산 방식 | 문서만 바뀐 커밋 |
|---|---|
| 기본값 (버그) | 8건 |
git -c core.quotepath=false (정확) | 172건 |
1.5%가 아니라 41%였다. 사실이 정반대였다
이 오답 때문에 첫 결정 문서가 paths-ignore 대안을 기각했고, 이틀 뒤 결정 문서가 그 기각을 뒤집었다. 본 결론은 다른 근거로 살아남았지만 곁가지 하나가 측정 버그로 죽어 있었다. 두 번의 측정 실패가 같은 계급이다. 0건과 8건은 “없다”와 “패턴이 빗나갔다”를 안 가른다
이제 이 저장소에서 변경 파일을 경로로 분류할 때는 git -c core.quotepath=false를 반드시 붙인다
첫 처방은 dev push 자동 CI를 끊는 것이었다
7월 25일에 90% 통지를 받았다. 남은 게 199분, 7일이었다. 소진처를 재 보니 서버 레포의 dev push CI가 계정 할당량의 29%를 쓰고 있었다
ci.yml의 push 트리거를 좁혔다
on:
push:
branches: [ main ] # dev 제거
pull_request:
workflow_dispatch: # 필요할 때 수동 실행하는 탈출구
pull_request와 workflow_dispatch를 남긴 게 중요하다. dev를 검증할 방법이 아예 없어지면 안 되기 때문에 수동 경로를 열어 뒀다
효과는 월 918분에서 346분, 62% 감소였다. 받아들인 대가도 명시했다. dev에 테스트가 깨진 커밋이 들어갈 수 있고, 발견 시점이 main 승격까지 밀린다. 그리고 .githooks/pre-push는 린트만 돌린다. 무거운 test는 CI에 맡기는 설계여서, 터미널에서 직접 push하는 경로에서는 CI가 유일한 테스트 안전망이 된다
이 결정을 지금까지 유지하는 이유가 바뀐 게, 나중에 문제가 됐다. 러너로 옮긴 뒤엔 분이 공짜가 됐으므로 “할당량 때문”이라는 원래 이유는 죽었다. 그런데 규칙은 그대로 살아 있어야 한다. 살아 있는 이유는 둘이다. dev CI가 136회 돌아 실패를 1건 잡았고, 그 1건조차 23분 뒤 main CI가 똑같이 검출했다. 같은 문제를 두 번 리포트한 것이다. 그리고 마크다운이 빌드 입력이 될 경로가 실측 0건이다
죽은 이유는 죽은 규칙보다 위험하다. 규칙은 가만히 앉아 있는데 죽은 이유는 손을 움직이게 한다. “분이 공짜가 됐으니 되살리자”가 정확히 그 자리다. 그래서 CLAUDE.md와 ci.yml 머리 주석의 이유를 살아 있는 것으로 갈아 끼웠다
문서만 바뀐 푸시를 뺐다. 근거를 재는 명령까지 같이 박았다
두 번째 처방이 paths-ignore다. 346분에서 약 296분이 됐다
paths-ignore: - 'docs/**' - '**/*.md' - 'tools/**'
tools/는 8월 23일에 추가했다. 실측 조사용 파이썬 스크립트 5개뿐이고 빌드가 참조하는 곳이 0건인데, 문서 작업 회차에 한 줄이 끼면 전 모듈 빌드가 돌았다. 그 낭비를 실제 run 하나로 확인하고 넣었다
반대로 넣지 않은 것도 정했다. infra/**는 운영 셸이고, .github/**는 워크플로 자체이고, config/**은 detekt 설정을 빌드가 읽는다
paths-ignore는 바뀐 파일이 전부 무시 대상일 때만 건너뛴다. 코드와 문서가 섞인 푸시는 정상 실행되므로 검증 공백이 생기지 않는다
여기서 한 가지를 다르게 했다. “마크다운이 빌드 입력이 아님을 확인했다”만 적으면 아무도 다시 안 본다. 그래서 다시 재는 명령을 워크플로 머리 주석에 박았다. 축이 셋이다
# ① 빌드 파일이 적어 둔 경로
grep -nE 'srcDir|file\(|from\(|include\(' $(git ls-files '*.kts')
# ② 코드가 트리를 걸어 읽는가
grep -nE '\bFile\(|Files\.walk|walkTopDown' $(git ls-files '*.kt')
# ③ 셸 아웃
grep -nE 'ProcessBuilder|exec \{|commandLine' $(git ls-files '*.kt')
①은 generated/jooq, db/migration, detekt.yml, 모듈명 10개를 물었고 tools/·docs/·*.md는 0건이었다. ②는 1건인데 FCM 자격증명을 런타임에 읽는 코드라 빌드 입력이 아니다. ③은 0건이다
그리고 이 검색이 걸리는 함정도 같이 적었다. 제외하겠다고 적은 주석 자체가 “참조하는가” 검색에 걸려서 0건이 아니게 되므로, 0건 점검으로는 안 잡힌다. 앵커 없는 패턴은 이름 속 낱말을 물어서, walk(가 테스트 헬퍼 20건을 잡았다. 세 번째 축은 이 저장소에 참인 사례가 아예 없어서 대조군을 고를 수 없었고, 합성 입력을 만들어 패턴이 살아 있는지만 확인했다
같은 처방을 앱 레포에 그대로 옮기면 안 됐다
여기가 이 사건에서 가장 중요한 갈래다
서버 레포에서 dev push CI를 없애 572분을 아꼈다. 그러면 앱 레포도 같이 없애면 된다. 그렇게 권고안을 만들어 앱 쪽으로 넘겼고, 앱 레포를 따로 재고 나서 그 권고를 접었다. 넘긴 수치를 그대로 적용하지 않고 받는 쪽에서 다시 잰 것이 유일한 안전장치였다
| 서버(api) | 앱(Flutter) | |
|---|---|---|
| dev CI 실패율 | 136회 중 1건 (0.7%) | 198회 중 21건 (10.6%) |
| 실패의 성격 | JVM 테스트. 환경에 안 갈린다 | OS 의존 16 · 명령 의존 1 · 사람이 안 돌림 0 |
| 로컬이 잡을 수 있었나 | 대개 잡는다 | 16/17을 못 잡는다 |
| 처방 | dev CI를 없앤다 | dev CI를 없앨 수 없다. 러너로 옮긴다 |
실패율이 13배 차이다. 성격도 다르다. 앱의 실패 16건은 OS 의존이다. 대비 7:1 위반, 글자 2.0배 오버플로, 골든 이미지 불일치. 골든은 래스터라이즈와 폰트 메트릭에 달려 있어서 OS가 바뀌면 결과가 갈린다. 이 16건은 로컬 맥에서 통과하고 CI 리눅스에서 깨졌다. 로컬 게이트가 원리적으로 못 잡는 계급이다
같은 “dev CI 572분”이라는 수가 두 레포에서 반대 처방을 낳았다. 값이 아니라 그 값이 무엇을 잡고 있었나가 결정을 정한다. 서버 쪽 실측을 그대로 옮겼으면 앱은 OS 의존 실패 16건을 그날부터 놓쳤을 것이다
절약이 아니라 과금 밖으로 나갔다
8월 23일 02:20에 차단됐다. 리셋은 9월 1일이다. 9일이 남았다
이 시점에 길은 하나였다. 청구되지 않는 자리로 옮기는 것이다. 분을 더 사거나 레포를 공개로 돌리는 안은 9일을 버티는 문제고, 차단을 지금 푸는 문제가 아니었다
서버 레포는 같은 날 안에 옮겼다
| 파일 | 변경 |
|---|---|
verify.yml | workflow_call 입력 runner(기본 self-hosted) → runs-on: ${{ inputs.runner }}, CI 전용 GRADLE_USER_HOME, 힙·워커 고정 |
ci.yml | workflow_dispatch 입력 runner(self-hosted/ubuntu-latest)를 호출 시 전달 |
deploy.yml | runs-on: self-hosted |
provision-auth-env.yml | runs-on: self-hosted + if: always() 정리 스텝 |
러너는 내(회사) 맥이다. arm64 10코어 16GB, JDK 25 Corretto, Docker 29. launchd로 상주시켰고 tarball은 공표된 sha256과 대조한 뒤 실행했다
문서만 믿고 넘기지 않으려고 차단 상태에서 직접 쟀다. 러너를 붙이고 workflow_dispatch를 치니 차단 문구 대신 잡이 배정되고 완주했다. 같은 날 03:43에는 차단 중에 main 승격 게이트가 실제로 통과했다
앱 레포는 축을 갈랐다. dev와 feature는 자체 호스팅(라벨 app-mac-arm64), main은 GitHub 호스팅에 남겼다. dev가 앱 소비의 82%였으므로 그것만 옮겨도 1,408분이 256분이 된다(1,408은 차단 직후 문서값이다. 리셋 뒤 재검산은 1,405분으로 3분 차다). 깨끗한 체크아웃이 실제로 필요한 자리는 승격이고, 거기는 남긴다는 판단이었다. 왜 승격에 그게 필요한지는 뒤에서 따로 적는다
앱 쪽에는 서버에 없는 함정이 하나 더 있었다. “Linux amd64″가 기계가 아니라 컨테이너라는 것이다. 러너 프로세스는 맥(arm64)에서 돌고 테스트는 그 안의 --platform linux/amd64 컨테이너에서 돈다. runs-on만 바꾸고 실행 명령을 그대로 두면 CI가 맥에서 돌아 로컬의 복제가 된다. 이 구성의 근거인 OS 의존 실패 16건을 그날부터 못 잡는다. runs-on과 실행 명령을 같이 바꿔야 했다
컨테이너 냉실행은 1회 7분 07초로 쟀다. 설정 1분 58초, 정적 분석 25초, 테스트 1,413건 4분 06초다. 예측했던 12분이 반증됐다. 그래서 “SDK를 이미지에 캐시할까”라는 트레이드오프가 통째로 사라졌다. 냉실행이 캐시 예측과 같은 값이라 맞바꿀 것이 없었다
러너를 붙이고 나서 넘어진 건 분이 아니었다
전부 코드는 한 줄도 안 바꿨는데 러너를 바꿔서 뜻이 달라진 계급이다. 다섯 개를 만났다
첫 초록이 38초였다. 사본이었다
러너를 붙이고 첫 디스패치를 쳤다. 38초에 BUILD SUCCESSFUL이 나왔다
10개 모듈 전체 빌드에 Testcontainers까지 도는 게 38초일 수 없다. 로그를 열었더니 108개 태스크가 FROM-CACHE고 test는 전 모듈 캐시였다
원인은 러너가 내 사용자 계정으로 돌아서 GRADLE_USER_HOME이 개발자의 ~/.gradle이었다는 것이다. 몇 분 전 로컬 pre-push 게이트가 채운 캐시를 CI가 그대로 읽었다
캐시 히트 자체는 옳다. 잘못은 그것을 독립 검증으로 읽는 것이다. 그러면 CI 초록이 “로컬에서 이미 돌았다”의 사본이 된다. $HOME/.gradle-ci로 갈랐다
일반형으로 적으면 이렇다. 러너를 개발자 계정으로 돌리면 “환경 독립 안전망”이 조용히 “같은 환경의 재확인”이 된다. 공유되는 상태를 하나씩 짚어 갈라야 하고, 무엇이 공유되는지는 도구마다 다르며 도구가 목록을 알려 주지 않는다
이 일반형을 앱 레포에 대 보니 전례가 있었다. build/unit_test_assets가 남으면 그림이 빠진 골든이 통과 상태로 나온다. 브랜드 심볼 둘이 실제로 그랬다. 서버는 빌드 캐시, 앱은 에셋이다. 공유 상태의 종류는 도구가 정하고 증상은 둘 다 “초록인데 검증이 아니다”다
캐시를 가르자 냉빌드가 메모리로 죽었다
캐시 홈을 분리하고 다시 돌렸다. 3분 32초에 죽었다
Gradle build daemon has been stopped: since the JVM garbage collector is thrashing
원인이 둘이었다. 레포 gradle.properties가 kotlin.daemon.jvmargs만 정하고 org.gradle.jvmargs는 안 정해서 빌드 JVM이 기본 힙으로 돌았다. 그리고 org.gradle.parallel=true가 워커를 코어 수만큼 띄우는데, 10코어 맥에서는 2코어 러너의 5배 동시 수요가 된다. 그 맥은 Docker Desktop과 16GB를 나눠 쓴다
ubuntu에서 왜 안 터졌는지는 재지 않았다. 가장 그럴듯한 설명은 setup-gradle이 캐시를 복원해서 진짜 냉빌드가 돈 적이 없다는 것이다. 그렇다면 맥으로 옮겨서 생긴 회귀가 아니라 처음 냉빌드를 돌려서 드러난 잠재 결함이 된다. 다만 ubuntu에서 냉빌드를 돌려 확인한 건 아니다. 이 해석 위에서 레포 설정을 손대지 않았다
처방은 워크플로에만 넣었다
env: GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx4g # 실행: ./gradlew build --max-workers=4
레포 gradle.properties는 안 건드렸다. 로컬과 ubuntu까지 바꾸는 건 별건이다. 대신 조건이 나타나는 자리인 parallel=true 바로 위에 이유와 올릴 신호를 주석으로 붙였다. 대기 목록이 아니라 그 자리를 여는 회차가 반드시 보는 곳이기 때문이다
네 실행을 나란히 놓으면 각 조치가 무엇을 바꿨는지 보인다
| run | 결과 | 시간 | 무엇이 돌았나 |
|---|---|---|---|
| 1 | success | 38초 | test 전 모듈 FROM-CACHE. 초록이지만 검증이 아니다 |
| 2 | failure | 3분 32초 | 캐시 격리 후 냉빌드 → GC 스래싱 |
| 3 | success | 6분 17초 | 힙·워커 고정 → 1,891건 실제 실행. 냉빌드 상한 |
| 4 | success | 45초 | 같은 트리 재실행 → 정상 상태 하한. CI 자기 캐시 |
두 번째 줄이 없으면 첫째와 셋째를 구분할 수 없었다. 실패가 “캐시 격리가 먹었다”의 증거였다
그리고 첫째와 넷째가 둘 다 40초대인데 뜻이 정반대다. 시간만 보면 구분되지 않는다. 캐시의 출처를 봐야 한다
자체 호스팅은 워크스페이스를 남긴다. 시크릿이 잔류했다
provision-auth-env.yml을 보다가 멈췄다. 이 워크플로에는 actions/checkout이 없어서 아무것도 지우지 않는데, key.pem(EC2 사설키)과 fragment.env(카카오·네이버 시크릿 평문)를 워크스페이스에 쓴다
러너 워크스페이스를 열어 보니 11:57과 12:53 산출물이 둘 다 남아 있었다. GitHub 호스팅에서는 VM이 파기돼서 무해했던 줄이다. if: always() 정리 스텝을 넣었다
이건 첫 번째 함정의 거울이다. 첫 번째는 휘발이라고 가정한 것이 영속이어서 검증을 잃었고, 이건 파기된다고 가정한 것이 남아서 시크릿이 잔류한다. 둘 다 코드는 안 바뀌었고 러너가 바뀌었다
범위를 단정하지 않고 셌다. actions/checkout의 기본값 clean: true가 git clean -ffdx를 돌린다는 것을 확인하면서, 잔류 위험의 축이 “자체 호스팅이라서”가 아니라 “checkout이 없어서”로 바뀌었다
| 워크플로 | checkout | secrets. 사용 |
|---|---|---|
verify.yml | 있음 | 없음 |
deploy.yml | 있음 | 없음 (OIDC) |
provision-auth-env.yml | 없음 | 있음 |
ci.yml | 해당 없음 (호출자) | 없음 |
둘이 겹치는 게 정확히 하나였고 그게 고친 파일이다
이걸 세면서 그렙에 속았다. grep -l "actions/checkout"이 provision-auth-env.yml을 물어왔다. 방금 넣은 주석(“actions/checkout이 없어…”)이 그 문자열을 담고 있어서다. X가 없다고 적은 주석이 X를 찾는 검색에 걸린다. YAML로 파싱해서 uses: 키를 읽어야 위 표가 나왔다
앱 쪽 거울은 반대 방향이었다. 누출이 아니라 파괴다. 앱 클론에는 git이 추적하지 않는 자격증명이 있다. google-services.json과 릴리즈 서명용 key.properties가 그것이고, 러너를 그 디렉터리에 겨누면 git clean -ffdx가 복구 안 되는 키를 지운다. 되살릴 방법이 없다. 그래서 앱 러너 착수 전 확인 항목에 “_work가 개발 클론과 겹치지 않음”을 넣었다. 서버 레포에는 그 대상이 0개다
두 러너가 같은 맥에서 큐를 타지 않고 동시에 돈다
개인 계정에는 러너 그룹이 없다. 레포마다 러너를 따로 등록해야 하고, 그러면 잡이 큐를 타지 않고 병렬로 붙는다. 그 맥이 실기기 확인을 하는 기계라는 게 진짜 비용이다
겹침을 실측했다. 앱 dev 게이트와 서버 냉게이트(./gradlew test --rerun-tasks)가 겹친 창이다
| 구간 | 혼자 | 겹침 | 배수 |
|---|---|---|---|
| 컨테이너 설정 (I/O 지배) | 1:58 | 3:10 | 1.6× |
| 테스트 1,413건 (CPU 지배) | 4:06 | 18:01 | 4.4× |
| 잡 전체 | 7:07 | 22:32 | 3.2× |
배수가 구간마다 다른 게 축을 가른다. I/O가 구속이었다면 설정 구간이 같이 늘었어야 한다
그 창에서 서버 게이트는 깨졌다. ChatActivityIntegrationTest의 ContainerLaunchException이다. 먼저 깨지는 건 부하가 큰 쪽이 아니라 하드 타임아웃을 가진 쪽이다. 앱 flutter test에는 테스트별 데드라인이 없어서, 같은 경합이 한쪽에는 실패로 한쪽에는 지연으로 나타난다
메모리는 이 실패의 축이 아니었다. 실패한 그 초의 Docker VM 여유가 55%였다. 다만 같은 창의 피크는 여유 18%라서 “메모리는 구속이 아니다”는 취소했다. 결론(VM 할당을 안 올린다)은 살지만 근거가 갈렸다
한 축만 재는 도구는 그 축이 틀렸을 때 침묵한다. “MiB가 모자랐나”를 물었더니 양쪽 표본기가 다 MiB만 쟀다. 축이 틀렸다는 걸 알려 준 건 도구가 아니라 여유가 넉넉한 채로 깨진 모순이었다
처방은 상시 제한이 아니었다. ci.yml이 잡 시작과 끝에 docker ps를 찍게 해서 사후에 보이도록 했다. 그게 없으면 다음 사람은 테스트가 느려졌다고 오진한다. 첫 회차에 실제로 부하가 잡 도중에 붙었기 때문에, 시작 스냅샷만 있었으면 그 오진을 오히려 뒷받침했을 것이다. 두 스텝은 한 쌍이다
러너가 굶으면 “연락 두절”로 죽는다
8월 31일에 앱 게이트 스텝이 52분 매달렸다. 평소 8분이다
| 뒤 푸시가 죽인 것 | 러너 연락 두절 | |
|---|---|---|
| run 결론 | cancelled | failure |
| 그보다 새 run | 있다 | 없다. 브랜치 팁이 그 SHA다 |
| 스텝 모양 | 잡 전체가 끊긴다 | 게이트 스텝만 cancelled, 앞뒤는 success |
| 스텝 지속 | 짧다 | 평소의 몇 배 (8분 → 52분) |
| 주석 | 없다 | lost communication with the server … starves it for CPU/Memory |
화면에서는 같은 낱말이다. 둘째는 빨강이 맞는데, 게이트가 안 돌았기 때문이다
원인을 굶주림으로 단정하진 않았다. 네트워크 끊김도 같은 주석을 낸다. 그쪽을 가리킨 근거는 주석 자체가 아니라 52분 매달림과 그 창의 load average 17~18.7(10코어)이었다
그래서 규칙이 하나 생겼다. push 직후 그 창에서 무거운 걸 안 돌린다. 부하가 빠진 걸 보고 재실행한다
9일을 기다리기로 했다. 안 한 것이 셋이다
리셋까지 9일이었다. 앱 main은 GitHub 호스팅에 남겨 뒀으니 승격 CI가 그동안 안 돈다. 선택지 셋을 검토하고 셋 다 “안 한다”로 닫았다
첫 번째, 지출 한도를 $0에서 약 $5로 올리는 안이다. 9일치 초과 종량을 Linux 2코어 $0.006/분으로 견적하면 그 정도였다. 안 한 이유는 그 돈으로 사는 게 “새 VM과 깨끗한 체크아웃”의 자동화뿐이고, 9일 뒤에 무료로 돌아온다는 것이다. 깨끗한 체크아웃은 git clone --depth 1로 손 검증해서 대신했다. 그리고 지출 한도는 계정 billing 설정이라 워크플로에서 손댈 수 있는 값이 아니다
두 번째, 레포를 공개로 돌리는 안이다. 공개하면 GitHub 호스팅 러너가 무제한 무료가 되니 영구히 해결된다. 셋 중 가장 큰 이득이다. 그런데 대가가 비대칭이다. 되돌릴 수 없고(포크와 색인), docs/가 계약 협의 내용과 실측값과 기기 사정을 담고 있어서 한 번에 훑어 판정할 분량이 아니다. 무엇을 세상에 내보내는가는 성질상 내가 결정할 일이고, 급해서 내릴 결정이 아니다
세 번째, 로컬 push 훅으로 리눅스 컨테이너 테스트를 강제하는 안이다. 8월 CI 실패 17건을 성격별로 가르면 OS 의존 16, 명령 의존 1, 사람이 안 돌림 0이다. 맥에서 명령을 강제하는 훅이 막을 수 있었던 게 0건이다. 값이 있는 게이트는 리눅스 컨테이너이고, 그걸 비동기로 돌리는 것이 러너다
셋째 판정은 근거 크기를 정확히 적어야 한다. 0/17은 CI가 잡은 것만 센 수여서, 로컬에서 내가 잡고 고친 건 애초에 표에 안 올라온다. 그래서 이 수는 “훅이 불필요하다”가 아니라 “관측된 누출이 없다”까지만 말한다. 뒤집힐 조건도 같이 적었다. “사람이 안 돌림” 갈래가 1건이라도 생기면 다시 본다. 그때 넣는 훅은 막은 횟수를 기록해야 한다. 같은 시기 다른 레포에서 자기 훅의 값을 세려다 분자가 없다는 걸 발견했다. 기록 없는 게이트는 사후에 값을 못 세고 소급도 안 된다
받아들인 대가는 배지 빨강 20개였다
9일 동안 무엇이 멈췄고 무엇이 안 멈췄는지 실측으로 갈랐다
| 앱 | 서버(api) | |
|---|---|---|
| 차단된 job (failure + 스텝 0 + 3~6초) | 27건. dev 7 + main 20 | 0건 |
| 차단 구간 | 08-23T02:20:57Z → 08-29T06:26:36Z | 없음 |
| 그 창에서 자체 호스팅으로 실제로 돈 job | 58건 (성공 52 · 실패 3 · 취소 3) | 29건 (성공 28 · 실패 1) |
| main 승격 게이트 | 20회 미검증 | 차단 중에도 통과 |
서버 쪽 실패 1건은 러너를 세우던 그 냉빌드 GC 사망이다. 차단과는 무관하다
같은 계정, 같은 9일인데 결과가 이렇게 갈린 이유는 하나다. 서버는 러너로 옮겼고 앱은 main을 호스팅에 남겼다
앱이 main을 남긴 건 실수가 아니라 “깨끗한 체크아웃은 승격에 있어야 한다”는 판단의 대가다. 그 대가가 이 9일에 청구됐다
한 가지 더 봤다. 서버 레포의 마지막 GitHub 호스팅 잡은 08-23 01:45:57에 시작해 01:47:55에 성공으로 끝났고, 02:57에 러너로 첫 디스패치를 쳤다. 그 사이 02:20에 앱이 처음 차단됐다. 서버는 차단을 한 번도 안 맞은 게 아니라, 맞기 전에 옮겨탄 것이다. 마지막 호스팅 잡이 끝난 01:47:55에서 차단까지 33분이었다
배지 대가는 이렇다. 잡이 실패한 게 아니라 시작을 못 한 것인데, 밖에서 배지만 보는 사람에게는 실패로 보인다. 이 대가를 받을 수 있었던 이유는 저장소가 private이라 지금 그 배지를 보는 제3자가 없다는 것이다. 그래서 공개 전환 판정과 묶여 있다. 공개하면 이 대가가 커진다
그리고 아무도 안 고친 주석 하나가 거짓이 됐다. .githooks/pre-push에 “무거운 test는 CI에 맡긴다”고 적어 뒀는데, CI가 안 도는 구간이 생겼다. 레포 변경은 0건이다. 변경이 레포 밖에서 왔다
위임을 적을 때는 위임처가 사라지는 조건을 같이 적어야 한다. “CI가 도는 동안에는”이라는 조건절이다. 같은 처방으로 앱 레포에서도 거짓 문장 하나를 찾았다. 골든이 “CI에서 실제로 검증된다”고 적혀 있었는데, 차단 중에는 그 골든을 검사하는 곳이 아무 데도 없었다
9월 1일 리셋, 같은 판별값으로 다시 쟀다
01:29:12Z에 앱 main 승격 CI가 돌았다. 차단 때와 같은 쌍으로 비교했다
| 08-23 ~ 08-29 (20회) | 09-01 | |
|---|---|---|
| 잡 결론 | failure | success |
| 스텝 수 | 0 | 9 |
| 러너 이름 | 없음 | GitHub Actions 1000001027 |
| 실행 시간 | 3~6초 | 10분 00초 |
여기서 못 가른 게 하나 있다. 리셋이 온 것인지 누군가 한도를 올린 것인지 구분할 방법이 없다. 둘 다 같은 초록을 낸다. Billing 화면의 잔량은 API로 조회되지 않고, 측정으로 얻을 수 있는 건 잔량이 아니라 소비다. 원인은 미확인으로 남겨 뒀다. 관측은 위 표로 닫히고, 원인 구분은 열려 있다
같은 날 서버 레포의 main 승격도 돌았다. 자체 호스팅으로 37초, 청구 0분이다. 리셋과 무관하게 돌아간다
| 레포 | 09-01 승격 | 러너 | 청구 |
|---|---|---|---|
| 서버(api) | main 승격 (37초) | 자체 호스팅 | 0분 |
| 앱 | main 승격 (9분 55초) | ubuntu-latest | 10분 |
| 합계 | 10 / 2,000분 |
리셋 첫날 계정 소비가 10분이다. 러너로 옮긴 쪽은 0분이고, 청구되는 건 앱 main 하나뿐이다.
최종 결과
| 7월 | 8월 | 지금 | |
|---|---|---|---|
| 서버(api) | 905분 | 594분 | 0분 |
| 앱 | 270분 | 1,405분 | 약 256분 |
| 그 밖 | 614분 | 2분 | 약 0 |
| 계정 합계 | 1,789분 | 2,001분 (100%) | 약 256분 (13%) |
여유가 1,744분이다. GitHub 호스팅을 쓰는 건 앱 main뿐이고 승격 1회가 10분이다. 8월 앱 main 43 job 기준으로 256분/월이 나온다
두 가지는 갈라 읽어야 한다
서버 7월이 표마다 905와 918로 다르게 나온다. 905는 job 단위 올림이고 918은 run 단위 올림이다. 틀린 게 아니라 축이 다르다. 계정 합계와 대조하는 표에서는 905를 쓴다
그리고 서버 쪽 검증 절감은 합계가 보여 주는 것보다 크다. CI만 보면 905분에서 400분으로 56% 줄었는데, 그 사이 deploy.yml을 도입해서 194분을 새로 얹었다. 배포를 Actions 버튼으로 위임하면서 편의를 얻고 분을 새로 쓴 것이다. 합계가 594가 되면서 효과가 작아 보이는 자리다
다시 넘치지 않기 위해 기각 근거를 같이 적었다
여기 있는 걸 다시 제안하려면 새 측정을 들고 와야 한다. 이유는 “전에 안 됐다”가 아니라 무엇을 재서 안 된다고 했나가 적혀 있기 때문이다
| 제안 | 왜 안 되나 |
|---|---|
| 실행 시간 단축·테스트 샤딩 | 샤딩은 벽시계만 줄고 청구 분은 그대로다. 오히려 설치 오버헤드가 는다. 앱 CI가 느린 건 2코어 러너 때문이고 스위트 탓이 아니다. 같은 스위트가 로컬 맥에서 72초다 |
| dev push CI 되살리기 | “분이 공짜가 됐으니”는 죽은 이유를 보고 손을 움직이는 것이다. 살아 있는 이유는 신호 값(136:1)과 정확성이다 |
paths-ignore를 할당량 때문으로 읽고 되돌리기 | 분은 이미 공짜다. 남은 이유는 마크다운이 빌드 입력이 될 경로가 0건이라는 실측이다 |
| macOS GitHub 호스팅 러너 | ×10 차감. 7월에 실제로 47분을 470분으로 청구받았다. 자체 호스팅 맥에는 배수가 없다 |
| 푸시 횟수 줄이기만으로 해결 | 유효하지만 부족하다. 소비의 46%가 집중 작업일에 몰려 있다 |
| Pro 플랜 ($4/월, 3,000분) | 22일에 2,000을 쓰는 속도면 9일 더 버는 것뿐이다 |
gh run rerun 남발 | 재실행도 분을 쓴다. 여전히 호스팅을 쓰는 앱 main에는 그대로 해당한다 |
원복 트리거도 같이 적었다. 적어 두지 않으면 판정이 낡을 방법이 없다
| 결정 | 되돌리거나 다시 볼 조건 |
|---|---|
| dev push CI 제거 | main 승격 테스트 실패가 월 2건 이상 / 협업자가 늘어 로컬 훅 설치를 통제 못 할 때 / dev 서버가 도입돼 dev가 실제 배포 대상이 될 때 |
paths-ignore | 마크다운이나 docs/를 읽는 빌드·테스트 단계가 생길 때. 특히 fileTree(나 확장자 필터 없는 walk가 생기는 날 |
| 자체 호스팅 러너 | GitHub이 자체 호스팅 과금을 다시 예고할 때 / 협업자가 생길 때 |
| Docker VM 할당 안 올림 | ContainerLaunchException이 다시 나거나 잡 소요가 +20%를 넘을 때. MiB 문턱으로는 걸지 않는다. 여유 55%인 채로 깨진 전례가 있다 |
| 로컬 push 훅 안 넣음 | “사람이 안 돌림” 갈래가 1건이라도 생기면 |
| 레포 공개 전환 안 함 | 공모전 제출에 저장소 공개가 요구되면 |
여섯 줄 중 둘이 「협업자가 생길 때」를 문턱으로 쓴다. 혼자라는 조건이 깨지면 그 둘을 같이 다시 봐야 한다
이 방법이 통하지 않는 조건
자체 호스팅 러너는 ubuntu-latest의 대체가 아니다. 축이 둘 다르다
깨끗한 체크아웃을 잃는다. ubuntu-latest는 매번 새 VM이라 그 성질을 공짜로 가졌다. 서버 CI 200건 중 실패 1건이 정확히 그 계급이었다. 7월 22일 패키지 루트 리네임이고, 로컬 게이트가 원리적으로 못 잡는다
병렬도가 다르다. --max-workers=4는 2코어 러너의 기본값 2와 다르다. 동시성에 민감한 흔들림이 두 환경에서 다르게 나타난다
그래서 ci.yml의 workflow_dispatch에 runner=ubuntu-latest 선택지를 남겼다. 패키지 구조나 의존성, 빌드 설정을 건드리는 회차는 그쪽으로 한 번 돌린다. 이게 성립하려면 스텝이 러너에 의존하지 않아야 해서 setup-java와 setup-gradle을 지우지 않았다
그리고 러너의 진짜 대가는 분이 아니라 맥 점유 시간이다. GitHub 호스팅은 남의 기계를 쓰지만 러너는 내 맥을 쓴다
| 8월 자체 호스팅 벽시계 | 1회 | |
|---|---|---|
| 서버(api) | 98분 | 하한 45초 ~ 상한 6분 17초 |
| 앱 | 795분 (13.3시간) | 7:07 (혼자) / 22:32 (전 구간 겹침) |
소요는 하나의 값이 아니라 분포다. “러너 잡은 N분”으로 적으면 어느 수를 골라도 틀린다. 부딪히면 지렛대는 항상 앱 쪽이다. 795 대 98이다
맥이 깨어 있어야 하고 Docker Desktop이 떠 있어야 한다는 조건도 새로 생겼다. 급할 때 폴백은 Actions를 안 쓰는 손 배포 스크립트다. 같은 파이프라인(빌드 → S3 → SSM)을 로컬에서 돌리고 Actions 분은 0이다. 이미 커밋해 뒀다
협업자가 생기면 이 구성은 다시 봐야 한다. 워크플로 코드를 내 개인 맥에서 실행하는 구조이기 때문이다. 지금은 혼자라서 성립한다
정리하면
이 작업에서 내 판단이 뒤집힌 자리가 넷이다
CI 분이 새는 원인을 푸시 습관으로 봤다. 실제로는 46%가 집중 작업일에 몰려 있어서 습관으로는 절반도 못 줄인다. 문서 전용 커밋을 1.5%로 봤다. 실제로는 41%였고, 틀린 원인은 한글 폴더명과 core.quotepath였다. 서버에서 통한 처방을 앱에 옮기면 된다고 봤다. 실패 성격을 재 보니 반대 처방이 나왔다. 마지막은 컨테이너 냉실행이다. 12분으로 봤다. 7분 07초였고, 그래서 캐시 전략 고민이 통째로 사라졌다
공통점이 있다. 틀린 건 재기 전에 정한 것들이다
한 줄로 요약하면, 무료 분이 터졌을 때 먼저 볼 것은 실행 시간이 아니라 청구 단위다. 그리고 나는 이 순서로 본다
/actions/runs/{id}/timing은 쓰지 않는다. /jobs에서 started_at과 completed_at을 받아 job 단위로 직접 센다. 0이 나오면 “안 썼다”가 아니라 “패턴이 빗나갔다”를 먼저 의심한다
절약안을 만들기 전에 과금 밖으로 나가는 경로가 있는지 본다. 자체 호스팅 러너는 절약이 아니라 청구 대상에서 빠지는 것이고, 차단 중에도 돈다
같은 수를 다른 레포에 옮기지 않는다. “dev CI 572분”이 한쪽에서는 제거였고 한쪽에서는 이전이었다. 값이 아니라 그 값이 무엇을 잡고 있었나가 결정을 정한다
결정을 적을 때 되돌릴 조건을 같이 적는다. 이유가 죽어도 규칙이 남으면, 다음 사람은 죽은 이유를 보고 규칙을 되살린다. 규칙은 가만히 앉아 있는데 죽은 이유는 손을 움직이게 한다
용어 정리
청구와 할당량
Actions 무료 분 — GitHub Free 계정에 월 2,000분이 주어진다. 레포별이 아니라 계정 합산이고 비공개 레포에만 적용된다. 공개 레포에서 GitHub 호스팅 러너를 쓰면 무제한 무료다
OS 배수 — 청구 시 ubuntu는 실행 시간의 1배, windows는 2배, macOS는 10배로 계산된다. macOS에서 47분을 쓰면 470분이 차감된다
지출 한도(spending limit) — 무료 분을 넘겼을 때 초과분을 유료로 계속 쓸지 정하는 값이다. $0이면 과금 대신 차단이다. 차단은 잡이 실패하는 게 아니라 시작을 못 하는 것이다
러너
GitHub 호스팅 러너 — GitHub이 잡마다 새 VM을 띄워 주는 실행 환경이다. runs-on: ubuntu-latest가 이것이다. 매번 새 VM이므로 깨끗한 체크아웃이 공짜로 보장된다
자체 호스팅 러너(self-hosted runner) — 내 기계에 러너 에이전트를 설치해 잡을 받는 방식이다. runs-on: self-hosted로 지정한다. 과금 체계 밖이라 무료지만 워크스페이스와 캐시가 실행 사이에 남는다
러너 라벨 — 러너를 구분하는 태그다. 레포가 여러 개면 runs-on: [self-hosted, app-mac-arm64]처럼 라벨로 특정 러너를 지정한다. 개인 계정에는 러너 그룹이 없어서 레포마다 따로 등록해야 한다
워크플로 문법
paths-ignore — 변경된 파일이 전부 이 패턴에 걸릴 때만 워크플로를 건너뛴다. 코드와 문서가 섞인 푸시는 정상 실행된다
workflow_dispatch — 사람이 버튼으로 실행하는 트리거다. 입력값을 정의할 수 있어서 러너를 골라 돌리는 데 썼다
workflow_call — 다른 워크플로가 호출할 수 있게 만드는 트리거다. 검증 로직을 verify.yml 하나에 두고 ci.yml이 호출하는 구조로 썼다
if: always() — 앞 스텝이 실패하거나 취소돼도 이 스텝을 실행한다. 시크릿 정리처럼 반드시 돌아야 하는 스텝에 붙인다
빌드와 테스트
GRADLE_USER_HOME — Gradle이 의존성과 빌드 캐시를 두는 디렉터리다. 기본값이 ~/.gradle이라서, 러너를 개발자 계정으로 돌리면 로컬 캐시를 그대로 읽는다
FROM-CACHE — Gradle이 태스크를 실행하지 않고 캐시된 결과를 재사용했다는 표시다. 테스트 태스크가 이 상태면 테스트가 돌지 않았다
GC 스래싱 — JVM이 힙이 부족해서 가비지 컬렉션만 반복하며 진척을 못 내는 상태다. Gradle 데몬은 이걸 감지하면 스스로 멈춘다
Testcontainers — 테스트 실행 중에 DB나 브로커를 Docker 컨테이너로 띄워 주는 라이브러리다. ContainerLaunchException은 그 컨테이너가 제 시간에 못 떴다는 뜻이다
골든 테스트 — 렌더링 결과 이미지를 기준 이미지와 픽셀 비교하는 테스트다. 폰트 메트릭과 래스터라이즈 방식에 결과가 달라져서 OS가 바뀌면 깨진다