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 방식에 몇 가지 불편함을 느꼈습니다.
- 타입 안전성 부족
-
invoke는 채널에 전달하는 인자와 반환값 모두에 대해 TypeScript의 타입 추론을 제공하지 않습니다.
ipcRenderer.invoke(channel: string, ...args: any[]): Promise<any>
-
어떤 데이터를 전달해야 하고, 어떤 결과가 반환되는지 호출 시점에서 명확히 알기 어렵습니다.
- 문자열 기반 채널 통신 구조
- 메세지를 주고받는 채널이 문자열로 지정되기 때문에, 이름을 변경하거나 리팩토링할 경우 컴파일 단계에서 오류를 감지하지 못해 런타임 오류가 발생할 수 있습니다.
invoke 메소드 개발자 경험 (DX) 개선하기
이러한 문제를 해결하기 위해 아래와 같은 목표를 가지고 개발자 경험을 개선하고자 했습니다.
-
invoke 호출 시 전달 인자와 반환값 모두에 대해 정적 타입 추론이 가능하도록 만들기
-
런타임 유효성 검증을 위해 zod 기반 스키마를 활용
-
문자열 채널 대신 함수명을 채널로 매핑하여 코드 일관성과 유지보수성을 향상
이 구조는 trpc에서 착안했고, 메인 프로세스에서 데이터를 처리하는 함수를 래핑하는 형태로 구현했습니다. 이를 통해 정적 타입 추론과 런타임 검증을 가능하게 만들 수 있습니다.
메인 프로세스: zod validation과 타입 추론이 되는 핸들러 래핑 함수 만들기
먼저 메인 프로세스에서 데이터를 핸들링 할 때 사용하는 함수를 래핑하는 함수를 제작했습니다.
첫번째 인자로 zod schema
를 입력 받습니다. 이는 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
메소드를 사용할 수 있습니다.
-
제네릭 타입
T
는z.ZodObject
타입으로 입력 스키마를 받습니다. 이 스키마는 런타임 검증과 타입 추론을 위해 사용됩니다. -
func
는 실제 비즈니스 로직을 담고 있는 함수이며,z.infer<T>
를 통해 스키마에서 자동으로 타입을 추출해줍니다.
렌더러 프로세스: 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
를 사용 시 반환 결과가 타입 추론 되는 것을 볼 수 있습니다.
Conclusion
요약하자면, 메인 프로세스에서는 ipcFunction을 사용해 zod 기반 스키마와 함께 각 핸들러를 래핑하고, 이를 ipcController 객체에 모아 관리합니다. 렌더러 프로세스에서는 이 객체의 타입 정보를 활용하여 invoke 호출 시 채널 이름, 인자, 반환값에 대해 타입을 추론할 수 있도록 구성했습니다.
zod를 활용해 인자의 유효성을 검증하고, 정적 타입 추론이 가능한 구조를 구성함으로써 메인 프로세스와 렌더러 프로세스 간 타입 안정성을 확보할 수 있었습니다.