본문 바로가기
프로그래밍

CI가 초록인데 라이브가 깨졌다: 배포 검증을 어디서 끝낼 것인가

by me_in_sk 2026. 8. 24.
반응형
이미지 편집 엔진

CI가 초록인데 라이브가 깨졌다: 배포 검증을 어디서 끝낼 것인가

각 신호가 실제로 증명하는 것, 그리고 자동화가 멈춰야 하는 지점
핵심 요약
  • 테스트 189개, config 검증, 헬스체크, 필수값 가드가 전부 초록인 채로 법적 고지가 깨져서 배포됐다. 넷 다 정상 동작했다 — 각자 다른 것을 증명하고 있었을 뿐이다.
  • 설정값이 여러 파일을 건널 때 중간이 끊겨도 아무것도 실패하지 않는다. 빌드는 성공하고 컨테이너는 뜬다. 이 경계에는 타입 검사에 해당하는 것이 없다.
  • 검사를 만들어 두는 것으로 끝나지 않는다. 실행되지 않는 검사는 없는 검사보다 나쁘다 — 있다고 믿는 만큼 확인을 덜 하기 때문이다.
  • 그래서 배포 마지막에 라이브 페이지를 직접 긁게 하고, 검증이 실패하면 자동으로 되돌리고, 판단이 필요한 변경 앞에서는 아예 배포하지 않게 했다.

1.초록불은 각자 무엇을 증명하나

배포 파이프라인이 초록이면 안심하게 된다. 그런데 초록불 하나하나는 아주 좁은 것만 증명한다. 그 좁은 범위를 정확히 알아 두지 않으면, 초록불을 모아 놓고 "서비스가 옳다"는 결론으로 건너뛰게 된다. 아무리 모아도 그 결론은 나오지 않는데도 그렇다.

이 프로젝트에서 실제로 그렇게 됐다. 국외 서버에서 업로드를 처리하므로 개인정보처리방침에 이전받는 자의 연락처를 적어야 했고, 호스팅 업체가 정해지기 전이라 값을 환경변수로 빼 뒀다. 배포하고 라이브 페이지를 열었더니 이랬다.

공개된 법적 고지rendered
이전받는 자: netcup GmbH ([APP_HOSTING_CONTACT unset])

폴백 문자열이 그대로 나갔다. 이 폴백은 일부러 눈에 띄게 만들었고, 설계대로 작동했다. 다만 그걸 발견했을 때는 이미 배포된 뒤였다. 직전 신호를 다시 보면 이렇다.

초록불실제로 증명하는 것증명하지 않는 것
단위·통합 테스트 189개문장과 로직이 맞다문장에 들어갈 이 오는지
docker compose config설정 파일이 병합된다병합된 값이 도착하는지
컨테이너 healthcheck프로세스가 살아 응답한다응답 내용이 옳은지
필수값 가드(${X:?})배포자가 값을 설정했다그 값이 소비되는 지점까지 가는지

오른쪽 열이 전부 비슷하게 읽힌다. 넷 중 어느 것도 "값이 실제로 도착했다"를 보지 않는다. 마지막 행이 특히 헷갈리는데, 필수값 가드는 값이 없으면 기동을 거부하므로 안전장치처럼 느껴진다. 그런데 그건 입구를 지키는 것이고, 실제로 깨진 곳은 중간이었다.

2.값이 여러 파일을 건널 때 생기는 일

설정값 하나가 소비되는 지점까지 가려면 대개 서로 다른 형식의 파일을 여러 번 건넌다. 그리고 그 경계마다 계약이 하나씩 있는데, 어느 쪽도 상대를 강제하지 않는다.

가장 흔한 예가 컨테이너 빌드 인자다. 보내는 쪽은 정직하게 넘기고 있었다.

보내는 쪽yaml
build:
  args:
    APP_HOSTING_PROVIDER: "${APP_HOSTING_PROVIDER:-}"
    APP_HOSTING_CONTACT:  "${APP_HOSTING_CONTACT:-}"   # 넘어간다
받는 쪽 — 하나가 없다dockerfile
ARG APP_HOSTING_PROVIDER=
# ARG APP_HOSTING_CONTACT  <- 선언이 없다
ENV APP_HOSTING_PROVIDER=${APP_HOSTING_PROVIDER}

Docker는 선언되지 않은 build arg를 빌드 실패 없이 무시한다. 값은 존재했고, 전달됐고, 이미지에는 없었다. 그리고 ARGENV가 둘 다 필요하다는 것도 같은 종류의 함정이다 — ARG는 빌드 인자를 받기만 하고, 애플리케이션 빌드가 읽는 것은 프로세스 환경변수다. 이어 주는 ENV 줄이 없으면 ARG만 선언해도 도달하지 않는다. 구간이 하나가 아니라 둘이다.

