본문 바로가기
JavaScript | 자바스크립트/Electron

Electron IPC 통신 사용성 올리기

by Pig_CoLa 2024. 11. 15.
반응형

IPC 통신

IPC(Inter-Process Communication)는 프로세스 간 통신을 의미합니다.
Electron에서는 메인 프로세스와 렌더러 프로세스 간의 통신에 사용됩니다.

Electron의 IPC 통신 특징

  • 비동기적 통신 지원
  • 양방향 통신 가능
  • 보안성 강화를 위한 컨텍스트 격리

ipcMain과 ipcRenderer

ipcMain은 메인 프로세스에서, ipcRenderer는 렌더러 프로세스에서 사용되는 모듈입니다.

메인 프로세스

  • 애플리케이션의 생명주기 관리
  • 시스템 리소스 접근 (파일 시스템, 네트워크 등)
  • 네이티브 API 사용 (메뉴, 트레이 아이콘 등)
  • 새 렌더러 프로세스 생성 및 관리

렌더러 프로세스

  • 웹 페이지 렌더링 및 사용자 인터페이스 표시
  • DOM 조작 및 웹 API 사용
  • 제한된 Node.js API 접근 (contextIsolation 설정에 따라)
  • 사용자 입력 처리 및 UI 이벤트 관리

Electron IPC 통신의 기본 패턴

가장 기본적인 형태는 아래와 같은 구성으로 핸들러를 등록하고 사용하는 형태입니다.

// src/ipcMethods.ts

import { ipcMain } from 'electron'

ipcMain.handle( 'foo', ( e, arg1: string ) => {
  console.log( 'bar!!!', arg1 )

  return 100
} )

ipcMain.handle( 'alpha', async ( e, arg1: number, arg2: number, arg3: number ) => {
  // 1초 딜레이
  await new Promise<void>( ( res ) => {
    setTimeout( () => res(), 1000 )
  } )
  console.log( 'bravo!!!', arg1, arg2, arg3 )

  return arg1 + arg2 === arg3
} )
// src/main.ts

import './ipcMethods'

import { app, BrowserWindow } from 'electron'

...
// src/react/some.tsx

const { ipcRenderer } = window.require( 'electron/renderer' )

const Some = () => {
  const action = async () => {
    const a = await ipcRenderer.invoke( 'foo', 33 )
    const b = await ipcRenderer.invoke( 'alpha', 'hi', 'hello', 'hihello' )

    console.log( a, b )
  }

  return <button onClick={action}>click</button>
}
export default Some

불편한 사용성

ipcMain과 ipcRenderder 함수를 그대로 사용하게 되면 개발 과정에서 여러 불편한 점이 있습니다.

  • ipcRenderer는 어떤 채널이 등록되어 있는지 알 수 없다.
  • ipcRenderer는 지금 통신하려는 채널의 전달인자가 몇 개 있을지, 어떤 타입인지 알 수 없다.
  • ipcRenderer는 지금 통신하려는 채널의 반환 값이 어떤 타입인지 알 수 없다.

실제로 위 예시를 자세히 보면 ‘foo’ 채널에서는 첫 번째 전달인자로 string타입을 원하고 있지만 number타입을 입력하고 있습니다. 하지만 invoke함수에서 모든 전달인자의 타입은 any 이기 때문에 ts서버는 오류를 띄우지 않습니다. 반환값의 타입 또한 알 수 없겠죠.

그러다 보니 alpha채널의 통신은 전달인자들 에게number타입을 원하였고 이중 일부를 더한(숫자 연산)값이 한 값과 일치하는 지를 돌려주는데도 불구하고,

string타입을 넣어 문자열 연산으로 계산해 일치 여부를 돌려주는 의도와 다른 (이 경우에는 심지어 에러조차 나지 않으니 발견이 늦어질 수 있습니다) 결과를 받게 될 수 있습니다.


Electron IPC 통신 사용성 향상 방법

저는 위와 같은 상황을 타개하기 위해 아래의 방법을 사용했습니다.

  1. ipcHandler타입을 만들어 채널과 함수에 대해 정의
  2. ipcMainipcRenderer의 커스텀

ipcHandler 타입 정의

채널명을 key로 하는 type을 export가능하게 생성해 줍니다.

// src/ipcMethods.ts

export type IpcHandler = {
  foo: ( arg1: string ) => number
  alpha: ( arg1: number, arg2: number, arg3: number ) => Promise<boolean>
}

ipcMainipcRenderer의 커스텀

이제 ipcHandler타입을 받아와 핸들을 등록하거나 invoke로 통신할 때에 활용할 수 있도록 구성해줍시다.

ipcMain

먼저 Electron.IpcMain를 상속 하는 customIpcMain이라는 인터페이스를 생성합니다.
이 때, 인터페이스와 핸들에 제네릭을 지정하여 ipcHandler와 연계할 수 있도록 구성했습니다.

// src/ipcMethods.ts

interface CustomIpcMain<T extends Record<string, ( ...args: unknown[] ) => unknown>> extends Electron.IpcMain {
  handle: {
    <J extends keyof T>(
      channel: J,
      fn: ( ...args: [e: Electron.IpcMainInvokeEvent, ...Parameters<T[J]>] ) => ReturnType<T[J]>
    ): void
  }
}

먼저 상속할 IpcMain의 handle에는 callback 함수의 첫 번째 전달인자로 이벤트가, 이후에는 각 핸들러 함수의 전달인자들이 들어올 수 있으니 이에 대응하여 구성해 둡니다.

ipcRenderer

