useEffect vs useLayoutEffect
useLayoutEffect 를 언제 꺼내야 할지 결정 트리로 정리해봐요.
버튼 옆에 떠야 할 툴팁이 화면 끝에 닿는 순간을 본 적 있어요. 위치를 다시 재서 위로 올라가긴 하는데, 그 사이에 한 프레임 정도 잘못된 자리에서 깜빡이고 들어가요. 두 훅 중 어느 쪽을 쓰는지로 결정되는 장면이에요. React 의 useEffect 가 기본값이고, useLayoutEffect 는 페인트 전 측정이 화면에 보일 때만 꺼냅니다.
툴팁이 화면 가장자리에서 잘릴 때
툴팁 컴포넌트를 만들 때는 보통 트리거 버튼 옆에 띄워요. 근데 버튼이 화면 오른쪽 끝에 있으면 그대로 띄울 수 없죠. 일단 한 번 그려보고 너비를 잰 다음, 잘릴 것 같으면 위치를 조정해야 해요.
useEffect 로 짜면 흐름은 이렇게 흘러요. 컴포넌트가 첫 렌더에서 임시 위치로 그려져요. 브라우저가 그 화면을 한 번 페인트해요. 그 다음 effect 가 돌면서 getBoundingClientRect() 로 위치를 재고, "어, 화면 밖으로 나가네" 싶으면 state 를 갱신해서 다시 렌더해요. 두 번째 페인트에서 비로소 제대로 된 위치에 자리 잡아요.
문제는 사이에 페인트가 한 번 더 들어간다는 거예요. 첫 페인트와 두 번째 페인트 사이에 잘못된 위치가 한 프레임 동안 화면에 나타나요. 빠른 모니터에서는 모르고 지나갈 수 있지만 60fps 환경에서도 눈에 잡히는 경우가 많아요. DOM 측정 자체가 reflow 를 부르는 비용 이라 측정과 페인트의 순서가 더 민감해지죠.
useLayoutEffect 는 이 흐름을 한 단계로 묶어요. 첫 렌더 직후, 브라우저가 페인트하기 전에 동기적으로 실행돼서 측정과 위치 보정과 재렌더까지 끝내요. 사용자는 처음부터 올바른 위치의 툴팁만 봐요.
두 훅이 갈리는 한 지점
React 공식 문서가 useLayoutEffect 를 정의할 때 쓰는 문장이 깔끔해요. "useLayoutEffect 는 모든 DOM 변경이 끝난 후, 브라우저가 페인트하기 전에 동기적으로 실행되는 훅" 이라고요.
여기서 두 단어가 핵심이에요. 페인트 전. 그리고 동기적으로. useEffect 는 일반적으로 페인트 후에, 비동기적으로 실행돼요. 같은 시그니처를 갖고 같은 종류의 cleanup 을 반환하지만 실행되는 시점이 달라요.
useLayoutEffect 안에서 state 를 갱신하면 React 가 그 자리에서 다시 렌더링하고, 새 layoutEffect 가 실행되고, 모든 게 끝난 다음 한 번에 페인트가 일어나요. 어떤 layoutEffect 가 트리거한 state 갱신이든 페인트 전에 전부 처리된다는 보증이 있다는 얘기예요.
대신 그만큼 페인트를 막아 세워요. layoutEffect 안의 코드가 무거우면 그대로 렌더링 지연이 돼요. 그래서 공식 문서 첫 줄에 "useLayoutEffect 는 성능에 해를 입힐 수 있어요, 가능하면 useEffect 를 쓰세요" 라는 경고가 박혀 있는 거예요.
useEffect 가 사실은 두 갈래로 흐른다는 것
여기서 자주 오해하는 지점이 하나 있어요. "useEffect 는 항상 페인트 후" 라는 단순화요. 저도 한참 그렇게 알고 썼거든요. 근데 공식 문서를 다시 읽어보면 미묘하게 다른 표현을 써요.
상호작용으로 발생한 effect 와 그렇지 않은 effect 가 다르게 처리돼요. 클릭 같은 이벤트 핸들러 안에서 트리거된 effect 는 React 가 페인트 전에 처리할 수도 있어요. 이벤트 시스템 입장에서 "사용자가 클릭한 결과" 가 다음 페인트에 이미 반영돼 있어야 자연스러우니까요. 반대로 타이머나 데이터 fetch 응답 같은 비상호작용 effect 는 일반적으로 페인트 후에 돌아요.
그래서 useEffect 가 "비동기" 라는 단순화는 절반만 맞아요. 더 정확히는 비상호작용 effect 는 페인트 후, 상호작용 effect 는 페인트 앞뒤로 어느 쪽도 가능한 형태예요. 결정 트리를 그릴 때 이 차이가 안 보이면 useEffect 와 useLayoutEffect 의 선택 기준도 흔들려요.
어떤 훅을 골라야 할까
기준이 두 개 있어요. 외부 시스템과의 동기화인지, 페인트 전 측정이 화면에 보이는지.
대부분의 effect 는 첫 질문에서 끝나요. 외부 구독을 걸거나, setInterval 을 시작하거나, 분석 이벤트를 보내거나, 데이터 fetch 의 응답을 처리하거나. 이런 건 페인트 전에 끝낼 이유가 없어요. 페인트를 막아봤자 사용자가 보는 그림에는 영향이 없거든요. 그래서 useEffect 가 답이에요.
두 번째 질문에 걸리는 경우가 useLayoutEffect 의 진짜 자리예요. DOM 의 너비, 높이, 위치 같은 layout 값을 측정해서 그 값을 토대로 다시 렌더링해야 할 때. 그 중에서도 "두 번 그려지는 게 사용자 눈에 보일 때" 만 useLayoutEffect 로 넘어가요. 백그라운드에서 캐시 용도로 measurement 만 저장한다면 useEffect 로 충분해요.
공식 문서가 제안하는 갈아타기 기준은 더 간단해요. "useEffect 로 짰는데 시각적으로 깜빡임이 보이면 그때 useLayoutEffect 로 바꾸세요." 미리 최적화하지 말고, 깜빡임이 관찰될 때만 페인트 차단의 대가를 치르라는 얘기예요.
SSR 경고와 React 19 가 바꾼 것
useLayoutEffect 를 쓰면 SSR 환경에서 콘솔 경고를 한 번쯤 마주쳐요.
"useLayoutEffect does nothing on the server." - React 공식 문서
서버에는 layout 자체가 없어서 페인트 전 측정이 성립하지 않아요. 쓰지 말라는 게 아니라, 서버 렌더 단계에서는 의미 없이 건너뛴다는 안내죠.
공식 문서가 제안하는 우회법은 네 갈래예요. 첫째, 측정 결과가 화면에 보이지 않아도 되는 경우라면 처음부터 useEffect 로 짜요. 둘째, 정말 페인트 전 보정이 필요한 컴포넌트라면 클라이언트에서만 렌더링되게 분기해요. Suspense fallback 으로 첫 페인트를 채우고, hydration 이후에 진짜 컴포넌트를 교체하는 식이에요. 셋째, 같은 효과를 isMounted state 로도 만들 수 있어요. 서버와 첫 클라이언트 렌더에서는 단순한 형태로, 그 이후엔 측정 기반으로요. 마지막 옵션은 외부 store 동기화 목적이라면 useSyncExternalStore 로 갈아타는 거예요. 이 훅은 SSR 까지 지원하도록 설계됐어요.
React 19 가 두 훅의 실행 시점을 새로 바꿨을까요. 결론부터 말하면 아니에요. 릴리스 노트를 다 훑어도 useEffect 와 useLayoutEffect 의 타이밍 변경은 명시되어 있지 않아요. 두 훅에 대한 결정은 React 18 의 모델 그대로 가져가면 돼요.
대신 effect 인접 영역에서 작은 변화가 하나 있어요. ref callback 이 cleanup 함수를 반환할 수 있게 됐어요. 컴포넌트가 사라질 때 React 가 그 cleanup 을 호출해줘요. 이전에는 ref 함수에 null 을 한 번 더 호출해서 정리 신호를 주던 패턴이었는데, 이제는 useEffect 처럼 setup 과 cleanup 쌍으로 깔끔하게 짤 수 있어요. effect 와 ref 의 라이프사이클이 서서히 같은 모양으로 정리되고 있는 거죠.
여기까지 따라왔다면 useEffect 와 useLayoutEffect 사이에서 헤매는 일은 줄어들어요. 페인트 전 보정이 화면에 보일 때만 useLayoutEffect, 나머지는 useEffect 가 답이에요.