Elli-Lee 님의 상세페이지[4팀 이유진] Chapter 1-3. React, Beyond the Basics

과제 체크포인트

배포 링크

https://elli-lee.github.io/front_6th_chapter1-3/

기본과제

equalities

  • shallowEquals 구현 완료
  • deepEquals 구현 완료

hooks

  • useRef 구현 완료
  • useMemo 구현 완료
  • useCallback 구현 완료
  • useDeepMemo 구현 완료
  • useShallowState 구현 완료
  • useAutoCallback 구현 완료

High Order Components

  • memo 구현 완료
  • deepMemo 구현 완료

심화 과제

hooks

  • createObserver를 useSyncExternalStore에 사용하기 적합한 코드로 개선
  • useShallowSelector 구현
  • useStore 구현
  • useRouter 구현
  • useStorage 구현

context

  • ToastContext, ModalContext 개선

과제 셀프회고

이번 과제가 1주차 세번의 과제 중 정답과 방향이 가장 명확해서 비교적 수월했기 때문에, 왜 이렇게 동작하도록 함수를 작성해야 하는지를 명확히 이해하고자 노력했고, 각 함수에서 처리해야할 로직들을 AI 도움을 최소화해서 구현하려고 노력했습니다 .리액트의 여러 훅들을 직접 구현하면서 훅들의 동작 원리를 알 수 있었고, 리액트가 무엇을 해결하고자 했는지가 조금씩 느껴졌습니다. 3주간 프레임워크 없이 SPA 만들기를 진행하면서 SPA 프레임워크의 동작 원리를 어느정도 알고있다고 생각했는데 알고있기는 커녕 저는 여태껏 궁금해 한 적 조차 없었다는 사실을 깨달았고, 자바스크립트 실력이 많이 부족하다는 것도 느꼈습니다. 저의 부족함을 많이 알게된 3주였고, 제 과제의 결과물이 제 스스로도 만족할 만큼의 수준은 아니지만(특히 1주차 과제..시간되는대로 꼭 다시 도전해보고 싶어요..), 3주간의 몰입이 돌아보니 정말 재밌었고, 개인적으로는 많이 성장했다고 생각합니다!

기술적 성장

** Equalities 구현 과정에서 ** 어떤 타입을 먼저 처리해야 하는지, 각 분기 처리를 거칠 때마다 어떤 타입으로 좁혀지는지, 잘못 처리된 타입은 없는지 신경쓰며 구현했습니다. 이 과정에서 typeof null은 object라는 사실을 처음 알게 되어 object 타입을 처리하기 전 null을 먼저 처리해주었습니다.

** useRef 구현 과정에서 ** 어떻게 내부적으로 useState를 사용하는데 리렌더링을 발생시키지 않을 수 있을지 이해하는데 시간이 걸렸습니다.

useState는 초기화 시에만 객체를 생성하고 이후 리렌더링에서는 동일한 객체 참조를 반환하고, useState의 setter를 호출하지 않는 한 리렌더링이 발생하지 않는다. React는 객체 내부 프로퍼티 변화(current의 변화)를 감지하지 못한다 (얕은 비교!) 는 점을 알게 되었습니다.

useState의 구조분해 할당으로 state만 받고 setter 함수는 아예 안받는 이유가 궁금했는데, setter가 리렌더링을 유발하기 때문에 useRef에서는 필요없어서 안 받았다는 아주아주 당연한 사실도 새삼 알게 되었습니다.. 또한, 테스트 코드를 통해 useRef가 수행해야하는 결과를 이해하고자 노력했는데, 중복을 걸러주는 Set 자료구조를 사용해서 Set의 size를 통해 리렌더링 시 참조가 변했는지를 체크하는 점이 인상깊었습니다.

** useMemo 구현 과정에서 ** useMemo를 구현하면서 궁금했던 부분은 왜 deps를 깊은비교가 아닌 얕은 비교로 수행하는지 였습니다. 찾아본 결과, 깊은 비교는 비용이 너무 크다! 만약 deps를 깊은 비교(deep equality)로 검사하려면: 배열의 각 요소가 객체일 경우 그 안의 속성까지 전부 비교해야 하는데, 이건 성능 비용이 크고, 특히 렌더링마다 비교하게 되면 전체 앱의 성능이 떨어질 수 있기 때문임을 알게 되었습니다.

