← 뉴스레터

2026-06-26

두 개 올렸는데 하나는 텅 빈 채 올라갔다

두 저장소를 동시에 올리는 작업이 돌고 있었다. 더치페이 앱 쪽이 자격증명 문제로 실패했다는 메시지가 떴다. 옆에는 “Cancelled: parallel tool call errored”라고 적혀 있었다. 페이지 쪽도 취소됐다는 뜻이었다. “그럼 페이지는 처음부터 다시 하면 되는데, push만 다시 넣으면 되겠지” — 그 순간 머릿속에서 그렇게 흘러갔다.


병렬로 발사하면 병렬로 끝난다는 착각

나는 요즘 여러 저장소를 동시에 관리한다. 앱도 있고, 뉴스레터 페이지도 있고, 관련 설정 파일들도 있다. 작업이 겹치는 날은 여러 저장소에 동시에 코드를 올려야 할 때가 있다. 자동화된 환경에서 이걸 한꺼번에 처리하면 시간이 절반으로 줄어든다. 명령을 동시에 두 개 발사하고, 둘 다 성공 신호를 기다리면 된다 — 이론적으로는.

더치페이 앱을 올리면서 뉴스레터 페이지 변경 사항도 같이 올리는 날이었다. 두 가지를 동시에 처리하는 게 그 시점엔 자연스러웠다. 묶어서 한 방에 끝내는 게 효율적인 것처럼 보였다.

병렬 작업을 오래 하다 보면 뇌 어딘가에 이런 모델이 생긴다. “동시에 출발했으니까 둘 다 같은 상태에 있을 것이다.” 성공하면 둘 다 됐고, 실패하면 둘 다 실패했을 거라는 대칭 직관이다. 이 모델을 의식적으로 세운 건 아니다. 그냥 자연스럽게 생긴다. 동시에 출발한 것들은 동시에 끝난다는 느낌. 합리적인 직관처럼 보인다.

그 직관은 틀렸다. 실제 병렬 작업에서 가장 자주 일어나는 건 전부 성공도, 전부 실패도 아닌 부분 성공이다. 한쪽은 완전히 됐고, 다른 쪽은 절반쯤 됐거나 아예 첫 발도 못 뗀 상태. 에러 종류에 따라, 어느 단계에서 끊겼냐에 따라, 다음 재시도에서 어디서부터 다시 해야 하는지가 완전히 달라진다. 그걸 무시하고 같은 방식으로 재시도하면 — 이번 사고처럼 — 빈 내용이 서버에 올라간다.

그리고 그 부분 성공은 겉으로 조용하다. 에러 메시지가 없다. 성공 신호가 온다. 나중에 어딘가에서 “이 브랜치 왜 비어 있지?”라는 질문이 나오기 전까지는 아무도 모른다.

git push를 잠깐 풀어두자

기술 용어를 한 번씩은 풀어야 이 이야기가 제대로 전달된다.

코드를 수정하면 세 단계를 거친다. 먼저 “이 파일들을 기록 대상으로 올린다”(add)는 동작을 하고, “지금까지 바꾼 것들을 하나의 묶음으로 저장한다”(commit)는 동작을 한 뒤, 마지막으로 “이 묶음을 원격 서버에 업로드한다”(push)하는 순서다. 이 세 단계가 순서대로 붙어서 돌아가는 게 보통의 코드 올리기다.

중요한 건 이 세 단계가 의존 관계에 있다는 점이다. add 없이 commit 하면 아무것도 묶이지 않는다. commit 없이 push 하면 로컬에 새로 만든 묶음이 없으니 서버엔 예전 상태만 올라간다 — 또는 빈 브랜치만 생긴다. 각 단계는 이전 단계의 결과물 위에서 돌아간다. 중간이 빠지면 그 아래는 없는 것 위에서 돌아가는 셈이다.

그래서 add+commit+push를 “묶음 명령”으로 취급하는 게 중요하다. 한 단계씩 독립적으로 재시도 가능하다는 생각 — “commit까지는 됐을 테니 push만 다시” — 이 이번 사고의 씨앗이었다.

