⚡ Electron3. IPC & Bridgeinvoke / handle 패턴

invoke / handle 패턴

이 문서가 답하는 질문: “Promise 기반 IPC는 어떻게 동시 호출을 섞이지 않게 처리하고, 에러는 어떻게 전달되는가?” 한 줄 답 (Pyramid Top): “invoke/handle은 requestId 매칭에러 직렬화를 표준화한 Promise-기반 RPC다 — send/on의 수동 응답 짝짓기를 자동화한 결과.”


Why — 왜 존재하는가

send/on은 한 방향이라 응답이 또 다른 채널로 와야 했다. 응답을 누구의 요청에 매칭하는지는 개발자가 직접 관리. 동시에 여러 호출이 가면 답이 섞일 위험. 에러도 별도 채널 또는 결과 객체로 전달해야 했다.

문제send/oninvoke/handle
응답 매칭채널 분리 또는 수동 IDrequestId 자동
동시 호출답 섞임 위험Promise 인스턴스 격리
에러 전파별도 채널 또는 결과 객체throw → reject 자동
코드 양송신/수신 2벌한 줄 await

웹의 fetch() API가 XHR + 콜백Promise + 한 줄로 대체한 것과 같은 패턴.


How — 어떻게 동작하는가

requestIdpreload 내부에서 자동 관리된다 — 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/onfire-and-forget에만 사용한다 (예: telemetry, log).

에러 직렬화 — 자동·반자동

invoke가 자동으로 reject로 던지는 건 Error.messageError.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);
방식장점단점
throwreject코드 짧음커스텀 필드 유실
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: invoketrip 후 처음 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/handlePromise 기반인 점이 모든 것을 바꿨다. try/catch + async/await로 IPC를 다룰 수 있게 되면서, Electron 코드의 모양이 React/Vue의 데이터 호출과 비슷해졌다. 그게 프론트엔드 개발자가 Electron에 더 친숙해진 결정적 변화였다.


요약

  • 표준은 invoke/handle. send/onfire-and-forget 전용.
  • 에러 직렬화 한계 — 커스텀 필드는 Result 객체에 담는다.
  • 타입 안전성 = 공유 contract + typed wrapper.
  • 동시 호출은 requestId로 자동 격리. 직접 관리할 필요 없음.