Blog으로 돌아가기
2026.06.17.12 min read

빌드가 멈춘 줄 알았는데 범인은 iCloud였다

유성구 당직 주차민원 분석 프로그램을 Electron 앱으로 빌드해 배포용 .dmg를 뽑아야 했습니다. 그런데 electron-builder가 아무 출력 없이 멈췄고, 저는 '도구 로딩 단계에서 정지한다'고 결론 내린 상태에서 디버깅을 시작했습니다. 결론부터 말하면 앱 코드도, electron-builder도, Node도 전부 멀쩡했습니다. 범인은 빌드 파이프라인과 아무 상관 없어 보이던 곳에 있었고, 거기에 도달하기까지 세 번의 그럴듯한 오답을 지나야 했습니다. 이 글은 그 과정을 한 단계도 건너뛰지 않고 적은 기록입니다.

ElectronDebuggingmacOS

어떤 프로그램을 만들고 있었나

유성구청 당직실에서 밤 시간대에 들어오는 주차 민원을 분석하는 데스크톱 프로그램입니다. 엑셀로 쌓여 있던 민원 처리 목록을 불러와서, 시간대별·지역별로 어디에서 민원이 자주 발생하는지를 보여주는 게 핵심이었습니다. 화면은 Next.js로 만들어 정적 파일로 익스포트하고, 그 화면을 Electron이 감싸 데스크톱 앱으로 띄우고, 데이터는 better-sqlite3에 담는 구조였습니다.

기능 개발은 끝난 상태였습니다. 엑셀 임포트, 기간/지역/유형별 분석, 대시보드까지 다 동작했고, 정말로 남은 일은 단 하나 — 당직실 컴퓨터에 설치할 수 있도록 .dmg로 패키징하는 것뿐이었습니다. 보통 이 단계는 명령어 한 줄이면 끝납니다. 그런데 여기서 막혔습니다.

npm run build:mac을 돌리면 렌더러 빌드와 Electron 컴파일까지는 잘 지나가다가, electron-builder가 패키징을 시작하는 순간부터 로그가 멈추고 아무리 기다려도 돌아오지 않았습니다. 처음 메모에는 이렇게 적혀 있었습니다. 'electron-builder는 앱 코드 문제가 아니라 패키징 도구 로딩 단계에서 멈춘다. require("electron-builder") 자체가 출력 없이 정지한다.' — 이 진단은 절반은 맞고 절반은 틀렸는데, 그 절반의 오류가 디버깅 방향을 한참 흔들었습니다.

고치기 전에 — 추측 금지

이런 상황에서 가장 하기 쉬운 실수는 '아마 이것 때문일 거야' 하고 곧바로 뭔가를 바꿔 보는 겁니다. 버전을 올려 보고, 옵션을 꺼 보고, 캐시를 지워 보고. 운이 좋으면 맞지만, 대부분은 시간을 태우면서 새로운 변수만 늘립니다. 그래서 규칙을 하나 세우고 시작했습니다. 근본 원인을 증거로 확인하기 전에는 어떤 수정도 하지 않는다.

첫 단계는 '재현'과 '측정'입니다. 정말 멈추는지, 멈춘다면 어디서 멈추는지, 그게 매번 똑같은지를 숫자로 확인하는 것. 고치는 건 그다음입니다. 돌이켜보면 이 순서를 지킨 게 결국 답에 도달한 유일한 이유였습니다.

1단계 — 정말 '멈춘' 게 맞나?

먼저 electron-builder의 CLI를 직접 호출해 봤습니다. --version조차 30초 타임아웃 안에 응답이 없었습니다. 여기까지는 메모와 일치했습니다. 그런데 '멈췄다'고 단정하기 전에, 타임아웃을 30초가 아니라 90초로 늘리고 인내심 있게 기다려 봤습니다. 결과가 흥미로웠습니다.

textrequire("electron-builder") 를 끝까지 기다려보면
1차 실행 (npm install 직후) : 52.4 초 만에 로드 완료
2차 실행                    : 0.3 초
이후 새 프로세스 x2          : 0.6 초 / 0.6 초

