invoke / handle 패턴
이 문서가 답하는 질문: “Promise 기반 IPC는 어떻게 동시 호출을 섞이지 않게 처리하고, 에러는 어떻게 전달되는가?” 한 줄 답 (Pyramid Top): “invoke/handle은 requestId 매칭과 에러 직렬화를 표준화한 Promise-기반 RPC다 —
send/on의 수동 응답 짝짓기를 자동화한 결과.”
Why — 왜 존재하는가
send/on은 한 방향이라 응답이 또 다른 채널로 와야 했다. 응답을 누구의 요청에 매칭하는지는 개발자가 직접 관리. 동시에 여러 호출이 가면 답이 섞일 위험. 에러도 별도 채널 또는 결과 객체로 전달해야 했다.
| 문제 | send/on | invoke/handle |
|---|---|---|
| 응답 매칭 | 채널 분리 또는 수동 ID | requestId 자동 |
| 동시 호출 | 답 섞임 위험 | Promise 인스턴스 격리 |
| 에러 전파 | 별도 채널 또는 결과 객체 | throw → reject 자동 |
| 코드 양 | 송신/수신 2벌 | 한 줄 await |
웹의
fetch()API가 XHR + 콜백을 Promise + 한 줄로 대체한 것과 같은 패턴.
How — 어떻게 동작하는가
requestId는 preload 내부에서 자동 관리된다 — Electron 코어가 UUID를 매기고 응답에 같은 UUID를 박는다. 그래서 동시에 100번 invoke해도 답이 섞이지 않는다.
What — 구체 사양·수치·예시
가장 단순한 형태
// preload.ts
contextBridge.exposeInMainWorld('api', {
getUser: (id: string) => ipcRenderer.invoke('user:get', id),
});
// main.ts
ipcMain.handle('user:get', async (_, id) => {
return db.users.findOne({ id });
});
// renderer.ts
const user = await window.api.getUser('u_1');이게 유일한 권장 패턴. send/on은 fire-and-forget에만 사용한다 (예: telemetry, log).
에러 직렬화 — 자동·반자동
invoke가 자동으로 reject로 던지는 건 Error.message와 Error.stack뿐. 커스텀 필드는 유실된다.
// main.ts
class AuthError extends Error {
constructor(public reason: string) {
super('Auth failed: ' + reason);
this.name = 'AuthError';
}
}
ipcMain.handle('login', async () => {
throw new AuthError('bad-token');
});
// renderer.ts — 받은 쪽
try {
await window.api.login();
} catch (err) {
// err는 일반 Error 인스턴스
// err.message === "Auth failed: bad-token"
// err.reason === undefined !! (커스텀 필드 사라짐)
}Result 객체 패턴 — 에러도 데이터로
복잡한 에러는 throw 대신 결과 객체로 감싸는 게 안전하다.
// shared/types.ts
export type ApiResult<T> =
| { ok: true; value: T }
| { ok: false; error: ApiError };
export type ApiError = {
code: string;
message: string;
details?: unknown;
};
// main.ts
ipcMain.handle('login', async (): Promise<ApiResult<User>> => {
try {
const user = await authenticate();
return { ok: true, value: user };
} catch (e) {
return {
ok: false,
error: {
code: 'AUTH_FAILED',
message: String(e),
details: { reason: 'bad-token' },
},
};
}
});
// renderer.ts
const res = await window.api.login();
if (!res.ok) {
showError(res.error.code, res.error.message);
return;
}
useUser(res.value);| 방식 | 장점 | 단점 |
|---|---|---|
throw → reject | 코드 짧음 | 커스텀 필드 유실 |
Result 객체 | 타입 안전·식별 명확 | 코드 약간 더 김 |
표준은 팀 컨벤션. 어느 한 방식으로 통일하는 게 중요.
타입 안전성 — TypeScript
// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
const api = {
user: {
get: (id: string) => ipcRenderer.invoke('user:get', id) as Promise<User>,
list: () => ipcRenderer.invoke('user:list') as Promise<User[]>,
},
app: {
getVersion: () => ipcRenderer.invoke('app:get-version') as Promise<string>,
},
};
export type Api = typeof api;
contextBridge.exposeInMainWorld('api', api);
// renderer/types.d.ts
import type { Api } from '../preload';
declare global {
interface Window {
api: Api;
}
}
export {};공유 contract 패턴 — 채널 한 곳에 정의
// shared/ipc-contract.ts
import type { User, UserPatch } from './types';
export type IpcContract = {
'user:get': { args: [id: string]; result: User };
'user:list': { args: []; result: User[] };
'user:update': { args: [id: string, patch: UserPatch]; result: User };
'app:get-version': { args: []; result: string };
};
export type IpcChannel = keyof IpcContract;// preload — 타입드 invoke wrapper
import { ipcRenderer } from 'electron';
import type { IpcContract, IpcChannel } from '../shared/ipc-contract';
export function typedInvoke<K extends IpcChannel>(
channel: K,
...args: IpcContract[K]['args']
): Promise<IpcContract[K]['result']> {
return ipcRenderer.invoke(channel, ...args);
}// main — 타입드 handle wrapper
import { ipcMain } from 'electron';
import type { IpcContract, IpcChannel } from '../shared/ipc-contract';
export function typedHandle<K extends IpcChannel>(
channel: K,
handler: (event: Electron.IpcMainInvokeEvent, ...args: IpcContract[K]['args']) =>
Promise<IpcContract[K]['result']> | IpcContract[K]['result']
) {
ipcMain.handle(channel, handler as any);
}
// 사용
typedHandle('user:get', async (_, id) => {
return db.users.findOne({ id });
});채널 이름 오타도 컴파일 에러로 잡힌다.
What-if — 잘못 쓰면
- 함정 1:
Error서브클래스의 커스텀 필드 → 직렬화 누락. Result 객체로 감싸야 안전. - 함정 2:
invoke가 trip 후 처음 timeout 없이 무한 대기 → main이 죽거나 채널 응답 안 보내면 영원히 pending. 직접 timeout 래핑 필요. - 함정 3: 같은 채널을 두 번 handle 등록 → 두 번째 호출에서 에러.
ipcMain.removeHandler후 재등록. - 함정 4: 큰 객체를 매번 invoke로 전송 → IPC overhead. 공유 메모리 필요 시
MessagePort사용. - 함정 5: handler가 블로킹 작업 → Main 이벤트 루프 멈춤 → 모든 창 freeze. 무거운 작업은
worker_threads또는UtilityProcess로.
timeout 래핑 예
// preload
function invokeWithTimeout<T>(channel: string, args: any[], ms: number): Promise<T> {
return Promise.race([
ipcRenderer.invoke(channel, ...args) as Promise<T>,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('IPC timeout: ' + channel)), ms)
),
]);
}Insight — 흥미로운 이야기
“invoke/handle은 Electron 7에서야 추가됐다”
Electron 1~6까지는
send/on+ 응답을 위한 별도 채널이 표준이었다. 개발자들은 매번requestId를 직접 만들고, 어느 응답이 어느 요청에 매칭되는지 직접 추적해야 했다.2019년 Electron 7에
invoke/handle이 등장하면서 — 이 수동 패턴이 코어에 흡수됐다. 같은 시기remote모듈이 deprecated로 들어갔다 (그리고 v14에서 제거). 둘은 같은 이야기다 — 편한 RPC가 필요했고,remote는 너무 마법적이라 위험했다.invoke/handle은 명시적이면서 편한 중간점이 되었다.흥미로운 건 —
invoke/handle이 Promise 기반인 점이 모든 것을 바꿨다. try/catch + async/await로 IPC를 다룰 수 있게 되면서, Electron 코드의 모양이 React/Vue의 데이터 호출과 비슷해졌다. 그게 프론트엔드 개발자가 Electron에 더 친숙해진 결정적 변화였다.
요약
- 표준은
invoke/handle.send/on은 fire-and-forget 전용. - 에러 직렬화 한계 — 커스텀 필드는 Result 객체에 담는다.
- 타입 안전성 = 공유 contract + typed wrapper.
- 동시 호출은 requestId로 자동 격리. 직접 관리할 필요 없음.