멀티프로젝트 Claude Code 설정 운영기 1편 - 여러 프로젝트 .claude/를 한 저장소가 소유하게 만들기
Claude Code를 여러 프로젝트에서 쓰다 보면 설정이 사방에 흩어진다. 프로젝트마다 .claude/(commands, rules, hooks, agents)가 있고, 루트엔 CLAUDE.md가 있고, 공통으로 쓰고 싶은 룰과 훅이 있다. 처음엔 손으로 복사했다. 룰 하나를 고치면 대여섯 군데를 똑같이 고쳐야 했고, 어느 프로젝트는 갱신을 빼먹어 조용히 갈라졌다. "설정을 한 곳에서 관리하고 각 프로젝트엔 링크만 걸자"는 결론은 자연스러웠다. 문제는 그다음이었다.
이 글은 개인 토이 프로젝트부터 회사 repo까지 예닐곱 개의 .claude/를 저장소 하나에서 관리하려고 만든 link.sh 작업기다. 셸 스크립트 300줄짜리지만, 진짜 어려웠던 건 코드가 아니라 "어느 쪽이 원본인가"라는 질문이었다. Claude Code든 유사한 에이전트 설정이든, 같은 걸 여러 repo에서 굴리는 사람이라면 그대로 겪는 문제다.
동작은 2026년 7월 기준 내 스크립트 구현이다.
dotfiles가 풀어주지 않는 세 가지
가장 먼저 떠오르는 건 dotfiles다. GNU Stow 같은 도구로 $HOME에 심링크를 쫙 거는 방식. 그런데 내 요구는 dotfiles의 가정과 세 군데서 어긋났다.
첫째, 타깃이 하나($HOME)가 아니라 N개 프로젝트다. 게다가 프로젝트마다 공유하는 범위가 다르다 — 공통 룰은 전부, 훅은 일부만, commands는 프로젝트 고유 + 공용이 섞인다.
둘째, 방향이 한쪽이 아니다. 내 개인 프로젝트는 설정 저장소가 원본이면 된다. 하지만 회사 repo는 그 repo 자체가 .claude/의 원본이어야 한다. 팀원이 그 repo만 클론해도 설정이 붙어 있어야 하니까. 내 저장소는 회사 repo에 "공용 부분만 얹고", 반대로 회사 repo의 상태는 "읽기용으로 미러"만 해야 한다.
셋째, 팀 공용 글로벌 설정은 아예 다른 repo(회사 공용 flow repo)에서 온다. 그건 내가 수정하는 게 아니라 읽기 전용으로 미러링해서 각 프로젝트에 얹는 대상이다.
dotfiles는 "하나의 홈, 통째 심링크, 단방향"을 가정한다. 내겐 "여러 타깃, 부분 공유, 자원마다 다른 소유 방향"이 필요했다. 그래서 얇은 스크립트를 직접 짰다.
저장소 하나를 진리원으로: registry와 apply
설계는 단순하다. 설정 저장소(여기선 ai-config라 부르자) 안에 각 프로젝트의 store를 두고, registry.json이 "프로젝트 이름 → 실제 경로 + 타입"을 매핑한다.
{
"my-toy-app": { "path": "~/dev/my-toy-app", "type": "local" },
"company-web": { "path": "~/work/company-web", "type": "external" },
"design-system": { "path": "~/work/design-system", "type": "external" }
}
./link.sh apply는 이 registry를 읽어 각 프로젝트에 심링크를 만든다. 심링크를 거는 함수는 이렇게 생겼다 — 핵심은 멱등성과, "실제 파일은 절대 덮어쓰지 않는다"는 방어다.
create_symlink() {
local source="$1" # ai-config 안의 경로 (심링크가 가리킬 대상)
local target="$2" # 프로젝트 안의 경로 (심링크를 만들 위치)
if [ -L "$target" ]; then
# 이미 올바른 심링크면 아무것도 안 함 — 몇 번을 돌려도 안전(멱등)
[ "$(readlink "$target")" = "$source" ] && return 0
rm "$target" # 엉뚱한 곳을 가리키면 교체
elif [ -e "$target" ]; then
# 실제 파일이 있으면 손대지 않는다. import로 store에 먼저 흡수할 것
echo "! $target 이미 존재(심링크 아님) — import 먼저" >&2
return 1
fi
ln -s "$source" "$target"
}
짧지만 edge case가 벌써 셋이다. 올바른 심링크(무시), 엉뚱한 심링크(교체), 실제 파일(중단). 마지막이 특히 중요하다. 프로젝트에 이미 사람이 손으로 만든 .claude/가 있으면 그걸 날려선 안 되고, 먼저 import로 store에 흡수한 뒤 심링크로 바꿔야 한다. 이 방어 하나가 없으면 apply 한 번에 남의 설정을 덮어쓴다.
local과 external, 두 방향의 소유권
타입이 두 개인 이유가 여기 있다. 같은 apply라도 방향이 정반대다.
| 타입 | 진리원 | apply가 하는 일 |
|---|---|---|
| local | 설정 저장소 | .claude/를 통째로 저장소 store에 심링크. 저장소가 원본 |
| external | 프로젝트 repo | 프로젝트 실파일은 그대로 두고, 공용 리소스만 심링크로 주입 + 프로젝트 상태를 store로 미러(읽기용) |
local은 쉽다. 저장소가 원본이니 .claude/를 통째 심링크한다. external이 까다롭다. 프로젝트가 원본이므로 그 파일을 건드리면 안 되고, 공용 룰·훅만 심링크로 "얹되", 팀 공용 글로벌에 이미 같은 이름이 있으면 그건 건너뛴다(중복 주입 방지):
# external 프로젝트: 팀 공용 global에 같은 이름이 있으면
# 개인 공용(shared) 주입을 건너뛴다 — 팀 글로벌이 우선 소유
if [ "$project_type" = "external" ] \
&& [ -f "$GLOBAL_DIR/$resource_type/$file_name" ]; then
continue
fi
여기까지가 1~2주면 도달하는 그림이다. 개인 프로젝트는 저장소→프로젝트, 회사 프로젝트는 프로젝트→저장소, 팀 글로벌은 회사 flow repo→저장소로, 화살표(=진리원의 방향)가 자원마다 다르다.

