Skip to content

[2팀 이진희] Chapter 1-3. 프레임워크 없이 SPA 만들기#7

Open
bebusl wants to merge 21 commits intohanghae-plus:mainfrom
bebusl:main
Open

[2팀 이진희] Chapter 1-3. 프레임워크 없이 SPA 만들기#7
bebusl wants to merge 21 commits intohanghae-plus:mainfrom
bebusl:main

Conversation

@bebusl
Copy link

@bebusl bebusl commented Jul 19, 2025

과제 체크포인트

배포 링크

bebusl.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 개선

과제 셀프회고

평상시에 쓰던 훅/hoc를 일부 구현해보면서, 그 훅들의 원리에 대해 좀 더 잘 알수 있어서 좋았습니다.
useAutoCallback은 실제 코드에서도 요긴하게 쓰일만한 훅이어서 가져다가 써보려고 합니다.
과제 해결 자체는 빨리 했는데 글쓰기(PR/블로그 등)를 회피해서 또 PR쓰기를 미루고 미루고 미뤄서 자괴감이 들었습니다. 남은 항해를 진행하면서 회고를 제때 남기는 버릇 + 그때그때 생각난 글감을 한 곳에 아카이브하는 습관을 만들도록 노력해야할 것 같습니다.

학습 효과 분석 및 기술적 성장

  • Fiber에 대한 학습

    • useRef 구현하다 보니, 리렌더링이 될때 컴포넌트가 재실행되면서 컴포넌트 내부의 계산들은 다 새로 될텐데, useRef는 재실행됨에도 어떻게 다시 초기화되지 않고 이전 값을 유지할까? 그러고 보니 state에 대한 것도 어딘가엔 저장할텐데 어디에 저장되는거고 어떻게 관리되는 걸까 ?궁금증이 생겼습니다.
    • 이 궁금증을 해결하기 위해선 react 16부터 도입된 Fiber라는 아키텍처를 봐야했습니다..
      • fiber는 type, pendingProps, memoizedProps, memoizedState, stateNode, …으로 구성되어 있습니다.
      • 이 memoizedState에 연결 리스트 형식으로 useState, useReducer, useRef등의 hook 상태가 저장됩니다.
      • 재렌더링 시에 기존 fiber가 버려지는 것이 아니라, 기존 fiber트리를 참조해서 새로운 fiber 트리 구성합니다. → 결론적으로 이전 데이터를 기반으로 새 fiber가 만들어지므로 기존의 hook의 결과물들이 기억된다고 보면 됩니다.
      • fiber는 우리가 2주차 과제에서 구현했던 vNode와 구조적으로 비슷하게 대응된다고 보면 됩니다.(다만 fiber는 UI구조 뿐만 아니라 렌더링 상태, 업데이트 큐, hook 상태, 우선순위 같은 추가 정보까지 담겨있는 복합 구조체라는 차이는 있음.)
  • 메모이제이션 관련된 hook/hoc에 대한 이해도 상승, 다른 커스텀 훅을 만들 때도 좀 더 정확히 이해하고 활용할 수 있을 것 같습니다.

    • useCallback(fn, [])처럼 deps 없이 고정시키면 오래된 fn을 계속 참조해서 버그가 생길 수 있는데,

      useRef를 함께 쓰면 참조값은 유지하면서도 최신 함수 로직을 안전하게 반영할 수 있다는 깨달음을 얻었습니다.

      이 패턴은 특히 하위 컴포넌트에 콜백 props를 넘기거나, 외부 라이브러리 콜백 등록 시 유용할 것 같고 함수 재생성으로 인한 불필요한 렌더링 문제를 해결할 수 있어 실무에서 유용하게 사용할 것 같습니다.

  • pnpm을 이용한 모노레포 슬쩍 엿보기

    • 모노레포 구조를 슬쩍 엿본것만으로도 꽤 유익한 경험이었습니다.
    • 단순히 이론으로 보는 것보다, 실제 구조를 훑어보는 것만으로도 더 빠른 이해가 가능했던 것 같습니다. 역시 이론 + 실습이 최고라는 걸 깨닫고 다른 공부할 때도 너무 이론에만 몰두하지 않도록 해야겠단 생각이 들었습니다.

자랑하고 싶은 코드

equals함수를 구현할 때 object형식인지 판별해야하는 곳이 있었는데, parameter자체는 unknown으로 정의되어 있었습니다.

이 부분을 typescript의 is 키워드를 이용해 좀 더 안전하게 타입 좁히기를 하면 좋겠다는 생각을 하였고, 아래와 같이 적용했습니다.

