Skip to Content
TypeScriptElectron IPC에서 타입 안정성 확보하기

Electron IPC에서 타입 안정성 확보하기

일렉트론으로 토이 프로젝트를 진행하면서, 메인 프로세스와 렌더러 프로세스 간 IPC 과정에서 중복되는 코드가 자주 발생하고, 타입 추론이 제대로 되지 않는다는 문제를 경험했습니다.

  • (*IPC :Inter-Process Communication, 프로세스 간 통신. 서로 다른 실행 컨텍스트 간 데이터를 주고받기 위한 통신 메커니즘)

이 방법은 문자열 기반의 채널명을 통해 데이터를 주고받기 때문에, 어떤 데이터를 전달해야 하고 어떤 형태의 응답을 받을 수 있는지에 대한 정보가 코드상에 명확히 드러나지 않습니다.

이로 인해 호출 시점에서 데이터의 타입이 추론되지 않고, 채널명을 변경하거나 리팩토링할 때도 타입 오류로 포착되지 않아 런타임 오류 가능성이 높아지는 문제가 있었습니다.

이 문제를 해결하기 위해 trpc라는 프레임워크에서 아이디어를 얻어, IPC를 호출하는 쪽과 구현하는 쪽 모두에서 타입이 완전하게 추론되도록 지원하는 TypeScript 유틸리티를 구현해보았습니다.

일렉트론에서 메인 프로세스와 렌더러 프로세스가 통신하는 방법

일렉트론은 아래와 같은 구조를 띄고 있습니다.

  • 메인 프로세스 (Main Process): Electron 애플리케이션의 백그라운드에서 실행되며, 애플리케이션의 라이프 사이클을 관리하고 시스템 리소스에 직접 접근합니다.

  • 렌더러 프로세스 (Renderer Process): UI를 표시하며, 각각의 브라우저 창에 대응되는 독립적인 환경에서 실행되어 사용자 인터페이스와 사용자 상호작용을 처리합니다.

일렉트론 구조

즉, 메인 프로세스가 백엔드의 역할을 수행하고 렌더러 프로세스가 프론트엔드의 역할을 수행하게 됩니다.

프로세스 간 통신을 구현할 때는 목적에 따라 사용할 수 있는 메소드가 여러 가지 있습니다.

  • ipcRenderer.send(channel, …args)

    • Renderer Process에서 Main Process로 비동기 메시지를 보내는 데 사용됩니다.
  • ipcMain.on(channel, listener)

    • Main Process에서 특정 채널로부터 메시지를 지속적으로 수신하도록 리스너를 설정합니다.
  • ipcRenderer.invoke(channel, …args)

    • Renderer Process에서 Main Process로 메시지를 비동기적으로 보내고, Promise를 반환받아 결과를 기다립니다.

이 외에도 다양한 메소드가 있으며, 추가적인 내용은 Electron ipcRenderer 공식 문서에서 확인할 수 있습니다.

일반적으로 단순히 데이터를 메인 프로세스로 전송만 할 경우에는 send 메소드를, 응답을 함께 받아야 하는 경우에는 invoke를 사용합니다.

실제 프로젝트에서는 send와 on 조합은 로깅이나 알림(toast) 등 단방향 처리에 주로 사용했고, 그 외 대부분의 기능은 REST API를 호출하는 것처럼 요청-응답 구조를 갖는 invoke 기반의 통신 방식으로 구성했습니다.

invoke 메소드 사용법

렌더러 프로세스에서 메인 프로세스로 데이터를 보낼 때, 첫 번째 인자로 문자열 형태의 채널 이름을 전달합니다. 이 채널 이름은 메인 프로세스에서도 동일하게 등록되어 있어야 통신이 정상적으로 이루어집니다.

두 번째 인자에는 메인 프로세스로 전달할 직렬화 가능한 데이터를 넘깁니다. Electron의 IPC는 Structured Clone Algorithm을 기반으로 직렬화를 수행하므로, 함수, Symbol, WeakMap 등은 전달할 수 없습니다.

// Renderer process ipcRenderer.invoke("channel", someArgument).then((result) => { // ... });

메인 프로세스에서는 ipcMain.handle을 통해 해당 채널을 처리합니다. 첫 번째 인자에는 채널 이름을 동일하게 입력해주고, 두 번째 인자로 비즈니스 로직을 처리하는 콜백 함수를 등록합니다.

// Main process ipcMain.handle("channel", async (event, someArgument) => { const result = await doSomeWork(someArgument); return result; });

invoke 메소드의 불편함

코드가 단순하고 적을때는 큰 문제가 되지 않았지만, 코드베이스가 커지면서 invoke 를 사용하는 IPC 방식에 몇 가지 불편함을 느꼈습니다.

  1. 타입 안전성 부족
  • invoke는 채널에 전달하는 인자와 반환값 모두에 대해 TypeScript의 타입 추론을 제공하지 않습니다.

    • ipcRenderer.invoke(channel: string, ...args: any[]): Promise<any>
  • 어떤 데이터를 전달해야 하고, 어떤 결과가 반환되는지 호출 시점에서 명확히 알기 어렵습니다.

  1. 문자열 기반 채널 통신 구조
  • 메세지를 주고받는 채널이 문자열로 지정되기 때문에, 이름을 변경하거나 리팩토링할 경우 컴파일 단계에서 오류를 감지하지 못해 런타임 오류가 발생할 수 있습니다.

invoke 메소드 개발자 경험 (DX) 개선하기

