여러 작업을 동시에 굴리고 싶다는 욕심은 늘 있었다. 티켓을 하나씩 잡고 브랜치 파고 PR 올리는 흐름은 안정적이긴 한데, 서로 안 겹치는 작업 두세 개를 굳이 순서대로 할 이유가 없다. 그래서 병렬 워커를 붙여 여러 작업을 한 번에 돌리는 실험을 해봤다. 결론부터 말하면 되긴 된다. 다만 아무 준비 없이 붙이면 꼭 같은 두 군데서 발목이 잡혔다 — ① 형제 브랜치가 fast-forward로 안 합쳐지는 문제(git 그래프 관계), ② coordinator worktree가 깨끗하지 않아 설치가 어긋나는 문제(파일시스템 상태). “파일만 안 겹치면 괜찮겠지” 했는데, 논리적 독립성과 별개인 이 두 축에서 걸린 거다. 이 글은 그 마찰 2건과 해결법을 정리한 것이다.

이 글은 “여러 워커(사람이든 에이전트든)가 같은 저장소에서 동시에 작업할 때” 생기는 문제를 다룬다. 코드보다는 워크플로우 이야기다.

왜 병렬로 굴리려 했나

작업 두 개가 있다고 하자. 하나는 A 영역, 하나는 B 영역. 파일이 안 겹친다. 이런 건 사실상 독립이라 동시에 진행해도 논리적으로 충돌이 없다. 그런데 실제로 붙여보면 “논리적으로 안 겹친다”와 “git 상에서 안 겹친다”가 다르다는 걸 금방 깨닫는다.

기본 구성은 이렇게 잡았다.

  • coordinator: 작업을 나누고, 각 워커에게 슬라이스를 배정하고, 결과를 모아 통합 브랜치로 정리하는 역할.
  • worker N개: 배정받은 슬라이스만 자기 브랜치에서 처리.

이 구성에서 마찰이 두 번 났다.

마찰 1 — 형제 브랜치는 fast-forward가 안 된다

첫 번째 함정. 워커들이 각자 main에서 브랜치를 따서(feat/a, feat/b) 작업을 마쳤다. coordinator가 이걸 순서대로 통합 브랜치에 합치려 하는데, 두 번째 병합에서 fast-forward가 거부됐다.

당연한 얘기였다. feat/a를 먼저 ff로 얹으면 통합 브랜치의 tip이 앞으로 나간다. 그 상태에서 feat/b는 여전히 옛날 main을 base로 갖고 있으니, 통합 브랜치와 feat/b형제(divergent) 관계가 된다. 조상-후손 관계가 아니라서 fast-forward가 성립하지 않는다.

main ── A' (통합, feat/a ff 완료)
   └──── B  (feat/b: 여전히 옛 main base)

여기서 git merge --ff-only feat/b를 하면 그냥 튕긴다. 순진하게 “다 ff로 얹으면 되겠지” 했던 게 문제였다.

해결: 통합 순서를 rebase로 규칙화

fast-forward만으로 여러 형제 브랜치를 얹을 수는 없다. 그래서 규칙을 하나 박았다.

워커 브랜치는 통합 직전에 최신 통합 tip 위로 rebase한 뒤에만 ff로 얹는다.

흐름으로 쓰면:

# coordinator가 순차 통합할 때
git checkout integration
git merge --ff-only feat/a        # 첫 번째는 그냥 ff

# 두 번째부터는 rebase 후 ff
git checkout feat/b
git rebase integration            # 형제 → 후손 관계로 정렬
git checkout integration
git merge --ff-only feat/b        # 이제 ff 성립

rebase로 feat/b의 base를 최신 통합 tip으로 옮겨주면 조상-후손 관계가 회복되고, 그다음 ff는 깔끔하게 붙는다. 히스토리도 선형으로 남아서 나중에 무슨 일이 있었는지 읽기 좋다.

파일이 정말 안 겹치면 rebase 자체는 충돌 없이 지나간다. 겹치면 그때 충돌을 해결하는데, 어차피 그건 “논리적으로도 겹치는 작업”이었다는 신호라 오히려 초기에 잡는 게 낫다. 병합 커밋으로 뭉개면 나중에 blame이 지옥이 되니까 rebase 규칙을 택했다.

💡 형제 브랜치 여러 개를 합칠 때 “왜 ff가 안 되지”로 헤매는 건, base가 옛날 지점에 묶여 있어서다. 통합 직전 rebase 한 줄이면 대부분 정리된다.

마찰 2 — coordinator worktree 설치 무결성

두 번째는 좀 더 은근했다. coordinator를 별도 worktree에서 돌렸는데(git worktree add), 여기서 통합만 하는 게 아니라 통합 결과를 한 번 빌드/설치해서 확인하고 싶었다. 문제는 이 worktree가 깨끗한 상태가 아니었다는 것.

git worktree.git을 공유하고 작업 디렉터리만 따로 가진다. 그래서 워커 worktree에서 만들어진 로컬 산출물(빌드 캐시, 설치된 의존성, 심볼릭 링크 등)이 파일시스템 상태에 따라 coordinator worktree의 결과에 영향을 줄 여지가 있었다. 특히 의존성 설치가 워커별로 조금씩 다른 시점에 이뤄지면, coordinator에서 통합본을 설치했을 때 lockfile과 실제 node_modules 상태가 어긋나는 경우가 생겼다. “내 통합 브랜치는 분명 맞는데 설치가 이상하다”가 여기서 나왔다.

해결: coordinator 설치는 매번 clean에서

규칙 하나 더.

coordinator worktree는 통합 전에 항상 깨끗한 상태에서 시작하고, 의존성은 lockfile 기준으로 재현 설치한다.

  • worktree를 재사용하지 말고, 통합 사이클마다 새로 만들거나(git worktree add) 최소한 추적되지 않은 산출물을 정리한다.
  • 의존성은 “있으면 쓰는” 설치가 아니라 lockfile을 기준으로 강제 재현 설치(clean install)한다. 워커가 뭘 깔아뒀든 coordinator는 lockfile만 신뢰한다.
  • worktree 경로가 서로의 산출물 디렉터리를 가리키지 않게 한다. 공유돼야 하는 건 히스토리(.git)뿐, 빌드 산출물은 각자 격리한다.

이렇게 바꾸고 나서는 “coordinator에서만 재현되는 설치 오류”가 사라졌다. 핵심은 coordinator를 신뢰의 단일 지점으로 만들되, 그 지점의 초기 상태를 매번 결정론적으로 맞추는 것이었다.

정리하면

병렬 워커 오케스트레이션에서 내가 실제로 걸린 곳은 두 군데였다.

  1. 형제 브랜치 fast-forward 불가 → 통합 직전 rebase를 규칙으로 박아 조상-후손 관계를 회복하고 ff로 얹는다.
  2. coordinator worktree 설치 무결성 → coordinator는 매번 clean 상태에서 lockfile 기준 재현 설치. 산출물은 worktree별로 격리.

둘 다 “파일이 안 겹치면 문제 없겠지”라는 낙관에서 나온 문제였다. 실제로는 git의 그래프 관계와 파일시스템 상태라는, 논리적 독립성과 별개인 두 축이 있었다. 병렬로 굴리고 싶다면 이 두 축을 규칙으로 먼저 못 박아두는 걸 추천한다.

관련 작업

  • 병렬 워커 오케스트레이션 실험 (내부 워크플로우 노트 기반, 코드 저장소 인용 없음)