• 프로그래머스, 타입스크립트로 함께하는 웹 풀 사이클 개발(React, Node.js) 5기 강의에서 번외로 진행되는 활동에 대한 내용을 정리하는 포스팅.

  • React.js (19버전)을 주제로 삼아 진행되는 스터디.



1. useInsertionEffect

  • useInsertionEffect는 CSS-in-JS 라이브러리 작성자를 위한 Hook.

  • layout Effects가 실행되기 전에 스타일을 DOM에 주입할 수 있게 한다.

  • (주의!) useInsertionEffect은 CSS-in-JS 라이브러리를 쓰는 경우에 사용되는 것으로, CSS-in-JS 라이브러리 작업 중에 스타일을 주입할 위치가 필요하다는 목적이 아니라면 공식적으로 useEffect나 useLayoutEffect를 사용하는 것을 권장하고 있다.



Effect 가족들?

  • useInsertionEffect, useLayoutEffect, 그리고 useEffect는 매개변수의 형태와 동작 방식이 동일하다. 단지 실행 시점이 다를 뿐이다.

useEffect

useEffect(() => {
  console.log("렌더링 이후 실행");
}, []);

useLayoutEffect

useLayoutEffect(() => {
  console.log("렌더링 이후, 브라우저가 그리기 전에 실행");
}, []);

useInsertionEffect

useInsertionEffect(() => {
  console.log("렌더링 이전에 실행 (주로 스타일 삽입)");
}, []);



매개변수의 역할

  • effect: 실행할 작업을 정의하는 함수.

  • dependencies: 의존성 배열. 배열에 포함된 값이 변경될 때 effect가 재실행됨.



CSS-in-JS?

  • Style 속성을 CSS 파일이 아닌, JavaScript로 작성된 컴포넌트에 바로 삽입하는 기법.

  • 자세한 설명은 이전 포스팅 참조.



useEffect와 어떤 차이가 있는가?

  • useEffect는 화면이 렌더링 된 이후에, 실행될 작업들을 위해 사용하는 Hook.

  • useInsertionEffect는 화면이 렌더링 되기 전에, 스타일 적용을 위해 사용하는 Hook.

  • useEffect에서도 스타일 로직을 사용할 수는 있는데.. useEffect의 동작 원리상, 렌더링이 끝난 후에 스타일을 DOM에 추가되기 때문에 화면이 한 번 깜빡이는(FOUC, Flash of Unstyled Content) 현상이 발생할 수 있다. 그래서 useInsertionEffect를 사용하는 것.



그냥 Styled-Components를 사용하는게 편하지 않나..?

  • 맞다. 애초에 Styled-Components는 내부적으로 useInsertionEffect을 사용하고 있다.
    (emotion 라이브러리도 마찬가지.)

  • 달리 말하자면.. useInsertionEffect을 더 편하고 직관적으로 사용하기 위해서 쓰는 것이 Styled-Components.

  • CSS-in-JS를 직접 하겠다고 useInsertionEffect을 사용해보면.. 라이브러리를 왜 사용하는지 잘 알게 된다.

  • 아래 예제 참조..



예제 코드:

// 라이브러리를 사용하지 않았을 때..

import { useInsertionEffect } from 'react';

function useCSS(rule) {
  useInsertionEffect(() => {
    const style = document.createElement('style');
    style.textContent = rule;
    document.head.appendChild(style);
    return () => {
      document.head.removeChild(style);
    };
  }, [rule]);

  return rule;
}

function MyComponent() {
  useCSS('.myClass { color: red; }');

  return <div className="myClass">Hello, World!</div>;
}



// styled-components를 사용했을 때.
import styled from 'styled-components';

const StyledDiv = styled.div`
  color: red;
`;

function MyComponent() {
  return <StyledDiv>Hello, World!</StyledDiv>;
}
  • 두 코드 모두, 결과는 동일하다.



useInsertionEffect은 동적 스타일링도 구현 가능하다!

  • ..그리고 styled-components도 동적 스타일링 구현이 가능하다. 더 편하게..

  • 이전 포스팅 참조.



2. useLayoutEffect

  • useLayoutEffect는 브라우저가 화면을 다시 그리기 전에 동기적으로 실행되는 Hook.

  • DOM을 읽거나 수정하는 작업을 수행할 수 있음.

  • 과도하게 사용하면 성능에 영향을 줄 수 있으므로, 필요한 경우에만 사용을 권장.