// 타입가드 함수
export const isObject = (a: unknown): a is { [k: PropertyKey]: unknown } => {
  return a !== null && typeof a === "object";
};

// 적용부
const equals = (a:unknown, b:unknown) => {
   const isAObject = isObject(a);
   const isBObject = isObject(b);

   if (isAObject && isBObject) {
    ...  안에서 a와 b는 {[k:PropertyKey]:unknown}으로 추론됨
   }
}

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

마찬가지로 equals함수 관련된 부분이었는데요,
shallowEquals, deepEquals 둘이 코드 베이스가 거의 똑같은데 그 부분을 따로 추출하여 쓰면 더 좋았을 것 같습니다.

(memo → deepMemo같은 구조로 갈 수 있었으면 좋았을 것 같습니다)

과제 피드백

hook/hoc 구현을 하는데 흐름이 자연스럽게 짜여있어서 이해하기에 좋았던 것 같습니다. 다음 hook은 어떻게 만들면 되겠구나, 저건 어떤 개념을 활용하면 되겠구나!가 자연스럽게 떠오르게 흐름이 잘 구성된 것 같습니다.

학습 갈무리

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

  1. 컴포넌트 함수를 실행한다.
    • createElement가 호출되고 가상DOM트리에 필요한 정보를 가진 ReactElement가 만들어집니다.
  2. 가상 DOM 생성 및 비교(Render단계, Reconciliation)
    • 가상 DOM 트리를 구성합니다.
    • Diffing 알고리즘을 통해 어떤 부분이 바뀌었는지 확인하고 변경된 부분만 추려냅니다.
  3. 실제 돔 업데이트를 한다.(Commit 단계)
    • 변경된 부분을 브라우저 DOM에 실제로 반영합니다.
      • 변경된 부분 리스트(Effect list)를 순회하면서 실제 DOM 조작을 실행합니다.
      • 이 때 어딜 바꿔야 할 지 이미 정해져있으니 효율적인 DOM 조작이 가능해집니다.
    • useEffect나 ref 등도 이 시점에 실행이 됩니다.

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

다른 곳에서도 성능 최적화를 위해 사용될 수 있겠지만 실시간성이 중요한 서비스, 예를 들어 라이브 방송처럼 여러 명의 유저가 동시에 상호작용하며 UI가 빈번하게 바뀌는 환경에서는 메모이제이션이 매우 효과적으로 활용될 수 있을 것 같습니다.

실시간 동영상 스트리밍과 같은 경우에 초 단위로 UI 갱신이 발생하므로 동일한 입력에 대해 중복 렌더링이나 연산을 피하는 것이 성능 유지에 중요합니다.

따라서 사용자 상호작용이 잦고 렌더링이 빈번히 발생하는 실시간 서비스에서 메모이제이션을 적극 활용하면 성능을 안정적으로 유지하는 데 큰 도움이 된다고 생각합니다.

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

컨텍스트는 상태나 데이터를 관심사에 따라 분리해서 저장해두는 공간이라고 생각합니다.(예시: 테마 정보는 ThemeContext에, 사용자 정보는 UserContext))

컨텍스트를 이용하면 전역적인 정보를 한 곳에서 제공할 수 있어서 불필요한 props drilling을 막을 수 있습니다. 만약 컨텍스트를 사용하지 않고 일일이 단계별로 데이터를 내려준다면 추적이 매우 힘들어지고 코드도 복잡해질 것입니다.

상태관리는 UI에 영향을 주는 여러 사용자 상호작용이나 비동기 데이터 변화를 효과적으로 관리하는 작업이라고 생각합니다. 상태가 바뀔 때마다 UI가 원하는 대로 정확하게 반영되어야 하기 때문에 상태를 어디에, 어떻게 저장할지에 대해 정하는 것가 매우 중요하다고 생각합니다.

또한 상태관리는 단순히 값을 저장하는 것뿐만 아니라, 상태 변경의 흐름을 명확하게 만들고 예측 가능하게 만드는 과정이라는 점에서 개발자의 편의를 위한 중요한 작업입니다.

결국 우리가 직접 다루는 코드는 사람이 읽고 유지보수하는 것이기 때문에, 유지보수하기 쉽고 읽기 좋은 코드가 가장 중요하다는 생각입니다. 대부분의 기술이나 방법론은 바로 이 목표를 달성하기 위한 도구일 뿐이라고 느끼게 되었습니다.

리뷰 받고 싶은 내용

유틸 함수 분리 기준과 테스트 가능성 사이의 균형

