당사(주식회사 Joinclass)에서는 Claude Code를 launchd에서 17개 이상의 작업(job)으로 구동하고 있습니다. 부서별 에이전트가 여유 시간이 생길 때 백로그(backlog) 항목을 하나씩 처리하고, CEO 대리가 승인 큐를 판단하며, 출판부는 신간을 작성하는 식입니다.
처리할 작업의 수가 늘어나면 반드시 발생하는 문제가 바로 '같은 파일에 두 개의 프로세스가 동시에 쓰기' 문제입니다. 필자의 환경에서는 다음과 같은 현상이 실제로 발생했습니다.
- 하루 동안 여러 기사 및 서적을 동시에 공개하면서 배포(deploy)가 실패함
- 두 개의 작업이 동시에
git commit을 시도하다가, 한쪽이 index.lock 때문에 중단됨 - 같은 부서의 작업이 이중으로 구동되어 동일한 백로그 항목을 2번 처리함
settings.json이나 메모리 파일(MEMORY.md 및 그 하위 폴더) 역시 비슷한 구조적 취약점을 가지고 있습니다. 본 기사에서는 당사에서 실제로 운영 중인 **mkdir 락(lock)**을 기반으로, 설정 파일과 메모리를 손상시키지 않고 다루는 방법을 단계별로 소개합니다.
settings.json의 업데이트는 대부분 '읽기 → 재작성 → 쓰기'의 3단계 과정을 거칩니다.
세션 A: 읽기(allow=[a]) → 쓰기(allow=[a,x])
세션 B: 읽기(allow=[a]) → 쓰기(allow=[a,y])
결과: allow=[a,y] ← A의 x가 사라짐
게다가 > 리다이렉트 방식으로 직접 쓰면, 쓰기 도중의 아주 짧은 순간에는 파일이 비어 있거나 부분적인 상태가 됩니다. 이때 다른 프로세스가 이 파일을 읽으면 JSON 파싱에 실패합니다. Claude Code는 손상된 settings.json을 읽으면 설정을 올바르게 반영할 수 없게 되므로, 권한이나 훅(hook) 등이 조용히 작동하지 않게 됩니다.
대책은 두 가지입니다.
- 배타적 접근 (Exclusion): 읽기부터 쓰기까지의 과정을 하나의 프로세스로 한정함 -
원자적 대체 (Atomic Replacement): 임시 파일에 작성한 후 mv 명령으로 교체함
flock은 macOS에 기본적으로 포함되어 있지 않습니다. 당사에서는 '존재하지 않으면 생성'을 원자적으로 수행하는 성질을 가진 mkdir을 활용하고 있습니다. 다음은 CEO 대리 작업인 ceo-proxy.sh의 시작 부분 그대로입니다.
lock="/tmp/one-ceo-ceo-proxy.lock"
mkdir "$lock" 2>/dev/null || exit 0
trap 'rmdir "$lock"' EXIT
이 3줄로 '동시에 오직 하나만' 실행됨이 보장됩니다. 두 번째 프로세스는 mkdir에 실패하여 즉시 종료합니다.
시간을 들여서라도 실행하고 싶은 처리(git commit 등)는 디스패처인 idle-dispatcher.sh에서 다음과 같이 재시도(retry)합니다.
for _ in $(seq 1 60); do mkdir /tmp/one-ceo-commit.lock 2>/dev/null && break; sleep 2; done
git_auto_commit "auto: 여유 시간이 생긴 부서의 작업 ($d)"
rmdir /tmp/one-ceo-commit.lock 2>/dev/null
부서별 작업을 병렬로 돌리되, 커밋하는 순간에만 일렬로 순서를 맞추는 방식입니다. 이것을 적용한 후 index.lock으로 인한 커밋 실패는 멈췄습니다.
락의 유무가 디렉토리이기 때문에, '현재 몇 개가 실행 중인지'도 셀 수 있습니다.
running=$(find /private/tmp -maxdepth 1 -type d -name 'one-ceo-idle-*.lock' | wc -l | tr -d ' ')
slots=$(( MAX_RUNNING - running ))
[ "$slots" -le 0 ] && exit 0
동시 실행 상한 관리까지 같은 메커니즘으로 해결할 수 있다는 것이 mkdir 락의 장점입니다.
위의 락을 settings.json 업데이트에 적용하면 다음과 같습니다. jq를 사용하여 변경하고 임시 파일을 통해 대체합니다.
#!/bin/bash
# safe-settings.sh — settings.json을 배타적 + 원자적으로 업데이트하는 스크립트
# 사용법: safe-settings.sh '.permissions.allow += [
- **잠금(Lock) 이름에 경로의 해시를 포함**: 프로젝트의 settings.json과 사용자의 ~/.claude/settings.json을 별도의 잠금으로 분리할 수 있습니다.
- **임시 파일은 같은 디렉토리에 배치**: `/tmp`에 두면 다른 볼륨이 될 수 있어, `mv`가 복사+삭제 방식으로 이루어져 원자적(Atomic)이지 않게 됩니다.
- **덮어쓰기 전에**: 손상된 JSON은 절대 본체로 넣지 않습니다. `jq empty`로 검증합니다.
병렬로 20회 쓰기를 수행하며, 단 하나도 누락되지 않았는지 확인합니다.
```bash
echo '{"permissions":{"allow":[]}}' > /tmp/test-settings.json
for i in $(seq 1 20); do
SETTINGS=/tmp/test-settings.json ./safe-settings.sh ".permissions.allow += ["Bash(echo $i)"]" &
...
비교로, 잠금을 해제하고 jq ... > tmp && mv tmp settings.json만으로 같은 작업을 수행하면, 필자의 Mac에서는 20건 중 열댓 건밖에 남아있지 않았습니다. 원자적 치환(Atomic Replacement)만으로는 '손상되지 않음'은 지킬 수 있어도 '사라지지 않음'은 지킬 수 없다는 것을 잘 알게 됩니다.
메모리(~/.claude/projects/<project>/memory/)는 당사에서는 다음 구조로 구성하고 있습니다.
memory/
├── MEMORY.md # 인덱스. 1줄 = 1개의 메모리 포인터만 기록
├── feedback_zenn_rate_limit.md # 1파일 = 하나의 사실(Fact)
...
하나의 큰 파일로 통합할 경우, 병렬 세션들이 각기 다른 사실을 추가하는 것만으로도 덮어쓰기 경쟁이 벌어집니다. 1파일=1사실이라면, 새로운 생성끼리는 충돌하지 않습니다. 충돌할 수 있는 것은 인덱스인 MEMORY.md에 내용을 추가하는 경우뿐입니다.
인덱스에 추가하는 작업은 '한 줄을 추가'하는 것이므로, 덮어쓰기가 아닌 추가(Append)로 처리하고 상단의 잠금으로 감쌉니다.
add_memory_index() {
local line="$1" index="$HOME/.claude/projects/$PROJECT/memory/MEMORY.md"
local lock="/tmp/claude-memory-index.lock"
...
}
grep -qxF를 사용하여 동일한 줄의 중복 등록도 방지합니다.
1. 프로세스가 kill -9 될 경우 잠금이 남는 문제
trap ... EXIT은 SIGKILL에는 작동하지 않습니다. 당사에서는 작업 감시(job-watch.sh)가 5분마다 실패를 감지하고 있지만, 잠금만 남아있는 상황에 대비하여 오래된 잠금을 삭제하는 처리를 넣어두는 것이 안전합니다.
find /private/tmp -maxdepth 1 -type d -name 'one-ceo-*.lock' -mmin +30 -exec rmdir {} \;
2. 대화 중 Claude Code 본체가 작성하는 부분은 잠금 외부 처리
/permissions나 '항상 허용(Always Allow)' 작업으로 인해 Claude Code 자신이 settings.json을 작성할 때, 이 잠금은 인식하지 못합니다. 당사에서는 자동화된 작업이 settings.json(팀 공유)에 쓰지 않고, 작업 전용 권한은 claude -p --allowedTools "Read,Grep,Glob,Edit,Write"와 같이 **명령줄 인자(Command Line Argument)**로 전달하는 방침을 세웠습니다. 파일에 쓰지 않으면 충돌이 발생하지 않습니다.
3. 임시 파일의 위치 지정
-p 실행 에이전트에게 /tmp에서 작업을 시킨 결과, rm이 거부되어 임시 파일이 쌓인 적이 있습니다. 현재는 idle-work.sh로 부서별 작업 디렉토리를 만들고, 정리(Cleanup)는 작업 측의 trap으로 처리하고 있습니다.
IDLE_TMP="$PROJECT_ROOT/.company/tmp/$label"
mkdir -p "$IDLE_TMP"
trap 'rm -rf "$IDLE_TMP"; rmdir "$lock"' EXIT
- '읽기 → 쓰기' 사이의 간격을
mkdir 잠금으로 1개 프로세스로 제한하고, 쓰기는 동일 디렉토리의 임시 파일 + jq empty
검증 + mv
- 메모리는 1 파일당 1 사실(fact)로 만들고, 충돌이 발생하는 위치는 인덱스에 추가하는 방식으로만 처리한다.
- 자동 작업(automatic job)의 권한은 파일이 아닌 인자(argument)로 전달한다.
경영적인 관점에서 볼 때, AI를 여러 개 병렬로 실행할수록 "사람이 모르는 곳에서 설정이 사라질" 위험은 커집니다. 저희 회사는 10개 사업을 동시에 운영하다가 9개 사업이 매출 제로라는 실패를 거쳐 3개 사업으로 줄였지만, 시스템 구조도 같아서, 병렬화 이전에 "동시에 건드려도 되는 것과 안 되는 것"을 결정해 두는 것이 가장 효과적이었습니다.
settings.json, CLAUDE.md, 메모리 설계를 좀 더 체계적으로 정리하고 싶은 분들에게는 저희 회사의 『CLAUDE.md 설계 패턴』을 추천합니다. 권한 설정의 위치나, 팀 공유와 로컬 설정을 나누는 방법을 자세히 설명하고 있습니다.
launchd 작업(job)이나 훅(hook)을 이용한 자동화 자체에 관심이 있는 분들은 『Claude Code 전자동화 바이블』도 참고해 보세요.