UI가 버벅이는 진짜 이유: 이벤트 루프부터 상태 관리까지

UI가 버벅이는 진짜 이유: 이벤트 루프부터 상태 관리까지

마이크로태스크 지옥, 배치 스케줄러 설계, useShallow까지—렌더링이 멈추는 세 가지 층위를 해부합니다.

이벤트 루프 마이크로태스크 리렌더링 최적화 Zustand useShallow 배치 스케줄러 UI 버벅임 React 성능 프론트엔드 최적화
광고

'왜 내 UI는 버벅이는가'라는 오래된 질문

Lighthouse 점수는 95점인데 실제 사용자는 '느리다'고 한다. 로딩 스피너가 돌아야 할 타이밍에 그냥 멈춰 있다. 상태 하나 바꿨을 뿐인데 컴포넌트 전체가 우르르 리렌더링된다. 프론트엔드 개발하면서 한 번도 안 겪어본 사람이 있을까요? 저는 이 문제를 마주할 때마다 '진짜 원인이 어느 층위에 있는가'를 먼저 따집니다. 그리고 대부분의 경우, 이유는 세 층위 중 하나에 있습니다. JavaScript 런타임, 반응형 시스템 설계, 그리고 상태 관리 라이브러리의 비교 로직.

첫 번째 층위: 이벤트 루프와 마이크로태스크 지옥

dev.to의 아티클 The Secret Life of JavaScript: The Microtask는 이 문제를 아주 잘 짚었습니다. 요약하면 이렇습니다. setTimeout(fn, 0)을 쓴다고 해서 '즉시' 실행된다는 보장은 없습니다. 이벤트 루프에는 두 개의 큐가 있고, Promise 기반의 마이크로태스크 큐setTimeout 같은 매크로태스크 큐보다 무조건 먼저 처리됩니다. 더 심각한 건, 마이크로태스크가 자기 자신을 계속 큐에 밀어 넣으면 매크로태스크 차례는 영원히 오지 않는다는 점입니다.

실무에서 자주 보이는 패턴이 바로 이겁니다. Promise.resolve().then(() => { ... }).then(() => processNextBatch(result)) 형태의 재귀 체인. 겉보기엔 비동기처럼 보이지만, 마이크로태스크를 계속 생성하면서 이벤트 루프가 브라우저의 렌더링 타이밍(매크로태스크)으로 넘어가지 못하게 막아버립니다. 스피너가 그 자리에서 얼어붙는 이유가 바로 이것입니다. 해결책은 의외로 단순합니다. 청크 단위로 데이터를 나눠 처리하고, 각 청크 사이에 setTimeout을 끼워 넣어 이벤트 루프에 숨 쉴 틈을 줘야 합니다. 애니메이션과 정확히 동기화하고 싶다면 requestAnimationFrame이 더 적합합니다. 브라우저가 페인트하기 직전에 실행되도록 설계된 특수 매크로태스크니까요.

두 번째 층위: 반응형 시스템의 배치와 스케줄러 설계

이벤트 루프 문제를 해결했다고 끝이 아닙니다. 신호(Signal) 기반 반응형 시스템을 직접 설계하거나 이해해야 하는 상황이라면, 스케줄러의 책임 분리가 핵심입니다. dev.to의 Why Batch Belongs to the Scheduler, Not Computed는 이 설계 원칙을 명확하게 정리합니다.

핵심 명제는 이렇습니다. computed언제 값을 계산할지를 결정하고, batch언제 사이드 이펙트를 실행할지를 결정합니다. 이 두 책임을 섞으면 시스템이 불안정해집니다. computed를 lazy로 유지하면서, 여러 번의 set() 호출이 발생해도 이펙트가 딱 한 번만 실행되게 하려면, 스케줄러 모듈이 별도로 존재해야 합니다. 기사에서 소개하는 scheduler.ts는 중복 제거(dedupe), 마이크로태스크 병합, 배치 깊이(batchDepth) 추적을 단일 모듈에서 처리합니다.

