블로그로 돌아가기
Frontend

React에서 Apple Liquid Glass 효과를 구현하고 최적화한 과정

2026.03.13·10분 소요·17회 조회
ReactTypeScriptPerformanceOpen Sourcenpm

liquid-glass-react

Apple이 WWDC25에서 공개한 Liquid Glass 디자인을 React 컴포넌트로 구현한 오픈소스 라이브러리다. npm에서 월간 45,000+ 다운로드를 기록하고 있다.

bashbash
npm install liquid-glass-react

npm · Live Demo · GitHub

기본 사용법

tsxtsx
import LiquidGlass from "liquid-glass-react";

function App() {
  return (
    <LiquidGlass
      displacementScale={70}
      blurAmount={0.0625}
      saturation={140}
      aberrationIntensity={2}
      elasticity={0.15}
      cornerRadius={24}
    >
      <h2>Your content here</h2>
    </LiquidGlass>
  );
}

4가지 굴절 모드를 지원한다: standard, polar, prominent, shader.

왜 최적화가 필요했나

원래 버전에서 마우스를 움직이면 초당 60회 이상의 React 리렌더링이 발생했다. 글래스 컴포넌트가 여러 개 있으면 프레임 드랍이 심각했다. 프로파일러로 확인해보니 주요 병목이 명확했다:

  1. 마우스 좌표를 useState로 관리 → 매 움직임마다 리렌더
  2. getBoundingClientRect()를 프레임당 4-5회 호출
  3. transition: all로 모든 프로퍼티에 전환 효과
  4. 브라우저 감지를 매 렌더마다 실행

최적화 전략

1. useState → useRef

가장 큰 임팩트. 마우스 좌표를 useRef로 바꾸고 requestAnimationFrame으로 DOM을 직접 업데이트한다.

typescripttypescript
// Before: 매 움직임마다 리렌더
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });

onMouseMove = (e) => {
  setMousePos({ x: e.clientX, y: e.clientY }); // 60fps 리렌더!
};

// After: React를 우회한 직접 DOM 조작
const mousePosRef = useRef({ x: 0, y: 0 });
const rafRef = useRef<number>();

onMouseMove = (e) => {
  mousePosRef.current = { x: e.clientX, y: e.clientY };
  if (!rafRef.current) {
    rafRef.current = requestAnimationFrame(() => {
      updateDOM(); // 직접 DOM 업데이트
      rafRef.current = undefined;
    });
  }
};

이것만으로 리렌더가 60fps → 0fps로 줄었다.

2. getBoundingClientRect 호출 최소화

프레임마다 4-5번 호출되던 것을 1번으로 통합하고, 결과를 캐시한다.

typescripttypescript
// rect를 한 번만 계산하고 재사용
const rect = elementRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;

추가로 ResizeObserver를 사용해 크기 변경 시에만 rect를 재계산한다.

3. GPU 레이어 최적화

csscss
/* transition: all → 특정 프로퍼티만 */
transition: transform 0.3s ease, opacity 0.2s ease;

/* GPU 레이어 프로모션 */
will-change: transform;

/* backdrop-filter 레이어 격리 */
contain: strict;

4. Shader Map 캐싱

shader 모드에서 displacement map을 매번 생성하지 않고 캐시한다.

typescripttypescript
const shaderMapCache = new Map<string, string>();

const getShaderMap = (width: number, height: number) => {
  const key = `${width}x${height}`;
  if (shaderMapCache.has(key)) return shaderMapCache.get(key)!;

  const url = generateShaderMap(width, height);
  shaderMapCache.set(key, url);
  return url;
};

5. 거리 기반 Early Return

마우스가 글래스 요소에서 멀리 떨어져 있으면 업데이트를 건너뛴다.

typescripttypescript
const updateDOM = () => {
  const dx = mousePosRef.current.x - cachedRect.centerX;
  const dy = mousePosRef.current.y - cachedRect.centerY;
  const distance = Math.sqrt(dx * dx + dy * dy);

  // 요소 크기의 3배 이상 떨어져있으면 스킵
  if (distance > cachedRect.diagonal * 3) return;

  // ... 실제 업데이트
};

벤치마크 결과

6개 페이즈로 나눠서 Original vs Optimized를 비교했다:

Phase설명Original FPSOptimized FPS
Baseline기본 렌더링5860
Spawn Storm컴포넌트 대량 생성3155
BG Chaos배경 애니메이션4258
Mouse Tornado빠른 마우스 이동2856
Content Churn내부 콘텐츠 변경3557
Combined모든 조건 동시1849

Combined 페이즈에서 18fps → 49fps, 약 2.7배 성능 향상.

핵심 교훈

  1. React 리렌더링이 곧 비용이다 — 마우스/스크롤 같은 고빈도 이벤트는 useRef + rAF로 React를 우회하자
  2. 측정이 먼저다 — 프로파일러 없이 감으로 최적화하면 엉뚱한 곳에 시간을 쓴다
  3. interleaved 벤치마크 — A를 다 돌리고 B를 돌리면 열 스로틀링으로 편향이 생긴다. 페이즈마다 A/B를 번갈아 돌려야 공정하다
  4. CSS contain과 will-change — 작지만 누적 효과가 크다

소스코드와 벤치마크는 GitHub에서 볼 수 있다.

이전 글

10일 만에 Limbus Company 팬 아카이브를 만든 이야기

다음 글

랜덤 편성 기능을 만들면서 고민한 것들

관련 글