Git Submodule을 활용한 공통모듈 관리

들어가며

작은 스타트업에서 근무하면서 다양한 이슈를 마주하게 되었고, 개선하고 싶은 부분들도 계속 쌓여갔다. 하지만 연속되는 기능 개발과 버그 수정, 그리고 개발 외 업무까지 겹치다 보니 블로그 글을 꾸준히 작성하는 것이 쉽지 않았다.

현재 내가 근무하는 환경은 명확한 개발 프로세스나 아키텍처가 잘 정리되어 있다고 보기는 어려운 상태다. 하지만 이런 상황을 단점으로만 보지 않으려고 한다. 오히려 구조를 개선하고 시스템을 직접 설계해볼 수 있는 기회라고 생각했고, 그 과정 자체가 성장에 도움이 된다고 느끼고 있다. 이번 글에서는 여러 서버에서 공통으로 사용하는 코드(특히 Entity)를 어떻게 관리할 수 있을지 고민하다가 선택한 Git Submodule 기반 공통 모듈 관리 방법에 대해 정리해보려고 한다.

1. 배경

현재 서비스의 백엔드는 크게 3개의 서버(메인, 서브, 관리자)로 구성되어 있다. 모두 NestJS 기반이며, ORM으로는 TypeORM을 사용하고 있다. 서비스가 점점 확장되면서 코드와 데이터 구조도 함께 복잡해지고 있다. 특히 초기 설계 없이 기능 위주로 빠르게 개발이 진행되다 보니, 데이터베이스 구조와 코드 간 불일치 문제가 발생했다. 예를 들어 TypeORM의 Migration 기능을 사용하지 않고, DB 스키마와 코드의 관계 설정이 서로 다르게 관리되고 있는 상황이다. 이로 인해 추후 Migration 적용이 어려워지고, Join 쿼리에서도 성능 문제가 발생할 가능성이 높아졌다.

이와 더불어 더 큰 문제는 공통 Entity 관리였다.

  • 약 20개 이상의 테이블이 3개의 서버에서 공통으로 사용됨
  • 각 서버에 동일한 Entity 코드가 중복 존재
  • 테이블 변경 시 3개 서버 모두 수정 필요

이 구조는 명확하게 비효율적이었다. 하나의 변경이 여러 곳에 영향을 주고, 유지보수 비용이 계속 증가하는 상황이었다.

1.1. npm 패키지 배포 방식 검토

가장 먼저 떠올린 방법은 공통 코드를 npm 패키지로 관리하는 방식이었다. 하지만 이 방식에는 몇 가지 현실적인 제약이 있었다.

  • Entity 구조 자체가 내부 데이터 구조를 포함하고 있어 외부 공개가 어려움
  • private registry 사용을 위한 별도의 결제 필요
  • 배포/버전 관리 프로세스 추가 필요

특히 스타트업 환경에서는 배포 프로세스가 추가되는 것 자체가 부담이 될 수 있기 때문에 이 방법은 우선 제외했다.

1.2. NestJS Monorepo 구조 검토

NestJS는 공식적으로 monorepo 구조를 지원한다. 실제로 이전 프로젝트에서 monorepo를 사용해본 경험도 있었다. 하지만 당시 경험과 현재 상황을 비교해보면, 이 구조 역시 적합하지 않다고 판단했다.

  • 여러 서버 코드가 하나의 repo에 들어가면서 충돌 빈도 증가
  • CI/CD 파이프라인 구성 복잡도 증가
  • 프로젝트 규모가 커질수록 구조 파악이 어려워짐

특히 서비스가 빠르게 변경되는 상황에서는 monorepo가 오히려 관리 부담으로 작용할 수 있다고 판단했다.

1.3 Git Submodule 선택

이때 떠올린 것이 Git Submodule이었다. 과거 Python 프로젝트에서 사용해본 경험이 있었고, '공통 코드를 별도의 저장소로 관리하면서 필요할 때 가져오는 구조'가 현재 문제에 적합하다고 판단했다.

2. Git Submodule 기반 구조 설계

테스트를 위해 두 개의 저장소를 생성했다.

submodule에는 공통 Entity 및 유틸 코드가 포함되며, 애플리케이션은 이를 외부 모듈처럼 참조하는 구조로 구성했다.

2.1. tsconfig 설정

submodule과 애플리케이션에서 동일한 alias를 사용하기 위해 tsconfig.json 설정을 맞춰준다.

