병렬 워커 오케스트레이션 실전: 마찰 2건과 해결
여러 작업을 동시에 굴리고 싶다는 욕심은 늘 있었다. 티켓을 하나씩 잡고 브랜치 파고 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는 자기 자리에 .git 파일을 하나 두고, 그 파일이 공통 object store(커밋·객체 히스토리)를 가리킬 뿐이다. 즉 공유되는 건 히스토리이고, 작업 디렉터리(빌드 캐시, 설치된 의존성, 심볼릭 링크 등)는 worktree마다 완전히 독립이다. 그래서 워커 worktree의 산출물이 .git을 타고 coordinator worktree로 새어 들어오는 일은 없다. 진짜 원인은 coordinator worktree 자체를 깨끗하지 않은 상태로 재사용한 데 있었다 — 이전 사이클에서 남은 node_modules나 빌드 캐시가 그대로 있는데 그 위에 통합본을 설치하니, lockfile과 실제 node_modules 상태가 어긋나는 경우가 생겼다. “내 통합 브랜치는 분명 맞는데 설치가 이상하다”가 여기서 나왔다.
해결: coordinator 설치는 매번 clean에서
규칙 하나 더.
coordinator worktree는 통합 전에 항상 깨끗한 상태에서 시작하고, 의존성은 lockfile 기준으로 재현 설치한다.
- worktree를 재사용하지 말고, 통합 사이클마다 새로 만들거나(
git worktree add) 최소한 추적되지 않은 산출물을 정리한다. - 의존성은 “있으면 쓰는” 설치가 아니라 lockfile을 기준으로 강제 재현 설치(clean install)한다. 워커가 뭘 깔아뒀든 coordinator는 lockfile만 신뢰한다.
- worktree 경로가 서로의 산출물 디렉터리를 가리키지 않게 한다. 공유돼야 하는 건 히스토리(
.git)뿐, 빌드 산출물은 각자 격리한다.
이렇게 바꾸고 나서는 “coordinator에서만 재현되는 설치 오류”가 사라졌다. 핵심은 coordinator를 신뢰의 단일 지점으로 만들되, 그 지점의 초기 상태를 매번 결정론적으로 맞추는 것이었다.
정리하면
병렬 워커 오케스트레이션에서 내가 실제로 걸린 곳은 두 군데였다.
- 형제 브랜치 fast-forward 불가 → 통합 직전 rebase를 규칙으로 박아 조상-후손 관계를 회복하고 ff로 얹는다.
- coordinator worktree 설치 무결성 → coordinator는 매번 clean 상태에서 lockfile 기준 재현 설치. 산출물은 worktree별로 격리.
둘 다 “파일이 안 겹치면 문제 없겠지”라는 낙관에서 나온 문제였다. 실제로는 git의 그래프 관계와 파일시스템 상태라는, 논리적 독립성과 별개인 두 축이 있었다. 병렬로 굴리고 싶다면 이 두 축을 규칙으로 먼저 못 박아두는 걸 추천한다.
관련 작업
- 병렬 워커 오케스트레이션 실험 (내부 워크플로우 노트 기반, 코드 저장소 인용 없음)