홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

먼저 온 배포가 양보하게 만들었습니다 — 직렬화가 지켜주지 않는 15분

정기창·2026년 8월 21일

배포가 서로 겹치지 않도록 락을 걸어둔 것이 3주 전입니다. 겹침은 정말 사라졌습니다. 원장을 열어 세어보니 락을 넣기 전에는 서로 다른 커밋의 배포가 시간상 겹친 쌍이 41건이었고, 넣은 뒤로는 0건입니다. 그런데 사라진 것은 겹침이지 낭비가 아니었습니다.

락이 없앤 것과 없애지 못한 것

여러 세션에서 동시에 작업하다 보면 배포가 겹칩니다. 앞 세션이 아직 빌드 중인데 뒤 세션이 같은 서비스에 배포를 또 트리거하는 상황입니다. 그래서 로컬 포트 하나를 물어 전역 뮤텍스로 쓰는 락을 넣었습니다.

구간 서로 다른 커밋의 배포가 시간상 겹친 쌍
락 도입 전 41건
락 도입 후 0건

숫자만 보면 완결된 이야기처럼 보입니다. 문제는 이 표가 대기를 세지 않는다는 점입니다. 기다리는 세션은 락을 잡기 전까지 원장에 아무 행도 쓰지 않습니다. 겹침은 흔적을 남기지만 대기는 남기지 않으므로, 락을 넣은 순간 비용이 장부에서 사라졌습니다. 사라진 것은 비용이 아니라 비용의 기록이었습니다.

실제로 무슨 일이 벌어지고 있었느냐 하면, 뒤에 온 세션이 앞 체인 15~25분을 꼬박 기다렸다가 자기 체인을 처음부터 다시 도는 것이었습니다. 앱이 여러 개라 체인 한 번이 그만큼 걸립니다.

기다림이 순손실이 되는 조건

여기서 중요한 사실이 하나 있습니다. 쓰고 있는 배포 도구의 API에는 커밋을 고정하는 파라미터가 없습니다. 배포를 트리거하면 그 순간의 origin/main HEAD를 체크아웃해 빌드합니다. 도구에 넘기는 --commit 값은 원장에 적을 라벨일 뿐이고, 실제로 무엇이 빌드되는지는 트리거 시각이 정합니다.

그래서 두 세션이 겹쳤을 때 관계가 이렇게 됩니다. 뒤 세션의 커밋은 앞 세션의 커밋을 이미 포함하고 있습니다. 같은 main 위에서 순서대로 머지된 것이니까요. 그러면 앞 체인이 지금 굽고 있는 것은 두 경우 중 하나입니다.

  • 낡은 코드입니다. 어차피 뒤 세션이 곧 덮어씁니다.
  • 또는 이미 뒤 세션의 커밋까지 담고 있습니다. HEAD를 굽기 때문입니다.

어느 쪽이든 뒤 세션이 15분을 기다릴 이유가 없습니다. 앞 체인이 낡았으면 접는 게 낫고, 이미 새 커밋을 담고 있으면 뒤 세션은 아예 배포할 필요가 없습니다. 락은 순서를 지켜주지만 무엇을 기다리는지는 모릅니다. 정합성이 필요 없는 구간까지 직렬화하면 그것은 안전장치가 아니라 그냥 대기입니다.

증명은 요청하는 쪽이 해야 했습니다

그래서 대기 세션이 락 홀더에게 "내 커밋이 네 커밋을 포함하니 양보해달라"고 알리는 프로토콜을 넣었습니다. 설계에서 가장 오래 붙잡고 있었던 것은 기능이 아니라 누가 판정하느냐였습니다.

직관적으로는 홀더가 검사해야 할 것 같습니다. 양보를 하는 쪽이 홀더이니 홀더가 확인하고 결정하는 게 자연스러워 보입니다. 그런데 해보면 안 됩니다. 홀더는 대기자의 커밋을 아직 fetch하지 않았을 수 있고, 그러면 그 커밋이 자기 커밋의 후손인지 판정할 방법이 구조적으로 없습니다. 모르는 커밋에 대해서는 참도 거짓도 답할 수 없습니다.

반대쪽은 항상 판정할 수 있습니다. 대기자는 자기 커밋을 로컬에 갖고 있고, git은 커밋을 가지면 그 모든 조상도 함께 가집니다. 이 조상 폐쇄성 덕분에 대기자 쪽에서는 다음 한 줄이 언제나 답을 냅니다.

git merge-base --is-ancestor <홀더_커밋> <내_커밋>

그래서 증명 책임을 요청하는 쪽에 뒀습니다. 홀더는 받은 값이 hex 형태인지, 자기 커밋과 다른지 정도의 형태 검사만 합니다. 오가는 것은 네 줄입니다.

홀더  → 대기자   coolify-deployer-lock v1 holder 8d22686d
대기자           (로컬 git 으로 판정) 8d22686d 가 내 커밋의 조상인가 → 예
대기자 → 홀더   supersede 769baca4 nestjs-backend-blog,backend-worker
홀더  → 대기자   ack

