React에서 useEffect와 useLayoutEffect, 언제 무엇을 써야 할까
빠른 답
- 기본 선택은 useEffect다. 네트워크 요청, 구독, 이벤트 등록처럼 화면 표시를 막을 이유가 없는 작업에 맞다.
- useLayoutEffect는 DOM 크기 측정이나 위치 보정처럼 화면이 그려지기 전에 끝나야 하는 작업에만 좁게 쓴다.
- useLayoutEffect는 브라우저 페인트를 막는 동기 작업이므로 무거우면 첫 화면 표시가 느려진다.
- React 18 개발 모드에서는 Strict Mode 때문에 Effect가 두 번 실행된 것처럼 보일 수 있어 타이밍 로그를 해석할 때 주의해야 한다.
현재 초안의 구조는 이미 잡혀 있어서, React 공식 문서와 MDN 기준으로 실행 시점 표현만 다시 확인한 뒤 발행용 문장으로 정리하겠습니다. 비교 섹션과 시간축 섹션은 초반에 더 압축하고, 오래된 설명으로 보일 수 있는 부분은 현재 기준 용어로 바로잡겠습니다.공식 문서 기준선은 반영했습니다. 현재 React 문서는 react.dev의 최신 메이저인 React 19.2 기준이고, 오래된 글에서 자주 보이는 “항상 페인트 후”, “DOM 업데이트 직전” 같은 표현은 지금 문서의 설명과 어긋나는 부분이 있어 그 차이를 본문에 녹여 정리하겠습니다.# React에서 useEffect와 useLayoutEffect, 언제 무엇을 써야 할까
목차
한눈에 비교
시간 흐름으로 이해하기
왜 둘 다 렌더 후처럼 보일까
헷갈리는 이유는 React의 렌더와 브라우저의 렌더를 같은 말로 부르기 때문이다. React에서 렌더는 컴포넌트 함수를 실행해 어떤 UI가 필요한지 계산하는 과정에 가깝다. 반면 브라우저에서 렌더는 DOM과 CSSOM을 바탕으로 렌더 트리를 만들고, 레이아웃으로 크기와 위치를 정한 뒤, 페인트와 컴포지팅으로 실제 픽셀을 화면에 올리는 과정이다.
useEffect와 useLayoutEffect는 둘 다 React의 렌더 함수 실행 중에는 동작하지 않는다. 둘 다 DOM 커밋 이후에 실행되지만, 브라우저가 이번 프레임을 그리기 전인지 후인지가 갈린다. 이 경계 때문에 같은 “렌더 후 실행”이라는 표현도 체감이 전혀 달라진다.
오래된 설명 중에는 useLayoutEffect를 “DOM 업데이트 직전”이라고 적은 경우가 있다. 현재 기준으로는 “DOM 커밋 직후, 브라우저 리페인트 전”이 더 정확하다. 이 차이를 바로잡아 두면 왜 어떤 코드는 깜빡이고, 어떤 코드는 첫 화면이 느려지는지 이해하기 쉬워진다.
어떤 작업에 무엇을 써야 하나
useEffect는 화면을 그리는 일보다 외부 시스템과의 연결이 중심인 작업에 어울린다. 네트워크 요청, 웹소켓 연결, 브라우저 이벤트 구독, 타이머 등록, 로그 전송 같은 일이 여기에 들어간다. 이런 작업은 지금 프레임의 레이아웃을 맞추기 위해 페인트를 막을 이유가 거의 없다.
프레임워크가 데이터 로더나 Server Components를 제공한다면 그 경로가 먼저일 수 있다. 그래도 브라우저에서 마운트 이후 외부 요청을 붙여야 하는 컴포넌트라면 useEffect가 자연스럽다.
import { useEffect, useState } from 'react';
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((response) => response.json())
.then((data) => setUser(data))
.catch((error) => {
if (error.name !== 'AbortError') {
console.error(error);
}
});
return () => controller.abort();
}, [userId]);
return user ? <p>{user.name}</p> : <p>Loading...</p>;
}
반대로 useLayoutEffect는 DOM의 실제 크기나 위치를 읽고, 그 결과를 바로 현재 프레임에 반영해야 할 때 의미가 생긴다. getBoundingClientRect(), offsetHeight, scrollTop처럼 레이아웃 정보에 닿는 읽기 작업이 대표적이다. 툴팁과 팝오버 위치 계산, 스크롤 점프 보정, 첫 표시 전에 끝내야 하는 포커스 이동이 여기에 가깝다.
여기서 한 번 더 볼 점도 있다. 단순 계산이나 CSS로 풀 수 있다면 Effect 자체가 필요 없을 수 있다. 공간을 미리 예약하거나, top과 left 대신 transform과 opacity로 처리할 수 있다면 레이아웃 비용을 줄이고 컴포지팅 단계에 더 가깝게 가져갈 수 있다.
깜빡임이 생기는 예제와 해결 방식
위치 보정을 useEffect에 두면 화면에 한 번 잘못된 상태가 보였다가 다시 움직이는 일이 생길 수 있다. 아래 코드는 팝오버를 일단 렌더한 뒤 높이를 읽어서 위쪽으로 옮긴다.
import { useEffect, useRef, useState } from 'react';
function Popover({ anchorRect, children }) {
const ref = useRef(null);
const [top, setTop] = useState(anchorRect.bottom);
useEffect(() => {
if (!ref.current) return;
const { height } = ref.current.getBoundingClientRect();
setTop(anchorRect.top - height);
}, [anchorRect]);
return (
<div ref={ref} style={{ position: 'fixed', top, left: anchorRect.left }}>
{children}
</div>
);
}
이 코드는 React가 DOM을 커밋하고 브라우저가 프레임을 그린 뒤에 useEffect가 돌 수 있으므로, 사용자는 잠깐 아래쪽에 나타난 팝오버가 위로 점프하는 장면을 볼 수 있다.
같은 작업을 useLayoutEffect로 옮기면 측정과 재렌더가 리페인트 전에 끝나므로 이런 깜빡임을 줄일 수 있다.
import { useLayoutEffect, useRef, useState } from 'react';
function Popover({ anchorRect, children }) {
const ref = useRef(null);
const [top, setTop] = useState(anchorRect.bottom);
useLayoutEffect(() => {
if (!ref.current) return;
const { height } = ref.current.getBoundingClientRect();
setTop(anchorRect.top - height);
}, [anchorRect]);
return (
<div ref={ref} style={{ position: 'fixed', top, left: anchorRect.left }}>
{children}
</div>
);
}
다만 이 방식은 공짜가 아니다. DOM을 쓴 직후 레이아웃을 읽고, 다시 상태를 바꾸면 브라우저는 스타일 재계산과 레이아웃을 즉시 수행해야 할 수 있다. 이 패턴이 커지면 깜빡임은 줄어도 첫 표시가 무거워질 수 있다. 그래서 useLayoutEffect는 범위를 작게 두고, 읽기와 쓰기를 최소 횟수로 묶는 쪽이 보통 더 낫다.
React 19.2 기준으로 다시 볼 점
2026년 4월 기준 React 공식 문서는 최신 메이저를 React 19.2로 안내한다. 이 기준에서 useEffect 문서와 useLayoutEffect 문서를 같이 보면 오래된 설명과 다른 지점이 몇 가지 분명하다.
- 오래된 설명:
useEffect는 항상 페인트 후 실행된다. - 현재 문서: 상호작용이 아닌 경우에는 대체로 페인트 뒤지만, 클릭 같은 상호작용 이후에는 더 이른 시점에 관찰될 수도 있다.
- 오래된 설명:
useLayoutEffect는 DOM 업데이트 직전 실행된다. - 현재 문서: DOM 커밋 이후, 브라우저가 다시 그리기 전에 실행된다.
- 오래된 패턴: 스타일 삽입 순서를 맞추려고
useLayoutEffect를 쓰기도 했다. - 현재 기준: CSS-in-JS 라이브러리의 스타일 삽입은
useInsertionEffect로 역할이 분리됐다.
또 하나 자주 만나는 차이는 개발 모드의 StrictMode다. React 18 이후 문서 기준으로 개발 환경에서는 Effect의 setup과 cleanup을 한 번 더 실행해 정리 로직의 누락을 드러낸다. 로그가 두 번 찍힌다고 해서 곧바로 프로덕션 버그라고 보기보다, cleanup이 setup과 대칭인지 먼저 확인하는 편이 해석에 도움이 된다. 관련 설명은 StrictMode 문서에서 확인할 수 있다.
SSR과 하이드레이션에서의 처리
useLayoutEffect는 서버에서 의미를 가지기 어렵다. 서버에는 실제 레이아웃 정보가 없고, 브라우저가 아직 DOM을 그리지도 않았기 때문이다. 그래서 현재 React 문서에는 아예 “useLayoutEffect does nothing on the server”라는 트러블슈팅 항목이 따로 있다.
레이아웃 정보가 꼭 필요한 컴포넌트라면, 처음부터 서버 HTML에 정확한 위치를 담으려 하기보다 클라이언트에서만 보이게 하거나 하이드레이션 이후에 렌더하는 편이 더 자연스러운 경우가 많다. Server Components 기반 프레임워크에서는 이 훅을 쓰는 컴포넌트가 클라이언트 경계 안에 있어야 한다.
'use client';
import { useEffect, useState } from 'react';
export default function ClientOnlyPopover(props) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <Popover {...props} />;
}
이 방식은 서버와 하이드레이션 단계에서는 레이아웃 의존 컴포넌트를 숨기고, 클라이언트 마운트 이후에만 실제 측정 로직을 돌리게 한다. 툴팁, 드롭다운, 측정 기반 오버레이처럼 사용자 상호작용 뒤에 나타나는 UI와 잘 맞는 편이다.
성능과 DevTools에서 확인할 지점
이 주제는 브라우저 렌더링 파이프라인과 직접 연결되기 때문에 DevTools에서 보면 차이가 더 분명해진다. 브라우저 관점에서 비용이 커지는 순간은 대개 “DOM 쓰기 → 레이아웃 읽기 → 다시 쓰기”가 같은 프레임 안에서 이어질 때다. 이때 Recalculate Style, Layout, Paint 구간이 길어지고, 애니메이션이나 상호작용이 둔해질 수 있다.
확인 순서는 비교적 단순하다.
- Chrome DevTools Performance panel로 문제 동작을 녹화한다.
- 타임라인에서
Recalculate Style,Layout,Paint가 길어지는 구간이useLayoutEffect가 도는 순간과 겹치는지 본다. - Performance monitor에서
Layouts / sec,Style recalculations / sec가 갑자기 늘어나는지 본다. - Rendering 탭의 paint flashing으로 불필요한 repaint를 확인한다.
- 레이어 분리가 의심되면 Layers panel에서 컴포지팅 상태를 함께 본다.
브라우저 쪽 배경은 MDN의 Critical Rendering Path 문서를 같이 보면 흐름이 잘 맞는다. DOM과 CSSOM이 렌더 트리로 합쳐지고, 레이아웃으로 위치와 크기가 정해진 뒤, 페인트와 컴포지팅을 거쳐 화면에 나온다는 설명이 useLayoutEffect의 위치를 이해하는 데 바로 연결된다.
선택 기준을 짧게 남기면 이 정도다. 지금 프레임이 한 번 잘못 보여도 괜찮다면 useEffect 쪽이 부담이 적고, 그 한 프레임조차 보이면 안 되는 레이아웃 보정이라면 useLayoutEffect를 검토할 수 있다. 그래도 먼저 살펴볼 것은 “정말 Effect가 필요한가”, “CSS나 레이아웃 구조 변경으로 해결할 수 있는가”다. 브라우저 파이프라인에 덜 개입하는 쪽이 대체로 더 단순하고 가볍다.
원문 참고
https://www.maeil-mail.kr/question/46
댓글
댓글 쓰기