본문으로 건너뛰기

네트워크가 끊긴 두 저장소 사이로 커밋을 옮기는 절차를 자동화했습니다 — 클립보드 하나로

2026년 7월 9일
2 views
Git
Developer Tools
Claude Code
주니어 개발
회고

이 글에는 git 커밋을 파일 한 장으로 만들어 옮기는 방법(format-patch / am)과, 머지 커밋을 자동으로 분류하는 트릭(merge-tree exit code)이 담겨 있습니다. 망 분리 환경이 아니어도, 백업·이관·오프라인 전달에 쓸 수 있습니다.


문제 인지 — push 가 아예 안 되는 저장소가 정본입니다

제가 다니는 회사는 보안 정책상 개발 작업을 하는 망과 정본을 두는 망이 분리돼 있습니다. 학생 개인정보를 다루는 교육 서비스라 코드와 데이터를 자산으로 보고 관리하는 구조입니다.

정리하면 이렇습니다.

어디상태
외부망실제 개발 작업이 이뤄지는 곳여기 저장소만 접근 가능
내부망정본 저장소외부망에서 push·fetch 자체가 안 됨

"외부망에서 커밋하면 정본에 자동으로 반영"구조적으로 불가능합니다. 누군가 손으로 옮겨야 합니다.

이 방식 자체는 팀의 정식 작업 절차입니다. 제가 우회하려고 만든 게 아니라, 원래 그렇게 일하는 환경이고 그 안에서 매번 발생하는 수작업을 줄인 이야기입니다.

고민 — 자동화가 원리적으로 막혀 있습니다

처음엔 그 절차를 사람이 매번 손으로 했습니다.

  1. 정본 저장소가 어디까지 반영됐는지 확인 (기준 커밋 찾기)
  2. 외부망 저장소에서 기준 커밋부터 지금까지의 변경을 비교 화면으로 확인
  3. 내부망에서 수동으로 적용
  4. chore(sync): 외부망 업데이트 반영 (기준 커밋: N) 으로 커밋

동작은 했습니다. 그런데 두 가지가 걸렸습니다.

① 매번 사람이 diff 를 읽고 재현해야 합니다. 10분 남짓이지만 실수가 섞입니다. 파일 하나 빠뜨리면 나중에 왜 다른지 추적하기 어렵습니다.

② 커밋 이력이 뭉개집니다. 며칠치 작업이 chore(sync) 하나로 합쳐집니다. 정본 저장소의 히스토리를 나중에 보면 "이 기능이 왜 이렇게 됐는지"를 읽을 수 없습니다.

동료가 방향을 명확히 잡아줬습니다.

"두 저장소 커밋을 통일하는 게 맞다."

뭉치지 말고 원본 커밋을 그대로 보존해서 양쪽 히스토리를 같게 유지하자는 뜻이었습니다.

여기서 고민이 시작됐습니다. 네트워크가 막혀 있는데 커밋을 어떻게 그대로 옮기나?

💡 비유 — 다리는 없고 바구니 하나가 왕복합니다

강 양쪽에 마을이 있습니다. 다리는 없습니다. 대신 줄에 매달린 바구니 하나가 왕복합니다. 그 바구니에 들어가는 것만 건너갈 수 있습니다.

이 상황에서 할 일은 "다리를 놓는 방법"을 찾는 게 아닙니다. 물건을 바구니에 맞게 포장하는 방법을 찾는 겁니다.

그리고 그 바구니가 무엇인지 정확히 아는 게 먼저였습니다. 여기서 한 번 크게 틀렸습니다.

원인 탐색 — "정답"이라 믿었던 방법이 채널을 못 탔습니다

커밋을 SHA까지 완전히 동일하게 복제하려면 이론적으로 답이 하나 있습니다. git bundle 입니다.

# 저장소의 일부(또는 전체)를 파일 한 개로 묶는다
git bundle create sync.bundle <base>..<head>

# 받는 쪽에서 그냥 원격처럼 취급해 가져온다
git fetch sync.bundle <branch>

이건 저장소 객체를 그대로 담기 때문에 메시지도, 작성자도, 커밋 SHA 도 완전히 동일합니다. 기준 커밋을 주석으로 남길 필요도 없습니다. 이론상 최선입니다.

