useEffect vs useLayoutEffect 완벽 가이드

SilverAsh·2025년 11월 15일
0

React

목록 보기
4/5

비교

Hook실행 시점특징사용 케이스
useEffect브라우저 페인트 비동기적, non-blockingAPI 호출, 이벤트 리스너
useLayoutEffect페인트 동기적, blockingDOM 측정, 스크롤 복원

useLayoutEffect는 화면이 그려지기 전에 실행되므로 깜빡임을 방지할 수 있지만, 성능에 영향을 줄 수 있다.


🚧 브라우저 렌더링 흐름 (React 기준)

┌─────────────────────────────────────────┐
│ 1. React 렌더 함수 실행                    │
│    (가상 DOM 계산, 변경사항 결정)            │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│ 2. React Commit Phase                   │
│    (실제 DOM에 변경사항 적용)                │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│ ⭐ useLayoutEffect 실행 (동기적, blocking) │
│    - DOM 측정 가능                        │
│    - 상태 업데이트 가능                     │
│    - 이 시간동안 페인트 지연됨                │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│ 3. 브라우저 Layout (Reflow)               │
│    - 각 요소의 크기/위치 계산                │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│ 4. 브라우저 Paint (Repaint)               │
│    - 실제 화면에 그리기                     │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│ 5. 브라우저 Composite                     │
│    - 여러 레이어 합성                       │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│ ⭐ useEffect 실행 (비동기, non-blocking)   │
│    - 시간 제약 없음                        │
│    - 메인 스레드 블로킹 없음                 │
└─────────────────────────────────────────┘

핵심: useLayoutEffect가 실행되는 동안 브라우저는 대기 중이다. 따라서 이 시간이 길어지면 페인트가 지연되어 화면이 멈춘 것처럼 보인다.


🔹 useEffect vs useLayoutEffect 실제 동작

useEffect의 문제점 - Visual Flickering

// ❌ useEffect 사용 - 깜빡임 발생!
function Component() {
  const [position, setPosition] = useState(0);
  const elementRef = useRef();

  useEffect(() => {
    // 1단계: 화면이 먼저 그려짐 (position = 0)
    // 유저가 잠깐 초기 상태를 봄 ← 깜빡임!
    
    const rect = elementRef.current.getBoundingClientRect();
    setPosition(rect.left); // 2단계: 이제 상태 업데이트
    // 3단계: 리렌더 → 화면 재그리기
  }, []);

  return <div ref={elementRef} style={{ marginLeft: position }}>Content</div>;
}

결과: 사용자가 보는 화면
1. 첫 번째 프레임: marginLeft: 0 (초기값)
2. 두 번째 프레임: marginLeft: 123px (계산 후 값)
요소가 움직이는 것처럼 보임 (깜빡임)

useLayoutEffect의 해결책 - Smooth Rendering

// ✅ useLayoutEffect 사용 - 깜빡임 없음!
function Component() {
  const [position, setPosition] = useState(0);
  const elementRef = useRef();

  useLayoutEffect(() => {
    // useLayoutEffect 내의 모든 작업이 끝날 때까지 페인트 지연
    const rect = elementRef.current.getBoundingClientRect();
    setPosition(rect.left); // 상태 업데이트
    // 이 상태 업데이트로 인한 리렌더가 바로 반영됨
  }, []);

  return <div ref={elementRef} style={{ marginLeft: position }}>Content</div>;
}

결과: 사용자가 보는 화면
1. 페인트가 한 번만 발생
2. marginLeft: 123px로 바로 렌더됨
깜빡임 없이 자연스러운 화면


✔️ useLayoutEffect 필수 사용 사례

1️⃣ DOM 크기/위치 측정

// 탭 인디케이터 예제
function Tabs() {
  const [activeTab, setActiveTab] = useState(0);
  const [indicatorStyle, setIndicatorStyle] = useState({});
  const tabRefs = useRef([]);

  // ✅ useLayoutEffect - 정확한 위치 계산
  useLayoutEffect(() => {
    const activeElement = tabRefs.current[activeTab];
    if (!activeElement) return;

    setIndicatorStyle({
      left: activeElement.offsetLeft,
      width: activeElement.offsetWidth,
    });
  }, [activeTab]);

  return (
    <div style={{ position: 'relative' }}>
      {['Tab 1', 'Tab 2', 'Tab 3'].map((label, i) => (
        <button
          key={i}
          ref={(el) => (tabRefs.current[i] = el)}
          onClick={() => setActiveTab(i)}
          style={{ padding: '10px 20px' }}
        >
          {label}
        </button>
      ))}
      {/* 인디케이터 바 */}
      <div
        style={{
          position: 'absolute',
          bottom: 0,
          height: '2px',
          backgroundColor: 'blue',
          transition: 'all 0.3s ease',
          ...indicatorStyle,
        }}
      />
    </div>
  );
}