런타임 값에서도 같은 일이 일어난다. 서버 .env에 관리용 토큰을 넣었는데 엔드포인트가 계속 404였던 적이 있다.

같은 모양, 다른 경계bash
$ grep ADMIN_TOKEN /opt/app/.env
ADMIN_TOKEN=****              # .env에는 있다

$ docker exec app-web-1 printenv ADMIN_TOKEN
$                                 # 컨테이너 안에는 없다 (compose에 선언 누락)
값이 출발지에서 소비지까지 가는 길 — 셋 다 중간에서 끊겼고, 아무것도 실패하지 않았다 테두리 있는 상자 = 값이 닿은 곳 · X = 끊긴 경계 · 흐린 상자 = 끝내 닿지 않은 곳 빌드 인자 .env compose build.args Dockerfile ARG·ENV next build 선언이 없어 버려짐 검증 경로 compose :? 가드 Makefile 더미값 CI 실행 목록이 안 따라감 런타임 값 .env compose environment 컨테이너 env process.env 선언이 없어 안 감
보내는 쪽은 받는 쪽이 준비됐는지 모르고, 받는 쪽은 무엇이 올지 모른다. 끊겨도 빌드는 성공하고 컨테이너는 뜬다.

화살표 하나하나가 계약이고, 보내는 쪽은 받는 쪽이 준비됐는지 모르고, 받는 쪽은 무엇이 올지 모른다. 함수 호출이라면 타입 시스템이 해 줄 일인데 여기서는 파일 형식이 YAML, Dockerfile, 애플리케이션 코드로 제각각이라 공통 검사기가 없다.

그 자리를 메우는 방법은 결국 하나다. 양쪽 파일을 읽어 집합을 비교하는 테스트를 두는 것이다. 형식은 촌스럽지만 하는 일은 타입 검사와 같다.

경계 검사 — 넘기는데 안 받으면 실패ts
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 타깃이 하나 있었는데, 몇 커밋 전부터 계속 실패 상태였다.

왜 죽었나text
① 프로덕션 설정에 새 필수값 가드 `${X:?}` 를 추가했다
② 이 타깃은 값을 일부러 비워 두고 병합만 시험한다
   ──▶ 새 가드에 걸려 그날부터 항상 실패
③ 그런데 CI는 기본 설정만 검증하고 있었다
   ──▶ 아무도 몰랐다

프로덕션 스택이 완전히 병합되는지 확인하는 유일한 타깃이 그 사이 내내 무력했다. 앞 절의 결함보다 이쪽이 더 나쁘다 — 앞의 것은 검사가 없어서 못 잡은 것이고, 이건 검사가 있는데도 못 잡은 것이다.

실행되지 않는 검사는 없는 검사보다 나쁘다

있다고 믿는 만큼 확인을 덜 하게 되기 때문이다. 로컬에서 손으로 돌리는 검증 명령은 전부 이 위험을 안고 있다 — "필요할 때 돌리면 된다"는 것은 아무도 안 돌린다는 뜻이다. 새 검사를 만들었으면 같은 커밋에서 CI에 넣고, 넣을 자리가 없으면 그 검사가 정말 필요한지부터 다시 본다.

4.배포가 끝났다는 것을 무엇으로 확인하나

배포 명령이 0으로 끝났다. 이제 무엇을 보고 "됐다"고 할 것인가. 앞의 표가 답을 좁혀 준다 — 파이프라인 안쪽 신호는 전부 대리 지표다. 실제 질문은 "지금 사용자가 보는 화면이 맞나"이고, 그건 배포된 사이트를 직접 가져와야 답할 수 있다.

그래서 배포 스크립트 마지막에 이 단계를 넣었다. 헬스체크가 200을 주는지 본 다음, 렌더링된 본문에서 치환되지 않은 자리표시자를 찾는다.

배포 후 검증python
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.검증이 실패하면 무엇을 하나

검증을 붙였으면 실패했을 때의 행동도 정해야 한다. 안 정하면 결국 사람이 로그를 보고 손으로 되돌리게 되는데, 그 사이 라이브는 깨진 채로 있다.

실패 경로bash
git reset --hard --quiet "$PREV"     # 이전 커밋으로
roll_web                             # 같은 무중단 절차로 되돌린다
if verify; then log "롤백 완료"
else        log "롤백 후에도 검증 실패 — 사람이 확인할 것"
fi
exit 1