매개변수의 역할

  • effect: 렌더링 이후 DOM을 읽거나 수정할 작업을 정의하는 함수.

  • dependencies: 의존성 배열. 배열의 값이 변경될 때 effect가 재실행됨.



useEffect와 어떤 차이가 있는가?

  • useLayoutEffect는 useEffect와 하는 역할은 동일하다.

  • 그런데 DOM을 조작해야하는 경우.

  • useEffect는 렌더링 된 이후에, 실행될 작업들을 위해 사용하는 Hook 이기 때문에 여기서 DOM을 조작하게 되면 위에서 언급했던 화면이 한 번 깜빡이는(FOUC, Flash of Unstyled Content) 현상이 발생해버린다.

  • 따라서, useEffect이지만 DOM 조작이 필요한 경우에는 useLayoutEffect이라는 별도의 Hook을 사용하라는 것이다.



동작 시점에 차이가 있다.

화면 렌더링이 완료 -> 화면이 출력(layout과 paint) -> useEffect가 동작.

화면 렌더링이 완료 -> useLayoutEffect이 동작 -> 화면이 출력(layout과 paint).



예제 코드:

useEffect 예제

렌더링 완료 후 데이터 가져오기:

import React, { useEffect, useState } from 'react';

function Example() {
  const [data, setData] = useState(null);

  useEffect(() => {
    console.log('Fetching data...');
    fetch('/api/data')
      .then((response) => response.json())
      .then((data) => setData(data));
  }, []);

  return <div>Data: {data || 'Loading...'}</div>;
}



useLayoutEffect 예제

렌더링 직후 DOM 요소의 크기 측정:

import React, { useLayoutEffect, useRef, useState } from 'react';

function Example() {
  const divRef = useRef(null);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });

  useLayoutEffect(() => {
    const { width, height } = divRef.current.getBoundingClientRect();
    setDimensions({ width, height });
  }, []);

  return (
    <div ref={divRef}>
      Width: {dimensions.width}, Height: {dimensions.height}
    </div>
  );
}



3. useSyncExternalStore

  • useSyncExternalStore는 외부 스토어와 컴포넌트를 동기화하기 위한 Hook.

  • 외부 스토어의 상태를 구독하고, 변경 사항이 발생하면 컴포넌트를 다시 렌더링한다.



매개변수의 역할

  • subscribe: 외부 스토어의 상태 변경을 감지하는 함수.
    - 매개변수 listener는 상태 변경 시 호출되는 콜백 함수.
    - 반환값으로 구독을 해제하는 함수(unsubscribe)를 반환해야 함.

  • getSnapshot: 현재 외부 스토어의 상태를 반환하는 함수.

  • getServerSnapshot (선택적): 서버 렌더링 시 상태를 반환하는 함수. (SSR 환경에서 사용)



외부 스토어?

  • 전역 상태 관리에서 사용되는, 모든 상태State를 관리하는 객체.

  • 전역 상태 관리에 대해서는 이전 포스팅 참조.



스토어라면.. 전역 상태 관리 라이브러리의 스토어?

  • 그렇다. 대표적인 전역 상태 관리 라이브러리들을 들어보자면.. 보통 Redux, Recoil, Zustand, jotai 등을 사용한다.

  • (참고), Recoil은 더 이상 사용하지 않는게 좋다. 해당 포스팅 참조.
    - 한 때는 각광받았는데.. 지금은 메인 업데이트는 2년 넘게 끊어졌고, 성능 및 최적화 문제가 제기되고, SSR 환경에 적합하지 않고, 메인 개발자가 해고당하는 악재까지..

  • 말이 좀 샜는데.. 아무튼 useSyncExternalStore Hook은 별도의 외부 상태 관리 라이브러리 등을 사용할 때 혹은 App 바깥에서 값을 가져올 때 등의 상황이 있다고 가정해보자.

  • 가져온 값이 변경될 때, React 컴포넌트에서 리렌더링이 발생하는 것은 지극히 당연한 일이다.

  • 그런데.. React 18에서 UI의 동시적 업데이트를 지원하는 여러 기능들이 추가되면서, React에 종속되어있지 않은 외부 상태 관리 라이브러리 등과 React App 사이에 Tearing 문제가 발생하는 일이 생겼다.