멈춘 게 아니었습니다. 첫 실행은 52초가 걸렸지만 끝까지 가긴 갔고, 두 번째부터는 0.3초로 정상이었습니다. 이건 '영구 정지(데드락)'가 아니라 '첫 접근만 비정상적으로 느림 + 그 뒤 캐싱'이라는, 완전히 다른 종류의 신호였습니다. 보통 이런 패턴은 OS가 갓 만들어진 파일을 처음 만질 때 뭔가 검사를 하고, 한 번 검사한 뒤엔 캐싱돼서 빨라지는 경우에 나타납니다.

그래서 이때 첫 가설을 세웠습니다. 'npm install로 막 풀린 node_modules를 macOS가 첫 접근 때 스캔(Gatekeeper나 Spotlight)하느라 느린 것이다.' — 방향은 맞았지만, 정확한 주체는 한참 뒤에야 밝혀집니다.

require 안으로 들어가 보기

52초 동안 정확히 어디서 시간을 쓰는지 궁금했습니다. Node의 require를 가로채서, 모듈을 로드할 때마다 시작과 끝을 파일에 기록하는 인터셉터를 끼웠습니다. 끝이 기록되지 않은 require가 있다면 거기서 막힌 것이고, 시작과 끝 사이 시간이 길다면 거기가 느린 구간입니다.

typescriptrequire 시작/끝을 기록해 '돌아오지 않는' 모듈 찾기
const orig = Module.prototype.require;
Module.prototype.require = function (id) {
  fs.appendFileSync(log, "START " + id + "\n");
  const r = orig.apply(this, arguments);
  fs.appendFileSync(log, "END   " + id + "\n");
  return r;
};

추적 결과 멈춤 직전의 체인은 이랬습니다. app-builder-lib → PublishManager → BitbucketPublisher → form-data → get-intrinsic → math-intrinsics/min. 마지막으로 START만 찍히고 END가 없는 게 math-intrinsics/min이었습니다. 그래서 잠깐, '이 작은 모듈이 멈추나?' 싶었습니다.

그런데 파일을 열어 보니 math-intrinsics/min.js는 단 한 줄, module.exports = Math.min 이 전부였습니다. 멈출 수가 없는 파일입니다. 단독으로 require해 보니 1ms 만에 로드됐습니다. 즉 min이 범인이 아니라, min을 require한 직후의 get-intrinsic의 top-level 코드 실행에서 시간이 사라지고 있었던 겁니다(require 인터셉터는 require 호출만 잡지, 그 사이의 일반 코드 실행은 못 잡으니까요). 실제로 get-intrinsic만 단독으로 require하면 어떤 때는 8초, 어떤 때는 3초로 들쭉날쭉했습니다.

바로 이 '들쭉날쭉함'이 결정적 단서였습니다. 코드는 결정적입니다. 같은 코드가 8초였다 3초였다 한다는 건 코드 자체의 문제가 아니라, 코드 바깥의 무언가 — 디스크나 OS 계층의 경합 — 가 끼어들고 있다는 뜻이었습니다.

패키징도 같은 병을 앓고 있었다

require가 결국 끝난다는 걸 알았으니, 진짜 막히는 패키징 단계로 돌아갔습니다. 이번엔 프로세스를 죽이지 않고 살아있는 상태 그대로 들여다봤습니다. macOS의 sample로 네이티브 스택을 뜨고, lsof로 어떤 파일을 붙잡고 있는지 확인했습니다.

text멈춘 패키징 프로세스의 메인 스레드 — 그냥 I/O를 기다리는 중
메인 스레드:
  uv_run -> uv__io_poll -> kevent   (이벤트 루프 유휴)
워커 스레드 4개:
  uv_cond_wait                      (전부 유휴, 할 일 없음)
CPU: 0.0%   자식 프로세스: 없음   네트워크 연결: 없음

그림이 분명했습니다. 무한 루프로 CPU를 태우는 게 아니라(그랬다면 CPU가 100%였겠죠), 이벤트 루프가 '오지 않을 이벤트'를 영원히 기다리는 모양이었습니다. 완료돼야 할 비동기 파일 작업의 콜백이 깨어나지 못하는, 전형적인 데드락처럼 보였습니다. 그리고 lsof를 시간차로 찍어 보니 더 이상한 게 보였습니다.