그래서 이걸 1순위로 정하고 만들기 시작했습니다.

그런데 실제 전송 채널을 확인해보니 파일 전송이 아니었습니다. 건너갈 수 있는 건 클립보드 텍스트 하나였습니다.

git bundle바이너리입니다. 클립보드로 옮길 수 없습니다.

💡 비유 — 바구니에 안 들어가는 짐

"짐을 완벽하게 포장하는 방법"을 찾아 상자를 아주 잘 만들었는데, 그 상자가 바구니보다 큽니다.

포장 기술이 나쁜 게 아닙니다. 바구니 크기를 먼저 재지 않은 게 문제였습니다.

이 실수에서 배운 게 하나 있습니다. "최선의 방법"을 고르기 전에 "전송 채널의 제약"을 먼저 확정해야 합니다. 제약이 방법을 결정하는데, 저는 방법을 먼저 고르고 제약을 나중에 봤습니다.

개선 — 텍스트로 건너갈 수 있는 형태로 바꿨습니다

바구니가 텍스트만 받는다면, 커밋을 텍스트로 만들면 됩니다.

git 에는 정확히 그 기능이 있습니다.

# 커밋 범위를 텍스트 패치로 뽑아 표준출력으로
git format-patch <base>..<head> --stdout

출력이 그냥 텍스트입니다. 메시지·작성자·작성일·파일 변경 내용이 다 들어 있습니다. 그리고 받는 쪽에서는

# 패치를 커밋으로 되살린다 (커밋 단위가 그대로 복원됨)
git am patch.txt

git apply 와 다릅니다. apply 는 파일 변경만 적용하고 커밋은 직접 해야 합니다. am커밋 메시지와 작성자까지 복원해서 커밋을 만들어 줍니다.

즉 이렇게 됩니다.

외부망:  git format-patch --stdout | 클립보드
                ↓ (바구니가 건너간다)
내부망:  붙여넣기 → patch.txt → git am
                ↓
        원본과 같은 커밋들이 되살아난다

감수한 트레이드오프 — SHA 는 달라집니다

솔직하게 적습니다. 최초 목표를 완전히 달성하지 못했습니다.

git am 으로 패치를 적용하면

유지되나
커밋 메시지✅ 그대로
author (작성자·작성일)✅ 패치에 기록된 원본 그대로
파일 변경 내용✅ 그대로
committer (커밋 시각)적용한 시점으로 새로 찍힘
커밋 SHA원본과 달라짐

커밋 SHA 는 커밋 시각까지 포함해 계산되기 때문입니다.

"SHA까지 동일"은 bundle 만 할 수 있고, bundle 은 이 채널을 못 탑니다. 그래서 "메시지·작성자·내용은 동일한 부분적 통일" 로 목표를 낮췄습니다.

이게 나쁜 결정이라고 생각하지 않습니다. 원래 원했던 건 "히스토리를 읽을 수 있게 하는 것"이었고, 그건 달성했습니다. SHA 동일성은 수단이었는데 목적으로 착각했던 겁니다.

개선 방법 — 머지 커밋이 복병이었습니다

여기서 예상 못 한 문제가 나왔습니다. 머지 커밋입니다.

기능 브랜치를 통합 브랜치에 합치는 게 흔한 흐름이라, 옮길 범위 안에 머지가 섞입니다. 그런데 머지는 두 종류이고, 둘을 같게 다루면 하나는 100% 실패합니다.

종류 ① 충돌 없이 자동 병합된 머지

이건 쉽습니다. 평소처럼 패치를 만들고, 받는 쪽에서 3-way 옵션으로 재생하면 됩니다.

git am --3way patch.txt

--3way 는 문맥이 살짝 안 맞아도 양쪽 조상을 보고 맞춰 줍니다.

종류 ② 손으로 충돌을 해결한 머지

이건 개별 커밋 패치로는 재현으로 확인한 결과 100% 실패합니다.

왜 그런지 순서를 따라가 보면 이렇습니다.

