Published on

Git submodule 지옥 탈출 - CI 체크아웃 오류 해결

Authors

서브모듈을 도입하면 저장소 경계가 명확해지고, 공통 라이브러리 재사용도 쉬워집니다. 문제는 로컬에서는 멀쩡한데 CI에서만 fatal: repository ... not found, Permission denied (publickey), not our ref, No url found for submodule path 같은 오류가 터지며 빌드가 멈추는 순간입니다. 이 글은 “왜 CI 체크아웃 단계에서 submodule이 지옥이 되는가”를 구조적으로 설명하고, 가장 자주 부딪히는 실패 시나리오를 재현 가능한 형태로 정리한 뒤, GitHub Actions를 중심으로 해결책을 제시합니다.

운영 환경에서의 장애 대응이 결국 “원인 분해”와 “재현 가능한 체크리스트”로 수렴하듯, submodule도 똑같습니다. 비슷한 방식의 문제 해결 사고법은 K8s CrashLoopBackOff 원인 10가지·즉시 진단법 같은 글에서 다루는 접근과도 닮아 있습니다.

CI에서 submodule 체크아웃이 실패하는 핵심 이유

CI에서 submodule이 깨지는 원인은 크게 6가지로 분류됩니다.

  1. 인증 수단 불일치
  • .gitmodules는 SSH URL인데 CI는 HTTPS 토큰만 갖고 있는 경우
  • 반대로 HTTPS URL인데 CI는 SSH deploy key만 세팅한 경우
  1. 권한 범위 부족
  • GitHub Actions 기본 GITHUB_TOKEN은 같은 org에서도 private submodule 접근이 막히는 케이스가 존재
  • submodule이 다른 org 또는 다른 계정 소유인 경우 권한이 더 자주 문제
  1. 얕은 클론과 커밋 참조 불일치
  • superproject는 특정 submodule 커밋을 가리키는데, submodule 저장소를 --depth 1로 받으면 그 커밋이 히스토리에 없어 not our ref가 발생
  1. 브랜치 추적 오해
  • submodule은 “브랜치”가 아니라 “커밋 해시”를 고정합니다.
  • .gitmodulesbranch = main은 편의 기능일 뿐, CI에서 무조건 최신 main을 받게 해주지 않습니다.
  1. 서브모듈 URL/경로 변경 후 동기화 누락
  • .gitmodules만 바꾸고 git submodule sync를 안 하면 CI 캐시나 이전 체크아웃 상태에 따라 엉뚱한 URL로 접근
  1. 중첩 서브모듈 및 재귀 업데이트 누락
  • submodule 안에 또 submodule이 있는 경우 --recursive가 빠지면 후속 단계에서 빌드가 터짐

이제부터는 “증상 로그”별로 바로 적용 가능한 처방을 정리합니다.

1) 가장 안전한 기본값: 재귀 체크아웃 + 명시적 업데이트

CI에서는 체크아웃 단계에서 애매함을 줄이는 게 중요합니다. GitHub Actions 기준으로는 actions/checkout에서 submodule을 켜고, 이어서 명시적으로 업데이트를 한 번 더 수행하면 실패율이 확 내려갑니다.

name: ci
on:
  push:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout (with submodules)
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          submodules: recursive

      - name: Ensure submodules are synced and updated
        run: |
          git submodule sync --recursive
          git submodule update --init --recursive

포인트는 두 가지입니다.

  • fetch-depth: 0으로 얕은 클론 이슈를 원천 차단
  • URL 변경, 캐시, 중첩 submodule까지 고려해 syncupdate를 명시

빌드 시간이 걱정된다면, “어떤 submodule에서만 얕은 클론이 안전한지”를 확인한 뒤 부분 최적화하는 편이 낫습니다. 처음부터 최적화하려다 지옥이 시작됩니다.

2) Permission denied / repository not found: 인증 수단을 하나로 통일

CI submodule 실패의 절반은 인증입니다. 해결의 핵심은 “superproject와 submodule이 모두 같은 방식으로 접근되도록 URL을 통일”하는 것입니다.

선택지 A: HTTPS + PAT(또는 fine-grained token)

