Skip to main content
Summary

데스크톱 앱의 경계는 운영체제까지 이어진다

지금까지 만든 작업 노트 앱은 창을 띄우고, IPC로 요청을 보내고, 파일을 저장하고, 메뉴와 단축키를 연결했다. 이제 앱은 더 넓은 데스크톱 환경과 연결될 수 있다. 사용자는 링크를 기본 브라우저에서 열고, 저장된 파일을 탐색기에서 확인하고, 클립보드에 복사한 내용을 다른 앱에 붙여 넣는다. 때로는 외부 명령어를 실행하거나 온라인 API와 로컬 파일을 함께 사용해야 한다.

Electron은 이런 작업을 위해 여러 네이티브 API를 제공한다. clipboard, shell, nativeImage가 대표적이다. 여기에 Node.js의 child_process, 네이티브 Node 모듈, HTTP 클라이언트까지 결합하면 웹 앱보다 넓은 범위의 기능을 만들 수 있다.

다만 원칙은 이전에 다룬 IPC 설계와 같다. 권한이 큰 작업은 renderer에 직접 열어 두지 않는다. renderer는 "사용자가 어떤 동작을 요청했다"는 사실만 전달하고, main 또는 preload가 검증한 작은 API를 통해 운영체제 기능을 호출한다.

네이티브 기능과 외부 시스템 연동AI 생성 이미지

clipboard: 복사와 붙여넣기를 앱 기능으로 만들기

클립보드는 사용자가 여러 앱 사이에서 데이터를 옮기는 공통 통로다. Electron의 clipboard API는 텍스트뿐 아니라 HTML, RTF, 이미지도 다룰 수 있다. 작업 노트 앱에서는 노트 제목과 본문 일부를 클립보드에 복사하거나, 외부에서 복사한 텍스트를 새 노트로 가져오는 기능을 만들 수 있다.

최근 Electron 문서에서는 renderer에서 clipboard를 직접 사용하는 방식을 deprecated로 안내한다. 필요한 경우 preload에서 contextBridge로 감싸 노출할 수 있지만, 권한 경계를 더 분명히 하려면 main에서 실제 클립보드 작업을 처리하고 preload에는 얇은 IPC 래퍼만 두는 방식이 안전하다.1

예를 들어 renderer에는 다음 정도의 API만 보이게 한다.

// main.js
const { ipcMain, clipboard } = require('electron');

ipcMain.handle('clipboard:write-text', (_event, text) => {
  if (typeof text !== 'string') {
    throw new Error('클립보드에 쓸 수 없는 값입니다.');
  }

  clipboard.writeText(text.slice(0, 100_000));
  return true;
});

ipcMain.handle('clipboard:read-text', () => {
  return clipboard.readText().slice(0, 100_000);
});
// preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('nativeClipboard', {
  writeText(text) {
    return ipcRenderer.invoke('clipboard:write-text', text);
  },
  readText() {
    return ipcRenderer.invoke('clipboard:read-text');
  }
});
// renderer.js
copyButton.addEventListener('click', async () => {
  await window.nativeClipboard.writeText(editor.value);
});

pasteButton.addEventListener('click', async () => {
  const text = await window.nativeClipboard.readText();
  editor.value += text;
});

여기서 중요한 점은 clipboard 전체를 공개하지 않는 것이다. 앱에 필요한 것이 텍스트 복사와 읽기뿐이라면 이미지, 임의 포맷, 시스템별 클립보드 영역까지 접근할 이유가 없다. API를 좁게 만들수록 renderer가 침해되었을 때 피해 범위도 줄어든다. 클립보드 읽기 기능은 사용자가 붙여넣기 버튼을 누르는 것처럼 명확한 동작 뒤에 실행되도록 만드는 편이 좋다.