1. 패치를 순서대로 재생한다
2. 원래 겪었던 충돌이 그 자리에서 그대로 재발한다   ← 당연하다. 같은 커밋들이니까
3. 손으로 해결한다 → 이미 정답 상태가 된다
4. 그 뒤에 "해결 내용" 패치를 적용하려 하면
   → "patch does not apply"  (이미 적용된 상태라 적용할 게 없다)

💡 비유 — 이미 푼 문제집에 정답지를 덮어쓰기

문제집을 복사해서 보냈습니다. 받는 사람이 문제를 다 풀었습니다. 그 뒤에 "정답지를 이 페이지에 붙이세요"라고 하면 붙일 자리가 없습니다. 이미 그 자리에 답이 쓰여 있으니까요.

그래서 이 경우는 접근을 바꿨습니다. 개별 커밋을 보내지 말고 최종 결과물을 커밋 하나로 뭉쳐서 통째로 보냅니다.

CURRENT=$(git branch --show-current)
git checkout -b temp-sync <end>      # 임시 브랜치에서만 작업
git reset --soft <base>              # 커밋만 풀고 변경은 남긴다
git commit -m "외부망 작업분 통합 반영 (머지 충돌 해결 포함)"
git format-patch -1 HEAD --stdout | pbcopy
git checkout "$CURRENT"
git branch -D temp-sync              # 임시 브랜치 즉시 삭제

원본 브랜치 히스토리는 건드리지 않습니다. 임시 브랜치에서만 뭉치고 바로 지웁니다.

💡 두 종류를 자동으로 구분하는 트릭

문제는 "이 머지가 어느 종류인지"를 매번 사람이 기억할 수 없다는 것이었습니다.

git merge-tree 가 답이었습니다.

PARENTS=$(git log -1 --format=%P <merge_sha>)   # "parent1 parent2"
git merge-tree --write-tree <parent1> <parent2> > /dev/null 2>&1
echo $?    # 0 = 충돌 없이 자동 병합 가능했던 머지
           # 0 아님 = 손으로 충돌을 해결한 머지

이 명령은 워킹 트리를 건드리지 않고 객체 데이터베이스 안에서만 3-way 병합을 시뮬레이션합니다. 체크아웃도, abort 도 필요 없습니다.

그래서 옮길 범위 안의 머지 커밋마다 이걸 돌려서 분기를 자동 결정합니다.

판정처리
머지 없음그냥 패치
순수 자동 병합만패치 + --3way
충돌 해결된 머지 있음임시 브랜치에서 뭉쳐 한 커밋으로

두 번 실패하고 배운 것 — 양쪽이 각자 만드는 파일

이 절차를 굳힌 뒤에도 두 회차 연속으로 같은 이유로 실패했습니다.

package-lock.json 이었습니다.

error: could not build fake ancestor
patch failed: package-lock.json:1234

원인은 단순했습니다. 그 파일은 양쪽이 각자 npm install 로 만듭니다. 같은 package.json 에서 만들어도 내용이 미세하게 갈라지고, 그러면 패치가 기준으로 삼을 조상 상태를 찾을 수 없습니다.

💡 비유 — 각자 구운 빵을 서로 배송하려는 것

레시피(package.json)를 보내는 건 됩니다. 받는 쪽에서 같은 빵을 구울 수 있습니다. 그런데 구운 빵 자체를 보내려 하면, 받는 쪽에 이미 자기가 구운 빵이 있어서 겹칩니다.

같은 이유로 실패한 파일이 하나 더 있었습니다. 이 절차의 진행 기록을 남기는 파일도 양쪽이 각자 추가하고 있었습니다.

그래서 패치를 만들 때 두 파일을 명시적으로 제외했습니다.

git format-patch <base>..<end> --stdout -- ':(top)' \
  ':(exclude,top)package-lock.json' \
  ':(exclude,top).claude/skills/<skill>/hash-history.md'

pathspec 으로 빼면 그 파일만 건드린 커밋은 통째로 빠지고(패치 번호는 자동 재정렬), 코드와 함께 건드린 커밋은 그 파일 부분 diff 만 빠집니다.

대신 의존성이 바뀐 회차에는 받는 쪽에서 npm install 을 한 번 돌려야 합니다. 그 안내를 절차에 넣었습니다 — 레시피는 보내고 빵은 각자 굽는다는 원칙입니다.