“어디서 끊겼지?”를 추측하는 게 아니라, 묶음 전체를 다시 돌리는 게 맞는 태도다. 계산보다 재실행이 더 빠르고 더 정확하다.

한쪽이 실패했을 때, 다른 쪽은 어디에 있는가

그날 사고의 핵심은 이 질문에서 왔다. 두 저장소를 동시에 올리는 명령을 발사했다 — 더치페이 앱 저장소와 뉴스레터 페이지 저장소, 동시에. 더치페이 앱 쪽에서 문제가 났다. HTTPS 방식으로 연결된 원격 주소를 쓰고 있었는데, 비대화형 환경에서는 사용자 이름과 비밀번호를 입력할 창이 뜨지 않는다. 터미널 앞에 앉아 있는 사람이 직접 타이핑하는 게 아니라 자동화가 실행하는 환경이라 ID/비밀번호 프롬프트가 생략된다. 그러니 자격증명 없음으로 push가 그냥 실패했다.

그 순간 페이지 저장소 쪽 결과에 “Cancelled: parallel tool call errored”가 찍혔다. 병렬로 돌리던 작업 중 한쪽이 에러를 내면, 함께 돌던 다른 쪽이 자동으로 취소되는 방식이었다. 당연한 동작이다. 하나가 터지면 묶음 전체를 중단한다 — 부분 성공 상태로 완료하는 것보다 안전하니까. 이 판단 자체는 맞다.

그런데 나는 그 “취소”가 정확히 어느 단계에서 일어난 건지 확인하지 않았다. 머릿속에서 이렇게 정리됐다. “더치페이 쪽은 push에서 자격증명이 없어 실패했다. 에러는 push 단계에서 났으니, 페이지 쪽도 비슷한 시점 어딘가에서 취소됐을 것이다. 그러면 add랑 commit까지는 됐을 수 있으니까 push만 다시 넣으면 되겠다.” 에러가 push 단계에서 났으니 다른 쪽도 그 비슷한 지점에 있을 거라는 추론이었다.

틀렸다.

에러 단계와 취소 단계는 연결되지 않는다. 한쪽에서 에러가 나면 다른 쪽은 그 시점과 상관없이 중단된다 — 그 중단이 add 이전이든, commit 이후든. 어느 쪽이든 “어디에서 멈췄는지”는 확인해보기 전까지 알 수 없다.

취소는 ‘중간에 멈춤’이 아니라 ‘처음부터 0건’이다

재시도를 돌렸다. 페이지 저장소에 git push만 다시 넣었다. 명령은 성공했다는 신호를 보내왔다. 그 결과를 보고서에 담아 올렸다. 작업 끝.

그런데 실제로 원격 서버에 올라간 건 내용 없는 빈 브랜치였다. add도 commit도 없이 push만 한 것이었다. commit이 없는 브랜치를 push하면 브랜치 자체는 생기지만, 그 안에 아무것도 없다. 서버에는 “이 브랜치가 존재합니다”라는 기록만 남고, 담겨야 했던 변경 내용은 없다. 빈 껍데기가 올라간 것이다.

왜 그렇게 됐냐면 — 병렬로 돌다가 취소된 쪽은 첫 명령부터 0건 수행된 것이다. 더치페이 쪽이 에러를 냈을 때, 페이지 쪽은 add를 채 시작하기도 전에 통째로 중단됐다. “어딘가 중간까지 됐다가 멈춘 게 아니라 시작도 못 한 것”이 실제 상황이었다.

비유를 하나 들자면, 공항 컨베이어에 짐 두 개를 올려놨는데 앞 짐에서 경보가 울려 컨베이어가 멈췄다. 나는 “앞 짐에서 경보가 났으니 뒤 짐은 이미 한참 이동했겠지” 하고 생각했다. 그런데 컨베이어가 멈춘 건 두 짐 모두 출발 위치에서였다. 뒤 짐은 처음 놓인 자리 그대로였다. push만 다시 올린다는 건, 뒤 짐이 이미 이동했다고 착각하고 목적지 태그만 다시 붙인 셈이다. 뒤 짐 자체가 여전히 출발 위치에 있는데.