되돌아보면, 위험은 충돌이 아니라 "누가 진리원인지"였다
심링크로 두 곳을 잇는 동기화를 짜면 누구나 파일 충돌을 걱정한다. 정작 나를 문 건 충돌이 아니었다.
사고는 이랬다. external 프로젝트 중 하나가 공개 repo라 .claude/ 자체를 gitignore하고 있었다. 즉 그 프로젝트의 planning docs(스펙, 노트)는 프로젝트 git으로 동기화되지 않는다. 그래서 나는 그 docs를 "설정 저장소가 소유"하게 하고 프로젝트엔 심링크만 뒀다. 여기까진 좋았다. 문제는 다른 기기였다. 저장소를 최신으로 pull하지 않은 기기에서 apply를 돌리자, 미러 단계가 "프로젝트엔 없고 저장소엔 committed인" 파일들을 "프로젝트 기준으로 지워야 한다"고 판단해 조용히 날렸다. commit해둔 문서가 사라진 것이다.
교훈은 한 문장으로 남았다. 심링크 양방향 동기화의 진짜 위험은 충돌이 아니라, 어느 쪽이 진리원인지 모호해지는 순간이다. 그래서 세 겹의 가드를 넣었다.
먼저 소유를 registry에서 명시하게 했다. .claude/(또는 docs만)를 저장소 단일 소유로 두는 opt-in 필드다.
"client-app": {
"path": "~/work/client-app",
"type": "external",
"claude": "ai-config" // .claude/ 전체를 저장소가 소유
}
claude: "ai-config"가 켜지면 미러는 자동으로 skip된다. store가 원본인데 프로젝트→store 미러를 돌리는 건 말이 안 되기 때문이다. 신규 파일은 store에 쓰고 commit·push한 뒤, 다른 기기에서 pull·apply한다. 소유의 방향을 사람이 데이터로 적어두면, 코드가 그 방향을 어기지 않는다.
둘째, apply 진입에서 저장소가 origin보다 뒤처졌는지 검사한다(stale-repo 가드). 뒤처졌으면 삭제를 동반하는 cleanup·store 재작성 단계를 통째로 건너뛰고, 주입만 진행한다. 사고의 근본 원인 — "pull 안 한 채 apply" — 을 코드가 스스로 막는 것이다.
behind="$(git -C "$STORE" rev-list --count 'HEAD..@{u}')"
if [ "${behind:-0}" -gt 0 ]; then
STALE_REPO=1 # cleanup 함수들이 이 플래그를 보고 자기 검열한다
echo "! 저장소가 origin 대비 ${behind} commit 뒤처짐 — cleanup skip(주입만 진행)"
fi
셋째, 미러가 실제로 파일을 지우기 직전에 한 번 더 막는다(손실 방지 가드). "저장소 git에 committed인데 프로젝트엔 없는" 파일을 지우려 하면, 그 하위 디렉토리 전체를 보류하고 목록을 출력한다. 의도된 삭제라면 --force로만 강행한다.
# store가 진리원인 subdir에서만 작동. committed인데 프로젝트엔 없는 파일이
# 삭제 대상이면 보류하고 목록을 출력 → 사람이 확인. 의도된 삭제면 --force
while IFS= read -r tracked; do
proj_equiv="$project_path/.claude/${tracked#*/.claude/}"
[ -e "$proj_equiv" ] || lost+=("$tracked")
done < <(git -C "$STORE" ls-files -- "$store/.claude/$subdir")
if [ ${#lost[@]} -gt 0 ] && [ "$force" != "force" ]; then
printf ' 보류: %s\n' "${lost[@]}" >&2
return 2 # 이 subdir는 이번 미러에서 통째 보류
fi
세 가드의 공통점은, 방어 대상이 "충돌"이 아니라 "정보 손실"이라는 것이다. 심링크로 두 저장소를 잇는 순간, 코드는 "사용자가 의도한 삭제"와 "stale 때문에 벌어진 사고"를 구분하지 못한다. 그래서 애매하면 멈추게 했다. 이게 이 스크립트가 배운 가장 비싼 규칙이다.
닫으며
여러 프로젝트의 에이전트 설정을 한 곳에서 관리하는 문제의 핵심 축은 "심링크를 어떻게 거느냐"가 아니라 "자원마다 누가 진리원인가"다. local/external로 방향을 가르고, 소유를 registry에 명시하고, 모호할 땐 삭제 대신 멈추게 하면, dotfiles가 못 풀던 부분 공유가 풀린다.
지금 할 수 있는 첫걸음은 이거다. 당신의 .claude/를 열어 "팀과 공유할 것 / 나만 쓸 것 / 원본이 딴 데 있는 것" 세 무더기로 갈라보라. 소유 방향이 갈리는 그 지점이 곧 당신 스크립트의 타입 경계가 된다.
참고
- GNU Stow — 전통적 dotfiles 심링크 관리 도구: https://www.gnu.org/software/stow/