반복 작업을 스킬로 굳혔습니다

여기까지 오니 사람이 기억해야 할 게 너무 많아졌습니다.

  • 기준 커밋을 어디서 찾나
  • 머지가 섞였는지 어떻게 판단하나
  • 어느 파일을 빼야 하나
  • 패치 순서가 맞는지 어떻게 확인하나

그래서 이 판단들을 AI 코딩 도구의 스킬로 굳혔습니다. 트리거 문장 하나면 절차 전체가 돕니다.

굳히면서 일반화한 것들이 있습니다.

① 리모트 이름·브랜치·클립보드 명령을 하드코딩하지 않습니다. 매 실행 시 자동 감지합니다. 저장소마다 이름 규칙이 다르고, 클립보드 명령은 OS 마다 다릅니다.

copy_to_clipboard() {
  if   command -v pbcopy   >/dev/null 2>&1; then pbcopy       # macOS
  elif command -v clip.exe >/dev/null 2>&1; then clip.exe     # Windows
  elif command -v wl-copy  >/dev/null 2>&1; then wl-copy      # Wayland
  elif command -v xclip    >/dev/null 2>&1; then xclip -selection clipboard
  else cat > /tmp/sync.patch
       echo "⚠️ 클립보드 도구를 못 찾아 파일로 저장했습니다: /tmp/sync.patch" >&2
  fi
}

pbcopy 를 하드코딩하면 macOS 밖에서는 command not found조용히 실패합니다. 패치가 클립보드에 없는데 사용자는 있다고 믿는 게 최악입니다.

② 기준 커밋을 파일로 추적합니다. "마지막으로 반영한 커밋"을 기록해두면 다음 실행 때 그 다음부터 자동으로 범위를 잡습니다. 같은 커밋을 두 번 보내는 일이 없어집니다.

③ 패치 순서를 사람이 눈으로 확인할 수 있게 합니다.

패치는 반드시 오래된 것 → 최신 순서여야 합니다. 순서가 어긋나면 적용이 깨집니다. format-patch --stdout 은 기본이 그 순서라 --reverse 를 넣으면 안 됩니다.

그런데 사용자가 "정말 순서대로 만들어졌나" 불안해할 수 있습니다. 패치 원문을 채팅에 다시 뿌리면 노이즈가 크니, 번호 매긴 커밋 목록으로 확인하게 했습니다.

git log --oneline --reverse --no-merges <base>..<end> -- <pathspec> | nl -w2 -s') '

이 번호가 곧 패치 파일에 매겨진 번호와 같습니다.

개선 후 — 한 가지를 뒤집었습니다

기록 파일을 처음엔 아래에 계속 쌓는 방식(위=오래된, 아래=최신)으로 만들었습니다. 로그 파일의 일반적인 관례라서요.

동료가 요청했습니다. "최근 게 맨 위에 오게 해주세요."

기록이 늘어나면 최신을 보려고 매번 파일 끝까지 스크롤해야 하니까요. 그리고 이 파일에서 가장 자주 보는 건 언제나 맨 마지막 기록입니다.

뒤집었습니다. 그런데 여기서 사소한 함정이 있었습니다.

헤더(설명문)와 본문(기록 줄들)을 분리해서 새 줄을 헤더 바로 뒤에 끼워야 하는데, 처음에는 셸의 명령 치환으로 분리했습니다.

HEADER=$(awk '...' "$FILE")     # ← 후행 빈 줄이 잘린다

명령 치환은 뒤쪽 개행을 없앱니다. 그래서 헤더와 첫 기록 줄 사이 빈 줄이 사라져 마크다운이 깨졌습니다. 삽입 위치만 계산하는 방식으로 바꿨습니다.

s = open(path).read()
i = s.index('\n- ')          # 첫 로그 줄 위치
open(path, 'w').write(s[:i] + '\n' + new_line + s[i:])

헤더를 다시 쓰지 않으니 빈 줄이 보존됩니다.

남아 있는 트레이드오프

정직하게 두 가지를 적어둡니다.

① SHA 불일치 — 앞서 적은 대로입니다. 정본 저장소의 커밋 SHA 는 외부망 원본과 다릅니다. 메시지·작성자·내용만 동일한 부분적 통일입니다.

