02. asar 아카이브 — 패키징 포맷과 그 한계
asar는 수만 개 작은 파일을 단일 아카이브로 묶는 성능 최적화 포맷이다. 암호화도 봉인도 아니고 — 누구나 한 줄 명령으로 추출할 수 있다. 이 문서는 asar의 구조, 무엇을 unpack해야 하는지, 그리고 왜 보안이 아닌지를 정확히 짚는다.
한 줄 답
asar = “Atom Shell Archive” — tar 비슷한 posix-free 단일 아카이브 포맷. fs 시스템 콜을 수만 번 → 한 번으로 줄인다. 그 대신 암호화·서명·보호는 하나도 없다. 비밀이 있다면 asar가 답이 아니다.
Why — 왜 asar가 필요한가
Electron 앱은 node_modules 안에 수만~수십만 개의 작은 JS 파일을 들고 있다.
| 패턴 | 파일 수 | OS가 하는 일 |
|---|---|---|
node_modules 그대로 | 80,000+ | 각각 open + stat + read + close (×4 시스템 콜) |
app.asar로 묶음 | 1 (+ unpack 몇 개) | mmap 1번 + 내부 offset 계산 |
| 효과 | 실측 (보고된 사례) |
|---|---|
| 시작 시간 | 800ms → 350ms (디스크 캐시 cold start) |
| 앱 폴더 크기 | 동일 (압축 없음) |
| Windows Defender 스캔 시간 | 수십 초 → 수 초 (스캔 대상 파일 수가 줄어듦) |
| macOS Spotlight 인덱스 | 수만 항목 → 1 항목 |
이 효과의 진짜 이유는 압축이 아니라 파일 시스템 메타데이터 비용이다. 작은 파일 N개의 합보다 큰 파일 1개의 read가 훨씬 빠르다.
How — asar의 구조
┌──────────────────────────────────────────┐
│ Pickle Header │
│ (4 bytes size + 4 bytes header size) │
├──────────────────────────────────────────┤
│ JSON Header │
│ { │
│ "files": { │
│ "package.json": { │
│ "size": 384, "offset": "0" │
│ }, │
│ "dist": { │
│ "files": { │
│ "main.js": { │
│ "size": 102400, │
│ "offset": "384" │
│ } │
│ } │
│ } │
│ } │
│ } │
├──────────────────────────────────────────┤
│ File Contents (concatenated, raw) │
│ package.json bytes ... │
│ main.js bytes ... │
│ ... │
└──────────────────────────────────────────┘- Pickle 포맷: Chromium 내부의 IPC 직렬화 포맷 재사용.
- JSON 헤더: 파일 트리 + 각 파일의
offset·size. 디렉터리는files키로 재귀. - 파일 본문: 그냥 concat. 압축 없음, 암호화 없음.
Electron 런타임의 패치
Electron은 Node fs 모듈을 몽키패치해 app.asar 안의 경로를 마치 디렉터리인 것처럼 처리한다.
// 사용자 코드
const fs = require('fs');
fs.readFileSync('/Applications/MyApp.app/Contents/Resources/app.asar/dist/main.js');
// → Electron이 asar 내부 offset으로 변환 → 한 번의 read그래서 대부분의 Node 코드가 asar에 대해 의식 없이 동작한다. 단, child_process나 OS API에 물리 경로를 넘기는 경우는 다르다 (아래).
What — 무엇을 unpack해야 하는가
asarUnpack은 빌드 시 특정 패턴은 asar 밖에 풀어두는 옵션이다. 결과 폴더는 app.asar.unpacked/.
unpack이 반드시 필요한 경우
| 종류 | 이유 |
|---|---|
네이티브 모듈 (.node) | dlopen은 물리 경로를 요구. asar 가상 경로에서는 못 연다 |
| 자식 프로세스 실행 파일 (ffmpeg.exe, sidecar) | spawn은 OS의 execve를 호출 — 가상 경로 모름 |
| OS API에 path를 넘기는 리소스 | macOS NSImage, Windows LoadLibrary |
| 사용자가 fs.watch로 감시해야 하는 파일 | asar 내부 파일은 watch 불가 |
| dynamic require의 절대 경로 | webpack output이 절대 경로를 굽는 경우 |
electron-builder 예시
asar: true
asarUnpack:
- "**/*.node" # 모든 네이티브 바인딩
- "node_modules/sharp/**/*" # libvips 의존 — .node + .so/.dll
- "node_modules/sqlite3/build/Release/*.node"
- "ffmpeg/**/*" # 사이드카 binary
- "resources/templates/**/*" # 사용자가 직접 fs로 열어야 하는 템플릿빌드 결과:
MyApp.app/Contents/Resources/
├── app.asar # 코드 대부분
└── app.asar.unpacked/ # unpack된 파일
├── node_modules/sharp/...
└── ffmpeg/ffmpeg코드에서 unpack 경로 참조
import { app } from 'electron';
import path from 'path';
// 개발 모드에서는 그냥 절대 경로
// 패키징 후에는 asar.unpacked 경로
const ffmpegPath = app.isPackaged
? path.join(process.resourcesPath, 'app.asar.unpacked', 'ffmpeg', 'ffmpeg')
: path.join(__dirname, '..', 'ffmpeg', 'ffmpeg');
import { spawn } from 'child_process';
spawn(ffmpegPath, ['-i', input, output]);함정:
__dirname이 asar 내부면 asar 가상 경로가 들어간다.ffmpegPath에.asar가 들어 있다면 잘못된 것. 대신process.resourcesPath로 Resources 폴더에서 시작.
What — asar는 보안이 아니다
# 풀기 — 한 줄
npx @electron/asar extract app.asar ./extracted
# 또는 단일 파일 확인
npx @electron/asar list app.asar
# 또는 핫패치
npx @electron/asar extract-file app.asar dist/main.js
# (수정 후)
npx @electron/asar pack ./extracted app.asar| 사실 | 의미 |
|---|---|
| asar는 plaintext concat 이다 | `strings app.asar |
| 수정 후 다시 pack 가능 | asar 자체에는 무결성 서명 없음. 외부 code signing만이 변조 감지 |
| API 키를 asar에 넣지 말 것 | 그냥 환경변수보다 5초 더 어려운 정도 |
| 비즈니스 로직 보호가 필요하면 | 별도로 난독화 (terser mangle) 또는 V8 bytecode (bytenode) |
진짜 무결성은 어디서 오나
| 레이어 | 무결성 방식 |
|---|---|
| asar 내부 | 없음 (포맷이 그렇다) |
.app / .exe 전체 | OS 수준 code signing (mac: Developer ID, win: Authenticode) — 04 code signing |
| 다운로드 채널 | TLS + (electron-updater는) latest.yml의 sha512 |
| 런타임 변조 감지 | macOS hardened runtime + Apple notarization ticket stapling |
즉, “이 binary가 변조되지 않았다”의 책임은 asar 밖에 있다. asar는 그 책임을 못 진다.
asar integrity (Electron 30+, 부분적 해결)
Electron 30부터는 빌드 시 asar 헤더에 hash를 박고, 런타임에 검증하는 asar integrity 기능이 도입됐다 (macOS 우선).
# electron-builder
asarIntegrity: true # 기본값- macOS는
.app의Info.plist에 hash를 박아 .app 서명 안에 묶어버린다 → asar 교체 시 서명 깨짐 → Gatekeeper 차단. - Windows는 비슷한 메커니즘이 v32+에서 들어오는 중.
- 단, 이건 변조 감지지 비밀 보호가 아니다. 코드는 여전히 plaintext.
What-if — 흔한 함정
| 함정 | 증상 | 원인 | 해결 |
|---|---|---|---|
spawn(path.join(__dirname, 'ffmpeg'))이 ENOENT | 패키지 후만 실패 | __dirname이 app.asar/... → OS가 그 경로 모름 | app.asar.unpacked 경로로 |
native module이 dlopen failed | 패키지 후만 실패 | .node가 asar 안에 들어감 | asarUnpack: ["**/*.node"] |
| 작은 파일이 무진장 많아서 asar가 수백 MB | asar 만들기 자체가 느림 + asar 패치/diff 비용 | 무거운 의존성 (puppeteer가 Chromium까지 끌고 옴) | files/extraResources로 제외 또는 따로 unpack |
| 비밀이 asar 안에 있다고 안심 | 한 줄 명령으로 노출됨 | asar는 보안이 아님 | 환경변수 + OS keychain (electron/safeStorage) |
| 빌드 후 asar 안의 한 줄을 핫패치하려고 풀어서 수정 → 재서명 안 함 | macOS에서 안 열림 | code signature가 깨짐 | 절대 사후 수정 금지. 수정하려면 재빌드 + 재서명 |
| watch 모드 dev에서는 잘 되는데 prod에서만 경로가 이상 | 개발 시엔 raw 폴더, prod는 asar | 경로 분기 누락 | app.isPackaged로 분기 |
Insight — 흥미로운 이야기
“asar는 GitHub이 만든 게 아니다 — Chromium의 Pickle을 재활용했다”
Pickle은 Chromium IPC의 직렬화 포맷이다. 헤더 구조(4-byte length prefix)가 asar 헤더와 동일한 이유 — Electron 팀이 이미 코드베이스에 있는 도구를 재활용했다. 새 포맷을 발명하지 않고 옆 동네 코드를 가져온 전형적인 시스템 디자인.
“V8 bytecode 컴파일로 진짜 보호하고 싶다면 — bytenode”
JS 소스를 V8 bytecode(
.jsc)로 미리 컴파일하면 코드 노출이 상당히 어려워진다. 단점: V8 버전이 정확히 일치해야 함 (Electron upgrade 시 깨짐), 디버깅 어려움, 시작 시간 오히려 느려질 수 있음. 그래서 전부 bytecode가 아니라 민감한 라이센스 검증 등 일부만 적용하는 게 일반적.
“asar integrity가 Electron 30에서 들어온 이유 — Discord 해킹 사건”
2022~2023년 BetterDiscord 같은 클라이언트 mod의 인기와 함께, asar를 핫패치해 malicious code 주입하는 공급망 공격 시나리오가 현실적 우려로 떠올랐다. Electron 팀의 응답이 asar integrity — .app 서명이 asar의 hash까지 커버하게 만드는 방식. 즉 asar 자체에 봉인을 박은 게 아니라 OS 코드 서명의 그늘 안에 asar를 집어넣었다.
요약 + Mermaid
asar는 수만 개 작은 파일 → 단일 아카이브의 성능 최적화다. 압축도, 암호화도, 봉인도 아니다. 네이티브 모듈·사이드카·OS API path 인자는
asarUnpack으로 풀어둬야 작동한다. 비밀 보호는 asar가 아니라 환경변수 + OS keychain, 변조 감지는 OS 코드 서명 (+ Electron 30+의 asar integrity)이 책임진다.