Skip to main content
Summary

오류를 프로세스별로 나누어 본다

Electron 앱에서 디버깅이 어려워지는 가장 큰 이유는 코드가 한 곳에서 실행되지 않기 때문이다. 창을 만드는 코드는 main 프로세스에서 실행되고, 화면 코드는 renderer 프로세스에서 실행된다. preload는 두 영역 사이에서 제한된 API를 노출한다. 그래서 "저장 버튼을 눌렀는데 반응이 없다"는 증상도 renderer 이벤트 문제, preload 노출 문제, IPC 채널 불일치, main handler 예외, 파일 시스템 권한 문제 중 하나일 수 있다.

문제를 추적할 때는 먼저 경계를 나눈다.

  • 창이 뜨지 않는다: main 프로세스, BrowserWindow, loadURL 또는 loadFile부터 확인한다.

  • 창은 뜨지만 화면이 비어 있다: renderer 번들, 라우팅, 정적 파일 경로, DevTools 콘솔을 확인한다.

  • 버튼은 눌리지만 기능이 동작하지 않는다: renderer 이벤트와 preload API 이름을 확인한다.

  • IPC 요청이 실패한다: 채널 이름, payload 검증, ipcMain.handle 등록 시점, main 예외 로그를 확인한다.

  • 개발에서는 되지만 배포 후 실패한다: 파일 경로, asar, 권한, 상대 경로 사용 여부를 확인한다.

디버깅의 목표는 "어디에서 멈췄는지"를 좁히는 것이다. 처음부터 모든 코드를 의심하지 말고, 프로세스 경계마다 관찰 지점을 만든다.

디버깅과 테스트 전략AI 생성 이미지

DevTools로 renderer 문제 확인하기

renderer는 브라우저 환경에 가깝다. React, Vue, Svelte, 일반 DOM 코드 모두 DevTools로 확인할 수 있다. 개발 모드에서는 창을 만들 때 DevTools를 자동으로 열어 두면 초기 오류를 놓치지 않는다.

function createMainWindow() {
  const win = new BrowserWindow({
    width: 1000,
    height: 700,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      nodeIntegration: false,
      contextIsolation: true,
      sandbox: true
    }
  });

  if (!app.isPackaged) {
    win.webContents.openDevTools({ mode: 'detach' });
  }

  win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => {
    console.error('[renderer:load-failed]', { errorCode, errorDescription, validatedURL });
  });

  win.webContents.on('render-process-gone', (event, details) => {
    console.error('[renderer:gone]', details);
  });

  return win;
}

DevTools에서는 세 탭을 우선 확인한다.

첫째, Console 탭이다. JavaScript 예외, 잘못된 preload API 호출, CSP 위반, source map 오류가 이곳에 표시된다. window.electronAPI가 undefined라면 renderer 코드보다 preload 로딩과 contextBridge.exposeInMainWorld() 호출을 먼저 확인해야 한다.

둘째, Network 탭이다. 개발 서버를 사용하는 앱에서 빈 화면이 나온다면 http://localhost:5173 같은 개발 서버 주소가 실제로 응답하는지 확인한다. 배포 앱에서 정적 파일이 404라면 loadFile() 경로나 라우터의 history 모드 설정을 점검한다.

셋째, Performance 탭이다. 화면이 늦게 반응하거나 입력이 끊긴다면 긴 JavaScript 작업이 있는지 확인한다. renderer에서 대용량 파일 파싱, 검색 인덱스 생성, 이미지 처리 같은 작업을 직접 수행하면 UI 스레드가 막힌다. 이 경우 main 프로세스, utility process, worker thread, Web Worker 중 어디로 옮길지 검토한다.

프런트엔드 프레임워크를 사용한다면 개발 빌드에서는 source map을 켜 두는 것이 좋다. 압축된 번들 위치가 아니라 실제 컴포넌트 파일과 줄 번호로 이동할 수 있어야 오류 수정 시간이 줄어든다.

main 프로세스에 브레이크포인트 걸기

main 프로세스는 Node.js 런타임에서 실행된다. 따라서 브라우저 DevTools만으로는 충분하지 않다. 창 생성, 앱 생명주기, 메뉴, 파일 시스템, IPC handler는 VS Code의 Node.js 디버거로 추적하는 편이 낫다.

.vscode/launch.json에 다음 구성을 추가한다.

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Electron Main",
      "type": "node",
      "request": "launch",
      "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
      "windows": {
        "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
      },
      "args": ["."],
      "cwd": "${workspaceFolder}",
      "env": {
        "NODE_ENV": "development"
      }
    }
  ]
}