② 충돌 머지 구간의 이력 손실 — 충돌을 해결한 머지가 섞인 회차는 뭉쳐서 넘어가므로, 정본 히스토리엔 그 구간이 커밋 하나로만 남습니다. 머지 없는 날처럼 개별 커밋 단위로는 보존되지 않습니다.

둘 다 전송 채널의 제약에서 나온 것이고, 채널이 바뀌지 않으면 해결되지 않습니다. "완전한 해결"이 아니라 "제약 안에서의 최선"입니다.

배운 것

제약을 먼저 확정하고 방법을 고르세요

가장 큰 실수는 git bundle틀린 답이라서가 아니었습니다. bundle 은 이 문제의 이론적 정답입니다.

문제는 전송 채널이 무엇인지 확인하기 전에 방법을 골랐다는 것입니다. 채널이 텍스트라는 걸 처음에 알았다면 bundle 은 후보에 오르지도 않았습니다.

최적해를 찾기 전에 해가 통과해야 하는 구멍의 크기를 재세요. 구멍이 방법을 결정합니다.

"수단"을 "목적"으로 착각하지 마세요

"SHA까지 동일"을 목표로 잡았는데, 원래 원했던 건 히스토리를 읽을 수 있게 하는 것이었습니다. SHA 동일성은 그걸 달성하는 한 가지 수단이었을 뿐입니다.

수단을 목적으로 놓으면 더 쉬운 길이 보이지 않습니다. 목표를 "부분적 통일"로 낮춘 순간 문제가 풀렸습니다.

양쪽이 각자 만드는 것은 옮기지 마세요

같은 이유로 두 회차 연속 실패한 뒤 정리한 원칙입니다.

생성물은 보내지 않고, 생성 방법을 보냅니다. package-lock.jsonpackage.json 을 보내고 받는 쪽에서 만들게 합니다.

이건 망 분리 환경만의 이야기가 아닙니다. 저장소 간 이관·백업 복원·환경 복제 어디서나 같습니다.

조용한 실패를 만들지 마세요

pbcopy 하드코딩이 그랬습니다. macOS 밖에서는 아무 말 없이 실패하고, 사용자는 클립보드에 패치가 있다고 믿습니다.

폴백을 넣고 폴백했다는 사실을 반드시 알리도록 했습니다. "조용히 실패하는 것"보다 "시끄럽게 실패하는 것"이 언제나 낫습니다.

마무리

이 작업을 한 줄로 적으면 이렇습니다.

다리를 놓을 수 없는 상황에서, 바구니에 맞는 포장법을 찾았다.

환경을 바꿀 수 없을 때 할 수 있는 건 두 가지입니다. 환경을 탓하며 멈추거나, 환경을 전제로 두고 그 안에서 마찰을 줄이는 것.

매번 10분씩 걸리던 수작업이 트리거 문장 하나로 줄었고, 무엇보다 커밋 이력이 뭉개지지 않게 됐습니다. 나중에 정본 저장소를 볼 사람이 "이 기능이 왜 이렇게 됐는지"를 읽을 수 있습니다.

완전한 해결은 아닙니다. SHA 는 여전히 다르고, 충돌 머지 구간은 뭉쳐집니다. 그래도 제약 안에서 갈 수 있는 만큼은 갔다고 생각합니다.

읽어주셔서 고맙습니다 :)


부록 — 이 글에 나온 git 명령

명령하는 일
git format-patch <base>..<head> --stdout커밋 범위를 텍스트 패치로. 기본 순서가 오래된 것 → 최신
git am <patch>패치를 커밋 단위로 복원. 메시지·작성자 유지
git am --3way <patch>문맥이 살짝 어긋나도 양쪽 조상을 보고 병합
git merge-tree --write-tree <p1> <p2>워킹 트리를 건드리지 않고 병합 시뮬레이션. exit 0 = 충돌 없음
git reset --soft <base>커밋만 풀고 변경은 워킹 트리에 남김 (뭉치기용)
git format-patch ... -- ':(exclude,top)<path>'특정 파일을 패치에서 제외
git log --oneline --reverse --no-merges <base>..<end>패치에 매겨질 순서를 눈으로 확인