모노레포에 대한 개인적인 경험과, 여러 GitHub 저장소를 로컬에서 한 번에 관리·검색하기 위한 간단한 스크립트 ‘monsterepo’ 소개
By Richard Crowley 2025-11-30
나는 모노레포(monorepo)라는 아이디어를 좋아한다. 아마도 그건 약간의 미스터리한 매력 때문일 것이다. 나는 단 한 번도 진짜 모노리식 소스 코드 저장소 하나만을 쓰는 회사에서 일해 본 적이 없다. 물론 저장소 개수가 적은 회사에서도, 크고 바쁜 저장소를 가진 회사에서도 일해 봤지만, 말 그대로 하나의 거대한 단일 소스 코드 저장소만 있는 회사는 아니었다. 나는 Piper와 구글, 페이스북의 대형 모노레포에 얽힌 여러 전설들에 매료돼 있다. Slack의 webapp 저장소는 크고 바쁘긴 했지만, 인프라 도구와 모바일 앱을 당신의 세계관에서 제외했을 때에만 모놀리식이라고 볼 수 있었다.
집에는 src라는 개인용 모노레포가 있고, 내 모든 머신에서 ~/src에 클론해 두었다. 회사에는 planetcrowley라는 개인용 모노레포가 있어서, 내가 떠올리는 가장 똑똑한 아이디어와 가장 멍청한 아이디어, TODO 리스트, 메모, 프로토타입을 전부 여기에 커밋한다. 바이너리만 빼고는 뭐든지 커밋하고, 푸시하고, 백업에 대해서는 스트레스 받지 않는다. 다만 이건 장난감에 가깝다. Piper 같은 진짜 모노레포와 같은 종류라기보다는, 이름을 붙이는 수고를 피하기 위한 꼼수에 더 가깝다.
나처럼 모노레포에 호기심을 가진 사람들은 피하라고 경고하는 글을 보면, 대부분은 결국 두 가지 요지로 요약된다.
이런 도구들은 나에게 무척 흥미롭다. 나는 ‘차별화되지 않은 무거운 작업(undifferentiated heavy lifting)은 아웃소싱해야 한다’고 믿는 동시에, 다른 사람들이 떠안고 있는 그런 차별화되지 않은 무거운 작업을 나서서 떠맡는 것을 정말 좋아하는 사람이기도 하다. 하지만 코드베이스가 모노레포 특유의 문제들을 해결해야 할 만큼 충분히 커지면서도, 그 문제를 아웃소싱하고 싶어 할 만큼 충분히 작은 회사가 소유하고 있는 경우는 드물다. (그래서 Piper-as-a-service에는 큰 시장이 없다.)
집과 회사에서 내가 다루는 코드베이스의 소박한 규모에서는, 조율해야 할 트래픽 정체가 있지도 않고, 가끔 git filter-branch를 쓰는 것 말고는 서브트리 단위로 작업하지도 않고, 기이하게 꼬인 의존성 그래프도 없고, CI/CD의 비효율 때문에 발목 잡힌다는 느낌도 없다. 사실, 내가 상상 속의 모노레포에서 가장 좋아할 것 같은 점을, 내 여러 저장소 환경으로 가져오기 위해 갖고 있는 소원 목록에는 항목이 딱 하나뿐이다. 전체 코드베이스를 상대로 grep을 쳐 보고 싶다는 것.
그리고 알고 보니, 약간의 손품과, 현대 노트북에 무리 없이 들어가는 코드베이스만 있다면, 이건 별로 어려운 일이 아니다. 보라, monsterepo. 나 자신을 위해 이 문제를 해결한 내 해답이다.
sh#!/bin/sh set -e usage() { printf "Usage: %s [-e \e[4mexclude\e[0m,\e[4m...\e[0m] [-x] \e[4morganization-or-username\e[0m\n" "$(basename "$0")" >&2 exit "$1" } EXCLUDE="" X="" while [ "$#" -gt 0 ] do case "$1" in "-e"|"--exclude") EXCLUDE="$2" shift 2;; "-e"*) EXCLUDE="$(echo "$1" | cut -c"3-")" shift;; "--exclude="*) EXCLUDE="$(echo "$1" | cut -d"=" -f"2-")" shift;; "-x") X="-x" shift;; "-h"|"--help") usage 0;; *) break;; esac done ORG_OR_USER="$1" shift if [ -z "$ORG_OR_USER" -o "$1" ] then usage 1 fi TMP="$(mktemp -d)" trap "rm -f -r \"$TMP\"" EXIT echo "$EXCLUDE" | tr "," "\n" >"$TMP/exclude.txt" COUNT=1000 LIMIT=1000 while true do gh repo ls "$ORG_OR_USER" --json "name" --limit "$LIMIT" --no-archived --source | jq -e -r '.[].name' >"$TMP/repos.txt" COUNT="$(wc -l <"$TMP/repos.txt" | awk '{print $1}')" if [ "$COUNT" -lt "$LIMIT" ] then break fi LIMIT="$((LIMIT * 10))" done while read REPO do if grep -q "^$REPO$" "$TMP/exclude.txt" then continue fi if [ -d "$REPO" ] then sh $X -c "git -C \"$REPO\" remote update" if sh $X -c "git -C \"$REPO\" diff --exit-code --no-patch" then sh $X -c "git -C \"$REPO\" pull \"origin\" \"$(git -C "$REPO" branch --show-current)\"" fi else sh $X -c "git clone \"https://github.com/$ORG_OR_USER/$REPO.git\"" fi done <"$TMP/repos.txt"
이 프로그램은 정말 멍청한 프로그램이다. 나는 이 프로그램의 멍청함, 복잡성이 일회용이라는 사실을 증명하기 위해 이 글 안에 그대로 박아 넣고 있다. 아주 사소한 물건이다. 그럼에도 불구하고, 이것은 집에서든 회사에서든 내가 모노레포에서 진짜로 원하던 걸 거의 다 제공해 주고 있다.
나는 여러 서비스, 혹은 클라이언트와 그 서버에 걸친 원자적(atomic) 변경을 만들 수 없다. 로컬에 수십, 수백 개의 저장소를 클론해 둔다고 해도, 여러 서비스나 클라이언트/서버 코드베이스를 한 번에 원자적으로 바꾸는 일은 여전히 불가능하다. 그리고 끊임없이 이런 사실을 상기할 필요는 없지만, 소스 코드 관리와 변경 관리가 구조적으로 서로 정렬(aligned)되어 있어도 나쁠 건 없다.
하지만 나는 전체 코드베이스를 상대로 grep을 칠 수 있다. 적어도 우리가 미친 듯이 커질 때까지는. 이 정도면 이긴 셈이다.
monsterepo에 대해 한 가지 더 확장 아이디어가 있다. 나는 여러 저장소에 걸친 변경을 로컬에서 함께 개발할 수 있게 해 주는 Go 워크스페이스의 큰 팬이다. 집에서 Mergician을 개발할 때나, 회사에서 Protocol Buffers와 그것을 주고받는 서비스들을 함께 손볼 때 자주 쓴다. monsterepo가 go.mod 파일을 발견할 때마다 자동으로 go work use ../$REPO를 실행해서, 로컬 개발을 위한 Go 워크스페이스를 자동으로 유지해 주도록 만들 수 있을 것 같다. 다만 그 의존성들 중 하나라도 메인 브랜치가 불안정하거나 너무 빠르게 움직인다면, 이게 성가신 일이 될 수도 있다.
또 한 가지, 봄에 회사에서 하기로 연필로 얇게 적어 둔(=가볍게 잡아 둔) 재해 복구 훈련을 염두에 두고 말하자면, 전체 코드베이스의 최신 로컬 사본을 가지고 있는 것은, GitHub에 재해가 발생하는 동시에 소스 코드 저장소 백업이 깨져 있는 상황에 대비한 현명한 보험이기도 하다.