HTML을 클립보드에 넣거나 클립보드에서 읽을 때는 더 조심해야 한다. 외부 앱에서 복사한 HTML에는 스크립트, 추적용 속성, 예상하지 못한 스타일이 섞일 수 있다. 노트 앱이 HTML 붙여넣기를 지원한다면 renderer에서 그대로 innerHTML에 넣지 말고, 허용할 태그와 속성을 정한 뒤 정제해야 한다.

shell: 기본 브라우저와 파일 탐색기를 활용하기

shell API는 운영체제가 알고 있는 기본 앱을 이용해 파일과 URL을 열어 준다. 예를 들어 URL은 기본 브라우저에서 열고, 로컬 파일은 연결된 기본 프로그램으로 열며, 특정 파일을 파일 탐색기에서 선택한 상태로 보여 줄 수 있다.2

자주 쓰는 메서드는 세 가지다.

  • shell.openExternal(url): 외부 URL이나 프로토콜을 기본 앱으로 연다.

  • shell.openPath(path): 로컬 파일을 기본 프로그램으로 연다.

  • shell.showItemInFolder(path): 파일 탐색기에서 해당 파일 위치를 보여 준다.

작업 노트 앱에 "공식 문서 열기"와 "저장 위치 보기"를 붙이면 다음처럼 구성할 수 있다. 예시의 notesDirectory는 앱이 직접 관리하는 노트 저장 폴더라고 가정한다.

// main.js
const { app, ipcMain, shell } = require('electron');
const path = require('node:path');

const notesDirectory = path.resolve(app.getPath('userData'), 'notes');

const allowedHosts = new Set([
  'www.electronjs.org',
  'example.com'
]);

function resolveNotePath(fileName) {
  if (typeof fileName !== 'string' || fileName.length === 0 || fileName.includes('0')) {
    throw new Error('잘못된 파일 이름입니다.');
  }

  const fullPath = path.resolve(notesDirectory, fileName);
  const relative = path.relative(notesDirectory, fullPath);

  if (relative === '' || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
    throw new Error('노트 폴더 밖의 파일은 열 수 없습니다.');
  }

  if (path.extname(fullPath) !== '.md') {
    throw new Error('허용되지 않은 파일 형식입니다.');
  }

  return fullPath;
}

ipcMain.handle('external:open-url', async (_event, rawUrl) => {
  if (typeof rawUrl !== 'string') {
    throw new Error('URL은 문자열이어야 합니다.');
  }

  let url;
  try {
    url = new URL(rawUrl);
  } catch {
    throw new Error('올바른 URL이 아닙니다.');
  }

  if (!['https:', 'mailto:'].includes(url.protocol)) {
    throw new Error('허용되지 않은 프로토콜입니다.');
  }

  if (url.protocol === 'https:' && !allowedHosts.has(url.hostname)) {
    throw new Error('허용되지 않은 도메인입니다.');
  }

  await shell.openExternal(url.toString());
});

ipcMain.handle('external:show-note-file', (_event, fileName) => {
  const fullPath = resolveNotePath(fileName);
  shell.showItemInFolder(fullPath);
});

openExternal은 편리하지만, 사용자 입력을 그대로 넘기면 위험하다. javascript:, file:, 커스텀 프로토콜 같은 값이 들어올 수 있고, 의도하지 않은 외부 앱이 실행될 수도 있다. 따라서 최소한 다음 기준을 둔다.

  1. 허용할 프로토콜을 정한다. 보통 https:와 필요한 경우 mailto: 정도로 시작한다.

  2. 서비스 연동 URL이라면 허용할 호스트를 제한한다.

  3. renderer에서 전달한 URL 문자열을 그대로 믿지 않고 new URL()로 파싱한다.

  4. 앱 내부 파일 경로를 외부 URL처럼 열지 않는다.

파일을 여는 기능도 마찬가지다. shell.openPath()로 아무 경로나 열 수 있게 만들면 renderer가 시스템의 민감한 파일을 열도록 유도할 수 있다. 사용자가 직접 선택한 파일이거나, 앱이 관리하는 디렉터리 안의 파일인지 확인한 뒤 열어야 한다. 단순히 파일명에 ..이 들어 있는지만 검사하는 방식은 부족하다. 기준 디렉터리를 정하고, 최종 경로를 해석한 뒤 그 안에 남아 있는지 확인하는 편이 안전하다.

