IPC 기초 — invoke, send, on 패턴 정리
"IPC는 Electron의 혈관" — 메인과 렌더러가 분리되어 있으니, 데이터를 주고받으려면 반드시 IPC를 거쳐야 합니다.
면접에서 Electron의 통신 방식을 물어보면, invoke와 send의 차이를 명확히 설명할 수 있어야 합니다.
IPC 통신의 세 가지 패턴
| 패턴 | 방향 | 반환값 | 용도 |
|---|---|---|---|
invoke / handle | 렌더러 → 메인 → 렌더러 | Promise | 요청-응답 (가장 권장) |
send / on | 렌더러 → 메인 | 없음 | 단방향 알림 |
webContents.send | 메인 → 렌더러 | 없음 | 메인에서 푸시 |
패턴 1: invoke / handle (양방향)
가장 많이 쓰이는 패턴입니다. 렌더러가 메인에 요청하고 결과를 받습니다.
// main.js — 핸들러 등록
const { ipcMain, dialog } = require('electron');
ipcMain.handle('dialog:openFile', async (_event, options) => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: options?.filters || [],
});
return result.filePaths[0] || null;
});
ipcMain.handle('app:getVersion', () => {
return app.getVersion();
});
// preload.js — 브릿지 설정
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
openFile: (options) => ipcRenderer.invoke('dialog:openFile', options),
getVersion: () => ipcRenderer.invoke('app:getVersion'),
});
// renderer.js — 렌더러에서 사용
async function handleOpenFile() {
const filePath = await window.electronAPI.openFile({
filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
});
if (filePath) {
console.log('선택한 파일:', filePath);
}
}
invoke가 권장되는 이유
- **Promise 기반 **: async/await로 깔끔하게 처리 가능
- ** 에러 전파 **: 메인에서 throw하면 렌더러에서 catch 가능
- ** 타이밍 보장 **: 응답이 올 때까지 기다릴 수 있음
패턴 2: send / on (렌더러 → 메인, 단방향)
응답이 필요 없는 단방향 메시지에 사용합니다.
// main.js — 수신
const { ipcMain } = require('electron');
ipcMain.on('log:write', (_event, level, message) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${level}] ${message}`);
});
ipcMain.on('analytics:track', (_event, eventName, data) => {
// 분석 데이터 전송 — 응답 불필요
sendToAnalytics(eventName, data);
});
// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
log: (level, message) => ipcRenderer.send('log:write', level, message),
track: (event, data) => ipcRenderer.send('analytics:track', event, data),
});
// renderer.js
window.electronAPI.log('info', '페이지 로드 완료');
window.electronAPI.track('button_click', { buttonId: 'submit' });
패턴 3: webContents.send (메인 → 렌더러)
메인 프로세스에서 렌더러로 이벤트를 ** 푸시 **합니다.
// main.js — 메인에서 렌더러로 메시지 전송
function startDownload(win, url) {
const download = new DownloadManager(url);
download.on('progress', (percent) => {
// 렌더러에 진행률 알림
win.webContents.send('download:progress', percent);
});
download.on('complete', (filePath) => {
win.webContents.send('download:complete', filePath);
});
download.on('error', (error) => {
win.webContents.send('download:error', error.message);
});
}
// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
onDownloadProgress: (callback) => {
const handler = (_event, percent) => callback(percent);
ipcRenderer.on('download:progress', handler);
return () => ipcRenderer.removeListener('download:progress', handler);
},
onDownloadComplete: (callback) => {
ipcRenderer.once('download:complete', (_event, path) => callback(path));
},
onDownloadError: (callback) => {
ipcRenderer.once('download:error', (_event, msg) => callback(msg));
},
});
// renderer.js
const removeProgressListener = window.electronAPI.onDownloadProgress((percent) => {
progressBar.style.width = `${percent}%`;
progressText.textContent = `${percent}% 완료`;
});
window.electronAPI.onDownloadComplete((path) => {
removeProgressListener(); // 리스너 정리
alert(`다운로드 완료: ${path}`);
});
에러 처리
invoke의 에러 전파
// main.js
ipcMain.handle('file:read', async (_event, filePath) => {
try {
const content = await fs.promises.readFile(filePath, 'utf-8');
return { success: true, data: content };
} catch (error) {
// 방법 1: 에러를 throw → 렌더러에서 catch
throw new Error(`파일을 읽을 수 없습니다: ${error.message}`);
// 방법 2: 에러 객체를 반환 (권장)
// return { success: false, error: error.message };
}
});
// renderer.js — 에러 처리
try {
const result = await window.electronAPI.readFile('/some/path');
if (result.success) {
displayContent(result.data);
} else {
showError(result.error);
}
} catch (error) {
// invoke에서 throw된 에러가 여기로 옵니다
showError(error.message);
}
sendSync — 가급적 사용하지 마세요
ipcRenderer.sendSync는 동기적으로 메인에 요청하고 응답을 기다립니다.
// ⚠️ 권장하지 않음 — 렌더러가 블로킹됨
ipcMain.on('sync:getConfig', (event) => {
event.returnValue = { theme: 'dark', language: 'ko' };
});
// 렌더러에서
const config = ipcRenderer.sendSync('sync:getConfig');
// 메인이 응답할 때까지 렌더러가 멈춤!
** 사용하지 않는 것이 좋은 이유:**
- 렌더러 프로세스가 ** 완전히 블로킹 **됨
- 메인 프로세스가 바쁘면 UI가 얼어버림
invoke로 대체 가능
IPC 데이터 직렬화
IPC로 전달되는 데이터는 ** 구조화된 복제 알고리즘 **(Structured Clone)으로 직렬화됩니다.
// ✅ 전달 가능한 데이터
ipcRenderer.invoke('save', {
name: '문서', // 문자열
count: 42, // 숫자
tags: ['a', 'b'], // 배열
metadata: { key: 'v' }, // 중첩 객체
created: new Date(), // Date 객체
buffer: Buffer.from('hello'), // Buffer
});
// ❌ 전달 불가능한 데이터
ipcRenderer.invoke('save', {
callback: () => {}, // 함수는 직렬화 불가
symbol: Symbol('x'), // Symbol 불가
element: document.body, // DOM 요소 불가
});
실전 패턴: IPC 채널 관리
프로젝트가 커지면 IPC 채널을 체계적으로 관리해야 합니다.
// shared/ipc-channels.js — 채널 상수 관리
const IPC_CHANNELS = {
FILE: {
READ: 'file:read',
WRITE: 'file:write',
DELETE: 'file:delete',
},
APP: {
GET_VERSION: 'app:getVersion',
QUIT: 'app:quit',
},
DIALOG: {
OPEN_FILE: 'dialog:openFile',
SAVE_FILE: 'dialog:saveFile',
},
};
module.exports = { IPC_CHANNELS };
면접 포인트 정리
invoke/handle: 양방향, Promise 반환, 가장 권장되는 패턴send/on: 단방향(렌더러→메인), 응답 불필요할 때webContents.send: 메인→렌더러 푸시 (진행률, 알림 등)sendSync는 렌더러를 블로킹하므로 사용 자제- IPC 데이터는 Structured Clone으로 직렬화 (함수 전달 불가)
- 채널명은 상수로 관리하면 유지보수가 편함
IPC 기초를 익혔으면, 다음은 메뉴와 트레이 아이콘 구현을 알아봅시다.
댓글 로딩 중...