화면 개발 시 좋은 사용자 경험을 제공하기 위한 핵심 요인 중 하나는 UI를 부드럽게 전환시키고 항상 일관성 있도록, 안정적으로 보여주는 것이다.
개인적으로 프로젝트를 진행하면서 UI 개발 부분에 있어 다양한 문제에 부딪히게 되었는데, 그 중 특정 한 화면에서 useEffect
를 사용했을 때 UI가 깜빡이는 현상과 같은 문제가 있었다. 이로 인해 애플리케이션이 불안정하게 보이고 사용자 경험을 저하시키게 되지 않을까 하는 우려가 생겼다.
해결 방안을 모색하던 중, useLayoutEffect
를 도입하여 효과적으로 해결할 수 있었다.
그러한 경험을 바탕으로 두 훅의 차이와 각각의 활용법에 대해 자세히 살펴보면서, UI 안정성을 높이고 더 나은 UX를 제공하는 방법에 대해 알아보았다.
🤚 본격적인 비교 이전에 알아 두어야 할 2가지 키워드:
1️⃣ hooks
리액트 함수형 컴포넌트에서 제공하는 내장 함수로, 상태 관리나 Side-Effect(부수 효과) 등을 다루는 등의 기능을 도와준다. state와 lifecycle의 기능을 연결("hook into")한다고 하여 hook 이라 부른다.
2️⃣ side effect
해석하면 "부수 효과"로, 어떤 함수가 본연의 목적 이외에 예상치 못한 다른 부분에 영향을 미치는 것을 가리킨다. 예시로, 특정 입력을 받아 값을 반환하는 게 주 목적인 함수가 있는데, 그 외에 함수가 전역 변수를 수정하거나 콘솔에 로그를 남기는 등의 동작도 한다면 이러한 동작이 부수 효과에 해당한다.
useEffect
함수형 컴포넌트에서 side effect, 즉 부수 효과를 처리하기 위한 훅이다.
컴포넌트에서 종종 React.js의 범위에 벗어나는 것을 사용해야 할 일이 생긴다. 예를 들면 데이터를 가져오기 위해 API를 호출한다거나, DOM을 직접 조작하거나 Local Storage 또는 Session Storage를 조작하기도 하고, 콘솔에 로그를 남길 수도 있어야 한다.
이러한 작업들은 컴포넌트의 라이프사이클 동안 발생하지만 렌더링 결과와는 직접적인 관련이 없는 동작들이기 때문에 부수 효과에 해당한다. 혹은 리액트에 의해 제어되는 작업이 아니기 때문에 '컴포넌트가 외부 시스템과 동기화된다' 라고 표현되기도 한다.
따라서 이제 useEffect
훅을 사용하여, 컴포넌트가 렌더링 될 때마다 특정 작업을 수행하도록 할 수 있다. 일반적으로 다음과 같은 구조를 갖는다.
useEffect(() => {
/* 부수 효과를 수행하는 코드 */
// ...
/* 정리(clean-up) 함수 (선택 사항) */
return () => {
/* 정리 작업 수행 */
// ...
};
}, [/* 의존성 배열 */]);
useEffect
를 호출하면 모든 렌더링 이후에 Effect 내의 코드가 실행된다.[]
)이면 마운트 될 때 한 번만 실행된다. useEffect
가 재실행된다.(다르게 말하면 의존성 배열 내의 값이 이전 값과 동일하다면 재실행을 건너 뛴다)useLayoutEffect
useEffect
와 비슷하지만, 다른 시점에 동작한다. 렌더링이 발생한 직후, 브라우저가 화면을 다시 그리기(repaint) 전에 실행된다.
일반적인 경우에 이 훅을 활용할 일은 별로 없을 것이며 공식 문서에서도 아래와 같이 경고하고 있다.
useLayoutEffect
는 성능을 저하시킬 수 있습니다. 가능하면useEffect
를 사용하세요.
대부분의 컴포넌트는 렌더링할 내용을 결정하기 위해 화면에서의 위치나 크기를 알 필요가 없기 때문일 것이다. 그저 JSX만 반환하도록 화면을 다시 그리면 된다.
그렇지만 이 훅이 필요한 상황이 존재한다. 바로 렌더링 전에 DOM Elements의 레이아웃과 스타일이 확정된 상태에서 특정 작업을 수행하려는 경우이다.
정리하면 다음과 같은 상황에 useLayoutEffect
훅을 활용하면 도움이 될 수 있다:
사용법은 useEffect
와 동일하다. 다만 몇 가지 주의사항이 존재하니, 실행 타이밍이 매우 중요한 작업과 같이 필요한 상황에서만 선택적으로 사용해야 겠다.
두 가지 훅 모두 리액트 컴포넌트의 생명주기 내에서 부수 효과를 처리하기 위해 사용된다는 공통점이 있다. 다만 실행 시점과 목적에 차이가 있다.
다음은 언제 각각을 사용하면 좋을 지에 대한 정리이다.
useEffect: 컴포넌트 렌더링된 후 비동기적으로 실행
useLayoutEffect: 컴포넌트 렌더링된 후 동기적으로 실행
useEffect
로 대체가 가능만들고 있는 채팅 웹 애플리케이션에서 메시지 목록에 페이지네이션을 적용해 특정 개수(20개)씩 가져 오는 기능이 있다.
새로 불러온 페이지에 해당하는 항목을 메시지 목록 배열의 앞쪽에 unshift 하게 되는데, 이때 스크롤의 위치는 메시지를 새로 불러오기 전 위치에 있는게 자연스러울 것이라 생각해서, 각 말풍선들의 높이를 계산해 스크롤 위치를 조정하는 기능을 넣었다.
그런데 이미 리스트가 앞쪽에 추가된 상태에서 스크롤 위치를 변경하니, 맨 앞쪽의 리스트가 아주 잠깐 보여졌다가 스크롤 위치 조정에 의해 이전에 보고 있던(새로 불러오기 전) 리스트가 보여지는, 깜빡인다고 느껴지는 UI가 완성되었다.
따라서 렌더링 결과가 아직 브라우저에 그려지기 전에 DOM 요소에 접근하여 높이를 계산하기 위해 useLayoutEffect
를 사용하도록 수정하여 깜빡이는 UI 문제를 해결할 수 있었다.
const boxRef = useRef<HTMLDivElement>(null);
const [viewCount, setViewCount] = useState(0);
// 페이지 별 불러온 갯수를 저장하기 위해 사용
// 마지막 페이지인 경우 20개보다 작을 수 있으니 불러온 갯수 만큼의 height를 총합하여 scrollTop으로 지정
...
useEffect(() => {
if (boxRef.current && viewCount > 0) {
const elements = [...boxRef.current.querySelectorAll(".chat-bubble")];
let offsetHeight = 0;
for (const el of elements.slice(0, viewCount)) {
offsetHeight += el.clientHeight;
}
boxRef.current.scrollTop = offsetHeight;
setViewCount(0);
}
}, [viewCount]);
const boxRef = useRef<HTMLDivElement>(null);
const [viewCount, setViewCount] = useState(0);
...
// 이 부분만 변경
useLayoutEffect(() => {
if (boxRef.current && viewCount > 0) {
const elements = [...boxRef.current.querySelectorAll(".chat-bubble")];
let offsetHeight = 0;
for (const el of elements.slice(0, viewCount)) {
offsetHeight += el.clientHeight;
}
boxRef.current.scrollTop = offsetHeight;
setViewCount(0);
}
}, [viewCount]);
전체 프로젝트의 코드는 https://github.com/mnngfl/real-time-chat-application 에서 확인할 수 있다.
어떤 상황에서 어떤 훅을 선택할 지와 관련해 명확한 이해를 통해, 앞으로도 리액트 애플리케이션을 개발하면서 성능을 최적화하고 예상치 못했던 버그를 미연에 방지하는데 도움이 될 수 있을 것 같다.
참고 링크: