Git submodule 충돌을 없애기 위한 Jenkins 빌드 구조 변경
여러 프론트엔드 프로젝트에서 반복되던 submodule 병합 충돌을 줄이기 위해, 공통 저장소의 조합 책임을 Git에서 Jenkins로 옮긴 과정을 정리했습니다.
들어가며
여러 업무 프로젝트에서 함께 사용하는 프론트엔드 코드가 있었습니다. 정적 리소스가 들어 있는 public과 공통 컴포넌트 및 기능이 들어 있는 src/common이었습니다.
기존에는 두 디렉터리를 Git submodule로 연결했습니다. Submodule은 다른 Git 저장소를 프로젝트 안의 하위 디렉터리로 연결하고, 그 저장소의 특정 코드 시점을 사용하도록 기록하는 기능입니다. 공통 코드를 별도 저장소에서 관리하면서 각 프로젝트가 필요한 버전을 가리킬 수 있다는 점에서는 자연스러운 선택이었습니다.
문제는 브랜치를 병합할 때마다 발생했습니다. develop, staging, main 사이를 병합할 때뿐 아니라 개발자가 자신의 기능 브랜치를 공용 브랜치에 병합할 때도 submodule 충돌이 반복됐습니다.
결국 공통 코드를 관리하는 일보다 submodule이 가리키는 커밋을 맞추는 일에 더 많은 주의를 기울이게 됐습니다. 이를 줄이기 위해 공통 저장소를 애플리케이션 Git의 추적 대상에서 제외하고, Jenkins가 빌드 시점에 가져오도록 구조를 변경했습니다.
기존 구조와 문제
업무 프로젝트 저장소
├── src
│ └── common → 공통 코드 저장소 submodule
├── public → 공통 리소스 저장소 submodule
└── .gitmodules
Git은 submodule 내부 파일을 부모 저장소에 직접 보관하지 않습니다. 부모 저장소에는 submodule이 가리키는 특정 커밋이 기록됩니다. 커밋은 코드를 저장한 한 시점을 뜻하며 고유 식별값을 가집니다. 이 글에서 ’포인터’라고 부르는 것은 메모리 주소가 아니라, 공통 저장소의 어느 커밋을 사용할지 기록한 값입니다. 두 브랜치에서 이 포인터가 서로 다르게 변경되면 어떤 커밋을 사용할지 결정해야 합니다.
예를 들어 처음에는 두 브랜치가 모두 공통 모듈의 A 커밋을 사용했지만, 개발 과정에서 develop은 B 커밋을, 기능 브랜치는 C 커밋을 사용하도록 변경될 수 있습니다. 이후 두 브랜치를 병합할 때 Git이 어느 커밋을 사용할지 자동으로 결정하지 못하면 submodule 충돌이 발생합니다. 서로 다른 커밋을 사용한다고 항상 충돌하는 것은 아니며, 두 커밋의 이력 관계에 따라 자동으로 병합되기도 합니다.
이처럼 공통 모듈이 가리키는 커밋이 브랜치마다 다르게 변경된 상태에서, 다음 병합을 진행할 때 충돌이 반복됐습니다.
- 기능 브랜치를
develop에 병합할 때 develop을staging에 반영할 때staging을main에 반영할 때
여러 프로젝트가 서로 다른 공통 모듈 커밋을 사용하는 것 자체는 충돌의 원인이 아닙니다. 다만 각 프로젝트에서 위와 같은 브랜치 병합을 진행해야 했기 때문에, 프로젝트 수가 늘수록 비슷한 충돌을 반복해서 확인하고 해결해야 했습니다.
변경한 구조
공통 코드를 각 업무 프로젝트에 복사하거나 하나의 저장소로 합치지는 않았습니다. public과 common은 별도 GitLab 저장소로 유지했습니다.
대신 관리 책임을 다음과 같이 변경했습니다.
변경 전: 애플리케이션 Git이 submodule 커밋을 추적
변경 후: Jenkins가 빌드 대상 브랜치의 공통 저장소를 clone
브랜치별 대응 관계는 단순하게 유지했습니다.
| 애플리케이션 브랜치 | public 저장소 | common 저장소 |
|---|---|---|
develop | develop | develop |
staging | staging | staging |
main | main | main |
이 구조는 Maven이나 Gradle의 일반적인 멀티모듈도 아니고, 모든 코드를 하나의 저장소에 합친 모노레포도 아닙니다. 정확히는 공통 코드를 여러 저장소에 나누어 보관하고, Jenkins가 실행 파일이나 배포 파일을 만드는 시점에 필요한 코드를 모으는 구조입니다. 여러 저장소를 유지한다는 의미에서 멀티레포라고 부릅니다.
1. Git 추적에서 submodule 제거
.gitignore에 경로만 추가해서는 이미 추적 중인 submodule을 제거할 수 없습니다. .gitignore는 아직 추적하지 않는 파일을 새로 추가하지 않도록 하는 설정이기 때문입니다.
먼저 기존 submodule 경로를 Git 인덱스에서 제거했습니다. 인덱스는 다음 커밋에 반영할 파일과 상태를 Git이 관리하는 목록입니다. 아래 명령으로 public과 src/common을 현재 프로젝트의 Git 관리 대상에서 제외했습니다. 로컬에 있는 폴더와 파일은 그대로 유지했습니다.
git rm -r --cached public src/common
--cached를 사용하면 Git의 추적 대상에서는 제거하면서 로컬 작업 디렉터리의 파일은 유지할 수 있습니다.
이후 .gitignore에 두 경로를 추가했습니다.
# Common repositories cloned separately
public/
src/common/
.gitmodules는 각 submodule이 프로젝트의 어느 폴더에 연결되어 있는지, 어떤 원격 저장소에서 코드를 가져오는지 기록하는 설정 파일입니다. 예를 들어 src/common 폴더와 공통 코드 저장소의 주소를 연결해 둡니다. 이제 두 공통 저장소를 submodule로 관리하지 않으므로, 더 이상 필요하지 않은 .gitmodules를 삭제했습니다.
git rm .gitmodules
당시 전체 변경 순서는 다음과 같았습니다.
- submodule 경로를 Git 인덱스에서 제거
.gitignore에 공통 코드 경로 추가.gitmodules삭제- README와 환경 설정 수정
develop에 먼저 적용- 검증 후
staging,main으로 순차 반영
2. Jenkins 설정 변경
기존 Jenkins Job에는 Git submodule을 처리하기 위한 설정이 있었습니다. Job은 Jenkins에서 실행할 빌드 절차와 대상 저장소 등을 묶어 설정한 작업입니다. 구조 변경 후에는 Jenkins의 submodule 자동 갱신 기능을 사용하지 않고, 빌드 스크립트가 공통 저장소를 직접 clone하도록 변경했습니다.
GitLab 인증 정보는 Jenkins에 별도로 저장하고, 빌드할 때만 필요한 환경변수로 전달했습니다. Jenkins의 Credential Binding은 저장된 계정·비밀번호 등의 값을 작업 실행 시 변수에 연결해 주는 기능입니다. 인증 정보는 스크립트에 직접 작성하지 않고, 아래 환경변수에서 가져오도록 했습니다.
GITLAB_USR
GITLAB_PSW
실제 저장소 주소와 Credential 이름은 내부 정보이므로 생략합니다.
기존 빌드 스크립트는 의존성을 설치하고 프론트엔드를 빌드하는 역할만 수행했었습니다.
FRONT_PRJ=sales
npm install
npx kendo-ui-license activate
npm run build:${FRONT_PRJ}
cd ./dist
tar cf ${FRONT_PRJ}.tar ${FRONT_PRJ}
여기에 현재 Jenkins Job의 브랜치를 확인하고 같은 브랜치의 공통 저장소를 가져오는 단계를 추가했습니다. 당시 스크립트를 내부 주소 없이 축약하면 다음과 같습니다.
FRONT_PRJ=sales
# origin/develop에서 develop만 추출
BRANCH_NAME=$(echo $GIT_BRANCH | sed 's#origin/##')
if [ "$BRANCH_NAME" = "main" ]; then
COMMON_BRANCH=main
elif [ "$BRANCH_NAME" = "staging" ]; then
COMMON_BRANCH=staging
else
COMMON_BRANCH=develop
fi
# 삭제 가능한 Jenkins 작업 공간에서 기존 디렉터리 정리
rm -rf public src/common
git clone -b "$COMMON_BRANCH" \
"http://${GITLAB_USR}:${GITLAB_PSW}@<gitlab-host>/<public-repository>.git" \
public
git clone -b "$COMMON_BRANCH" \
"http://${GITLAB_USR}:${GITLAB_PSW}@<gitlab-host>/<common-repository>.git" \
src/common
npm install
npx kendo-ui-license activate
npm run build:${FRONT_PRJ}
cd ./dist
tar cf ${FRONT_PRJ}.tar ${FRONT_PRJ}
핵심은 기존 빌드 과정 앞에 공통 저장소를 준비하는 단계를 추가한 것입니다.
이전 빌드에서 남은 공통 코드가 섞이지 않도록, 기존 public과 src/common 폴더를 삭제한 뒤 다시 clone했습니다.
3. 전체 프로젝트에 순차 적용
먼저 테스트용 프로젝트에서 구조와 빌드를 확인했습니다. 이후 w_sales를 포함해 공통 모듈을 사용하던 전체 업무 프로젝트에 같은 변경을 적용하고 각 Jenkins Job도 수정했습니다.
프로젝트별로 다음 항목을 확인했습니다.
- submodule 경로가 Git 추적 대상에서 제거됐는가
.gitmodules가 삭제됐는가.gitignore에 공통 경로가 등록됐는가- 환경별
.env설정이 올바른가 - Jenkins가 현재 환경에 맞는 브랜치를 가져오는가
- 기존 빌드와 배포가 정상적으로 완료되는가
변경 결과
가장 직접적인 결과는 공통 모듈 때문에 발생하던 병합 충돌이 사라진 것입니다.
개발자는 기능 브랜치를 병합하거나 develop, staging, main 사이를 승격할 때 더 이상 submodule 포인터를 별도로 맞추지 않아도 됐습니다. 여러 프로젝트가 같은 공통 저장소를 사용해도 애플리케이션 저장소의 병합 과정에는 영향을 주지 않았습니다.
반면 새로운 작업도 생겼습니다. 개발자가 프로젝트를 로컬에 처음 구성할 때는 public과 common 저장소를 각 경로에 직접 clone해야 했습니다.
git clone -b develop <public-repository-url> public
git clone -b develop <common-repository-url> src/common
기존의 git submodule update --init을 두 번의 clone으로 대체한 셈입니다. 최초 구성 이후에도 필요에 따라 각 저장소의 브랜치를 직접 맞춰야 합니다.
트레이드오프
좋아진 점은 명확했습니다.
- 애플리케이션 병합에서 submodule 포인터 충돌이 사라졌습니다.
- 환경별 브랜치 규칙이 단순해졌습니다.
- 여러 업무 프로젝트에 동일한 빌드 방식을 적용할 수 있었습니다.
대신 다음 사항을 감수해야 했습니다.
- 개발자가 최초 로컬 환경을 구성할 때 공통 저장소를 직접 clone해야 합니다.
- 애플리케이션 코드와 공통 코드가 함께 정상적으로 동작하도록, 각 저장소의 변경 내용을 맞춰야 합니다.
- 공통 브랜치가 변경되면 같은 애플리케이션 커밋도 나중에 다른 결과로 빌드될 수 있습니다.
공통 브랜치의 최신 코드를 가져오기 때문에, 애플리케이션 코드가 같아도 빌드 시점에 따라 공통 코드가 달라질 수 있습니다. 이전과 같은 코드로 다시 빌드해야 한다면, 당시 사용한 공통 코드의 커밋을 기록하고 해당 커밋을 가져오도록 해야 합니다. 이번에는 이러한 버전 고정보다 여러 프로젝트에서 반복되던 submodule 병합 충돌을 줄이는 것을 우선했습니다.
다시 개선한다면
로컬 초기 설정을 자동화하는 스크립트를 함께 제공하면 수동 clone의 불편을 줄일 수 있습니다.
또한 Jenkins가 clone한 실제 커밋을 빌드 로그에 남기면 어떤 공통 코드로 배포했는지 추적하기 쉬워집니다.
git -C public rev-parse HEAD
git -C src/common rev-parse HEAD
마치며
이번 작업의 핵심은 submodule을 제거한 것 자체가 아닙니다. 공통 코드의 조합 책임을 어디에 둘지 다시 결정한 것입니다.
기존에는 Git이 각 프로젝트와 공통 저장소의 정확한 커밋 관계를 관리했습니다. 변경 후에는 Jenkins가 환경별 브랜치 규칙에 따라 빌드 시점에 저장소를 조합했습니다.
그 결과 특정 커밋을 고정하는 submodule의 장점 일부는 포기했지만, 여러 프로젝트에서 반복되던 병합 충돌과 운영 부담을 줄일 수 있었습니다.
반복되는 submodule 병합 충돌을 줄이기 위해 Git의 의존성 추적을 제거하고, Jenkins가 빌드 시점에 환경별 공통 저장소를 조합하도록 변경했습니다.