이유: offsetLeftoffsetWidth는 DOM이 실제로 렌더된 후에만 정확한 값을 반환한다. useLayoutEffect에서 이 값을 읽고 상태를 업데이트하면, 페인트 전에 모든 계산이 완료되어 깜빡임 없이 정확한 위치에 인디케이터가 나타난다.

2️⃣ 스크롤 위치 복원

// 뒤로가기 시 이전 스크롤 위치 복원
function useScrollRestoration(routeKey) {
  const scrollPositions = useRef(new Map());

  // 떠나기 전 현재 스크롤 위치 저장
  useEffect(() => {
    return () => {
      scrollPositions.current.set(routeKey, window.scrollY);
    };
  }, [routeKey]);

  // ✅ 새 페이지 진입 시 이전 스크롤 위치 복원
  useLayoutEffect(() => {
    const savedPosition = scrollPositions.current.get(routeKey);
    if (savedPosition !== undefined) {
      window.scrollTo(0, savedPosition);
    }
  }, [routeKey]);
}

이유: 만약 useEffect를 사용하면:
1. 페이지가 top에서 렌더됨
2. 사용자가 처음으로 그려진 페이지를 봄 (스크롤 위치 0)
3. 그 후 useEffect 실행 → scrollTo() 호출
4. 페이지가 이전 위치로 스크롤됨
사용자가 튀는 것을 봄

useLayoutEffect를 사용하면 페인트 전에 스크롤 위치가 설정되므로 깜빡임 없다.

3️⃣ 초기 렌더에서 화면 튀는 것 방지

// 다크모드 테마 적용 예제
function App() {
  const [isDark, setIsDark] = useState(false);

  // ❌ useEffect - 테마 튀는 현상 발생
  // useEffect(() => {
  //   const theme = localStorage.getItem('theme');
  //   setIsDark(theme === 'dark');
  // }, []);

  // ✅ useLayoutEffect - 튀는 현상 없음
  useLayoutEffect(() => {
    const theme = localStorage.getItem('theme');
    setIsDark(theme === 'dark');
  }, []);

  return (
    <div style={{
      backgroundColor: isDark ? '#000' : '#fff',
      color: isDark ? '#fff' : '#000',
      minHeight: '100vh',
      transition: 'background-color 0.3s'
    }}>
      {/* 내용 */}
    </div>
  );
}

이유: useEffect 사용 시 흰 배경에서 검은 배경으로 깜빡이는 현상이 발생한다. useLayoutEffect를 사용하면 페인트 전에 테마가 적용되어 처음부터 올바른 색상으로 표시된다.

4️⃣ 반응형 레이아웃 조정

