이 글은 innei.in/posts/tech/lobehub-performance-dx-optimization에 대한 분석입니다. 원문은 LobeHub이라는 ChatGPT/LLM 클라이언트 프로젝트를 사례로 삼아 프런트엔드 컴포넌트, Electron 메인 프로세스, Next.js 개발자 경험 등 여러 계층에서 진행한 일련의 최적화를 소개합니다. 이 글은 원문의 설명을 반복하지 않고, 그 안에 흩어져 있는 구체적인 변경 사항들을 다른 프로젝트에도 적용할 수 있는 하나의 엔지니어링 프레임워크로 정리합니다. 네 가지 비용 × 비용 발생 위치의 이동.
관련 노트
- [[笔记/web/Java/JVM 原理、性能调优与故障排查]]: JVM 튜닝과 이 글의 접근 방식은 비슷합니다. 둘 다 “빈번하게 발생하는 작은 비용을 분산”하는 일을 다룹니다.
1. 먼저 직관부터 잡기: 인프라의 작은 비용은 컴포넌트 수만큼 증폭된다
LobeHub의 이번 최적화들이 공유하는 전제는 다음과 같습니다: 작업 한 번에 0.1 ms가 드는 것은 크지 않지만, 컴포넌트 수를 곱하면 문제가 달라집니다.
원문에는 다음과 같은 대략적인 곱셈이 제시됩니다.
0.1 ms × 500 个组件 = 50 ms
원활한 렌더링을 위한 프레임 예산은 16.7 ms입니다. 인프라의 작은 작업 하나가 “컴포넌트를 렌더링할 때마다 한 번씩 수행되는 것”으로 바뀌면, 여기에 백여 개에서 200개쯤의 컴포넌트가 곱해지는 순간 “무시할 수 있는” 수준에서 “눈에 띄는 지연”으로 바뀝니다.
이 직관은 중요합니다. LobeHub이 최적화하는 대상이 일반 사용자가 보기에는 아무래도 중요해 보이지 않는 것들인 이유를 설명해 줍니다.
<Flexbox>컴포넌트 함수 자체의 실행useStyles()Hook 호출 한 번- Tooltip의 플로팅 레이어 초기화 한 번
- 평소에는 보이지 않는 Modal이 React 트리에 연결해 둔 Hook
이들은 모두 “인프라 계층의 작은 동작”입니다. 하나만 최적화해도 어떤 페이지가 1000 ms에서 10 ms로 내려가지는 않습니다. 하지만 이를 모두 합치면, 홈 화면을 열 때마다 수백 개의 컴포넌트를 마운트하는 앱이 눈에 띄게 더 매끄러워질 수 있습니다.
이후의 모든 구체적인 변경은 다음과 같이 이해할 수 있습니다: 이 0.1 ms짜리 작은 동작을 “매번 발생”에서 “단 한 번만 발생”하도록 옮기는 것. 또는 “실제로 필요할 때 발생하도록” 옮기는 것입니다.
3. react-layout-kit 교체: CSS-in-JS 런타임을 모듈 로드 시점으로 옮기기
react-layout-kit의 Flexbox는 단순한 레이아웃 컴포넌트처럼 보이지만, antd-style / Emotion 체계에서는 내부적으로 여전히 다음 과정을 거쳐야 합니다.
서로 다른 props 조합 다섯 가지입니다.
조합마다 서로 다른 CSS가 생성될 수 있습니다.
캐시가 적중하더라도 그 앞에서 여전히 컴포넌트 함수 실행, 스타일 인자 구성, 객체 할당, 직렬화, 해시 계산, 조회가 일어나야 합니다. 개발 환경에서는 디버깅 정보와 Source Map도 보관해야 하므로 메모리 문제가 더 두드러집니다.
새 방식: 모든 인스턴스가 하나의 CSS를 공유
LobeHub은 내부 Flex를 직접 구현했습니다. 기존 API는 그대로 둔 채 기반 구현을 고정 클래스로 바꿨습니다.
컴포넌트는 props를 CSS 변수로 매핑하기만 합니다.
결과는 다음과 같습니다.
절약되는 비용은 다음과 같습니다.
- gap 값마다 새로운 Hash 클래스 이름을 만들지 않음
<style>에 규칙을 계속 삽입하지 않음- Emotion Cache를 조회하지 않음
추가되는 비용은 다음과 같습니다.
- 매 렌더링마다 작은
cssVars객체를 생성함 className을 조합함
둘 중 후자가 전보다 확실히 저렴합니다. 원문에서는 컴포넌트 하나의 오버헤드를 약 0.1 ms로 설명하며, 진짜 문제는 수백 개 컴포넌트 × 0.1 ms라는 점입니다.
CSS 변수 기본값을 모든 Flex에 지정해야 하는 이유
CSS 사용자 정의 속성은 기본적으로 상속됩니다. 다음과 같은 경우를 생각해 보겠습니다.
하위 요소에 --lobe-flex-gap을 명시적으로 정의하지 않으면 부모의 16px를 상속할 수 있고, 하위 레이아웃에 의도하지 않은 간격이 생깁니다.
그래서 내부 CSS는 모든 .lobe-flex에 기본값을 다시 설정합니다.
이 수준의 기본값은 반드시 필요한 비용입니다. “CSS 변수로 컴포넌트 스타일을 제어하는” 유사한 코드를 읽을 때는 각 계층에서 기본값을 모두 초기화하는지 바로 확인하면 됩니다.
5. 홈 화면 상시 유지: React Activity와 “방문 후 캐싱”
LobeHub의 홈 화면(DesktopHomeLayout)에는 아주 많은 요소가 들어 있습니다.
- 사이드바
- 최근 기록
- 입력 영역
- 스킬 목록
- 여러 팝업 진입점
- 다수의 store selector
- 데이터 복구 로직
이전 라우트 구조는 대략 다음과 같았습니다.
설정 페이지에 들어갔다가 홈 화면으로 돌아오면 완전한 언마운트-재구성 경로를 거쳐야 했습니다.
원문에서 측정한 DesktopHomeLayout의 복귀 시간은 500 ms에 가까웠습니다.
수정 방법: 홈 화면을 라우트 분기 밖으로 옮기기
변경된 구조는 대략 다음과 같습니다.
기존 index route은 더 이상 홈 화면 컴포넌트를 별도로 만들지 않습니다.
홈 화면 내부에서는 경로에 따라 판단합니다.
Activity가 mode="hidden"일 때의 동작은 다음과 같습니다.
display: none으로 내용을 숨김- 컴포넌트의 React State를 유지함
- 해당 DOM을 유지함
- 컴포넌트의 Effect와 구독을 정리함
- 숨겨져 있는 동안에도 낮은 우선순위로 새 props에 반응할 수 있음
- 다시 표시되면 기존 상태를 복원하고 Effect를 다시 만듦
다음처럼 이해할 수 있습니다.
Fiber、State、DOM:保留
Effect、订阅:清理
视觉:display: none
돌아올 때의 주요 작업은 다음과 같이 바뀝니다.
hidden → visible
↓
恢复 Effect
↓
浏览器重新展示已有 DOM
더 이상 전체 DOM 트리를 다시 만들지 않습니다.
hasActivated: 방문 후 캐싱, 앱 시작 시 사전 렌더링 아님
실제 코드에는 다음과 같은 로직이 추가되었습니다.
로직은 다음과 같습니다.
用户从未访问首页 → 不创建首页
用户第一次访问首页 → 创建首页
用户以后离开首页 → 隐藏但保留
사용자가 홈 화면을 한 번도 방문하지 않았다면 만들지 않고, 처음 방문하면 생성한 뒤, 이후에는 숨기되 상태를 유지합니다.
이 방식은 “방문 후 캐싱”이지 “앱 시작 시 무조건 사전 렌더링”이 아닙니다. 그렇지 않으면 사용자가 바로 /settings로 들어가더라도 앱이 백그라운드에서 무거운 홈 화면을 한 번 마운트해 시작 시간과 메모리 사용량을 늘리게 됩니다.
절대 위치 지정과 비용
상시 유지되는 홈 화면 컨테이너는 다음을 사용합니다.
홈 화면은 더 이상 일반적인 라우트 출력이 아니므로 다른 페이지의 문서 흐름에 참여하지 않아야 합니다. 홈 화면이 보일 때는 주 콘텐츠를 덮고, 숨겨질 때는 Activity가 보이지 않도록 처리합니다.
이 최적화의 대가는 상시 점유 비용입니다.
- Fiber 메모리
- DOM 메모리
- 컴포넌트 State
- 캐시된 데이터
- 브라우저 레이아웃 객체
다음과 같은 상황에 적합합니다.
- 자주 돌아오는 홈 화면
- 편집기
- 채팅 세션
- 입력 상태를 유지해야 하는 페이지
다음과 같은 상황에는 적합하지 않습니다.
- 드물게 돌아오는 페이지
- DOM이 특히 큰 리포트
<video>/<audio>/<iframe>가 많이 있는 페이지
<video>, <audio>, <iframe>는 숨겨진 뒤에도 DOM이 남아 있어 자체 동작을 계속 실행할 수 있으므로, Effect 정리 함수에서 명시적으로 일시 중지하거나 해제해야 합니다.
7. Tooltip 단일화: 하나의 플로팅 레이어 공유
기존 Tooltip의 각 인스턴스에는 다음이 포함될 수 있었습니다.
useFloating위치 결정 로직- 마우스 진입/이탈 타이머
- Focus 및 Hover 인터랙션 Hook
- Portal
- Arrow 위치 조정
- Open State
- Motion 애니메이션
ResizeObserver- Scroll 리스너
- 자동 위치 갱신 로직
페이지에 각각 Tooltip이 감싼 아이콘이 200개 있다면 다음과 같습니다.
어느 Tooltip도 열리지 않았더라도 각 인스턴스의 트리거와 Hook은 생성해야 합니다. 원문의 TooltipFloating은 약 200줄에 이르며, Floating UI, debounce, AnimatePresence, 동적 transformOrigin, 콘텐츠 전환 애니메이션, 화살표 로직, Portal, 레이아웃 애니메이션을 포함합니다.
단일 Tooltip의 구조
각 Tooltip은 다음만 등록합니다.
触发节点
title
placement
回调
Group 전체는 실제 플로팅 레이어 하나를 공유합니다.
一个 Root
一个 Portal
一个 Floating Content
一个 Arrow
一套定位状态
마우스가 어떤 트리거에 들어가면 현재 payload만 갱신합니다.
플로팅 레이어를 새 anchor에 맞춰 다시 배치할 뿐, 버튼마다 완전한 플로팅 레이어 컴포넌트 트리를 만들지는 않습니다.
실제 구현은 Base UI의 <BaseTooltip.Root handle={handle}>를 기반으로 하며, Group Context가 handle을 공유합니다. 일반 Tooltip은 Group 내부에 있으면 공유 모드로 전환합니다. 제어형 Tooltip과 defaultOpen이 지정된 Tooltip, 또는 명시적으로 standalone인 Tooltip은 독립 구현을 유지합니다.
Base UI가 완전한 UI 라이브러리보다 더 쉽게 최적화되는 이유
Base UI는 Headless 컴포넌트로, 주로 다음을 제공합니다.
- 접근성
- 인터랙션 상태
- 위치 조정
- 키보드 동작
- ARIA
- 기본 Portal
스타일과 애니메이션은 프로젝트가 직접 제어합니다. 완전한 컴포넌트 라이브러리와 비교하면 다음을 덜 감당해도 됩니다.
- 호환 계층
- 테마 래퍼
- 다층 Context
- 기본 DOM
- 기본 애니메이션
- 스타일 주입 로직
더 정확한 표현은 “Ant Design이 느리고 Base UI가 빠르다”가 아니라 “LobeHub은 Base UI를 바탕으로 더 작은 컴포넌트 트리와 공유 플로팅 레이어를 구현할 수 있다”입니다. 기본 라이브러리를 바꿀 가치가 있는지는 내 프로젝트에 이런 상황이 얼마나 많은지에 달려 있습니다.
9. Electron 패키지 크기와 네이티브 모듈 관련 세 가지
Electron 메인 프로세스는 원래 다음처럼 의존성을 직접 사용할 수 있었습니다.
빌드 도구가 모든 의존성을 externalize하면 산출물에는 require('some-package')만 남고, 런타임에는 완전한 node_modules/some-package를 함께 배포해야 합니다. 전체 node_modules에는 다음도 들어 있습니다.
- 사용하지 않는 파일
- README
- 타입 선언
- Source Map
- 여러 모듈 형식
- 테스트 코드
- 선택적 리소스
- 중복 의존성
일반 JS 의존성을 번들링하도록 변경
최적화된 electron-vite는 더 이상 모든 의존성을 externalize하지 않습니다. 일반 JavaScript 패키지는 Rollup이 다음 경로에 번들링합니다.
dist/main/index.js
dist/preload/index.js
Rollup은 그 과정에서 다음 작업도 함께 수행합니다.
- 트리 셰이킹
- 중복 제거
- 압축
- 의존성 인라인
- 참조되지 않은 코드 제거
electron-builder 설정은 다음과 같습니다.
node_modules를 전체에서 제외하고, 반드시 유지해야 하는 네이티브 의존성만 다시 추가합니다.
네이티브 모듈은 함께 번들링할 수 없음
예를 들면 다음과 같습니다.
better-sqlite3
sharp
node-mac-permissions
이 패키지들은 .node 파일을 포함할 수 있으며, JavaScript가 아니라 네이티브 동적 라이브러리입니다. Node.js가 이를 로드하려면 실제 파일 경로가 필요합니다.
따라서 네이티브 모듈은 다음 세 가지를 모두 만족해야 합니다.
Rollup이 번들링하지 않아야 함
생성 결과에도 require('node-mac-permissions')가 그대로 남습니다.
electron-builder가 설치 패키지에 포함해야 함
ASAR에서 풀어져야 함
최종 파일은 app.asar.unpacked/node_modules/...에 놓입니다.
프로젝트는 네이티브 모듈의 package.json을 재귀적으로 읽어 전이 의존성도 함께 추가합니다. 그렇지 않으면 네이티브 모듈 자체는 존재해도 시작 시점에 하위 의존성이 없어 실패할 수 있습니다.
세 항목은 서로 일치해야 합니다.
external 列表 = builder include 列表 = asarUnpack 列表
하나라도 빠지면 “빌드는 성공하지만 런타임에는 실패”할 수 있습니다. Electron 패키징에서 초보자가 가장 자주 빠지기 쉬운 함정입니다.
Electron 언어 리소스 제거
Electron Framework에는 Chromium의 현지화 리소스가 내장되어 있습니다.
en.lproj
zh_CN.lproj
ja.lproj
fr.lproj
이것들은 Chromium 네이티브 인터페이스 리소스이며, 앱 자체의 i18n JSON이 아닙니다.
빌드가 끝난 뒤의 afterPack Hook은 다음 경로를 순회합니다.
Electron Framework.framework/
Versions/A/Resources/*.lproj
영어 관련 디렉터리만 남깁니다.
전부 삭제해서는 안 됩니다. Electron Framework에도 기본 현지화 리소스가 필요합니다.
11. IPC 타입 안전성: 문자열에서 Controller 레지스트리로
기존 방식은 다음과 같았습니다.
철자가 서로 달라도 TS가 잡아내지 못하고, 채널 이름 / 매개변수 / 반환값 / 메인 프로세스 구현 / 렌더러 프로세스 호출이 모두 서로 독립적입니다. 메인 프로세스 메서드를 리팩터링해도 호출부는 계속 컴파일되고, 런타임에야 오류가 발생할 수 있습니다.
Controller + 데코레이터
메인 프로세스는 다음과 같이 변경했습니다.
채널 이름은 windows.minimizeWindow로 자동 추론됩니다. Controller는 controllerIpcConstructors에 일괄 등록되며, 이 목록은 런타임 IPC Handler 설치와 TypeScript 서비스 타입 추출 양쪽에 사용됩니다.
렌더러 프로세스에서 Proxy 사용
ensureElectronIpc()는 JavaScript Proxy를 사용해 속성 접근을 수집할 수 있습니다.
TypeScript 타입은 Controller 레지스트리에서 나오며 다음을 추론할 수 있습니다.
- 사용 가능한 Controller
- 사용 가능한 메서드
- 매개변수
- 반환값
하지만 TS 타입은 컴파일 시점에만 존재합니다. IPC는 프로세스 경계를 넘어 operates, 렌더러 프로세스가 공격받을 수 있으므로 메인 프로세스는 여전히 런타임 매개변수 검증을 해야 합니다. TS에만 의존해서는 안 됩니다.
13. Next.js에서 Vite로: 프런트엔드와 백엔드 빌드 책임 분리
LobeHub의 접근은 단순히 “Next.js를 삭제”하는 것이 아니라 책임을 나누는 방식입니다.
UI만 개발할 때는 Vite만 시작하고, 전체 Next.js App Router, RSC, 서버 번들링, 라우트 분석 프로세스는 실행하지 않습니다.
큰 Next.js 프로젝트에서 개발 메모리가 많이 필요한 이유
App Router 프로젝트는 개발 중 다음과 같은 것들을 동시에 유지할 수 있습니다.
- 클라이언트 모듈 그래프
- 서버 모듈 그래프
- RSC 모듈 그래프
- 라우트 트리
- Server Action
- Route Handler
- 미들웨어
- HMR 상태
- Source Map
- 빌드 캐시
같은 모듈에 클라이언트 버전, 서버 버전, RSC 참조 버전이 함께 존재할 수도 있습니다. Vite의 순수 SPA 모드는 주로 브라우저 측 ESM 그래프를 유지하고 요청에 따라 모듈을 변환하므로, UI만 개발할 때의 프로세스 모델이 훨씬 단순합니다.
원문의 Next.js: 10 GB+, Vite: 约 1 GB은 LobeHub 프로젝트 자체에서 얻은 실험 결과이며, 모든 프로젝트에서 항상 10배 차이가 난다는 뜻은 아닙니다. 프로젝트 규모, 플러그인, Source Map, 의존성 트리, Node.js 버전에 따라 달라집니다.
마이그레이션 비용
마이그레이션 후에는 다음 항목을 다시 처리해야 합니다.
- App Router에서 React Router로의 라우트 매핑
- SSR 및 SEO
- 서버 구성을 프런트엔드에 주입
- 인증 및 라우트 가드
- Next API 프록시
- Vite와 Next의 두 개발 서버
- 정적 자산 경로
- Electron과 Web 빌드 차이
- RSC 컴포넌트 경계
이는 단순히 next dev를 vite로 바꾼 것이 아니라 아키텍처 수준의 DX 최적화입니다. 현재 Next.js를 아주 편하게 쓰고 있다면 굳이 옮길 가치가 없을 수도 있습니다.
참고
[1] LobeHub 성능 및 DX 최적화. innei. https://innei.in/posts/tech/lobehub-performance-dx-optimization
[2] antd-style createStaticStyles. antd-style. https://github.com/ant-design/antd-style
[3] Base UI. MUI. https://base-ui.com/
[4] React Activity(오프스크린 컴포넌트). React. https://react.dev/blog/2025/04/23/react-19-updates
[5] class-variance-authority. https://github.com/joebell.co.uk/cva
[6] electron-vite. https://electron-vite.org/
[7] electron-builder. https://www.electron.build/