AI 엔지니어링

심링크에서 복사로 후퇴한 세 번의 작업기

Kir93 2026. 9. 22. 07:59
728x90
반응형
병렬 git worktree, 리뷰 스코프, POSIX 설정 파일 교체. 세 번 다 우아한 참조를 택했다가 물러섰다.

 

Claude Code 세션을 두 개 띄우고 나서였다. 하나는 폼 컴포넌트를 뜯었고 다른 하나는 급한 버그를 잡았다. git worktree로 디렉터리를 갈라놨으니 서로 안 건드릴 줄 알았는데 20분쯤 지나 한쪽 dev server가 엉뚱한 포트를 물고 죽었다. 범인은 두 worktree가 함께 바라보던 .env.local 심링크였다.

그 뒤로 몇 달간 개인 커맨드 몇 개를 만들고 고쳤다. 병렬 worktree를 관리하는 커맨드, 변경사항을 리뷰·검증하는 커맨드, Codex를 낮은 reasoning effort로 잠깐 눌러 부르는 래퍼. 도메인은 제각각인데 회고해 보니 세 번 다 같은 모양으로 무너졌다. 처음엔 참조를 택했다. 운영에서 깨졌고 복사로 물러섰다. AI 에이전트를 여러 개 동시에 돌리기 시작한 사람이라면 셋 다 곧 만나게 될 문제다. 아래는 2026년 7월 기준으로 정착한 지점과, 거기까지 간 경로다.

심링크는 우아했고 그 우아함이 문제였다

worktree를 새로 만들면 .env, 로컬 인증서, 각종 로컬 설정 같은 gitignored 파일이 없어서 프로젝트가 안 뜬다. 처음 택한 방법은 심링크였다. 원본 하나만 고치면 모든 worktree에 반영되고 디스크도 안 먹는다. 우아했다.

우아함이 무너진 건 정확히 worktree를 쓰는 이유 때문이었다. worktree는 격리된 작업 공간인데 심링크가 그 격리를 뚫는다. A 세션이 포트를 바꾸면 B가 죽는다. 더 나쁜 경우도 있다. worktree를 정리할 때 링크를 따라간 삭제가 원본을 지운다.

그래서 규칙을 하나 박았다. gitignored 경로는 링크하지 않는다. 복사한다. 그런데 복사로 바꾸자 심링크 시절엔 없던 함정이 셋 나왔다.