이러한 문제를 해결하기 위해 아래와 같은 목표를 가지고 개발자 경험을 개선하고자 했습니다.

  1. invoke 호출 시 전달 인자와 반환값 모두에 대해 정적 타입 추론이 가능하도록 만들기

  2. 런타임 유효성 검증을 위해 zod 기반 스키마를 활용

  3. 문자열 채널 대신 함수명을 채널로 매핑하여 코드 일관성과 유지보수성을 향상

이 구조는 trpc에서 착안했고, 메인 프로세스에서 데이터를 처리하는 함수를 래핑하는 형태로 구현했습니다. 이를 통해 정적 타입 추론과 런타임 검증을 가능하게 만들 수 있습니다.

메인 프로세스: zod validation과 타입 추론이 되는 핸들러 래핑 함수 만들기

먼저 메인 프로세스에서 데이터를 핸들링 할 때 사용하는 함수를 래핑하는 함수를 제작했습니다.

핸들러 래핑 함수 구조

첫번째 인자로 zod schema를 입력 받습니다. 이는 2가지 용도로 활용 되는데요,

  1. 함수 실행 시, 인자로 받은 데이터를 검증해서 잘못된 데이터가 들어온건 아닌지 판별합니다.
  2. 타입을 추출하여 함수의 인자 타입을 자동으로 추론합니다.

두번째 인자로는 함수의 내용을 입력 받습니다. 비지니스 로직을 입력하면 되며 zod schema의 (2)번 용도로 인자의 타입을 별도로 지정해주지 않아도 됩니다.

이렇게 구현하면 함수 실행 전에 인자로 전달된 값을 스키마를 통해 정밀하게 검증할 수 있어, 런타임에서 발생할 수 있는 예외 상황을 사전에 방지할 수 있습니다. 동시에 zod로부터 타입 정보를 추론하여 함수 정의 시 별도의 타입을 명시하지 않아도 인자 타입이 자동으로 유추됩니다.

실제 구현 코드는 아래와 같습니다.

export const ipcFunction = <T extends z.ZodObject<any>, S>( inputValidator: T, func: (input: z.infer<T>) => S ) => { return async (payload: z.infer<T>) => { const parsed = inputValidator.safeParse(payload); if (parsed.success) { return { success: true, data: await func(parsed.data), }; } return { success: false, error: parsed.error.errors, }; }; };

ipcFunction으로 래핑 시 함수의 인자가 자동으로 zod로 지정한 타입으로 추론됩니다.

스키마 파싱 성공시에는 인자로 넘긴 비지니스 로직 함수를 실행하고, 실패했다면 에러를 반환합니다. zod에서 safeParse 메소드는 파싱 실패 시 throw를 하지 않고 success라는 변수에 성공/실패 유무를 담아 반환합니다. 만약 try-catch로 에러를 처리하고 싶다면 safeParse대신 parse메소드를 사용할 수 있습니다.

  • 제네릭 타입 Tz.ZodObject 타입으로 입력 스키마를 받습니다. 이 스키마는 런타임 검증과 타입 추론을 위해 사용됩니다.

  • func는 실제 비즈니스 로직을 담고 있는 함수이며, z.infer<T>를 통해 스키마에서 자동으로 타입을 추출해줍니다.

함수 인자를 zod 스키마의 타입으로 추론하기

렌더러 프로세스: IPC 호출 시 타입이 추론되도록 하기

이후 preload.ts 파일에서 invoke의 타입을 지정해줍니다.

총 3개의 제네릭 타입을 사용합니다.

  • 채널의 타입은 ipcFunction으로 래핑된 함수의 이름
  • 인자의 타입은 zod 스키마에서 타입을 파싱
  • 리턴 타입은 ipcFunction으로 래핑된 함수의 반환 타입

예제

이를 코드로 구현하면 아래와 같습니다.

export const ipcController = { ... } // { [function: string]: Function } 의 구조로, ipcFunction 으로 래핑된 함수들을 모아 놓습니다. export type IPC_CONTROLLER_TYPE = typeof ipcController; const electronHandler = { // ... invoke: <ChannelType extends keyof IPC_CONTROLLER_TYPE>( // ipcController 객체의 키(채널 이름) 중 하나를 선택 channel: ChannelType, args: Parameters<IPC_CONTROLLER_TYPE[ChannelType]>[0], // 해당 채널에 등록된 함수의 첫 번째 인자 타입을 추론 ): Promise<ReturnType<IPC_CONTROLLER_TYPE[ChannelType]>> => { // 해당 채널의 함수가 반환하는 타입을 추론하고, Promise<...>로 감싸주기 return ipcRenderer.invoke(channel, args); } }

이제 렌더러 프로세스에서 invoke를 사용 시 반환 결과가 타입 추론 되는 것을 볼 수 있습니다.

invoke의 결과 타입 추론

Conclusion

요약하자면, 메인 프로세스에서는 ipcFunction을 사용해 zod 기반 스키마와 함께 각 핸들러를 래핑하고, 이를 ipcController 객체에 모아 관리합니다. 렌더러 프로세스에서는 이 객체의 타입 정보를 활용하여 invoke 호출 시 채널 이름, 인자, 반환값에 대해 타입을 추론할 수 있도록 구성했습니다.

zod를 활용해 인자의 유효성을 검증하고, 정적 타입 추론이 가능한 구조를 구성함으로써 메인 프로세스와 렌더러 프로세스 간 타입 안정성을 확보할 수 있었습니다.

Last updated on