Skip to content

Conversation

@kk3june
Copy link

@kk3june kk3june commented Apr 10, 2025

과제 체크포인트

배포 링크

https://kk3june.github.io/front_5th_chapter1-3/

기본과제

  • shallowEquals 구현 완료
  • deepEquals 구현 완료
  • memo 구현 완료
  • deepMemo 구현 완료
  • useRef 구현 완료
  • useMemo 구현 완료
  • useDeepMemo 구현 완료
  • useCallback 구현 완료

심화 과제

  • 기본과제에서 작성한 hook을 이용하여 렌더링 최적화를 진행하였다.
  • Context 코드를 개선하여 렌더링을 최소화하였다.

과제 셀프회고

기술적 성장

좀 더 깊이 있게 문제에 대해 고민하기 시작했습니다. ‘과제를 해결’ 하기 위해 문제 해결에 매몰되지 않고, 항해를 원래 시작했던 목표에 맞춰 ‘올바른 학습 방법을 습득’ 하는데 우선 순위를 두고 이번 주차에 임했습니다. 1주차, 2주차 BP 들을 보면서 같은 문제를 다른 관점에서 바라보고 표면적인 문제 이면에 숨어있는 원리에 대해 고민하는, 사고하는 방법을 습득하는데 중점을 두었습니다. 덕분에 이전이라면 지나쳤을 내용에 대해 고민해보는 시간을 가질 수 있었습니다.

  • 트러블 슈팅

심화과제 중 첫번째 테스트 케이스가 ‘초기 렌더링 시 모든 컴포넌트가 한 번씩 렌더링되어야 한다’ 였는데, 계속 랜더링이 한번 더 발생하는 이슈가 있었습니다.

기본 과제를 다 통과 했다는 이유로 구현된 hook 에 대해 살펴볼 생각을 하지 못하고 전역에서의 context 상태 변화에 따라 발생하는 이슈로 짐작하여 context 파일의 구조를 바꿔보기도 하고 context 호출 시점을 바꿔보기도 하다가 도저히 문제가 해결 되지 않았습니다.

부끄럽지만 다른 분들의 PR 에서 테스트 케이스를 모두 통과한 컴포넌트를 복붙하여 테스트를 돌려 보았는데, 역시나 동작하지 않았고 그때서야 기본 과제를 하며 구현했던 기능에 뭔가 문제가 있음을 발견했습니다.

😥 before 

// memo.ts
export function memo<P extends object>(Component: ComponentType<P>) {
  const MemoizedComponent = function (props: P) {
    const ref = useRef({ props: {} });
    if (!shallowEquals(props, ref.current.props)) {
      ref.current.props = props;
      return React.createElement(Component, props);
    }
    return React.createElement(Component, props);
  };
  return MemoizedComponent;
}

DOM을 비교하여 변경사항이 있을 때만 컴포넌트를 재생성 하여 Reconcilation이 발생하도록 하고, 그렇지 않은 경우 이전의 변경되지 않은 컴포넌트를 전달해야 하는데, 비교 결과와 무관하게 컴포넌트가 기본적으로 재생성 되도록 하여 매번 리렌더링이 발생했던 것이었습니다.

✅ after

// memo.ts
export function memo<P extends object>(Component: ComponentType<P>) {
  const MemoizedComponent = function (props: P) {
    const ref = useRef({ props: {} });
    if (!shallowEquals(props, ref.current.props)) {
      ref.current.props = props;
      return React.createElement(Component, props);
    }
    return ref.current.element;
  };
  return MemoizedComponent;
}

기본적으로는 이전에 저장된 요소를 전달함으로써 문제를 해결하였습니다.

기능에 대해 제대로 이해하지 않고 문제해결에만 급급하다 보니 에러의 발생 원인조차 자력으로 찾을 수 없었습니다. 이를 반면교사 삼아 사용하는 기능에 대해 이해를 하고 사용할 수 있도록 노력해야겠다는 다짐을 한번 더 하였습니다.

코드 품질 개선

context 호출 위치에 대한 고민

ItemList 컴포넌트는 onClick 이벤트가 발생하면 데이터를 1000개씩 추가하여 하위 요소를 렌더링 하고 있습니다. 이때 theme 상태에 따라 스타일링이 달라지는데, 이를 위해 아래 2가지 방식으로 컴포넌트를 구현할 수 있었습니다.

  1. 상위 컴포넌트에서 context를 호출하고, 하위 컴포넌트에 해당 컨텍스트를 전달하는 방식
