Skip to content

[4팀 이유진] Chapter 1-3. React, Beyond the Basics#32

Open
Elli-Lee wants to merge 17 commits into
hanghae-plus:mainfrom
Elli-Lee:main
Open

[4팀 이유진] Chapter 1-3. React, Beyond the Basics#32
Elli-Lee wants to merge 17 commits into
hanghae-plus:mainfrom
Elli-Lee:main

Conversation

@Elli-Lee
Copy link
Copy Markdown

@Elli-Lee Elli-Lee commented Jul 20, 2025

과제 체크포인트

배포 링크

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 에서 한 타입 단언이 안전한지 궁금합니다.

@eveneul
Copy link
Copy Markdown

eveneul commented Jul 21, 2025

우리 유진 님 파이팅 💪

@eveneul
Copy link
Copy Markdown

eveneul commented Jul 25, 2025

안녕하세요 공주님! 접니다! 퇴근 시간이 24분 가량 남아서.. 유진 공주님의 PR 보러 왔습니다 👀

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.

🐳 자랑하고 싶은 코드
모두 비슷하게 작성하셨을 useMemo일 테지만..!ㅋㅋㅋ
그래도 스스로 오래 고민하고 구현해서 마음에 듬니다아...

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

잘했습니다 공주님!!!
사실 이번 과제는 다 쪼끔씩 다르지 뭔가 구현 자체는 다 비슷비슷하더라구요 ㅋㅋㅋㅋㅋ

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

와아아👏👏👏
저는 다른거지만 저도 자랑하고 싶은 코드는 제가 찾아서 했다는데 의의둔 코드였습니다.

for (let i = 0; i < objA.length; i++) {
if (!deepEquals(objA[i], objB[i])) return false;
}
return true;
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.

🦐 고민되는 부분
팀원분들께서는 배열을 따로 처리해주셨는지 궁금합니다!

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

  // 배열인지 확인
  const isArrayA = Array.isArray(a);
  const isArrayB = Array.isArray(b);

  if (isArrayA !== isArrayB) return false;
  if (isArrayA && isArrayB) {
    // 길이 다르면 false
    if (a.length !== b.length) return false;

    // 배열의 각 요소를 재귀적으로 비교
    // 요소가 배열, 객체일 수도 있으니까
    for (let i = 0; i < a.length; i++) {
      if (!deepEquals(a[i], b[i])) return false;
    }

    return true;
  }

네! 배열 처리 했습니다!

Copy link
Copy Markdown

@nimusmix nimusmix Jul 26, 2025

Choose a reason for hiding this comment

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

저는 객체 비교하는 코드로도 커버 가능하다고 생각해서 하지 않았습니다ㅎㅎ
금요일 준일 코치님 공개 코드 리뷰 세션 금주 멘토링에서 배열을 따로 처리하면 더 간단하다?라고 말씀하셨던 것 같은데,
성능상 얼마나 차이가 날지도 궁금하네용!

export const deepEquals = (a: unknown, b: unknown) => {
  if (a === b) return true;

  if (!isObject(a) || !isObject(b)) return false;

  const aKeys = Object.keys(a);
  const bKeys = Object.keys(b);

  if (aKeys.length !== bKeys.length) return false;

  for (const key of aKeys) {
    if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
    if (!deepEquals(a[key], b[key])) return false;
  }

  return true;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

저도 하늘님과 비슷하게 적었습니다.변수만 다르고 동일하게 한...;;;

// undefined는 첫 렌더
// resultRef는 factory()의 실행결과 저장
const depsRef = useRef<DependencyList | undefined>(undefined);
const resultRef = useRef<T | undefined>(undefined);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

코치님께 리뷰 받고 싶은 내용에 하나의 객체로 두는 게 나을지, 아니면 각각 분리할지(지금 유진님처럼).. 이 있는데
저는 일단 합쳐서 넣었어요.

const cache = useRef({ deps: null, result: null });

나는 왜 이렇게 했는가? → 그냥 제 눈에 예뻐서입니다.

useRef를 두 번 호출하기보다는, 한 번에 호출하고,
같은 관심사 코드를 같이 묶어 두는 게 제 취향이에요. (변태 같죠? ㅋ)

코치님의 의견은 다르실 수도 있겠지만.. useRef를 두 번 호출한다고 해서 성능 차이가 있는 것도 아니고, 미미하대요.

곧 있을 클린 코드 & 디자인 패턴 & 관심사 분리 챕터에서 이런 내용이 나오지 않을까 기대되네요오. 🤔

// useSyncExternalStore 에서 활용할 수 있도록 subscribe 함수를 수정합니다.
const subscribe = (fn: Listener) => {
listeners.add(fn);
return () => unsubscribe(fn);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

const subscribe = (callback) => {
  // 구독 로직
  return () => {
    // 구독 해제 로직
  };
};

저는 return 함수에 바로 listeners.delete(fn);을 넣었습니다!
이유:

  1. unsubscribe 함수를 다른 데에서 쓸 일이 없을 것 같아서.
  2. useSyncExternalStore 훅이 그런 방식을 취하고 있어서.
  3. 직접 listeners.delete(fn)를 쓰면 함수 호출 오버헤드가 하나 줄어듦.
// useSyncExternalStore
function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

유진 님께서는 unsubscribe 함수를 어딘가에서 쓰일 수 있다 생각하여 return에 호출하신 건가요?!

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

저도 유진님처럼 unsubscribe를 지우지 않고 사용했는데,
보는 사람 입장에서 unsubscribe를 return하면
해당 함수가 구독 해제 함수를 return하는구나 라고 직관적으로 알 수 있을 것 같아서 그렇게 했어용!

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.

4 participants