text복사 자식 프로세스가 붙잡은 파일 핸들 — 계속 쌓이기만 한다
[30초] 열린 node_modules fd: 15
[60초] 49
[90초] 68
[150초] 98
[180초] 106   (열기만 하고 닫히지 않음 = 복사가 완료되지 못함)
같은 시간 dist 디렉토리 증가량: 약 6MB / 3분  (~33KB/s)

파일을 열기만 하고 거의 닫지 못하면서, 복사 속도가 초당 33KB. 250MB가 넘는 node_modules를 이 속도로 복사하면 몇 시간이 걸립니다. 사실상 '멈춘 것처럼 보이는 극단적인 느림'이었습니다. 이제 이게 데드락인지 느림인지를 확실히 갈라야 했습니다.

헛다리 1 — 버전이 안 맞아서일 거야

가장 먼저 떠오른 그럴듯한 설명은 버전 부정합이었습니다. 프로젝트는 electron-builder 24.13.3을 쓰고 있었는데, 이건 2023년 말 버전입니다. 반면 패키징하려는 Electron은 41, Node는 22 — 둘 다 한참 최신이었습니다. '오래된 빌더가 새 런타임에서 데드락을 일으킨다'는 건 충분히 있을 법한 이야기였습니다.

그래서 electron-builder를 최신인 26.15.3으로 올리고 다시 빌드했습니다. 모듈 식별 단계까지는 더 진행되는 듯했지만, 결국 node_modules를 복사하는 바로 그 지점에서 똑같이 멈췄습니다. 스택도 동일하게 kevent 유휴 상태. 버전은 범인이 아니었습니다. (다만 최신이 더 안전하니 26은 그대로 두기로 했습니다.)

헛다리 2 — asar 패커가 멈추는 거야

electron-builder는 앱 파일들을 asar라는 하나의 아카이브로 묶습니다. 이 패킹 단계가 의심스러웠습니다. better-sqlite3 같은 네이티브 모듈은 asar에서 빼내야(unpack) 하는데, 마침 멈추는 지점이 그 native .node 파일을 처리한 직후였거든요.

그래서 asar를 끄고(-c.asar=false) 돌렸습니다. asar를 끄면 파일을 아카이브로 묶지 않고 그냥 복사만 하니, 패커가 범인이라면 통과해야 했습니다. 결과: asar를 꺼도 같은 지점에서 멈췄습니다. asar 패킹이든 일반 복사든, '파일을 복사하는 동작' 자체가 느렸던 겁니다. 패커도 범인이 아니었습니다.

헛다리 3 — Spotlight가 스캔하느라 느린 거야

여기서 1단계의 첫 가설로 돌아왔습니다. macOS Spotlight가 갓 설치된 node_modules를 인덱싱하느라 파일 접근과 경합한다는 것. 실제로 확인해 보니 mdworker_shared가 CPU 23%로 돌고 있었고, lsof로 보니 mdworker가 바로 그 순간 node_modules/typescript 안의 파일을 열어 인덱싱하고 있었습니다. 꽤 그럴듯했습니다.

그래서 Spotlight 인덱싱을 껐습니다. (sudo mdutil -i off /System/Volumes/Data) 인덱싱이 disabled로 바뀐 걸 확인하고, mdworker가 더 이상 node_modules를 건드리지 않는 것도 확인한 뒤 다시 빌드했습니다. 그런데 복사 속도는 여전히 12KB/s. Spotlight도 주된 범인이 아니었습니다.

세 번 연속으로 그럴듯한 가설이 빗나갔습니다. 시스템적 디버깅에는 이런 규칙이 있습니다. 수정 시도가 세 번 실패하면, 개별 가설을 의심하지 말고 접근 자체를 의심하라. 저는 계속 'electron-builder의 복사 로직'이라는 같은 층위만 두드리고 있었습니다. 한 층 아래로 내려가야 했습니다.

한 층 아래로 — 파일 한 개를 읽는 데 얼마나 걸리나

electron-builder를 완전히 잊고, 가장 원초적인 질문을 던졌습니다. '이 폴더에서 작은 파일 하나를 그냥 읽는 데 시간이 얼마나 걸리지?' cat으로 typescript 타입 정의 파일 102개를 읽어 봤는데, 20초가 지나도 끝나지 않았습니다. 작은 텍스트 파일 100개가요. 그래서 한 개씩, 종류별로 정밀하게 쟀습니다.

