홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

실행 파일은 왜 타깃 OS에서 빌드해야 할까 — PyInstaller·Electron 패키징 원리

정기창·2026년 5월 22일

평소 맥북으로 작업합니다. 어느 날, 만들어 둔 도구를 실행 파일(.exe) 하나로 묶으면 어떤 점이 좋을까 하는 생각이 들었습니다. 받는 쪽 컴퓨터에 파이썬이나 Node.js 같은 개발 환경을 따로 설치하지 않아도 되고, 파일 하나만 전달하면 되니 비개발자에게 건네기에 한결 수월하겠다는 것이었습니다. exe를 실제로 만들어 본 적은 없지만, 그 장점을 막연히 떠올리다 보니 자연스럽게 패키징이라는 주제를 들여다보게 됐습니다.

그렇게 살펴보던 중 예상하지 못한 사실과 마주쳤습니다. 맥북에서 만든 .exe를 Windows로 보내면 제대로 실행되지 않고, 더 정확히는 맥에서 Windows용 .exe를 만드는 것 자체가 막혀 있다는 것입니다. 평소 파일 확장자 정도로만 여겼던 .exe가 생각보다 깊은 사정을 가지고 있었던 셌입니다. 그 경계가 왜 생기는지, 그리고 이와 관련해 미리 알아두면 좋을 지식은 또 무엇이 있을지 궤금해졌습니다. 이 글은 그 궤금증을 따라 추가로 조사한 내용을 정리한 것입니다. PyInstaller도, Node.js의 SEA도, Electron도 — 이름은 다르지만 모두 같은 이유로 "타깃 OS에서 빌드하라"고 안내하는데, 그 이유부터 차근차근 짚어 보겠습니다.

실행 파일은 확장자가 아닙니다

.exe는 단순한 파일 이름의 꼬리표가 아닙니다. 그 안에는 운영체제마다 다른 실행 파일 포맷이 들어 있습니다. 같은 CPU 명령어를 담고 있어도, 운영체제는 저마다 다른 방식으로 실행 파일을 읽어들입니다.

운영체제 실행 파일 포맷 대표 확장자
Windows PE (Portable Executable) .exe / .dll
macOS Mach-O (없음) / .dylib
Linux ELF (Executable and Linkable Format) (없음) / .so

운영체제의 커널에는 자기 포맷을 읽는 로더만 들어 있습니다. macOS 커널은 Mach-O를 읽지만 PE를 해석하는 로더가 아예 없습니다. 그래서 macOS에서 .exe를 더블클릭하면 실행되지 않고 그냥 멈춥니다. 굳이 돌리려면 Wine 같은 호환 계층이나 가상머신으로 Windows 환경을 따로 얹어야 합니다.

이 도구들은 컴파일러가 아니라 "포장기"입니다

여기서 가장 중요한 오해 하나를 짚고 넘어가야 합니다. PyInstaller나 Node SEA가 내 코드를 그 운영체제용 기계어로 번역해 준다고 생각하기 쉽습니다. 하지만 실제로 하는 일은 번역이 아닙니다.

이 도구들은 빌드하는 컴퓨터에 이미 설치되어 있는 런타임(인터프리터)을 그대로 집어다 함께 포장할 뿐입니다. 컴파일러가 아니라 포장기에 가깝습니다. 그래서 결과물의 운영체제는 "내가 빌드한 컴퓨터의 운영체제"로 자연스럽게 고정됩니다. 포장지 안에 들어가는 알맹이가 이미 특정 OS용 바이너리이기 때문입니다.

이 한 문장 — "컴파일러가 아니라 포장기다" — 을 머릿속에 넣어두면, 왜 크로스 빌드가 막히는지가 한결 또렷해집니다.

타깃 OS에서 빌드해야 하는 세 가지 층위

좀 더 구체적으로 들여다보면, 포장지 안에 들어가는 알맹이는 한 덩어리가 아닙니다. 적어도 세 층위가 모두 특정 OS에 묶여 있습니다.

1. 인터프리터 바이너리

패키징 도구가 실행 파일 안에 넣는 인터프리터 — 파이썬이라면 python 실행 파일 — 자체가 네이티브 바이너리입니다. Windows용, macOS용, Linux용 인터프리터는 서로 다른 포맷(PE / Mach-O / ELF)으로 컴파일된 별개의 파일입니다. macOS에 설치된 Mach-O 인터프리터를 가지고 Windows용 PE 실행 파일을 만들어낼 방법은 없습니다.

2. 네이티브 C 확장

"표준 라이브러리만 쓰니까 괜찮겠지"라는 생각도 안전하지 않습니다. 스크립트 언어의 표준 라이브러리에도 순수 코드가 아니라 C로 컴파일된 모듈이 섞여 있습니다. 암호화, 소켓, 압축 같은 기능이 대표적입니다. 이런 모듈은 운영체제마다 다른 형식의 컴파일된 파일로 존재합니다.

