Joyuno's Dev Blog

2026-09-16 (수) 개발일지

dev-blog

오늘은 두 가지가 겹쳤다. 하나는 어제 실패한 run이 GitHub Actions에서 아직 안 고쳐진 채로 남아있다는 것, 다른 하나는 그 run을 추적하다 보니 로컬 실행에서 지속적으로 나오던 파일 읽기 실패 로그의 정체를 파헤치게 된 것이다. 결론부터 말하면, 어제 실패는 Pages 빌드에 이미 반영됐고, 진짜 안 고쳐진 건 로컬의 com.joyuno.collector였다. 그런데 그 로그를 쫓다 보니 st_blocks==0이라는 APFS 특성과 iCloud 플레이스홀더 오해까지 얽혀서 꽤 오래 헤맸다.

st_blocks==0 파일 300개 — iCloud 플레이스홀더로 오인한 APFS 인라인 저장

로컬 수집기 로그에서 st_blocks==0인 파일 300개를 읽기 시도 → 성공 300, 실패 0 이라는 기록을 발견했다. 처음엔 이게 iCloud가 파일을 원격에만 두고 로컬에는 플레이스홀더만 남긴 것이라고 확신했다. macOS에서 파일이 클라우드로 evict되면 st_blocks가 0이 되는 건 흔한 패턴이니까.

그래서 iCloud 드라이브 상태를 확인하는 쪽으로 방향을 잡았다. brctl status 같은 명령을 돌려서 evict된 파일 목록을 뽑아보려 했는데, 정작 로그를 다시 보니 이상한 점이 있었다. 읽기 시도한 300개 파일 중 단 하나도 실패하지 않았다. 진짜 iCloud 플레이스홀더라면 파일을 열 때 네트워크로 원본을 내려받아야 하고, 오프라인 상태면 실패가 섞여 있어야 정상이다.

그 순간 가설을 버렸다. 이건 evict된 게 아니라 APFS가 작은 파일을 inode 안에 인라인으로 저장하는 경우다. APFS는 일정 크기 이하의 파일을 별도 데이터 블록에 쓰지 않고 inode 내부에 직접 저장하는데, 이때 st_blocks가 0으로 나온다. 즉, 300개 파일은 전부 로컬 디스크에 실제로 존재하고, 읽기 성공 300 / 실패 0 이라는 로그는 그걸 그대로 반영한 것이다.

핵심 발견: st_blocks==0은 iCloud eviction이 아니라 APFS의 인라인 저장 특성이다. 파일이 실제로 로컬에 존재하므로 읽기 실패 로그는 오탐이었다.

이걸 확인하기 위해 파일 시스템 상세 정보를 조사했다. stat으로 실제 파일들의 블록 할당을 확인했고, 파일 크기가 수백 바이트 수준인 것들만 st_blocks==0에 해당한다는 걸 확인했다. 만약 진짜 iCloud 플레이스홀더였다면 파일이 원격에 있고 로컬에는 메타데이터만 있으니, 읽기 시도 자체가 실패하거나 지연됐을 텐데 그런 흔적이 전혀 없었다.

해결은 사실 코드 수정이 아니라 판단의 수정이었다. 로그 수집 파이프라인에서 st_blocks==0을 경고로 분류하는 조건이 있었는데, 이걸 정보성 로그로 다운그레이드했다. 파일이 실제로 읽히는지 여부가 기준이 되어야지, 블록 할당 방식이 기준이 되면 안 된다. iCloud eviction을 감지해야 한다면 st_blocks==0 대신 파일 읽기 실패 자체를 봐야 한다.

검증: 해당 로그를 다시 돌려서 st_blocks==0 파일 300개가 더 이상 경고로 분류되지 않고, 실제 읽기 실패가 발생한 경우에만 경고가 뜨는 걸 확인했다. 남은 과제: 진짜 iCloud eviction을 감지해야 한다면 별도로 getattrlist의 ATTR_CMN_EXTENDED_FLAGS에 UF_INVISIBLE 또는 ATTR_CMN_ICLOUD_EVICTION 플래그를 확인하는 로직을 추가해야 한다.

GitHub Actions Pages 빌드 — 어제 실패가 오늘 성공한 run에 남긴 흔적

두 번째 이슈는 더 헷갈렸다. 어제 실패한 run이 오늘도 아직 안 고쳐진 상태로 로그에 남아있다는 걸 발견했다. 처음엔 GitHub Actions가 뭔가 잘못된 캐시를 재사용하고 있는 줄 알았다. 하지만 Pages 빌드 로그를 직접 뜯어보니, 어제 실패한 run은 이미 고쳐졌고, 안 고쳐진 건 로컬 run이었다.

여기서 결정적인 단서를 잡았다. 최근 성공한 Pages 빌드 로그에서 deploy job의 error_count: 10 이라는 수치를 발견했다. 빌드가 성공했는데 에러 카운트가 10이라는 건, 실패한 에러가 빌드를 죽이지 않고 넘어갔다는 뜻이다. Jekyll은 Liquid 문법 오류 같은 걸 만나면 빌드를 중단하는데, 이건 중단되지 않았다.

원인 추적을 위해 실패한 run과 성공한 run의 SHA를 비교했다. 실패한 run이 fix 커밋의 조상(ancestor) 인지 확인했고, 그 결과 fix 이후에 실행된 run들은 전부 성공했다. 즉, 어제 실패는 이미 해결됐고, 오늘 내가 본 “안 고쳐진 run”은 로컬에서 실행된 것이지 GitHub Actions가 아니었다.