“취소됐다 = 어디선가 멈췄다”가 아니라 “취소됐다 = 처음부터 아무것도 실행 안 됐다”가 정확한 해석이었다. 그러니 push만 다시 돌리면 이전 commit 상태 그대로 push가 된다. 새로 만들려던 변경 사항은 어디에도 없다.

이걸 한 문장으로 정리하면: 취소된 쪽은 add부터 다시 해야 한다. 중간부터 시작할 근거가 없다.

보고서에 내용이 있다는 게 증거가 아니었다

사고를 더 오래 끌게 만든 건 보고서 때문이었다. 작업 결과를 정리해 올리는 보고서 안에 페이지 쪽 spec 본문이 들어가 있었다. 그 내용을 보면서 “올라갔겠지” 하고 느꼈을 것이다. 보고서에 내용이 있으니 push가 됐다고 연결 지은 것이다.

그런데 보고서 본문에 내용이 있다는 건, git push가 됐다는 증거가 아니다. 두 가지는 아무 관계가 없다. 보고서는 그냥 텍스트를 담아 전송한 거고, 원격 서버에 올라간 브랜치와는 별개다. spec 본문을 보고서 안에 inline으로 복사해 넣은 것일 뿐 — 그게 서버 반영 여부와는 무관하다.

“보고서에 있다 = 서버에 있다”고 읽은 것 자체가 틀렸다.

이게 자동화 환경에서 자주 빠지는 함정이기도 하다. 결과처럼 보이는 것들이 중간 산출물인데, 그걸 최종 결과로 착각하는 것. 텍스트가 있다, 파일이 있다, 로그가 찍혔다 — 그게 다 “됐다”는 뜻은 아니다. 작업의 흔적과 작업의 완료는 다르다. 최종 목적지인 서버에 실제로 반영됐는지는 따로, 직접 확인해야 한다.

그 확인을 생략했을 때, 중간 산출물이 완료의 증거로 둔갑한다. 그리고 그 착각은 아무도 눈치채지 못한 채로 기록에 남는다. 가장 무서운 건 그 기록이 나 자신을 안심시킨다는 점이다 — “보고서에 내용이 있으니까 됐겠지”라는 식으로. 틀린 전제 위에 안도감이 쌓인다.

부분 성공이 가장 위험한 이유

전부 성공이면 확인 불필요, 전부 실패면 다시 하면 된다 — 이 이분법은 편하다. 처리해야 할 상태가 두 가지뿐이니까. 단순하고 관리하기 쉽다.

그런데 실제로 가장 많이 일어나는 건 그 사이 어딘가다. 하나는 완전히 됐고, 다른 하나는 0건이거나, 세 단계 중 두 단계만 됐거나. 이 다양한 중간 상태를 이분법으로 단순화하는 순간, 실제 상태를 눈으로 보지 않게 된다. 추측으로 대체한다.

부분 성공이 특히 위험한 이유는 틀렸을 때 조용히 사고가 난다는 점이다. push 명령 자체는 성공 신호를 보낸다 — 기존에 있던 상태를 그대로 올렸으니까. 에러가 없다. 실패 메시지가 없다. 빈 브랜치가 원격에 올라갔지만 그 사실은 서버를 직접 열어보기 전까지 보이지 않는다. 조용한 실패다. 나중에 “이 브랜치 왜 비어 있지?”라는 질문이 나오기 전까지는 표면에 드러나지 않는다. 그리고 그 시점이 되면 “언제부터 이랬지?”를 역추적해야 한다.

전부 실패면 즉시 눈에 보인다. 부분 성공은 성공처럼 보인다. 그래서 부분 성공이 더 나쁘다. 에러 없이, 완료된 것처럼 기록된다. 이게 가장 나쁜 종류의 실패다.

그리고 자동화가 많아질수록 이 위험이 커진다. 사람이 직접 작업하면 “뭔가 이상한데?” 하고 멈출 수 있다. 자동화는 멈추지 않는다. 취소가 뜨면 그걸 보고 판단해야 하는 건 결국 사람이다 — 그 판단이 틀리면 자동화가 틀린 방향으로 계속 간다. 판단 품질이 자동화 품질을 결정한다.