운영체제 네이티브 확장 형식
Windows .pyd (실체는 .dll)
macOS .so / .dylib
Linux .so

패키징 도구는 빌드하는 컴퓨터에 깔린 이 파일들을 그대로 수집해 포장지에 담습니다. macOS에서 수집한 .so는 Windows에서 로드 자체가 불가능합니다. 서드파티 라이브러리를 함께 쓴다면 이 문제는 더 커집니다.

3. 부트로더

실행 파일을 더블클릭했을 때 가장 먼저 도는 작은 런처가 있습니다. 인터프리터를 메모리에 올리고 압축을 풀어 코드를 넘겨주는 역할을 하는 부분입니다. 이 런처 역시 C로 작성되어 OS별로 미리 컴파일된 바이너리입니다. 패키징 도구는 각 플랫폼용 런처를 미리 갖고 있다가 빌드하는 OS의 것을 골라 씁니다.

세 층위가 전부 OS에 묶여 있으니, 결국 "타깃 OS에서 빌드"가 가장 단순하고 확실한 길이 됩니다. 이론적으로 타깃 플랫폼의 인터프리터·확장·런처를 전부 갖춰두면 크로스 빌드가 불가능한 것은 아니지만, 그 수고를 들이느니 타깃 OS의 가상머신이나 CI에서 빌드하는 편이 압도적으로 간단합니다. 그래서 PyInstaller도 공식적으로 크로스 컴파일을 지원하지 않습니다.

운영체제뿐 아니라 CPU 아키텍처도 갈립니다

한 가지 더 짚을 점이 있습니다. 빌드의 경계는 운영체제 하나로 끝나지 않습니다. CPU 아키텍처도 별개의 축입니다. 같은 macOS여도 인텔 칩(x86_64)과 애플 실리콘(arm64)은 명령어 집합이 다른 별개의 환경입니다.

그래서 정확히 말하면 빌드 단위는 "운영체제 하나"가 아니라 "타깃 OS × 타깃 CPU 아키텍처"의 조합입니다. Windows x64, macOS arm64, macOS x86_64처럼 조합마다 한 번씩 빌드해야 합니다. macOS에는 두 아키텍처를 하나로 합친 유니버설 바이너리라는 절충안이 있지만, 그것 역시 결국 양쪽을 각각 빌드해 묶은 결과물입니다.

Python, Node.js, Electron — 무엇이 어떻게 다른가

지금까지의 원리는 패키징 도구 전반에 공통으로 적용됩니다. 다만 도구마다 손이 가는 정도와 결과물의 무게는 꽤 다릅니다. 대표적인 세 갈래를 비교하면 이렇습니다.

구분 대표 도구 포장 안의 알맹이 결과물 크기(대략)
Python PyInstaller 파이썬 인터프리터 수십 MB
Node.js SEA (공식) Node 바이너리 50~90 MB
Electron electron-builder Chromium + Node + V8 120 MB 이상

파이썬 쪽은 PyInstaller가 오래 다듬어진 덕에 명령 한 줄로 단일 실행 파일이 떨어집니다. 가장 손이 적게 갑니다. Node.js는 한때 간편한 도구가 있었지만 지금은 유지보수가 끊겼고, 공식 경로인 SEA는 블롭을 만들어 Node 바이너리에 주입하는 여러 단계를 거쳐야 해서 상대적으로 번거롭습니다.

Electron은 결이 다른 도구입니다. 웹 기술로 데스크톱 GUI를 만드는 프레임워크라, 결과물 안에 크롬 브라우저 한 벌이 통째로 들어갑니다. 그만큼 무겁습니다. 화면이 있는 본격적인 데스크톱 앱이 목표가 아니라면, 단순한 도구 하나를 위해 선택하기엔 과한 무게라는 생각이 듭니다.

Electron은 OS마다 산출물의 형식이 다릅니다

Electron을 예로 들면 "포장기는 타깃 플랫폼의 네이티브 실행물을 감쌀 뿐"이라는 원리가 결과물의 겉모습에서 그대로 드러납니다. 같은 프로젝트를 빌드해도 타깃 OS에 따라 산출물의 형식 자체가 달라집니다.

타깃 OS Electron 산출물 안의 실행 포맷
Windows .exe (설치 마법사 포함) PE
macOS .app 번들 (.dmg / .zip로 배포) Mach-O
Linux .AppImage / .deb 등 ELF

특히 macOS 산출물은 .exe 같은 단일 파일이 아니라 .app 번들입니다. .app은 사실 디렉터리인데, Finder가 한 덩어리 아이콘처럼 보여줄 뿐입니다. 그 안을 열어보면 대략 이런 구조입니다.

앱이름.app/
└── Contents/
    ├── Info.plist        앱 메타데이터(이름·버전·아이콘)
    ├── MacOS/
    │   └── 앱이름         실제 Mach-O 실행 바이너리
    ├── Frameworks/        Chromium·Node 등 런타임 본체
    └── Resources/         앱 코드·아이콘 등 리소스

