CI가 초록인데 라이브가 깨졌다: 배포 검증을 어디서 끝낼 것인가
- 테스트 189개, config 검증, 헬스체크, 필수값 가드가 전부 초록인 채로 법적 고지가 깨져서 배포됐다. 넷 다 정상 동작했다 — 각자 다른 것을 증명하고 있었을 뿐이다.
- 설정값이 여러 파일을 건널 때 중간이 끊겨도 아무것도 실패하지 않는다. 빌드는 성공하고 컨테이너는 뜬다. 이 경계에는 타입 검사에 해당하는 것이 없다.
- 검사를 만들어 두는 것으로 끝나지 않는다. 실행되지 않는 검사는 없는 검사보다 나쁘다 — 있다고 믿는 만큼 확인을 덜 하기 때문이다.
- 그래서 배포 마지막에 라이브 페이지를 직접 긁게 하고, 검증이 실패하면 자동으로 되돌리고, 판단이 필요한 변경 앞에서는 아예 배포하지 않게 했다.
1.초록불은 각자 무엇을 증명하나
배포 파이프라인이 초록이면 안심하게 된다. 그런데 초록불 하나하나는 아주 좁은 것만 증명한다. 그 좁은 범위를 정확히 알아 두지 않으면, 초록불을 모아 놓고 "서비스가 옳다"는 결론으로 건너뛰게 된다. 아무리 모아도 그 결론은 나오지 않는데도 그렇다.
이 프로젝트에서 실제로 그렇게 됐다. 국외 서버에서 업로드를 처리하므로 개인정보처리방침에 이전받는 자의 연락처를 적어야 했고, 호스팅 업체가 정해지기 전이라 값을 환경변수로 빼 뒀다. 배포하고 라이브 페이지를 열었더니 이랬다.
이전받는 자: netcup GmbH ([APP_HOSTING_CONTACT unset])
폴백 문자열이 그대로 나갔다. 이 폴백은 일부러 눈에 띄게 만들었고, 설계대로 작동했다. 다만 그걸 발견했을 때는 이미 배포된 뒤였다. 직전 신호를 다시 보면 이렇다.
| 초록불 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---|---|
| 단위·통합 테스트 189개 | 문장과 로직이 맞다 | 문장에 들어갈 값이 오는지 |
docker compose config | 설정 파일이 병합된다 | 병합된 값이 도착하는지 |
| 컨테이너 healthcheck | 프로세스가 살아 응답한다 | 응답 내용이 옳은지 |
필수값 가드(${X:?}) | 배포자가 값을 설정했다 | 그 값이 소비되는 지점까지 가는지 |
오른쪽 열이 전부 비슷하게 읽힌다. 넷 중 어느 것도 "값이 실제로 도착했다"를 보지 않는다. 마지막 행이 특히 헷갈리는데, 필수값 가드는 값이 없으면 기동을 거부하므로 안전장치처럼 느껴진다. 그런데 그건 입구를 지키는 것이고, 실제로 깨진 곳은 중간이었다.
2.값이 여러 파일을 건널 때 생기는 일
설정값 하나가 소비되는 지점까지 가려면 대개 서로 다른 형식의 파일을 여러 번 건넌다. 그리고 그 경계마다 계약이 하나씩 있는데, 어느 쪽도 상대를 강제하지 않는다.
가장 흔한 예가 컨테이너 빌드 인자다. 보내는 쪽은 정직하게 넘기고 있었다.
build: args: APP_HOSTING_PROVIDER: "${APP_HOSTING_PROVIDER:-}" APP_HOSTING_CONTACT: "${APP_HOSTING_CONTACT:-}" # 넘어간다
ARG APP_HOSTING_PROVIDER= # ARG APP_HOSTING_CONTACT <- 선언이 없다 ENV APP_HOSTING_PROVIDER=${APP_HOSTING_PROVIDER}
Docker는 선언되지 않은 build arg를 빌드 실패 없이 무시한다. 값은 존재했고,
전달됐고, 이미지에는 없었다. 그리고 ARG와 ENV가
둘 다 필요하다는 것도 같은 종류의 함정이다 — ARG는 빌드 인자를 받기만
하고, 애플리케이션 빌드가 읽는 것은 프로세스 환경변수다. 이어 주는 ENV
줄이 없으면 ARG만 선언해도 도달하지 않는다. 구간이 하나가
아니라 둘이다.
런타임 값에서도 같은 일이 일어난다. 서버 .env에 관리용 토큰을 넣었는데
엔드포인트가 계속 404였던 적이 있다.
$ grep ADMIN_TOKEN /opt/app/.env ADMIN_TOKEN=**** # .env에는 있다 $ docker exec app-web-1 printenv ADMIN_TOKEN $ # 컨테이너 안에는 없다 (compose에 선언 누락)
화살표 하나하나가 계약이고, 보내는 쪽은 받는 쪽이 준비됐는지 모르고, 받는 쪽은 무엇이 올지 모른다. 함수 호출이라면 타입 시스템이 해 줄 일인데 여기서는 파일 형식이 YAML, Dockerfile, 애플리케이션 코드로 제각각이라 공통 검사기가 없다.
그 자리를 메우는 방법은 결국 하나다. 양쪽 파일을 읽어 집합을 비교하는 테스트를 두는 것이다. 형식은 촌스럽지만 하는 일은 타입 검사와 같다.
const declared = new Set([...dockerfile.matchAll(/^ARG (APP_[A-Z_]+)/gm)] .map((m) => m[1])); for (const [, name] of compose.matchAll(/^\s+(APP_[A-Z_]+):/gm)) { assert.ok(declared.has(name), `compose passes ${name}, Dockerfile has no ARG`); }
설정값을 하나 추가할 때 스스로 묻는다. "이 값이 실제로 소비되는 지점까지 몇 개의 파일을 건너야 하는가?" 하나면 그냥 넣으면 된다. 둘 이상이면 중간이 끊겨도 조용할 가능성이 크고, 그 경계에 검사가 필요하다.
3.검사가 있는데도 안 걸리는 경우
검사를 하나 만들어 두면 그때부터 그 영역은 안심하게 된다. 그런데 그 검사가 실제로 돌고 있는지는 별개 질문이고, 대개 아무도 다시 묻지 않는다.
이 프로젝트에서 프로덕션 설정이 제대로 병합되는지 확인하는 Make 타깃이 하나 있었는데, 몇 커밋 전부터 계속 실패 상태였다.
① 프로덕션 설정에 새 필수값 가드 `${X:?}` 를 추가했다
② 이 타깃은 값을 일부러 비워 두고 병합만 시험한다
──▶ 새 가드에 걸려 그날부터 항상 실패
③ 그런데 CI는 기본 설정만 검증하고 있었다
──▶ 아무도 몰랐다
프로덕션 스택이 완전히 병합되는지 확인하는 유일한 타깃이 그 사이 내내 무력했다. 앞 절의 결함보다 이쪽이 더 나쁘다 — 앞의 것은 검사가 없어서 못 잡은 것이고, 이건 검사가 있는데도 못 잡은 것이다.
있다고 믿는 만큼 확인을 덜 하게 되기 때문이다. 로컬에서 손으로 돌리는 검증 명령은 전부 이 위험을 안고 있다 — "필요할 때 돌리면 된다"는 것은 아무도 안 돌린다는 뜻이다. 새 검사를 만들었으면 같은 커밋에서 CI에 넣고, 넣을 자리가 없으면 그 검사가 정말 필요한지부터 다시 본다.
4.배포가 끝났다는 것을 무엇으로 확인하나
배포 명령이 0으로 끝났다. 이제 무엇을 보고 "됐다"고 할 것인가. 앞의 표가 답을 좁혀 준다 — 파이프라인 안쪽 신호는 전부 대리 지표다. 실제 질문은 "지금 사용자가 보는 화면이 맞나"이고, 그건 배포된 사이트를 직접 가져와야 답할 수 있다.
그래서 배포 스크립트 마지막에 이 단계를 넣었다. 헬스체크가 200을 주는지 본 다음, 렌더링된 본문에서 치환되지 않은 자리표시자를 찾는다.
for path in ("/ko/privacy", "/en/privacy", "/ko", "/en"): raw = urllib.request.urlopen(site + path, timeout=30).read().decode() # ① script를 먼저 지운다 — 아래 설명 body = re.sub(r"<script\b.*?</script>", " ", raw, flags=re.S | re.I) # ② 실제로 문단으로 렌더된 텍스트만 본다 text = " ".join(re.sub(r"<[^>]+>", "", p) for p in re.findall(r"<p[^>]*>(.*?)</p>", body, re.S)) bad = re.findall(r"\{[a-zA-Z]+\}|\[[A-Z_]+ unset\]", text) if bad: print(path, bad); sys.exit(1)
①이 없으면 못 쓴다. 처음에 원문 HTML을 그대로 뒤졌더니 멀쩡한 페이지에서
{host} 같은 문자열이 계속 잡혔다. 서버 컴포넌트를 쓰는
프레임워크는 직렬화된 페이로드를 <script> 안에 함께 실어
보내고, 거기에는 치환 전 템플릿 문자열이 남아 있다. 화면에는 안 나오는데 응답 본문에는
있다. 렌더된 요소만 골라 보지 않으면 거짓 경보로 검증이 무력해진다.
이 검사는 배포될 때마다 돈다. 그러니 앞에서 깨져 있던 그 페이지가 지금은 제대로 나오는지도 직접 확인할 수 있다.
Reflatten — 평평한 이미지를 편집 가능한 텍스트로
이미지를 올리면 글자를 찾아 편집 가능한 텍스트로 바꿔 준다. 계정 없이 쓸 수 있고, 업로드는 메모리에서만 처리된다.
5.검증이 실패하면 무엇을 하나
검증을 붙였으면 실패했을 때의 행동도 정해야 한다. 안 정하면 결국 사람이 로그를 보고 손으로 되돌리게 되는데, 그 사이 라이브는 깨진 채로 있다.
git reset --hard --quiet "$PREV" # 이전 커밋으로 roll_web # 같은 무중단 절차로 되돌린다 if verify; then log "롤백 완료" else log "롤백 후에도 검증 실패 — 사람이 확인할 것" fi exit 1
롤백까지 실패하면 더 시도하지 않고 멈춘다. 자동화가 계속 뭔가를 시도하는 것이 가장 나쁜 상태다 — 원인은 그대로인데 로그만 길어지고, 사람이 도착했을 때 시스템이 어느 상태인지 알 수 없게 된다.
6.자동화가 확인할 수 없는 것 앞에서 멈추기
"main에 푸시하면 재배포"는 CD의 기본형이다. 이게 위험해지는 지점이 있는데, 재배포의 의미가 커밋마다 다를 때다.
이 스택에서는 웹 계층 교체는 무해하고, 상태를 들고 있는 계층의 재생성은 사용자 작업을 지운다. 그런데 명령만 보면 구분이 안 된다 — 두 경우 모두 성공하고 컨테이너는 뜬다. 자동화가 판정할 수 없는 차이라면, 판정하게 만들지 말고 멈추게 하는 편이 낫다.
web_only() {
while read -r path; do
case "$path" in
apps/web/*|packages/*|docs/*|*.md|.github/*|scripts/*) ;;
*) return 1 ;; # 하나라도 밖이면 거부
esac
done < <(git diff --name-only "$1")
}
거부 목록이 아니라 허용 목록인 것이 요점이다. 새 디렉터리가 생기면 자동으로 "안전하지 않음"에 들어간다. 반대로 짜면 새로 추가된 위험한 경로가 조용히 통과한다.
거부할 때는 아무것도 건드리지 않은 채 종료한다. 체크아웃도 옮기지 않는다. 그래야 사람이 도착했을 때 서비스가 멀쩡히 돌고 있고, 로그의 파일 목록만 보고 절차를 고르면 된다.
종료 코드를 나눠 두면 CI 화면만 보고도 다음 행동이 갈린다. 그리고 테스트 job과 배포 job을 분리하면 신호가 하나 더 늘어난다 — 테스트가 빨가면 코드가 깨진 것이고, 배포가 빨가면 코드는 멀쩡한데 사람이 필요한 것이다.
7.자동화가 가진 권한도 같은 원리로 좁힌다
CI에서 서버로 들어가려면 SSH 키가 필요한데, 그 키가 셸을 줄 수 있으면 곤란하다. CI 시크릿은 워크플로 파일을 고칠 수 있는 사람이 사실상 다 쓸 수 있기 때문이다.
배포 전용 계정을 만들고 권한을 깎는 방법이 먼저 떠오르는데, 더 좁은 방법이 있다. OpenSSH는 키 하나에 실행할 명령을 못박을 수 있다.
command="/opt/app/scripts/deploy.sh",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAA... ci-deploy
이 키로 접속하면 클라이언트가 무엇을 보내든 저 스크립트만 실행된다. 보낸 명령은
SSH_ORIGINAL_COMMAND에 문자열로 들어갈 뿐이고, 스크립트가 그 값을 읽지
않으니 그대로 버려진다. 확인도 간단하다 — whoami; cat /etc/shadow를
보내도 배포 스크립트의 출력만 돌아온다.
계정 분리는 무엇을 할 수 있는지를 제한하고,
command=는 무엇을 하는지를 하나로 고정한다. 필요한
동작이 정확히 하나라면 후자가 더 좁다. 계정을 만들면 sudo 규칙·그룹·파일 소유권을 다시
설계해야 하고, 그 과정에서 오히려 구멍이 생기기 쉽다.
8.이 방식의 비용과 안 잡히는 것
스크립트가 git으로 체크아웃을 옮기는데 그 체크아웃 안에
스크립트 자신이 들어 있다. bash는 파일 전체를 미리 읽지 않고 오프셋을 기억한 채
이어서 읽으므로, 실행 도중 내용이 바뀌면 엉뚱한 바이트에서 재개될 수 있다. 해법은
본문을 main()으로 감싸는 것이다 — 함수는 통째로 파싱된 뒤 호출된다.
지금까지 안 터진 것은 git이 새로 쓴 뒤 rename하기 때문인데, 그건 git의 구현 세부사항이지 보장이 아니다.
| 비용 | 왜 감수했나 | 언제 다시 볼까 |
|---|---|---|
| 경계 검사가 설정 파일을 정규식으로 판다 | YAML·Dockerfile·코드를 한 번에 볼 파서가 없다. 넣기 전에 선언을 지우고 실제로 실패하는지 확인해 검사가 살아 있음을 한 번은 증명했다 | 인용 스타일이나 들여쓰기 규칙이 바뀔 때 |
| 라이브 검증이 자리표시자만 본다 | 기능 e2e를 붙이면 배포마다 실제 처리가 돌아 요청 한도를 먹고 시간도 늘어난다. 기능 확인은 사람이 별도로 돌린다 | 배포 빈도가 늘어 손으로 못 따라갈 때 |
| 범위 전체를 하나로 판정한다 | 문서 한 줄 때문에 웹 변경까지 멈춘다. 경로별로 쪼개면 스크립트가 판단을 더 하게 되는데, 그게 정확히 피하려던 것이다 | 거짓 정지가 잦아질 때 |
| 알림이 없다. 실패는 CI 화면에만 남는다 | 혼자 개발하고 푸시 직후에 결과를 본다. 알림 경로를 붙이면 그것도 관리 대상이 된다 | 푸시하고 자리를 뜨는 일이 생길 때 |
그리고 근본적인 한계 하나. 이 모든 검사는 "알려진 실패 모양"만 잡는다. 자리표시자 패턴을 찾고, 선언 누락을 찾고, 종료 코드를 나눈다. 처음 보는 방식으로 깨지면 여전히 전부 초록이다. 그래서 결함이 하나 새로 나올 때마다 그 모양을 검사에 추가하는 것이 실제 작업이 된다 — 이 글의 세 결함이 각각 테스트 한 개씩을 남긴 이유다.
9.정리
- 초록불마다 무엇을 증명하는지 적어 본다. 대부분은 "이 명령이 에러 없이 끝났다"이고, "서비스가 옳다"를 보는 것은 하나도 없을 수 있다.
- 설정값이 몇 개의 파일을 건너는지 센다. 둘 이상이면 중간이 끊겨도 조용하다. 양쪽을 읽어 비교하는 검사를 그 경계에 둔다.
- 검사는 만든 커밋에서 CI에 넣는다. 실행되지 않는 검사는 없는 검사보다 나쁘다.
- 배포의 마지막 단계는 라이브를 가져오는 것이다. 서버 컴포넌트를 쓴다면 렌더된 요소만 봐야 거짓 경보를 피한다.
- 실패하면 되돌리고, 되돌려도 실패하면 멈춘다. 계속 시도하는 자동화가 가장 다루기 어렵다.
- 자동화가 판정할 수 없는 차이 앞에서는 배포하지 않는다. 허용 목록으로 쓰고, 멈출 때는 아무것도 건드리지 않은 상태로 멈춘다.
결국 남는 것은 하나다. 배포가 끝났다는 신호는 스스로 정의해야 한다. 기본으로 주어지는 초록불들은 각자 자기 일만 증명하고, 그것들을 다 모아도 "사용자가 보는 화면이 맞다"가 되지 않는다. 그 마지막 한 걸음은 직접 붙여야 한다.
'프로그래밍' 카테고리의 다른 글
| 서버 한 대로 무중단 배포하기: Caddy 헬스체크가 못 막는 502 (0) | 2026.08.24 |
|---|---|
| Docker 메모리 한도를 감으로 정하지 않는 법: 최악 입력과 RSS 기울기 (0) | 2026.08.24 |
| GitHub Actions 러너로 VPS 사양 재는 법: 36% 편차와 측정 함정 (0) | 2026.08.09 |
| 병목은 OCR이 아니었다: 요청의 53%를 쓰던 폰트 추론 2.2배 줄이기 (0) | 2026.08.09 |
| x86에 배포하고 나서야 보인 결함 둘: 추론 런타임과 Docker WORKDIR (0) | 2026.08.09 |
댓글