alias 설정은 필수는 아니지만, 상대경로 깊이가 깊어질 경우 가독성이 크게 떨어지기 때문에 사용하는 것을 권장한다.

  • nestjs-submodule의 tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@submodule": ["src"],
      "@submodule/*": ["src/*"]
    }
  }
}
  • nestjs-structure의 tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@submodule/*": ["submodule/src/*"],
      "@app/*": ["src/*"]
    }
  }
}

tsconfig.json 파일을 수정 후 submodule 코드에 임시 코드를 작성 후 main 브랜치에 push 했다.

export const helloSubmodule = () => 'hello, submodule.';

2.2. Submodule 추가

이제 애플리케이션(nestjs-structure)에서 공통 모듈 저장소(nestjs-submodule)를 submodule로 불러와야 한다. Git에서는 다음 명령어를 통해 다른 저장소를 현재 프로젝트 내부에 연결할 수 있다.

git submodule add <repository-url> <directory>

이 명령어를 실행하면 <directory> 경로에 해당 저장소가 clone되며, 단순히 코드가 복사되는 것이 아니라 현재 프로젝트와 연결된 하나의 독립적인 git 저장소로 관리된다. 즉, submodule은 폴더처럼 보이지만 내부적으로는 별도의 git 히스토리를 가지는 구조이다. 여기서 중요한 점은 submodule이 특정 브랜치가 아니라 특정 commit을 기준으로 고정된다는 것이다. 일반적으로 우리가 git 저장소를 clone하거나 pull 할 때는 최신 상태의 코드를 가져오는 것이 기본 동작이지만, submodule은 다르게 동작한다. submodule을 추가하면, 메인 프로젝트에는 해당 submodule의 commit hash가 기록된다. 즉, 메인 프로젝트는 '이 submodule은 이 commit 상태를 사용한다'라고 명시적으로 고정해두는 구조이다. 이 구조를 조금 더 흐름으로 보면 다음과 같다.

sequenceDiagram
    participant MainRepo as 메인 프로젝트
    participant SubRepo as Submodule 저장소

    MainRepo->>SubRepo: 특정 commit 참조
    Note over MainRepo: commit hash 기록 (.gitmodules, index)
    SubRepo-->>MainRepo: 해당 시점 코드 사용

이 때문에 submodule은 다음과 같은 특징을 가진다.

  • submodule 저장소에 새로운 코드가 push되더라도 자동으로 반영되지 않는다.
  • 메인 프로젝트에서 submodule의 commit을 직접 업데이트해야 변경 사항이 반영된다.
  • 즉, 항상 검증된 특정 버전을 사용하는 구조가 된다.

예를 들어 submodule 저장소에서 Entity를 수정하고 main 브랜치에 push했다고 하더라도, 메인 프로젝트에서는 여전히 이전 commit을 바라보고 있기 때문에 해당 변경 사항은 바로 반영되지 않는다. 이 상태에서 최신 코드를 반영하려면 submodule을 업데이트하는 작업이 필요하다. 이 구조는 처음에는 다소 불편하게 느껴질 수 있지만, 실제로는 큰 장점을 가진다. 메인 프로젝트가 항상 특정 시점의 코드를 기준으로 동작하기 때문에, 의도하지 않은 변경으로 인해 서비스가 깨지는 상황을 방지할 수 있다. 즉, submodule은 단순한 코드 공유를 넘어서 버전 고정과 안정성을 함께 제공하는 방식이라고 이해하는 것이 중요하다. 다만 이 특징을 제대로 이해하지 못하면 submodule이 최신 상태로 안 바뀐다는 문제로 이어질 수 있기 때문에, 이후에 설명할 업데이트 방식까지 함께 숙지하는 것이 필요하다.

2.3. Nest CLI JSON 설정

애플리케이션(nestjs-structure)의 nest-cli.json을 아래와 같이 수정해주었다. 이 설정은 단순히 submodule을 포함시키기 위한 것이 아니라, NestJS의 빌드 과정에서 submodule 코드를 어떻게 처리할 것인지 정의하는 부분이다.

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true,
    "webpack": true,
    "assets": [
      {
        "include": "submodule/src/**/*",
        "exclude": "submodule/src/main.ts", // [!code highlight]
        "outDir": "dist/submodule",
        "watchAssets": true
      }
    ]
  }
}

