"기본 IPC만으로는 복잡한 앱을 만들기 어렵다" — 렌더러 간 통신, 스트리밍 데이터, 대용량 전송에는 심화 패턴이 필요합니다.


렌더러 간 통신 문제

기본 IPC에서는 렌더러끼리 직접 통신할 수 없습니다. 반드시 메인 프로세스를 경유해야 합니다.

PLAINTEXT
렌더러 A ──── IPC ────► 메인 프로세스 ──── IPC ────► 렌더러 B

메인 프로세스 중계 패턴

JAVASCRIPT
// main.js — 렌더러 A의 메시지를 렌더러 B로 전달
const windows = new Map();

function createEditorWindow(id) {
  const win = new BrowserWindow({
    webPreferences: { preload: path.join(__dirname, 'preload.js') },
  });
  windows.set(id, win);
  win.on('closed', () => windows.delete(id));
  return win;
}

// 렌더러 A가 렌더러 B에게 메시지 전달 요청
ipcMain.on('relay:message', (event, targetId, channel, data) => {
  const targetWin = windows.get(targetId);
  if (targetWin && !targetWin.isDestroyed()) {
    targetWin.webContents.send(channel, data);
  }
});

MessagePort를 활용한 직접 통신

Electron 13+에서는 MessagePort를 사용하여 렌더러 간 직접 통신 채널을 만들 수 있습니다.

JAVASCRIPT
// main.js — MessagePort 쌍을 생성하여 두 렌더러에 전달
const { MessageChannelMain } = require('electron');

function connectRenderers(win1, win2) {
  // 포트 쌍 생성
  const { port1, port2 } = new MessageChannelMain();

  // 각 렌더러에 포트 전달
  win1.webContents.postMessage('port:connect', { partner: 'window2' }, [port1]);
  win2.webContents.postMessage('port:connect', { partner: 'window1' }, [port2]);
}

app.whenReady().then(() => {
  const editor = createEditorWindow('editor');
  const preview = createEditorWindow('preview');

  // 두 창이 모두 준비되면 연결
  Promise.all([
    new Promise(r => editor.webContents.once('did-finish-load', r)),
    new Promise(r => preview.webContents.once('did-finish-load', r)),
  ]).then(() => {
    connectRenderers(editor, preview);
  });
});
JAVASCRIPT
// preload.js — MessagePort 수신
const { ipcRenderer } = require('electron');

ipcRenderer.on('port:connect', (event) => {
  const port = event.ports[0];

  // contextBridge로는 MessagePort를 직접 전달할 수 없으므로
  // 이벤트 기반으로 래핑합니다
  port.onmessage = (msgEvent) => {
    window.dispatchEvent(
      new CustomEvent('partner-message', { detail: msgEvent.data })
    );
  };

  // 전송 함수를 전역에 노출
  window.__sendToPartner = (data) => {
    port.postMessage(data);
  };

  port.start();
});
JAVASCRIPT
// renderer.js (에디터 창)
// 텍스트 변경 시 프리뷰 창에 전송
editor.addEventListener('input', () => {
  window.__sendToPartner({
    type: 'content-update',
    content: editor.value,
  });
});

// renderer.js (프리뷰 창)
window.addEventListener('partner-message', (e) => {
  if (e.detail.type === 'content-update') {
    renderPreview(e.detail.content);
  }
});

ipcRenderer.invoke 에러 핸들링 심화

JAVASCRIPT
// main.js — 에러를 구조화하여 전달
ipcMain.handle('api:request', async (_event, endpoint, options) => {
  try {
    const response = await fetch(endpoint, options);
    if (!response.ok) {
      return {
        success: false,
        error: {
          code: 'HTTP_ERROR',
          status: response.status,
          message: response.statusText,
        },
      };
    }
    const data = await response.json();
    return { success: true, data };
  } catch (error) {
    return {
      success: false,
      error: {
        code: 'NETWORK_ERROR',
        message: error.message,
      },
    };
  }
});

IPC 미들웨어 패턴

JAVASCRIPT
// main.js — IPC 핸들러에 미들웨어 적용
function withLogging(handler) {
  return async (event, ...args) => {
    const start = Date.now();
    const channel = handler.name || 'unknown';
    console.log(`[IPC] ${channel} 호출`, args);
    try {
      const result = await handler(event, ...args);
      console.log(`[IPC] ${channel} 완료 (${Date.now() - start}ms)`);
      return result;
    } catch (error) {
      console.error(`[IPC] ${channel} 에러:`, error);
      throw error;
    }
  };
}

function withAuth(handler) {
  return async (event, ...args) => {
    const win = BrowserWindow.fromWebContents(event.sender);
    if (!isAuthenticated(win)) {
      throw new Error('인증이 필요합니다');
    }
    return handler(event, ...args);
  };
}

// 미들웨어 적용
ipcMain.handle(
  'secure:getData',
  withLogging(withAuth(async (_event, key) => {
    return await database.get(key);
  }))
);

대용량 데이터 전송

IPC로 큰 데이터를 보내면 직렬화/역직렬화 비용이 발생합니다.

JAVASCRIPT
// 대용량 데이터는 파일을 통해 전달하는 패턴
ipcMain.handle('data:exportLarge', async (_event, query) => {
  const data = await database.query(query);
  const tempPath = path.join(app.getPath('temp'), `export-${Date.now()}.json`);

  await fs.promises.writeFile(tempPath, JSON.stringify(data));

  // 파일 경로만 전달 (직렬화 비용 절감)
  return { filePath: tempPath, count: data.length };
});

// 렌더러에서는 경로를 받아 필요한 부분만 읽기
const { filePath } = await window.electronAPI.exportData(query);

이벤트 버스 패턴

JAVASCRIPT
// main.js — 중앙 이벤트 버스
class IPCEventBus {
  constructor() {
    this.subscribers = new Map();
  }

  subscribe(channel, webContents) {
    if (!this.subscribers.has(channel)) {
      this.subscribers.set(channel, new Set());
    }
    this.subscribers.get(channel).add(webContents);

    // 윈도우가 닫히면 자동 구독 해제
    webContents.on('destroyed', () => {
      this.subscribers.get(channel)?.delete(webContents);
    });
  }

  publish(channel, data, excludeSender) {
    const subs = this.subscribers.get(channel);
    if (!subs) return;

    for (const wc of subs) {
      if (wc !== excludeSender && !wc.isDestroyed()) {
        wc.send(channel, data);
      }
    }
  }
}

const bus = new IPCEventBus();

ipcMain.on('bus:subscribe', (event, channel) => {
  bus.subscribe(channel, event.sender);
});

ipcMain.on('bus:publish', (event, channel, data) => {
  bus.publish(channel, data, event.sender);
});

면접 포인트 정리

  • 기본 IPC에서 렌더러 간 통신은 메인 프로세스 중계 필요
  • MessageChannelMain으로 렌더러 간 직접 통신 채널 생성 가능
  • 대용량 데이터는 IPC 대신 파일 경로를 전달하여 직렬화 비용 절감
  • IPC 미들웨어 패턴으로 로깅, 인증 등 횡단 관심사 처리
  • 이벤트 버스 패턴으로 pub/sub 구현 가능

IPC 심화를 다뤘으면, 다음은 webContents API를 깊이 파봅시다.

댓글 로딩 중...