Google Copybara 실전 가이드: 내부 저장소와 공개 저장소를 동기화하는 법
오픈소스 프로젝트를 운영하는 팀이라면 한 번쯤 겪는 상황이 있다. 내부에서는 커다란 모노레포에서 개발하는데, 그중 일부만 공개 저장소로 떼어내서 세상에 내보내야 하는 경우다. 이 "내보내기"를 한 번만 하면 편하겠지만, 문제는 계속 반복해야 한다는 거다. 내부에서 커밋할 때마다 공개 저장소에도 반영하고, 반대로 외부 컨트리뷰터가 보낸 PR도 내부로 끌어와야 한다.
이걸 셸 스크립트와 git subtree 조합으로 어떻게든 굴려본 사람이라면 그 고통을 안다. 이번에 GeekNews에 올라온 Google의 Copybara가 딱 이 문제를 풀려고 나온 도구다. 이미 gVisor, Bazel, Dagster 같은 프로젝트가 실제로 이걸로 내부 모노레포 ↔ 공개 저장소를 동기화하고 있다.
1. 왜 지금 이 도구가 화제인가
모노레포 vs 멀티레포는 오래된 논쟁이다. 그런데 현실은 "둘 중 하나만 고를 수 없다"는 데 있다. 회사 내부 정책상 코드는 confidential 모노레포에 있어야 하는데, 커뮤니티에 공개는 하고 싶다. 이때 나오는 선택지가 대략 세 가지다.
- 수동 복붙: 릴리스마다 사람이 파일 복사. 이력 날아가고 휴먼 에러 폭발.
- git subtree / filter-repo 스크립트: 자동화는 되는데, 경로 재매핑이나 파일 제외 로직이 붙기 시작하면 스크립트가 괴물이 된다.
- Copybara: 변환 규칙을 설정 파일로 선언하고, 반복 이동을 도구가 관리.
Copybara의 핵심 컨셉은 하나다. 저장소 중 하나를 authoritative(권위) 저장소로 지정해서 단일 진실 공급원(source of truth)을 유지하되, 기여와 릴리스는 어느 쪽에서든 받을 수 있게 한다. HN 댓글에서 실사용자들이 공통적으로 말하는 건, Google 내부에서도 대부분 모노레포 → GitHub 단방향으로 쓴다는 점이다. 양방향은 가능하지만 지저분해진다고 여러 명이 경고한다. 이 부분은 뒤에서 다시 다룬다.
2. 동작 원리: stateless와 변환 파이프라인
Copybara를 이해하는 데 가장 중요한 특징 두 개가 있다.
상태를 별도 서버가 아니라 커밋 메시지에 저장한다
보통 "동기화 도구"라고 하면 어딘가에 "마지막으로 어디까지 옮겼는지" 상태를 저장하는 DB나 서버가 있을 거라 생각한다. Copybara는 그걸 안 쓴다. 대신 대상 저장소의 커밋 메시지 라벨(트레일러)에 원본 SHA를 박아둔다. 이게 GitOrigin-RevId 트레일러다.
Add retry logic to fetcher
GitOrigin-RevId: 7f3a9c1e2b8d4f6a0c5e9b1d3a7f2c8e4b6d1a9f
이 방식의 장점은, 여러 사람이나 여러 CI 서비스가 같은 설정과 같은 저장소로 돌리면 항상 같은 결과가 나온다는 것이다. "내 로컬에서만 되는" 상태 불일치가 원천적으로 안 생긴다. 별도 상태 서버를 운영/백업할 필요가 없다는 것도 인프라 관점에서 크다.
이력 "보존"의 진짜 의미
여기서 실무자가 오해하기 쉬운 지점. Copybara가 이력을 보존한다고 해서 원본 커밋이 그대로 넘어가는 게 아니다. HN 댓글의 표현을 빌리면 이건 재작성된 커밋을 체리픽하는 방식이다. 파일 내용과 작성자 정보는 넘어가서 git blame은 잘 작동하지만, SHA는 새로 생성된다. 그래서 앞서 말한 GitOrigin-RevId로 원본과 대조가 가능하게 만들어 둔 거다.
3. 실전 설정 파일 해부: copy.bara.sky
Copybara 설정은 copy.bara.sky 파일에 Starlark(파이썬 유사 문법)로 쓴다. 원문 예시를 기반으로, 실제로 쓸 법한 형태로 풀어보자.
core.workflow(
name = "default",
origin = git.github_origin(
url = "https://github.com/google/copybara.git",
ref = "master",
),
destination = git.destination(
url = "file:///tmp/foo",
),
# 어떤 파일을 대상 저장소에 반영할지 필터링
destination_files = glob(
["third_party/copybara/**"],
exclude = ["**/README_INTERNAL.txt"],
),
authoring = authoring.pass_thru("Team <team@example.com>"),
transformations = [
# BUILD 파일 안의 경로 문자열 치환
core.replace(
before = "//java/com/google/copybara",
after = "//third_party/copybara",
paths = glob(["**/BUILD"]),
),
# 디렉터리 통째로 이동
core.move("java/com/google/copybara", "third_party/copybara"),
],
)
각 규칙이 무슨 일을 하는지 실무 관점에서 짚어보자.
- destination_files: 내보낼 파일을 화이트리스트로 관리한다. 여기서
README_INTERNAL.txt처럼 내부 전용 문서를exclude로 빼는 게 핵심이다. 실수로 confidential 문서가 공개 저장소로 새는 사고를 막는 1차 방어선이다. - core.replace: 내부 빌드 경로(
//java/com/google/...)를 외부용 경로로 바꾼다. 내부 Blaze BUILD 규칙과 외부 Bazel 규칙이 다를 때 이런 치환이 필수다. - core.move: 디렉터리 구조 자체를 재배치한다. 내부 모노레포의 깊은 경로를 공개 저장소의 루트 가까이로 끌어올릴 때 쓴다.
실제로 돌려보기
원문 흐름대로, bare 저장소를 만든 뒤 실행하는 예시다.
# 대상용 bare 저장소 준비
$ mkdir /tmp/foo && git init --bare /tmp/foo
# migrate 실행 (기본 서브커맨드)
$ copybara migrate copy.bara.sky
# 예상 출력 (버전/환경에 따라 문구는 다를 수 있음)
Running Copybara workflow default (SQUASH)
origin: git.github_origin
destination: git.destination
Fetching from https://github.com/google/copybara.git master
Transforming ...
core.replace //java/com/google/copybara -> //third_party/copybara
core.move java/com/google/copybara -> third_party/copybara
Migration 'default' finished
참고로 최신 배포 바이너리는 class file version 65.0으로 빌드돼 있어서 Java Runtime 21 이상이 필요하다(원문 명시). JDK 버전이 낮으면 실행 자체가 안 된다.
4. CI/CD에 붙이기: GitHub Actions 연동
Copybara의 진짜 가치는 "반복 이동"에 있으므로, 사람이 손으로 돌리는 게 아니라 파이프라인에 넣어야 한다. Docker 이미지로 돌리는 방식이 CI에 붙이기 편하다. 다만 원문 기준으로 Docker 실행은 아직 experimental이라는 점은 인지하고 쓰자.
# .github/workflows/copybara.yml (개념 예시)
name: sync-to-public
on:
push:
branches: [ main ]
jobs:
copybara:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build copybara image
run: docker build --rm -t copybara .
- name: Run migration
env:
COPYBARA_SUBCOMMAND: migrate
COPYBARA_CONFIG: copy.bara.sky
COPYBARA_WORKFLOW: default
run: |
docker run -it \
-v $(pwd):/usr/src/app \
-v ${HOME}/.gitconfig:/root/.gitconfig \
-v ${HOME}/.ssh:/root/.ssh \
copybara
여기서 실무 포인트. 원문에 나온 대로 Docker 컨테이너에서 실행 인자 대신 환경 변수로 동작을 제어할 수 있다(COPYBARA_SUBCOMMAND, COPYBARA_CONFIG, COPYBARA_WORKFLOW, COPYBARA_SOURCEREF, COPYBARA_OPTIONS). CI 환경에서는 인자보다 환경 변수가 관리하기 편하니 이쪽을 쓰는 게 낫다.
그리고 대상 저장소에 push하려면 SSH 자격 증명이 컨테이너 안에 있어야 한다. ~/.ssh와 SSH_AUTH_SOCK를 마운트하는 방식이 원문에 나와 있다. CI에서는 시크릿으로 deploy key를 주입하는 형태로 바꿔주면 된다.
5. 도입 시 고려사항, 흔한 함정, 대안
양방향 동기화는 되도록 피해라
이게 실사용자들이 가장 강하게 경고하는 부분이다. 단방향(모노레포 → 공개)은 깔끔하다. 그런데 양방향으로 가면 문제가 생긴다. 이유는 명확하다.
경로 재매핑, 파일 제외, 헤더 제거 같은 변환은 한 방향으로는 쉽지만 항상 깨끗하게 역변환할 수는 없다. 양쪽이 모두 갈라지면 Copybara의 기준선 추적이 헷갈리는 결과를 낸다. 의미상 같은 커밋도 변환 뒤에는 서로 다른 SHA를 만들기 때문이다.
실무 번역: core.move로 A→B 이동은 쉽지만, 반대로 B→A로 완벽 복원이 안 되는 케이스가 생긴다. 특히 양쪽이 동시에 갈라지면(diverge) 도구가 baseline을 잘못 잡아서 이상한 diff를 만든다. 외부 PR을 내부로 가져와야 한다면, HN 댓글의 여러 사례처럼 내부에서 병합한 뒤 다시 밖으로 내보내는 단방향 흐름으로 설계하는 게 안전하다.
흔한 함정: Java 런타임 버전
가장 먼저 부딪히는 벽이 JDK 버전이다. 배포 jar가 class version 65.0(Java 21)로 빌드돼 있어서, 런타임이 낮으면 이런 에러가 난다.
Error: LinkageError occurred while loading main class com.google.copybara.Main
java.lang.UnsupportedClassVersionError: com/google/copybara/Main
has been compiled by a more recent version of the Java Runtime
(class file version 65.0), this version of the Java Runtime only
recognizes class file versions up to 61.0
이건 Java 17(버전 61.0) 환경에서 Java 21용 바이너리를 돌렸을 때 나오는 전형적인 메시지다. 검색해서 여기 왔다면 답은 하나다. Java 21 이상으로 런타임을 올려라. Bazel에서 사전 빌드 jar를 쓸 때는 원문대로 .bazelrc에 다음을 추가한다.
# .bazelrc
run --java_runtime_version=remotejdk_21
순수 미러링이라면 오버킬
이건 꼭 짚고 넘어가야 한다. 변환도 제외도 없이 그냥 저장소를 통째로 복제만 하고 싶다면 Copybara는 지나치게 무겁다. HN에서도 여러 명이 같은 말을 한다. 순수 미러라면 GitLab의 push mirroring 기능이나 단순 git push --mirror가 훨씬 낫다.
Copybara가 빛나는 건 경로 재매핑, 파일 제외, 내부/외부 import 치환 같은 "변환"이 끼어들 때다. 외부 bzl을 내부 Blaze BUILD 호환으로 바꾸거나, 내부 third_party import를 외부 표준 import로 바꾸는 식의 작업이 있을 때 진가가 나온다.
대안 비교
| 도구 | 성격 | 언제 쓰나 |
|---|---|---|
| git subtree | Copybara의 원조 격. Git 본체에 병합됨 | 변환 없이 하위 디렉터리를 다른 저장소와 공유하는 단순 케이스 |
| git filter-repo | 이력 재작성/경로 필터링 특화 | 일회성 추출·정리. 반복 동기화에는 부적합 |
| Copybara | 변환+반복 이동+상태 관리 | 변환 규칙이 있는 지속적 동기화, 대규모 오픈소스 벤더링 |
| Josh | Rust 프로젝트가 커밋 동기화에 사용 | 모노레포 부분 트리를 실시간 프록시로 노출하고 싶을 때 |
참고로 HN 댓글에 나온 대로, HN에서 "일회성으로 폴더 하나를 이력 보존하며 떼어내는" 용도로 Copybara를 잘 쓴다는 사람도 많다. 새 프로젝트 구조가 완전히 바뀌어도 git blame은 살아있어서 만족한다는 후기다. 다만 이 경우도 SHA는 새로 생긴다는 점은 기억하자.
지속성 우려는?
Google 도구는 언제 archive 될지 모른다는 불안이 늘 있다(kaniko 사례가 자주 언급된다). 다만 HN의 한 의견은 "Copybara는 google3와 대규모 오픈소스 프로젝트 유지/벤더링 방식에서 꽤 핵심 도구라 종료 가능성은 정말 낮다고 본다"고 평가한다. 이건 어디까지나 커뮤니티 의견이니 참고만 하자. 반대로 Meta의 fbshipit처럼 더 이상 유지되지 않는 유사 도구도 있다는 걸 기억하면 된다.
6. 정리: 누가 언제 써야 하나
한 줄 요약: Copybara는 "변환이 끼어드는 반복적 저장소 동기화"를 선언적 설정으로 관리하는 도구다. 순수 미러링에는 오버킬, 변환+반복에는 최적.
- 쓰면 좋은 팀: 내부 모노레포에서 개발하면서 일부를 공개 저장소로 지속적으로 내보내야 하고, 그 과정에서 경로/import/파일 필터링 변환이 필요한 팀.
- 단방향으로 설계해라: 모노레포 → 공개가 기본. 외부 기여는 내부에서 병합 후 다시 내보내는 흐름으로.
- 쓰지 말아야 할 때: 변환 없는 순수 미러(→ push mirroring), 일회성 이력 정리(→ filter-repo)면 굳이 이걸 끌어올 이유가 없다.
- 먼저 확인할 것: Java 21 이상 런타임, 그리고
destination_files의 exclude 규칙이 내부 전용 파일을 제대로 막는지.
개인적으로는 5년쯤 전에 중첩 Git 저장소와 셸 스크립트로 비슷한 걸 만들어 굴려본 사람으로서, "변환 규칙을 코드로 선언"할 수 있다는 것만으로도 유지보수 부담이 확 줄어든다고 본다. 스크립트가 괴물이 되기 전에 도입 검토할 가치는 충분하다.