aws-cli #1138 (1) - 두 번 죽은 PR의 묘비
Published:
aws s3 sync --exclude 가 거대 디렉터리를 walk 하는 9년 된 버그를 두 사람이 풀려고 했다 — 둘 다 실패. 왜 단순해 보이는 이 문제가 코드가 아니라 합의 에서 막혔는지, PR #2105 와 #5425 의 시간선을 따라가며 본다.
(앞선 waiter contribution (1), (2) 와 같은 프로젝트, 더 큰 버그.)
Part 1. #1138 — filter while recursing
aws s3 sync 를 자주 쓰는 사람이라면 한 번쯤 만난 적 있을 명령이 있다.
aws s3 sync ./root s3://bucket --exclude 'src/*'
./root/src/ 안의 모든 걸 제외하고 나머지만 올린다. 큰 디렉터리를 제외해서 시간을 줄이려는 의도. 그런데 실제로는 안 줄어든다. CLI 가 src/ 안의 모든 파일을 listdir + stat 한 뒤에 필터로 버리기 때문이다.
이게 aws/aws-cli#1138 “filter while recursing” — 2015년 4월에 열려 9년간 OPEN 으로 남아 있는 이슈다. 거대한 디렉터리를 exclude 해도 traverse 비용이 그대로 발생한다는 것이다.
코드 흐름이 말하는 것
[FileGenerator.list_files] → [FileGenerator.call] → [Filter.call]
↓ ↓ ↓
listdir 전부 yield 모든 파일 매치 안 되는
재귀 전부 것 drop
stat 전부
핵심 결함: --exclude/--include 는 iterator chain 의 후처리 단계에서만 동작한다. FileGenerator 는 어떤 파일이 excluded 인지 모르고 전부 traverse 후 stat 시도. 그래서 거대 디렉터리 exclude 해도 traverse 비용이 그대로 남는다.
awscli/customizations/s3/filegenerator.py:174-233 의 list_files 가 깊이 우선으로 디렉터리를 재귀하며 모든 파일을 yield 한다. --exclude/--include 필터는 이 generator 의 산출물을 “뒤에서” 걸러내는 별도 단계 (Filter.call, subcommands.py::run 의 command_dict['filters']) 에서 적용된다.
S3 측 listing 은 awscli/customizations/s3/utils.py 의 BucketLister.list_objects 가 ListObjectsV2 를 Prefix 만 주고 Delimiter 는 빼고 호출 → 평면 listing. 모든 키를 클라이언트로 받아온 뒤 필터링.
영향 받는 명령
| 명령 | 영향 |
|---|---|
s3 sync (local→s3) | ✓ 직접 |
s3 cp --recursive (local→s3) | ✓ 직접 |
s3 mv --recursive (local→s3) | ✓ 직접 |
s3 sync (s3→local, s3→s3) | 부분적 (server-side listing 비효율) |
s3 rm --recursive | 부분적 (전체 bucket listing 후 필터) |
해결 방향
수정 방향은 거의 자명하다. walker 가 디렉터리에 들어가기 전에 필터를 묻게 만들면 된다 — “이 디렉터리 안에 매칭될 파일이 없으면 들어가지 마라”. 그러면 listdir/stat 자체가 안 일어난다.
떠오르는 후보들
문제는 어떻게 묻느냐 다. 처음 이 이슈를 봤을 때 머리에 떠오른 후보들을 적어두면:
| 안 | 핵심 아이디어 |
|---|---|
| Naive prune | exclude 패턴에 매칭되는 디렉터리는 walk 안 함 |
| Auto-include parents | include 패턴의 부모 디렉터리를 자동으로 include 룰에 추가 |
| 보수적 prune 분석 | 디렉터리 하위에 매칭 가능 파일이 없음을 정적으로 증명 할 때만 prune |
| 일반 reachability (NFA/DFA) | fnmatch 패턴이 매칭하는 집합을 정확히 계산 |
--hard-exclude 신 플래그 | 사용자가 명시적으로 prune 디렉터리 선언 |
| filters.py 자체 리팩터 | 매칭 엔진 재설계 |
어느 게 답일까. 답은 메뉴 안에 있는데, 어느 안이 살아남을지는 두 가지 외부 사실이 결정한다.
- 이 중 두 개는 이미 시도된 적이 있고, 둘 다 실패했다.
- 메인테이너가 명시적으로 거부한 영역이 있다.
먼저 시도된 두 개를 보고, 그다음 거부 영역을 본다.
Part 2. 두 번 죽은 PR
PR #2105 — clarete (2015) — Naive prune (파일 단위)
aws/aws-cli#2105. 이슈가 열린 같은 해에 나온 첫 시도. 메뉴 중 Naive prune 의 가장 약한 형태 — 디렉터리가 아닌 파일 단위로만 적용. 핵심 아이디어는 단순하다 — should_ignore_file 에 file_filter 사전 체크를 추가하자.
def should_ignore_file(self, path):
if self.file_filter is not None:
if not list(self.file_filter.call([FileInfo(path)])):
return True # exclude 매치 → traverse 안 함
파일 단위로는 stat 전에 미리 필터링이 된다. 그러나 #1138 의 본질인 디렉터리 단위 prune 은 못 풀었다 — 디렉터리 자체는 여전히 listdir 한다. excluded/ 안에 100만 파일이 있으면 100만 번의 file_filter probe 가 필요하다. 절반의 해결이다.
PR 은 5년간 review 한 번 못 받고 abandoned 됐다. 작성자가 응답을 끊었다.
PR #5425 — bugfood (2020-2024) — Naive prune + Auto-include parents
aws/aws-cli#5425. clarete 의 패치를 이어받아 디렉터리 prune 까지 추가한 후속 시도. 메뉴의 Naive prune (디렉터리 단위) + Auto-include parents 조합. 4년에 걸친 처절한 시간선이 있다.
| 시점 | 사건 |
|---|---|
| 2020-07 | clarete patch + 단위 테스트 추가해서 PR 생성 |
| 2020-2022 | bugfood 가 7회 ping. AWS 팀 1.5년간 무대응 |
| 2022-03 | bugfood 가 회사 AWS support case 로 escalate |
| 2022-04 | tim-finnigan 첫 응답: “더 큰 리팩토링 필요 + functional test 추가” |
| 2022-05 | bugfood 가 요구사항 수용. justindho 가 2차 리뷰 |
| 2022-05 | --include 'a/b/c' 부모 경로 처리 design 난점 발견 |
| 2023-08 | Draft 강등 |
| 2024-06 | Close (“design 미결, 추가 연구 필요”) |
1.5년 무대응. 회사 support case 로 escalate 해서야 첫 응답. 그리고 합의에 못 이른 채 2년 더 끌다 close. 이 이슈를 풀고 싶으면 무엇을 각오해야 하는지 보여주는 시간선이다.
무엇을 시도했나
bugfood 는 두 가지를 추가했다.
(1) FileGenerator 에 filter 주입 — clarete 의 패치 + 디렉터리 단위 prune.
for name in names:
file_path = join(path, name)
if isdir(file_path):
if filter_excludes(file_path):
continue # ← 디렉터리 통째로 prune
for x in self.list_files(file_path, dir_op):
yield x
(2) --include 깊은 경로 처리 — 자동 부모 디렉터리 include 패턴 생성
여기서부터 어려워진다. 이 케이스를 보자.
aws s3 cp . s3://b --recursive --exclude '*' --include 'a/b/c.txt'
기대 동작: a/b/c.txt 한 개만 업로드. 그런데 단순 prune 알고리즘은 이렇다:
a/디렉터리 →--exclude '*'매치 → prune → walk 안 함 →c.txt못 찾음 ❌
bugfood 의 우회: include 패턴의 부모 prefix 들을 자동으로 include 룰에 추가.
# --include 'a/b/c.txt' 가 주어지면 자동으로:
# include /root/a/b/c.txt
# include /root/a/b ← 자동 추가 (정확 매치)
# include /root/a ← 자동 추가 (정확 매치)
이렇게 하면 a/, a/b/ 가 include 매치되어 traverse 되고, 그 안의 c.txt 가 정확 매치되어 yield 된다. 정상 작동.
결정타 — kyleknap 의 한 줄
그런데 이 트릭을 깨는 케이스를 kyleknap 이 discussion_r883991435 에서 던졌다.
aws s3 cp . s3://b --recursive --exclude '*' --include '*.py'
.
├── directory
│ ├── another-dir
│ │ ├── exclude-me.txt
│ │ └── include-me.py
│ └── include-me.py
└── include-me.py
기대: 모든 깊이의 include-me.py 3개 업로드.
bugfood 의 휴리스틱: *.py 의 부모 prefix 가 없다. include 패턴이 a/b/c.txt 처럼 명시적 부모 경로를 갖고 있어야 자동 부모 include 가 만들어지는데, *.py 는 부모가 빈 문자열. 따라서:
directory/→--exclude '*'매치 → 자동 부모 include 안 만들어짐 → pruneanother-dir/도 마찬가지로 prune- 결과: 루트의
include-me.py1개만 업로드 (silent 회귀)
기존 코드는 이 케이스에서 3개를 다 올렸다. bugfood 의 패치가 2개를 silent 하게 잃는다. 사용자 입장에서는 로그도 안 남고 그냥 파일이 사라진다.
bugfood 본인도 한계를 인정했다.
“Hmm, that is rough. … A third idea would be to give the user control, either
--no-traverse-excludesor--hard-exclude <path>…”
새 플래그를 추가하면 RFC 가 필요하고 design 합의가 또 필요하다. 그렇게 1년이 더 흐르고 결국 “not prioritized” 사유로 close. bugfood 의 작업 중 단 한 줄도 머지되지 않았다.
Part 3. 왜 이렇게 어려웠나
이 문제는 단순한 “코드를 짜면 끝나는” 문제가 아니다. 세 개의 제약이 서로 얽혀 있다.
제약 1: 회귀 0 보장
위에서 본 --exclude '*' --include '*.py' 케이스가 이 제약을 만든다. 단순 “exclude 매치되면 디렉터리 skip” 같은 직관적 규칙은 silent 회귀를 만든다. 알고리즘이 잘못 prune 하는 일이 절대 없어야 한다.
이 제약 하나로 두 접근이 탈락한다.
| 안 | 결정타 |
|---|---|
| Naive prune (“exclude 에 매칭되는 디렉터리 skip”) | 회귀 케이스에서 silent drop |
| Auto-include parents (PR #5425 방식) | *.py 처럼 부모 prefix 없는 패턴에서 깨짐 |
제약 2: filters.py 정책
kyleknap 의 PR #5425 코멘트 에 명시적인 가이드가 있다.
“I’d prefer we do not change anything in the
filters.pylogic if we do not need to. … I think before adding any additional complexity to it, we do some refactoring of it to make it easier to follow/work with.”
“filters.py 의 매칭 로직은 건드리지 말고, 추가 복잡도를 넣지 말고, 손댈 거면 먼저 리팩터해라.” 메인테이너의 명시적 거부. 이 제약으로 또 두 접근이 탈락한다.
| 안 | 결정타 |
|---|---|
| 일반 reachability (NFA/DFA) | 복잡도 폭증, “filters.py 안 건드리길” 정책 위반 |
| filters.py 자체 리팩터 | 본 이슈 범위 밖, kyleknap 명시적 거부 (2022) |
제약 3: 단일 PR 머지 가능 분량
bugfood 가 4년에 걸쳐 보여준 게 있다 — AWS CLI 는 외부 PR 리뷰 사이클이 길고, 큰 design 변경은 합의가 안 된다. 그래서 새 플래그 (--hard-exclude) 를 도입하는 RFC 같은 건 현실적으로 머지가 안 된다.
| 안 | 결정타 |
|---|---|
--hard-exclude 신 플래그 (사용자 명시적 prune 선언) | 디폴트 동작 미해결, UX 표면 증가, RFC 필요 |
세 제약을 다 만족하는 안이 하나 남는다.
“이 디렉터리 하위에 매칭 가능 파일이 없음을 정적으로 증명할 때만 prune”
증명을 못 하면 traverse 한다. 회귀 0 보장은 알고리즘이 자체적으로 보장. 새 플래그 없음. filters.py 의 매칭 로직 그대로. 추가되는 건 별도 함수 한 개.
이게 보수적 prune 분석 이다. 이름이 거창하지만 핵심 아이디어는 단순하다.
다음 글에서 이 알고리즘을 구체적으로 풀어 본다. _literal_prefix, pattern_can_match_under, pattern_matches_all_under 세 함수와, 이게 왜 PR #5425 의 회귀 케이스를 알고리즘 단계에서 자동으로 통과시키는지.
느낀 점
오래된 이슈일수록 코드가 아니라 합의에서 막힌다. PR #5425 의 4년 시간선을 보면 명확하다 — bugfood 의 코드 자체는 2020년에 거의 완성됐다. 1.5년의 침묵, 1년의 design 분쟁, 1년의 우선순위 박탈. 코딩이 아니라 회귀 케이스 하나가 합의를 못 만들어서 죽었다. 다음 사람이 이걸 풀려면 회귀 0 보장을 알고리즘 차원에서 증명할 수 있어야 했다.
메인테이너의 거부는 메시지가 아니라 제약이다. kyleknap 의 “filters.py 손대지 마라” 는 PR 코멘트가 아니라 design 제약으로 받아들여야 한다. 이 제약을 어기면 PR 이 안 머지된다. 어떤 안이든 이 제약을 만족하는 형태로 재포장돼야 한다 — bugfood 가 1년 동안 이걸 풀려고 시도했지만 자동 부모 include 라는 간접 우회 는 결국 회귀를 만들었다.
다음 글에서는 보수적 prune 분석이 어떻게 위 세 제약을 동시에 만족시키는지, 그리고 PR #5425 를 죽인 회귀 케이스를 알고리즘이 step 1 에서 어떻게 자동으로 잡아내는지 본다.
Issue: aws/aws-cli#1138 Prior PRs: #2105, #5425