// src/react/hooks/useIpcRenderer.ts

import type { IpcHandler } from 'src/ipcMethods'

interface CustomIpcRenderer<T extends Record<string, ( ...args: unknown[] ) => unknown>> extends Electron.IpcRenderer {
  invoke: {
    <J extends keyof T>( channel: J, ...args: Parameters<T[J]> ): Promise<Awaited<ReturnType<T[J]>>>
  }
}

렌더러에서 역시 IpcRenderer의 invoke함수에 맞도록 맞춰줘야 하는데,
Promise<Awaited<ReturnType<T[J]>>> 구문에서 알 수 있듯이 invoke함수는 항상 Promise를 돌려줍니다.

다만 핸들러 함수가 비동기 함수이든 일반 함수 이든 대응할 수 있도록 Awaited 유틸 타입을 이용했습니다. (Promise<Promise<any>>와 같은 형태는 존재해선 안되니까요..!

실제 코드와 연결 및 활용 이점

// src/ipcMethods.ts

// 다른 변수명으로 import 해둔다
import { ipcMain as _ipcMain } from 'electron'

export type IpcHandler = {
  foo: ( arg1: string ) => number
  alpha: ( arg1: number, arg2: number, arg3: number ) => Promise<boolean>
}

interface CustomIpcMain<T extends Record<string, ( ...args: unknown[] ) => unknown>> extends Electron.IpcMain {
  handle: {
    <J extends keyof T>(
      channel: J,
      fn: ( ...args: [e: Electron.IpcMainInvokeEvent, ...Parameters<T[J]>] ) => ReturnType<T[J]>
    ): void
  }
}

// 원래 할당하려던 변수에 커스텀 인터페이스 및 핸들러 타입 연결
const ipcMain = _ipcMain as CustomIpcMain<IpcHandler>

ipcMain.handle( 'foo', ( e, arg1 ) => {
  console.log( 'bar!!!', arg1 )

  return 100
} )

ipcMain.handle( 'alpha', async ( e, arg1, arg2, arg3 ) => {
  // 1초 딜레이
  await new Promise<void>( ( res ) => {
    setTimeout( () => res(), 1000 )
  } )
  console.log( 'bravo!!!', arg1, arg2, arg3 )

  return arg1 + arg2 === arg3
} )

이젠 ipcMain에서 핸들에 등록 할 때에 어떤 채널이 있는지, 해당 채널엔 어떤 유형의 callback함수가 필요한 지 알 수 있습니다.

// src/react/hooks/useIpcRenderer.ts

import type { IpcHandler } from 'src/ipcMethods'

interface CustomIpcRenderer<T extends Record<string, ( ...args: unknown[] ) => unknown>> extends Electron.IpcRenderer {
  invoke: {
    <J extends keyof T>( channel: J, ...args: Parameters<T[J]> ): Promise<Awaited<ReturnType<T[J]>>>
  }
}

// 이 hooks는 react hooks의 기본 기능을 사용하지 않아 다른 위치에서도 호출이 가능하다.
const ipcRendererLazy = {} as {
  // 커스텀 인터페이스 및 핸들러 타입 연결
  ipcRenderer: CustomIpcRenderer<IpcHandler>
}

export function useIpcRenderer() {
  if ( !ipcRendererLazy.ipcRenderer ) {
    const { ipcRenderer } = window.require( 'electron/renderer' )
    ipcRendererLazy.ipcRenderer = ipcRenderer
  }

  return { ipcRenderer: ipcRendererLazy.ipcRenderer }
}

// src/react/some.tsx

import { useIpcRenderer } from './hooks/useIpcRenderer'

const Some = () => {
  const { ipcRenderer } = useIpcRenderer()

  const action = async () => {
    const a = await ipcRenderer.invoke( 'foo', 33 )
    const b = await ipcRenderer.invoke( 'alpha', 'hi', 'hello', 'hihello' )

    console.log( a, b )
  }

  return <button onClick={action}>click</button>
}
export default Some

ipcRenderer에서도 invoke할 때, 잘못된 채널로 통신을 시도하거나, 해당 채널에 잘못된 배개변수를 넣지 못하도록 오류를 띄울 수 있게 되었습니다.

또한, ipcMain에서 처리한 리턴값의 타입을 알 수 있게 되었습니다.


결론

이러한 방식으로 Electron의 IPC 통신을 개선함으로써 아래와 같은 이점을 얻을 수 있습니다:

  • 타입 안정성:
    잘못된 채널명이나 매개변수 사용을 컴파일 시점에 방지할 수 있다.
  • 코드 자동완성
    IDE에서 채널명과 매개변수에 대한 자동완성 기능을 활용할 수 있어 개발 생산성이 향상된다.
  • 가독성 향상
    코드의 의도를 명확히 파악할 수 있어 유지보수가 용이해진다.
  • 런타임 오류 감소
    타입 검사로 인해 런타임에 발생할 수 있는 오류를 사전에 방지할 수 있습니다.

결과적으로, 이러한 접근 방식은 Electron 애플리케이션의 안정성을 높이고 개발 과정을 더욱 효율적으로 만들어줄 수 있습니다. TypeScript의 강력한 타입 시스템을 활용하여 IPC 통신의 안정성과 사용성을 크게 향상시킬 수 있으며, 이는 대규모 Electron 프로젝트에서 특히 유용할 것 같습니다.

반응형

'JavaScript | 자바스크립트 > Electron' 카테고리의 다른 글

Electron + NextUI / focus error  (0) 2024.11.09

댓글