nativeImage: 운영체제에 맞는 이미지를 준비하기

nativeImage는 Electron에서 아이콘과 이미지를 운영체제 기능에 전달할 때 쓰는 이미지 객체다. 트레이 아이콘, 알림 이미지, 클립보드 이미지, 메뉴 아이콘 같은 곳에서 사용된다. 파일 경로, 버퍼, Data URL, 플랫폼별 named image 등 여러 방식으로 만들 수 있다.3

// main.js
const { nativeImage, Tray } = require('electron');
const path = require('node:path');

const trayIcon = nativeImage.createFromPath(
  path.join(__dirname, 'assets', 'trayTemplate.png')
);

const tray = new Tray(trayIcon);

아이콘은 단순히 PNG 하나를 넣는 것으로 끝나지 않는다. 고해상도 디스플레이에서는 @2x, @3x 같은 배율 이미지가 필요하다. Electron은 이런 DPI suffix가 붙은 이미지를 인식할 수 있다. 예를 들어 trayTemplate.png, trayTemplate@2x.png를 같은 폴더에 두고 기본 파일명을 사용하면 화면 배율에 맞는 이미지를 선택할 수 있다.

macOS의 메뉴 바나 트레이에 쓰는 아이콘은 템플릿 이미지로 준비하는 경우가 많다. 템플릿 이미지는 색이 고정된 그림이 아니라, 밝은 모드와 어두운 모드에 맞춰 시스템이 표시 방식을 조정할 수 있는 아이콘이다. 보통 파일명에 Template을 붙여 관리한다.

실무에서는 아이콘을 기능 코드와 분리해 관리하는 편이 낫다.

assets/
  trayTemplate.png
  [email protected]
  app-icon.png
  notification.png

그리고 가능하면 main 프로세스에서 nativeImage를 생성한다. renderer가 이미지 파일 경로를 직접 결정하게 하면, 의도하지 않은 로컬 경로 접근 문제가 생길 수 있다. renderer가 "이 아이콘을 써 달라"고 요청해야 한다면 파일 경로 전체를 받기보다, default, syncing, error처럼 미리 정한 이름만 받는 식으로 통로를 좁히는 편이 좋다.

외부 프로그램 실행은 마지막 선택지로 둔다

Electron 앱에서는 Node.js의 child_process로 외부 명령어를 실행할 수 있다. 예를 들어 Git 명령어를 호출하거나, 로컬에 설치된 변환 도구를 실행하거나, 운영체제 명령을 통해 특정 작업을 처리할 수 있다.

하지만 외부 프로세스 실행은 위험도가 높다. 특히 사용자 입력이 명령어 문자열에 섞이면 명령어 주입 취약점이 생긴다. 따라서 exec()보다 spawn()이나 execFile()을 우선 고려한다. exec()는 셸을 거쳐 문자열을 실행하므로 편리하지만, 공백·따옴표·특수문자 처리에서 실수하기 쉽다.

// main.js
const { spawn } = require('node:child_process');

function runGitStatus(repoPath) {
  return new Promise((resolve, reject) => {
    const child = spawn('git', ['status', '--short'], {
      cwd: repoPath,
      shell: false,
      windowsHide: true
    });

    const maxOutputBytes = 1024 * 1024;
    let outputBytes = 0;
    let output = '';
    let errorOutput = '';
    let settled = false;

    const timeout = setTimeout(() => {
      fail(new Error('Git 명령이 시간 초과되었습니다.'));
    }, 10_000);

    function fail(error) {
      if (settled) return;
      settled = true;
      clearTimeout(timeout);
      child.kill('SIGTERM');
      reject(error);
    }

    child.stdout.on('data', chunk => {
      outputBytes += chunk.length;
      if (outputBytes > maxOutputBytes) {
        fail(new Error('프로세스 출력이 너무 큽니다.'));
        return;
      }
      output += chunk.toString('utf8');
    });

    child.stderr.on('data', chunk => {
      errorOutput += chunk.toString('utf8');
    });

    child.on('error', fail);

    child.on('close', code => {
      if (settled) return;
      settled = true;
      clearTimeout(timeout);

      if (code === 0) resolve(output);
      else reject(new Error(errorOutput || `프로세스 종료 코드: ${code}`));
    });
  });
}