먼저 제외 필터. 루트에 앵커링 하면 모노레포에서 뚫린다. ^node_modules/apps/*/node_modules/를 못 잡는다. 어느 모노레포에서 이 구멍으로 461MB가 딸려 들어왔다. 패턴은 경로 세그먼트에 앵커링 한다.

다음은 cp -rL-L이다. 심링크를 따라간다. 후보 중에 저장소 밖을 가리키는 심링크가 있으면 외부 트리를 통째로 끌어온다. 복사하기 전에 readlink -f로 타깃이 저장소 루트 안인지 확인하고 밖이면 건너뛰되 목록에 남긴다. 조용히 빠지면 나중에 "왜 안 뜨지"로 돌아온다.

마지막은 .env*.pem이다. 아무 확인 없이 복사된다. 같은 기계의 형제 디렉터리로 옮기는 것이라 유출은 아니다. 물으면 매번 "예"만 누르게 되니 묻지 않기로 했다. 대신 무엇이 복사됐는지는 반드시 출력한다. 여기에 함정이 하나 더 있는데 후보가 디렉터리면 파일명 패턴이 그 안을 못 본다. certificates/ 하나로 뭉쳐 출력하면 그 안의 localhost-key.pem은 목록에 안 뜬다. 디렉터리 후보는 재귀로 스캔해서 개별 항목으로 펼친다.

# gitignored 후보 탐색 — 제외 패턴은 (^|/)...(/|$)로 세그먼트에 앵커링한다.
git -C "$MAIN_ROOT" ls-files --others --ignored --exclude-standard --directory 2>/dev/null \
  | grep -vE '(^|/)(node_modules|\.next|\.turbo|\.cache|coverage|dist|build|\.vercel)(/|$)' \
  | sort -u \
  | awk 'prev && index($0, prev) == 1 { next } /\/$/ { prev = $0 } { print }'
  # 마지막 awk: 무시된 디렉터리가 디렉터리 엔트리와 하위 파일로 두 번 잡히는 걸 막는다.

# 복사 전 심링크 경계 검사 — 타깃이 저장소 밖이면 건너뛰고 목록에 남긴다.
for p in $CANDIDATES; do
  if [[ -L "$MAIN_ROOT/$p" ]]; then
    target="$(readlink -f "$MAIN_ROOT/$p" || true)"   # 해석 실패도 '밖'으로 취급
    [[ -n "$target" && "$target" == "$MAIN_ROOT"/* ]] \
      || { echo "skipped (external symlink → ${target:-unresolved}): $p"; continue; }
  fi
  cp -rL "$MAIN_ROOT/$p" "$WORKTREE/$p"
done

.claude/ 설정 자체는 지금도 심링크다. 뒤에서 다시 이야기한다.

diff는 작업 트리가 아니다

리뷰 자동화는 쉬워 보였다. diff를 뽑아서 넘기면 되니까. 실제로 첫 버전은 git diff HEAD 한 줄이 전부였다.

무너진 순간은 시시했다. 에이전트에게 새 모듈을 짜게 하고 리뷰를 돌렸는데 "변경 없음"이 나왔다. 새로 만든 파일은 index에 없으니 diff에도 없다. 방금 생성된 코드 전부가, 그러니까 리뷰가 가장 필요한 코드가 통째로 스코프 밖이었다.

"리뷰하려면 먼저 스테이징 하세요"라고 안내할 수도 있었다. 그건 도구가 자기 구현 사정을 사용자에게 떠넘기는 셈이다. untracked 텍스트 파일을 기본 스코프에 넣기로 했다. 라인 단위 근거가 필요하면 git diff --no-index -- /dev/null <path>로 합성 diff를 만든다.

git ls-files --others --exclude-standard   # untracked 발견
git diff --no-index -- /dev/null src/new-module.ts   # 라인 근거가 필요할 때

그러자 반대편 함정이 열렸다. 스크린샷과 동영상까지 읽으려 든다. 그래서 바이너리 미디어는 내용 검증에서 빼되 스코프 안의 코드가 그 에셋을 참조하면 경로·존재 여부·크기·번들 리스크만 확인하고 단독으로 놓인 미디어는 스코프 밖으로 보고한다. 예외를 두는 대신 예외를 눈에 보이게 남기는 쪽이다.

같은 커맨드에서 임시 파일로도 한 번 데었다. Codex에게 리뷰 문서를 적대적으로 비판시키려고 문서를 파일로 넘기는 구조인데 문서 저장 모드에서는 초안을 먼저 디스크에 쓰고 Codex 결과를 붙여 다시 쓰는 순서였다. 중간에 끊으면 반쪽짜리 문서가 남는다. 두 군데를 고쳤다. 초안 선저장을 없애고 결과가 다 모인 뒤 한 번만 쓴다. 그리고 Codex에 넘기는 입력 파일은 만들자마자 trap 'rm -f "$TMPFILE"' EXIT INT TERM을 건다. 실전에서 제일 흔한 종료 경로는 성공도 실패도 아닌 Ctrl-C였다.

mv는 심링크를 끊는다

세 번째는 설정 파일이다. 자동 비판 호출은 지연이 곧 비용이라 Codex를 medium effort로 눌러 부르고 싶었다. 방법은 단순하다. ~/.codex/config.toml을 잠깐 바꿔치기하고 명령이 끝나면 되돌린다.

원자적 교체 공식대로 임시 파일에 쓰고 mv로 덮었다. 그리고 dotfiles가 깨졌다. ~/.codex/config.toml은 dotfiles 저장소를 가리키는 심링크였는데 mv는 디렉터리 엔트리를 갈아 끼우는 연산이라 심링크가 사라지고 그 자리에 실파일이 생긴다. 되돌려도 실파일이다. 그날 이후로 그 기계의 codex 설정만 조용히 dotfiles 관리 밖으로 나가 있었다.

cp는 대상을 열어서 쓴다. 대상이 심링크면 링크를 따라가 원본에 기록하므로 링크가 살아남는다. 대신 원자성을 잃는다. 쓰는 도중에 읽으면 반쪽 파일을 본다.

잃은 원자성은 다른 데서 되샀다. 락과 trap이다. flock은 macOS 기본 설치에 없어서 쓸 수 없었고 대신 mkdir을 썼다. 디렉터리 생성은 POSIX에서 원자적이라 "이미 있음" 실패가 곧 "남이 쥐고 있음"이다. 그리고 trap을 락을 잡은 직후에 건다. 처음엔 준비를 다 끝내고 걸었는데 그러면 mktempawk가 실패했을 때 락 디렉터리가 그대로 남아 다음 호출이 15분 타임아웃까지 서 있게 된다.

 

정작 제일 오래 잡아먹은 건 락도 trap도 아니고 awk였다. TOML에서 top-level 키가 없을 때 파일 끝에 붙이면 그 키는 top-level이 아니라 마지막 [table] 소속이 된다. 없는 키는 첫 테이블 앞에 삽입한다.

아래가 셋을 합친 최소 버전이다. 그대로 받아서 쓰면 된다.

#!/usr/bin/env bash
# temp-config.sh — TOML 설정의 키 하나를 잠시 바꾸고 명령을 실행한 뒤 되돌린다.
# 사용: ./temp-config.sh ~/.codex/config.toml model_reasoning_effort medium -- codex exec "..."
set -euo pipefail

CONFIG="$1"; KEY="$2"; VALUE="$3"; shift 3
[[ "${1:-}" == "--" ]] && shift

LOCKDIR="${CONFIG}.lock.d"
TIMEOUT="${TEMP_CONFIG_LOCK_TIMEOUT:-900}"

# 바꿀 설정이 아예 없으면 락도 백업도 필요 없다. 그대로 실행하고 끝낸다.
[[ -f "$CONFIG" ]] || exec "$@"

# 1) 락 획득 — mkdir은 POSIX에서 원자적이다. flock은 macOS 기본 설치에 없다.
waited=0
until mkdir "$LOCKDIR" 2>/dev/null; do
  # 부모 디렉터리가 없거나 쓸 수 없으면 영원히 못 잡는다. 대기 전에 걸러낸다.
  [[ -d "${CONFIG%/*}" && -w "${CONFIG%/*}" ]] || { echo "lock 생성 불가: $LOCKDIR" >&2; exit 71; }
  (( waited >= TIMEOUT )) && { echo "lock 대기 ${TIMEOUT}s 초과" >&2; exit 75; }
  sleep 1; waited=$(( waited + 1 ))
done

BACKUP=""

# 2) trap은 락을 잡은 "직후"에 건다. 아래 어느 줄에서 죽어도 백업과 락이 정리된다.
cleanup() {
  local code=$?
  trap - EXIT INT TERM
  if [[ -n "$BACKUP" && -f "$BACKUP" ]]; then
    # 복원도 cp다. mv로 되돌리면 심링크였던 원래 상태를 복구하지 못한다.
    if cp "$BACKUP" "$CONFIG"; then rm -f "$BACKUP"
    else echo "복원 실패 — 백업 보존됨: cp '$BACKUP' '$CONFIG'" >&2; fi
  fi
  rm -f "${CONFIG}.tmp"
  rmdir "$LOCKDIR" 2>/dev/null || true
  exit "$code"
}
trap cleanup EXIT INT TERM

# 3) 백업
BACKUP="$(mktemp -t cfg.XXXXXX)" || exit 73
cp "$CONFIG" "$BACKUP" || exit 74

# 4) 키 재작성. 키가 없으면 "첫 테이블 앞"에 넣어야 top-level로 남는다.
awk -v k="$KEY" -v v="$VALUE" '
  /^[[:space:]]*\[/ {
    if (!found && !inserted) { print k " = \"" v "\""; inserted = 1 }
    seen = 1; print; next
  }
  !seen && $0 ~ "^[[:space:]]*" k "[[:space:]]*=" { print k " = \"" v "\""; found = 1; next }
  { print }
  END { if (!found && !inserted) print k " = \"" v "\"" }
' "$CONFIG" > "${CONFIG}.tmp"

# 5) mv가 아니라 cp. 대상이 심링크면 링크를 유지한 채 링크가 가리키는 파일에 쓴다.
cp "${CONFIG}.tmp" "$CONFIG"
rm -f "${CONFIG}.tmp"

# 6) 실행. 래핑한 명령의 종료 코드는 trap이 그대로 물려준다.
"$@"

심링크 대상, 기존 키 재작성, 키 부재 삽입, 명령 실패(종료 코드 보존), Ctrl-C 네 경로를 돌려봤고 모두 원본 복원과 락 정리를 확인했다. 다만 이 래퍼에는 확인하지 못한 가정이 하나 들어 있다. 이미 떠 있는 codex App Server가 시작 시점의 설정을 캐시하고 매 턴 다시 읽지 않는다는 가정이다. 문서로는 그렇게 읽히지만 직접 검증하지 못했고 그래서 소스와 문서 양쪽에 unverified로 적어뒀다. 자동 호출이 도는 동안 대화형 세션의 effort가 같이 떨어지는 게 관찰되면 그 가정이 틀렸다. 그러면 래퍼를 걷어내고 플러그인 쪽에서 effort를 인자로 받게 고치면 된다.

세 번의 후퇴가 같은 말을 하고 있었다

심링크, git diff, mv. 셋 다 참조다. 실체를 옮기지 않고 자리만 가리킨다. 그래서 싸고 우아하다. 그리고 셋 다 소유자가 여럿이 되는 순간 조용히 틀린다. worktree 두 개가 한 파일을 가리킬 때, index와 작업 트리가 서로 다른 진실을 말할 때, dotfiles와 로컬 도구가 같은 경로를 두고 다툴 때.

복사는 비싸고 투박하다. 디스크를 먹고 원자성을 잃는다. 동기화도 손으로 한다. 대신 경계가 눈에 보인다. 이 파일은 이 worktree 것, 저 파일은 저 worktree 것.

그렇다고 전부 복사가 답은 아니다. 앞에서 미뤄둔 .claude/는 지금도 심링크로 남겨뒀다. 규칙과 커맨드는 진리원이 하나여야 하고 worktree에서 고친 규칙이 저장소로 흘러가기 때문이다. 결국 기준은 우아함이 아니라 소유권이었다. 이 경로의 소유자가 하나면 참조해도 되고, 여럿이면 복사해야 한다.

세 번의 후퇴에서 남은 건 세 가지다. 참조는 소유자가 하나일 때만 안전하다. 병렬 실행이 끼어드는 순간 복사가 맞다. mv는 원자적이지만 심링크를 끊는다. 링크를 지켜야 하면 cp로 쓰고 원자성은 락과 trap으로 따로 산다. git diff는 작업 트리가 아니며, untracked를 1급으로 넣지 않으면 방금 생성된 코드가 검증에서 통째로 빠진다.

지금 쓰는 자동화 스크립트에서 ln -smv를 전부 grep 해보라. 각 줄마다 "이 경로의 소유자가 하나인가"를 묻고 아니라면 복사로 바꿔라.

반응형