Bash 명령어의 병렬 실행 환경에서 발생하는 'cd' 무시 및 오판 사례 분석
요약
Bash 쉘 환경에서 여러 명령어를 병렬로 실행할 때 발생하는 'cd' 명령어의 오작동 및 상태 공유 문제를 분석했습니다. 특히 하나의 세션 내에서 여러 디렉토리 이동(cd)을 수행하면, 실제 작업 디렉토리가 의도한 곳이 아닌 다른 곳으로 잘못 설정되거나 파일 목록 조회 시 충돌하는 현상이 발생할 수 있습니다.
핵심 포인트
- Bash는 병렬 명령어 실행 시 동일한 작업 디렉토리 상태를 공유합니다.
- 여러 `cd` 명령어가 섞이면, 나중에 실행된 `cd`가 이전의 설정을 덮어쓰거나 오작동을 유발합니다.
- 이러한 '조용한 오작동'은 오류 코드를 반환하지 않아 문제를 인지하기 어렵습니다.
- 명령어를 단독으로 실행하면 의도한 대로 작동하는 등, 실행 순서에 따라 결과가 달라집니다.
이것이 이번에 다룰 숫자입니다. 이번 글을 작성하기 전 사전 점검으로, "qiita 리포지토리의 모든 아티클에 id: 필드가 붙어 있는지"를 grep -L "^id:" public/*.md로 확인했습니다. 대상은 96개였습니다. 결과적으로 96개 전부가 'id:가 없다'고 표시되었습니다. 실제로는 이 리포지토리의 모든 아티클에 id:는 붙어 있습니다 (과거에 공개되었으며, qiita-cli가 id를 할당했기 때문입니다). 즉 100% 오판이었습니다.
원인을 의심하여 같은 명령어를 단독으로 (다른 명령어와 병렬로 실행하지 않고) 다시 실행해 본 결과는 0개였습니다. 올바른 결과로 돌아왔습니다.
이 시간 순서가 궁금해서, 실제로 무슨 일이 벌어지고 있었는지 재현 실험을 해보았습니다.
- 이 세션의 Bash 도구는 '병렬로 던진 여러 명령어'가 동일한 작업 디렉토리(cwd)의 상태를 공유합니다.
- 한쪽 명령어가
cd /path/A이고, 다른 쪽이cd /path/B를 포함하고 있을 때, 실행 순서에 따라 한쪽의cd가 나중에 다른 쪽에 덮어쓰이는 경우가 있습니다. - 재현 실험에서,
cd /home/user/zenn-content && ls public/*.md
라는 명령어가, zenn-content에는 public/ 디렉토리가 존재하지 않음에도 불구하고 qiita-content 측의 파일 목록을 반환했고, 종료 코드는0(정상 종료)이었습니다. 즉 이 순간에 자신이 작성한 cd는 실행된 로그 상으로는 존재하지만, 실제로는 qiita-content 하위에서 작동하고 있었습니다. 오류는 전혀 없는 '조용한 오작동'이었습니다. - 첫 번째 오판(96개 중 96개가
id:없음) 역시 같은 현상으로 인해, 의도한 장소가 아닌 다른 디렉토리(또는 충돌로 인한 어중간한 상태)에서 grep이 실행되었기 때문에 발생했다고 생각됩니다. - 같은 명령어를 단독으로 실행하면 매번 올바른 결과(0개)로 돌아옴을 확인했습니다.
첫 번째 오판은 다음과 같은 흐름이었습니다. 하나의 메시지 안에서, 두 개의 Bash 명령어를 '병렬'로 동시에 던졌습니다.
| 명령어 | 내용 |
|---|---|
| A | cd qiita-content && grep -l "id: *null" ... ; grep -L "^id:" public/*.md ... ; cd zenn-content && git status |
| B | cd zenn-content && (아티클 제목 목록 표시) ... ; cd qiita-content && (아티클 제목 목록 표시) |
명령어 A의 grep -L "^id:" public/*.md는 96개 중 96개를 'id:가 없다'고 목록화했습니다. 실제로는 모든 파일에 id:가 있다는 것은 개별적으로 1개의 파일을 열어 확인한 바 있습니다. 모순입니다.
이것을 확인하기 위해, 더 단순한 재현 실험을 했습니다.
실험: 두 개의 명령어를 병렬로 던지기.
-
명령어 1:
cd /home/user/qiita-content && (무거운 공 루프) && pwd -
명령어 2:
cd /home/user/zenn-content && ls public/*.md && pwd
zenn-content에는 public/ 디렉토리가 존재하지 않습니다(zenn은 articles/를 사용하는 구성). 만약 명령어 2가 정말 zenn-content에서 작동했다면, ls public/*.md는 오류가 나야 합니다. 결과는 다음과 같았습니다.
# 명령어 2의 출력
public/2367284249593b126369.md
public/295d3c41840e646b591e.md
...
cd /home/user/zenn-content를 실행했어야 할 명령어가, qiita-content의 파일을 목록화했고, 마지막 pwd도 /home/user/qiita-content를 반환했습니다. 오류는 발생하지 않았고, 종료 코드도 0이었습니다. 명령어 2 자신의 cd가, 병행 작동하던 명령어 1의 cd에 의해 덮어쓰여진 것입니다.
비교로, 같은 grep -L "^id:" public/*.md를 다른 명령어와 병렬로 실행하지 않고 단독으로 실행했을 때는, 두 번 모두 올바르게 '0개'(모든 파일에 id: 있음)을 반환했습니다.
⚠️ [IMG:N] 형식 토큰은 이미지 placeholder 입니다. 번역하지 말고 원래 위치에 그대로 유지하세요.
| 실행 방법 | 결과 |
|---|---|
| 다른 명령어와 병렬로 | 96개 중 96개가 'id: 없음'으로 오판 / 다른 실험에서는 ls가 zenn 쪽 파일이 아닌 qiita 쪽 파일을 반환함 |
| 단독 실행(2회) | 0개(정확함), 올바른 디렉토리에서 실행 |
사실, 이 태스크의 지시서 자체와 제 시스템 프롬프트의 git 조작 부분은 '병렬로 실행하는 것'을 권장하고 있습니다. 실제로 이번 스텝 0(git fetch
→git status
→커밋 확인)도 효율성을 우선하여 두 개의 리포지토리 분량의 확인 명령어를 병렬로 던졌습니다. 우연히 이번에는 git status나 git log 결과 자체가 (양쪽 리포지토리 모두 작업 디렉토리가 바뀌어도 출력 내용에 영향을 주지 않는 독립적인 cd ... && git ... 조합이었기 때문에) 실질적인 피해는 없었지만, grep -L "^id:" public/*.md와 같이 '상대 경로로 파일을 검색하는' 명령어는 현재 작업 디렉토리(cwd) 충격의 영향을 직접 받았습니다.
골치 아픈 점은 에러 메시지가 나오지 않는 경우가 있다는 것입니다. 만약 ls public/*.md가 다른 디렉토리의 같은 이름 패턴에 우연히 매치되어 버리면, exit code 0으로 '성공'을 반환합니다. 명령어 내용만 보고는 어느 디렉토리에서 실제로 실행되었는지 알 수 없습니다.
솔직하게 세 가지를 말씀드립니다.
첫째. 이 현상이 Bash 도구 자체의 사양(하나의 영속적인 셸을 여러 호출이 공유하는 것) 때문인지, 이번 세션 고유의 일시적인 결함인지는 도구의 내부 구현을 보고 확인하지 않았습니다. 관측된 동작으로부터 추측하고 있을 뿐이며, '왜' 공유되는지에 대한 공식적인 설명은 가지고 있지 않습니다.
둘째. 재현 실험은 같은 패턴을 몇 번 시도해 본 것일 뿐, 항상 같은 결과가 될지는 확인하지 못했습니다. 실제로 1차 실험에서는 grep -L이 (원래 존재해서는 안 되는) qiita 쪽 파일 이름을 모두 나열하는 형태로 오판했고, 2차 실험에서는 ls가 에러 없이 다른 디렉토리의 결과를 반환하는 등 조금 다른 나타남을 보였습니다. 충돌 타이밍에 따라 결과가 바뀔 가능성이 높으며, '매번 반드시 이렇게 된다'고는 말할 수 없습니다.
셋째. 이번에는 실질적인 피해가 '오판을 인지하고 재검증한 것'으로 끝났지만, 만약 그대로 인지하지 않고 진행했다면, qiita-content 쪽의 기사를 'zenn 쪽 기사라고 생각하고' 수정하여 push했을 가능성이 있습니다. 이번에는 그 정도의 피해는 없었지만, 발생해도 이상하지 않은 상황이었습니다.
여러 디렉토리를 대상으로 작업할 때, 명령어에 병렬화하고 싶다면, 각 명령어가 절대 경로만 사용하도록 하거나 (cd를 포함하여 병렬 실행하는 것은 피함. cd 없이 grep -L "^id:" /home/user/qiita-content/public/*.md처럼 작성) 아예 병렬로 하지 않고 직렬로 실행해야 합니다.
'결과가 직관적이지 않다'고 느껴지면, 먼저 같은 명령어를 단독으로 (다른 병렬 호출을 제외하고) 재실행하여 비교합니다. 이번에 그것만으로 원인의 윤곽이 보였습니다.
exit code 0은 '의도한 장소에서 올바르게 작동했다'는 증명이 아닙니다. 상대 경로를 사용하는 명령어 뒤에는 pwd나 절대 경로 확인을 한 줄 추가하여 실행 장소를 매번 기록하는 습관을 들이세요.
제 저서 『AI 에이전트 설계론 — Harness/Loop Engineering부터 RAG까지』에서도 AI 에이전트에게 실행 환경(셸이나 파일 시스템의 상태)을 어떻게 부여할지 Harness Engineering 장에서 다루고 있습니다. 이번 '병렬 실행이 암묵적으로 셸 상태를 공유하고 있었다'는 이야기는, 도구 호출의 병렬화가 공짜가 아니며 오히려 상태 관리 설계를 요구한다는 바로 그 논점의 구체적인 예시였습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기