** useCallback 구현 과정에서 ** 리액트를 제대로 사용해본 적이 없는 저는... React.memo로도 충분할것 같은데 왜 useCallback이 필요한지 궁금했습니다. 핵심은 함수도 결국 객체이기 때문에 그 함수를 가지고 있는 부모컴포넌트가 리렌더 될 때마다 다시 생성된 새 함수가 되어 참조값이 달라지기 때문이었습니다. 자식 컴포넌트에 memo가 적용되어 있어도, 부모 컴포넌트가 자식 컴포넌트에게 함수를 전달하고 있는 경우, 부모컴포넌트가 리렌더링 될 때마다 함수의 참조값이 바뀌므로 자식컴포넌트는 props가 바뀌었다고 판단하기 때문에 memo와 관계없이 리렌더링되기 때문임을 알게 되었습니다.

자랑하고 싶은 코드

자랑할 만한 코드를 찾는것... 정말 어려운 일입니다..🥹 그나마 찾아보자면.. 코드적으로 자랑하고 싶다기 보다는.. useMemo의 동작을 이해하기 위해 오래 고민하고 공부하고 스스로 구현했다는 점에서 당첨되었습니다.

export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {

  // 1. 이전 의존성과 결과를 저장할 ref 생성
  // undefined는 첫 렌더
  // resultRef는 factory()의 실행결과 저장
  const depsRef = useRef<DependencyList | undefined>(undefined);
  const resultRef = useRef<T | undefined>(undefined);

  // 2. 현재 의존성과 이전 의존성 비교
  // 초기 렌더링이거나, 이전 의존성과 현재 의존성이 다르면 새로 메모이제이션 -> factory() 실행
  // 3. 의존성이 변경된 경우 factory 함수 실행 및 결과 저장
  if (depsRef.current === undefined || !_equals(depsRef.current, _deps)) {
    depsRef.current = _deps;
    resultRef.current = factory();
  }

  // 4. 메모이제이션된 값 반환
  return resultRef.current as T;
}

개선이 필요하다고 생각하는 코드

deepEquals에서

  // 둘다 객체인 경우
  // 배열인지 확인
  if (Array.isArray(objA) && Array.isArray(objB)) {
    if (objA.length !== objB.length) return false;
    for (let i = 0; i < objA.length; i++) {
      if (!deepEquals(objA[i], objB[i])) return false;
    }
    return true;
  }

객체 처리할 때 배열을 먼저 별도로 처리했는데요, 구현할 때는 배열을 별도로 분기처리 안했더니 테스트코드를 통과하지 못해서 분기처리를 했었는데,, 과연 정말 필요한 분기 처리였을까, 분기처리의 문제가 아니라 기존 로직 자체에 문제가 있었을 수도 있겠다하는 생각이 듭니다.

학습 효과 분석

** 가장 큰 배움이 있었던 부분 ** React의 렌더링 최적화 메커니즘을 이해하게 되었습니다. 특히 의존성 배열의 비교 방식과 메모이제이션의 실제 동작 원리를 알 수 있었습니다.

** 추가 학습이 필요한 영역 ** 복잡한 타입스크립트 이슈가 발생하면 타입 단언으로 처리하거나 타입 오류를 해결해달라고 AI에게 요청..해서 해결했는데, 이에 대한 추가적인 학습이 필요할 것 같습니다. 개인적으로는 과제를 다 진행하고 나니, 실무에서 사용하고 있는 Vue의 동작 원리와 내부 구현도 궁금해졌습니다.

과제 피드백

앞에서 구현한 함수를 그 다음 함수를 구현하는데 사용하도록 설계되어 왜 이렇게 동작해야 하는지를 명확히 이해할 수 있어서 좋았습니다. 또한 실제 React 내부 구현과 유사한 방식으로 설계되어 리액트 deep dive 경험을 할 수 있어서 좋았습니다.

