Skip to content

[3팀 이호재] Chapter 1-3 React, Beyond the Basics #55

Open
kevinhojae wants to merge 25 commits into
hanghae-plus:mainfrom
kevinhojae:feat/project-3
Open

[3팀 이호재] Chapter 1-3 React, Beyond the Basics #55
kevinhojae wants to merge 25 commits into
hanghae-plus:mainfrom
kevinhojae:feat/project-3

Conversation

@kevinhojae
Copy link
Copy Markdown

@kevinhojae kevinhojae commented Apr 9, 2025

과제 체크포인트

배포 링크

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

기본 과제 checklist
  • shallowEquals 구현 완료
  • deepEquals 구현 완료
  • memo 구현 완료
  • deepMemo 구현 완료
  • useRef 구현 완료
  • useMemo 구현 완료
  • useDeepMemo 구현 완료
  • useCallback 구현 완료
심화 과제 checklist
  • 기본과제에서 작성한 hook을 이용하여 렌더링 최적화를 진행하였다.
  • Context 코드를 개선하여 렌더링을 최소화하였다.

과제 셀프회고

🤔 Design consideration and decisions

#useRef 구현

  • 과제를 시작할 때, useRef 구현시 useState를 사용하라고 힌트를 받았다.

    • 왜 useState를 사용해야 하고, 다른 구현 방식을 사용하지 않는 이유가 뭘까? 궁금해졌다.

    • useState 외 구현 접근

      approach 1. 상수를 이용해서 구현하기

      • 컴포넌트 함수 내부에서 단순 객체 선언: const ref = { current: initialValue }

      • 컴포넌트가 리렌더링될 때마다 함수가 재실행되면서 새로운 객체가 생성됨

      • 참조가 깨지고 값이 초기화되어 useRef의 핵심인 렌더링 간 값 유지가 불가능

      approach 2. 외부 변수를 활용한 구현

      • const refStore = new Map(); 를 두고 ref에 따라 값 업데이트하는 식으로도 생각해볼 수 있음

      • 하지만 React의 생명주기와 동기화되지 않음. 컴포넌트가 unmount되어도 refStore에는 데이터가 남아있고, 클린업 관리가 어려움

  • 왜 useState을 사용해야 할까?

    • 결론부터 말하면, useRef의 값을 react fiber 메모리에 묶어두기 위함이다.

    • useState를 사용해서 상태 값을 정의하게 되면 두 가지를 보장한다.

      1. React의 Hook linked list 시스템에 등록된다 → fiber의 memoizedState에 연결됨

      2. 생성된 상태 객체는 React가 관리하는 메모리 상에 저장된다 → 렌더링이 반복되어도 React가 해당 순서의 Hook의 값을 기억하고 있어서 초기화되지 않음

    • useRef를 사용하는 목적은 저장된 값은 유지하지만 리렌더링을 유발하지 않기 위함이다.

      • 결국 useRef 구현체에서 useState의 역할은 fiber 객체에 값을 등록해서 유지하는 것이고, current 에 초기값을 담아두는 형태로 구현하는 것이다!

#FSD architecture 적용

