Git submodule 서브모듈 실무 | 추가·업데이트·CI·모노레포 대안
이 글의 핵심
서브모듈은 상위 저장소가 하위 저장소의 특정 커밋만 가리키는 구조라서, 클론 한 번으로 끝나지 않고 팀원마다 업데이트 시점이 어긋나기 쉽습니다. 이 글은 gitlink 개념에서 출발해 서브모듈 안이 detached HEAD인 이유, 포크된 서브모듈에서 작업하는 법, 언제 submodule 대신 패키지나 모노레포가 나은지 판단 기준을 정리합니다.
들어가며
Git submodule은 한 저장소 안에 다른 Git 저장소를 디렉터리로 포함시키는 방식입니다. 상위 저장소(슈퍼프로젝트)는 하위 저장소의 특정 커밋 SHA만 기록하므로, “의존 라이브러리를 소스로 포함하되 버전을 엄격히 핀한다”는 요구에 맞습니다. 반면 클론 한 번에 모든 것이 끝나지 않으며, CI·Docker 빌드에서 초기화 단계를 빼먹기 쉬워 운영 난이도가 올라갑니다. 이 글은 서브모듈의 추가·업데이트·삭제 절차, CI 설정, 흔한 오류, 그리고 subtree·패키지·모노레포 같은 대안과의 비교까지 정리합니다.
개념: 슈퍼프로젝트와 gitlink
- 상위 저장소의
.gitmodules에 경로·URL·브랜치 추적 설정이 기록됩니다. - Git은 하위 디렉터리를 일반 파일이 아니라 gitlink(커밋 포인터)로 취급합니다.
- 따라서 “하위 저장소의 최신 main을 자동으로 따라간다”가 기본은 아닙니다. 상위에 기록된 SHA가 진실입니다.
gitlink를 이해하면 서브모듈의 거의 모든 동작이 설명됩니다. 상위 저장소의 트리에서 vendor/shared-lib는 파일 내용이 아니라 “하위 저장소의 커밋 abc1234”라는 40자리 해시 하나로 저장됩니다(git ls-tree HEAD vendor로 보면 모드가 160000인 항목으로 나옵니다). 그래서 상위 저장소를 클론해도 하위 저장소의 파일은 따라오지 않고, 하위 저장소에서 새 커밋을 만들어도 상위 저장소는 그 사실을 모릅니다. 누군가 상위 저장소에서 “이제 이 커밋을 쓴다”고 SHA를 바꿔 커밋해야 비로소 팀 전체가 새 버전을 쓰게 됩니다. .gitmodules의 branch 설정도 이 원칙을 바꾸지 않습니다. 그 설정은 git submodule update --remote를 실행할 때 “어느 브랜치의 최신 커밋으로 SHA를 옮길지”를 알려 줄 뿐입니다.
실전: 추가·클론·업데이트·삭제
서브모듈 추가
기본 추가:
# 서브모듈 추가
git submodule add https://github.com/org/shared-lib.git vendor/shared-lib
# 상태 확인
git status
# Changes to be committed:
# new file: .gitmodules
# new file: vendor/shared-lib (커밋 SHA)
# 커밋
git commit -m "chore: add shared-lib submodule"
git push
특정 브랜치 추적:
# develop 브랜치 추적
git submodule add -b develop https://github.com/org/shared-lib.git vendor/shared-lib
# .gitmodules 확인
cat .gitmodules
# [submodule "vendor/shared-lib"]
# path = vendor/shared-lib
# url = https://github.com/org/shared-lib.git
# branch = develop
.gitmodules 파일 구조:
[submodule "vendor/shared-lib"]
path = vendor/shared-lib
url = https://github.com/org/shared-lib.git
branch = main
[submodule "vendor/utils"]
path = vendor/utils
url = https://github.com/org/utils.git
branch = stable
처음 클론할 때
방법 1: 클론 시 서브모듈 포함
# 모든 서브모듈 함께 클론
git clone --recurse-submodules https://github.com/org/main-app.git
# 또는 (동일)
git clone --recursive https://github.com/org/main-app.git
방법 2: 클론 후 서브모듈 초기화
# 먼저 메인 저장소 클론
git clone https://github.com/org/main-app.git
cd main-app
# 서브모듈 초기화 및 가져오기
git submodule init
git submodule update
# 또는 한 번에
git submodule update --init --recursive
방법 3: 병렬 클론 (빠름)
# 서브모듈을 병렬로 클론
git clone --recurse-submodules --jobs 4 https://github.com/org/main-app.git
서브모듈 상태 확인
# 서브모듈 목록 및 상태
git submodule status
# abc1234 vendor/shared-lib (v1.2.3)
# +def5678 vendor/utils (v2.0.0-5-gdef5678)
# -ghi9012 vendor/legacy (heads/main)
# 기호 의미:
# (공백): 정상 (상위가 가리키는 커밋과 일치)
# +: 서브모듈이 다른 커밋을 체크아웃 (변경됨)
# -: 서브모듈이 초기화되지 않음
# U: 병합 충돌
# 상세 정보
git submodule foreach 'echo $name: $(git rev-parse HEAD)'
하위 저장소를 최신으로 올리기
방법 1: 수동 업데이트
# 서브모듈 디렉터리로 이동
cd vendor/shared-lib
# 최신 코드 가져오기
git fetch origin
git checkout main
git pull origin main
# 상위 저장소로 돌아와서 변경 기록
cd ../..
git add vendor/shared-lib
git commit -m "chore: update shared-lib to v1.3.0"
git push
방법 2: 자동 업데이트
# 모든 서브모듈을 원격 브랜치 최신으로
git submodule update --remote
# 특정 서브모듈만
git submodule update --remote vendor/shared-lib
# 변경사항 커밋
git add .gitmodules vendor/shared-lib
git commit -m "chore: update submodules to latest"
방법 3: 특정 태그로 고정
cd vendor/shared-lib
git fetch --tags
git checkout v1.3.0
cd ../..
git add vendor/shared-lib
git commit -m "chore: pin shared-lib to v1.3.0"
팀원이 변경사항 받기
# 상위 저장소 업데이트
git pull
# 서브모듈도 업데이트
git submodule update --init --recursive
# 또는 한 번에
git pull --recurse-submodules
자동화 (Git 설정):
# pull 시 항상 서브모듈 업데이트
git config submodule.recurse true
# 이제 git pull만 해도 서브모듈 자동 업데이트
git pull
서브모듈 제거
완전 제거 절차:
# 1. 서브모듈 등록 해제 (워킹 트리 비우기, .git/config 항목 제거)
git submodule deinit -f vendor/shared-lib
# 2. Git에서 제거
git rm -f vendor/shared-lib
# 3. .git/modules 정리 (선택)
rm -rf .git/modules/vendor/shared-lib
# 4. 커밋
git commit -m "chore: remove shared-lib submodule"
git push
3단계의 .git/modules 정리는 선택으로 적혀 있지만 생략하면 나중에 문제가 됩니다. 이 디렉터리에는 서브모듈의 실제 Git 데이터가 남아 있어서, 같은 경로에 다른 URL(예: 포크)로 서브모듈을 다시 추가하면 “A git directory for ‘vendor/shared-lib’ is found locally with remote(s): origin https://github.com/org/shared-lib.git” 에러와 함께 --force 여부를 묻습니다. 아래 “서브모듈 포크 사용” 절차에서 이 에러를 만나는 경우가 많으므로, 경로를 재사용할 계획이라면 .git/modules 쪽도 지워 두는 편이 깔끔합니다. 또 1~2단계만 한 커밋을 다른 팀원이 pull하면, 그 팀원의 로컬에는 서브모듈 디렉터리가 추적되지 않는 폴더로 남을 수 있으니 직접 지우라고 알려 주는 것이 좋습니다.
부분 제거 (나중에 다시 추가 가능):
# 등록만 해제 (디렉터리는 유지)
git submodule deinit vendor/shared-lib
# 다시 활성화
git submodule update --init vendor/shared-lib
고급: shallow·포크·대체 URL
CI에서 빠른 fetch
얕은 클론으로 시간 단축:
# 히스토리 1개만 가져오기
git submodule update --init --recursive --depth 1
# 또는 클론 시
git clone --recurse-submodules --shallow-submodules https://github.com/org/main-app.git
GitHub Actions 예제:
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout with submodules
uses: actions/checkout@v4
with:
submodules: recursive
- name: Build
run: |
make build
GitLab CI 예제:
variables:
GIT_SUBMODULE_STRATEGY: recursive
build:
script:
- git submodule sync
- git submodule update --init --recursive --depth 1
- make build
actions/checkout의 submodules 옵션은 기본값이 false라서, 이 줄을 빠뜨리면 체크아웃은 성공하지만 서브모듈 디렉터리가 빈 채로 빌드가 진행됩니다. 이때 증상은 서브모듈 에러가 아니라 “Cannot find module” 이나 “No such file or directory: proto/user.proto”처럼 빌드 도구의 에러로 나타나서 원인을 한 단계 더 추적해야 합니다. 서브모듈이 비공개 저장소라면 기본 GITHUB_TOKEN은 현재 저장소에만 권한이 있으므로 “fatal: could not read Username for ‘https://github.com’” 같은 인증 에러가 납니다. 서브모듈 저장소에 접근할 수 있는 deploy key나 PAT를 ssh-key·token 입력으로 넘겨야 합니다. --depth 1 얕은 클론은 빠르지만, 상위 저장소가 가리키는 커밋이 브랜치 끝이 아니면 서버 설정에 따라 그 커밋을 가져오지 못해 “Fetched in submodule path ‘vendor/shared-lib’, but it did not contain abc1234. Direct fetching of that commit failed.” 에러가 날 수 있습니다.
Docker 빌드:
FROM node:18
WORKDIR /app
# Git 설치 (서브모듈 클론에 필요)
RUN apt-get update && apt-get install -y git
# 저장소 클론
COPY . .
# 서브모듈 초기화
RUN git submodule update --init --recursive --depth 1
# 빌드
RUN npm install && npm run build
CMD ["npm", "start"]
이 Dockerfile은 빌드 컨텍스트에 .git 디렉터리가 포함되어 있다는 전제에서만 동작합니다. 많은 프로젝트가 .dockerignore에 .git을 넣어 두기 때문에, 그대로 빌드하면 git submodule update 단계에서 “fatal: not a git repository (or any of the parent directories): .git” 에러가 납니다. 또 비공개 서브모듈이라면 컨테이너 안에는 인증 정보가 없어 클론이 실패합니다. 그래서 실무에서는 Docker 안에서 서브모듈을 초기화하기보다, CI가 체크아웃 단계에서 서브모듈까지 받아 둔 뒤 그 결과를 COPY하는 방식이 더 단순하고 안전합니다. 이미지 안에 Git과 저장소 히스토리가 남지 않는다는 장점도 있습니다.
URL 재매핑 (엔터프라이즈 미러)
로컬 설정:
# 특정 서브모듈 URL 변경
git config submodule.vendor/shared-lib.url https://git.internal.corp/org/shared-lib.git
# 모든 GitHub URL을 내부 미러로
git config --global url."https://git.internal.corp/".insteadOf "https://github.com/"
SSH ↔ HTTPS 전환:
# HTTPS를 SSH로
git config --global url."[email protected]:".insteadOf "https://github.com/"
# SSH를 HTTPS로 (CI에서 유용)
git config --global url."https://github.com/".insteadOf "[email protected]:"
서브모듈에서 작업하기
브랜치 생성 및 푸시:
# 서브모듈로 이동
cd vendor/shared-lib
# 현재 상태 확인 (detached HEAD일 가능성 높음)
git status
# HEAD detached at abc1234
# 브랜치 생성
git checkout -b feature/my-change
# 작업 후 커밋
git add .
git commit -m "feat: add new feature"
# 푸시 (upstream 설정)
git push -u origin feature/my-change
# PR 생성 후 머지되면 상위 저장소 업데이트
cd ../..
git submodule update --remote vendor/shared-lib
git add vendor/shared-lib
git commit -m "chore: update shared-lib with new feature"
서브모듈에서 작업할 때 가장 흔한 사고는 순서입니다. 하위 저장소에서 커밋만 하고 push하지 않은 채 상위 저장소에서 새 SHA를 커밋·push하면, 다른 팀원이나 CI는 원격에 존재하지 않는 커밋을 가리키는 상위 저장소를 받게 됩니다. 그 결과 git submodule update에서 “fatal: remote error: upload-pack: not our ref abc1234” 또는 “Unable to checkout ‘abc1234’ in submodule path” 에러가 납니다. 제가 서브모듈을 쓰는 팀에서 가장 자주 본 CI 실패 원인도 이것이었습니다. 이를 막으려면 상위 저장소를 push할 때 git push --recurse-submodules=check를 쓰거나 git config push.recurseSubmodules check를 설정해 두면, 서브모듈 커밋이 원격에 없을 때 push가 거부됩니다. on-demand로 설정하면 서브모듈을 먼저 자동으로 push합니다.
서브모듈 포크 사용
원본 대신 포크 사용:
# 1. 원본 서브모듈 제거
git submodule deinit -f vendor/shared-lib
git rm -f vendor/shared-lib
# 2. 포크 추가
git submodule add https://github.com/myorg/shared-lib.git vendor/shared-lib
# 3. 원본을 upstream으로 추가
cd vendor/shared-lib
git remote add upstream https://github.com/org/shared-lib.git
git fetch upstream
# 4. 원본 변경사항 가져오기
git merge upstream/main
git push origin main
cd ../..
git add vendor/shared-lib
git commit -m "chore: switch to forked shared-lib"
비교: submodule vs subtree vs 패키지 vs 모노레포
상세 비교표
| 항목 | Submodule | Subtree | 패키지 (npm/pip) | 모노레포 |
|---|---|---|---|---|
| 클론 복잡도 | 추가 명령 필요 | 단순 (git clone) | 단순 (패키지 설치) | 단순 |
| 버전 관리 | 커밋 SHA | 커밋 히스토리 | 시맨틱 버전 | 단일 버전 |
| 업데이트 | 수동 (명시적) | 수동 (git subtree pull) | 자동 (패키지 매니저) | 즉시 반영 |
| 독립 개발 | ✅ 쉬움 | ⚠️ 복잡 | ✅ 쉬움 | ❌ 어려움 |
| 권한 관리 | 레포별 분리 | 레포별 분리 | 레지스트리 권한 | 단일 레포 |
| CI 복잡도 | 높음 | 중간 | 낮음 | 낮음 |
| 학습 곡선 | 가파름 | 가파름 | 낮음 | 중간 |
Submodule 예제
# 구조
main-app/
├── src/
├── vendor/
│ ├── shared-lib/ (서브모듈)
│ └── utils/ (서브모듈)
└── .gitmodules
# 클론
git clone --recurse-submodules https://github.com/org/main-app.git
# 업데이트
cd vendor/shared-lib
git pull origin main
cd ../..
git add vendor/shared-lib
git commit -m "update"
Subtree 예제
# 추가
git subtree add --prefix=vendor/shared-lib https://github.com/org/shared-lib.git main --squash
# 구조 (일반 디렉터리처럼 보임)
main-app/
├── src/
└── vendor/
└── shared-lib/ (일반 디렉터리, 히스토리 포함)
# 업데이트
git subtree pull --prefix=vendor/shared-lib https://github.com/org/shared-lib.git main --squash
# 변경사항 업스트림에 푸시
git subtree push --prefix=vendor/shared-lib https://github.com/org/shared-lib.git feature-branch
패키지 예제 (npm)
# 구조
main-app/
├── src/
├── node_modules/
│ └── @org/shared-lib/ (npm 패키지)
└── package.json
# 설치
npm install @org/shared-lib
# 업데이트
npm update @org/shared-lib
# package.json
{
"dependencies": {
"@org/shared-lib": "^1.2.3"
}
}
모노레포 예제 (Turborepo)
# 구조
monorepo/
├── apps/
│ ├── web/
│ └── api/
├── packages/
│ ├── shared-lib/
│ └── utils/
└── turbo.json
# 클론 (한 번에 모든 것)
git clone https://github.com/org/monorepo.git
# 빌드 (의존성 자동 해결)
npm install
npm run build
# 변경사항 (원자적 커밋)
git add packages/shared-lib apps/web
git commit -m "feat: update shared-lib and use in web"
선택 가이드
- Submodule: 하위 저장소가 독립적인 릴리스 주기를 갖고, 레포별 권한 분리가 필요하며, 여러 프로젝트가 같은 라이브러리를 소스로 공유할 때 맞습니다. 팀이 Git에 익숙하지 않다면 초기화·업데이트 누락이 잦아 부담이 됩니다.
- 패키지: 시맨틱 버전 관리가 중요하고 퍼블릭·프라이빗 레지스트리를 쓸 수 있으며, 패키지 매니저로 업데이트를 받는 편이 자연스러울 때 적합합니다. 대신 의존 라이브러리 소스를 그 자리에서 직접 고쳐야 하는 경우에는 불편합니다.
- 모노레포: 여러 패키지를 동시에 고치는 일이 잦고, 변경을 한 커밋으로 원자적으로 묶어야 하며, CI/CD 파이프라인을 공유할 때 유리합니다. 저장소가 수십 GB 단위로 커지면 클론·호스팅 제약을 따로 고민해야 합니다.
실무 사례
공유 프로토콜 버퍼 레포
구조:
api-gateway/
├── src/
└── proto/ (서브모듈 → shared-proto-repo)
user-service/
├── src/
└── proto/ (서브모듈 → shared-proto-repo)
order-service/
├── src/
└── proto/ (서브모듈 → shared-proto-repo)
워크플로우:
# 1. shared-proto-repo에서 스키마 변경
cd shared-proto-repo
git checkout -b feature/add-user-field
# user.proto 수정
git commit -m "feat: add email field to User"
git push origin feature/add-user-field
# 2. PR 머지 후 각 서비스 업데이트
cd api-gateway
git submodule update --remote proto
git add proto
git commit -m "chore: update proto to include email field"
공유 UI 컴포넌트 라이브러리
구조:
web-app/
├── src/
└── components/ (서브모듈 → shared-components)
admin-app/
├── src/
└── components/ (서브모듈 → shared-components)
버전 고정 전략:
# 안정 버전 사용 (태그)
cd components
git fetch --tags
git checkout v2.1.0
cd ..
git add components
git commit -m "chore: pin components to v2.1.0"
# 최신 개발 버전 사용 (브랜치)
cd components
git checkout develop
git pull origin develop
cd ..
git add components
git commit -m "chore: update components to latest develop"
문서 레포 분리
구조:
website/
├── src/
├── public/
└── docs/ (서브모듈 → documentation-repo)
자동 업데이트 (GitHub Actions):
name: Update Docs
on:
repository_dispatch:
types: [docs-updated]
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
token: ${{ secrets.PAT }}
- name: Update docs submodule
run: |
git submodule update --remote docs
git config user.name "GitHub Actions"
git config user.email "[email protected]"
git add docs
git commit -m "chore: update docs" || exit 0
git push
CI 캐싱 전략
GitHub Actions:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Cache submodules
uses: actions/cache@v3
with:
path: |
.git/modules
vendor/shared-lib
key: submodules-${{ hashFiles('.gitmodules') }}-${{ hashFiles('vendor/shared-lib/.git/HEAD') }}
- name: Build
run: make build
GitLab CI:
build:
cache:
key:
files:
- .gitmodules
paths:
- .git/modules
- vendor/
script:
- git submodule sync
- git submodule update --init --recursive
- make build
트러블슈팅
| 증상 | 원인 | 대응 |
|---|---|---|
| 빈 디렉터리 | submodule 미초기화 | submodule update --init --recursive |
| detached HEAD | submodule은 포인터만 따라감 | 작업 시 브랜치 생성·명시적 push |
| 권한 오류 | CI 토큰에 서브레포 접근 없음 | machine user·deploy key·조직 SSO |
| 충돌 merge | 상위·하위 동시 변경 | 하위에서 먼저 정리 후 상위 SHA 갱신 |
| 버전 불일치 | 일부만 pull | 문서에 “항상 이 명령” 고정 |
“서브모듈 안에서 실수로 push” 방지: 하위 저장소에 branch protection을 두며, 상위 bump는 PR로만 받습니다.
마무리
Git submodule은 멀티레포 의존성을 Git 네이티브로 표현하는 도구이며, 성공하려면 클론·업데이트·CI를 레포 README에 한 줄이라도 표준 명령으로 박아 두는 것이 중요합니다. 원격 협업과 Actions 패턴은 Git push pull·원격 협업, 충돌 해결 사고례는 Git merge conflict 실전과 함께 보면 좋습니다.
자주 묻는 질문 (FAQ)
Q. 서브모듈 안에 들어가 보면 detached HEAD 상태인데 정상인가요?
A. 정상입니다. 상위 저장소는 서브모듈의 브랜치가 아니라 특정 커밋 SHA만 기록하므로, submodule update를 하면 그 커밋이 detached HEAD로 체크아웃됩니다. 이 상태에서 바로 커밋하면 어느 브랜치에도 속하지 않는 커밋이 생기기 쉬우므로, 서브모듈에서 작업하려면 먼저 브랜치를 만들고 push한 뒤 상위 저장소에서 새 SHA를 커밋해야 합니다.
같이 보면 좋은 글
- cargo workspace 모노레포 | Cargo.toml 구조·멤버·공통 의존성·빌드 최적화
- C++ 패키지 매니저 vcpkg와 Conan 비교: manifest·트리플렛·프로파일과 CMake 연동