학습 갈무리

리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.

리액트의 렌더링 과정: 리액트는 상태(state)나 props가 변경되었을 때 컴포넌트를 다시 렌더링합니다.

  • 트리거 > setState 혹은 부모 컴포넌트로부터 전달받은 props가 변경되면 해당 컴포넌트가 다시 렌더링됩니다.
  • 렌더 > JSX를 기반으로 새로운 Virtual DOM을 생성합니다.이 과정은 순수 함수처럼 작동하며, 화면에 아무것도 그리지 않습니다.
  • 조정 (Reconciliation) > diff 알고리즘을 사용해서 이전 Virtual DOM과 새로운 Virtual DOM을 비교하여 변경점을 찾습니다.
  • 커밋 > 변경된 부분만 실제 브라우저 DOM에 적용합니다.

리액트의 렌더링 최적화 방법:

  • useMemo > 무거운 연산 결과를 캐싱해서, 의존성이 변경되지 않으면 다시 계산하지 않습니다.
  • useCallback > 함수를 메모이제이션하여, 불필요하게 새로운 함수 인스턴스를 생성하지 않도록 합니다. 자식 컴포넌트에 함수를 props로 넘길 때 유용합니다.
  • React.memo > 컴포넌트를 메모이제이션하여, props가 바뀌지 않으면 리렌더링하지 않도록 합니다.

메모이제이션에 대한 나의 생각을 적어주세요.

메모이제이션이 필요한 경우:

  • 비용이 큰 계산이 반복될 때 > 예를 들어, 무거운 연산을 수행하는 함수가 렌더링마다 실행된다면 useMemo를 사용해 계산을 캐싱할 수 있습니다.
  • 자식 컴포넌트의 불필요한 리렌더링을 방지할 때 > React.memo를 통해 props가 변경되지 않았을 때 자식 컴포넌트의 리렌더링을 막을 수 있습니다.

장점:

  • 성능 최적화 > 불필요한 계산, 불필요한 컴포넌트 렌더링을 방지할 수 있습니다.
  • 예측 가능한 렌더링 > 의존성 배열을 명시함으로써, 어떤 조건에서 계산이 다시 수행되는지 명확해집니다.

단점:

  • 메모리 사용량 증가 > 캐시된 값을 메모리에 보관하므로, 리소스를 추가로 사용하게 됩니다.
  • 복잡성 증가 > 로직을 분석할 때 메모이제이션된 값을 따로 추적해야 하는 경우가 생깁니다.

제가 생각하는 사용법:

  • 성능 문제가 실제로 발생했을 때 적용하기...? useMemo는 언제 사용하면 좋을지 조금 감이 오는 것 같은데, React.memo는 언제 적용해야 할지 판단이 어렵습니다...
  • 만약 메모이제이션을 사용한다면 의존성 배열을 정확히 관리할 것

컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.

컨텍스트와 상태관리가 필요한 이유:

  • React는 기본적으로 단방향 데이터 흐름을 갖기 때문에, 상위 컴포넌트에서 하위 컴포넌트로 props를 계속 전달해야 합니다. 여러 컴포넌트에서 공통으로 사용하는 전역 상태가 생겼을 때 Context API나 상태 관리 라이브러리를 통해 상태를 전역으로 공유할 수 있습니다.

컨텍스트와 상태관리를 사용하지 않으면 발생하는 문제:

  • Prop drilling: 중간에 쓰지도 않는 컴포넌트들이 props를 전달만 하게 됩니다
  • 상태의 일관성 문제: 여러 컴포넌트가 동일한 데이터를 따로따로 관리하면 서로 동기화가 되지 않아 UI가 일관되지 않게 됩니다.

사용했을 때의 장점:

  • 전역적으로 상태를 공유할 수 있음: 여러 컴포넌트에서 동일한 상태를 쉽게 참조하고 수정할 수 있습니다.
  • 구조가 간결해짐: 중간 단계 컴포넌트에서 props를 전달할 필요가 없어지고, 로직이 분리되어 코드가 더 깔끔해집니다.

