⚡ Electron4. 네이티브 통합외부 CLI & childprocess

외부 CLI & child_process

이 문서가 답하는 질문: “Electron 앱에서 ffmpeg, git, python 같은 외부 CLI를 어떻게 호출하고, 쉘 인젝션을 어떻게 막는가?” 한 줄 답 (Pyramid Top): child_process.spawn을 배열 인자로 호출하라 — execshell: true는 사용자 입력이 그대로 쉘 명령이 되는 통로다.”


Why — 왜 존재하는가

Node 생태계에 있는 라이브러리만으로는 부족한 일이 많다. ffmpeg로 비디오 변환, git으로 저장소 조작, python 스크립트로 ML 추론. Electron은 데스크톱 앱이므로 사용자 시스템의 CLI를 호출할 수 있다 — 동시에 쉘 인젝션·DoS·권한 상승 같은 공격면을 함께 짊어진다.

문제해법한계
Node fs로 못 하는 작업외부 CLI 호출시스템 의존·없을 수 있음
사용자 시스템의 PATHspawn(command)PATH 미설정 시 못 찾음
큰 작업이 UI 블록child_process로 비동기결과 스트리밍 설계 필요

웹에는 없는 능력이자 없는 위험. 브라우저는 OS 명령을 절대 실행할 수 없다.


How — 어떻게 동작하는가

핵심 분리: Renderer는 데이터를 보내고, Main이 검증 후 CLI 호출. 직접 명령 문자열을 받지 않는다.


What — 구체 사양·수치·예시

spawn vs exec vs execFile

API입력 형식쉘 거치는가?위험도
spawn(cmd, [args])배열No (default)낮음 — 권장
execFile(cmd, [args])배열No낮음
exec(cmdString)문자열 (전체)Yes높음 — 인젝션
spawn(cmd, args, { shell: true })어느 쪽이든Yes높음

안전한 호출 — ffmpeg 예시

// main.js
const { spawn } = require('child_process');
const path = require('path');
 
ipcMain.handle('encode-video', async (event, inputPath, outputPath) => {
  // 1) 입력 검증
  if (!inputPath.startsWith(app.getPath('userData'))) {
    throw new Error('forbidden path');
  }
  if (!/^[\w\-./]+\.mp4$/.test(outputPath)) {
    throw new Error('invalid output filename');
  }
 
  // 2) 배열 인자 — 쉘 거치지 않음
  return new Promise((resolve, reject) => {
    const ff = spawn(path.join(__dirname, 'bin', 'ffmpeg'), [
      '-i', inputPath,
      '-c:v', 'libx264',
      '-preset', 'medium',
      '-y',  // overwrite
      outputPath,
    ]);
 
    let stderr = '';
    ff.stderr.on('data', (chunk) => {
      stderr += chunk;
      // 진행 상황 파싱해서 renderer로 progress 이벤트
      event.sender.send('encode-progress', parseProgress(chunk.toString()));
    });
    ff.on('close', (code) => {
      if (code === 0) resolve(outputPath);
      else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-500)}`));
    });
    ff.on('error', reject);
  });
});

번들된 바이너리 — 어디 두고 어떻게 부르는가

// electron-builder 설정
{
  "build": {
    "extraResources": [
      { "from": "resources/bin/${platform}", "to": "bin/", "filter": "**/*" }
    ]
  }
}
// 런타임 경로 — 개발 vs 패키징 차이
const ffmpegPath = app.isPackaged
  ? path.join(process.resourcesPath, 'bin', 'ffmpeg')
  : path.join(__dirname, 'resources/bin', process.platform, 'ffmpeg');

macOS notarization 시 chmod +x 권한·코드 서명 필요. 외부 바이너리도 함께 사이닝해야 Gatekeeper 통과.

사용자 PATH 활용 (git, python 등)

// PATH에 있는 git 호출 — 없을 수도 있으니 체크
const which = require('which');
try {
  const gitPath = await which('git');
  const git = spawn(gitPath, ['status', '--porcelain'], { cwd: repoDir });
  ...
} catch {
  // git이 시스템에 없음 → 사용자에게 안내
}

Windows 차이

// Windows .cmd / .bat은 spawn으로 직접 실행 안 됨 → shell: true 필요
// 하지만 shell: true는 위험 → 별도 npm package(`cross-spawn`)가 정석
const spawn = require('cross-spawn');
spawn('npm', ['install'], { cwd: projectDir });

What-if — 잘못 쓰면

  • 함정 1: exec('ffmpeg -i ' + userInput + ' out.mp4')userInput'"; rm -rf ~"'이면 → RCE. 모든 exec 호출은 인젝션 가능하다고 가정.
  • 함정 2: shell: true + 사용자 입력 → 위와 같음.
  • 함정 3: cwd를 검증 없이 사용자 입력으로 → 임의 디렉토리 실행. 항상 allowlist로 검증.
  • 함정 4: 자식 프로세스 누수 — ff.kill() 안 함, 앱 종료 시 ffmpeg 계속 돎. app.on('before-quit')에서 정리.
  • 함정 5: 큰 stdout을 메모리에 다 모음 → OOM. 스트리밍 처리.
  • 함정 6: macOS Gatekeeper — 번들된 바이너리 미서명 → 사용자가 실행 못 함. --deep --options=runtime로 함께 서명.

Insight — 흥미로운 이야기

“VS Code는 git을 child_process로 부른다 — 자기가 git 라이브러리를 짜지 않았다”

“왜 JS 구현체를 안 쓰고 외부 git을 부르나?”라는 질문이 자주 나온다. 답은 단순하다 — git의 사용자는 자기 git을 쓰고 싶어 한다. 사용자가 설정한 .gitconfig, signing key, hook 모두 그대로 동작해야 한다. JS 구현체(isomorphic-git)는 기능 호환은 있지만 행동 호환은 아니다.

같은 이유로 GitHub Desktop, Sourcetree, Tower 모두 child_process로 시스템 git을 부른다. 이게 Electron 앱이 데스크톱 시민으로 행동하는 방법이다 — 자기 안에 모든 걸 가두지 않고, 사용자 환경과 협력한다. 단, 그 협력에는 완벽한 입력 검증이라는 대가가 따른다.


요약

  • 항상 spawn(cmd, [arr])execshell: true는 인젝션 통로.
  • Renderer 입력은 반드시 Main에서 allowlist·정규식 검증 후 사용.
  • 번들 바이너리는 extraResources + 코드 서명.
  • 자식 프로세스 라이프사이클은 before-quit에서 정리.