핵심 발견: “아직 안 고쳐진 run”은 GitHub이 아니라 로컬 실행이었다. Pages 빌드의 실패는 이미 fix 커밋으로 해결됐고, 성공한 run에 남은 error_count: 10은 빌드를 죽이지 않는 경고성 에러였다.

이때 또 하나의 의심이 생겼다. fix가 적용된 이후에도 다른 파일에서 같은 패턴의 실패가 나오는 건 아닌지. 그래서 fix가 건드리지 않은 두 번째 작성자 파일(_posts/*-dev-blog.md)을 확인했다. 이 파일은 별도 작성자가 쓴 글인데, fix가 적용된 protect_liquid 로직이 여기에는 적용되지 않았을 가능성이 있었다.

하지만 파일 내용을 직접 확인해보니 dev-blog 파일의 front matter는 Liquid 표현식을 포함하지 않았고, Jekyll 빌드에서 제외되는지 여부도 _config.yml의 exclude 목록을 확인해서 정상적으로 처리되고 있음을 확인했다. 이 가설은 기각했다.

해결은 코드 수정이 아니라 로그 해석의 수정이었다. Pages 빌드의 성공/실패 판정 기준이 “exit code 0”인데, 이게 에러 로그가 있어도 성공으로 처리되는 구조였다. 그래서 로그 수집 스크립트에서 error_count가 0이 아닌데도 빌드가 성공한 경우를 별도로 분류하도록 변경했다. 이후에는 에러가 있어도 빌드가 성공한 케이스와, 에러 때문에 빌드가 실패한 케이스를 구분해서 표시한다.

검증: 최근 실행 이력 10개를 전부 확인했고, fix 이후 실행된 run은 전부 성공 + error_count: 0을 확인했다. 남은 과제: error_count: 10이었던 그 성공 run이 정확히 어떤 에러를 삼켰는지 아직 추적 중이다. Jekyll이 빌드를 죽이지 않는 경고성 에러일 가능성이 높지만, 만약 Liquid 표현식이 front matter에서 처리되지 않고 넘어간 거라면 다른 글에서도 같은 문제가 재발할 수 있다.

로컬 수집기 전체 실행 실패 — 단독 실행은 정상, 부수 피해로 무너진 테스트

마지막으로, 로컬 수집기에서 전체 실행 시에만 실패하는 문제가 있었다. 개별 파일(connect.test.ts, map-layout.test.ts)은 단독 실행하면 전부 통과하는데, 전체 테스트 스위트를 돌리면 한꺼번에 ✖로 무너지고 Go 런타임 패닉이 찍혔다.

처음엔 테스트 간 상태 오염을 의심했다. 어떤 테스트가 전역 상태를 건드려서 다음 테스트에 영향을 주는 패턴이라고 생각했다. 그래서 테스트 실행 순서를 바꿔가며 재현을 시도했는데, 순서를 바꿔도 같은 지점에서 무너졌다. 순서 문제가 아니었다.

그 다음엔 파일 시스템 의존성을 의심했다. 특정 테스트가 파일을 쓰고, 다음 테스트가 그 파일을 읽는 구조라면, 파일이 없을 때 실패할 수 있다. 하지만 실패 지점을 보니 파일 접근과 무관한 Go 런타임 패닉이었다. 메모리 관련 문제일 가능성이 높아 보였다.

핵심 발견: 테스트 파일 대부분이 동시에 무너진 건 테스트 간 충돌이 아니라 시스템 메모리 부족으로 강제 종료된 것이었다. 제품 결함이 아니라 내 실행 방식 탓이었다.

로그를 다시 보니, 패닉이 찍히기 직전에 메모리 할당 실패 메시지가 있었다. 전체 테스트 스위트가 한꺼번에 실행되면서 메모리 사용량이 급증했고, 시스템이 OOM 킬러를 작동시켜 프로세스를 강제 종료한 것이다. 단독 실행에서는 메모리 사용량이 낮아서 문제가 없었던 것.

해결: 테스트 실행을 병렬에서 직렬로 바꾸고, GOMAXPROCS를 제한해서 메모리 사용량을 낮췄다. go test에 -parallel 1 옵션을 주고, GOMAXPROCS=2로 실행하니 전체 스위트가 안정적으로 돌아갔다.

검증: 전체 테스트 스위트를 3회 연속 실행해서 전부 통과하는 걸 확인했다. 남은 과제: 근본적으로는 테스트가 왜 그렇게 많은 메모리를 쓰는지 파악해야 한다. 대용량 파일을 메모리에 통째로 올리는 테스트가 있는지, 아니면 테스트 간 공유 리소스가 누수되고 있는지 확인이 필요하다.

오늘의 한줄

오늘은 로그를 “있는 그대로” 읽지 않고 내가 기대하는 패턴에 맞춰 해석한 실수가 세 번 나왔다 — st_blocks==0을 iCloud로 오인, 성공 run의 에러를 미해결로 오인, 전체 실행 실패를 테스트 결함으로 오인. 셋 다 가정을 먼저 세우고 증거를 나중에 봐서 생긴 일이었다. 로그를 볼 땐 “이게 왜 이렇게 나왔는지”를 먼저 묻고, 그 다음에 내 가설을 세울 것.