export const ItemList: React.FC<{
  items: ItemProps[];
  onAddItemsClick: () => void;
}> = memo(({ items, onAddItemsClick }) => {
...
  const { theme } = useTheme();

  const filteredItems = items.filter(
    (item) =>
      item.name.toLowerCase().includes(filter.toLowerCase()) ||
      item.category.toLowerCase().includes(filter.toLowerCase()),
  );
...

  return (
    <div className="mt-8">
    ...
      <ul className="space-y-2">
        {filteredItems.map((item, index) => (
          <Item key={index} item={item} theme={theme} />
        ))}
      </ul>
    </div>
  );
});
  1. 하위 컴포넌트에서 직접 context를 호출하는 방식
const Item = memo(({ item }) => {
	  const { theme } = useTheme();
    return (
      <li
        className={`p-2 rounded shadow ${theme === "light" ? "bg-white text-black" : "bg-gray-700 text-white"}`}
      >
        {item.name} - {item.category} - {item.price.toLocaleString()}</li>
    );
  },
);

첫 번째 방식은 상위 컴포넌트에서 한번만 context를 호출하고 하위 컴포넌트에 context 를 전달하는 방식이고, 두 번째 방식은 렌더링 되는 하위컴포넌트에서 개별적으로 context를 호출하는 방식입니다. 첫번째 방식은 context를 한번만 호출하지만 context 값이 변경될 경우 하위 컴포넌트가 모두 렌더링 됩니다. 반면 두번째 방식은 context를 하위 컴포넌트 갯수만큼 호출하지만 context 값이 변경 되었을 때 해당 컴포넌트만 렌더링 되게 됩니다.

테스트 가설

context를 전역상태에 접근하는 비용이 props 로 상태 값을 전달하는 것보다 비용이 클 것으로 짐작되기에, 첫번째 방식이 성능적으로 우위를 보일 것이다.

이를 비교하기 위해 Profiler를 사용하여 2가지 방식을 테스트를 진행하였습니다.

  • 테스트 방식
    • 클릭 한번 당 하위 요소 1,000개 추가
    • 10번씩 클릭 이벤트 발생
  • 테스트 결과
커밋 부모 컴포넌트에서 Context 소비 (ms)
(ItemList에서 Context 소비)
자식 컴포넌트에서 Context 소비 (ms)
(Item에서 Context 소비)
차이 (ms) 차이 (%)
1 69.7 91.3 -21.6 -23.7%
2 66.0 88.6 -22.6 -25.5%
3 72.6 94.2 -21.6 -22.9%
4 79.6 106.3 -26.7 -25.1%
5 114.2 111.5 +2.7 +2.4%
6 107.4 131.5 -24.1 -18.3%
7 150.1 138.7 +11.4 +8.2%
8 161.6 149.4 +12.2 +8.2%
9 172.2 165.2 +7.0 +4.2%
10 152.7 175.4 -22.7 -12.9%

Anonymous 컴포넌트 시간 비교

커밋 부모 컴포넌트에서 Context 소비 (ms)
(ItemList에서 Context 소비)
자식 컴포넌트에서 Context 소비 (ms)
(Item에서 Context 소비)
차이 (ms) 차이 (%)
10 19.5 28.6 -9.1 -31.8%

MemoizedComponent 시간 비교

커밋 부모 컴포넌트에서 Context 소비 (ms)
(ItemList에서 Context 소비)
자식 컴포넌트에서 Context 소비 (ms)
(Item에서 Context 소비)
차이 (ms) 차이 (%)
1 67.1 90.4 -23.3 -25.8%
10 151.6 172.6 -21.0 -12.2%

예외 케이스가 존재하기는 했지만, 대체적으로 상위 컴포넌트에서 한번만 context 를 호출하고 하위 컴포넌트에 해당 context를 전달하는 방식이 성능적으로 10% 우위를 보였습니다.

테스트 결과에 따라 첫 번째 방식인 상위 컴포넌트에서 context를 선언하고 하위 컴포넌트에 props 로 전달하는 방식으로 구현하였습니다.

테스트 결과에 대해 Claude Sonet 3.7 로 분석을 요청한 바에 따르면 두 번째 방식은 3가지 단점이 존재하였습니다.

  1. context 구독 비용 증가로 인한 성능 이슈
  2. 각 Item의 독립적인 렌더링 결정으로 인해 최적화 하기 어려움
  3. 가상 DOM 비교 과정에서 1,000개(하위 요소의 갯수만큼)의 개별 구독 상태를 처리해야 함