돌이켜 생각해보면 이건 권한의 문제가 아니라 정보의 문제였습니다. 결정은 홀더가 하지만 증명은 그 사실을 알 수 있는 쪽이 해야 합니다. 둘을 같은 곳에 두려고 했던 것이 처음의 착각이었습니다.

진행 중인 빌드를 취소해도 되는가

양보가 의미를 가지려면 이미 돌고 있는 빌드를 멈출 수 있어야 합니다. 그런데 운영 중인 서비스의 배포를 도중에 취소하는 일은 겁이 납니다. 취소가 실행 중인 컨테이너까지 건드리면 양보하려다 서비스를 내리는 셈이 됩니다.

문서로는 확신이 안 서서 배포 플랫폼의 소스를 직접 열었습니다. 취소는 빌드 helper 컨테이너만 제거하고 실행 중인 앱 컨테이너는 건드리지 않습니다. 무중단 배포가 컨테이너를 교체하기 직전 단계마다 취소 여부를 확인하기 때문에, 취소된 배포는 교체 지점에 도달하지 못하고 멈춥니다. 라우트가 실제로 운영 인스턴스에 존재하는지는 상태를 바꾸지 않는 조회만으로 미리 확인했습니다.

안전하다는 것을 확인한 뒤에도 취소를 무조건 쏘지는 않게 했습니다. 폴링이 읽어온 실제 체크아웃 커밋이 이미 대기자의 커밋을 담고 있으면, 그 빌드는 낡은 것이 아니므로 취소가 순수한 손해입니다. 그럴 때는 그대로 완주시키고 다음 앱 경계에서 양보합니다.

만들지 않았으면 영영 몰랐을 버그

취소를 붙이면서 상태값을 확인하다 잠복해 있던 버그를 하나 발견했습니다. 취소된 배포의 최종 상태는 cancelled가 아니라 cancelled-by-user였습니다.

종전 폴링 코드는 이 값을 몰랐습니다. 종료 상태 목록에 없으니 아직 진행 중이라고 읽고, 15분짜리 폴 예산을 끝까지 다 태운 뒤 timeout으로 접었습니다. 사람이 화면에서 배포를 취소했을 때 정확히 이 경로를 밟고 있었던 것입니다. 원장에는 timeout으로 남았고, 나중에 원장을 실물과 대조하는 정정 명령도 이 상태를 모르는 것은 마찬가지여서 그 행들을 영영 미지 상태로 놔뒀습니다.

이 버그는 취소 기능과 아무 상관이 없습니다. 취소는 원래도 가능했고, 저는 그동안 몇 번 눌러봤습니다. 다만 그 결과를 코드가 어떻게 읽는지 확인할 이유가 없었을 뿐입니다. 새 기능이 기존 버그를 데려오는 게 아니라, 새 기능을 만드느라 들여다본 자리에서 원래 있던 것이 드러나는 일이 종종 있다는 생각이 들었습니다.

리뷰가 막은 두 개의 거짓

첫 구현을 올리고 리뷰에서 두 가지를 더 넣었습니다. 둘 다 원장이 거짓말을 하게 되는 경로였습니다.

하나는 앱 집합입니다. 처음에는 대기자의 커밋만 보고 양보했는데, 대기자가 배포하려는 앱이 홀더가 남겨둔 앱보다 적으면 어떻게 되는지 짚였습니다. 홀더는 남은 앱을 전부 접는데 대기자는 그중 일부만 배포합니다. 빠진 앱은 영영 배포되지 않는데 원장에는 "다른 배포에 커버됨"이라고 적힙니다. 그래서 요청 줄에 대기자의 앱 목록을 함께 싣고, 홀더의 남은 앱이 전부 그 안에 있을 때만 양보하도록 했습니다. 부분 양보는 하지 않습니다. 커버가 안 되면 지금까지의 직렬 대기가 이미 정답입니다.

다른 하나는 신호의 수명입니다. 양보 요청을 보낸 대기자가 그 사이 죽으면, 홀더는 유령에게 양보하게 됩니다. 홀더는 접고 대기자는 없으니 아무도 배포하지 않는데 원장은 커버됐다고 주장합니다. 대기자가 60초마다 요청을 다시 보내고 있으므로, 그 3배인 180초가 지난 신호는 죽은 것으로 보고 무시하게 했습니다. 살아있는 대기자의 신호는 항상 이보다 신선합니다.

두 결함의 모양이 같습니다. 배포가 안 되는 것보다 나쁜 것은, 배포가 안 됐는데 장부에 됐다고 적히는 것입니다. 앞은 다음 배포에서 드러나지만 뒤는 드러날 계기가 없습니다.

모든 실패는 어제의 동작으로 접습니다

이 프로토콜은 최적화지 정합성 장치가 아닙니다. 정합성은 여전히 락이 담당합니다. 그래서 어느 단계가 실패하든 결과가 "예전처럼 조용히 기다렸다가 완주"로 수렴하게 만들었습니다.

실패 지점 결과
홀더가 구버전이라 프로토콜을 모름 조용히 대기 후 완주
git 판정 불가 (미fetch·얕은 클론·git 부재) 요청을 보내지 않음
취소 라우트 부재 또는 400 이 앱은 완주, 다음 앱 경계에서 양보
앱 커버리지 미충족 · 신호 만료 신호 무시, 완주

