-화면 품질 개선을 위한 React 렌더링 타이밍 이해-
1. 운영 과정에서 발견한 문제: 화면 깜빡임 현상
올해 프로젝트의 프론트엔드 운영 업무를 맡으면서 기존에 복잡하게 얽혀 있던 컴포넌트 구조를 읽기 쉽게 정리하고, 반복되는 상태 처리와 스타일 계산 로직을 개선하는 작업을 진행했습니다. 대부분의 개선 작업은 기능 변경보다 유지보수성을 높이는 리팩토링에 가까웠지만, 코드를 살펴보는 과정에서 사용자가 직접 체감할 수 있는 UI 품질 문제도 함께 발견되었습니다.
가장 눈에 띄었던 문제는 특정 컴포넌트가 화면에 처음 표시될 때 아주 짧은 순간 부자연스럽게 깜빡이는 현상이었습니다. 기능적으로는 정상 동작했지만, 화면이 한 번 잘못된 크기로 그려졌다가 곧바로 올바른 크기로 다시 조정되면서 사용자가 보기에는 레이아웃이 튀는 것처럼 보였습니다. 이러한 현상은 흔히 Flicker 또는 Layout Shift라고 부를 수 있습니다.
문제가 된 컴포넌트는 브라우저 너비에 맞춰 자체 너비를 계산해야 했습니다. 예를 들어 브라우저 너비의 절반을 컴포넌트 너비로 사용하거나, 부모 요소의 실제 크기를 기준으로 내부 요소를 재배치해야 하는 구조였습니다. 하지만 기존 구현에서는 이 계산이 useEffect 안에서 수행되고 있었기 때문에 브라우저가 먼저 화면을 그린 뒤, 그다음에 너비 보정 로직이 실행되었습니다.
결과적으로 사용자는 초기 렌더링 시점의 잘못된 너비와 보정 이후의 너비를 모두 보게 됐습니다. 아주 짧은 시간이지만 시각적으로는 깜빡임처럼 느껴졌고, 특히 화면 크기가 자주 바뀌거나 반응형 레이아웃이 중요한 영역에서는 품질 저하로 이어질 수 있었습니다.
2. useEffect와 useLayoutEffect의 핵심 차이
React에서 useEffect와 useLayoutEffect는 모두 렌더링 이후 특정 부수 효과를 실행하기 위한 훅입니다. 사용 방법도 매우 비슷하기 때문에 처음에는 두 훅의 차이가 크게 느껴지지 않을 수 있습니다. 하지만 실행 타이밍에는 명확한 차이가 있으며, 이 차이가 화면 깜빡임 여부를 결정하는 중요한 기준이 됩니다.
useEffect는 브라우저가 화면을 실제로 그린 뒤 비동기적으로 실행됩니다. 즉, React가 Virtual DOM 변경 사항을 실제 DOM에 반영하고 브라우저가 페인트(Paint)까지 완료한 다음 실행됩니다. 따라서 데이터 페칭, 로그 전송, 구독 등록, 이벤트 리스너 등록처럼 화면의 첫 페인트를 막을 필요가 없는 작업에 적합합니다.
반면 useLayoutEffect는 DOM 변경이 끝난 직후, 브라우저가 화면을 그리기 전에 동기적으로 실행됩니다. 이 말은 useLayoutEffect 내부에서 DOM의 크기나 위치를 측정하고, 그 결과에 따라 상태를 변경하면 사용자는 보정 전 화면을 보지 않고 최종 보정된 화면만 보게 된다는 뜻입니다.
따라서 DOM 요소의 크기 측정, 스크롤 위치 보정, 툴팁 위치 계산, 모달 또는 드롭다운의 배치 계산처럼 화면에 표시되기 전에 레이아웃이 정확해야 하는 작업에는 useLayoutEffect가 더 적합합니다. 반대로 이러한 작업을 useEffect에서 처리하면 사용자는 최초 화면과 보정 이후 화면 사이의 차이를 짧게나마 보게 되고, 이때 깜빡이는 것처럼 보이는 현상이 발생할 수 있습니다.
|
구분 |
useEffect |
useLayoutEffect |
|---|---|---|
|
실행 시점 |
브라우저가 화면을 그린 후 실행 |
DOM 변경 후, 화면이 그려지기 전 실행 |
|
실행 방식 |
비동기적으로 실행 |
동기적으로 실행 |
|
주요 용도 |
데이터 요청, 이벤트 구독, 로그 처리 |
DOM 측정, 크기 계산, 위치 보정 |
|
주의점 |
초기 화면 보정에는 Flicker 가능 |
과도하게 사용하면 렌더링 지연 가능 |
3. 기존 코드의 문제점
기존 코드는 컴포넌트가 마운트된 뒤 useEffect에서 브라우저 너비를 기준으로 상태를 다시 설정하는 방식이었습니다. 코드만 보면 단순하고 문제가 없어 보이지만, 실제 렌더링 순서를 기준으로 보면 깜빡임이 발생할 수밖에 없는 구조였습니다.
초기 렌더링에서는 useState의 초기값으로 한 번 화면이 그려집니다. 그 후 브라우저 페인트가 완료되고 useEffect가 실행되면서 setWidth가 호출됩니다. 상태가 변경되면 React는 다시 렌더링을 수행하고, 그때서야 최종 너비가 반영됩니다. 즉, 사용자는 보정 전 화면과 보정 후 화면을 모두 보게 됩니다.
또한 기존 코드는 브라우저 resize 이벤트에 대한 대응이 부족했습니다. 최초 렌더링 시점에는 너비를 계산하지만, 사용자가 브라우저 크기를 변경했을 때 동일한 비율로 컴포넌트 너비를 다시 계산하는 처리가 없다면 반응형 UI로서 일관성이 떨어집니다. 따라서 훅 변경과 함께 resize 이벤트 리스너를 추가해 화면 크기 변화에도 대응하도록 개선할 필요가 있었습니다.
기존 코드 예시는 다음과 같습니다.
export const ExampleComponent = () => {
const [width, setWidth] = useState(window.innerWidth / 2);
useEffect(() => {
setWidth(window.innerWidth / 2);
}, []);
return <div style={{ width: width, height: 100 }} />;
};
4. useLayoutEffect를 적용한 개선 코드
개선 방향은 명확했습니다. 화면에 그려지기 전에 너비 계산을 완료해야 했기 때문에 useEffect를 useLayoutEffect로 변경했습니다. useLayoutEffect는 DOM 변경 직후, 브라우저가 실제 화면을 그리기 전에 실행되기 때문에 초기 화면이 잘못된 크기로 노출되는 문제를 줄일 수 있었습니다.
또한 resize 이벤트를 등록하여 브라우저 너비가 변경될 때마다 컴포넌트 너비도 함께 다시 계산되도록 구성했습니다. 이때 컴포넌트가 언마운트될 때 이벤트 리스너를 반드시 제거해야 합니다. 그렇지 않으면 불필요한 이벤트 핸들러가 계속 남아 메모리 누수나 중복 실행 문제가 발생할 수 있기 때문입니다.
따라서 개선 코드에서는 addEventListener와 removeEventListener를 함께 사용해 등록과 해제를 명확히 처리했습니다.
변경한 코드는 다음과 같습니다.
import { useLayoutEffect, useState } from 'react';
export const ExampleComponent = () => {
const [width, setWidth] = useState(() => window.innerWidth / 2);
useLayoutEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth / 2);
};
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return l&tdiv style={{ width: width, height: 100 }} />;
;};
이 코드에서는 컴포넌트가 마운트된 직후 handleResize를 한 번 실행해 현재 브라우저 너비를 기준으로 상태를 보정합니다. 이후 resize 이벤트가 발생할 때마다 동일한 계산을 다시 수행합니다. cleanup 함수에서는 등록했던 이벤트 리스너를 제거하여 컴포넌트 생명주기에 맞는 안전한 처리를 보장합니다.
초기값을 useState(() => window.innerWidth / 2)처럼 함수 형태로 전달한 것도 작은 개선점입니다. useState에 함수를 넘겨주는 방식은 지연 초기화(Lazy Initialization)라고 하는데, 이렇게 구현하면 초기 렌더링 시점에 한 번만 계산되므로 불필요한 계산을 줄일 수 있습니다. 다만 서버 사이드 렌더링 환경에서는 window 객체가 존재하지 않을 수 있기 때문에 별도의 방어 로직이 필요합니다.
5. 언제 useLayoutEffect를 사용해야 할까
useLayoutEffect는 화면 깜빡임을 줄이는 데 효과적이지만, 모든 상황에서 useEffect 대신 사용해야 하는 훅은 아닙니다. useLayoutEffect는 브라우저의 페인트를 지연시킬 수 있기 때문에 과도하게 사용하면 오히려 초기 렌더링 성능을 떨어뜨릴 수 있습니다.
따라서 기준은 명확하게 가져가는 것이 좋습니다. 화면이 그려진 뒤 실행되어도 사용자 경험에 문제가 없다면 useEffect를 사용하는 편이 적절합니다. 반대로 화면이 그려지기 전에 DOM을 측정하거나 위치를 보정해야 하고, 보정 전 화면이 사용자에게 노출되면 안 되는 경우에는 useLayoutEffect를 고려할 수 있습니다.
6. 적용 후 얻은 효과와 회고
useLayoutEffect를 적용한 뒤 초기 렌더링 시점의 부자연스러운 깜빡임이 사라졌고, 브라우저 크기 변경 시에도 컴포넌트 너비가 자연스럽게 동기화되었습니다. 기능 자체는 작은 변경이었지만, 사용자에게 보이는 화면 품질은 확실히 개선되었습니다.
이번 경험을 통해 useEffect처럼 자주 사용하는 훅일수록 더 신중하게 선택해야 한다는 점을 다시 느꼈습니다. 익숙한 코드라고 해서 항상 적절한 것은 아니며, React 렌더링 흐름과 브라우저 페인트 타이밍을 이해해야 더 나은 UI를 만들 수 있다는 점도 함께 체감했습니다.
특히 운영 업무에서는 새로운 기능을 만드는 것뿐만 아니라 기존 코드에서 사용자가 느끼는 작은 불편함을 찾아내고 개선하는 일이 중요합니다. 화면 깜빡임처럼 사소해 보이는 문제도 반복적으로 노출되면 서비스의 완성도를 낮추는 요소가 됩니다.
결국 이번 개선은 useEffect와 useLayoutEffect의 차이를 단순히 문법적으로 이해하는 데서 그치지 않고, 실제 사용자 경험과 연결해 판단하는 계기가 되었습니다. 앞으로도 훅을 사용할 때는 '언제 실행되는가', '화면에 어떤 영향을 주는가', '사용자에게 보정 전 상태가 보여도 되는가'를 함께 고려하며 더 적절한 선택을 해야겠다고 느꼈습니다.
7. 실무 적용 시 함께 고려한 점
useLayoutEffect를 적용할 때는 클라이언트 환경에서만 실행되는 코드인지도 함께 확인해야 합니다. 브라우저 전용 객체인 window, document, ResizeObserver 등을 직접 사용하는 경우 서버 사이드 렌더링 환경에서는 오류가 발생할 수 있습니다.
또한 단순히 브라우저 전체 너비를 기준으로 계산하는 것보다 실제 컴포넌트가 놓인 부모 컨테이너의 크기를 기준으로 계산하는 편이 더 정확한 경우가 많습니다. 이때는 window resize 이벤트만으로는 충분하지 않을 수 있으며, ResizeObserver를 활용하면 특정 DOM 요소의 크기 변화를 더 정밀하게 추적할 수 있습니다. 예를 들어 사이드바 열림/닫힘, 탭 전환, 부모 레이아웃 변경처럼 브라우저 크기는 그대로이지만 컴포넌트 영역만 바뀌는 상황에도 대응할 수 있습니다.
다만 ResizeObserver 역시 너무 많은 요소에 무분별하게 적용하면 성능 부담이 될 수 있습니다. 따라서 실제로 크기 측정이 필요한 핵심 요소에만 적용하고, 계산 결과가 이전 값과 동일하다면 setState를 호출하지 않는 방식으로 불필요한 재렌더링을 줄이는 것이 좋습니다. 작은 차이처럼 보이지만 운영 화면에서는 이러한 세부 최적화가 누적되어 전체 사용자 경험에 영향을 줍니다.
8. 정리: 훅 선택 기준을 명확히 가져가기
이번 사례를 정리하면 useEffect와 useLayoutEffect의 선택 기준은 단순히 어떤 훅이 더 많이 쓰이는가가 아니라, 해당 작업에 대한 브라우저 페인트 타이밍에 달려 있습니다. 화면에 최초로 보이는 값에 문제가 없고, 이후 비동기적으로 처리해도 괜찮다면 useEffect가 적합합니다. 반대로 잘못된 레이아웃이 사용자에게 노출되면 안 되는 경우에는 useLayoutEffect를 고려해야 합니다. 화면이 흔들리거나 깜빡이는 현상은 기능 오류는 아니지만, 사용자는 이를 불안정한 화면으로 받아들일 수 있습니다. 따라서 프론트엔드 개발에서는 데이터 흐름뿐만 아니라 렌더링 순서, 브라우저 페인트 타이밍, DOM 측정 시점을 함께 이해하는 것이 중요합니다.
결국 이번 개선은 단순히 useEffect를 useLayoutEffect로 바꾼 작업이 아니라, React 컴포넌트가 실제 브라우저에서 어떤 순서로 렌더링되고 사용자에게 어떻게 보이는지 다시 점검한 작업이었습니다.
앞으로도 훅을 사용할 때는 습관적으로 선택하기보다, 해당 코드가 실행되는 타이밍과 사용자 화면에 미치는 영향을 기준으로 더 정확한 판단을 해야 합니다.
9. 재발 방지를 위한 점검 기준
비슷한 문제가 반복되지 않도록 이후 리팩토링 과정에서는 몇 가지 기준을 세워 컴포넌트를 점검했습니다.
첫 번째는 상태 변경이 실제 화면의 크기나 위치를 바꾸는지 확인하는 것입니다. 단순 데이터 저장이나 외부 통신이라면 useEffect로 충분하지만, 상태 변경 결과가 레이아웃에 직접 영향을 준다면 실행 타이밍을 다시 검토해야 합니다.
두 번째는 초기 렌더링 값이 사용자에게 노출되어도 괜찮은지 확인하는 것입니다. 잘못된 위치의 Tooltip, 접히지 않은 Accordion, 화면 밖으로 삐져나온 Dropdown처럼 보정 전 상태가 사용자에게 어색하게 보인다면 useLayoutEffect 또는 CSS 기반 사전 제어가 필요합니다.
세 번째는 JavaScript로 계산해야 하는 값인지, CSS만으로 해결할 수 있는지 먼저 판단하는 것입니다. JavaScript 계산은 필요한 경우에만 사용하고, 레이아웃 자체는 가능한 CSS가 담당하도록 설계하는 편이 더 안정적입니다.
네 번째는 이벤트 리스너와 cleanup을 반드시 함께 작성하는 것입니다. resize, scroll, mousemove 같은 이벤트는 자주 발생하기 때문에 컴포넌트가 언마운트된 뒤에도 리스너가 남아 있으면 성능 문제와 예기치 못한 상태 업데이트가 발생할 수 있습니다. 따라서 훅 내부에서 외부 리소스를 등록했다면 cleanup 함수에서 해제하는 패턴을 습관화해야 합니다.
10. 결론
useEffect와 useLayoutEffect는 문법적으로 매우 비슷하지만 브라우저 렌더링 흐름 안에서는 전혀 다른 시점에 실행됩니다. 이번 사례처럼 화면이 먼저 그려진 뒤 보정되는 구조에서는 useEffect가 자연스러운 선택처럼 보여도 실제 사용자 경험에는 깜빡임을 만들 수 있습니다.
useLayoutEffect는 이러한 문제를 해결할 수 있는 도구이지만, 렌더링을 막을 수 있다는 특성 때문에 꼭 필요한 곳에만 제한적으로 사용하는 것이 좋습니다. 결국 중요한 것은 특정 훅을 무조건 선호하는 것이 아니라, 해당 로직이 화면에 보이기 전 완료되어야 하는지 판단하는 것입니다.
이번 경험을 통해 작은 UI 현상 하나도 React의 렌더링 순서, 브라우저 페인트 타이밍, DOM 측정 방식과 연결되어 있다는 것을 체감했습니다.
앞으로도 단순히 동작하는 코드를 작성하는 데서 그치지 않고, 사용자가 실제로 보게 되는 화면의 흐름까지 고려하는 방식으로 프론트엔드 코드를 개선해 나가고자 합니다.
May