이번 주는 useCallback
, useMemo
, useRef
, useDeepMemo
같은 React 기본 Hook을 직접 구현해보고, memo
, deepMemo
같은 HOC도 직접 만들어보며 동작 원리를 이해했다.
memo
는 컴포넌트를 인자로 받아 props가 바뀌지 않으면 리렌더링을 생략하는 방식으로 동작한다.
그래서 처음에는 함수 컴포너트의 결과를 클로저 내부에 저장해두고, props가 이전과 동일하다면 저장된 결과를 반환하도록 구현했다.
하지만 여러 곳에서 memo
를 사용했을 때, 클로저에 저장된 값들이 덮어써지면서 props가 변경되지 않았음에도 불구하고 컴포넌트가 다시 렌더링되는 문제가 발생했다.
클로저 대신 userRef
를 사용해, memo
가 적용된 각 컴포넌트마다 별도로 결과를 저장하도록 변경했다.
이렇게 저장 범위를 분리하니 문제 없이 동작했다.
클로저의 저장 범위(scope)를 명확히 설정하지 않으면 예상치 못한 리렌더링 문제가 발생할 수 있다는 걸 경험했다.
직접 만든 Hook을 적용하며 기존 코드를 리팩토링했고, Context를 사용할 때 불필요한 렌더링을 최소화하는 구조를 고민했다.
우선 AppContext에 같이 있던 theme(다크모드 theme 설정), notification(로그인 알람 토스트), auth(로그인)를 각각의 context로 분리하였다.
그리고 각각의 context 상태가 변경 될 때, 불필요한 리렌더링이 발생하지 않도록 구조를 나누는 데 집중하였다.
예를 들어, 사용자가 로그인하면 알림 토스트가 나타나도록 구현되어 있었기 때문에 AuthProvider에서 NotificationContext를 참조해야 했다.
하지만 Context API는 context value가 변경되면 해당 값을 구독하는 모든 컴포넌트가 리렌더링된다는 특성이 있기 때문에,
헤더에 있는 로그인 버튼으로 로그인했을 때 알림 토스트가 뜨고, 그 알림을 닫았을 때 auth를 구독 중인 Header 컴포넌트가 불필요하게 리렌더링되지 않도록 구조를 어떻게 분리할지 고민하였다.
AuthProvider
안에서 직접 NotificationContext
를 구독하는 대신, addNotification
만 prop으로 전달하고 AuthProvider
자체를 memo()
로 감쌌다.
...
<NotificationProvider>
<AuthWithNotification>{children}</AuthWithNotification>
</NotificationProvider>
...
function AuthWithNotification({ children }: AuthWithNotificationProps) {
const { addNotification } = useNotification("AuthWithNotification");
return (
<AuthProvider addNotification={addNotification}>{children}</AuthProvider>
);
}
이렇게 하면 notification
과 관련된 상태가 변경돼도 AuthProvider
는 리렌더링되지 않는다. 또한, notification
과 AuthProvider
의 의존관계를 prop으로 명시적으로 표현할 수 있다.
멘토의 코드를 참고하면서 AuthProvider
안에서 useNotification
을 직접 사용하되,
useCallback
과 useMemo
를 활용해 authContextValue
의 참조를 고정시키는 방식이 더 구조적으로 깔끔할 수 있겠다는 생각이 들었다.
export const UserProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
...
const login = useCallback(
(email: string) => {
setUser({ id: 1, name: "홍길동", email });
addNotification("성공적으로 로그인되었습니다", "success");
},
[addNotification],
);
const logout = useCallback(() => {
setUser(null);
addNotification("로그아웃되었습니다", "info");
}, [addNotification]);
const userContextValue = useMemo(
() => ({ user, login, logout }),
[user, login, logout],
);
...
}
추가적으로 멘토의 코드 리뷰를 통해 추천받은 방식 중 하나가, notification
과 AuthProvider
의 의존관계를 더 명시적으로 쓰도록 강제하고 싶다면 의존관계를 children 콜백 패턴으로 표현하는 것이다.
export const AppProvider = ({ children }: AppProviderProps) => {
return (
<ThemeProvider>
<NotificationProvider>
{(notificationAPI) => (
<AuthProvider notificationAPI={notificationAPI}>
{children}
</AuthProvider>
)}
</NotificationProvider>
</ThemeProvider>
);
};
이 방식은 AuthProvider
가 notification
API를 명시적으로 전달받게 되므로, 구조적으로 NotificationProvider
가 상위에 존재해야 한다는 제약을 코드 레벨에서 자연스럽게 강제할 수 있다.
의존관계를 좀 더 겉으로 드러내고 명확히 표현할 수 있다는 점에서 인상 깊었다.
기존의 useNotificationContext
방식도 간결하고 편리하지만, 의존성이 암묵적으로 감춰지는 단점이 있다는 걸 생각해보지 못했다.
설계적인 안정성과 구조적인 명확함이 중요한 상황이라면, 이런 형태의 의존성 전달도 충분히 고려해볼 만하다는 생각이 들었다.
이번 과제를 진행하면서 학습메이트에게 Redix UI의 undefined safe한 context 생성 함수를 추천받았다.
이 코드 패턴을 참고하면 createContext
를 좀 더 간결하게 사용할 수 있고, Provider와 useContext hook을 한 번에 구성할 수 있어서 적용해보면 좋겠다는 생각이 들었다.
역시 같이 공부하면 참고할 수 있는 자료나 시야가 훨씬 넓어지는 느낌이다.