병렬 묶음에서 에러 옆에 취소가 뜨면

그날 이후로 규칙 하나를 박았다. 병렬로 돌린 묶음 명령(add+commit+push)에서 한쪽에 에러가 뜨고 다른 쪽에 “Cancelled”가 뜨면 — 취소된 쪽은 첫 명령부터 다시 실행한다. push만 다시 하지 않는다. add부터 다시 한다.

추측 대신 재실행이다. 묶음은 묶음 단위로. “어디까지 됐을까” 계산하는 데 쓰는 시간보다 처음부터 다시 돌리는 게 더 빠르고 더 정확하다. 재실행이 이미 된 부분을 덮어쓰더라도, 빈 브랜치가 서버에 올라가는 사고보다 낫다.

그리고 push 명령이 성공 신호를 보내왔다고 해서 끝이 아니다. 두 줄을 더 확인한다.

git log --oneline -1로 방금 만들어진 commit의 식별자(SHA)와 메시지를 눈으로 본다. 이게 있어야 새로 만든 변경 사항이 실제로 묶였다는 뜻이다. commit이 없다면 push는 빈 채로 올라간 것이다.

git rev-list --count origin/<브랜치>..HEAD로 “로컬에는 있는데 서버에는 아직 안 올라간 commit이 0개인가”를 확인한다. 이 숫자가 0이면 로컬과 서버가 일치한다. 0이 아니면 push가 제대로 안 된 거다.

이 두 줄짜리 검증을 push 직후 확인하는 걸 습관으로 만들었다. 성공 신호 하나만 믿고 끝내던 것에서, 실제 반영 결과를 눈으로 보고 끝내는 것으로.

그리고 HTTPS 방식의 원격 주소를 쓰는 새 저장소는 push 전에 SSH 방식으로 미리 바꿔둔다. git remote set-url origin git@github.com:... 한 줄이면 된다. 비대화형 환경에서 HTTPS는 자격증명 창을 띄울 수 없어 그냥 실패한다. 이번 사고의 근본 원인을 제거하는 것이기도 하다. 함정을 알았으면 다음에는 함정 자체를 없애는 게 맞다.

새 저장소를 클론하거나 추가할 때마다 git remote -v로 URL을 확인하고 HTTPS면 즉시 SSH로 바꾼다. 이게 습관이 되면 이번 사고의 시작점 자체가 사라진다. 더치페이가 HTTPS로 실패하지 않았다면, 페이지가 취소될 일도 없었다.

배운 것

자동화가 많아질수록 “에러 옆의 취소”를 자주 보게 된다. 그걸 볼 때마다 이제는 먼저 묻는다 — 저쪽은 어딘가에서 멈춘 게 아니라, 처음부터 아무것도 시작 못 한 거 아닌가?

push 성공 신호를 믿지 않는다. git log --oneline -1로 commit이 실제로 있는지, rev-list --count로 서버와 차이가 없는지 — 이 두 줄을 확인하고 나서야 “됐다”고 쓴다. 성공 신호는 “push 명령이 에러 없이 실행됐다”는 뜻이지, “올려야 할 내용이 실제로 올라갔다”는 뜻이 아니다.

보고서에 내용이 있다는 걸 서버에 반영됐다는 증거로 읽지 않는다. 작업의 흔적과 작업의 완료는 다르다. 중간 산출물을 최종 결과로 착각하는 순간 조용한 실패가 완료로 기록된다.

병렬 작업에서 “전부 성공 아니면 전부 실패”라는 가정을 버렸다. 가장 흔한 건 부분 성공이다. 부분 성공은 성공처럼 보인다 — 그래서 더 나쁘다. 에러 메시지 없이, 조용히, 나중에 터진다.

그리고 묶음 명령에서 “취소” 신호를 보면 그쪽은 처음부터 다시 돌린다. 추측으로 중간을 이어가지 않는다. 재실행 비용이 추측 비용보다 항상 싸다.


병렬로 돌렸다고 병렬로 끝난 게 아니다. 끝났는지는 직접 봐야 안다.

— 강대종 / @ssamssae