과제 체크포인트
배포 링크
https://ldhldh07.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 개선
과제 셀프회고
기존의 과제들도 마찬가지였지만, 이번 과제도 한번쯤 해보고 싶었던 작업이었습니다.
개인적으로 효과가 좋다고 생각하는 학습방식이 있습니다. 원리를 다 파악하고 실제로 활용하는 것보다는, 일단 원리도 모르는 상태에서 다들 하는대로 적용하고 보는 것입니다. 그렇게 익숙해질 즈음에 다시 이론적인 개념을 파악하면 그 때 이해가 쏙쏙 됩니다.
리액트도 많이 써왔으면서도 그 코어 작동 로직이나, 구체적인 동작에 대해서 모르고 있던 것이 사실입니다. 조금 늦긴 했지만 그래도 적절한 타이밍에 살짝 리액트를 파고들어서 공부할 수 있는 기회였습니다. 실제로 써왔던 것들이라 그 로직들이 이해가 됐고, 앞으로 더 높은 이해도로 리액트를 사용할 수 있겠다 싶었습니다.
기술적 성장
useRef
특히 그 원리를 모르고 그냥 쓰고 있던 대표적인 훅이 이 useRef였습니다. 그냥 DOM 조작할 때 쓰는구나 싶어서 루틴대로 사용하던 훅입니다.
const divRef = useRef(null);
<div ref={divRef}>
// divRef.current에 DOM이 할당됨
그러던중 이런 방식으로 작성된 코드를 봤습니다.
const Provider = ({ children }: PropsWithChildren) => {
const [isOpen, setIsOpen] = useState(false);
...
const contentRef = useRef<ReactNode>(null);
const bottomSheetOptionsRef = useRef<BottomSheetOptions>({});
const open = useCallback(({ content, options }: BottomSheetConfigs) => {
contentRef.current = content;
bottomSheetOptionsRef.current = options || {};
setIsOpen(true);
}, []);
...
return (
<context.Provider value={memoizedValue}>
{children}
{isOpen && (
<BottomSheet
open={isOpen}
{...bottomSheetOptionsRef.current}
onOpenChange={setIsOpen}
>
{contentRef.current}
</BottomSheet>
)}
</context.Provider>
);
};
useRef로 값을 저장하는 듯한 이 활용법은 어색하게 다가왔습니다.
이 때 이해못했던 것을 과제에서 useRef를 구현하고 그 동작 목적에 맞게 활용하면서 이해가 됐습니다.
먼저 공식문서에서 useRef에 대해
useRef는 처음에 제공한 초기값으로 설정된 단일 current 프로퍼티가 있는 ref 객체를 반환합니다. 다음 렌더링에서 useRef는 동일한 객체를 반환합니다. 정보를 저장하고 나중에 읽을 수 있도록 current 속성을 변경할 수 있습니다. state가 떠오를 수 있지만, 둘 사이에는 중요한 차이점이 있습니다. ref를 변경해도 리렌더링을 촉발하지 않습니다. 즉 ref는 컴포넌트의 시각적 출력에 영향을 미치지 않는 정보를 저장하는 데 적합합니다. 예를 들어 interval ID를 저장했다가 나중에 불러와야 하는 경우 ref에 넣을 수 있습니다. ref 내부의 값을 업데이트하려면 current 프로퍼티를 수동으로 변경해야 합니다
라고 설명이 되어있습니다.
여기서 중요한 점이자 useState와의 차이점은 다음과 같습니다.
- ref 변경이 리렌더링을 촉발하지 않음
- 단일 current 프로퍼티를 가진 객체 구조
- 프로퍼티 직접 변경 가능
이 특징들로 인해 "화면에 영향을 주지 않는 데이터"를 리렌더링 없이 저장할 수 있습니다.
이 때 프로퍼티를 수동으로 변경해야 한다는 것은 ref의 프로퍼티의 접근이 가능하다는 것입니다. useState로 state를 저장할 때 객체로 저장한 후 그 프로퍼티에 접근해서 변경하는 것은 리액트의 정책에 위반되기에, 이 특성이 useRef의 여러 특성들을 가능케 합니다.
- 참조가 유지된다
- 그러면서 내부 값 변경은 자유롭다
이걸 그냥 텍스트로만 읽고는 완벽하게 와닿지는 않았습니다.
useState(() => {})
useRef를 처음에 구현하려고 하다가 복잡도가 너무 높아져서 찾아보는 과정에서 useState(() => {})라는 생소한 패턴을 발견했습니다:
생소했지만 조금만 더 생각해보니 크게 새로운 형태가 아닌, 기존 useState()에 함수가 들어간 형태라는 점을 인지했습니다.
그로 인해 몇가지 특성이 유발됩니다.
- 첫 렌더링에서만 함수 실행 - 성능 최적화
- 참조 유지 - 같은 객체/함수 반환
// 렌더링마다 계산 실행
const [data] = useState(sum());
// 첫 렌더링에서만 계산 실팽
const [data] = useState(() => sum());
React는 전달받은 값이 함수인지 확인하고, 함수라면 첫 렌더링에서만 호출합니다. 이후 리렌더링에서는 저장된 값만 반환하여 불필요한 재계산을 방지합니다.
이 특성를 이해하면서 useRef의 동작 뿐 아니라 useRef와 별개로 이 형태 자체의 특징과 어떤 상황에 쓸 수 있는지도 알 수 있었습니다.
의존성 없는 메모이제이션으로 사용할 수 있는 패턴으로, 다양한 최적화 시나리오에 적용 가능했습니다
- input value는 onChange로 set하면서 화면의 리렌더링은 onBlur시 작동하게 하는 방식
- qna때 코치님이 보여주었던 방식으로 실제로도 유용할 것 같아 인상깊었고 해당 동작에 대한 이해도도 높여주었습니다.
- toastProvider 최적화시 함수들을 분리해서 메모이제이션할 때 사용
const actions = { show, hide };
const [actions] = useState(() => ({ show, hide }));
구현한 useRef
import { useState } from "react";
export function useRef<T>(initialValue: T): { current: T } {
const [ref] = useState(() => ({ current: initialValue }));
return ref;
}
앞의 과정들을 거쳐 이 코드를 다시 쳐다봤습니다. useRef가 어떻게 설계되었고 그래서 어떻게 활용되는지 좀 더 이해가 됐습니다.
동작 원리 분석
저 코드의 동작을 다시 보면 이렇습니다:
- lazy initialization을 통해 current 단일 프로퍼티를 가진 객체를 생성
- 그 객체만을 반환
이로 인해 얻을 수 있는 효과
- 참조 안정성 확보
- 최초 생성된 참조값을 계속 리턴해서 씀으로써 확보되는 참조 안정성
- 컴포넌트가 리렌더링되어도 항상 같은 객체
- 제한적 가변성
- 단일 프로퍼티인 객체로서 객체는 변하지 않고 속성만 변한다
current에 접근해서 수정함으로써 리렌더링 없이 값 수정 가능
- 명시적 접근
// 명시적으로 .current를 통해 접근
ref.current = newValue;
const value = ref.current;
용도
이런 특성들이 조합되어서 "상태는 바뀌지만 렌더링은 안 했으면 좋겠다" 싶을 때 쓴다는 점을 이해했습니다.
깊은 비교는 얼마나 깊어야 하는걸까
Object.keys()
객체의 키에 들어갈 수 있는 타입은 3가지가 있습니다.
- string
- number
- symbol
하지만 Object.keys()의 반환 타입은 string[]입니다:
- string → string
- number → string 형변환
- symbol → 제외
이런 방식이기 때문입니다. 얕은 비교일 때는 symbol에 대한 비교가 불필요하다고 생각했습니다.
deepEquals
deepEquals는 중첩된 객체와 배열까지 재귀적으로 비교하여 모든 레벨에서 값이 동일한지 확인하는 것입니다.
그런데 여기서 의문이 생겼습니다. Deep 비교에서도 Symbol 키를 제외해야 할까?
기본 라이브러리의 경우
주요 라이브러리들이 어떻게 처리하는지 찾아봤습니다.
Lodash.isEqual, fast-deep-equal의 경우에서도 symbol은 배제하고 비교했습니다.
배제하는 이유
- 성능상의 이유
- Object.keys()는 enumerable string/number 키만 순회
- Reflect.ownKeys()는 모든 own properties (symbol 포함) 순회
- Symbol 처리 로직이 추가적인 성능 오버헤드 발생
- 실용성 관점
- React 애플리케이션에서 Symbol 키 사용 빈도 매우 낮음
- 대부분의 상태는 JSON 직렬화 가능한 일반 객체
- JSON 직렬화와의 일관성
- 서버와의 데이터 통신, localStorage 저장 등에서 Symbol 손실
- deepEquals이 JSON 동작과 일치하면 예측 가능한 동작
대부분의 경우 Symbol은 "내부 메타데이터"로 이용되는 것이고 실제 비교할 대상에는 사용되지 않스니다. 그렇기 때문에 깊은 비교에서도 비교할 필요가 없습니다.
학습 효과 분석
과제에서 요구되는 많은 훅들이 useRef기반으로 작성됩니다.
use훅들을 만들고 use훅들을 만들 때 useRef를 통해 이전 ref와 얕은 비교 후 상태를 바꾸는 과정이 반복되는데 이를 통해 리액트 시스템의 이해도가 높아졌습니다. 또한 커스텀 훅을 만드는 것이 리액트의 가독성을 높이고 활용하는 데 중요한 점이라고 생각하는데 이후 개발하는데 커스텀 훅을 보다 적극적으로 활용할 수 있을 듯 합니다.
동작을 이해하자 정확히 어떤 유즈케이스에 어떤 리액트 훅을 써야 할지 명확해졌습니다.
useState - 상태 관리, 변경 시 리렌더링 트리거
useState(() => {}) - 초기 렌더링 생성, 참조는 유지하되 리렌더링 의존성 없음
useRef - 리렌더링 없는 값 저장
useMemo - 의존성 있는 값 메모이제이션
useCallback - 의존성 있는 함수 메모이제이션
학습 갈무리
메모이제이션에 대한 나의 생각을 적어주세요.
우리의 컴퓨터는 아주 빠르기 때문에 대부분의 최적화에서 메모이제이션은 우선순위가 낮다고 생각합니다. QnA에서 코치님이 해주신 "메모이제이션은 최적화보다는 정합성을 맞추는 데 주로 쓴다"라는 말이 인상깊었습니다.
실제로도 메모이제이션을 사용했던 대부분의 경우가 참조의 불안정성으로 인한 무한 리렌더링을 해결하기 위해서였습니다.
다만 메모이제이션의 과한 사용으로 인한 단점도 경험했습니다.
메모이제이션으로 트러블슈팅을 해결한 경험 이후, 메모이제이션이 만능인 줄 알고 과도하게 사용했습니다. 하지만 하나를 메모이제이션하면 의존성이 있는 다른 요소들도 함께 고려해야 했기 때문에 코드 복잡도가 높아지고 오히려 생산성이 낮아졌습니다.
- 참조 안정성으로 올바른 동작 보장
- 예외적으로 정말 느릴 때만 최적화
이를 메모이제이션 사용의 기준으로 삼으려고 합니다.
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
컨텍스트는 단방향 데이터 흐름을 유지하면서 prop drilling 문제를 해결할 때 유용한 패턴입니다. 하지만 기본적으로는 props를 활용한 상태 관리가 우선되어야 한다고 생각합니다.
props drilling은 안티 패턴으로 간주되지만 개인적으로는 props 통해 상태 전파를 하는 것이 좋았습니다. 처음에는 막연히 부모 컴포넌트 -> 자식 컴포넌트 -> 손자 컴포넌트 -> 증손자 컴포넌트로 가는 과정에서 홀연히 상태가 호출되어 나타나는것보다 실제 흐름대로 명시되는 것이 당연하다 느껴졌고 더 기분이 후련했기 때문에 선호했습니다.
그 막연한 느낌을 글로 표현해주어 인상깊게 읽은 게시글이 있습니다. https://velog.io/@woohm402/no-global-state-manager
이 글에서 제시한 것과 개인적인 생각을 종합한 props 상태 관리의 장점은 아래와 같습니다.
- 명시성: 데이터 흐름이 명확하게 보임
- 추적 가능성: 어디서 어떤 데이터가 오는지 쉽게 파악
- 테스트 용이성: 컴포넌트 단위 테스트가 쉬움
- 성능: 불필요한 리렌더링 위험이 적음
과제 피드백
안녕하세요 두현, 수고하셨습니다! 이번 과제는 React의 내장 훅들을 직접 구현해보면서 프레임워크의 내부 동작 원리를 깊이 이해하는 것이 목표였습니다.
"...개인적으로 효과가 좋다고 생각하는 학습방식이 있습니다. 원리를 다 파악하고 실제로 활용하는 것보다는, 일단 원리도 모르는 상태에서 다들 하는대로 적용하고 보는 것입니다. 그렇게 익숙해질 즈음에 다시 이론적인 개념을 파악하면 그 때 이해가 쏙쏙 됩니다. ..."
저희 과제의 취지에 너무 부합하는 말이라 좋았습니다. 언제든 새로운 개념을 학습하게 되면 최소한의 방식으로 직접 구현해보는 것이 그 개념을 이해하는데 가장 크게 도움이 되는 것 같아요. 상태관리나 서버상태관리등도 꼭 이러한 방식으로 접근해보기를 바랍니다.
Context와 상태관리에 대한 철학적 고민도 인상깊었습니다. props drilling을 통한 명시적 데이터 흐름을 선호하시는 이유가 매우 합리적이에요. 특히 "데이터 흐름이 명확하게 보이는 것"의 가치를 아시는 것은 복잡한 애플리케이션을 다룰 때 큰 도움이 될 거예요.
클린코드 시간에는 이러한 여러가지 철학적 베이스로 어떤 코드가 더 좋은 코드가 될지 함께 고민해보는 시간을 가져봅시다! 수고하셨습니다. 다음 클린코드 챕터에서도 이런 탐구 정신을 발휘해서 더욱 성장하시길 바랍니다! 화이팅입니다! :)