02 · ipcMain · ipcRenderer — 두 모듈의 API 전수
이 문서가 답하는 질문: 두 모듈의 메서드는 정확히 몇 개고, 언제 어느 것을 써야 하나? 왜
sendSync는 절대 쓰면 안 되나? 한 줄 답: “가장 안전한 짝은ipcRenderer.invoke↔ipcMain.handle단 하나다. 나머지는 알아야 피할 수 있는 API들이다.”
Why — API가 많아 보이지만 실제로는 2짝
Electron 공식 문서를 처음 보면 ipcMain과 ipcRenderer에 메서드가 8~10개씩 있어 압도된다. 그러나 실용에서 쓰는 짝은 사실 셋뿐이다.
| 통신 방향 | 비동기 응답 필요 | 권장 짝 |
|---|---|---|
| Renderer → Main, 응답 받음 | yes | ipcRenderer.invoke ↔ ipcMain.handle |
| Renderer → Main, fire-and-forget | no | ipcRenderer.send ↔ ipcMain.on |
| Main → Renderer, push | no | webContents.send ↔ ipcRenderer.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개만 등록되어 충돌 감지가 쉬움
- 응답 그대로 return —
reply패턴 불필요
패턴 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', ...);규칙
- 도메인 prefix —
fs:,user:,app:,auth:등 - 동사로 끝나기 —
get,create,delete,list - 점이 아니라 콜론 또는 슬래시 —
.은 nested object 느낌이지만 그냥 string이라 혼동의 원인 - kebab-case —
get-version,show-open-dialog(JavaScript 식별자 제약 없음) - 하나의 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.x
v6 시절(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
| 짝 | 응답 | 권장도 |
|---|---|---|
invoke ↔ handle | Promise | ⭐⭐⭐ |
send ↔ on | 없음 | ⭐⭐ |
webContents.send ↔ on | 없음 | ⭐⭐ |
postMessage ↔ on('event', { ports }) | MessagePort | ⭐ (고급) |
sendSync ↔ on (event.returnValue) | 동기 | 🚫 |
sendTo | — | ❌ 제거됨 |
한 줄 결론 — 짝이 어울리는지, 채널 이름이 충돌하지 않는지가 IPC 코드 리뷰의 1·2번이다. 다음 문서(03)는 이 API들을 renderer에 어떻게 안전하게 노출하는지 — contextBridge를 본다.