기본 패턴
# 서브모듈 추가
git submodule add https://github.com/org/shared-lib libs/shared-lib
# 저장소 클론 후 서브모듈 초기화 및 체크아웃
git clone https://github.com/org/my-project
git submodule update --init --recursive
# 서브모듈을 최신 커밋으로 업데이트
git submodule update --remote --merge
# 상위 저장소에 서브모듈 포인터 변경 반영
git add libs/shared-lib
git commit -m "chore: shared-lib 최신 버전으로 업데이트"설명
서브모듈은 외부 저장소를 특정 커밋에 고정해 포함한다 — .gitmodules와 SHA 포인터가 핵심이다
git submodule add를 실행하면 두 가지가 생긴다. .gitmodules 파일에 서브모듈의 URL과 로컬 경로가 기록되고, 상위 저장소의 트리 객체에 해당 디렉터리가 특수한 "gitlink" 항목으로 등록된다. gitlink는 서브모듈 저장소의 특정 커밋 SHA를 가리키는 포인터다. 상위 저장소는 서브모듈의 파일 내용을 직접 저장하지 않는다. 대신 "이 경로의 서브모듈은 SHA a3f8c12를 체크아웃해야 한다"는 정보만 가진다. 덕분에 공유 라이브러리의 버전을 프로젝트마다 다르게 고정할 수 있고, 서브모듈 저장소에 기여하지 않는 한 그 내부 히스토리 전체를 받을 필요가 없다.
# .gitmodules 파일 내용 예시
# [submodule "libs/shared-lib"]
# path = libs/shared-lib
# url = https://github.com/org/shared-lib
# 상위 저장소가 저장하는 것: SHA 포인터
git ls-tree HEAD libs/shared-lib
# 160000 commit a3f8c12d... libs/shared-lib
# ↑ "160000"은 gitlink를 의미하는 특수 모드클론 후 서브모듈은 자동 체크아웃되지 않는다 — --init --recursive가 필요한 이유
git clone은 상위 저장소의 파일만 내려받는다. 서브모듈 디렉터리는 빈 채로 남는다. .git/config에 서브모듈 항목이 등록되지 않았기 때문에 git submodule update만으로는 동작하지 않는다. --init은 .gitmodules를 읽어 .git/config에 서브모듈을 등록하는 단계이고, --recursive는 서브모듈 안에 또 다른 서브모듈이 있을 때 재귀적으로 초기화한다. 두 단계를 하나의 명령으로 합치는 것이 git submodule update --init --recursive다. 팀 온보딩 스크립트나 CI 파이프라인에서는 이 명령을 항상 포함시켜야 한다.
# ❌ clone 직후 서브모듈 디렉터리가 비어 있는 상황
ls libs/shared-lib # (빈 디렉터리)
# ✅ 초기화 + 체크아웃을 한 번에
git submodule update --init --recursive
# 또는 clone 시 옵션으로 처리
git clone --recurse-submodules https://github.com/org/my-project서브모듈은 항상 detached HEAD 상태다 — 수정 후 브랜치에 커밋하지 않으면 업데이트 시 유실된다
서브모듈 디렉터리 안에서 git status를 실행하면 HEAD detached at a3f8c12라고 표시된다. 브랜치를 추적하는 것이 아니라 특정 커밋에 HEAD가 직접 붙어 있는 상태다. 이 상태에서 파일을 수정하고 커밋해도 어떤 브랜치에도 속하지 않는다. 이후 상위 저장소에서 git submodule update를 실행하면 서브모듈이 다른 SHA로 이동하면서 방금 만든 커밋이 도달 불가능한 상태가 된다. 서브모듈 안에서 작업할 때는 반드시 먼저 브랜치를 만들거나 기존 브랜치로 전환한 뒤 커밋해야 한다.
# 서브모듈 안에서 작업할 때 올바른 순서
cd libs/shared-lib
# ✅ 먼저 브랜치로 전환
git switch main
# 또는 새 브랜치 생성
git switch -c feat/new-feature
# 작업 후 커밋
git add .
git commit -m "feat: 새 기능 추가"
git push origin feat/new-feature
# 상위 저장소로 돌아가 포인터 업데이트
cd ../..
git add libs/shared-lib
git commit -m "chore: shared-lib 포인터 업데이트"git subtree와의 비교 — subtree는 단순하지만 외부 기여가 복잡해진다
subtree는 외부 저장소의 히스토리를 상위 저장소에 직접 병합해 넣는다. 클론하면 별도 초기화 없이 모든 파일이 바로 포함되고, detached HEAD 같은 함정이 없다. 반면 외부 저장소에 변경을 다시 push하려면(git subtree push) 상위 저장소 히스토리에서 관련 커밋을 필터링해야 해서 복잡하다. 서브모듈은 외부 저장소와의 양방향 기여가 목적이거나 버전을 엄밀히 고정해야 할 때 적합하고, subtree는 외부 코드를 "가져와서 내 것으로 포함"하는 단순한 시나리오에 잘 맞는다.
# subtree로 외부 저장소 추가 (히스토리 병합 없이)
git subtree add --prefix=libs/shared-lib \
https://github.com/org/shared-lib main --squash
# subtree 업데이트
git subtree pull --prefix=libs/shared-lib \
https://github.com/org/shared-lib main --squash
# 변경을 외부 저장소로 push (서브모듈과 달리 복잡함)
git subtree push --prefix=libs/shared-lib \
https://github.com/org/shared-lib main빠른 정리
| 상황 | 적합한 선택 |
|---|---|
| 외부 저장소를 특정 버전에 고정해 포함 | git submodule add |
| clone 후 서브모듈 코드가 없을 때 | git submodule update --init --recursive |
| 서브모듈 안에서 코드 수정 | 먼저 브랜치 전환 후 커밋 |
| 서브모듈을 최신 커밋으로 올리기 | git submodule update --remote --merge |
| 단순 포함, 양방향 기여 불필요 | git subtree add --squash |
주의할 점
상위 저장소에서 git pull하면 서브모듈 SHA 포인터만 바뀌고 실제 파일은 갱신되지 않습니다.
항상 --recurse-submodules를 함께 사용하거나 pull 후 update를 실행하세요.
# ❌ 포인터만 바뀌고 파일은 이전 상태 그대로
git pull
# ✅ 서브모듈 파일까지 자동으로 업데이트
git pull --recurse-submodules
# 또는 pull 후 수동으로 업데이트
git pull
git submodule update --init --recursive
# git config로 기본값 설정 (권장)
git config --global submodule.recurse truesubmodule.recurse true를 전역으로 설정하면 pull/checkout 시 서브모듈이 자동으로 따라옵니다.