훅: 헬스체크가 401을 실패로 보고 있었다
배포 스크립트에 헬스체크가 있었다. 앱을 재시작하고 나서 /monitor/health를 찔러 응답이 오면 성공으로 본다.
until "${SSH_CMD[@]}" "$REMOTE_USER@$REMOTE_HOST" \
"curl -fsS 'http://127.0.0.1:$APP_PORT$HEALTH_ENDPOINT' >/dev/null"; do
...
done
curl -f는 4xx/5xx를 실패로 처리한다. 그런데 이 엔드포인트는 Spring Security로 인증이 걸려 있어서 401을 반환한다. 애플리케이션이 완전히 정상 기동한 상태에서 401이 오는 것이다.
여기서 401이 의미하는 바를 정확히 보면: 요청이 애플리케이션에 도달했고, 서블릿 컨테이너가 살아 있고, 필터 체인이 동작하고 있다. 헬스체크가 알고 싶은 것 — “앱이 떴는가” — 은 이미 충족됐다. 인증이 안 됐다는 건 그 다음 얘기다.
200만이 성공이라고 가정한 게 문제였다. 헬스체크는 “이 앱이 요청을 처리할 수 있는가”를 묻는 것이지 “내가 이 리소스에 접근할 권한이 있는가”를 묻는 게 아니다.
성공으로 인정할 코드를 목록으로 바꿨다.
HEALTH_SUCCESS_CODES="${HEALTH_SUCCESS_CODES:-200,401}"
until false; do
HTTP_CODE="$(... "curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:$APP_PORT$HEALTH_ENDPOINT' || true")"
if [[ ",$HEALTH_SUCCESS_CODES," == *",$HTTP_CODE,"* ]]; then
echo "헬스체크 성공: HTTP $HTTP_CODE"
break
fi
echo "헬스체크 대기중: HTTP $HTTP_CODE"
...
done
같이 고친 게 하나 더 있다. 원래는 타임아웃이 나면 “수동 점검 필요”를 찍고 exit 1로 끝났다. 실패한 배포본이 서버에 그대로 올라간 채 사람을 기다린다는 뜻이다. 타임아웃 시 rollback을 즉시 호출하도록 바꿨다. 자동 롤백이 없는 자동 배포는 절반만 자동이다.
그리고 curl 실패 시 코드를 못 읽는 상황에 대비해 || true를 붙였다. set -euo pipefail 아래에서는 이게 없으면 curl 실패가 스크립트 전체를 죽여 롤백 로직에 도달하지 못한다.
더 큰 문제: 스크립트가 두 벌이었다
헬스체크를 고치고 나니 구조가 눈에 들어왔다. deploy.sh가 있고, scripts/deploy/deploy-to-web2.sh라는 266줄짜리가 따로 있었다. 두 파일이 SSH 접속·빌드·전송·재시작을 각자의 방식으로 중복 구현하고 있었다.
이런 상태에서 헬스체크를 고치면 한쪽만 고쳐진다. 실제로 위의 401 수정도 한쪽에만 들어가 있었다.
어떻게 고쳤나: 서브커맨드 오케스트레이터
deploy.sh를 진입점 하나로 만들고, 실제 로직은 파일로 나눴다.
deploy.sh <backend | frontend admin|user|all | all>
│
├── scripts/deploy/_common.sh SSH 실행, 로그 출력
├── scripts/deploy/_backend.sh 빌드 → 전송 → 헬스체크 → 롤백
└── scripts/deploy/_frontend.sh git 사전 검증 → 원격 pull/build/restart
_ 접두사는 “직접 실행하는 파일이 아니라 source되는 라이브러리”라는 표시다. 진입점은 deploy.sh 하나뿐이라는 걸 파일명만 보고 알 수 있다.
분리 기준은 배포 대상이 달라지면 절차가 달라진다는 점이었다. 백엔드는 로컬에서 jar를 빌드해 전송하고, 프론트엔드는 원격에서 git pull 후 빌드한다. 공통은 SSH와 로그뿐이라 _common.sh는 54줄로 작다. 억지로 더 공통화하지 않았다.
집계하면 396줄 삭제, 418줄 추가다. 거의 같은 양이지만 중복 한 벌이 사라지고 파일 경계가 생겼다.
프론트엔드에는 git 사전 검증을 붙였다
FE 배포는 원격에서 git pull 후 빌드하는 방식이다. 이 구조에는 함정이 있다. 로컬에서 아무리 확인해도, 푸시하지 않은 커밋은 서버에 안 간다.
로컬: 수정하고 확인 → "잘 되네" → 배포 실행
원격: git pull → 변경 없음 → 이전 코드 그대로 빌드
결과: 배포는 성공했는데 아무것도 안 바뀜
배포 전에 세 가지를 검사하도록 했다.
| 검사 | 막는 상황 |
|---|---|
| uncommitted 변경 | 커밋 안 한 수정이 반영 안 됨 |
| 브랜치 확인 | 다른 브랜치에서 배포 |
| unpushed 커밋 | 로컬에만 있는 커밋이 반영 안 됨 |
셋 다 “배포는 성공하는데 결과가 틀린” 종류의 실패다. 에러가 나면 알아채지만 이건 조용히 지나간다. 그래서 사전 검증으로 막는 게 값이 크다.
남는 교훈
헬스체크는 무엇을 확인하는지 정확히 정의해야 한다. “200이면 정상”은 가정이지 정의가 아니다. 이 엔드포인트에서 앱이 살아 있다는 증거는 401이었다. 확인하려는 것이 프로세스 생존인지, 요청 처리 가능성인지, 의존 서비스 연결인지에 따라 성공 조건이 달라진다.
자동 배포의 완결 조건은 자동 롤백이다. 실패를 감지하고 사람을 부르는 것과 실패를 감지하고 되돌리는 것 사이에는 장애 시간만큼의 차이가 있다.
중복된 스크립트는 수정을 반쪽으로 만든다. 401 수정이 한쪽에만 들어간 게 그 증거였다. 스크립트가 두 벌이면 버그도 두 벌이고, 고치는 사람은 한 벌만 안다.
조용한 실패를 사전 검증으로 바꾼다. unpushed 커밋으로 배포하는 건 아무 에러도 안 내면서 결과만 틀리다. 이런 종류는 뒤에서 감지하기 어려우므로 앞에서 막는 게 유일하게 싼 방법이다.