결국 구독 비용 뿐만 아니라, 구독 상태를 처리하는데도 하위 요소 갯수만큼 추가비용이 발생할 수 있음을 확인하였습니다.

결론

특정 상태에 따라 다수의 컴포넌트가 리렌더링 되어야 한다면, 해당 컴포넌트들을 감싸고 있는 wrapper 컴포넌트에서 context를 호출하고 하위 컴포넌트에서 context를 pros로 전달받되, 하위 컴포넌트들은 memo를 사용하여 props가 변경될 때 리렌더링 되도록 하는게 가장 좋은 방법 인 것 같습니다.

다만 context 호출을 할때는 해당 context 값에 영향을 받을 요소들을 잘 타겟팅 하여 wrapping 하여야 할 것 같습니다.

context 파일 분리

context 와 관련된 코드들을 아래와 같이 한 파일로 관리했습니다.

😥 before 

export const ThemeContext = createContext<ThemeContextType | undefined>(
  undefined,
);

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error("useTheme must be used within an AppProvider");
  }
  return context;
};

export const ThemeProvider: React.FC<PropsWithChildren> = ({ children }) => {
  const [theme] = useState<"light" | "dark">("light");

  const value = useMemo(() => ({ theme }), [theme]);
  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  );
};

코드가 정상 동작하였지만 아래와 같은 warning 이 발생하였습니다.

Fast refresh only works when a file only exports components. Move your React context(s) to a separate file.eslint(react-refresh/only-export-components)

React 에서는 코드 변경 시 페이지 전체를 새로고침 하지 않고, 수정된 컴포넌트만 빠르게 업데이트 하는 Hot Reloading 기능을 제공합니다.

이 기능은 파일이 React 컴포넌트만을 내보내는 경우 정상 동작을 하고, React 컴포넌트 이외의 것들을 함께 내보내고 있다면 제대로 동작하지 않을 수 있습니다.

Fast Refresh 는 파일의 React 컴포넌트를 추적하여 코드 변경 시 해당 컴포넌트를 업데이트 하게 되는데, React 컴포넌트 외 다른 요소가 섞여 있는 경우 파일에서 무엇을 업데이트 해야할지 모호해지기 때문입니다.

따라서 아래와 같이 코드를 파일 단위로 분리하였습니다.

✅ after

// context/index.ts
export const ThemeContext = createContext<ThemeContextType | undefined>(
  undefined,
);

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error("useTheme must be used within an AppProvider");
  }
  return context;
};

// context/ThemeProvider.tsx
export const ThemeProvider: React.FC<PropsWithChildren> = ({ children }) => {
  const [theme] = useState<"light" | "dark">("light");

  const value = useMemo(() => ({ theme }), [theme]);
  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  );
};

학습 효과 분석

React Fiber

React Fiber는 동기적으로 렌더링을 처리하던 Stack Reconciler을 개선하기 위해 React16(2017)에서 도입된 새로운 렌더링 로직입니다.

React Fiber 도입을 통해 React는 비동기 렌더링, 우선순위 기반 작업 스케줄링, 그리고 복잡한 UI 애플리케이션의 효율적인 업데이트를 제공하고 있습니다.

Fiber의 주요 개념

  • Fiber는 React의 가상 DOM 트리에서 각 컴포넌트를 나타내는 작업 단위(work unit)입니다.
  • 각각의 Fiber 노드는 컴포넌트의 상태, props, DOM 요소, 그리고 다른 Fiber 노드와의 관계(부모, 자식, 형제)를 저장합니다.
  • Fiber는 렌더링 작업을 작은 단위로 나누어 스케줄링할 수 있습니다. 이를 통해 React는 중요한 작업(예: 사용자 입력 처리)을 우선 처리하고, 덜 중요한 작업(예: 오프스크린 콘텐츠 렌더링)을 나중에 처리할 수 있습니다.
  • Fiber는 각 작업에 우선순위를 부여하여 우선 순위가 높은 작업을 우선적으로 수행합니다.
  • 중분 렌더링 : 복잡한 UI를 한 번에 렌더링하지 않고, 작업을 여러 프레임에 걸쳐 분산 처리함으로써 UI를 부드럽게 유지합니다.

Fiber는 화면을 렌더링 하면서 수시로 콜 스택의 작업들을 중단하고 태스크 큐의 작업을 진행합니다. 따라서 사용자 행동에 따른 I/O 입력, 이벤트들이 UI 렌더링보다 우선순위를 갖고 작업이 수행 됩니다.