이 예시는 명령어를 문자열 하나로 합치지 않고, 실행 파일과 인자를 분리해서 전달한다. 그래도 repoPath를 그대로 믿어서는 안 된다. 앱이 허용한 작업 폴더 안인지, 사용자가 실제로 선택한 저장소인지, 심볼릭 링크나 권한 문제가 없는지 main에서 확인한 뒤 실행해야 한다.

외부 프로세스를 쓸 때는 다음을 확인한다.

  • 실행할 바이너리를 고정하거나, 사용자가 직접 선택한 경로만 허용한다.

  • 인자는 배열로 전달하고, 문자열 조합으로 명령어를 만들지 않는다.

  • 작업 디렉터리 cwd를 검증한다.

  • stdout/stderr 크기가 무한히 커지지 않게 제한한다.

  • 앱 종료 시 자식 프로세스를 정리한다.

  • 오래 걸리는 작업은 취소 기능과 timeout을 둔다.

또 하나의 기준이 있다. Electron API로 가능한 일이라면 외부 명령어보다 Electron API를 우선 사용한다. 파일을 기본 앱으로 여는 일은 child_process보다 shell.openPath()가 적절하다. 휴지통으로 이동하는 일도 직접 rm이나 del을 호출하기보다 shell.trashItem() 같은 API를 검토하는 편이 안전하다.

네이티브 Node 모듈과 ABI 호환성

Node.js 생태계에는 JavaScript만으로 작성된 패키지도 있고, C/C++ 또는 Rust로 작성된 네이티브 모듈을 포함한 패키지도 있다. 예를 들어 SQLite 바인딩, 이미지 처리 라이브러리, 압축 라이브러리, OS 저수준 기능을 쓰는 패키지가 여기에 해당한다.

문제는 Electron이 일반 Node.js와 완전히 같은 런타임이 아니라는 점이다. Electron은 특정 버전의 Node.js, V8, Chromium을 함께 묶어 제공한다. 네이티브 모듈은 이 런타임의 ABI와 맞아야 한다. ABI는 "바이너리 수준에서 서로 호출하는 방식"이라고 이해하면 된다. 버전이 맞지 않으면 설치는 되었는데 실행 시점에 모듈을 로드하지 못하는 문제가 생긴다.

이때 사용하는 도구가 @electron/rebuild다. 이 도구는 네이티브 Node 모듈을 대상 Electron 버전에 맞춰 다시 빌드한다.4

npm install --save-dev @electron/rebuild
npx electron-rebuild

프로젝트에 스크립트로 추가해 두면 팀원과 CI 환경에서 같은 절차를 반복할 수 있다.

{
  "scripts": {
    "rebuild:native": "electron-rebuild"
  }
}

패키징 도구를 사용할 때는 해당 도구가 네이티브 모듈 rebuild를 자동으로 처리하는지 확인해야 한다. 개발 환경에서 동작하던 앱이 배포 빌드에서 실패하는 흔한 이유 중 하나가 아키텍처와 Electron 버전 불일치다. 특히 Apple Silicon, Windows x64/arm64, Linux 배포판 차이를 함께 고려해야 한다.