Tearing?

  • 동일한 데이터 소스에 따라 렌더링이 이뤄지는 두 가지 UI가 있다고 할 때..

  • 동시적 업데이트 이전에는 순서대로 작업이 잘 이루어졌는데..

  • 이제는 어느 한 쪽의 작업이 실패하거나, 지연되거나 하는 등의 이유로 '동일하지 못한 데이터 소스'에 의해 여러 UI가 렌더링 되어버릴 수도 있게 되었다.

  • 값이 잘못되면, UI도 이상하게 출력된다는 것.



그래서 어떻게 사용하는거지?

  • useSyncExternalStore는 외부 전역 상태 관리 라이브러리를 사용하는 경우에 사용할 수 있는 녀석이다.

  • Recoil은.. 이 녀석은 React 개발진에서 만든 녀석이라 따로 뭘 해줄 필요는 없다고 한다.
    - 근데 라이브러리가 망했네?

  • Redux는.. 내부적으로 useSyncExternalStore를 쓰도록 변경되었다고 한다. 따로 조치를 취할 필요는 없는 것 같고..

  • Zustand는.. use-sync-external-store라고 useSyncExternalStore을 사용하는 패키지가 따로 있는데, 요 녀석을 설치해서 사용하면 useSyncExternalStore의 기능을 사용할 수 있다고 한다.
    - 필수는 아니다. useSyncExternalStore 기능을 원하는 경우에만 패키지를 설치해서 사용하라는 뜻.

  • Jotai는.. 개발자 피셜로 useSyncExternalStore를 안쓴다는 것 같다? 자체적인 방식으로 문제를 해결한다고..



Jotai는 왜 useSyncExternalStore를 안쓰지?

Jotai 개발자가 말하는 내용을 번역하고, 이를 이해하기 위해 최대한 노력해보았다..

  • useSyncExternalStore는 만능이 아니고 오히려 최적화에 방해가 될 수 있다.

  • Time Slicing과 useTransition을 잘 지원하기 위해서 useSyncExternalStore 대신 useState와 useReducer를 사용한다.

  • useSyncExternalStore는 동기적으로 상태를 가져오기 때문에, 상태를 구독하는 모든 컴포넌트를 즉시 업데이트해야한다.

  • 그래서 React의 Time Slicing을 무효화(de-optimize)시키며, 작업을 분할하거나 우선순위를 조정할 수 없다.

  • useSyncExternalStore는 업데이트를 강제로 즉시 반영하기 때문에, useTransition으로 비긴급 업데이트를 처리할 때 적절히 대기 상태를 표시하지 못할 수 있다.

  • 예를 들어, 데이터가 변경될 때 "Pending…" 상태를 보여주고 싶어도 useSyncExternalStore는 즉시 렌더링을 강제하여 이러한 UX를 방해할 수 있는 것.

  • 그럼 어떤 방법을 사용하지? -> useState와 useReducer만을 사용하여 Time Slicing을 방해하지 않고 작업을 스케줄링하고 / useTransition과 더 자연스럽게 연동되어 대기 상태를 효과적으로 처리하고 / 그러면서도 기본 상태 관리 메커니즘과 밀접하게 통합되어 React 18의 동시성 기능을 최대한 활용한다고..

Time Slicing?

  • React 18의 동시성 기능 중 하나, 작업을 분할(Slicing)하여 브라우저의 메인 스레드가 과부하되지 않도록 한다.

  • 높은 우선순위 작업(예: 사용자 입력, 애니메이션)과 낮은 우선순위 작업(예: 렌더링)을 구분하여 처리.

useTransition?

  • 요건 아래에서 정리.



동작 원리는?

  • getSnapshot을 동기적으로 호출하여, 외부 상태 저장소의 최신 상태를 항상 정확히 가져온다.

  • 상태 변경이 발생하면 저장소에서 구독 중인 모든 컴포넌트가 동일한 업데이트 주기 안에서 다시 렌더링시킨다.
    - 여러 컴포넌트들이 공유하는 상태가 일치하지 않는Tearing 현상을 방지한다는 것.

  • 다만 이렇게되면 불필요한 리렌더링이 발생할 수 있다. 따라서 getSnapshot의 반환값을 메모이제이션하여, 동일한 상태 값이 반복적으로 렌더링되는 것을 방지한다.

  • (SSR에서는) getServerSnapshot을 활용해 클라이언트 초기 상태와 서버 상태 간의 불일치를 방지한다.
    - 클라이언트가 초기 상태를 렌더링하기 전에 서버에서 가져온 상태와 동기화하므로 UI가 일관성을 유지시킬 수 있다.



