상세 컨텐츠

본문 제목

git submodule로 여러 프로젝트의 하네스를 한곳에서 관리하기

카테고리 없음

by ougi 2026. 9. 29. 19:40

본문

입사하고 처음 접한 기능 중 하나가 git submodule이었습니다.

써보니 꽤 괜찮아서 개인 프로젝트에도 적용해 봤고, 그 과정에서 겪은 것들을 정리해 두려고 합니다.


submodule이란?

서브모듈은 한 저장소 안에 다른 저장소를 통째로 넣어 두고 관리하는 기능입니다.

주로 공통 라이브러리 같은 의존성을 관리하거나 외부 프로젝트를 가져다 쓸 때 사용합니다.

핵심은 두 저장소가 각자 독립적으로 버전 관리된다는 점입니다.

메인 저장소는 서브모듈의 내용을 복사해 두는 게 아니라 "서브모듈 저장소의 몇 번 커밋을 쓴다"는 포인터(커밋 해시) 만 들고 있습니다.

서브모듈 쪽은 자기만의 히스토리와 브랜치를 그대로 가집니다.

# 서브모듈 추가
git submodule add git@github.com:user/shared-lib.git libs/shared

# 서브모듈까지 함께 클론
git clone --recurse-submodules git@github.com:user/main-project.git

# 이미 클론했다면
git submodule update --init

# 서브모듈을 원격 최신으로 올리고 포인터 커밋
git submodule update --remote libs/shared
git add libs/shared && git commit -m "chore: shared 포인터 갱신"

마지막 명령이 중요합니다.

서브모듈 저장소에 새 커밋이 올라와도 메인 저장소는 자동으로 따라가지 않습니다.

포인터를 올리는 커밋을 따로 해 줘야 반영됩니다. 이게 장점이자 단점입니다.

장점

  • 레포 독립성 — 각 저장소가 자기 히스토리·브랜치·릴리즈를 따로 가집니다.
  • 재사용 — 공통 코드를 여러 프로젝트에서 같은 저장소로 가져다 씁니다.
  • 권한 분리 — 저장소 단위로 접근 권한을 다르게 줄 수 있습니다.
  • 버전 고정 — 서브모듈이 바뀌어도 메인 저장소는 포인터를 올리기 전까지 영향받지 않습니다.

단점

  • git 사용이 복잡해짐 — --recurse-submodules를 빼먹으면 빈 폴더가 생기고, 포인터 갱신을 잊으면 옛날 버전을 계속 씁니다.
  • CI/CD가 복잡해짐 — 러너에서 서브모듈을 따로 받아야 하고, private 저장소면 토큰도 필요합니다.
  • 포인터 갱신이 수작업 — 서브모듈을 쓰는 곳이 많을수록 "올리는 일" 자체가 일이 됩니다.

회사에서는 어떻게 쓰고 있었나

회사의 upstream 프로젝트는 auth, bi, emr, crm처럼 기능별로 저장소를 나누고 서브모듈로 묶는 구조였습니다. 기능마다 독립성을 지키고 따로 배포하려는 목적이었던 것 같습니다.

다만 저장소가 여러 개로 나뉘다 보니 비슷한 역할의 코드가 여기저기 겹친다는 느낌을 자주 받았습니다. 구조 자체의 문제라기보다는, 레포 사이에 "이건 이미 저기 있다"를 공유하는 소통이 부족해서 생긴 문제라고 생각합니다. 서브모듈은 코드를 나눠 주지만, 무엇을 공통으로 뺄지는 결국 사람이 정해야 합니다.


내 프로젝트에 적용한 방법

저는 Claude Code를 쓰는 개인 프로젝트가 여러 개 있는데, 프로젝트마다 CLAUDE.md와 .claude/(규칙·스킬·에이전트·훅) 설정이 따로 있었습니다. 같은 커밋 규칙이나 훅을 레포마다 복사해 두다 보니, 하나를 고치면 나머지는 늘 뒤처졌습니다.

그래서 하네스(설정)만 모아 두는 저장소를 하나 만들고, 각 프로젝트가 이 저장소를 서브모듈로 가리키게 했습니다.

구조

claude-config/            ← 하네스 전용 저장소
├── shared/               ← 모든 프로젝트 공통 (rules, skills, agents, hooks)
├── project-a/
│   ├── CLAUDE.md
│   └── .claude/          ← 프로젝트 고유 설정 + shared 로 가는 링크
├── project-b/
└── project-c/

프로젝트 쪽에서는 서브모듈을 넣고, 심링크로 자기 폴더를 연결합니다.

project-a/
├── .claude-config/                                  ← 서브모듈 (claude-config)
├── .claude    -> .claude-config/project-a/.claude   ← 심링크
└── CLAUDE.md  -> .claude-config/project-a/CLAUDE.md ← 심링크
cd ~/project-a
git submodule add git@github.com:user/claude-config.git .claude-config
ln -s .claude-config/project-a/.claude .claude
ln -s .claude-config/project-a/CLAUDE.md CLAUDE.md

이렇게 하면 shared/에 있는 규칙을 한 번 고치는 것만으로 모든 프로젝트에 반영할 수 있습니다.

포인터 갱신은 GitHub Actions로 자동화

앞에서 말한 "포인터 갱신이 수작업"이라는 단점은 워크플로우로 해결했습니다. 하네스 저장소 main에 push가 들어오면, 각 프로젝트에 포인터를 최신으로 올리는 PR을 자동으로 만들고, 머지만 사람이 합니다.

on:
  push:
    branches: [main]

jobs:
  sync:
    strategy:
      matrix:
        include:
          - { repo: project-a, dir: project-a }
          - { repo: project-b, dir: project-b }
    steps:
      - uses: actions/checkout@v4
        with:
          repository: user/${{ matrix.repo }}
          token: ${{ secrets.SUBMODULE_SYNC_TOKEN }}
          submodules: true

      - run: |
          git -C .claude-config fetch origin main
          # 이 프로젝트가 보는 경로가 안 바뀌었으면 PR을 만들지 않는다
          if git -C .claude-config diff --quiet HEAD origin/main -- shared "${{ matrix.dir }}"; then
            exit 0
          fi
          git -C .claude-config checkout --detach origin/main
          git add .claude-config
          # ... 브랜치 생성 → 커밋 → push → gh pr create

포인트는 git diff --quiet ... -- shared <자기 폴더> 입니다. project-a 설정만 바꿨는데 project-b에까지 PR이 쌓이면 귀찮기 때문에, 그 프로젝트가 실제로 보는 경로가 바뀐 경우에만 PR을 만듭니다.


마무리

회사에서 처음 본 서브모듈을 개인 프로젝트에 가져와 보니, 여러 프로젝트의 하네스를 한곳에서 관리하면서 중복을 확실히 줄일 수 있었습니다.

회사에서 느꼈던 "코드가 겹친다"는 문제도 결국 무엇을 공통으로 뺄지 정하는 일이 핵심이었습니다. 저는 shared/에 있는 건 모든 프로젝트에 전부 연결한다는 규칙을 정하고, 빠진 링크를 잡아 주는 검사 스크립트도 따로 두었습니다. 서브모듈은 그 결정을 담는 그릇일 뿐이라는 걸 배웠습니다.

다음에도 새로 배운 걸 직접 적용해 보고 정리해 보겠습니다!

728x90