text파일 한 개 읽기 시간 — 이 표 하나가 모든 걸 설명했다
콜드 node_modules 파일 (작은 .d.ts)  : 2871 ms   <-- !!
같은 파일 즉시 재읽기 (웜)            :    0.0 ms
프로젝트 루트 package.json           :    0.3 ms
/tmp 에 방금 만든 새 파일             :    0.1 ms

node_modules의 파일을 처음 읽는 데 2.9초. 같은 파일을 두 번째 읽으면 즉시. 프로젝트 루트의 package.json이나 /tmp의 새 파일은 처음부터 빨랐습니다. 즉 느림은 '디스크 전체'의 문제가 아니라 'node_modules 안의, 아직 한 번도 안 읽은 파일'에만 국한된 현상이었습니다. electron-builder가 수만 개의 그런 파일을 4개짜리 스레드풀로 복사하니, 곱셈의 결과가 '영원'이었던 것입니다. 그리고 처음의 52초 require도 정확히 같은 이유였습니다.

이제 질문은 하나로 좁혀졌습니다. node_modules 파일을 '처음 읽을 때' 2.9초 동안 무슨 일이 벌어지는가?

진짜 범인 — iCloud File Provider

처음엔 Gatekeeper 같은 보안 검사를 의심했지만(파일마다 com.apple.provenance 속성이 붙어 있었거든요), 그건 제거해도 소용없었고 새로 만든 파일에도 똑같이 붙어 있는데 빨랐습니다. 그래서 다른 가설을 세웠습니다. '혹시 이 파일들이 로컬에 진짜로 있는 게 아닌 건 아닐까?'

프로젝트 경로는 /Users/eunsik/Desktop/Dev/... 였습니다. 그리고 macOS에는 '데스크톱과 문서 폴더 iCloud 동기화' 기능이 있습니다. Desktop 아래에 있다면, 이 프로젝트는 통째로 iCloud Drive로 동기화되고 있다는 뜻입니다. 동기화 데몬들의 CPU를 확인한 순간 그림이 완성됐습니다.

textiCloud 동기화 데몬들이 폭주하고 있었다
fileproviderd   CPU 91.4%   (iCloud File Provider)
cloudd          CPU 59.4%
bird            CPU  9.3%   (iCloud Drive)

node_modules 표본 2000개 중 1304개(65%) = 디스크 점유 0KB
=> 파일 본체가 로컬에 없는 'dataless' 상태

핵심은 dataless였습니다. iCloud의 'Mac 저장 공간 최적화'는 디스크가 부족하면 잘 안 쓰는 파일의 본체를 클라우드로 올려 버리고, 로컬에는 0KB짜리 플레이스홀더만 남깁니다. node_modules 파일의 65%가 그 상태였습니다. 그러니 그 파일을 읽으려는 순간, OS는 fileproviderd를 통해 iCloud에서 파일을 한 개씩 내려받아야 했고 — 그게 파일당 약 2.9초였던 겁니다. 한 번 받으면 로컬에 생기니 두 번째는 즉시였고요. 모든 증상이 이 하나의 원인의 그림자였습니다. 52초 require, kevent 유휴 스택, 닫히지 않고 쌓이던 fd, 33KB/s, 8초였다 3초였다 하던 get-intrinsic까지 전부.

받아도 안 되는 이유 — 디스크가 86% 차 있었다

그럼 그냥 다 받아 버리면 되지 않을까? iCloud에는 파일을 강제로 내려받는 명령(brctl download)이 있습니다. node_modules에 걸고 dataless 파일 개수가 줄어드는지 지켜봤습니다. 그런데 개수가 줄기는커녕 525 → 531 → 773으로 오히려 늘었습니다.

이유는 디스크였습니다. 볼륨이 86% 차 있었고(여유 29GB), iCloud의 저장 공간 최적화가 한쪽에서 파일을 받는 동안 다른 쪽에서는 공간을 확보하려고 다시 파일을 비워 버렸습니다. 같은 폴더 안에서는 받는 속도보다 비우는 속도가 빨라서, 이기는 게 불가능한 싸움이었습니다. 결론은 분명해졌습니다. node_modules를 이 위치(iCloud 동기화 + 꽉 찬 디스크)에서 끌어올 게 아니라, 아예 동기화되지 않는 곳으로 빠져나가야 한다.