실무 관점에서 이 설계가 중요한 이유는 플리커(flickering) 방지입니다. a.set(10)b.set(20)a.set(30)을 순서대로 실행하면, 배치 없이는 이펙트가 세 번 돌면서 중간 상태가 화면에 잠깐 보일 수 있습니다. batch() 안에 묶으면 이펙트는 마지막에 딱 한 번만 실행됩니다. Vue의 nextTick, React의 자동 배치(Auto Batching, 18 버전 이후)가 프레임워크 레벨에서 이 문제를 처리하는 방식과 본질적으로 같은 철학입니다. 배치 안에서 무거운 동기 작업을 돌리면 마이크로태스크 예약이 지연된다는 주의사항도 첫 번째 층위와 맞닿아 있습니다.

세 번째 층위: Zustand와 참조 동일성 함정

이벤트 루프도 잡았고, 스케줄러도 이해했는데 여전히 리렌더링이 넘칩니까? 그렇다면 이번엔 상태 관리 라이브러리의 비교 로직을 봐야 합니다. Zustand를 다룬 velog 포스트 Prevent rerenders with useShallow는 이 지점을 정확히 건드립니다.

Zustand는 셀렉터 함수의 반환값을 Object.is로 비교해 리렌더링 여부를 결정합니다. 원시 타입(string, number, boolean)은 문제없지만, 셀렉터가 Object.keys(state) 같이 매번 새 배열을 반환한다면? 배열 내부 값이 전혀 바뀌지 않아도, 새 배열 참조는 이전 참조와 다르기 때문에 React는 '값이 바뀌었다'고 판단하고 리렌더링을 트리거합니다. 이게 대시보드나 리스트 뷰처럼 상태 구독이 많은 화면에서 성능을 갉아먹는 주범입니다.

해결책은 useShallow입니다. useMeals(useShallow((state) => Object.keys(state))) 형태로 감싸면, 반환된 배열의 각 요소를 얕은 비교(shallow equality)로 검사합니다. 내부 값이 실제로 바뀌지 않았다면 리렌더링은 발생하지 않습니다. 실무 팁으로 덧붙이자면, 객체나 배열을 반환하는 셀렉터에는 무조건 useShallow를 습관적으로 감싸는 것을 권장합니다. 이 패턴 하나만 정착시켜도 불필요한 리렌더링의 상당수를 막을 수 있습니다.

세 층위를 이어 읽는 시사점

세 기사를 연결하면 하나의 흐름이 보입니다. 렌더링 품질 문제는 '어느 한 곳'의 실수가 아니라, 런타임 → 반응형 시스템 → 상태 관리라는 층위별 설계 결정이 쌓인 결과입니다.

  • 이벤트 루프 층위: 마이크로태스크 체인이 매크로태스크(=렌더링)를 굶기고 있지는 않은가
  • 스케줄러 층위: 이펙트 실행 타이밍이 computed의 lazy 계산과 올바르게 분리되어 있는가
  • 상태 관리 층위: 셀렉터가 참조 동일성을 유지하고 있는가, 혹은 shallow 비교가 필요한 상황인가

Figma에서 볼 때는 완벽했던 마이크로인터랙션이 실제 구현에서 끊기는 이유, 사용자 입장에서는 이 세 층위 중 어느 하나가 무너진 것입니다. 문제를 진단하기 전에 '어느 층위의 문제인가'를 먼저 묻는 것—그게 프론트엔드 성능 디버깅의 출발점입니다.

앞으로: 도구가 자동화해도 원리는 남는다

React 18의 자동 배치, Signals(Angular, Solid, Vue) 기반 프레임워크의 확산, Zustand·Jotai 같은 경량 상태 관리 도구의 대중화는 이 세 층위의 문제를 '프레임워크가 대신 처리해주는 방향'으로 빠르게 이동하고 있습니다. 하지만 도구가 추상화해준다고 해서 원리가 사라지지는 않습니다. 배치 경계를 잘못 설정하거나, 셀렉터에서 참조를 새로 만들거나, 마이크로태스크를 무한 생성하는 코드는 어떤 프레임워크를 써도 동일하게 렌더링을 망가뜨립니다. Core Web Vitals의 INP(Interaction to Next Paint)가 구글 랭킹 신호로 자리 잡은 지금, 이 층위별 이해는 선택이 아니라 실무 필수 역량입니다.

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요