롤백까지 실패하면 더 시도하지 않고 멈춘다. 자동화가 계속 뭔가를 시도하는 것이 가장 나쁜 상태다 — 원인은 그대로인데 로그만 길어지고, 사람이 도착했을 때 시스템이 어느 상태인지 알 수 없게 된다.

6.자동화가 확인할 수 없는 것 앞에서 멈추기

"main에 푸시하면 재배포"는 CD의 기본형이다. 이게 위험해지는 지점이 있는데, 재배포의 의미가 커밋마다 다를 때다.

이 스택에서는 웹 계층 교체는 무해하고, 상태를 들고 있는 계층의 재생성은 사용자 작업을 지운다. 그런데 명령만 보면 구분이 안 된다 — 두 경우 모두 성공하고 컨테이너는 뜬다. 자동화가 판정할 수 없는 차이라면, 판정하게 만들지 말고 멈추게 하는 편이 낫다.

허용 목록 — 거부가 기본값bash
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")
}

거부 목록이 아니라 허용 목록인 것이 요점이다. 새 디렉터리가 생기면 자동으로 "안전하지 않음"에 들어간다. 반대로 짜면 새로 추가된 위험한 경로가 조용히 통과한다.

거부할 때는 아무것도 건드리지 않은 채 종료한다. 체크아웃도 옮기지 않는다. 그래야 사람이 도착했을 때 서비스가 멀쩡히 돌고 있고, 로그의 파일 목록만 보고 절차를 고르면 된다.

main 푸시 하나가 도달할 수 있는 세 가지 끝 안전한 경로만 배포하고, 판단이 필요하면 아무것도 건드리지 않은 채 멈춘다 push to main verify 테스트 web 경로만? 롤링 교체 라이브 검증 허용 목록 --no-deps 아니오 종료 코드 2 배포 안 함 · 체크아웃도 그대로 검증 실패 종료 코드 1 이전 커밋으로 롤백됨 통과 종료 코드 0 완료 · 무중단으로 교체됨 verify가 빨가면 코드가 깨진 것이고, deploy가 빨가면 코드는 멀쩡한데 사람이 필요한 것이다.
종료 코드 2로 멈춘 배포는 실패가 아니라, 원래 사람이 하던 판단이 제자리로 돌아온 것이다.

종료 코드를 나눠 두면 CI 화면만 보고도 다음 행동이 갈린다. 그리고 테스트 job과 배포 job을 분리하면 신호가 하나 더 늘어난다 — 테스트가 빨가면 코드가 깨진 것이고, 배포가 빨가면 코드는 멀쩡한데 사람이 필요한 것이다.

7.자동화가 가진 권한도 같은 원리로 좁힌다

CI에서 서버로 들어가려면 SSH 키가 필요한데, 그 키가 셸을 줄 수 있으면 곤란하다. CI 시크릿은 워크플로 파일을 고칠 수 있는 사람이 사실상 다 쓸 수 있기 때문이다.

배포 전용 계정을 만들고 권한을 깎는 방법이 먼저 떠오르는데, 더 좁은 방법이 있다. OpenSSH는 키 하나에 실행할 명령을 못박을 수 있다.

~/.ssh/authorized_keys — 한 줄이다text
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.정리

  1. 초록불마다 무엇을 증명하는지 적어 본다. 대부분은 "이 명령이 에러 없이 끝났다"이고, "서비스가 옳다"를 보는 것은 하나도 없을 수 있다.
  2. 설정값이 몇 개의 파일을 건너는지 센다. 둘 이상이면 중간이 끊겨도 조용하다. 양쪽을 읽어 비교하는 검사를 그 경계에 둔다.
  3. 검사는 만든 커밋에서 CI에 넣는다. 실행되지 않는 검사는 없는 검사보다 나쁘다.
  4. 배포의 마지막 단계는 라이브를 가져오는 것이다. 서버 컴포넌트를 쓴다면 렌더된 요소만 봐야 거짓 경보를 피한다.
  5. 실패하면 되돌리고, 되돌려도 실패하면 멈춘다. 계속 시도하는 자동화가 가장 다루기 어렵다.
  6. 자동화가 판정할 수 없는 차이 앞에서는 배포하지 않는다. 허용 목록으로 쓰고, 멈출 때는 아무것도 건드리지 않은 상태로 멈춘다.

결국 남는 것은 하나다. 배포가 끝났다는 신호는 스스로 정의해야 한다. 기본으로 주어지는 초록불들은 각자 자기 일만 증명하고, 그것들을 다 모아도 "사용자가 보는 화면이 맞다"가 되지 않는다. 그 마지막 한 걸음은 직접 붙여야 한다.

이미지 편집 엔진 · 배포와 검증

반응형

댓글