⚡ Electron3. IPC & Bridge02-ipcmain-ipcrenderer

02 · ipcMain · ipcRenderer — 두 모듈의 API 전수

이 문서가 답하는 질문: 두 모듈의 메서드는 정확히 몇 개고, 언제 어느 것을 써야 하나? 왜 sendSync절대 쓰면 안 되나? 한 줄 답: “가장 안전한 짝은 ipcRenderer.invokeipcMain.handle 단 하나다. 나머지는 알아야 피할 수 있는 API들이다.”


Why — API가 많아 보이지만 실제로는 2짝

Electron 공식 문서를 처음 보면 ipcMainipcRenderer에 메서드가 8~10개씩 있어 압도된다. 그러나 실용에서 쓰는 짝은 사실 셋뿐이다.

통신 방향비동기 응답 필요권장 짝
Renderer → Main, 응답 받음yesipcRenderer.invokeipcMain.handle
Renderer → Main, fire-and-forgetnoipcRenderer.sendipcMain.on
Main → Renderer, pushnowebContents.sendipcRenderer.on

나머지는 알아야 피할 수 있는 함정들이다.


How — 모듈별 API 전수

ipcRenderer (Renderer 측에서 import)

import { ipcRenderer } from 'electron'; // ⚠ preload에서만 — renderer 직접 불가
메서드시그니처상태용도
send(channel, ...args)(string, ...any) => void권장 (fire-and-forget)Main에 알림. 응답 없음
invoke(channel, ...args)(string, ...any) => Promise<any>권장Main에 요청 + Promise로 응답
on(channel, listener)(string, (event, ...args) => void) => this권장Main → Renderer push 수신
once(channel, listener)같음권장한 번만 수신
removeListener(channel, listener)(string, Function) => this필수메모리 누수 방지
removeAllListeners(channel?)(string?) => this가끔cleanup
sendSync(channel, ...args)(string, ...any) => any금기UI 스레드 freeze — 후술
postMessage(channel, message, transfer?)(string, any, MessagePort[]) => void고급MessagePort 전송
sendToHost(channel, ...args)(string, ...any) => void<webview> 전용embedded webview → host renderer
sendTo(webContentsId, channel, ...args)제거됨 (v12+)보안상 제거

ipcMain (Main 측에서 import)

import { ipcMain } from 'electron';
메서드시그니처상태용도
on(channel, listener)(string, (event, ...args) => void) => this권장ipcRenderer.send 수신
once(channel, listener)같음권장한 번만
handle(channel, handler)(string, (event, ...args) => Promise<any>) => void권장ipcRenderer.invoke 응답
handleOnce(channel, handler)같음가끔한 번만 응답
removeHandler(channel)(string) => void필수hot reload·재등록 시
removeListener(channel, listener)같음필수cleanup
removeAllListeners(channel?)같음가끔cleanup

event 객체 — handler 첫 인자

ipcMain.handle('foo', (event, ...args) => {
  event.sender;          // 보낸 webContents (응답 출처 검증용)
  event.senderFrame;     // 어느 frame인지 (iframe 검증)
  event.processId;       // 프로세스 ID
  event.frameId;         // frame ID
  event.returnValue;     // sendSync 전용 (쓰지 말 것)
  event.ports;           // MessagePort 배열 (postMessage용)
  event.reply(channel, ...args); // 응답 전용 (handle에선 return으로 충분)
});

What — 3가지 패턴의 정확한 코드

패턴 1 — invoke/handle (가장 흔하고 권장)

// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
 
contextBridge.exposeInMainWorld('api', {
  getUserData: (id: string) => ipcRenderer.invoke('user:get', id),
});
 
// main.ts
import { ipcMain } from 'electron';
 
ipcMain.handle('user:get', async (event, id: string) => {
  // 입력 검증
  if (typeof id !== 'string' || id.length > 64) {
    throw new Error('invalid id');
  }
  const user = await db.users.findOne({ id });
  return user; // → Renderer의 Promise가 resolve
});
 
// renderer.ts
const user = await window.api.getUserData('u_123');

왜 권장인가:

  • Promise라서 await 자연스러움
  • 에러를 throw하면 renderer에서 reject로 받음 (직렬화는 04 문서)
  • 채널당 handler 1개만 등록되어 충돌 감지가 쉬움
  • 응답 그대로 returnreply 패턴 불필요

패턴 2 — send/on (fire-and-forget)

// preload.ts
contextBridge.exposeInMainWorld('logger', {
  log: (msg: string) => ipcRenderer.send('log:write', msg),
});
 
// main.ts
ipcMain.on('log:write', (event, msg: string) => {
  fs.appendFileSync(logPath, `${msg}\n`); // 응답 없음
});
 
// renderer.ts
window.logger.log('user clicked button');

언제 쓰나: 응답이 진짜로 필요 없을 때 (로그·analytics·fire-and-forget event). 응답 받고 싶어지면 반드시 invoke로 옮겨라 — send + event.reply 패턴은 동기화 버그의 온상이다.

패턴 3 — Main → Renderer push

// main.ts
import { BrowserWindow } from 'electron';
 
setInterval(() => {
  const win = BrowserWindow.getAllWindows()[0];
  win.webContents.send('clock:tick', new Date().toISOString());
}, 1000);
 
// preload.ts
contextBridge.exposeInMainWorld('clock', {
  onTick: (cb: (time: string) => void) => {
    const listener = (_: any, time: string) => cb(time);
    ipcRenderer.on('clock:tick', listener);
    return () => ipcRenderer.removeListener('clock:tick', listener);
  },
});
 
// renderer.ts
const unsubscribe = window.clock.onTick((time) => updateClock(time));
// 컴포넌트 unmount 시
unsubscribe();