예제 코드:

import { useSyncExternalStore } from 'react';

const store = {
  state: 0,
  listeners: new Set(),
  subscribe(listener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  },
  increment() {
    this.state += 1;
    this.listeners.forEach((listener) => listener());
  },
  getSnapshot() {
    return this.state;
  },
};

function Counter() {
  const count = useSyncExternalStore(
    (listener) => store.subscribe(listener),
    () => store.getSnapshot()
  );

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => store.increment()}>Increment</button>
    </div>
  );
}



4. useTransition

  • useTransition은 상태 업데이트를 전환 상태로 표시하여 비동기 작업의 진행 상태를 관리할 수 있게 해주는 Hook

  • 사용자 인터페이스의 응답성을 향상시킬 수 있음.



매개변수의 역할

  • 매개변수 없음.



UI를 차단하지 않고 상태를 업데이트할 수 있다?

  • React에서는 여러 상태가 '동시에 업데이트'될 때, UI가 잠시 멈추거나 과부하가 발생할 수 있다.

  • A, B, C, ... 여러 상태들이 동시에 업데이트되면, 컴포넌트가 한꺼번에 리렌더링되고..

  • 많은 작업이 몰리면서 응답이 지연되고, 자칫 잘못하면 UI가 멈춰버리는 일까지 발생할 수 있다.

  • 작업이 완료되기 전까지, UI 변화나 동작을 잠시 막을 수도block 있겠지만.. 사용자 경험 측면에서 좋은 방법은 아니다.

  • 그럴 때 사용하는 것이 useTransition.



useTransition으로 문제 해결:

  • useTransition을 사용하면 작업을 우선순위로 분류하여 처리한다.
  1. 높은 우선순위: 즉각적인 상태 업데이트
  2. 낮은 우선순위: 느린 작업 처리



useTransition!

A 상태가 업데이트되고, UI가 리렌더링 된다. - ?순위
B 상태가 업데이트되고, UI가 리렌더링 된다. - ?순위
C 상태가 업데이트되고, UI가 리렌더링 된다. - ?순위
D 상태가 업데이트되고, UI가 리렌더링 된다. - ?순위
E 상태가 업데이트되고, UI가 리렌더링 된다. - ?순위
F 상태가 업데이트되고, UI가 리렌더링 된다. - ?순위
=> CBEDAF..?? 모든 작업들이 자기가 먼저 실행되겠다고 나서다가 뻗어버렸다.




A 상태가 업데이트되고, UI가 리렌더링 된다. - 1순위
B 상태가 업데이트되고, UI가 리렌더링 된다. - 2순위
C 상태가 업데이트되고, UI가 리렌더링 된다. - 3순위
D 상태가 업데이트되고, UI가 리렌더링 된다. - 4순위
E 상태가 업데이트되고, UI가 리렌더링 된다. - 5순위
F 상태가 업데이트되고, UI가 리렌더링 된다. - 6순위
=> A, B, C, D, E, F. 작업들이 순차적으로 하나씩 실행되면서 아무 문제도 발생하지 않는다.



예제 코드:

import React, { useState, useTransition } from "react";