해결 — 동기화 바깥에서 새로 설치

묘수는 의외로 단순했습니다. 거대한 dataless node_modules를 클라우드에서 힘들게 받아올 필요가 없었습니다. node_modules는 어차피 npm 레지스트리에서 다시 받을 수 있으니까요. 그래서 '소스만' 동기화되지 않는 경로로 복사하고, 그곳에서 npm install을 새로 했습니다. 사용자분이 ~/Downloads는 iCloud 동기화 대상이 아니라고 알려주셔서 그곳을 택했습니다.

bash핵심 조치 — 소스만 옮겨 비동기화 위치에서 새로 설치
# 1) 소스만 복사 (무거운 node_modules / dist 는 제외)
rsync -a --exclude node_modules --exclude dist --exclude .next \
  ~/Desktop/Dev/useongu/app/ ~/Downloads/useongu-build/

# 2) 비동기화 위치에서 의존성 새로 설치 (레지스트리에서, 로컬에만 남음)
cd ~/Downloads/useongu-build
npm install

# 3) 패키징
npm run build:mac

효과는 즉시 측정으로 확인됐습니다. 새로 설치한 node_modules는 표본 1000개 중 dataless가 단 1개였고, 콜드 파일 읽기는 2871ms에서 0.8ms로 떨어졌습니다. 빌드를 돌리자 복사 속도가 12KB/s에서 17MB/s로 — 약 1400배 — 빨라지면서, 그토록 막히던 패키징 단계를 15초 만에 통과했습니다.

textbefore / after — 같은 코드, 다른 위치
iCloud(Desktop)     비동기화(Downloads)
콜드 파일 읽기            2871 ms             0.8 ms
복사 처리량              ~12 KB/s            ~17 MB/s
node_modules dataless    65%                 0.1%
패키징                   사실상 무한          정상 통과

코드 서명까지 끝난 120MB짜리 .dmg가 무결성 검증(hdiutil verify: checksum VALID)을 통과하며 나왔습니다. 원본 프로젝트는 그대로 두고, 빌드는 복사본에서 마쳤습니다.

배운 점

첫째, '멈춤'과 '느림'은 다르다. 출력이 없다고 정지가 아니었습니다. 30초 타임아웃을 90초로 늘려 끝까지 기다려 본 그 한 번이 첫 단추였습니다. 무한 대기처럼 보이면, 죽이기 전에 한 번은 끝까지 기다려 보고 CPU가 도는지(루프) 안 도는지(I/O 대기)부터 봐야 합니다.

둘째, 그럴듯한 가설일수록 빨리, 측정으로 죽여야 한다. 버전·asar·Spotlight는 모두 합리적인 의심이었지만 전부 오답이었습니다. 추측으로 고치기 시작했다면 각각 한참을 태웠을 겁니다. 세 번 빗나간 뒤에야 'electron-builder'라는 층위를 버리고 한 칸 아래 — 파일 한 개의 읽기 시간 — 로 내려갔고, 그 한 번의 측정이 모든 걸 설명했습니다. 증상을 좇지 말고 데이터 흐름을 거슬러 올라가는 게 핵심이었습니다.

셋째, 실무적으로 — 개발 프로젝트를 iCloud로 동기화되는 Desktop·Documents 아래에 두지 말 것. node_modules처럼 파일이 수만 개인 디렉토리는 동기화 대상이 되는 순간, 빌드 도구가 완벽히 정상이어도 멈춘 것처럼 보입니다. 게다가 동기화 데몬이 CPU를 잡아먹고, 디스크가 차면 파일을 멋대로 비워 버립니다. 앞으로는 ~/Dev나 ~/Downloads 같은 비동기화 경로에서만 개발합니다.

마지막으로, 가장 빠른 길은 결국 '고치기 전에 이해하기'였습니다. 답을 찾고 나니 수정은 명령어 몇 줄이었지만, 그 몇 줄에 닿기까지 필요한 건 손이 아니라 측정과 인내였습니다.