릴리스 패키징 자동화: 모듈별 빌드·취합·압축에서 시간과 실수를 줄이는 방법

이 글의 핵심

각자 모듈을 빌드해 모아 압축하는 수동 패키징은 오래 걸리고, 버전이 하나만 어긋나도 처음부터 다시 해야 합니다. 릴리스에 들어갈 모듈과 버전을 한 파일에 적고, 빌드 전에 검사하고, 바뀐 모듈만 병렬로 빌드하고, 결과를 같은 입력이면 같은 파일이 나오게 묶는 구조를 작은 데모로 만들어 시간과 실수가 어디서 줄어드는지 측정했습니다.

모듈을 담당자가 각자 빌드해 한곳에 모은 다음 압축해서 릴리스를 만드는 방식은 소규모에서는 돌아가지만, 모듈과 버전이 늘면 두 가지가 문제가 된다. 하나는 시간이다. 사람이 순서를 기다리며 빌드하므로 전체 시간이 모듈 수에 비례한다. 다른 하나는 실수다. 모듈 하나가 빠지거나 버전이 어긋난 것을 압축이 끝난 뒤에야 알면 처음부터 다시 해야 한다.

이 글은 그 구조를 자동화하는 방법을 정리하고, 12개 모듈짜리 작은 데모로 시간과 실수가 어디서 줄어드는지 직접 측정한 결과를 적는다. 데모는 실제 회사 빌드가 아니고 C++ 파일 12개를 컴파일하는 장난감 규모라서, 숫자는 방식의 효과를 보여 주는 용도이지 실제 시간을 예측하는 값이 아니다.

수동 패키징이 느리고 틀리는 이유

  • 직렬 대기: 서로 독립인 모듈도 사람이 한 명씩 차례로 빌드한다.
  • 전부 다시 빌드: 한 모듈만 바뀌어도 이전 결과를 믿지 못해 전체를 다시 빌드하는 경우가 흔하다.
  • 버전 정보가 사람 머릿속에 있다: “이번 릴리스에는 A는 1.2, B는 1.1”이라는 정보가 어디에도 코드로 없다.
  • 검사가 맨 뒤에 있다: 압축한 뒤 설치해 보고서야 모듈 누락이나 버전 불일치를 발견한다.
  • 결과가 재현되지 않는다: 같은 소스로 다시 묶어도 해시가 달라서, 무엇이 바뀌었는지 비교하기 어렵다.

자동화의 다섯 가지 원칙

  1. 릴리스 매니페스트 한 개가 단일 기준이다. 어떤 모듈의 어떤 버전을 묶는지, 각 모듈이 무엇을 필요로 하는지를 한 파일(JSON 등)에 적는다. 사람은 이 파일만 고친다.
  2. 빌드 전에 검사한다(빨리 실패). 소스 존재, 필요한 모듈이 릴리스에 있는지, 버전 조건이 맞는지를 빌드 전에 확인한다. 실패하면 한 번에 모든 문제를 보여 준다.
  3. 바뀐 모듈만 병렬로 빌드한다. 모듈별 입력 해시를 키로 결과를 캐시해, 같은 입력이면 다시 빌드하지 않는다.
  4. 재현 가능하게 묶는다. 파일 순서를 정렬하고 시각·소유자·gzip 시간을 고정해, 같은 입력이면 같은 해시가 나오게 한다. 안에 파일별 sha256 목록(MANIFEST)을 넣는다.
  5. 묶은 뒤 검증한다. 압축을 풀어 MANIFEST의 해시와 실제 파일을 대조한다. 이 글의 데모는 이 단계를 구현하지 않았다.

데모: 12개 모듈

환경은 Apple clang 21.0.0(-O2 -std=c++20), macOS arm64, 8코어다. 모듈마다 <map>, <regex> 등을 쓰는 작은 C++ 파일을 컴파일한다. 릴리스 매니페스트는 이런 모양이다.

{
 "release": "2026.11.1",
 "modules": {
  "mod01": {"version": "1.0.0", "requires": {}},
  "mod02": {"version": "1.1.0", "requires": {"mod01": ">=1.1"}},
  "mod03": {"version": "1.2.0", "requires": {"mod01": ">=1.1"}}
 }
}

아래는 핵심 로직만 줄인 pack.py다. 전체 흐름은 검사, 병렬 빌드(캐시), 정렬된 tar와 고정 gzip이다.