# 편의를 위해 barrel export를 위한 index.ts는 모두 제거
📦src
 ┣ 🗃️app
 ┃ ┣ 📂layout
 ┃ ┃ ┣ 📜AppLayout.tsx
 ┃ ┣ 📂model
 ┃ ┃ ┣ 📜ThemeProvider.tsx
 ┃ ┣ 📜App.tsx
 ┣ 🗃️features
 ┃ ┣ 📂auth
 ┃ ┃ ┗ 📂model
 ┃ ┃ ┃ ┣ 📜AuthProvider.tsx
 ┃ ┣ 📂notification
 ┃ ┃ ┗ 📂model
 ┃ ┃ ┃ ┣ 📜NotificationProvider.tsx
 ┃ ┗ 📂product
 ┃ ┃ ┗ 📂model
 ┃ ┃ ┃ ┣ 📜ProductProvider.tsx
 ┣ 🗃️pages
 ┃ ┣ 📂ui
 ┃ ┃ ┣ 📜MainPage.tsx
 ┣ 🗃️shared
 ┃ ┣ 📂equalities
 ┃ ┃ ┣ 📜deepEquals.ts
 ┃ ┃ ┗ 📜shallowEquals.ts
 ┃ ┣ 📂hocs
 ┃ ┃ ┣ 📜deepMemo.ts
 ┃ ┃ ┗ 📜memo.ts
 ┃ ┣ 📂hooks
 ┃ ┃ ┣ 📜useCallback.ts
 ┃ ┃ ┣ 📜useDeepMemo.ts
 ┃ ┃ ┣ 📜useMemo.ts
 ┃ ┃ ┗ 📜useRef.ts
 ┃ ┣ 📂tests
 ┃ ┃ ┣ 📜generateItems.ts
 ┃ ┃ ┗ 📜renderLog.ts
 ┃ ┣ 📂utils
 ┃ ┃ ┗ 📜isRecord.ts
 ┣ 🗃️widgets
 ┃ ┣ 📂auth-actions
 ┃ ┃ ┣ 📂ui
 ┃ ┃ ┃ ┣ 📜AuthActions.tsx
 ┃ ┣ 📂complex-form
 ┃ ┃ ┣ 📂model
 ┃ ┃ ┃ ┗ 📜useComplexForm.tsx
 ┃ ┃ ┣ 📂ui
 ┃ ┃ ┃ ┣ 📜ComplexForm.tsx
 ┃ ┣ 📂header
 ┃ ┃ ┣ 📂ui
 ┃ ┃ ┃ ┣ 📜Header.tsx
 ┃ ┣ 📂notification-system
 ┃ ┃ ┣ 📂ui
 ┃ ┃ ┃ ┣ 📜NotificationSystem.tsx
 ┃ ┃ ┃ ┣ 📜Notificationitem.tsx
 ┃ ┗ 📂product-list
 ┃ ┃ ┣ 📂lib
 ┃ ┃ ┃ ┗ 📜formatPrice.ts
 ┃ ┃ ┣ 📂model
 ┃ ┃ ┃ ┗ 📜useProductList.tsx
 ┃ ┃ ┣ 📂ui
 ┃ ┃ ┃ ┣ 📜ProductItem.tsx
 ┃ ┃ ┃ ┣ 📜ProductList.tsx
 ┃ ┃ ┃ ┣ 📜ProductSummary.tsx

항상 적용해보고 싶었던 feature-sliced design 아키텍쳐를 이번 프로젝트에 리팩토링하며 적용해보았다. 기본 layer인 app / features / pages / shared / widgets 로 구분지었고, slice에는 관심사별로 클러스팅하여 구분지었다. 그리고 segment로 model에는 비즈니스 로직을, ui에 도메인 컴포넌트를 두었다.

refs:

⚒️ What I learned

