오픈소스 성능을 최적화하며 배운 것들 프론트엔드를 개발하면서 화면이 갑자기 버벅대거나, 심각한 성능 저하를 겪어 본 적 있으신가요? 특히 애플리케이션 규모가 커질수록 이런 문제를 마주치기 쉬운데, 정작 어디서부터 손을 대야 할지 막막할 때가 많습니다. 렌더링에 관여하는 요소가 많아질수록, 작은 코드 변경만으로도 전체 성능이 급격히 떨어지거나, 의도치 않은 부분에서 병목이 발생하기도 합니다. 이번 글에선 제가 겪은 성능 최적화 여정을 공유하고자 합니다. 규모가 커지는 프론트엔드 애플리케이션에서 성능 문제로 고민하거나, 최적화 과정에 관심이 있는 분들께 조금이나마 도움이 되면 좋겠습니다. 쉽게 접하기 어려운 저수준 최적화 이야기부터, 실제 적용 과정에서 얻은 인사이트까지 살펴보겠습니다. “왜 이렇게 느려진 거지?” 성능 문제와의 첫 만남저는 최근 ‘Easyrd’라는 다이어그램 서비스를 개발하면서 비슷한 문제를 겪었습니다. 이 서비스는 제가 만든 ‘Flitter’라는 렌더링 엔진 프레임워크를 기반으로 하는데요. Flitter는 SVG나 Canvas를 선언적으로 다루면서, Flutter와 비슷한 인터페이스를 자바스크립트로 구현한 오픈소스 프로젝트입니다. 처음엔 그럭저럭 잘 돌아가는 것 같았지만, 다이어그램의 노드를 드래그하거나 초점을 옮길 때마다 화면이 버벅이기 시작했습니다. 결국 사용자 경험이 크게 저하되는 상황까지 맞닥뜨리게 되었죠. 노드가 많아질수록 한 프레임(16ms)을 훌쩍 넘기는 함수 호출이 쌓였습니다. 노드를 빠르게 움직이거나 확대·축소를 반복하면, 이벤트가 몰려들어 브라우저가 견디지 못했죠. 결국 노드가 툭툭 끊기거나 예상치 못한 곳으로 튀어가는 현상이 발생했고, 이는 서비스의 핵심 가치를 훼손하는 심각한 문제였습니다. 처음 Flitter를 설계할 땐 단순히 SVG 요소들을 선언적으로 그려주면 될 거라 생각했습니다. 하지만 “왜 첫 로딩이 이렇게 느리지?”라는 의문이 들면서 문제의 실체가 드러났습니다. 네트워크 지연이 아닌, 브라우저가 SVG를 그리는 과정 자체가 병목이었던 거죠. 자세히 들여다보니 SVG 자식 요소마다 개별적으로 이벤트 핸들러를 달면서, DOM 접근이 과도하게 발생하고 있었습니다. flitter로 만든 다이어그램 서비스 ‘Easyrd’ <출처: 작가> 시행착오의 연속초기에는 성능 문제의 심각성을 제대로 파악하지 못한 채, 오히려 상황을 악화시키는 방향으로 구조를 수정하기도 했습니다. 괜히 엉뚱한 부분을 고치느라 더 복잡한 로직을 추가하게 되었고, 결국 렌더링 부담만 가중되는 악순환을 겪었죠. 이 과정에서 깨달은 건 성능 최적화는 무작정 코드를 고치는 게 아니라, 정확한 측정과 분석이 선행되어야 한다는 점이었습니다. SVG 이벤트 처리에서 불필요한 DOM 접근 줄이기초기 문제: 각 요소에 직접 핸들러 부착의 한계프로젝트 초기에 Flitter가 SVG만 지원하던 시절, 가장 먼저 마주한 문제는 모든 SVG 요소에 직접 이벤트 핸들러를 부착하는 방식이었습니다. 대표적으로 GestureDetector라는 위젯에 클릭, 드래그 등의 이벤트 핸들러를 달면, 내부적으로 실제 SVG 요소에 핸들러가 추가되는 구조였죠. 이렇게 자식 요소가 하나씩 늘어날 때마다 DOM 접근이 급격히 증가했고, 결국 수많은 요소에 핸들러가 연결되면서 화면 초기 로딩부터 인터랙션까지 버벅댐이 두드러졌습니다. 노드가 여러 개 있을 때는 크게 눈에 띄지 않았지만, 점차 다이어그램 노드나 UI 위젯이 복잡해지면서 트리 구조가 깊어졌습니다. 이때마다 SVG 요소마다 이벤트 핸들러를 붙이는 작업 자체가 병목 지점이 되었고요. DOM 접근이 누적되어 브라우저가 한 화면을 완전히 구성하거나 변경하는 데 필요한 시간이 눈에 띄게 길어졌고, 사용자는 “화면이 생각보다 늦게 뜬다”라고 느끼게 되었죠. 이벤트 위임 패턴의 도입이 문제를 해결하기 위해 도입된 것이 이벤트 위임(Event Delegation) 패턴입니다. 최상위 SVG 요소 하나에만 핸들러를 등록하고, 실제로는 이벤트 버블링을 통해 내부 이벤트를 캐치하도록 설계한 것이 핵심입니다. 자식 위젯에 연결된 핸들러는 실제로는 ‘위젯트리’ 내부에서만 관리되며, 최상위 SVG에서 발생하는 이벤트가 적절한 콜백 목록으로 분배되는 구조가 만들어졌습니다. 이렇게 루트 요소 하나에만 이벤트를 집중시키면 DOM 접근이 획기적으로 줄어들어, 노드가 아무리 많아져도 자식마다 직접 핸들러를 다는 방식에 비해 훨씬 가벼운 인터랙션 응답 속도를 얻을 수 있습니다. 이벤트 위임은 사실 리액트 등 다른 라이브러리에서도 널리 사용되는 기법입니다. 리액트 역시 ‘가상 DOM’에 이벤트 핸들러를 관리하고, 실제 DOM에는 루트 하나에만 이벤트를 등록함으로써 중복 작업을 최소화합니다. Flitter 역시 이를 벤치마킹하여 “부모 SVG에만 이벤트 핸들러를 부착하는 정책”을 채택했습니다. 그 결과, 대규모 다이어그램에서도 일관된 인터랙션 성능을 유지할 수 있었고, 요소 개수에 비례해 늘어났던 DOM 접근이 현저히 줄어들었습니다. 프레임당 한 번의 ‘requestAnimationFrame’으로 최적화하기requestAnimationFrame 비용 문제렌더링 성능을 분석하면서 의외의 사실을 발견했습니다. requestAnimationFrame 함수 자체가 꽤 큰 비용을 발생시킨다는 거죠. 브라우저 내부에서 콜백을 특정 큐에 등록하고 관리하는 과정에서, 30~60μs 정도의 시간이 소요된다는 점을 크롬 DevTools로 확인할 수 있었습니다. “겨우 마이크로초 단위인데 뭐가 문제야?”라고 생각할 수 있지만, 이 작은 비용이 프레임마다 수십 번씩 발생하면 이야기가 달라집니다. 실제로 프레임 분석 결과, 단순한 드래그 동작에서도 여러 컴포넌트가 개별적으로 requestAnimationFrame을 호출하면서 불필요한 오버헤드가 쌓여가고 있었죠. Phase로 나누어 콜백을 관리하다이 문제를 해결하기 위해 Flutter의 프레임 처리 방식에서 영감을 얻었습니다. 한 프레임 내에서 모든 업데이트 요청을 phase별로 구분하고, requestAnimationFrame은 딱 한 번만 호출하는 방식을 도입한 거죠. 실제 구현한 코드를 한번 살펴볼까요? enum SchedulerPhase { idle, persistenceCallbacks, postFrameCallbacks, } class Scheduler { phase: SchedulerPhase; private persistenceCallbacks: (() => void)[]; private postFrameCallbacks: (() => void)[]; private renderFrameDispatcher: RenderFrameDispatcher; constructor({ renderFrameDispatcher, }: { renderFrameDispatcher: RenderFrameDispatcher; }) { this.phase = SchedulerPhase.idle; this.persistenceCallbacks = []; this.postFrameCallbacks = []; this.renderFrameDispatcher = renderFrameDispatcher; renderFrameDispatcher.setOnFrame(() => this.handleDrawFrame()); } private hasScheduledFrame = false; ensureVisualUpdate() { switch (this.phase) { case SchedulerPhase.idle: case SchedulerPhase.postFrameCallbacks: this.schedule(); break; case SchedulerPhase.persistenceCallbacks: break; } } private schedule() { if (this.hasScheduledFrame) return; // 이미 예약된 프레임이 있다면 중복 호출 방지 this.renderFrameDispatcher.dispatch(); this.hasScheduledFrame = true; } } 위 코드를 보면, ‘hasScheduledFrame’ 플래그를 통해 프레임당 단 한 번만 requestAnimationFrame이 호출되도록 보장합니다. 이를 통해 불필요한 requestAnimationFrame 호출을 방지할 수 있었죠. 실제로 체감되는 차이이렇게 변경 후 성능 측정하니 놀라운 결과가 나왔습니다. 동일한 드래그 동작에서 requestAnimationFrame 호출 횟수가 프레임당 평균 100회에서 1회로 줄어들었습니다. CPU 프로파일링 결과에서도 콜백 등록과 관련된 오버헤드가 확연히 감소한 것을 확인할 수 있었죠. 특히 여러 노드를 동시에 드래그하거나, 복잡한 애니메이션을 실행할 때 차이가 더 두드러졌습니다. 이전에는 프레임 드랍이 발생하던 상황에서도 이제는 60fps를 안정적으로 유지할 수 있게 됐습니다. 단순히 콜백을 모아서 처리하는 것을 넘어, 브라우저의 렌더링 파이프라인과 더 효율적으로 동작하는 구조를 만들어낸 겁니다. “이게 정말 빨라진 거 맞나요?” 성능 개선을 수치로 증명하기개발을 하다 보면 자주 마주치는 상황이 있습니다. “여기 최적화했는데 확실히 빨라진 것 같아요.”“음... 저는 별로 차이를 못 느끼겠는데요?” 이런 모호한 상황을 벗어나기 위해, Playwright와 크롬 데브툴 프로토콜(Chrome DevTools Protocol)을 결합한 자동화된 성능 측정 시스템을 구축했습니다. 이 시스템을 통해 코드의 특정 부분이 얼마나 자주 실행되는지, 실제로 얼마나 시간이 걸리는지를 정확하게 파악할 수 있었습니다. 자동화된 성능 측정, 어떻게 구현했나요?test.describe("Performance Tracking", () => { test("Capture performance traces ans save json file on diagram is rendered", async ({ page, browser, }) => { await browser.startTracing(page, { path: `./performance-history/${formatDate(new Date())}.json`, }); await page.goto("http://localhost:4173/performance/diagram"); await page.evaluate(() => window.performance.mark("Perf:Started")); await page.click("button"); await page.waitForSelector("svg"); await page.evaluate(() => window.performance.mark("Perf:Ended")); await page.evaluate(() => window.performance.measure("overall", "Perf:Started", "Perf:Ended") ); await browser.stopTracing(); }); test("Capture analyzed trace when diagram is rendered", async () => { const COUNT = 10; const duration = { timestamp: Date.now(), runApp: 0, mount: 0, draw: 0, layout: 0, paint: 0, note: "", }; for (let i = 0; i < COUNT; i++) { const browser = await chromium.launch({ headless: true }); const context = await browser.newContext(); const page = await context.newPage(); await page.goto("http://localhost:4173/performance/diagram"); await browser.startTracing(page, {}); await page.evaluate(() => window.performance.mark("Perf:Started")); await page.click("button"); await page.waitForSelector("svg"); await page.evaluate(() => window.performance.mark("Perf:Ended")); await page.evaluate(() => window.performance.measure("overall", "Perf:Started", "Perf:Ended") ); const buffer = await browser.stopTracing(); const jsonString = buffer.toString("utf8"); // buffer를 UTF-8 문자열로 변환 const trace = JSON.parse(jsonString); // 문자열을 JSON 객체로 파싱 const analyzer = new ChromeTraceAnalyzer(trace); duration.runApp += analyzer.getDurationMs("runApp") / COUNT; duration.mount += analyzer.getDurationMs("mount") / COUNT; duration.draw += analyzer.getDurationMs("draw") / COUNT; duration.layout += analyzer.getDurationMs("layout") / COUNT; duration.paint += analyzer.getDurationMs("paint") / COUNT; browser.close(); } console.log("****Execution Time****"); console.log(`runApp: ${duration.runApp}ms`); console.log(`mount: ${duration.mount}ms`); console.log(`draw: ${duration.draw}ms`); console.log(`layout: ${duration.layout}ms`); console.log(`paint: ${duration.paint}ms`); console.log("********************"); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const filePath = path.join(__dirname, "../performance-history/duration.ts"); let fileContent = fs.readFileSync(filePath, { encoding: "utf8" }); fileContent += `histories.push(${JSON.stringify(duration)});\n`; fs.writeFileSync(filePath, fileContent); }); }); Playwright는 여러 브라우저를 자동으로 구동하고 제어할 수 있어, 테스트와 측정 환경을 일관성 있게 유지하기 좋습니다. 크롬 데브툴 프로토콜을 통해 얻은 브라우저 내부 성능 지표와 실행 중인 스크립트 정보를 분석하면, 어느 부분이 CPU나 메모리를 많이 소모하는지 세밀하게 파악할 수 있죠. 자동화 시스템을 통해 동일한 시나리오를 여러 차례 반복 측정하면, 단순히 한두 번의 체감 테스트로는 놓치기 쉬운 미세한 변화도 정확히 포착할 수 있습니다. 병목 지점을 수정한 뒤에는 그 전후 데이터를 누적 관리함으로써, “정말 성능이 개선되었는가?”를 명확하게 증명할 수 있게 됩니다. 이런 방식은 특히 프로젝트 규모가 커질수록 더욱 중요해집니다. 팀 단위로 협업할 때 “어느 부분에 더 투자해야 하는가?”를 결정하는 객관적인 근거가 되어주죠. 게다가 개발 과정에서 쉽게 놓치기 쉬운 ‘작은 함수’나 특정 케이스가 반복 호출되는 부분도 데이터로 정확히 나타나므로, 사소해 보이는 병목 요소도 찾아낼 수 있습니다. 성능 측정한 결과 데이터를 차트로 시각화한 모습 <출처: 작가> “어? 이제 안 버벅대네” 사용자들의 첫 반응이러한 최적화를 거친 후 복잡한 SVG 다이어그램을 드래그하거나, 확대·축소하는 과정에서 발생하던 프레임 드랍이 크게 줄었습니다. 마우스 포인터가 도형보다 훨씬 앞서가거나, 객체 이동 중간에 순간 멈춤 증상이 나타나는 등 이전에는 자주 보이던 현상도 거의 사라졌습니다. 이는 사용자 인터랙션 흐름이 한층 매끄러워졌다는 의미로, 실제 사용자 만족도도 상승했습니다. ‘Easyrd’에서는 노드를 드래그할 때 마우스 움직임을 자연스럽게 따라가는 모습을 체감할 수 있습니다. 노드를 재빨리 움직여도 지연 없이 쫓아오기 때문에, 여러 개 노드를 동시에 이동하거나 복잡한 구조를 미세 조정할 때도 훨씬 안정적인 환경을 제공합니다. 이러한 시각적·기능적 개선은 엔진 내부에서 이루어진 최적화가 실제로 어떤 변화를 불러올 수 있는지 보여줍니다. <출처: 작가> 의외로 별거 아닌 작은 변화의 시작프레임워크 차원의 이벤트 처리와 렌더링 파이프라인을 일부 손본 것만으로도, 이렇게 직관적인 사용자 경험 변화를 끌어낼 수 있다는 점이 놀라웠는데요. 실무에서 저수준 최적화를 시도하면 초기 단계에서 복잡성이 증가하기도 하지만, 제대로 적용만 된다면 프로젝트 전체 퍼포먼스가 달라질 수 있습니다. 특히 상대적으로 간단해 보이는 특정 함수 호출 횟수를 줄이거나, 이벤트 로직을 재설계하는 것만으로도 의외의 결과를 얻을 수 있었죠. 물론 실제 프로젝트에서 최적화 작업은 생각처럼 단순하지 않습니다. 코드 구조를 개편하다 보면, 기존 기능과 충돌을 일으키거나 예상치 못한 버그가 발생할 수 있죠. 이벤트 핸들링 로직을 바꾸면 바로 반응 속도가 빨라지는 대신, 이전에 달아두었던 종속된 로직들이 정상적으로 작동하지 않는 예측 불가능한 상황을 만나기도 했습니다. 만약 여러분도 성능 최적화를 위해 독특한 기법을 적용했거나, 예상치 못한 어려움을 겪은 적이 있다면 어떤 시행착오라도 좋으니, GitHub Discussions에서 여러분의 경험을 나눠주시면 좋겠습니다. 마지막으로 실제 구현 코드가 궁금하다면, GitHub 리포지토리에서 확인해 보세요. 프레임워크 내부 구조와 각종 최적화 아이디어가 어떻게 반영되었는지 볼 수 있습니다. 아직 부족한 점이 많지만, 앞으로도 피드백을 통해 더욱 발전해 나가고자 합니다. ©요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.