function SearchComponent({ items }) {
  const [query, setQuery] = useState(""); // 검색어
  const [filteredItems, setFilteredItems] = useState(items); // 필터링된 결과
  const [isPending, startTransition] = useTransition(); // useTransition 사용

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value); // 높은 우선순위 업데이트

    startTransition(() => {
      // 낮은 우선순위 업데이트
      const filtered = items.filter((item) =>
        item.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  };

  return (
    <div>
      <input type="text" value={query} onChange={handleChange} />
      {isPending && <p>Loading...</p>}
      <ul>
        {filteredItems.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  );
}
  • setQuery가 높은 우선순위, 아무런 지연없이 바로 상태 업데이트가 이루어지도록 한다.

  • startTransition 함수에서 콜백 함수 형태로 넘겨진 코드가 낮은 우선순위.

  • 낮은 우선순위의 작업은, 높은 우선순위 작업이 다 이루어진 뒤에 실행되도록 한다.

  • 이렇게 순차적으로 작업이 진행되도록 해서 순간적인 작업량 증가로 인해 UI에 문제가 발생하기 않도록 하는 것이다.

  • 조금 더 자세한 내용은 외부 포스팅 참고.



5. useOptimistic

  • useOptimistic은 비동기 작업이 진행 중일 때 낙관적인 UI 업데이트를 가능하게 하는 Hook

  • 네트워크 요청 등의 비동기 작업이 완료되기 전에 사용자에게 예상 결과를 미리 보여줄 수 있음.



매개변수의 역할

  • initialState: 낙관적 상태 관리의 초기 상태.

  • reducer: 상태 업데이트 로직을 정의하는 함수.
    - state: 이전 상태.
    - action: 상태를 업데이트하는 데 필요한 값.



낙관적인?

  • 비동기 작업이 모두 완료된 뒤, 데이터를 상태에 업데이트하고, UI를 리렌더링 한다.

  • 그런데 비동기 작업 특성상 결과가 언제 반환될지는 미지수. 결과를 기다릴 때까지 UI 렌더링을 계속 기다릴 수도 없다..

  • 따라서, 비동기 작업 결과를 '예상'하여 이를 기반으로 UI를 먼저 업데이트하는 것.

  • 작업이 성공했을 때를 '예상'하기 때문에, '낙관적인' UI 업데이트라고 지칭한다.

  • 물론 작업이 실패하면 롤백.



예제 코드:

import React, { useState, useOptimistic } from "react";

function Comments() {
  const [comments, setComments] = useState(["First comment", "Second comment"]);
  const [optimisticComments, setOptimisticComments] = useOptimistic(comments, (prev, newComment) => {
    return [...prev, newComment]; // 새 댓글 추가
  });

  const handleAddComment = async (newComment) => {
    // 낙관적 상태 업데이트
    setOptimisticComments(newComment);

    try {
      // 서버에 새 댓글 저장 요청
      await fetch("/api/addComment", {
        method: "POST",
        body: JSON.stringify({ comment: newComment }),
      });
    } catch (error) {
      // 서버 요청 실패 시 롤백
      alert("Failed to add comment");
      setOptimisticComments((prev) => prev.slice(0, -1)); // 마지막 댓글 삭제
    }
  };

  return (
    <div>
      <ul>
        {optimisticComments.map((comment, index) => (
          <li key={index}>{comment}</li>
        ))}
      </ul>
      <button onClick={() => handleAddComment("New comment")}>Add Comment</button>
    </div>
  );
}

댓글을 작성한다 -> 서버로 전송한다 -> 작업이 정상적으로 완료된다 -> 댓글 데이터를 새로 받아 화면을 갱신한다.

  • 원래대로면 위 순서대로 작업을 진행해야겠지만..

  • 사용자 입장에서는 내가 댓글을 새로 입력했는데, 화면이 업데이트 되는 것이 '너무 느리다'고 생각할 수 있다.
    - 길어봐야 몇 초 차이인데 이게 그렇게 문제가 되냐 싶지만..
    - 모 설문 조사에서는 사용자들은 평균적으로 3초에서 5초 내로 화면이 렌더링 되지 않으면 그 페이지를 그냥 나가버렸다는 결과도 있다.
    - 불과 몇 초 때문에 사용자 평가가 나빠질 수 있는 것..

  • 따라서 setOptimisticComments를 통해 '작업이 성공했다 치고', 새 댓글이 작성된 것 처럼 화면을 업데이트하는 것이다.
    - 입력한 댓글이 서버에 업로드 되지도 않았는데, 어떻게 화면을 업데이트하지?
    - 위 예제의 경우.. 사용자가 댓글 데이터를 입력하면, 이 데이터를 그대로 서버에 전송하면서, 클라이언트에서는 낙관적인 UI 업데이트에 사용하게 된다.

  • 서버에 데이터가 성공적으로 업로드 되었다? 잘 된 일이다.

  • 실패했다? 작업 내용을 롤백하면 된다.

profile
프론트엔드 개발자를 준비하고 있습니다.

0개의 댓글