유틸 함수를 작성할 때, 저는 150줄 미만 정도의 함수라면 굳이 세세하게 나누기보다는 기능 단위로 한 파일에 모아두는 방식을 선호하는 편입니다. 그런데 이번에 equals 함수를 직접 구현해보면서, 타입에 따른 분기 처리를 한 함수 안에서 모두 처리했는숩니다.
shallowEquals나 deepEquals 파일을 보시면 어레이 타입일때, 객체일때, 원시타입잍 때의 로직 모두 그냥 shallowEquals,deepEquals 안에 작성 되어 있는데 각 타입별 로직을 분리한 다음 equals함수에서 조립하는 식으로 분리하는 편이 더 좋았을 것 같기도 합니다.
근데 이런 조그마한 함수들이 너무 많아지면 오히려 관리가 더 힘들지 않을까하는 생각이 동시에 들기도 합니다.

코치님은 이런 경우, 어디까지를 "분리할 만한 단위"로 보시는지 의견이 궁금합니다.

명확함을 위해 긴 네이밍을 쓰는 게 좋을까요?

저는 변수나 함수 이름을 지을 때, isEnabledByUserAction, fetchPostsByCategoryId처럼다소 길어지더라도 형용사나 전치사(by 등)를 붙여서 더 명확하게 표현하는 걸 선호하는 편입니다. 그런데 코드 리뷰를 하다 보면, 길다는 이유로 이름을 더 간결하게 바꾸자는 피드백을 받는 경우도 있어서 고민됩니다. 코치님은 협업 코드에서 가독성과 간결성 사이의 네이밍 밸런스를 어떻게 잡으시나요? 그리고 긴 이름이더라도 명확하면 괜찮다고 보는지, 혹은 더 좋은 네이밍 전략이 있는지 궁금합니다.

@bebusl bebusl changed the title [2팀 이진희] [2팀 이진희] Chapter 1-3. 프레임워크 없이 SPA 만들기 Jul 19, 2025
Copy link

@devchaeyoung devchaeyoung left a comment

Choose a reason for hiding this comment

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

주석 덕분에 코드가 잘 읽혔던 것 같아요! 이번주차 너무너무 고생하셨습니다!! PR작성까지 화이탱!!

return a === b;
import { isObject } from "../utils/typeGuards";

export const deepEquals = (a: unknown, b: unknown): boolean => {

Choose a reason for hiding this comment

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

deepEquals.ts와 shallowEquals.ts는 로직이 일관성있어서 가독성이 좋은 것 같습니다!
혹시 더 유지보수성을 높이고 싶으시다면, 공통 로직을 baseEquals와 같은 함수로 분리해서,
비교하는 인자만 넘기는 방식으로 리팩토링해보는 것도 추천드려요 !

예를 들면

type CompareFn = (a: unknown, b: unknown) => boolean;

function baseEquals(
  a: unknown,
  b: unknown,
  valueCompare: CompareFn // 값 비교 전략 (===, deepEquals 등)
): boolean {
  const isAObject = isObject(a);
  const isBObject = isObject(b);

  if (!isAObject && !isBObject) return Object.is(a, b);
  if (Array.isArray(a) !== Array.isArray(b)) return false;

  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) return false;
    return a.every((item, idx) => valueCompare(item, b[idx]));
  }

  if (isAObject && isBObject) {
    const aKeys = Object.keys(a);
    const bKeys = Object.keys(b);
    if (aKeys.length !== bKeys.length) return false;
    for (const key of aKeys) {
      if (!valueCompare(a[key], b[key])) return false;
    }
    return true;
  }

  return false;
}

이렇게 baseEquals가 있다고 했을 때

export const shallowEquals = (a: unknown, b: unknown) =>
  baseEquals(a, b, (x, y) => x === y);

export const deepEquals = (a: unknown, b: unknown): boolean =>
  baseEquals(a, b, deepEquals);

이런 식으로 사용할 수 있을 것 같아요!!

Copy link

@soyalattee soyalattee left a comment

Choose a reason for hiding this comment

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

LGTM! 🥇

Comment on lines +1 to +4
const isObject = (a: unknown): a is Record<string, unknown> => {
return a !== null && typeof a === "object";
};

Choose a reason for hiding this comment

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

오, 저는 if문에서 처리했는데, 이렇게 함수로 뺀거 좋은거같아용 👍

// useState를 사용하여 상태를 관리하고, shallowEquals를 사용하여 상태 변경을 감지하는 훅을 구현합니다.
return useState(initialValue);
const [state, _setState] = useState<T | undefined>(initialValue);
const curState = useRef<T>(state); //setState 메모이제이션용. state를 의존해서는 안됨. -> 매번 리렌더링 됨

