분리된 프로세스는 어떻게 협력할까?
데이터를 주고받는 통로, IPC 알아보기
Table Of Contents
들어가기
프로세스를 분리하면 UI가 멈추지 않고, 크래시를 격리할 수 있어요. 이때 데이터를 주고받는 통로가 IPC예요. 이 글에서는 Node.js와 Electron을 예로, IPC를 어떻게 설계하고 쓰는지 다뤄요.
Callback Hell 벗어나서 비동기 코드 깔끔하게 처리하기에서 비동기 코드로 콜백 지옥을 벗어나는 법을 다뤘고, 비동기 코드를 작성해도 I/O 방식에 따라 UI가 멈출 수 있다는 문제를 I/O 때문에 UI가 멈춘다면에서 살펴봤어요.
비동기 I/O를 쓰면 파일 읽기나 네트워크 요청처럼 오래 걸리는 작업을 메인 스레드를 막지 않고 처리할 수 있었어요. 하지만 대용량 미디어 변환, 실시간 데이터 분석처럼 CPU 계산 자체가 오래 걸리는 작업은 여전히 메인 스레드를 점유해요.
이전 글에서는 브라우저 환경에서 Web Worker로 스레드를 분리하는 방법을 다뤘어요. 하지만 데스크톱 앱이나 서버 환경에서는 스레드 분리만으로 부족할 때가 있어요. 이런 요구사항이 있다면 프로세스 분리를 고려해야 해요.
- 작업 프로세스가 크래시해도 앱 전체가 죽지 않아야 해요.
- 작업이 끝나면 메모리를 OS 레벨에서 확실히 회수해야 해요.
- 네이티브 라이브러리나 별도 런타임을 격리해서 실행해야 해요.
서로 다른 프로세스는 메모리를 공유하지 않아서, 데이터를 주고받을 통로가 필요해요. 이 통로가 바로 IPC(Inter-Process Communication, 프로세스 간 통신)예요.
프로세스를 분리했을 때 달라지는 점
하나의 프로세스에서 모든 작업을 처리하면 프로그램의 구조가 단순해져요. 하지만 앱이 다루는 작업의 크기와 복잡도가 커지면, 단일 프로세스로는 해결하기 어려운 문제가 생겨요.
- 사용자와 앱의 인터랙션이 어려워요.
- CPU를 오래 점유하는 작업이 UI를 그리는 작업과 동일한 프로세스에서 실행되면, CPU 작업이 끝날 때까지 화면이 멈춰요.
- 크래시가 일어나면 프로그램 전체가 영향을 받아요.
- 단일 프로세스에서 크래시가 일어나면, 앱 전체가 종료돼요.
- 프로세스를 분리하면 문제가 생긴 프로세스만 종료되고, 나머지 프로세스는 여전히 작동해요.
- 리소스 격리가 어려워요.
- 운영체제는 프로세스마다 별도의 메모리와 CPU 시간을 할당해요.
- 따라서 작업 프로세스가 CPU를 많이 사용해도 UI 프로세스는 영향을 덜 받아요.
따라서 프로세스를 분리하면 이런 장점을 얻을 수 있어요.
- UI 응답성을 유지할 수 있어요.
- CPU 작업과 UI 작업을 별도의 프로세스로 분리하면 사용자와 UI의 인터랙션을 계속 처리할 수 있어요.
- 크래시를 격리할 수 있어요.
- 문제가 생긴 프로세스만 종료되고, 나머지 프로세스는 여전히 작동해요.
- 리소스를 격리할 수 있어요
- 작업 프로세스가 CPU를 많이 사용해도 UI 프로세스는 영향을 덜 받아요.
그런데 프로세스는 서로의 메모리에 접근할 수 없어요.
- 진행률을 보여주려면 작업 프로세스가 UI 프로세스에게 진행률을 보내야 해요.
- 취소하려면 UI 프로세스가 작업 프로세스에게 취소를 알려야 해요.
- 결과를 쓰려면 작업 프로세스가 결과를 전달해야 해요.
분리된 둘이 협력하려면 통로가 필요해요.
이 통로가 IPC(Inter-Process Communication, 프로세스 간 통신)예요.
IPC란
IPC(Inter-Process Communication, 프로세스 간 통신)는 서로 다른 프로세스가 데이터를 주고받는 방식이에요.
IPC는 JavaScript나 특정 언어에 국한된 개념이 아니라, 운영체제가 제공하는 기본 매커니즘이에요. 1960년대 멀티태스킹 운영체제가 등장하면서, 여러 프로세스가 안전하게 협력할 수 있는 방법이 필요했어요.
운영체제는 각 프로세스를 격리된 메모리 공간에서 실행해요. 한 프로세스가 크래시해도 다른 프로세스에는 영향을 주지 않아요. 격리된 프로세스가 협력하려면 데이터를 주고받을 방법이 필요해요. 이때 IPC로 데이터를 주고받아요.
IPC는 서로 다른 프로세스가 데이터를 주고받는 방법이에요.
프로세스 간 데이터 전달 방식
IPC 방식은 크게 네 가지예요.
- Pipe(파이프): 한쪽에서 쓰고 다른 쪽에서 읽는 단방향 데이터 채널이에요.
- 같은 머신의 부모-자식 프로세스 간 통신에 적합하고, 설정이 간단해요.
- 파이프는 단방향이기 때문에, 양방향으로 통신해야 한다면 파이프를 2개 만들어야 해요.
- Node.js의
child_process.fork()는 pipe를 사용해요.
- Socket(소켓): 네트워크를 통한 양방향 통신 채널이에요.
- 같은 머신에서는 Unix Domain Socket을, 다른 머신 사이에는 TCP Socket을 사용해요.
- 양방향 통신이 자연스럽고 원격 프로세스와도 통신할 수 있지만, Pipe보다 설정이 복잡해요.
- Electron은 메인 프로세스와 렌더러 프로세스 사이에 통신하기 위해 소켓을 사용해요.
- Shared Memory(공유 메모리): 여러 프로세스가 같은 메모리 영역을 직접 읽고 쓰는 방식이에요.
- 데이터를 복사하지 않기 때문에 대용량 데이터를 주고받을 때 가장 빨라요.
- 여러 프로세스가 같은 메모리에 동시에 접근하면 데이터가 꼬일 수 있어서,
Atomics같은 동기화 도구를 함께 사용해야 해요. - JavaScript에서는
SharedArrayBuffer로 사용할 수 있어요.
- Message Queue(메시지 큐): 운영체제가 관리하는 큐에 메시지를 넣고 빼는 방식이에요.
- 보내는 쪽과 받는 쪽이 동시에 실행되지 않아도 돼요.
- 작업 요청을 쌓아두고 순서대로 처리하는 구조에 유리해요.
아래에서 UI 프로세스와 작업 프로세스가 메시지를 주고받는 흐름을 볼 수 있어요.
프로세스는 메모리를 공유하지 않아서, 메시지를 보낼 때 데이터를 직렬화(serialize) → 전송 → 역직렬화(deserialize) 해야 해요. 큰 데이터를 그대로 보내면 복사와 직렬화 비용이 커져서 IPC가 병목이 되기 쉬워요.
JavaScript 환경에서 IPC 사용하기
브라우저에서는 프로세스를 직접 생성할 수 없어서 Web Worker(스레드 분리)를 사용해요.
하지만 Node.js나 Electron 같은 환경에서는 child_process를 통해 프로세스를 직접 분리할 수 있어요.
Node.js 예제
Node.js에서는 child_process.fork()로 자식 프로세스를 만들고, 메시지를 주고받아요.
부모는 send로 작업을 보내고, message 이벤트로 진행률·결과를 받아요.
자식은 process.on('message')로 요청을 받고, process.send로 보내요.
부모 프로세스 (main.js)
import { fork } from "child_process"; const worker = fork("worker.js"); // 자식 프로세스에 작업을 요청해요 worker.send({ type: "TASK", payload: { filePath: "/tmp/video.mp4" } }); // 자식 프로세스로부터 메시지를 받아와요 worker.on("message", (msg) => { if (msg.type === "PROGRESS") { console.log(`진행률: ${msg.percent}%`); } else if (msg.type === "COMPLETE") { console.log("작업 완료:", msg.result); } });
자식 프로세스 (worker.js)
process.on("message", async (msg) => { if (msg.type === "TASK") { process.send({ type: "PROGRESS", percent: 10 }); // 무거운 작업을 처리해요 const result = await doHeavyWork(msg.payload.filePath); // 진행률을 부모 프로세스로 전달해요 process.send({ type: "PROGRESS", percent: 100 }); // 작업 완료를 부모 프로세스에 알려요 process.send({ type: "COMPLETE", result }); } });
Electron 예제
Electron으로 데스크톱 앱을 만들 때는 **렌더러 프로세스(UI)**와 **메인 프로세스(Node.js)**가 완전히 분리돼 있어요.
렌더러는 ipcRenderer.send로 요청을 보내고, on으로 진행률·완료를 받아요.
메인은 ipcMain.on으로 요청을 받고, event.sender.send로 응답해요.
렌더러 프로세스 (UI)
import { ipcRenderer } from "electron"; // 메인 프로세스에 작업 요청 function handleConvertClick() { ipcRenderer.send("convert-video", { filePath: "/path/to/video.mp4" }); } // 메인 프로세스로부터 진행률을 받아와요 ipcRenderer.on("conversion-progress", (event, percent) => { updateProgressBar(percent); }); // 메인 프로세스에서 작업 완료 사실을 받아와요 ipcRenderer.on("conversion-complete", (event, result) => { showSuccessMessage("변환 완료!"); });
메인 프로세스 (Node.js)
import { ipcMain } from "electron"; // 렌더러 프로세스로부터 작업 요청을 받아와요 ipcMain.on("convert-video", async (event, { filePath }) => { // 진행률을 렌더러 프로세스로 전달해요 event.sender.send("conversion-progress", 10); // 무거운 변환을 처리해요 const result = await convertVideo(filePath); // 진행률을 렌더러 프로세스로 전달해요 event.sender.send("conversion-progress", 100); // 작업 완료를 렌더러 프로세스에 알려요 event.sender.send("conversion-complete", result); });
마치며
분리된 프로세스는 메모리를 공유하지 않아서, 협력하려면 IPC로 데이터를 주고받아야 해요.
Node.js에서는 child_process.fork()와 send/message로, Electron에서는 ipcMain과 ipcRenderer로 메시지를 주고받을 수 있어요.
실무에서는 "이 작업을 별도 프로세스로 뺄 만큼 무거운가?", "메시지 형식과 직렬화 비용은 어떻게 할 것인가?"를 먼저 정한 뒤 설계하는 게 좋아요.
참고 자료
- Node.js child_process - 자식 프로세스 생성 및 IPC
- Electron IPC - 메인·렌더러 프로세스 간 통신