사용했을 때의 단점:

  • 렌더링 성능 이슈: Context의 값이 바뀌면 해당 컨텍스트를 구독하고 있는 모든 컴포넌트가 리렌더링됩니다.
  • 남용 시: 모든 상태를 Context로 관리하면, 흐름 추적이 어려워지고 디버깅이 힘들 수 있습니다.

사용 시 주의할 점:

  • 진짜 전역 상태만 Context로 관리할 것: 예를 들어 다크모드 설정이나 로그인 정보처럼 앱 전반에 영향을 주는 상태만 Context로 사용하는 것이 좋습니다.

리뷰 받고 싶은 내용

  1. 동작에는 아무런 차이가 없겠지만, useMemo에서 의존성 배열에 대한 depsRef와 factory 실행 결과를 저장하는 resultRef를 하나의 객체로 두는게 더 좋은 구조일지에 대한 고민을 했습니다. 저는
  const depsRef = useRef<DependencyList | undefined>(undefined);
  const resultRef = useRef<T | undefined>(undefined);

이렇게 별도로 두긴 했는데요, (이유는.. 객체로 다루는 것이 비교 등등에서 신경 쓸 포인트가 늘어날 수도 있겠다...는 생각이었습니다)

	const memoRef =  useRef<{ deps: DependencyList | undefined; result: T | undefined }>({
    deps: undefined, // 이전 의존성
    result: undefined, // 결과
  });

이렇게 하나의 객체로 두는 것이 더 나은 구조인지, 코치님께서는 어떤 방식을 선호하시는지 궁금합니다.

  1. 타입스크립트가 최대한 알아서 추론하게 두고, 타입 단언은 지양해라! 라는 내용을 늘 생각하면서 개발하고자 하는데요, 과제 구현 과정에서 타입 이슈 해결을 위해서 타입 단언을 사용한 부분이 꽤 있습니다. 이 중에서 특히 useMemo의 return 에서 한 타입 단언이 안전한지 궁금합니다.

과제 피드백

안녕하세요 유진님! 3주차 과제 잘 진행해주셨네요 ㅎㅎ 고생하셨스비낟!!

3주간 프레임워크 없이 SPA 만들기를 진행하면서 SPA 프레임워크의 동작 원리를 어느정도 알고있다고 생각했는데 알고있기는 커녕 저는 여태껏 궁금해 한 적 조차 없었다는 사실을 깨달았고, 자바스크립트 실력이 많이 부족하다는 것도 느꼈습니다.

"여태껏 궁금해한 적 조차 없었다는 사실"이 인상적이네요 ㅋㅋ 언젠간 왜 이렇게 동작하는거지!? 라는 근원적인 궁금함을 느낄수 있으리라 생각해요!

useMemo에서 의존성 배열에 대한 depsRef와 factory 실행 결과를 저장하는 resultRef를 하나의 객체로 두는게 더 좋은 구조일지에 대한 고민을 했습니다. 하나의 객체로 두는 것이 더 나은 구조인지, 코치님께서는 어떤 방식을 선호하시는지 궁금합니다.

저는 하나의 객체를 통해 관리하는게 더 좋지 않을까 싶어요! 사실 몇 개의 ref로 관리하든 큰 차이가 없긴 한데, 중요한건 사용성이라고 생각합니다. 하나의 ref로 관리해야 객체를 다루기가 더 쉽지 않나!? 라는 생각이랍니다.

타입스크립트가 최대한 알아서 추론하게 두고, 타입 단언은 지양해라! 라는 내용을 늘 생각하면서 개발하고자 하는데요, 과제 구현 과정에서 타입 이슈 해결을 위해서 타입 단언을 사용한 부분이 꽤 있습니다. 이 중에서 특히 useMemo의 return 에서 한 타입 단언이 안전한지 궁금합니다.

지금은 최선의 작업을 해주신 것 같아요. 물론 타입이 자연스럽게 추론되면 좋지만... 그렇지 못한 상황도 분명 있으니까요!