이 설정으로 실행하면 main.js의 app.whenReady(), createMainWindow(), ipcMain.handle() 안에 브레이크포인트를 걸 수 있다. 창이 뜨지 않는다면 다음 순서로 본다.

  1. Electron 엔트리 파일이 실행되는가?

  2. app.whenReady() 이후에 창을 생성하는가?

  3. BrowserWindow 생성자에서 예외가 나는가?

  4. loadURL() 또는 loadFile()이 올바른 주소를 가리키는가?

  5. renderer 프로세스가 비정상 종료되지는 않는가?

main 프로세스에는 전역 예외 로그도 필요하다.

process.on('uncaughtException', (error) => {
  console.error('[main:uncaughtException]', error);
});

process.on('unhandledRejection', (reason) => {
  console.error('[main:unhandledRejection]', reason);
});

이 코드는 오류를 숨기기 위한 장치가 아니다. 앱을 계속 실행할지 종료할지는 별도로 판단해야 한다. 여기서 중요한 것은 예외가 조용히 사라지지 않게 하는 것이다.

preload와 IPC 경계에 로그 남기기

IPC 문제는 대부분 "보냈다고 생각했지만 실제로는 도착하지 않은" 상태에서 시간을 잃는다. 채널 이름이 다르거나, preload에서 API를 노출하지 않았거나, main handler 등록보다 renderer 호출이 먼저 발생했거나, payload 검증에서 거절됐을 수 있다.

preload에서는 노출하는 API 이름을 작게 유지한다.

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('notesAPI', {
  list: () => ipcRenderer.invoke('notes:list'),
  save: (note) => ipcRenderer.invoke('notes:save', note)
});

renderer에서는 ipcRenderer를 직접 알 필요가 없다.

async function saveCurrentNote(note) {
  try {
    await window.notesAPI.save(note);
    showToast('저장했습니다.');
  } catch (error) {
    console.error('[ui:save-failed]', error);
    showToast('저장하지 못했습니다.');
  }
}

main에서는 handler를 감싸는 공통 로깅 함수를 두면 추적이 쉬워진다.

const { randomUUID } = require('node:crypto');

function withIpcLog(channel, handler) {
  return async (event, payload) => {
    const requestId = randomUUID();
    const senderURL = event.senderFrame?.url;

    console.info('[ipc:start]', { channel, requestId, senderURL });

    try {
      const result = await handler(event, payload, { requestId });
      console.info('[ipc:done]', { channel, requestId });
      return result;
    } catch (error) {
      console.error('[ipc:error]', {
        channel,
        requestId,
        message: error.message
      });
      throw new Error('요청을 처리하지 못했습니다.');
    }
  };
}

ipcMain.handle('notes:save', withIpcLog('notes:save', saveNote));

이 구조의 장점은 요청 하나를 끝까지 따라갈 수 있다는 점이다. renderer 콘솔의 [ui:save-failed], main 로그의 [ipc:start], [ipc:error]를 같은 시간대에서 비교하면 어느 경계에서 실패했는지 알 수 있다.

단, 로그에 파일 본문, 인증 토큰, 개인정보를 그대로 남기면 안 된다. 개발 로그와 운영 로그는 목적이 다르다. 운영 로그에는 채널 이름, 요청 ID, 오류 종류, 파일 크기처럼 진단에 필요한 최소 정보만 남긴다.

자동화 테스트는 도구보다 경계를 먼저 정한다

Electron 공식 문서는 Electron 자체가 별도의 E2E 테스트 솔루션을 적극적으로 유지하지 않으며, WebdriverIO, Selenium, Playwright, 또는 직접 만든 테스트 드라이버 같은 선택지를 제시한다1. 과거에는 Spectron을 많이 사용했지만 새 프로젝트에서는 현재 유지보수 상태와 생태계를 고려해 다른 도구를 선택하는 편이 낫다.

Playwright를 사용하면 Electron 앱을 실행하고 첫 번째 창을 가져와 renderer UI를 검증할 수 있다. 다만 Electron 지원은 실험적 성격이 있으므로, 팀의 CI 환경에서 안정성을 검증한 뒤 도입해야 한다2.

import { test, expect } from '@playwright/test';
import { _electron as electron } from 'playwright';

test('메인 창이 열리고 노트 목록이 보인다', async () => {
  const app = await electron.launch({ args: ['.'] });

  try {
    const page = await app.firstWindow();

    await expect(page.getByRole('heading', { name: 'Notes' })).toBeVisible();
    await expect(page.getByRole('button', { name: '새 노트' })).toBeVisible();
  } finally {
    await app.close();
  }
});