#useState deep dive

  • Motivation

    • 처음에는 useMemo, useRef와 마찬가지로 useState 자체를 구현해보고 싶었으나, useState를 구현하는 의의는 상태 업데이트에 따른 리렌더링 과정을 구현하는데 있다고 생각했다. 하지만 리렌더링 프로세스는 react의 fiber 구조와 너무 깊게 엮여있어서 멈추었다.

    • 리렌더링 없는 useState 구현은 값 또는 함수 전달받아서 내부 상태 업데이트해주는 로직을 위주로 구현하는 태스크일 것 같아 useState의 내부 동작 원리를 deep dive 해보기로 방향을 잡았다.

    • 구현 동작은 react/packages/react-reconciler/src/ReactFiberHooks.js at main · facebook/react 코드를 기반으로 디깅해보았다.

    🚀🚀🚀

  • Under the hood

    image

    • Mount / Update / Rerender로 분리

      • React는 useState를 3가지 상황에 따라 다르게 처리한다.

        1. mountState: 컴포넌트가 처음 렌더링될 때 상태 초기화

        2. updateState: 상태 업데이트 후 다시 렌더링될 때

        3. rerenderState: 렌더링 중 상태가 업데이트될 때 (렌더링 중 다시 setState)

        이들은 각각 상황에 맞는 dispatcher을 통해서 처리된다.

    • How it works

      1. mountState - 초기 상태 생성 및 fiber와 연결

        function mountState(initialState): [state, dispatch]
        • Implementation (​go to mountStateImpl)

          useState(initial) → mountState()
            ↳ mountWorkInProgressHook() → Hook 객체 생성
            ↳ dispatch 생성 (dispatchSetState.bind)
              ↳ 나중에 호출되면:
                ↳ 업데이트 객체 생성 → queue에 추가
                ↳ scheduleUpdateOnFiber 호출
          
          1. mountWorkInProgressHook() 을 호출해서 새로운 Hook 객체 생성

          2. 전달받은 initialState가 함수라면 호출해서 초기값 계산

          3. 1번에서 생성한 hook 객체의 memoizedState, baseState에 초기값 저장

          4. hook.queue에 이후 업데이트를 위한 queue 구조 생성

          5. 상태 변경 함수 (dispatchSetState) 생성 후 queue.dispatch에 저장

        • Insights

          • mountWorkInProgressHook (a)

            function mountWorkInProgressHook(): Hook {
              // 현재 컴포넌트의 Fiber에서 hook 리스트를 만들거나 이어줌
              const hook: Hook = {
                memoizedState: null,
                baseState: null,
                baseQueue: null,
                queue: null,
                next: null,
              }
              if (workInProgressHook === null) {
                // 첫 번째 hook 할당
                currentlyRenderingFiber.memoizedState = hook
                workInProgressHook = hook
              } else {
                // 기존 hook 뒤에 연결
                workInProgressHook = workInProgressHook.next = hook
              }
              return hook
            }

            새로운 hook을 할당하면서 현재 렌더링 중인 Fiber에 연결시켜주는 hook 연결리스트 생성기

            • 컴포넌트 함수가 useState를 호출할 때마다 hook 객체가 생성되고,

              type Hook = {
                memoizedState,  // 현재 상태
                baseState,      // 마지막 커밋된 상태
                queue,          // 업데이트 큐
                ...
              }

              Hook: 상태 저장과 업데이트 추적을 위한 linked list 기반 타입

            • 생성된 hook들이 현재 렌더링 중인 Fiber의 memoizedState 리스트로 연결된다.

          • lazy initialiation (b)

            // mountStateImpl 로직 중 초기값 계산
            if (typeof initialState === 'function') {
              const initialStateInitializer = initialState;
              // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
              initialState = initialStateInitializer();
              if (shouldDoubleInvokeUserFnsInHooksDEV) {
                setIsStrictModeForDevtools(true);
                try {
                  // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
                  initialStateInitializer();
                } finally {
                  setIsStrictModeForDevtools(false);
                }
              }
            }
            • Lazy initial state (React docs) 에도 lazy initialization 에 대한 내용이 명시되어 있다. 주요 요지는 useState의 초기값으로 함수를 전달해주면 초기 렌더링에서만 실행되어 lazy initialization이 된다는 것이다.

            • 그 이유를 mountState 구현체에서 찾을 수 있었다.

              mountState에서 initialState가 function 타입이면 전달받은 함수를 실행해서 초기값을 설정하는 로직이 있다.

              useState(compute())
              useState(() => compute()) // lazy initilization

              위 예시에서의 차이를 설명하자면, compute() 의 호출 시점의 차이에 있다.

              • useState(compute())

                컴포넌트가 렌더링될 때마다 compute() 가 즉시 실행되어 리액트가 useState를 처리하기 전에 이미 계산이 완료된다

              • useState(() => compute()) // lazy initilization

                함수 자체가 useState가 전달되어 바로 실행되지 않고, 실제로 hook이 마운트될 때 mountState를 통해 함수가 실행된다

              결국 연산 비용이 큰 로직이면 lazy initialization을 쓰는게 이득인 근본적인 이유를 알 수 있었다.

          • dispatchSetState (e)

            const dispatch: Dispatch<BasicStateAction<S>> =
              dispatchSetState.bind(null, currentlyRenderingFiber, queue)
            
            function dispatchSetState<S, A>(
              fiber: Fiber,
              queue: UpdateQueue<S, A>,
              action: A,
            ): void {
              // 우선순위에 따라 렌더링 스케쥴
              const lane = requestUpdateLane(fiber);
              const didScheduleUpdate = dispatchSetStateInternal(
                fiber,
                queue,
                action,
                lane,
              );
              if (didScheduleUpdate) {
                startUpdateTimerByLane(lane);
              }
              markUpdateInDevTools(fiber, lane, action);
            }

            setState(새로운 값)를 호출했을 때 업데이트 객체를 생성하고 큐에 넣는 함수로, dispatchSetStatefiberqueue가 바인딩된 상태로 생성됨

            그리고 dispatchSetStateInternal 에서

            • 업데이트 객체 생성

              const update = {
                lane, // 우선순위
                action, // 업데이트 함수 또는 값
                next: null
              }
            • 큐에 업데이트 객체 추가

              if (queue.pending === null) {
                // 최초 업데이트
                update.next = update
              } else {
                // 기존 업데이트 뒤에 연결
                update.next = queue.pending.next
                queue.pending.next = update
              }
              queue.pending = update
            • React에게 다시 렌더링 요청

              scheduleUpdateOnFiber(fiber, lane)

              이 호출로 인해 React는 해당 컴포넌트를 다음 렌더링 사이클에 포함시킴

      2. updateState - 상태 업데이트 처리 (표면적 흐름만 이해)

        function updateState(initialState): [state, dispatch]
        • Implementation (go to updateReducerImpl)

          function updateReducerImpl(hook, currentHook, reducer): [state, dispatch]
          1. 업데이트 queue 프로세싱

            • 이전 렌더링에서 처리되지 않은 queue.pending을 현재 처리중인 baseQueue 에 병합
          2. 업데이트 순회하며 실행

            • 각 업데이트의 action을 순회하면서 reducer를 통해 새로운 상태를 계산

              // Process this update.
              const action = update.action;
              if (shouldDoubleInvokeUserFnsInHooksDEV) {
                reducer(newState, action);
              }
              if (update.hasEagerState) {
                // If this update is a state update (not a reducer) and was processed eagerly,
                // we can use the eagerly computed state
                newState = ((update.eagerState: any): S);
              } else {
                newState = reducer(newState, action);
              }
            • 우선순위가 맞지 않으면 스킵하고, 다음 렌더링을 위해 복제

          3. 업데이트된 상태 저장

            • hook.memoizedState에 최종 업데이트된 상태 저장

            • hook.baseStatehook.baseQueue 업데이트

            • queue.lastRenderedState 업데이트

          이후 렌더링 스케쥴링 로직이 대해 더 깊게 이해해보고 싶다.

      3. rerenderReducer - render phase update

        function rerenderReducer(...)
        • 같은 렌더 사이클에서 setState 호출 시 실행

        • queue.pending을 처리하여 렌더링 중 상태 업데이트를 가능하게 함

        • 최종적으로 memoizedState를 최신 상태로 갱신