핵심: cleanup 함수를 반드시 반환하라. 안 그러면 컴포넌트가 unmount된 뒤에도 listener가 살아 메모리 누수가 생긴다.


What-if — sendSync 금기인가

ipcRenderer.sendSync(channel, ...args)동기적으로 main에 메시지를 보내고 블로킹하며 응답을 기다린다.

// ❌ NEVER
const data = ipcRenderer.sendSync('user:get', 'u_123');
// → renderer 스레드가 main의 응답을 기다리며 멈춤
// → 그 동안 모든 UI 입력·애니메이션·React 리렌더가 정지

왜 멈추는가

Renderer는 단일 스레드 V8이다. sendSync그 스레드를 막고 main 응답을 기다린다. Main이 1초 걸리면 — UI가 1초 동안 응답 없음이다. Main이 deadlock하면 renderer도 죽는다.

그런데 왜 남아 있나

레거시 호환 + 매우 드문 합법적 자리 (앱 init 직전 동기적 config 로드 등). Electron 코어 팀도 제거 검토를 여러 번 한 흔적이 issue에 남아 있다. 실무에서 절대 쓸 일 없다고 생각하면 된다.

대체 — invoke가 답

// ✅ 같은 효과, freeze 없음
const data = await ipcRenderer.invoke('user:get', 'u_123');

What — sendTo가 제거된 이유 (v12+)

ipcRenderer.sendTo(webContentsId, channel, ...args)renderer가 다른 renderer에게 직접 메시지를 보내는 API였다. v12에서 제거됐다.

왜 제거됐나

  • 권한 문제: 한 renderer가 다른 renderer의 ID를 알면 임의로 메시지를 쏠 수 있었다 — main의 권한 검사를 우회한 셈.
  • iframe 공격: 악성 광고가 떠 있는 renderer가 다른 renderer (예: 결제 창)에 임의 메시지를 보낼 수 있었다.
  • 대체: MessagePortMain (05 문서) — main이 중개해서 port를 명시적으로 전달하는 방식. 권한이 명시적으로 위임된다.

대체 패턴

// 옛날 (v11 이하, 제거됨)
ipcRenderer.sendTo(otherWebContentsId, 'msg', payload);
 
// 지금 — main을 중계로
// renderer A
window.api.sendToB(payload);
 
// preload
contextBridge.exposeInMainWorld('api', {
  sendToB: (p) => ipcRenderer.invoke('relay:to-b', p),
});
 
// main
ipcMain.handle('relay:to-b', (event, p) => {
  // event.sender로 *누가* 보냈는지 검증
  const bWin = BrowserWindow.fromId(bWinId);
  bWin.webContents.send('msg-from-a', p);
});

What — 채널 이름 컨벤션

채널 이름은 전역 namespace다. 라이브러리·앱이 같은 채널을 우연히 쓰면 메시지가 교차한다.

안 좋은 이름

ipcMain.handle('data', ...);       // ❌ 너무 일반적
ipcMain.handle('get', ...);        // ❌ 무엇을 get?
ipcMain.handle('event', ...);      // ❌ Electron event와 혼동
ipcMain.handle('message', ...);    // ❌ postMessage와 혼동

좋은 이름 — domain:action 또는 domain/action

ipcMain.handle('fs:read', ...);
ipcMain.handle('fs:write', ...);
ipcMain.handle('user:get', ...);
ipcMain.handle('app:get-version', ...);
ipcMain.handle('dialog:show-open', ...);

규칙

  1. 도메인 prefixfs:, user:, app:, auth:
  2. 동사로 끝나기get, create, delete, list
  3. 점이 아니라 콜론 또는 슬래시.은 nested object 느낌이지만 그냥 string이라 혼동의 원인
  4. kebab-caseget-version, show-open-dialog (JavaScript 식별자 제약 없음)
  5. 하나의 enum/const로 묶기:
// shared/channels.ts
export const Channels = {
  fsRead: 'fs:read',
  fsWrite: 'fs:write',
  userGet: 'user:get',
} as const;
 
// main.ts
ipcMain.handle(Channels.fsRead, ...);
 
// preload.ts
ipcRenderer.invoke(Channels.fsRead, path);

이렇게 하면 오타 채널컴파일 타임에 잡힌다.


Insight — 한 단락 이야기

handle은 2018년에야 추가됐다 — 그 전엔 모두가 send + reply로 동기화 버그를 만들었다”

Electron v0.xv6 시절(20152019), invoke/handle존재하지 않았다. 모두가 이렇게 짰다:

ipcRenderer.send('user:get', id);
ipcRenderer.once('user:get-reply', (e, data) => { ... });

문제는 — 동시에 두 번 'user:get'을 부르면 어느 응답이 어느 요청 것인지 알 수가 없었다는 점. 그래서 requestId를 직접 만들어 매번 채널 이름에 박아 넣는 수공 RPC 프레임워크가 앱마다 생겼다. Electron v7(2019)에서 ipcRenderer.invoke + ipcMain.handle이 추가됐다 — Promise 기반에 requestId가 내부에서 자동 관리된다. 이걸 본 커뮤니티 반응은 한 단어로 “finally” 였다. 그 이후 모든 Electron 보일러플레이트가 invoke/handle을 디폴트로 한다.


요약 + Mermaid

응답권장도
invokehandlePromise⭐⭐⭐
sendon없음⭐⭐
webContents.sendon없음⭐⭐
postMessageon('event', { ports })MessagePort⭐ (고급)
sendSyncon (event.returnValue)동기🚫
sendTo❌ 제거됨

한 줄 결론짝이 어울리는지, 채널 이름이 충돌하지 않는지가 IPC 코드 리뷰의 1·2번이다. 다음 문서(03)는 이 API들을 renderer에 어떻게 안전하게 노출하는지 — contextBridge를 본다.