.gitmodules가 HTTPS라면 CI에서 토큰을 주입해 인증을 통일합니다.

# .gitmodules
[submodule "libs/common"]
  path = libs/common
  url = https://github.com/your-org/common.git

GitHub Actions에서는 아래처럼 토큰을 사용합니다.

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
    submodules: recursive
    token: ${{ secrets.CI_GIT_TOKEN }}

여기서 CI_GIT_TOKEN은 다음 조건을 만족해야 합니다.

  • submodule 저장소에 대한 contents:read 이상 권한
  • private 저장소라면 org 정책에 맞는 접근 권한
  • submodule이 다른 org에 있으면 그 org에도 접근 권한

선택지 B: SSH + deploy key

SSH를 쓰려면 .gitmodules도 SSH로 맞추고, CI에서 ssh-agent에 키를 로드해야 합니다.

.gitmodules 예:

[submodule "libs/common"]
  path = libs/common
  url = git@github.com:your-org/common.git

GitHub Actions 예:

- name: Setup SSH
  run: |
    mkdir -p ~/.ssh
    echo "${{ secrets.SUBMODULE_DEPLOY_KEY }}" > ~/.ssh/id_rsa
    chmod 600 ~/.ssh/id_rsa
    ssh-keyscan github.com >> ~/.ssh/known_hosts

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
    submodules: recursive

주의할 점:

  • deploy key는 보통 “단일 저장소”에 귀속됩니다. submodule이 여러 개면 키 전략을 설계해야 합니다.
  • org 정책에 따라 SSH 접근이 제한될 수 있습니다.

3) not our ref / 특정 커밋을 못 찾음: 얕은 클론을 의심

대표 로그는 다음 형태입니다.

  • fatal: remote error: upload-pack: not our ref ...
  • Fetched in submodule path ..., but it did not contain ...

대부분 submodule이 가리키는 커밋이 submodule 저장소의 최신 커밋이 아니거나, 히스토리가 필요한데 CI가 얕게 받으면서 발생합니다.

해결은 단순합니다.

  • superproject도 fetch-depth: 0
  • submodule 업데이트도 얕게 받지 않기

Git 명령으로 강제하는 방법:

git submodule update --init --recursive --depth 2147483647

다만 이 방식은 사실상 전체 히스토리와 유사하므로, 처음부터 fetch-depth: 0이 더 명확합니다.

4) .gitmodules 변경 후 CI에서만 실패: sync 누락과 캐시

URL을 바꿨는데 CI에서 계속 예전 URL로 접근하는 경우가 있습니다. 로컬은 이미 정리되어 멀쩡해 보이니 더 헷갈립니다.

CI에서 아래를 습관처럼 넣으면 해결되는 경우가 많습니다.

git submodule sync --recursive
git submodule update --init --recursive

그리고 “캐시”를 쓰는 경우(예: submodule 디렉터리를 캐시)에는 캐시 키를 .gitmodules 변경에 연동해야 합니다. 캐시가 오래된 submodule remote 설정을 들고 있으면 계속 같은 문제가 재발합니다.

5) Pull Request에서만 실패: 토큰 권한과 포크 정책

PR에서만 submodule이 실패한다면, 특히 “포크 PR”을 의심해야 합니다.

  • GitHub Actions는 보안상 포크 PR에서 secrets 접근이 제한됩니다.
  • 그래서 private submodule은 PR 빌드에서 접근 불가가 정상 동작일 수 있습니다.

대응 전략은 보통 셋 중 하나입니다.

  1. 포크 PR에서는 submodule이 필요한 잡을 스킵
  2. submodule을 public으로 전환하거나, 빌드에 필요한 최소 artifact만 별도 배포
  3. pull_request_target를 신중하게 사용(권한 상승이므로 보안 검토 필수)

이 지점은 “CI가 왜 실패하는가”가 아니라 “실패하도록 설계되어 있다”에 가깝습니다. 운영 보안 이슈는 권한 모델을 먼저 확인하는 것이 핵심인데, 비슷한 문제 해결 흐름은 GCP Cloud Run 403 해결 - IAM·Invoker 권한 7단계 같은 글에서도 동일하게 적용됩니다.