이 테스트는 "창이 뜬다"와 "초기 UI가 보인다"를 검증한다. 여기서 더 나아가려면 저장 버튼 클릭, 파일 선택 대화상자 대체, 메뉴 항목 실행, 단축키 입력, 새 창 열림, IPC 결과 반영을 단계적으로 추가한다.

다만 모든 것을 E2E 테스트로 검증하면 실행 시간이 길고 실패 원인 분석이 어려워진다. Electron 앱의 테스트는 역할별로 나누는 편이 유지보수에 좋다.

테스트를 세 계층으로 나누기

단위 테스트는 Electron을 실행하지 않고 검증한다. 예를 들어 노트 제목 생성, 파일 이름 정규화, payload 검증, 저장 경로 계산, 설정 병합 로직은 순수 함수로 분리할 수 있다. 이 테스트는 빠르게 실행되어야 하며, 커밋 전마다 돌릴 수 있어야 한다.

통합 테스트는 main의 기능 단위를 검증한다. notes:save handler가 잘못된 payload를 거절하는지, 허용된 디렉터리 밖의 경로를 막는지, 임시 파일 저장 후 rename하는지 확인한다. 파일 시스템은 실제 사용자 폴더가 아니라 테스트용 임시 디렉터리를 사용한다. 대화상자와 알림처럼 운영체제 UI에 의존하는 부분은 얇은 래퍼로 감싼 뒤 mock으로 대체한다.

E2E 테스트는 사용자의 흐름을 검증한다. 앱 실행, 첫 창 표시, 노트 작성, 저장, 다시 열기, 메뉴 저장, 단축키 저장처럼 여러 경계를 지나는 시나리오를 다룬다. 테스트 수는 적어도 된다. 대신 실패했을 때 스크린샷, 비디오, 로그를 남겨 원인을 추적할 수 있어야 한다.

마지막으로 수동 테스트 체크리스트가 필요하다. 자동화하기 어렵거나 운영체제 차이가 큰 항목은 사람이 확인한다.

  • Windows, macOS, Linux에서 첫 실행이 되는가?

  • 창을 모두 닫았을 때 플랫폼 관례에 맞게 종료되거나 유지되는가?

  • 메뉴, 단축키, 트레이 동작이 충돌하지 않는가?

  • 파일 열기와 저장이 권한 오류 없이 동작하는가?

  • 네트워크가 끊겨도 로컬 기능이 유지되는가?

  • DevTools를 닫은 배포 빌드에서도 오류 메시지가 사용자에게 적절히 표시되는가?

  • 보안 설정이 개발 편의를 위해 완화된 채 배포되지 않았는가?

문제 해결 순서를 고정해 둔다

팀 프로젝트에서는 디버깅 순서가 문서화되어 있어야 한다. 예를 들어 "저장이 안 된다"는 이슈가 들어오면 다음 순서로 확인한다.

  1. renderer에서 버튼 클릭 이벤트가 발생했는가?

  2. window.notesAPI.save가 존재하는가?

  3. preload가 예상한 채널로 ipcRenderer.invoke를 호출했는가?

  4. main에 ipcMain.handle('notes:save')가 등록되어 있는가?

  5. payload 검증에서 거절되지 않았는가?

  6. 저장 경로가 userData 아래 허용된 위치인가?

  7. 파일 시스템 예외가 로그에 남았는가?

  8. renderer가 실패 결과를 사용자에게 표시하는가?

이 순서를 반복하면 디버깅은 감이 아니라 절차가 된다. Electron 앱은 웹 앱과 데스크톱 앱의 문제가 함께 발생한다. 그래서 더 많은 로그가 필요한 것이 아니라, 경계마다 의미 있는 로그가 필요하다. 테스트도 마찬가지다. 모든 기능을 한꺼번에 검증하려 하지 말고, main, preload, renderer, 운영체제 연동을 나누어 확인해야 한다.

디버깅과 테스트 전략은 배포 직전에 붙이는 부가 기능이 아니다. IPC 채널을 만들 때 로그 정책을 함께 정하고, 파일 저장 기능을 만들 때 단위 테스트와 통합 테스트를 함께 둔다. 이렇게 해야 창이 뜨지 않거나 IPC가 동작하지 않는 문제를 추측이 아니라 기록과 재현 절차로 해결할 수 있다.

참고

1Electron Automated Testing

2Playwright Electron API

책 목록

Electron으로 배우는 데스크톱 앱 개발

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기