// 컨테이너 크기에 따라 동적 레이아웃
function ResponsiveGrid() {
  const [columnCount, setColumnCount] = useState(3);
  const containerRef = useRef();

  useLayoutEffect(() => {
    const handleResize = () => {
      const width = containerRef.current.offsetWidth;
      const newCount = width > 1200 ? 4 : width > 768 ? 2 : 1;
      setColumnCount(newCount);
    };

    handleResize(); // 초기 계산
    window.addEventListener('resize', handleResize);

    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return (
    <div
      ref={containerRef}
      style={{
        display: 'grid',
        gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
        gap: '1rem'
      }}
    >
      {/* 그리드 아이템 */}
    </div>
  );
}

🛑 useLayoutEffect 성능 주의사항

useLayoutEffect가 메인 스레드를 블로킹하는 원리

// ❌ 나쁜 예: 무거운 계산 + useLayoutEffect
useLayoutEffect(() => {
  // 이 반복문이 끝날 때까지 페인트가 지연됨!
  for (let i = 0; i < 1000000; i++) {
    // 복잡한 계산
    Math.sqrt(i) * Math.sin(i) * Math.cos(i);
  }
}, []);

// 결과:
// - 약 100-200ms 지연 (디바이스에 따라 다름)
// - 60fps 기준 6-12 프레임 건너뜀
// - 사용자는 화면이 멈춘 것처럼 느낌
// - 모바일에서는 더 심각함 (데스크톱의 3-4배 느림)

모바일에서의 성능 차이

// 데스크톱: 괜찮음 (버벅거림 거의 없음)
// 모바일: 심각한 버벅거림

useLayoutEffect(() => {
  // 단순해 보이는 작업도 모바일에서는 느릴 수 있음
  const rects = Array.from(document.querySelectorAll('.item'))
    .map(el => el.getBoundingClientRect());
  
  setPositions(rects);
}, []);

React 팀 권고

기본값은 항상 useEffect를 사용하세요. visual flickering이 실제로 발생하는 경우에만 useLayoutEffect로 변경하세요.


SSR 환경에서의 주의사항 (Next.js)

문제: SSR에서 DOM이 없음

// ❌ SSR에서 에러 발생
useLayoutEffect(() => {
  const width = window.innerWidth; // 서버에 window가 없음!
  setWidth(width);
}, []);

// 결과: "ReferenceError: window is not defined"

해결책 1: Isomorphic Hook 패턴

// ✅ 클라이언트/서버 모두에서 작동
const useIsomorphicLayoutEffect = 
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

function Component() {
  const [width, setWidth] = useState(0);

  useIsomorphicLayoutEffect(() => {
    setWidth(window.innerWidth);
  }, []);

  return <div>Width: {width}</div>;
}

동작 원리

  • 서버 사이드: window가 undefined이므로 useEffect 실행
  • 클라이언트 사이드: window가 존재하므로 useLayoutEffect 실행
  • 결과: Hydration mismatch 방지

해결책 2: Hydration 상태 추적

// ✅ Hydration 완료 후에만 실행
function Component() {
  const [isHydrated, setIsHydrated] = useState(false);

  useLayoutEffect(() => {
    setIsHydrated(true);
  }, []);

  if (!isHydrated) {
    return null; // 또는 서버 렌더링과 동일한 폴백
  }

  return <div>Only client content</div>;
}

해결책 3: CSS와 useEffect 조합 (권장)

// ✅ Next.js 14+ 권장 패턴
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    // localStorage에서 테마 읽기
    const saved = localStorage.getItem('theme') ?? 'light';
    setTheme(saved);
  }, []);

  return (
    <div 
      data-theme={theme}
      style={{
        // CSS 변수로 초기값 설정 (깜빡임 방지)
        '--bg': theme === 'dark' ? '#000' : '#fff',
        '--text': theme === 'dark' ? '#fff' : '#000',
      } as React.CSSProperties}
    >
      {children}
    </div>
  );
}

// CSS (globals.css)
// [data-theme="light"] { background: var(--bg); color: var(--text); }
// [data-theme="dark"] { background: var(--bg); color: var(--text); }

이 방식의 장점

  • SSR 친화적
  • Hydration mismatch 없음
  • CSS 변수로 인한 깜빡임 최소화
  • useEffect로 점진적 개선 가능

🔧 React 내부 메커니즘

React가 effect를 두 가지로 나누는 이유

// React 내부 (simplified)

// ======= Commit Phase =======
flushSync(() => {
  // DOM 업데이트 적용
  updateDOM();
  
  // 🔴 useLayoutEffect 큐 실행 (blocking)
  // 모든 useLayoutEffect가 여기서 완료될 때까지 대기
  flushLayoutEffects();
});

// ======= Browser Layout Phase =======
// 브라우저 reflow 계산
// 정확한 크기/위치 계산

// ======= Browser Paint Phase =======
// 실제 화면에 픽셀 그리기

// ======= Passive Phase =======
// 🟢 useEffect 큐 스케줄 (non-blocking)
// 메인 스레드가 여유있을 때 실행
scheduleCallback(() => {
  flushPassiveEffects(); // useEffect 실행
});

Effect 큐 분리의 이점

측면useLayoutEffectuseEffect
실행 시점동기적, 즉시비동기적, 유연
메인 스레드 블로킹O (페인트 지연)X
DOM 측정 정확도최고 (확정된 레이아웃)중간 (변경 가능)
성능 영향직접적간접적
사용 빈도낮음 (필요할 때만)높음 (대부분의 경우)

📊 성능 비교 실습

Performance 탭에서 확인하기

// 성능 차이를 시각적으로 확인할 수 있는 코드