네이티브 모듈을 선택할 때는 성능만 보지 말고 유지보수 상태를 확인한다. Electron 최신 버전을 따라가는지, prebuild 바이너리를 제공하는지, 빌드 도구 설치가 필요한지, 배포 대상 운영체제를 모두 지원하는지 검토해야 한다. 가능하다면 순수 JavaScript 구현이나 WebAssembly 구현으로 충분한지도 함께 비교해 본다. 네이티브 모듈은 강력하지만, 설치와 배포의 복잡도를 함께 가져오기 때문이다.

온라인 API와 로컬 기능을 결합하기

Electron 앱의 장점은 온라인 서비스와 로컬 기능을 한 앱 안에서 결합할 수 있다는 점이다. 예를 들어 작업 노트 앱은 다음 구조로 확장할 수 있다.

  • 로컬 파일: 노트 원본과 첨부 파일 저장

  • 온라인 API: 계정 동기화, 백업, 공유 링크 생성

  • clipboard: 공유 링크 복사

  • shell: 공유 링크를 기본 브라우저에서 열기

  • nativeImage: 트레이와 알림 아이콘 표시

  • child_process: 사용자가 선택한 외부 도구와 선택적 연동

이 구조에서 renderer는 UI 상태와 사용자 입력을 담당한다. main은 파일 시스템, 외부 URL, 프로세스 실행, 인증 토큰 저장 같은 권한이 큰 작업을 맡는다. 온라인 요청은 main에서 처리할 수도 있고 renderer에서 처리할 수도 있지만, 인증 토큰이나 로컬 파일 경로가 함께 얽힌다면 main으로 모으는 편이 관리하기 쉽다.

예를 들어 "노트를 공유 링크로 만들고 클립보드에 복사"하는 흐름은 다음처럼 나눌 수 있다.

  1. renderer가 현재 노트 ID로 공유 요청을 보낸다.

  2. main이 노트 파일을 읽고 필요한 데이터만 온라인 API에 보낸다.

  3. 서버가 공유 URL을 반환한다.

  4. main 또는 preload가 URL을 클립보드에 쓴다.

  5. renderer는 성공 메시지를 표시한다.

이렇게 하면 renderer가 파일 경로, 인증 토큰, 클립보드 전체 권한을 모두 알 필요가 없다. 온라인 API와 로컬 기능을 결합할수록 "어떤 값이 어느 프로세스까지 가야 하는가"를 먼저 정해야 한다. 토큰은 main에 머물게 하고, renderer에는 화면에 필요한 결과만 돌려주는 식으로 경계를 유지하면 이후 기능이 늘어나도 구조가 무너지지 않는다.

실제 앱에 적용할 때의 기준

네이티브 기능은 앱의 활용 범위를 넓힌다. 동시에 앱이 사용자 컴퓨터에 더 깊이 들어간다는 뜻이기도 하다. 그래서 기능을 붙일 때마다 다음 질문을 던져야 한다.

  • 이 기능은 renderer에 직접 공개해야 하는가?

  • main이나 preload에서 더 작은 API로 감쌀 수 있는가?

  • 사용자 입력이 파일 경로, URL, 명령어 인자로 들어가는가?

  • 허용할 프로토콜, 도메인, 디렉터리, 파일 확장자를 제한했는가?

  • 외부 프로그램이 실패하거나 오래 멈출 때 UI는 어떻게 반응하는가?

  • 배포 대상 플랫폼에서 네이티브 모듈이 같은 방식으로 동작하는가?

Electron의 네이티브 API는 웹 기술과 운영체제 사이를 이어 주는 강한 도구다. 좋은 구조는 이 도구를 모두 열어 두는 것이 아니라, 앱의 사용 흐름에 맞게 좁은 통로로 제공하는 것이다. 이어지는 보안 설계에서는 이 원칙을 더 엄격하게 다룬다. 보안을 추가 작업이 아니라 기본 설계로 놓고, Electron 앱에서 피해야 할 설정과 안전한 기본값을 정리한다.

참고

1Electron clipboard API

2Electron shell API

3Electron nativeImage API

책 목록

Electron으로 배우는 데스크톱 앱 개발

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기