- Published on
모노레포에서 Submodule·Subtree 전환 시 함정
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모노레포를 운영하다 보면 공통 라이브러리나 디자인 시스템 같은 "공유 코드"를 어떻게 붙일지로 결국 git submodule 또는 git subtree를 만나게 됩니다. 문제는 한 번 선택한 방식이 영원히 맞지 않다는 점입니다. 조직 구조가 바뀌거나(권한/레포 분리), 배포 단위가 바뀌거나, CI가 복잡해지면서 결국 "서브모듈에서 서브트리로", 혹은 그 반대로 갈아타게 됩니다.
이때 대부분이 겪는 공통 경험이 있습니다. 로컬에서는 잘 되는데 CI에서만 깨지고, 특정 개발자만 업데이트가 안 되고, PR에서 이상한 대량 변경이 발생하고, 히스토리가 사라지거나 반대로 히스토리가 폭발합니다. 이 글은 그 "반드시 터지는" 지점을 미리 정리한 함정 목록과, 전환을 안전하게 끝내는 절차를 제공합니다.
참고로 Git 쪽 이슈는 CI 파이프라인에서 증폭되는 경우가 많습니다. 컨테이너 캐시/체크아웃 전략 때문에 무한 재빌드나 반복 실패로 번지기도 하니, CI 관련 맥락은 GitHub Actions Docker CI/CD 무한 재빌드 루프 끊기도 같이 보면 도움이 됩니다.
Submodule vs Subtree: 전환 전에 합의해야 할 것
전환을 시작하기 전에, 팀이 "정답"을 합의하지 않으면 이관은 성공해도 운영이 실패합니다.
- 업데이트 주체
- Submodule: 각 서비스 레포(모노레포)에서 서브모듈 커밋을 올려야 함
- Subtree: 모노레포에 바로 커밋이 들어오므로 업데이트가 자연스럽지만, upstream 반영은 별도 절차 필요
- 히스토리 요구
- Submodule: 히스토리 보존은 원격 레포가 담당
- Subtree: 히스토리를 가져오면 모노레포 로그가 커짐, 안 가져오면 디버깅이 어려움
- 권한/접근
- Submodule: CI나 개발자 머신에서 서브모듈 레포 접근 권한이 필수
- Subtree: 모노레포만 접근하면 되므로 권한 설계가 단순해짐
- 충돌 해결 방식
- Submodule: 충돌은 "포인터" 충돌(어느 커밋을 가리키나)로 단순하지만, 코드 충돌은 upstream에서 해결
- Subtree: 코드 충돌이 모노레포에서 직접 발생
이 합의가 없으면 전환 이후에 "업데이트를 누가 하죠" 같은 질문이 터지고, 결국 다시 되돌아갑니다.
함정 1: 서브모듈이 CI에서만 깨지는 checkout 문제
서브모듈을 쓰는 모노레포에서 CI가 실패하는 가장 흔한 이유는 "서브모듈이 체크아웃되지 않았다"입니다. 로컬은 이미 git submodule update를 해놨기 때문에 재현이 안 됩니다.
GitHub Actions에서의 대표 해결
# CI 스텝에서
git submodule sync --recursive
git submodule update --init --recursive
Actions의 actions/checkout를 쓴다면, 설정을 명시해야 합니다.
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
fetch-depth가 얕으면 특정 서브모듈 커밋을 못 가져와 실패할 수 있습니다.- private 서브모듈이면 토큰 권한이 부족해 403이 납니다.
Subtree로 갈아타는 이유 중 하나가 바로 이 "권한과 체크아웃" 복잡도입니다.
함정 2: .gitmodules URL 변경 후에도 계속 옛 URL을 물고 늘어짐
서브모듈 URL을 바꾸면 끝이라고 생각하지만, Git은 로컬 설정에 캐시를 남깁니다.
git config --file .gitmodules --get-regexp url
# URL 수정 후
git submodule sync --recursive
그래도 안 되면 아래를 확인합니다.
# 로컬 git config에 박힌 값 확인
git config --get-regexp submodule
특히 조직에서 레포를 https에서 ssh로 바꾸거나, GitHub Enterprise로 이전하면 이 함정이 거의 100% 터집니다.
함정 3: Submodule에서 Subtree로 옮길 때 "폴더가 이미 추적 중" 문제
서브모듈은 해당 경로가 "별도 Git 디렉터리"처럼 취급되는데, 이를 제거하지 않고 subtree를 붙이면 경로 충돌이 납니다.
안전한 제거 순서
# 1) 서브모듈 디이니셜라이즈
git submodule deinit -f path/to/lib
# 2) 인덱스에서 제거(워킹 트리는 유지 옵션도 가능)
git rm -f path/to/lib
# 3) 남은 메타데이터 제거
rm -rf .git/modules/path/to/lib
# 4) 커밋
여기서 git rm은 "파일 삭제"로 보이기 때문에 PR에서 대량 삭제로 보일 수 있습니다. 이후 subtree add로 동일 경로를 다시 채우면 PR diff가 거대해집니다. 이건 정상이며, 리뷰 전략을 바꿔야 합니다(아래 "PR 함정" 참고).
함정 4: Subtree로 가져왔더니 히스토리가 사라지거나, 반대로 커밋이 폭발
Subtree 이관에서 가장 중요한 선택은 "히스토리를 보존할지"입니다.
- 히스토리 보존:
--squash를 쓰지 않음- 장점: 원인 추적/블레임이 쉬움
- 단점: 모노레포 로그가 커지고, 과거 커밋이 대량 유입
- 히스토리 단순화:
--squash- 장점: 모노레포 히스토리 깔끔
- 단점: 과거 추적이 upstream 레포로 점프해야 함
Subtree 추가 예시
# 원격 등록
git remote add shared-lib git@github.com:org/shared-lib.git
git fetch shared-lib
# 히스토리 보존
git subtree add --prefix=packages/shared-lib shared-lib main
# 또는 스쿼시
git subtree add --prefix=packages/shared-lib shared-lib main --squash
"히스토리를 보존"할 경우, 모노레포의 다른 디렉터리와 무관한 커밋들이 과거 시점으로 유입됩니다. 릴리즈 노트 자동 생성, 변경 로그 집계, PR 단위 배포 같은 자동화가 있다면 영향이 큽니다.
함정 5: Subtree로 운영하다가 upstream에 다시 밀어넣을 때 충돌
Subtree는 모노레포에서 코드를 고치고, 그 변경을 원래 레포로 다시 보내는 흐름이 가능합니다. 하지만 이때 운영 규칙이 없으면 충돌이 누적됩니다.
upstream으로 push하는 기본 형태
# 모노레포의 packages/shared-lib 변경을 upstream main으로 반영
git subtree push --prefix=packages/shared-lib shared-lib main
자주 터지는 문제는 다음과 같습니다.
- upstream에서도 동시에 변경이 들어감
- 모노레포에서 파일 이동/리네임이 잦음
- 스쿼시 전략을 섞어 씀(어떤 릴리즈는 스쿼시, 어떤 릴리즈는 히스토리 보존)
권장 운영 규칙(실무형):
- upstream을 "소스 오브 트루스"로 둘지, 모노레포를 "소스 오브 트루스"로 둘지 명확히 결정
- 양방향 편집이 필요하면, 최소한 릴리즈 브랜치 또는 태그 전략을 강제
- 파일 이동은 한쪽에서만 하거나, 이동 커밋을 단독 PR로 분리
함정 6: PR에서 대량 변경이 발생해 리뷰가 불가능해짐
Submodule은 포인터 한 줄이 바뀌는 PR이지만, Subtree는 실제 파일들이 바뀌는 PR입니다. 전환 PR은 특히 "삭제 후 추가" 형태가 되어 diff가 폭발합니다.
대응 전략:
- 전환 PR은 기능 변경을 절대 섞지 말고 "이관만" 수행
- 전환 직후 1~2주간은 해당 디렉터리 변경을 최소화(충돌 비용 감소)
- 필요하면 GitHub의 "Hide whitespace changes" 같은 옵션을 안내
또한 rebase나 히스토리 정리 과정에서 PR이 꼬이면, 강제 푸시 없이 정리하는 패턴이 도움이 됩니다. 관련해서는 Git rebase 후 강제푸시 없이 PR 정리하는 법도 함께 참고할 만합니다.
함정 7: 모노레포 툴링(Turborepo, Nx, pnpm workspace)과 경로 규칙 충돌
서브모듈/서브트리는 "Git 레벨"의 구성이고, 모노레포 빌드 도구는 "워크스페이스 레벨"의 구성입니다. 경로가 바뀌면 아래가 터집니다.
pnpm-workspace.yaml의packages/*글롭에 새 경로가 포함되지 않음- TypeScript
paths가 서브모듈 경로를 가리키고 있었음 - 빌드 캐시 키가 디렉터리 단위로 잡혀 전환 후 캐시 미스 폭발
전환 PR에서 반드시 같이 점검할 파일:
pnpm-workspace.yaml
package.json (workspaces)
tsconfig.json (compilerOptions.paths)
turbo.json / nx.json
Dockerfile (COPY 경로)
여기서 Docker 빌드가 연쇄적으로 실패하거나 캐시가 꼬이면, CI가 "계속 다시 빌드"되는 형태로 나타날 수 있습니다. 이 경우는 Git 문제처럼 보여도 실제로는 Docker 컨텍스트/캐시 키가 바뀐 것이 원인인 경우가 많습니다.
함정 8: Submodule로 되돌아갈 때, "현재 디렉터리가 Git에 의해 추적 중" 역문제
Subtree로 들어온 디렉터리를 다시 서브모듈로 바꾸려면, 해당 경로가 일반 파일로 Git에 추적되고 있으므로 먼저 제거해야 합니다.
# subtree 디렉터리를 인덱스에서 제거
git rm -r packages/shared-lib
# 커밋 후 서브모듈 추가
git submodule add git@github.com:org/shared-lib.git packages/shared-lib
git submodule update --init --recursive
이 과정도 "대량 삭제 후 소량 변경"처럼 보이므로 PR 리뷰 전략을 동일하게 가져가야 합니다.
함정 9: 태그/릴리즈 기준이 "서브모듈 커밋"에서 "모노레포 커밋"으로 바뀌며 배포가 흔들림
서브모듈 기반 운영에서는 특정 서비스가 사용하는 라이브러리 버전이 "서브모듈 커밋 해시"로 명확합니다. Subtree로 오면 그 경계가 모노레포 커밋으로 섞입니다.
실무 대응:
- 라이브러리 디렉터리 변경이 있을 때만 버전 태그를 찍는 자동화 도입
- 또는 Changeset 같은 도구로 패키지 단위 버저닝을 강제
- "어떤 서비스가 어떤 라이브러리 버전을 쓰는가"를 문서화(커밋 해시 기준에서 패키지 버전 기준으로 이동)
Submodule에서 Subtree로 안전하게 이관하는 절차(추천 시나리오)
아래는 가장 사고가 적은 형태의 절차입니다.
1) 현재 서브모듈 상태 고정
git submodule status --recursive
이 결과를 전환 PR 설명에 그대로 붙여두면, "어느 커밋을 이관했는지" 추적이 됩니다.
2) 서브모듈 제거 커밋
git submodule deinit -f packages/shared-lib
git rm -f packages/shared-lib
rm -rf .git/modules/packages/shared-lib
git commit -m "chore: remove submodule shared-lib"
3) subtree add 커밋
git remote add shared-lib git@github.com:org/shared-lib.git
git fetch shared-lib
git subtree add --prefix=packages/shared-lib shared-lib main --squash
git commit -m "chore: import shared-lib as subtree"
4) CI 체크리스트
- 더 이상 서브모듈 init이 필요 없는지 확인
- private 레포 접근 토큰이 불필요해졌다면 권한 최소화
- Dockerfile의
COPY경로가 바뀌지 않았는지 확인
마이그레이션 후 운영 규칙(팀 합의용 템플릿)
- Subtree 디렉터리 변경은 원칙적으로 모노레포에서만 한다
- upstream 레포에도 유지가 필요하면,
git subtree push는 릴리즈 담당자만 수행한다 - 전환 직후 2주간은 파일 이동/리네임 금지
- 전환 PR은 기능 변경 금지, 오직 이관만
이 규칙이 없으면, subtree의 장점(단순한 체크아웃) 대신 단점(코드 충돌의 직접 부담)만 남습니다.
결론: "기술 선택"보다 "운영 선택"이 더 중요하다
Submodule과 Subtree는 각각 장단점이 명확하고, 전환 자체는 명령어 몇 줄로 끝납니다. 하지만 실제로 터지는 문제는 대부분 운영(권한, CI, 리뷰, 릴리즈, 책임 경계)에서 발생합니다.
전환을 계획한다면, 먼저 "누가 업데이트를 책임지는가"와 "히스토리를 어떻게 다룰 것인가"를 합의하고, 그 다음에 위 함정 체크리스트를 따라가면 실패 확률을 크게 줄일 수 있습니다.