Windows였다면 .exe였을 진짜 실행 파일이 Contents/MacOS/ 안의 Mach-O 바이너리로 자리를 옮겼을 뿐, 본질은 같습니다. 배포할 때는 폴더를 그대로 건네기 불편해서 .dmg 디스크 이미지로 한 번 더 포장하는 것이 macOS의 관례입니다.

macOS는 .exe를 실행하지 못하고, 그 반대도 마찬가지입니다

지금까지의 이야기를 종합하면, 처음에 품었던 의문의 정체가 분명해집니다. macOS가 .exe를 실행하지 못하는 것은 버그도, 설정 누락도 아닙니다. .exe는 Windows 전용 PE 포맷이고, macOS 커널에는 그것을 읽는 로더가 없을 뿐입니다. 같은 이유로 Windows는 macOS의 .app을 실행하지 못합니다.

그래서 작업 흐름은 자연스럽게 둘로 갈립니다. 개발과 테스트는 평소 쓰는 환경에서 인터프리터로 직접 코드를 돌리면 됩니다. 굳이 실행 파일을 만들 필요가 없습니다. 실행 파일은 어디까지나 개발 환경이 없는 상대에게 건네는 최종 산출물이고, 그것은 받는 사람의 OS와 같은 환경에서 빌드해야 합니다.

한 가지 더 — 코드 서명과 공증

실행 파일을 만들었다고 끝이 아닙니다. 요즘 운영체제는 출처가 불분명한 실행 파일을 기본적으로 경계합니다. macOS의 Gatekeeper는 서명되지 않은 .app을 실행할 때 "확인되지 않은 개발자" 경고를 띄우거나 아예 막습니다. 정식으로 배포하려면 Apple 개발자 인증서로 코드 서명을 하고 공증(notarization) 절차를 거쳐야 하며, 여기에는 유료 개발자 계정이 필요합니다.

Windows에도 비슷한 장치가 있습니다. 서명되지 않은 .exe는 SmartScreen이 경고를 띄웁니다. 정도의 차이는 있지만, 단순히 "빌드해서 파일을 만드는 것"과 "경고 없이 매끄럽게 실행되는 배포물을 만드는 것" 사이에는 한 단계가 더 있다는 점은 양쪽 모두 같습니다. 패키징을 계획에 넣을 때 이 단계까지 함께 고려해야 한다는 생각이 들었습니다.

정리하며

결국 핵심은 한 문장으로 모입니다. PyInstaller도, Node SEA도, Electron도 코드를 다른 OS의 언어로 번역하는 컴파일러가 아니라, 이미 특정 OS에 묶여 있는 런타임을 감싸는 포장기라는 것입니다. 인터프리터 바이너리, 네이티브 C 확장, 부트로더 — 포장지 안의 세 층위가 모두 OS와 CPU 아키텍처에 묶여 있으니, 실행 파일은 그것이 돌아갈 환경과 같은 곳에서 빌드하는 것이 가장 단순하고 확실합니다.

처음에는 단순히 '도구를 exe로 묶으면 뭐가 좋을까' 하는 가벼운 호기심에서 출발했습니다. 하지만 그 궤금증을 따라가 보니, 운영체제가 실행 파일을 다루는 방식이라는 더 근본적인 그림이 보였습니다. 평소 익숙하게 여기던 .exe 같은 개념도 한 겹 더 들여다보면 의외로 단순한 원리에 가닿게 된다는 것을, 그리고 그 작은 궁금증 하나가 의외로 넓은 지식으로 이어진다는 것을 다시 한 번 느꼈습니다.

PyInstallerElectron실행 파일패키징크로스 컴파일Node.js

관련 글

.bat과 .exe, 그리고 Windows가 명령을 실행하는 원리

직접 만든 파이썬 CLI 도구를 .bat으로 포장해 전달하고 나니 문득 궁금해졌습니다. .bat과 .exe는 무엇이 다르고, 실행되는 파일에는 어떤 종류가 있으며, Windows는 이름만으로 명령을 어떻게 찾아내는지 — 평소 당연하게 써오던 것들을 들여다봤습니다.

관련도 89%

Node.js 소스코드를 직접 열어봤습니다 — 런타임, V8, libuv의 실체 (1편)

면접 준비를 하다가 "Node.js가 뭔가요?"라는 질문에 제대로 답할 수 없다는 걸 깨달았습니다. 런타임이 뭔지, V8과 libuv가 각각 무슨 역할인지, 실제 Node.js GitHub 소스코드를 열어서 os.hostname() 한 줄이 OS까지 도달하는 과정을 추적해봤습니다.

관련도 87%

npm, yarn, pnpm — 패키지 매니저가 node_modules를 만드는 세 가지 방식

같은 package.json인데 npm, yarn, pnpm이 만드는 node_modules 구조가 다릅니다. nested에서 flat으로, 다시 symlink로 — 구조가 바뀌어 온 이유와 각 방식의 트레이드오프를 정리합니다.

관련도 86%