Choose a reason for hiding this comment

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

useRef 를 사용해 state를 저장한 이유가 매번 리렌더링 되는걸 막기 위해서인가요!? 매번 리렌더링이 되는 이유가 궁금합니다!

Copy link
Author

Choose a reason for hiding this comment

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

제가 만든 setState 함수에서 state를 직접 참조하면, state가 변경될 때마다 의존성 배열에 state가 들어가게 되고, 그 결과 state가 업데이트될 때마다 setState가 매번 새로 생성됩니다.
그래서 useRef를 사용해 상태를 관리함으로써 setState 함수가 불필요하게 재생성되는 걸 막고 동시에 최신 상태에 안정적으로 접근할 수 있게 했습니다.

제가 '리렌더링'이라고 표현을 좀 잘못 사용한 것 같아요!
리렌더링을 막기 위함이 아니라 setState함수가 매번 재생성되는 걸 막기 위해 ref를 썼다고 보심 될 것 같아요

Copy link

@nemobim nemobim left a comment

Choose a reason for hiding this comment

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

꼼꼼함이 느껴지는 코드였습니다..굿굿

"pnpm": ">=10"
},
"type": "module",
"homepage": "https://bebusl.github.io/front_6th_chapter1-3/",
Copy link

Choose a reason for hiding this comment

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

오.."homepage" 기능도 있나요?!?

Copy link
Author

@bebusl bebusl Jul 24, 2025

Choose a reason for hiding this comment

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

앗 별다른 기능이 있는건 아닌데 gh-pages로 배포할 때 homepage 적어주면 리소스 경로 오류 방지해준대서 넣어뒀어요

gpt에 따르면 이렇다네요; 저도 그냥 버릇처럼 넣은거라 빼고 한 번 확인해봐야겠어요

gh-pages로 배포할 때 꼭 package.json에 homepage 필드를 추가해야 하는 건 아니지만, 보통은 추가하는 게 좋습니다. 이유는 다음과 같아요:

1. homepage 필드 역할

React 같은 프레임워크에서 빌드할 때 빌드된 결과물 내에서 사용되는 경로(base URL)를 지정해줍니다.

예를 들어, GitHub Pages에서 보통 주소가 https://username.github.io/repo-name/ 형태인데,
기본 설정은 / (루트)로 인식해서 리소스 경로가 깨질 수 있어요.

homepage를 "https://username.github.io/repo-name" 또는 "/repo-name" 으로 지정하면, 빌드 결과 내에서 JS, CSS, 이미지 등의 경로가 정확히 맞춰집니다.

2. homepage 안 쓰면?

기본값이 /이기 때문에, index.html이 다른 경로에 있어도 리소스를 못 찾는 문제가 발생할 수 있습니다.

그래서 배포 후 화면이 깨지거나, 리소스 로딩 실패 오류가 나곤 해요.

3. 결론

GitHub Pages에서 프로젝트를 하위 경로(/repo-name)로 배포할 경우에는 homepage를 꼭 지정하는 것이 실수 방지에 좋습니다.

만약 도메인 최상위 루트(예: https://username.github.io/)로 배포한다면, homepage 필드를 생략해도 무방합니다.

const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]);

const hideAfter = debounce(hide, DEFAULT_DELAY);
const showWithHide = useCallback<ShowToast>(
Copy link

Choose a reason for hiding this comment

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

useAutoCallback을 쓰라는 힌트를 받아서 쓰긴했는데 useCallback으로도 가능하군요,,!!

Comment on lines +7 to +8
// 둘다 오브젝트 타입 아니면 값비교
if (!isAObject && !isBObject) return Object.is(a, b);
Copy link

Choose a reason for hiding this comment

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

console.log(Object.is("1", 1));
// Expected output: false

console.log(Object.is(NaN, NaN));
// Expected output: true

console.log(Object.is(-0, 0));
// Expected output: false

const obj = {};
console.log(Object.is(obj, {}));
// Expected output: false

이런 기능도 있군요 배워갑니다.

Comment on lines +17 to +23
return renderedComponent;
}

return memoizedComponent.current;
};

return renderMemoizedContent(props);
Copy link

Choose a reason for hiding this comment

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

셋다 같은걸 반환하는데 각기 다르게 표현이 된 느낌이라 리턴을 줄이고 통일시켜도 좋을거같습니다!
ex

return renderMemoizedContent: FunctionComponent<P> = (props) => {
      if (!propsRef.current || !equals(propsRef.current, props)) {
        propsRef.current = props;
        memoizedComponent.current = Component(props);
      }
      return memoizedComponent.current;
    };

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