refs:

리뷰 받고 싶은 내용

  • fsd 아키텍쳐

    • entities

      from FSD 관점으로 바라보는 코드 경계 찾기

      entities: 데이터와 렌더링이 주 관심사일 때

      • 도메인이 확정되면 도메인 관련 코드를 분리해주면 좋습니다.

      • 특히 읽기 전용인 데이터와 계산 코드들은 분리를 해두면 아주 좋답니다. 그저 데이터!

      features: 사용자 행동과 데이터 변경이 주 관심사일 때

      • 주로 하나의 컴포넌트에 이벤트 핸들러가 2~3개가 되어 관심사가 복잡해져 컴포넌트가 복잡해진다면 분리하면 좋습니다.

      • 이벤트 핸들러는 props로 넘기지 말고 컴포넌트가 하도록 하는게 좋습니다. 단일 책임!

      지금 저는 인증 관련 (features/auth), 알림 관련 (features/notification), 프로덕트 관련 (features/product) 이 사용자 행동과 데이터 변경인 기능이라고 생각하여 features로 분리했습니다.

      그런데 이 중 product는 사용자 행동에 따른 아이템 추가가 일어나는 feature이라고 볼 수도 있지만, 프로덕트 자체는 데이터이니 entities라고 볼 수도 있을 것 같은데, 어떤 분리가 적절할까요?

      아직 layer 계층에서 features와 entities가 특히 헷갈려서 어떤 기준으로 분리하는걸 생각하면 좋을지 여쭤보고 싶습니다!

    • type 정의

      • 만약 type을 여러 곳에 걸쳐서 사용해야 하는 경우라면?

      • type들이 한 곳에 모여있지 않아서 불편하지 않을지?

      from Types | Feature-Sliced Design

      Resist the temptation to create a shared/types folder, or to add a types segment to your slices. The category "types" is similar to the category "components" or "hooks" in that it describes what the contents are, not what they are for. Segments should describe the purpose of the code, not the essence.

      fsd 공식 문서의 지침에 따라, type을 공통화해서 관리하지 않고 각 model에서 타입이 사용되는 곳에서 정의했습니다.

      // features/auth/model/AuthProvider.tsx
      export type User = {
        id: number;
        name: string;
        email: string;
      };
      
      type AuthContextType = {
        user: User | null;
        login: (email: string, password: string) => void;
        logout: () => void;
      };
      
      const AuthContext = createContext<AuthContextType | undefined>(undefined);

      그런데 이렇게 되니 드는 의문점이,

      • type 정의들이 모여있지 않아서 불편하지 않을지?

        그 동안 아래와 같은 패턴으로 type을 폴더 하나 위치에 정의해서 사용했었습니다.

        /types
          index.ts // 모든 타입 barrel export
          auth.ts // 인증 관련 타입
          notification.ts // 알림 관련 타입
      • 만약 type을 정의해야 하는 관심사가 한 파일이 아니라 여러 파일이라고 생각되면 어디에 정의해야 할지?

        예를 들어 type Canvas를 정의해야 하는 상황이고 model 폴더에 useCanvasDraw, useCanvasClick 훅이 있을 때 Canvas 타입은 두 훅 모두에게 해당되는데, 어디에 정의해야 할지?

        type Canvas {}
         features/canvas/model/useCanvasDraw.tsx
         features/canvas/model/useCanvasClick.tsx
  • 커스텀 훅 내부에서 useContext 사용 vs 커스텀 훅 외부에서 useContext 사용 후 prop 전달

    컴포넌트와 같이 커스텀 훅이 호출되는 곳에서 useContext를 사용하고, 커스텀 훅에 의존성으로 주입받는 패턴이 더 명시적으로 드러나서 좋을지?

    아니면 prop을 줄이면서 커스텀 훅 자체를 독립적으로 유지하기 위해 useContext를 커스텀 훅 내부에 사용하는게 좋을지?

    지금까지는 커스텀 훅 자체를 독립적으로 유지하기 위한 목적으로 useContext를 커스텀 훅 내부에서 호출해왔었는데, 사실 그렇게 되면 커스텀 훅을 사용하는 위치가 무조건 provider로 감싸져야 한다는 implicit한 의존성이 존재해서 더 좋은 패턴이 무엇인지 여쭤보고 싶습니다!

@kevinhojae kevinhojae changed the title Feat/project 3 [3팀 이정운] Chapter 1-3 React, Beyond the Basics Apr 9, 2025
@kevinhojae kevinhojae changed the title [3팀 이정운] Chapter 1-3 React, Beyond the Basics [3팀 이호재] Chapter 1-3 React, Beyond the Basics Apr 9, 2025
Comment thread src/app/layout/Layout.tsx Outdated
Comment on lines +17 to +18
<AuthProvider>
<Header />
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

개인적으로 궁금한 부분인데, Auth를 Layout단에 따로 넣은 이유가 있나용?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

아 auth 기능을 header에서만 사용하고 있어서 컨텍스트 범위를 최소화하기 위한 목적이었습니다!
사실 auth 기능은 일반적으로 앱 전반적으로 필요해서 provider를 전체에 감싸줘야 할텐데, 여기서는 Header 컴포넌트에서만 쓰이고 있길래 Provider 범위 줄이려고 Header에만 감싸줬어요

@unseoJang
Copy link
Copy Markdown

지금 보니까 진짜 미쳤네요; 휴

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