성능은 '빠른 코드'보다 '멈추지 않는 흐름'이다
Electron 앱은 Chromium과 Node.js를 함께 포함한다. 그래서 같은 기능의 네이티브 앱보다 설치 용량과 메모리 사용량이 커질 수 있다. 그러나 사용자가 느끼는 품질은 단순한 메모리 숫자만으로 결정되지 않는다. 앱이 언제 화면을 보여 주는지, 입력에 바로 반응하는지, 저장 중에도 UI가 멈추지 않는지가 더 중요하다.
성능 최적화의 첫 원칙은 추측하지 않는 것이다. Electron 공식 문서도 먼저 측정하고, 실제로 자원을 많이 쓰는 부분을 찾은 뒤 최적화하라고 권장한다1. "Electron은 무겁다"라는 막연한 판단으로 시작하면 효과가 작은 작업에 시간을 쓰기 쉽다. 시작이 느린지, 목록 렌더링이 느린지, 파일 저장이 느린지, IPC가 과한지 먼저 나누어 보아야 한다.
이 장에서는 작업 노트 앱을 기준으로 다음 세 가지를 다룬다.
시작할 때 꼭 필요한 작업만 먼저 실행한다.
렌더러가 그려야 할 코드와 DOM을 줄인다.
오래 걸리는 작업은 UI 흐름 밖으로 보낸다.
AI 생성 이미지
시작 시점의 작업을 나누기
앱 시작 속도는 app.whenReady() 이후 실행되는 작업의 합으로 느껴지는 경우가 많다. 창 생성, 메뉴 구성, 설정 파일 읽기, 최근 문서 목록 불러오기, 자동 업데이트 확인, 트레이 생성, 데이터베이스 초기화, 원격 API 호출을 모두 시작 시점에 넣으면 첫 화면은 늦어진다.
먼저 작업을 세 그룹으로 나눈다.
| 구분 | 예 | 실행 시점 |
|---|---|---|
| 필수 작업 | 보안 설정이 적용된 메인 창 생성, 기본 메뉴 구성, 최소 설정 읽기 | 앱 시작 즉시 |
| 첫 화면 직후 작업 | 최근 문서 목록, 마지막 열린 노트 복원, 가벼운 캐시 읽기 | 창 표시 후 |
| 지연 가능 작업 | 업데이트 확인, 분석 도구 초기화, 대용량 인덱싱, 트레이 보조 기능 | 사용 흐름에 맞춰 나중에 |
예를 들어 자동 업데이트 확인은 앱이 켜지는 순간 반드시 끝나야 하는 작업이 아니다. 사용자가 첫 화면을 볼 수 있게 만든 뒤 백그라운드에서 실행하는 편이 낫다.
// main.js
const { app, BrowserWindow } = require('electron')
let mainWindow
async function createWindow() {
mainWindow = new BrowserWindow({
width: 1100,
height: 760,
show: false,
backgroundColor: '#ffffff',
webPreferences: {
preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
nodeIntegration: false,
contextIsolation: true,
sandbox: true
}
})
mainWindow.once('ready-to-show', () => {
mainWindow.show()
// 첫 화면 표시 후 실행해도 되는 작업
setTimeout(() => {
checkForUpdatesInBackground()
warmUpSearchIndex()
}, 1000)
})
await mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY)
}
app.whenReady().then(async () => {
buildApplicationMenu()
await createWindow()
})show: false와 ready-to-show는 준비되지 않은 흰 화면을 줄이는 데 도움이 된다. 이때 ready-to-show 리스너는 loadURL()이나 loadFile()을 호출하기 전에 등록해 두는 편이 안전하다. 페이지가 빠르게 로드되면 이벤트가 이미 지나간 뒤에 리스너를 붙이는 실수를 할 수 있기 때문이다.
다만 초기 로딩이 길다면 창을 숨긴 채 오래 기다리게 하는 것보다, 창을 먼저 보여 주고 내부에서 로딩 상태를 표시하는 편이 낫다. 사용자는 빈 화면보다 "최근 노트를 불러오는 중"이라는 진행 상태를 더 신뢰한다. 배경색을 앱의 기본 배경과 맞춰 두는 것도 짧은 로딩 순간의 어색한 깜박임을 줄이는 데 도움이 된다.
BrowserWindow와 렌더러 번들 비용
BrowserWindow 하나는 단순한 DOM 컨테이너가 아니다. 새 Chromium 렌더러 프로세스와 웹 페이지 실행 환경을 준비한다. 창을 여러 개 만들수록 메모리 사용량도 증가한다. 설정 창, 미리보기 창, 도움말 창을 모두 미리 만들어 숨겨 두는 방식은 시작 속도와 메모리 양쪽에 부담을 준다.
창은 다음 기준으로 만든다.
자주 쓰는 메인 창은 시작 시 생성한다.
설정 창처럼 가끔 쓰는 창은 사용자가 열 때 생성한다.
닫힌 뒤 상태가 필요 없으면 객체 참조를
null로 정리한다.같은 정보를 보여 주는 보조 UI는 새 창보다 모달, 패널, 라우트 전환이 나은지 검토한다.
렌더러 번들도 시작 속도에 직접 영향을 준다. React, Vue, Svelte 같은 프레임워크 자체보다 더 큰 문제는 불필요한 라이브러리를 첫 번들에 모두 넣는 것이다. 날짜 포맷 하나 때문에 큰 유틸리티 라이브러리 전체를 포함하거나, 설정 화면에서만 쓰는 차트 라이브러리를 첫 화면 번들에 넣으면 앱 시작 때 사용하지 않는 코드까지 파싱하고 실행한다.
프런트엔드 번들은 다음 방식으로 줄인다.
// renderer/App.jsx
import { Suspense, lazy } from 'react'
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const SearchPage = lazy(() => import('./pages/SearchPage'))
export function App() {
return (
<Suspense fallback={<div>화면을 준비하는 중...</div>}>
{/* 현재 라우트에서 필요한 화면만 로드 */}
</Suspense>
)
}코드 분할은 모든 화면을 느리게 만드는 기술이 아니다. 첫 화면에 필요 없는 코드를 뒤로 미루는 기술이다. 작업 노트 앱이라면 노트 목록과 편집기는 초기 번들에 포함해도 된다. 반면 설정, 내보내기, 통계, 동기화 연결 화면은 사용자가 열 때 로드해도 된다.
여기서 중요한 기준은 "언젠가 필요하다"가 아니라 "첫 화면에 반드시 필요한가"이다. 처음 1초 안에 보이지 않는 화면이라면 별도 번들로 나누어도 사용자는 거의 불편을 느끼지 않는다. 오히려 첫 화면이 빨리 나타나는 쪽이 전체 앱을 더 가볍게 느끼게 한다.
병목을 찾는 도구
성능 문제를 찾을 때는 프로세스를 나누어 보아야 한다.
main 프로세스: 창 생성, IPC 처리, 파일 시스템, 메뉴, 백그라운드 작업
renderer 프로세스: UI 렌더링, 프런트엔드 상태, DOM, CSS 레이아웃
preload: 노출 API, IPC 연결, 이벤트 구독
렌더러 문제는 Chrome DevTools에서 확인한다. Performance 탭을 열고 녹화를 시작한 뒤, 문제가 되는 동작을 직접 수행한다. 예를 들어 노트 목록에서 검색어를 입력하고, 긴 목록을 스크롤하고, 노트를 저장한다. 녹화를 멈춘 뒤 긴 작업(Long task), 잦은 레이아웃 계산, 과도한 스크립트 실행을 찾는다.
Memory 탭에서는 힙 스냅샷을 비교한다. 같은 동작을 반복한 뒤 객체 수가 계속 늘고 줄지 않는다면 누수가 있을 가능성이 있다. 특히 다음 패턴을 의심한다.
화면에서 사라진 DOM을 JavaScript 변수가 계속 참조한다.
컴포넌트가 사라졌는데 이벤트 리스너가 제거되지 않았다.
setInterval이 계속 살아 있다.큰 배열이나 파일 내용을 클로저가 붙잡고 있다.
main 프로세스는 renderer DevTools만으로 충분하지 않다. VS Code 디버거, --inspect, 운영체제 작업 관리자, Electron의 process.getProcessMemoryInfo() 같은 정보를 함께 본다. DevTools의 JS 힙은 JavaScript 객체 중심이고, 운영체제의 메모리 표시는 네이티브 라이브러리와 Chromium 내부 메모리까지 포함한다. 둘의 숫자가 다르다고 해서 한쪽이 틀린 것은 아니다.
간단한 진단용 IPC를 만들 수도 있다. 단, 개발 모드에서만 열어 두어야 한다.
// main.js
const { ipcMain, app } = require('electron')
ipcMain.handle('diagnostics:memory', async () => {
if (!app.isPackaged) {
return process.getProcessMemoryInfo()
}
throw new Error('diagnostics disabled')
})이런 도구의 목적은 숫자를 꾸미는 것이 아니다. 특정 동작 전후로 CPU와 메모리가 어떻게 변하는지 확인하는 것이다. "느린 것 같다"에서 멈추지 말고, 어느 동작에서 얼마나 느려지는지 기록해야 다음 수정의 우선순위를 정할 수 있다.
IPC가 성능을 떨어뜨리는 경우
IPC는 프로세스 사이의 메시지 전달이다. 함수 호출처럼 보이지만 실제로는 데이터를 직렬화하고 다른 프로세스로 넘긴다. 그래서 너무 자주 호출하거나 큰 데이터를 반복해서 보내면 성능이 떨어진다.
피해야 할 대표 사례는 다음과 같다.
// 좋지 않은 예: 입력할 때마다 main에 저장 요청
editor.addEventListener('input', async () => {
await window.notes.save(editor.value)
})노트 편집 중 input 이벤트마다 파일 저장 IPC를 호출하면 renderer, preload, main, 파일 시스템이 모두 바빠진다. 저장은 사용자의 의도와 데이터 손실 방지 사이에서 조절해야 한다. 예를 들어 1초 동안 입력이 멈췄을 때 자동 저장하거나, 변경분이 있을 때만 저장한다.
// renderer.js
let saveTimer
let lastSavedText = ''
function scheduleAutoSave(text) {
clearTimeout(saveTimer)
saveTimer = setTimeout(async () => {
if (text === lastSavedText) return
await window.notes.save({ content: text })
lastSavedText = text
}, 1000)
}대용량 메시지도 주의해야 한다. 이미지 원본, 큰 로그 파일, 수만 개의 노트 객체를 IPC로 한 번에 보내면 직렬화 비용과 메모리 복사가 발생한다. 가능한 경우에는 파일 경로, ID, 범위 조건만 보내고 실제 처리는 main이나 별도 작업자에서 수행한다. 목록은 페이지 단위로 가져오고, 검색 결과는 필요한 필드만 반환한다.
이벤트 리스너 누수도 흔하다. 설정 창을 열 때마다 ipcRenderer.on('settings:changed')를 등록하고 닫을 때 제거하지 않으면 같은 이벤트가 여러 번 실행된다. React라면 useEffect의 정리 함수에서 구독을 해제한다.
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('settings', {
onChanged(callback) {
const listener = (_event, value) => callback(value)
ipcRenderer.on('settings:changed', listener)
return () => {
ipcRenderer.removeListener('settings:changed', listener)
}
}
})// renderer component
useEffect(() => {
const unsubscribe = window.settings.onChanged((next) => {
setSettings(next)
})
return unsubscribe
}, [])동기 IPC인 sendSync는 renderer를 멈추게 하므로 피해야 한다. Electron 공식 IPC 문서도 성능 문제 때문에 동기 메시지를 사용하지 말라고 설명한다2. 결과가 필요하면 invoke와 handle을 사용하고, 오래 걸리는 작업은 진행 상태를 별도 이벤트로 알려 준다.
UI를 멈추지 않게 작업 분리하기
성능 문제의 상당수는 "작업이 느리다"보다 "느린 작업이 UI 스레드에서 실행된다"에 가깝다. renderer에서 큰 JSON을 파싱하거나, 수만 개 항목을 한 번에 필터링하거나, Markdown을 대량 변환하면 입력과 스크롤이 끊긴다.
작업 위치는 성격에 따라 나눈다.
| 작업 | 적절한 위치 |
|---|---|
| DOM 업데이트, 사용자 입력 처리 | renderer |
| 파일 읽기·쓰기, 권한 검증 | main |
| CPU를 많이 쓰는 계산 | Web Worker, worker_threads, utility process |
| 반복 조회 결과 | 캐시 또는 인덱스 |
renderer 내부 계산이 길다면 Web Worker를 사용할 수 있다.
// renderer/searchWorker.js
self.onmessage = (event) => {
const { notes, keyword } = event.data
const result = notes.filter((note) =>
note.title.includes(keyword) || note.content.includes(keyword)
)
self.postMessage(result.slice(0, 100))
}// renderer.js
const worker = new Worker(new URL('./searchWorker.js', import.meta.url), {
type: 'module'
})
worker.onmessage = (event) => {
renderSearchResult(event.data)
}
function search(keyword) {
worker.postMessage({ notes: cachedNotes, keyword })
}main 프로세스에서 CPU 작업이 필요하다면 Node.js의 worker_threads나 별도 프로세스를 검토한다. main은 앱 생명주기와 IPC 응답의 중심이므로 오래 붙잡아 두면 전체 앱이 둔해진다. 파일 해시 계산, 대량 가져오기, 압축, 인덱스 생성 같은 작업은 백그라운드로 보내고 진행률만 main을 통해 renderer에 전달한다.
캐시도 효과적이다. 다만 캐시는 "한 번 계산한 결과를 재사용하는 구조"이지 "모든 데이터를 메모리에 쌓는 구조"가 아니다. 최근 열었던 노트 20개, 검색 인덱스의 요약 필드, 썸네일처럼 사용 빈도와 크기가 예측되는 데이터만 캐시한다. 캐시에는 최대 크기와 만료 조건이 있어야 한다.
긴 목록은 가상화한다. 노트가 1만 개 있어도 화면에 보이는 행은 수십 개뿐이다. 모든 행의 DOM을 만들지 말고 보이는 범위만 렌더링하면 스크롤과 메모리 사용량이 안정된다.
메모리 누수를 줄이는 습관
Electron 앱은 오래 켜 두는 경우가 많다. 메신저, 노트, 트레이 앱은 몇 시간 또는 며칠 동안 실행된다. 짧은 테스트에서는 문제가 없어 보여도 장시간 실행하면 작은 누수가 크게 보인다.
다음 항목은 코드 리뷰 때 확인할 만하다.
BrowserWindow를 닫은 뒤 참조를 정리했는가?webContents,ipcMain,ipcRenderer리스너를 필요 이상 등록하지 않았는가?컴포넌트 해제 시 타이머, 구독, 파일 watcher를 정리했는가?
대용량 파일 내용을 상태 관리 도구에 오래 보관하지 않는가?
숨긴 창이 실제로 계속 필요한 창인가?
캐시에 최대 개수 또는 최대 용량이 있는가?
창 정리는 다음처럼 명시한다.
let settingsWindow = null
function openSettingsWindow() {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus()
return
}
settingsWindow = new BrowserWindow({
width: 640,
height: 520,
parent: mainWindow,
webPreferences: {
preload: SETTINGS_PRELOAD,
nodeIntegration: false,
contextIsolation: true,
sandbox: true
}
})
settingsWindow.loadURL(SETTINGS_URL)
settingsWindow.on('closed', () => {
settingsWindow = null
})
}메모리 최적화는 무조건 적게 쓰는 방향만 의미하지 않는다. 사용자가 자주 여는 화면을 매번 새로 계산하느라 앱이 버벅인다면 적절한 캐시가 더 나은 선택이다. 반대로 한 번만 쓰는 대용량 데이터를 전역 상태에 넣어 두면 메모리를 낭비한다. 기준은 사용 빈도, 재계산 비용, 데이터 크기다.
적용 순서
성능 개선은 한 번에 끝나는 작업이 아니다. 기능이 늘고 데이터가 쌓이면 병목도 바뀐다. 다음 순서로 접근하면 효과를 확인하기 쉽다.
시작 시간을 측정한다. 앱 실행부터 첫 화면 표시까지의 시간을 기록한다.
시작 시 실행되는 작업을 필수, 첫 화면 직후, 지연 가능 작업으로 나눈다.
렌더러 번들을 분석하고 첫 화면에 필요 없는 코드를 분리한다.
DevTools Performance로 느린 사용자 동작을 녹화한다.
IPC 호출 빈도와 payload 크기를 확인한다.
반복 등록되는 이벤트 리스너와 정리되지 않는 타이머를 찾는다.
긴 계산은 Worker나 별도 프로세스로 옮긴다.
장시간 실행 테스트로 메모리가 일정 수준에서 안정되는지 확인한다.
Electron 앱의 성능은 프레임워크 선택 하나로 결정되지 않는다. 시작 작업을 미루고, 창을 필요한 만큼만 만들고, IPC를 적절히 묶고, 무거운 계산을 UI 밖으로 보내는 설계가 체감 품질을 만든다. 사용자는 내부 구조를 보지 않는다. 대신 앱이 빨리 열리고, 입력을 놓치지 않고, 오래 켜 두어도 안정적으로 움직이는지를 느낀다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.