// ❌ useEffect + 무거운 계산
function SlowEffectComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // Heavy computation
    console.time('useEffect');
    for (let i = 0; i < 10000000; i++) {
      Math.sqrt(i);
    }
    console.timeEnd('useEffect');
  }, [count]);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>
        Increment (useEffect)
      </button>
    </div>
  );
}

// ✅ useLayoutEffect + 무거운 계산
function SlowLayoutEffectComponent() {
  const [count, setCount] = useState(0);

  useLayoutEffect(() => {
    console.time('useLayoutEffect');
    for (let i = 0; i < 10000000; i++) {
      Math.sqrt(i);
    }
    console.timeEnd('useLayoutEffect');
  }, [count]);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>
        Increment (useLayoutEffect)
      </button>
    </div>
  );
}

DevTools Performance 탭 확인 방법
1. 개발자 도구 → Performance 탭
2. 녹화 시작
3. 버튼 클릭
4. 녹화 중지
5. 프레임 드롭 확인

  • useEffect: 부드러운 60fps
  • useLayoutEffect: 프레임 드롭 (jank 발생)

🎯 실제 프로젝트 체크리스트

useEffect 사용 시 체크리스트

useEffect(() => {
  // ✅ 다음 작업들은 useEffect에 적합
  
  // 1. API 호출
  fetch('/api/data').then(res => res.json()).then(setData);
  
  // 2. 이벤트 리스너 등록
  window.addEventListener('scroll', handleScroll);
  
  // 3. 타이머 설정
  const timer = setTimeout(() => {}, 1000);
  
  // 4. localStorage/sessionStorage 접근
  localStorage.setItem('key', value);
  
  // 5. 분석/로깅
  analytics.track('page_view');
  
  return () => {
    // 정리 작업
  };
}, [dependency]);

useLayoutEffect 사용 시 체크리스트

useLayoutEffect(() => {
  // ✅ 다음 중 하나라도 해당하면 useLayoutEffect 검토
  
  // 1. getBoundingClientRect() 사용
  const rect = element.getBoundingClientRect();
  
  // 2. offsetWidth/offsetHeight 접근
  const width = element.offsetWidth;
  
  // 3. scrollLeft/scrollTop 수정
  element.scrollTop = savedPosition;
  
  // 4. 초기 렌더에서 깜빡임 방지 필요
  setFinalState(initialValue);
  
  // ⚠️ 그러나 항상 성능을 고려할 것!
}, [dependency]);

🧪 useEffect vs useLayoutEffect 선택 가이드

의사결정 플로우

시작
  ↓
DOM을 읽거나 쓸 필요가 있는가?
  ├─ 아니오 → useEffect 사용
  │
  └─ 예
      ↓
       시각적 깜빡임이 발생하는가?
      ├─ 아니오 → useEffect 사용 (성능 우선)
      │
      └─ 예
          ↓
          SSR 환경인가?
          ├─ 예 → isomorphic hook 패턴 사용
          │
          └─ 아니오 → useLayoutEffect 사용

실제 예제로 선택하기

상황 1: 데이터 로드

// → useEffect
useEffect(() => {
  loadUserData();
}, [userId]);

상황 2: 요소 크기 측정

// → useLayoutEffect (깜빡임 방지 필요)
useLayoutEffect(() => {
  const size = element.getBoundingClientRect();
  setSize(size);
}, []);

상황 3: 이벤트 리스너

// → useEffect
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

상황 4: 테마/스타일 적용 (SSR)

// → useIsomorphicLayoutEffect
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;

useIsoLayoutEffect(() => {
  applyTheme();
}, []);

📚 핵심 정리

한 줄 요약

  • useEffect: 브라우저가 화면을 다 그린 후 실행 (기본값)
  • useLayoutEffect: 브라우저가 화면을 그리기 전에 실행 (필요할 때만)

성능 원칙

  1. 기본값은 useEffect
  2. visual flickering이 보이면 useLayoutEffect로 변경
  3. SSR 환경에서는 isomorphic 패턴 사용
  4. 성능 모니터링으로 검증

사용 빈도 통계

  • useEffect: 전체의 95% 이상
  • useLayoutEffect: 특수한 경우만 5% 미만

Next.js 14+ 권장 패턴

// 1순위: CSS + useEffect
// 2순위: isomorphic hook + useLayoutEffect
// 3순위: useEffect만 사용 (대부분의 경우)

🔗 참고

profile
Frontend Developer

0개의 댓글