def check():  # 빌드 전에 모든 문제를 모아서 보여 준다
    errs = []
    for m, info in mods.items():
        if not Path(f"src/{m}.cpp").exists(): errs.append(f"{m}: 소스 없음")
        for dep, c in info["requires"].items():
            if dep not in mods: errs.append(f"{m}: 필요한 {dep} 가 릴리스에 없음"); continue
            if ver(mods[dep]["version"]) < ver(c.lstrip(">=")):
                errs.append(f"{m}: {dep} {c} 필요, 릴리스에는 {mods[dep]['version']}")
    return errs

def build(m):  # 입력이 같으면 캐시를 쓴다
    key = sha(src_bytes + version + b"clang++ -O2 -std=c++20")
    if (CACHE / f"{key}.o").exists(): return m, key, True
    subprocess.run(["clang++", "-std=c++20", "-O2", "-c", src, "-o", obj], check=True)
    return m, key, False

# 묶기: 정렬된 순서, mtime=0, uid/gid=0, gzip mtime=0

측정 결과

실행결과
버전 불일치 상태(mod01이 1.0.0인데 나머지가 >=1.1 요구)0.000초 만에 중단, 문제 11건을 한 번에 출력
수정 후 직렬 빌드(작업 1개)빌드 20.87초
같은 빌드를 병렬(8개)빌드 9.66초 (약 2.2배)
같은 캐시로 재실행빌드 0.00초, 캐시 적중 12/12
모듈 1개 소스 수정 후빌드 2.71초, 캐시 적중 11/12
서로 다른 캐시에서 두 번 묶은 결과둘 다 같은 sha256 (11094a63…)

해석할 때 주의할 점이 있다.

  • 병렬 효과가 8배가 아닌 2.2배인 이유는 이 컴파일러 호출이 모듈마다 시간이 비슷하게 걸리고 CPU와 메모리를 서로 다투기 때문으로 보인다. 원인을 따로 확인하지는 않았다. 실제 빌드에서는 의존 관계로 병렬화가 더 제한될 수 있다.
  • 모듈 1개를 고친 실행에서 1개를 다시 빌드한 결과가 이전과 같은 sha256이었다. 내가 소스 끝에 주석 한 줄만 추가해서 목적 코드가 바뀌지 않았기 때문이다. 즉 이 방식은 소스가 바뀌어도 결과 바이너리가 같으면 같은 패키지가 나온다.
  • 버전 검사는 빌드가 시작되기 전에 끝났다. 수동 방식에서 패키징을 끝낸 뒤 발견했을 때와 비교해, 실패를 앞쪽으로 당기는 효과가 가장 크다.

실제 회사 환경에 적용할 때

  • 캐시 키에 컴파일러 버전과 의존 라이브러리 버전을 넣는다. 데모는 소스·모듈 버전·컴파일 옵션만 넣었다. 컴파일러가 바뀌면 이전 결과를 재사용하면 안 된다.
  • 버전 조건을 모두 >=로 쓰지 말고 호환 범위를 정한다. 데모의 >=는 단순화한 규칙이다. 실제 호환 규칙(메이저 버전 고정 등)은 팀이 정해야 한다.
  • 모듈 담당자가 만든 결과를 직접 올리게 하지 말고 소스에서 CI가 빌드한다. 사람이 올린 바이너리를 섞으면 재현성이 깨진다. 이 글에서 CI 구성까지 확인하지는 않았다.
  • MANIFEST 검증을 설치 단계에도 넣는다. 받은 쪽이 파일별 해시를 대조하면 전송 중 손상이나 누락을 알 수 있다.
  • 기존 도구도 검토한다. CMake의 CPack, Conan·vcpkg의 잠금 파일, Bazel 같은 빌드 시스템은 위 원칙의 일부를 제공한다고 알려져 있으나, 이 글에서 직접 써 보지는 않았다.

도입 순서

한 번에 다 바꾸기 어렵다면 효과가 큰 순서로 넣는다.

  1. 릴리스 매니페스트를 만들고 지금 쓰는 모듈·버전을 적는다.
  2. 빌드 전 검사 스크립트를 먼저 넣는다(가장 적은 비용으로 재작업이 줄어든다).
  3. 모듈 빌드를 병렬로 돌린다.
  4. 빌드 캐시를 도입한다.
  5. 압축을 재현 가능하게 바꾸고 MANIFEST 검증을 붙인다.

한계

  • 데모는 모듈 12개, 파일 1개씩인 장난감이다. 실제 의존 그래프, 링크 단계, 테스트 단계는 포함하지 않았다.
  • 시간은 한 환경에서 한 번씩 잰 값이고 반복 측정하지 않았다(재현성 확인은 sha256만 두 번 했다).
  • 회사의 실제 빌드 시스템과 배포 방식을 알지 못해서, 위 구조를 그대로 적용할 수 있는지는 확인하지 못했다.

같이 보면 좋은 글