useEffect Vs. useLayoutEffect 비교

시소·2024년 3월 12일
0
post-thumbnail

들어가며

화면 개발 시 좋은 사용자 경험을 제공하기 위한 핵심 요인 중 하나는 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 () => {
    /* 정리 작업 수행 */ 
    // ...
  };
}, [/* 의존성 배열 */]);

Effect 선언

  • 컴포넌트 최상위 수준에서 useEffect를 호출하면 모든 렌더링 이후에 Effect 내의 코드가 실행된다.

Effect 종속성 지정

  • 2번째 인수에 종속성 배열을 지정할 수 있다.
  • 빈 배열([])이면 마운트 될 때 한 번만 실행된다.
  • 반면 이 배열에 값이 존재하면 이 값들이 변경될 때마다 useEffect가 재실행된다.(다르게 말하면 의존성 배열 내의 값이 이전 값과 동일하다면 재실행을 건너 뛴다)

정리(clean-up) 함수 설정 (선택 사항)

  • 등록한 Effect 함수의 정리를 수행하는 함수이다.
  • 컴포넌트가 언마운트 되거나, 의존성 배열이 변경되어 새로운 Effect가 등록될 때 호출된다.

useLayoutEffect

useEffect와 비슷하지만, 다른 시점에 동작한다. 렌더링이 발생한 직후, 브라우저가 화면을 다시 그리기(repaint) 전에 실행된다.

일반적인 경우에 이 훅을 활용할 일은 별로 없을 것이며 공식 문서에서도 아래와 같이 경고하고 있다.

useLayoutEffect는 성능을 저하시킬 수 있습니다. 가능하면 useEffect를 사용하세요.

대부분의 컴포넌트는 렌더링할 내용을 결정하기 위해 화면에서의 위치나 크기를 알 필요가 없기 때문일 것이다. 그저 JSX만 반환하도록 화면을 다시 그리면 된다.

그렇지만 이 훅이 필요한 상황이 존재한다. 바로 렌더링 전에 DOM Elements의 레이아웃과 스타일이 확정된 상태에서 특정 작업을 수행하려는 경우이다.
정리하면 다음과 같은 상황에 useLayoutEffect 훅을 활용하면 도움이 될 수 있다:

  • DOM 요소의 크기나 위치를 측정하거나 계산해야 하는 경우
  • 눈에 띄는 화면 깜빡임(Jank)의 방지를 위해, DOM이 변경되기 전에 동기적인 연산이 필요할 때
  • CSS 애니메이션을 정밀하게 조정해야 하는 경우 (요소의 초기 상태 지정 / 애니메이션 트리거 등)

사용법은 useEffect와 동일하다. 다만 몇 가지 주의사항이 존재하니, 실행 타이밍이 매우 중요한 작업과 같이 필요한 상황에서만 선택적으로 사용해야 겠다.


언제 어떤 훅을 사용하면 좋을까

두 가지 훅 모두 리액트 컴포넌트의 생명주기 내에서 부수 효과를 처리하기 위해 사용된다는 공통점이 있다. 다만 실행 시점과 목적에 차이가 있다.
다음은 언제 각각을 사용하면 좋을 지에 대한 정리이다.

useEffect: 컴포넌트 렌더링된 후 비동기적으로 실행

  • 대부분의 부수 효과 처리 시에 적절
  • Data Fetching, Subscription 설정, Window Event Handler 등록 등
  • 렌더링 작업을 차단하지 않으므로 성능에 미치는 영향 少

useLayoutEffect: 컴포넌트 렌더링된 후 동기적으로 실행

  • 렌더링 결과가 화면에 그려지기 전에 실행되어야 하는 부수 효과에 적절
  • 동기적으로 실행되기 때문에, 작업이 오래 걸리면 브라우저가 화면을 그리는 게 지연될 가능성 有
  • DOM 조작(요소의 크기/위치 측정 등)이 필요한 경우 등
  • 가능한 한 적은 곳에 사용, 대부분의 경우 useEffect로 대체가 가능

실제 활용 예 (깜빡이는 UI 해결)

만들고 있는 채팅 웹 애플리케이션에서 메시지 목록에 페이지네이션을 적용해 특정 개수(20개)씩 가져 오는 기능이 있다.

새로 불러온 페이지에 해당하는 항목을 메시지 목록 배열의 앞쪽에 unshift 하게 되는데, 이때 스크롤의 위치는 메시지를 새로 불러오기 전 위치에 있는게 자연스러울 것이라 생각해서, 각 말풍선들의 높이를 계산해 스크롤 위치를 조정하는 기능을 넣었다.

그런데 이미 리스트가 앞쪽에 추가된 상태에서 스크롤 위치를 변경하니, 맨 앞쪽의 리스트가 아주 잠깐 보여졌다가 스크롤 위치 조정에 의해 이전에 보고 있던(새로 불러오기 전) 리스트가 보여지는, 깜빡인다고 느껴지는 UI가 완성되었다.

따라서 렌더링 결과가 아직 브라우저에 그려지기 전에 DOM 요소에 접근하여 높이를 계산하기 위해 useLayoutEffect 를 사용하도록 수정하여 깜빡이는 UI 문제를 해결할 수 있었다.

useEffect 사용했을 때

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]);

useLayoutEffect로 변경했을 때

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 에서 확인할 수 있다.

마치며

어떤 상황에서 어떤 훅을 선택할 지와 관련해 명확한 이해를 통해, 앞으로도 리액트 애플리케이션을 개발하면서 성능을 최적화하고 예상치 못했던 버그를 미연에 방지하는데 도움이 될 수 있을 것 같다.

참고 링크:

profile
배우고 익힌 것을 나만의 언어로 정리하는 공간 ..🛝

0개의 댓글