이 설정이 필요한 이유는 NestJS의 기본 빌드 구조와 관련이 있다. NestJS는 기본적으로 src 디렉토리를 기준으로 컴파일을 수행하고, 그 외 경로에 있는 파일들은 자동으로 포함하지 않는다. 즉, submodule이 프로젝트 루트에 존재하더라도 아무 설정 없이 빌드를 수행하면 해당 코드가 결과물에 포함되지 않는다. 이 문제를 해결하기 위해 assets 옵션을 사용한다. assets는 컴파일 대상이 아닌 파일이나 외부 디렉토리를 빌드 결과물(dist)에 복사해주는 역할을 한다. 여기서는 submodule의 src 디렉토리를 포함시켜, 실제 실행 환경에서도 해당 코드를 사용할 수 있도록 설정한 것이다. 여기서 한 가지 주의할 점은 main.ts 파일을 제외한 부분이다. submodule은 하나의 독립적인 프로젝트처럼 구성되어 있기 때문에, 내부에 main.ts가 존재할 수 있다. 하지만 이 파일은 해당 submodule을 단독으로 실행하기 위한 진입점일 뿐, 현재 애플리케이션에서는 필요하지 않다. 오히려 빌드에 포함될 경우, 의도하지 않은 실행이나 충돌을 유발할 수 있기 때문에 명시적으로 제외해주는 것이 안전하다. 또한 watchAssets 옵션을 활성화해두면, 개발 환경에서 submodule의 코드가 변경될 경우 이를 감지하여 자동으로 반영할 수 있다. 이는 submodule을 수정하면서 동시에 애플리케이션을 테스트할 때 유용하게 사용할 수 있다. 이 설정을 마친 뒤에는 실제로 submodule 코드가 정상적으로 사용되는지 확인해보는 것이 좋다. 간단하게 submodule에 작성한 함수를 불러와 실행해보았다.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

import { helloSubmodule } from '@submodule/helpers';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000, () => {
    console.log(helloSubmodule());
  });
}

bootstrap();

이 코드를 실행했을 때 submodule에서 정의한 함수가 정상적으로 호출된다면, 설정이 올바르게 적용된 것이다. 이 과정을 통해 알 수 있는 점은, submodule은 단순히 코드를 가져오는 것에서 끝나는 것이 아니라, 현재 애플리케이션의 빌드 시스템과 어떻게 연결할 것인지까지 고려해야 한다는 것이다. 특히 NestJS처럼 빌드 기반 프레임워크를 사용하는 경우에는 외부 모듈을 포함시키는 방식에 대한 이해가 필요하다.

2.4. Package JSON 실행 스크립트 수정

앞서 설명했듯이 submodule은 기본적으로 특정 commit을 바라보는 구조이기 때문에, 별도의 작업을 하지 않으면 최신 코드가 자동으로 반영되지 않는다. 즉, submodule 저장소에서 변경 사항이 발생하더라도 메인 프로젝트에서는 이를 명시적으로 업데이트해주지 않는 한 이전 상태를 계속 유지하게 된다. 여기서 한 가지 더 중요한 점이 있다. submodule이 포함된 프로젝트를 처음 clone 했을 때는, submodule이 자동으로 함께 내려받아지지 않는다는 것이다. 이 경우에는 submodule을 초기화하고 실제 코드를 가져오는 과정이 반드시 필요하다. 일반적으로는 아래 명령어를 통해 이를 수행한다.

git submodule update --init --recursive

이 명령어는 다음 두 가지 작업을 동시에 수행한다.

  • submodule 초기화(init)
  • submodule 실제 코드 다운로드(update)

즉, 처음 프로젝트를 clone 한 이후에는 반드시 이 과정을 거쳐야 submodule 내부 코드가 정상적으로 존재하게 된다. 이러한 특성 때문에 개발 환경에서는 submodule을 항상 올바른 상태로 맞춰주는 작업이 필요하다. 이를 매번 수동으로 수행하는 것은 번거롭기 때문에, 실행 스크립트에 포함시키는 방식으로 개선할 수 있다.

{
  "scripts": {
    "submodule:init": "git submodule update --init --recursive",
    "submodule:update": "git submodule update --remote",
    "start:dev": "npm run submodule:init && npm run submodule:update && nest start --watch"
  }
}

즉, 개발자는 별도로 submodule 상태를 신경 쓰지 않아도 항상 정상적인 상태에서 서버를 실행할 수 있게 된다. 여기서 git submodule update --remote 명령어의 동작 방식도 함께 이해할 필요가 있다. 이 명령어는 submodule이 추적하고 있는 브랜치를 기준으로 최신 commit을 가져와 현재 프로젝트에 반영한다. 다만 이 변경 사항은 자동으로 메인 프로젝트에 commit되지 않기 때문에, 실제로 반영하려면 별도의 commit 작업이 필요하다. 또한 이 방식에는 주의해야 할 점도 있다. submodule이 업데이트되면서 공통 코드가 변경되면, 기존 애플리케이션 코드와의 호환성이 깨질 수 있다. 예를 들어 Entity 구조가 변경되거나 공통 함수의 인터페이스가 수정된 경우, 서버 실행 시 오류가 발생할 수 있다. 따라서 submodule을 항상 최신 상태로 유지하는 것뿐만 아니라, 변경 사항이 현재 애플리케이션에 어떤 영향을 주는지 함께 고려하는 것이 중요하다.

3. Submodule 방식의 장단점

3.1. 장점

Submodule을 사용하면서 가장 크게 느낀 장점은 공통 코드를 하나의 저장소에서 중앙 집중적으로 관리할 수 있다는 점이었다. 기존에는 동일한 Entity나 유틸 코드가 여러 서버에 중복으로 존재했기 때문에, 변경이 발생할 때마다 모든 프로젝트를 수정해야 했다. 하지만 submodule 구조에서는 공통 모듈만 수정하면 되고, 각 서비스에서는 해당 변경을 필요에 따라 반영할 수 있다. 또한 각 서비스가 완전히 독립된 저장소로 유지된다는 점도 중요한 장점이다. monorepo와 달리 하나의 프로젝트 구조에 모든 코드가 얽히지 않기 때문에, 서비스 단위로 배포나 관리가 가능하고, 변경 범위를 명확하게 분리할 수 있다. 마지막으로 submodule은 특정 commit을 기준으로 동작하기 때문에, 안정성이 확보된 상태에서 업데이트를 선택적으로 적용할 수 있다. 즉, 공통 모듈이 변경되었다고 해서 무조건 최신 상태를 따라갈 필요 없이, 검증된 시점의 코드를 유지할 수 있다는 점도 장점이다.

3.2. 단점

반면 단점도 분명하다. 가장 큰 문제는 submodule의 개념 자체가 직관적이지 않다는 점이다. 일반적인 Git 사용 방식과 달리, submodule은 commit 단위로 외부 저장소를 참조한다는 개념을 이해해야 하기 때문에 처음 접하는 사람에게는 혼란을 줄 수 있다. 특히 submodule이 최신 상태로 자동 반영되지 않는다는 점을 이해하지 못하면, '왜 코드가 업데이트되지 않는지'와 같은 문제를 겪기 쉽다. 이 때문에 팀 단위로 사용할 경우, submodule의 동작 방식에 대한 공통된 이해가 필요하다.

또한 초기 설정이 다소 번거롭다. 단순히 라이브러리를 설치하는 방식과 달리, Git 설정, 빌드 설정, 경로 설정 등을 함께 고려해야 하기 때문에 도입 비용이 존재한다. 마지막으로, 공통 모듈의 변경이 각 서비스에 영향을 줄 수 있다는 점도 주의해야 한다. submodule을 업데이트하는 순간 여러 서비스에서 동시에 문제가 발생할 수 있기 때문에, 변경 관리와 테스트 전략이 함께 수반되어야 한다.

이렇게 보면 submodule은 단순한 코드 공유 도구라기보다는, 버전과 의존성을 함께 관리하는 구조라고 보는 것이 맞는 것 같다. 따라서 도입 자체보다도 어떻게 관리하고 어떤 범위까지 적용할 것인지에 대한 설계가 더 중요하다고 느꼈다.

마치며

submodule을 적용하면서 느낀 점은 단순히 기술을 적용하는 것이 아니라 구조 설계가 더 중요하다는 것이었다. 공통 모듈을 분리하는 것 자체는 어렵지 않지만 어디까지를 공통으로 둘 것인지에 대한 기준이 없다면 오히려 더 복잡한 구조가 될 수 있다. 특히 submodule은 코드 중복 문제를 해결하는 데에는 효과적이지만, 잘못 설계하면 의존성이 꼬이거나 관리 비용이 더 증가할 수 있다. 따라서 적용 전에 팀 단위에서 충분한 합의와 기준 정리가 필요하다고 느꼈다. 현재는 TypeORM Entity 중심으로 submodule을 구성했지만, 인증 로직이나 공통 유틸 등으로 확장하는 것도 충분히 고려해볼 수 있다. 다만 모든 것을 공통화하기보다는 실제로 여러 서비스에서 반복적으로 사용되는 영역에 한해서 적용하는 것이 더 현실적인 접근이라고 생각한다.