여기서 하나 정리하고 넘어간 개념이 있습니다. git 판정 함수가 false를 돌려줄 때 그 뜻은 "조상이 아니다"가 아니라 "증명하지 못했다"입니다. 조상이 아닌 경우와 커밋을 아직 못 받아온 경우가 같은 false로 돌아옵니다. 그래서 이 값을 쓰는 자리는 전부 증명이 있을 때만 행동하고, 없으면 원래 하던 일을 합니다. 증명 실패를 반대 결론으로 읽으면, 판정을 못 했을 뿐인데 배포를 건너뛰는 사고가 납니다.

양보한 홀더는 실패가 아니라 exit 0으로 끝납니다. 이건 취향이 아니라 필요였습니다. 배포 실패는 자동으로 복구 절차를 부르게 되어 있어서, 양보를 실패로 찍으면 아무 문제가 없는데 복구가 돌기 시작합니다.

나흘째, 아직 한 번도 발동하지 않았습니다

글을 쓰면서 원장을 다시 열어봤습니다. 머지 나흘째인데 양보가 실제로 일어난 행은 0건입니다. 함께 넣은 "이미 배포된 커밋이면 트리거 생략" 쪽도 0건입니다.

그리고 하나 더 확인했습니다. PR 본문에 제가 근거로 적어둔 문장이 있습니다. 원장에 손으로 심어둔 'superseded' 5행이 이 상황의 반복을 증언한다고 썼는데, 열어보니 그 5행은 제가 말한 상황이 아니었습니다. 두 커밋이 겹친 기록이 아니라, 배포를 돌리던 세션이 도중에 죽어서 복구 세션이 그 자리를 대신한 기록이었습니다. 양보라는 점만 같고 원인이 다릅니다. 근거라고 적을 때 저는 그 5행을 다시 열어보지 않았습니다.

그렇다면 이 작업은 근거 없이 한 것이냐 하면, 그건 아닙니다. 다만 진짜 근거는 그 5행이 아니라 맨 위의 표였습니다. 락 이전에 41건이 겹쳤다는 것은 이 시나리오가 실재한다는 뜻이고, 락 이후 0건이라는 것은 그 41건이 사라진 게 아니라 장부 밖의 대기로 옮겨갔다는 뜻입니다. 표를 먼저 만들었다면 더 정확한 문장을 쓸 수 있었을 텐데, 저는 기억을 먼저 적고 표를 나중에 만들었습니다.

아직 발동하지 않았다는 사실 자체는 별로 걱정되지 않습니다. 이 코드가 하는 일은 겹칠 때 손해를 줄이는 것이지 무언가를 상시 처리하는 것이 아니라서, 안 겹치는 날에는 아무것도 하지 않는 게 정상입니다. 다만 발동한 적이 없다는 것은 실전에서 한 번도 검증되지 않았다는 뜻이기도 합니다. 테스트 43건과 실제 소켓·실제 git 판정까지는 확인했지만, 그것과 운영에서 처음 도는 것은 다른 일입니다.

혼자 개발하면 이런 장치를 만들 때마다 비슷한 자리에 섭니다. 겪은 횟수는 적고, 다시 겪으면 확실히 아까운데, 만들어두면 언제 값을 하는지 알기 어렵습니다. 그래서 요즘은 판단 기준을 하나로 좁혔습니다. 틀렸을 때 조용한가. 이번 것은 실패가 전부 어제의 동작으로 접히고, 양보가 안 일어나면 아무 일도 없습니다. 그 정도면 발동을 못 봐도 두고 볼 만하다는 생각이 들었습니다.

배포 자동화Coolify동시성gitCI/CD분산 락1인 개발

관련 글

background agent는 종료 시 1번만 알린다: Monitor 이중 채널 패턴

백그라운드 배포 에이전트가 167초 만에 종료한 뒤 15분간 무음이었던 실제 사고에서 출발해, 일하는 채널(background agent)과 보는 채널(Monitor)을 분리하는 이중 채널 패턴, 그리고 tail·head가 만드는 폴링 함정 두 가지를 코드 예시로 정리했습니다.

관련도 93%

사람 손 없이 PR→머지→배포: AI 에이전트 자동 연쇄를 설계하며 배운 것

코드 리뷰·머지 게이트·배포는 다 자동화했는데, 사슬의 진입점만 수동 호출로 남아 있었습니다. PR 생성 직후 자동 트리거, 보고가 아닌 GitHub를 직접 조회하는 머지 게이트, 배포 보류 게이팅까지 — 자동 연쇄를 직접 설계하며 겪은 시행착오를 정리했습니다.

관련도 93%

테스트를 통과시켰지만 근거가 틀렸습니다 — 리뷰가 잡아낸 두 건

경로를 잘못 단정하던 테스트를 고쳤는데, 리뷰가 제 수정의 근거를 무너뜨렸습니다. 검색 결과의 첫 히트를 근거로 삼았고, 그것은 제가 찾던 것과 이름만 같은 다른 값이었습니다.

관련도 93%