성능 최적화
개발자들은 종종 Electron 애플리케이션의 성능을 최적화하는 전략에 대해 질문한다. 소프트웨어 엔지니어, 소비자, 프레임워크 개발자들은 "성능"이라는 단어의 정의에 대해 항상 동의하지는 않는다. 이 문서는 Electron 메인테이너들이 선호하는 방법을 소개하며, 메모리, CPU, 디스크 리소스 사용량을 줄이면서도 사용자 입력에 빠르게 반응하고 작업을 신속하게 완료할 수 있는 전략을 다룬다. 또한, 모든 성능 전략이 애플리케이션의 보안 수준을 높게 유지할 수 있도록 한다.
JavaScript로 성능이 뛰어난 웹사이트를 구축하는 방법에 대한 지식과 정보는 일반적으로 Electron 애플리케이션에도 적용된다. 일정 부분에서는 Node.js 애플리케이션의 성능을 높이는 방법에 대한 자료도 유용하지만, "성능"이라는 용어가 Node.js 백엔드와 클라이언트에서 실행되는 애플리케이션에서 의미하는 바가 다르다는 점을 이해해야 한다.
이 목록은 편의를 위해 제공되며, 보안 체크리스트와 마찬가지로 모든 것을 포괄하지 않는다. 아래에 설명된 모든 단계를 따르더라도 느린 Electron 애플리케이션을 만들 가능성이 있다. Electron은 개발자가 원하는 대로 무엇이든 할 수 있는 강력한 개발 플랫폼이다. 이러한 자유는 성능이 주로 개발자의 책임이라는 것을 의미한다.
측정, 측정, 그리고 또 측정
아래 목록은 비교적 간단하고 쉽게 구현할 수 있는 단계들을 포함하고 있다. 하지만 앱의 성능을 최적화하려면 이 단계들을 넘어서야 한다. 앱에서 실행되는 모든 코드를 프로파일링하고 측정하며 면밀히 분석해야 한다. 병목 현상은 어디에서 발생하는가? 사용자가 버튼을 클릭했을 때, 어떤 작업이 가장 많은 시간을 소모하는가? 앱이 유휴 상태일 때, 어떤 객체가 가장 많은 메모리를 차지하는가?
우리는 반복적으로 성능 최적화를 위한 가장 효과적인 전략이 실행 중인 코드를 프로파일링하고, 가장 리소스를 많이 소모하는 부분을 찾아 최적화하는 것임을 확인했다. 이렇게 보이는 것처럼 노동 집약적인 과정을 반복하면 앱의 성능이 크게 향상된다. Visual Studio Code나 Slack과 같은 주요 앱을 다루는 경험에서 이 방법이 성능을 개선하는 데 가장 신뢰할 수 있는 전략임이 입증되었다.
앱 코드를 프로파일링하는 방법을 더 깊이 이해하려면 Chrome 개발자 도구에 익숙해져야 한다. 여러 프로세스를 동시에 분석하는 고급 기능을 원한다면 Chrome Tracing 도구를 고려해보자.
추천 자료
체크리스트: 성능 최적화 권장사항
이 단계들을 따라하면 여러분의 앱이 더 가볍고 빠르며 리소스를 덜 소모하도록 만들 수 있다.
- 모듈을 무분별하게 포함하기
- 코드를 너무 일찍 로드하고 실행하기
- 메인 프로세스를 블로킹하기
- 렌더러 프로세스를 블로킹하기
- 불필요한 폴리필 사용하기
- 불필요하거나 블로킹되는 네트워크 요청
- 코드 번들링하기
- 기본 메뉴가 필요하지 않을 때
Menu.setApplicationMenu(null)
호출하기
1. 모듈을 무분별하게 포함하는 경우
애플리케이션에 Node.js 모듈을 추가하기 전에 해당 모듈을 꼼꼼히 검토해야 한다. 그 모듈이 얼마나 많은 종속성을 포함하고 있는지, 단순히 require()
문으로 호출하는 데 어떤 리소스가 필요한지 확인해 보자. NPM 패키지 레지스트리에서 가장 많은 다운로드 수를 기록했거나 GitHub에서 가장 많은 별을 받은 모듈이 반드시 가장 가볍거나 가장 작은 모듈은 아닐 수 있다.
왜 그럴까?
이 권장 사항의 배경을 이해하려면 실제 사례를 살펴보는 것이 가장 좋다. Electron 초기 시절에는 네트워크 연결 상태를 안정적으로 감지하는 것이 어려웠다. 이로 인해 많은 앱이 간단한 isOnline()
메서드를 제공하는 모듈을 사용했다.
이 모듈은 네트워크 연결 상태를 확인하기 위해 여러 잘 알려진 엔드포인트에 접근을 시도했다. 이 엔드포인트 목록은 다른 모듈에 의존했고, 그 모듈은 잘 알려진 포트 목록을 포함하고 있었다. 이 의존성은 다시 포트 정보를 담은 모듈에 의존했는데, 이 모듈은 10만 줄이 넘는 JSON 파일 형태로 정보를 제공했다. 모듈이 로드될 때마다(보통 require('module')
구문을 통해), 모든 의존성을 로드하고 결국 이 JSON 파일을 읽고 파싱했다. 수만 줄의 JSON을 파싱하는 작업은 매우 비용이 많이 드는 작업이다. 느린 머신에서는 몇 초가 걸리기도 했다.
많은 서버 환경에서는 시작 시간이 거의 중요하지 않다. 모든 포트 정보가 필요한 Node.js 서버의 경우, 서버가 부팅될 때 필요한 정보를 메모리에 로드하는 것이 요청을 더 빠르게 처리할 수 있으므로 "더 성능이 좋은" 방법일 수 있다. 이 예제에서 언급된 모듈이 "나쁜" 모듈이라는 뜻은 아니다. 그러나 Electron 앱은 실제로 필요하지 않은 정보를 로드하고, 파싱하고, 메모리에 저장해서는 안 된다.
간단히 말해, 주로 Linux에서 실행되는 Node.js 서버를 위해 작성된 훌륭해 보이는 모듈이 앱의 성능에는 좋지 않은 영향을 미칠 수 있다. 이 특정 예제에서는 아무 모듈도 사용하지 않고, Chromium의 후기 버전에 포함된 연결 상태 확인 기능을 사용하는 것이 올바른 해결책이었다.
어떻게 할까?
모듈을 고려할 때 다음 사항을 확인하는 것을 권장한다:
- 포함된 의존성의 크기
- 모듈을 로드(
require()
)하는 데 필요한 리소스 - 관심 있는 작업을 수행하는 데 필요한 리소스
모듈 로딩에 대한 CPU 프로파일과 힙 메모리 프로파일을 생성하려면 커맨드라인에서 단일 커맨드로 실행할 수 있다. 아래 예제에서는 인기 모듈인 request
를 살펴본다.
node --cpu-prof --heap-prof -e "require('request')"
이 커맨드를 실행하면 해당 디렉토리에 .cpuprofile
파일과 .heapprofile
파일이 생성된다. 두 파일은 Chrome 개발자 도구의 Performance
와 Memory
탭을 사용해 분석할 수 있다.
이 예제에서, 작성자의 머신에서는 request
를 로드하는 데 거의 0.5초가 걸렸지만, node-fetch
는 훨씬 적은 메모리와 50ms 미만의 시간이 소요됐다.
2. 코드를 너무 빨리 로드하고 실행하는 경우
비용이 많이 드는 초기화 작업이 있다면, 이를 지연시키는 것을 고려해야 한다. 애플리케이션이 시작된 직후 실행되는 모든 작업을 점검한다. 모든 작업을 즉시 실행하는 대신, 사용자의 흐름과 더 잘 맞춰 순차적으로 실행하는 방식을 고려한다.
전통적인 Node.js 개발에서는 모든 require()
문을 파일 상단에 배치하는 것이 일반적이다. 현재 Electron 애플리케이션을 동일한 전략으로 작성 중이고, 즉시 필요하지 않은 큰 모듈을 사용하고 있다면, 동일한 전략을 적용해 더 적절한 시점에 로드를 지연시켜야 한다.
왜 중요할까?
모듈을 로드하는 작업은 특히 윈도우 환경에서 상당히 비용이 많이 드는 작업이다. 앱이 시작될 때 사용자가 당장 필요하지 않은 작업을 기다리게 해서는 안 된다.
이것은 당연해 보일 수 있지만, 많은 애플리케이션은 앱이 실행된 직후에 상당한 양의 작업을 수행하는 경향이 있다. 예를 들어 업데이트 확인, 이후에 사용될 콘텐츠 다운로드, 또는 무거운 디스크 I/O 작업 등이 여기에 해당한다.
Visual Studio Code를 예로 들어보자. 파일을 열면, 코드 강조 없이 즉시 파일을 표시하여 사용자가 텍스트와 상호작용할 수 있도록 우선순위를 둔다. 그 작업을 마친 후에야 코드 강조를 진행한다.
어떻게 할까?
예제를 통해 설명해보자. 여러분의 애플리케이션이 가상의 .foo
파일 형식을 파싱한다고 가정한다. 이를 위해 동일하게 가상의 foo-parser
모듈을 사용한다. 전통적인 Node.js 개발 방식에서는 다음과 같이 의존성을 즉시 로드하는 코드를 작성할 수 있다.
const fs = require('node:fs')
const fooParser = require('foo-parser')
class Parser {
constructor () {
this.files = fs.readdirSync('.')
}
getParsedFiles () {
return fooParser.parse(this.files)
}
}
const parser = new Parser()
module.exports = { parser }
위 예제에서는 파일이 로드되는 즉시 많은 작업을 실행한다. 파싱된 파일을 바로 가져와야 할까? getParsedFiles()
가 실제로 호출될 때 이 작업을 조금 늦게 수행할 수는 없을까?
// "fs"는 이미 로드될 가능성이 높으므로 `require()` 호출은 비용이 적게 든다.
const fs = require('node:fs')
class Parser {
async getFiles () {
// `getFiles`가 호출되는 즉시 디스크에 접근한다. 더 일찍 접근하지 않는다.
// 또한 비동기 버전을 사용해 다른 작업이 블로킹되지 않도록 한다.
this.files = this.files || await fs.promises.readdir('.')
return this.files
}
async getParsedFiles () {
// 가상의 foo-parser는 로드 비용이 큰 모듈이므로 파일을 실제로 파싱해야 할 때까지 이 작업을 미룬다.
// `require()`는 모듈 캐시를 사용하므로 `require()` 호출은 처음 한 번만 비용이 크다.
// `getParsedFiles()`의 후속 호출은 더 빠르게 실행된다.
const fooParser = require('foo-parser')
const files = await this.getFiles()
return fooParser.parse(files)
}
}
// 이 작업은 이전 예제보다 훨씬 저렴하다.
const parser = new Parser()
module.exports = { parser }
간단히 말해, 애플리케이션이 시작될 때 모든 리소스를 할당하는 대신, 필요한 순간에 "적시에" 할당하는 방식을 사용한다.
3. 메인 프로세스의 블로킹 문제
Electron의 메인 프로세스(때로는 "브라우저 프로세스"라고도 함)는 특별한 역할을 한다. 이 프로세스는 애플리케이션의 모든 다른 프로세스의 부모 프로세스이며, 운영체제와 상호작용하는 주요 프로세스다. 윈도우 관리, 사용자 상호작용, 그리고 애플리케이션 내부의 다양한 컴포넌트 간의 통신을 처리한다. 또한 UI 스레드도 이 프로세스에 포함된다.
어떤 경우에도 이 메인 프로세스와 UI 스레드를 장시간 실행되는 작업으로 블로킹해서는 안 된다. UI 스레드가 블로킹되면 메인 프로세스가 작업을 재개할 때까지 전체 애플리케이션이 멈추게 된다.
왜 중요한가?
메인 프로세스와 UI 스레드는 앱 내부의 주요 작업을 제어하는 핵심적인 역할을 한다. 운영체제가 앱에 마우스 클릭 이벤트를 전달할 때, 이벤트는 윈도우에 도달하기 전에 반드시 메인 프로세스를 거친다. 만약 윈도우에서 부드러운 애니메이션을 렌더링 중이라면, GPU 프로세스와 통신해야 하는데, 이때도 다시 메인 프로세스를 거치게 된다.
Electron과 Chromium은 무거운 디스크 I/O 작업과 CPU 집약적인 작업을 새로운 스레드에서 처리하도록 설계했다. 이렇게 하면 UI 스레드가 블로킹되는 것을 방지할 수 있다. 여러분도 동일한 접근 방식을 따르는 것이 좋다.
어떻게 해야 할까?
Electron의 강력한 멀티 프로세스 아키텍처는 장시간 실행되는 작업을 처리하는 데 도움을 주지만, 몇 가지 성능 문제를 야기할 수도 있다.
-
CPU 집약적인 장시간 작업의 경우, [worker threads][worker-throws]를 활용하거나 BrowserWindow로 이동시킨다. 마지막 수단으로 전용 프로세스를 생성하는 방법도 있다.
-
동기식 IPC와
@electron/remote
모듈 사용을 최대한 피한다. 합당한 사용 사례가 있지만, UI 스레드를 의도치 않게 블로킹하기 쉽다. -
메인 프로세스에서 블로킹 I/O 작업을 사용하지 않는다. 간단히 말해,
fs
나child_process
와 같은 코어 Node.js 모듈이 동기식과 비동기식 버전을 제공할 때, 비동기식 논블로킹 방식을 선택해야 한다.
4. 렌더러 프로세스 블로킹 방지
Electron은 최신 버전의 Chrome을 포함하고 있어, 웹 플랫폼에서 제공하는 최신 기능을 활용해 무거운 작업을 지연하거나 분산시킬 수 있다. 이를 통해 앱이 부드럽고 반응성이 좋은 상태를 유지할 수 있다.
왜 필요할까?
여러분의 앱은 렌더러 프로세스에서 실행할 많은 JavaScript 코드를 가지고 있을 것이다. 핵심은 스크롤을 부드럽게 유지하거나, 사용자 입력에 즉각 반응하거나, 60fps로 애니메이션을 실행하는 데 필요한 리소스를 방해하지 않으면서도 가능한 한 빠르게 작업을 실행하는 것이다.
사용자가 앱이 가끔 "끊김 현상"을 보인다고 불평한다면, 렌더러 코드에서 작업의 흐름을 조율하는 것이 특히 유용하다.
어떻게?
일반적으로 모던 브라우저에서 성능이 뛰어난 웹 앱을 만들기 위한 모든 조언은 Electron의 렌더러에도 적용된다. 주로 사용할 수 있는 두 가지 도구는 작은 작업을 위한 requestIdleCallback()
과 오래 실행되는 작업을 위한 Web Workers
다.
_requestIdleCallback()
_은 개발자가 프로세스가 유휴 상태에 들어갈 때 실행될 함수를 큐에 추가할 수 있게 한다. 이를 통해 사용자 경험에 영향을 주지 않고 낮은 우선순위 작업이나 백그라운드 작업을 수행할 수 있다. 사용 방법에 대한 자세한 정보는 MDN의 requestIdleCallback 문서를 참고한다.
_Web Workers_는 코드를 별도의 스레드에서 실행할 수 있는 강력한 도구다. 몇 가지 주의할 점이 있으므로 Electron의 멀티스레딩 문서와 MDN의 Web Workers 문서를 참고한다. Web Workers는 오랜 시간 동안 많은 CPU 성능이 필요한 작업에 이상적인 해결책이다.
5. 불필요한 폴리필
Electron의 큰 장점 중 하나는 자바스크립트, HTML, CSS를 파싱할 엔진을 정확히 알 수 있다는 점이다. 웹 전반을 위해 작성된 코드를 재사용할 때, Electron에 이미 포함된 기능에 폴리필을 추가하지 않도록 주의해야 한다.
왜 중요한가?
현재의 인터넷 환경에서 웹 애플리케이션을 구축할 때, 가장 오래된 환경이 사용할 수 있는 기능과 사용할 수 없는 기능을 결정한다. Electron은 고성능 CSS 필터와 애니메이션을 지원하지만, 오래된 브라우저는 이를 지원하지 않을 수 있다. WebGL을 사용할 수 있는 상황에서도 개발자들은 오래된 휴대폰을 지원하기 위해 더 많은 리소스를 소모하는 솔루션을 선택할 수 있다.
자바스크립트의 경우, DOM 선택자를 위해 jQuery 같은 툴킷 라이브러리를 포함하거나 async/await
를 지원하기 위해 regenerator-runtime
같은 폴리필을 추가할 수 있다.
자바스크립트 기반 폴리필이 Electron의 네이티브 기능보다 빠른 경우는 드물다. 표준 웹 플랫폼 기능을 직접 구현하여 Electron 앱의 속도를 늦추지 말아야 한다.
어떻게 할까?
현재 Electron 버전에서는 폴리필이 필요하지 않다고 가정하고 작업한다. 만약 의문이 생긴다면 caniuse.com을 확인하고, 사용 중인 Electron 버전의 Chromium 버전이 원하는 기능을 지원하는지 확인한다.
또한 사용 중인 라이브러리를 꼼꼼히 검토한다. 정말 필요한 것인지 고민해본다. 예를 들어 jQuery
는 매우 성공적이어서, 그 기능 중 상당수가 이제 표준 JavaScript 기능 집합에 포함되었다.
TypeScript와 같은 트랜스파일러나 컴파일러를 사용한다면, 설정을 검토하고 Electron이 지원하는 최신 ECMAScript 버전을 대상으로 설정했는지 확인한다.
6. 불필요하거나 블로킹 되는 네트워크 요청
자주 변경되지 않는 리소스를 인터넷에서 가져오기보다는 애플리케이션에 번들링하는 것이 좋다. 이렇게 하면 불필요한 네트워크 요청을 줄이고, 애플리케이션의 성능을 향상시킬 수 있다.
왜 필요한가?
많은 Electron 사용자들은 웹 기반 애플리케이션을 데스크톱 애플리케이션으로 전환하는 과정에서 시작한다. 웹 개발자로서 우리는 다양한 콘텐츠 전송 네트워크(CDN)에서 리소스를 로드하는 데 익숙하다. 하지만 이제는 제대로 된 데스크톱 애플리케이션을 배포하는 것이므로, 가능한 한 외부 의존성을 줄이고 사용자가 변하지 않는 리소스를 기다리지 않도록 해야 한다. 이러한 리소스는 애플리케이션에 쉽게 포함시킬 수 있다.
대표적인 예로 Google Fonts가 있다. 많은 개발자들이 Google의 인상적인 무료 폰트 컬렉션을 활용하며, 이는 CDN을 통해 제공된다. 사용법은 간단하다: 몇 줄의 CSS를 추가하면 Google이 나머지를 처리해준다.
그러나 Electron 애플리케이션을 개발할 때는 폰트를 다운로드하여 애플리케이션 번들에 포함시키는 것이 사용자에게 더 나은 경험을 제공한다. 이렇게 하면 사용자가 폰트를 로드하기 위해 인터넷 연결을 기다릴 필요가 없어지며, 애플리케이션의 성능과 안정성도 향상된다.
방법
이상적으로는 애플리케이션이 네트워크 없이도 작동할 수 있어야 한다. 이를 위해선 애플리케이션이 어떤 리소스를 다운로드하는지, 그리고 그 리소스의 크기가 얼마나 되는지 이해하는 것이 중요하다.
먼저 개발자 도구를 열어 Network
탭으로 이동한 후 Disable cache
옵션을 체크한다. 그다음, 렌더러를 다시 로드한다. 애플리케이션이 이를 금지하지 않는 한, 보통 개발자 도구가 활성화된 상태에서 Cmd + R
또는 Ctrl + R
을 눌러 재로드를 트리거할 수 있다.
이제 도구는 모든 네트워크 요청을 세밀하게 기록한다. 첫 번째 단계로, 다운로드되는 모든 리소스를 확인하고, 특히 큰 파일부터 집중적으로 살펴본다. 변경되지 않는 이미지, 폰트, 미디어 파일 중 번들에 포함시킬 수 있는 것이 있는지 확인한다. 있다면 이를 포함시킨다.
다음 단계로 Network Throttling
을 활성화한다. 현재 Online
으로 표시된 드롭다운에서 Fast 3G
와 같은 느린 속도를 선택한다. 그런 다음 렌더러를 다시 로드하고, 애플리케이션이 불필요하게 기다리는 리소스가 있는지 확인한다. 많은 경우, 애플리케이션은 실제로 필요하지 않은 리소스를 위해 네트워크 요청이 완료될 때까지 기다린다.
팁으로, 애플리케이션 업데이트 없이 변경하고 싶은 리소스를 인터넷에서 로드하는 것은 강력한 전략이다. 리소스 로드 방식을 더 세밀하게 제어하려면 Service Workers를 고려해보는 것도 좋다.
7. 코드 번들링
"코드를 너무 빨리 로드하고 실행하기"에서 이미 언급했듯이, require()
를 호출하는 작업은 비용이 많이 든다. 가능하다면 애플리케이션 코드를 하나의 파일로 번들링하는 것이 좋다.
왜 하나의 파일로 번들링할까?
모던 자바스크립트 개발에서는 보통 여러 파일과 모듈을 사용한다. 이는 Electron으로 개발할 때도 문제가 되지 않지만, 애플리케이션 로드 시 require()
호출에 따른 오버헤드를 최소화하기 위해 모든 코드를 하나의 파일로 번들링하는 것을 강력히 권장한다.
이렇게 하면 애플리케이션이 시작될 때 require()
의 비용을 한 번만 지불하면 되므로, 성능과 효율성을 크게 향상시킬 수 있다.
어떻게?
수많은 JavaScript 번들러가 존재하며, 하나의 도구를 다른 것보다 우월하다고 추천해 커뮤니티의 반감을 사는 것은 바람직하지 않다. 하지만 Electron의 고유한 환경을 다룰 수 있는 번들러를 사용할 것을 권장한다. Electron은 Node.js와 브라우저 환경을 모두 처리해야 하는 특수한 상황이다.
이 글을 작성하는 시점에서 널리 사용되는 선택지는 Webpack, Parcel, 그리고 rollup.js이다.
8. 기본 메뉴가 필요하지 않을 때 Menu.setApplicationMenu(null)
호출하기
Electron은 시작 시 몇 가지 표준 항목으로 구성된 기본 메뉴를 설정한다. 하지만 여러분의 애플리케이션이 이를 변경해야 하는 경우가 있으며, 이는 시작 성능에도 도움이 된다.
왜 필요한가?
여러분이 직접 메뉴를 만들거나 네이티브 메뉴가 없는 프레임리스 윈도우를 사용할 경우, Electron이 기본 메뉴를 설정하지 않도록 미리 알려줘야 한다.
어떻게 할까?
app.on("ready")
이전에 Menu.setApplicationMenu(null)
을 호출한다. 이렇게 하면 Electron이 기본 메뉴를 설정하지 않는다. 관련 토론은 https://github.com/electron/electron/issues/35512에서 확인할 수 있다.