6) 서브모듈을 계속 쓸 것인가: 지옥을 줄이는 운영 규칙

서브모듈은 도구일 뿐이라 “없애는 것”이 정답인 경우도 많습니다. 다만 당장 제거가 어렵다면 아래 규칙만 지켜도 CI 실패율이 크게 줄어듭니다.

규칙 1: submodule 커밋 업데이트는 반드시 PR로

submodule 포인터 변경은 결국 superproject의 변경입니다. 로컬에서 submodule을 최신으로 당긴 뒤 커밋하고, PR에서 CI가 동일 커밋을 재현하는지 확인해야 합니다.

# submodule을 특정 커밋으로 이동
cd libs/common
git fetch
git checkout <커밋해시>

# superproject에서 포인터 커밋
cd ../..
git add libs/common
git commit -m "chore: bump common submodule"

위에서 <커밋해시>처럼 부등호가 들어갈 수 있는 표기는 MDX에서 문제를 일으킬 수 있으니, 실제 문서/가이드에서도 인라인 코드로 표기하는 습관을 권장합니다.

규칙 2: .gitmodules는 리뷰 대상 1순위

  • URL이 SSH인지 HTTPS인지
  • path가 바뀌었는지
  • 중첩 submodule이 생겼는지

이 세 가지는 CI 실패로 직결됩니다.

규칙 3: CI 체크아웃은 “명시적으로 느리게” 시작

처음에는 다음 조합을 기본으로 두고, 병목이 확인되면 그때 최적화하세요.

  • fetch-depth: 0
  • submodules: recursive
  • git submodule sync --recursive
  • git submodule update --init --recursive

장애 대응에서 “성급한 최적화가 장애를 만든다”는 교훈은 인프라든 애플리케이션이든 같습니다. 예를 들어 리소스 부족으로 프로세스가 죽는 문제도, 먼저 원인을 확정한 다음 튜닝해야 합니다. 비슷한 접근은 리눅스 OOM Killer로 프로세스 죽음 원인 추적 같은 글에서도 강조됩니다.

GitHub Actions에서 자주 쓰는 최종 레시피 3종

팀 상황에 따라 바로 복붙할 수 있게 3가지 템플릿을 정리합니다.

레시피 1: 가장 범용(권장)

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
    submodules: recursive
    token: ${{ secrets.CI_GIT_TOKEN }}

- run: |
    git submodule sync --recursive
    git submodule update --init --recursive

레시피 2: SSH 강제(조직 정책이 SSH 중심일 때)

- name: Setup SSH
  run: |
    mkdir -p ~/.ssh
    echo "${{ secrets.SUBMODULE_DEPLOY_KEY }}" > ~/.ssh/id_rsa
    chmod 600 ~/.ssh/id_rsa
    ssh-keyscan github.com >> ~/.ssh/known_hosts

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
    submodules: recursive

- run: |
    git submodule sync --recursive
    git submodule update --init --recursive

레시피 3: 포크 PR에서는 submodule 빌드 스킵

- name: Checkout
  uses: actions/checkout@v4
  with:
    fetch-depth: 0

- name: Build with submodules (skip on fork PR)
  if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
  run: |
    git submodule update --init --recursive
    make build

마무리: submodule 지옥을 “규칙”으로 끝내기

CI에서 submodule 체크아웃 오류는 랜덤 장애처럼 보이지만, 대부분은 인증 방식, 권한 범위, 얕은 클론, URL 동기화, 재귀 업데이트 중 하나로 환원됩니다. 해결의 요지는 “CI가 로컬과 동일한 방식으로 submodule을 재현하도록 만든다”입니다.

정리하면 다음 순서로 접근하세요.

  1. .gitmodules의 URL 스킴(SSH 또는 HTTPS)을 CI 인증과 통일
  2. fetch-depth: 0으로 커밋 참조 불일치 제거
  3. submodules: recursivegit submodule sync/update로 동기화 보장
  4. PR과 포크 정책에서 secrets 접근 제한을 고려해 파이프라인 분기

이 4가지만 지키면, “가끔 CI에서만 터지는” submodule 지옥은 상당 부분 탈출할 수 있습니다.