리뷰 받고 싶은 내용

  • context 와 관련하여 테스트를 진행한 부분이, 테스트 목적과 부합하는 적절한 방법일까요?

  • context 구독 비용과 props로 전역 상태를 전달하는 비용을 정확히 비교하기 위해서는 어떻게 테스트를 진행 해야할까요? 현재 테스트는 렌더링 시간만 측정했지만, Context 구독과 Props 전달의 메모리 사용량이나 CPU 부하 같은 다른 측면도 고려해야 할까요?

  • Fiber의 동작 원리를 더 이해하려면 React 소스 코드를 분석하거나 특정 아티클을 참고하는 것이 일반적인가요? 추천할 만한 자료나 학습 경로가 있을까요?

  • 현재 Providers 라는 파일에서 모든 context Provider를 한번에 import 하여 App.tsx에서 일괄 적용 하고 있습니다. 특정 context 가 일부 컴포넌트에서 사용된다면 해당 context의 Provider 는 영향을 주는 컴포넌트를 wrapping 하여 개별적으로 사용해야될 것 같습니다.

    export default function Providers({ children }: { children: React.ReactNode }) {
      return (
        <ThemeProvider>
          <NotificationProvider>
            <UserProvider>{children}</UserProvider>
          </NotificationProvider>
        </ThemeProvider>
      );
    }
    
    // App.tsx
    <Providers>
      <Header />
      <div className="container mx-auto px-4 py-8">
        <div className="flex flex-col md:flex-row">
          <div className="w-full md:w-1/2 md:pr-4">
            <ItemList items={items} onAddItemsClick={addItems} />
          </div>
          <div className="w-full md:w-1/2 md:pl-4">
            <ComplexForm />
          </div>
        </div>
      </div>
      <NotificationSystem />
    </Providers>
    만약 위와 같은 구조에서 ComplexForm 하위의 일부 요소에서 특정 context에 의존하는 컴포넌트들이 존재한다면 ComplexForm 에서 별도로 Provider를 사용하는게 성능적으로 유리할 것 같은데, 이렇게 사용하는 방식이 적절한 방식일까요? 이 경우, Provider를 분리하는 방식과 하위 컴포넌트에서 Context를 직접 소비하는 방식의 성능 차이와 설계상 장단점은 무엇일까요?

@kk3june kk3june changed the title Feat/chapter1 3 [1팀 김세준] Chapter 1-3 React, Beyond the Basics Apr 10, 2025
@Jeong-wonho
Copy link

와 장난 아니게 멋져요 대장!

Copy link

@ywkim95 ywkim95 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

세준님 고생 많으셨습니다!
파일 단위로 분리하시고 구현하시는 데 고민ㅇ르 많이 하신 것 같아요!
이후에는 일관성이나 단일 책임 원칙 등에 대해서 고민 해보시면 좋을 듯 합니다!
이번 주도 고생 많으셨고, 다움 주도 화이팅입니다!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

env로 관리하는 건 좋은 방식이라고 생각이 드네요! 다른 부분만 따로 분리되서 접근하는게 좋은 생각이라고 생각됩니다!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Providers를 통해서 Provider들만 따로 분리하는 것도 좋은 접근 방식 같아요!

}
};

return MemoizedComponent;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MemoizedComponent 이외에 사용하는 함수나 값이 없다면 즉시 반환을 하셔도 괜찮을 듯 합니다!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shallowEquals에서 undefined나 null에 대한 체크를 명시적으로 해보시는 건 어떠세요?

* - 초기 랜더링 시에는 인자 없이 calculateValue를 호출한 값 반환
*/
const ref = useRef({
value: undefined as T,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined as T로 선언하신 이유가 어떤걸까요? 궁금합니다

export function useRef<T>(initialValue: T): { current: T } {
// React의 useState를 이용해서 만들어보세요.
return { current: initialValue };
const [ref] = useState({ current: initialValue });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분에서 일반적으로 객체나 값을 직접적으로 할당하는데 큰 데이터나 복잡한 연산에서는 조금 비효율적인 것으로 알고 있어요!

아래와 같이 선언하시면 최적화와 성능을 같이 잡을 수 있을 거에요!

const [refValue] = useState(() => ({ current: initialValue });

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UI와 로직을 분리해보시는 건 어떠세요?
더 깔끔한 코드가 나올 듯 합니다!

Comment on lines +16 to +22
notification.type === "success"
? "bg-green-500"
: notification.type === "error"
? "bg-red-500"
: notification.type === "warning"
? "bg-yellow-500"
: "bg-blue-500"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분을 함수로 분리해서 사용하시면 어떨까요? 가독성이 더 개선될 것 같아요!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants