React-Redux 간단하게 따라 만들어보기 - 1편.
React-Redux 간단하게 따라 만들어보기.
이 글은 React-Redux라이브러리를 직접 열어서 구현해본 과정을 기록 한 글입니다. 만약 정보를 얻기 위해 들어오셨다면 시간이 아까우실 테니 뒤로 가시는 게 좋습니다. 아직 내공이 부족해서 내
2hakjoon-mindmap.tistory.com
이 글은 "React-Redux간단하게 따라 만들어보기 - 1편"에서 부족했던 부분을 개선한 내용을 기록한 글입니다. 2편 역시 정보를 전달보다는 기록에 가까운 글입니다.
1편에서 부족했던 부분.
1편은 사실 라이브러리를 탐방하고 간단하게 구현하는데에 초점을 맞추고 시작했었다. 라이브러리를 뜯어보는 것만으로도 쉽지가 않았고, 코드를 잘 짜기도 어려웠다. 부족한 부분이 많았음에도 이 정도면 충분하지 하는 생각으로 마무리를 지었다. 하지만 시간이 흐를수록 부족했던 부분이 자꾸 생각이 났고, 다시 하면 더 잘할 것 같은 생각이 들었다. 저번에 부족했던 부분을 우선순위에 맞춰서 다시 정의해보자.
- unsubscribe 미구현
- 이 부분은 잠재적으로 문제가 있는 부분이다. 한번 추가된 listener는 unmount시에 반드시 지워줘야 한다. React가 unmount 된 컴포넌트의 setState동작은 error를 뱉을뿐더러, memory누수가 발생하게 된다. 가장 우선순위가 높은 문제다.
- HeduxProvider 추상화
- React-Redux처럼 깔끔하게 Provider컴포넌트를 만들어서 해결해보고 싶다.
- combineReducer함수 추가
- 나중에 확장하여 사용할 경우 모듈 단위로 분리하여 작성하기 위해서 필요한 기능이다.
그럼 이제 1편에서 작성한 Hedux를 개선했던 과정을 정리해 보겠다.
unsubscribe구현.
React-Redux의 경우 Listener를 Array가 아닌 Linked List로 구현한다. 1편을 작성할 때 Array로 쉽게 구현하려고 고민을 해봤다. Array의 경우 중간의 요소가 제거될 경우 제거된 Index의 빈칸을 채우기 위해 다른 요소들의 index에 영향을 준다. 그러면 삭제하려고 하는 요소를 특정하기 어려워지거나, 다른 요소를 삭제하게 되는 일이 발생할 수 있다. 하지만 Linked List를 사용하면 이 문제를 쉽게 해결할 수 있다.
삭제할 Linked List객체의 앞 객체와 뒷 객체를 연결해주기만 하면 된다. Linked List를 이용하여 subscribe와 unsubscribe기능을 구현해보자.
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Hedux, HeduxReducer } from './types';
function createHeduxStore<T>(initState: T, { reducer }: HeduxReducer<T>): Hedux<T> {
let state: T = initState;
const getState = (key: keyof T): Partial<T> => {
const data = state[key];
return data;
};
const dispatch = (type: string, payload?: { [k: string]: any }) => {
state = reducer(state, { type, payload });
reflect();
};
// Todo : utils/subscirbe로 분리하기.
// Listener객체 타입 선언
interface Listener {
callback: () => void;
prev: Listener | null;
next: Listener | null;
}
// Linked List를 만들기 위한 first와 last객체.
let first: Listener | null = null;
let last: Listener | null = null;
const reflect = () => {
// Linked List의 첫번째 객체를 listner변수에 할당.
let listener = first;
// 다음 listner객체로 이동하며 마지막까지 callback함수 호출.
while (listener) {
listener.callback();
listener = listener.next;
}
};
const subscribe = (trigger: () => void) => {
// tigger인자를 callback에 할당하며 newListener객체 생성.
const newListener: Listener = {
callback: trigger,
prev: last,
next: null,
};
// Linked List에 객체요소가 있다면 가장 마지막에 할당. last를 newListener로 변경.
// Linked List가 비어있다면, first, last객체로 모두 newListener로 변경
if (newListener.prev !== null) {
newListener.prev.next = newListener;
last = newListener;
} else {
first = newListener;
last = newListener;
}
// unscribe함수 리턴.
// 앞 뒤의 요소들을 서로 연결해주는 로직.
// 요소가 맨 처음이거나, 맨 마지막일 경우를 조건문으로 처리.
return function unsubscribe() {
if (newListener.prev) {
newListener.prev.next = newListener.next;
} else {
first = newListener.next;
}
if (newListener.next) {
newListener.next.prev = newListener.prev;
} else {
last = newListener.prev;
}
};
};
// 여기까지 포함.
return { getState, dispatch, reflect, subscribe };
}
export default createHeduxStore;
Linked List의 경우 JavaScript에서 기본적으로 제공하지 않는 객체이다. 그러므로 간단한 형태로 직접 구현했다.
React-Redux의 subscription 코드를 많이 참고했다.
링크 : https://github1s.com/reduxjs/react-redux/blob/HEAD/src/utils/Subscription.ts
subscription에 해당하는 코드가 너무 길어졌다. 그러므로 utils/subscription 파일로 분리하기로 했다. 분리한 다음 각각의 파일은 아래와 같이 수정되었다.
hedux/core/hedux - createStore.
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HeduxReducer } from './types';
function createHeduxStore<T>(initState: T, { reducer }: HeduxReducer<T>) {
let state: T = initState;
const getState = (): T => {
return state;
};
const dispatch = (type: string, payload?: { [k: string]: any }) => {
state = reducer(state, { type, payload });
};
return { getState, dispatch };
hedux/utils/subscription - subscription.
export interface Subscription {
reflect: () => void;
subscribe: (arg: () => void) => () => void;
}
function subscription(): Subscription {
interface Listener {
callback: () => void;
prev: Listener | null;
next: Listener | null;
}
let first: Listener | null = null;
let last: Listener | null = null;
const reflect = () => {
let listener = first;
while (listener) {
listener.callback();
listener = listener.next;
}
};
const subscribe = (trigger: () => void) => {
const newListener: Listener = {
callback: trigger,
prev: last,
next: null,
};
if (newListener.prev !== null) {
newListener.prev.next = newListener;
last = newListener;
} else {
first = newListener;
last = newListener;
}
return function unsubscribe() {
if (newListener.prev) {
newListener.prev.next = newListener.next;
} else {
first = newListener.next;
}
if (newListener.next) {
newListener.next.prev = newListener.prev;
} else {
last = newListener.prev;
}
};
};
return { reflect, subscribe };
}
export default subscription;
createStore는 store에 관련된 기능을 하는 코드만 모여있게 되었고, subscription은 subscription과 관련된 코드만 모이게 되었다. 실제 React-Redux에도 이렇게 분리가 되어있다. 두 파일로 분리하는 게 각각의 코드가 더 응집도 있게 느껴진다.
hedux/hooks/useSelector - useSelector.
import { useContext, useEffect, useState } from 'react';
import { HeduxStore } from '../..';
import { HeduxInitState } from '../moudles';
function useSelector<T>(key: keyof HeduxInitState): T {
const storeState = useContext(HeduxStore).getState(key);
const { subscribe } = useContext(HeduxStore);
const [state, setState] = useState(Math.random());
useEffect(() => {
const unsubscribe = subscribe(() => setState(Math.random()));
return () => unsubscribe();
}, []);
return storeState;
}
export default useSelector;
useSelector는 이제 subscribe함수를 통해 변화를 반영할 trigger를 전달하고, unsubscribe함수를 리턴 받는다. useEffect의 return에 unSubscribe를 콜백으로 전달해서 컴포넌트가 unmount 될 때 listener목록에서 제거할 수 있게 되었다.
HeduxProvider 추상화 작업.
React-Redux는 Provider컴포넌트에 생성한 store만 전달하여 사용하고 있다.
1편에서는 index.ts파일에서 따로 createContext함수를 이용해서 Context객체를 생성하고 export를 시켰다. React-Redux에 비해 깔끔하지 않아서 비슷하게 처리하려고 고민을 했다. Context객체는 createStore함수의 리턴 값을 받아 생성할 수 있는데, createStore는 외부에서 선언이 된다. 이 부분을 어떻게 내부에서 추상화를 시키지?라는 고민이 있었다. 이 부분은 라이브러리를 열어서 원리를 다시 파악해보기로 했다.
사실 그냥 null로 생성하는 거였음.
링크 : https://github1s.com/reduxjs/react-redux/blob/HEAD/src/components/Context.ts
Context에 대한 타입을 정의한 다음 그냥 null을 넣어서 생성하고 있다. 심지어 null을 any로 타입 단언까지 하고 있다. 내가 생각하지도 못한 방법이 2가지나 사용되었다. 나는 Context Api를 거의 사용한 적이 없다. 그래서 그런지 초기값을 null로 하여 context객체를 생성하고 나중에 바꿔주는 방법을 떠올리지 못한 것이다.
value에 해당하는 값은 언제든지 변경할 수 있는 값이다. 그러므로 나중에 할당해준다고 하더라도 문제가 되지 않는다. 이 부분을 내가 잘 몰라서 어려워했던 것 같다. 그럼 이제 내가 작성한 코드를 살펴보자.
Hedux의 타입부터 수정하기.
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HeduxActionObj } from '../reducer/types';
// Generic Type을 받아서 사용됨.
// Generic에 의존하고 있어서 독립적으로 사용하지 못함.
export interface Hedux<T> {
getState: (key: keyof T) => any;
dispatch: (type: string, payload?: { [k: string]: any }) => void;
}
원래 작성한 코드는 createStore를 할 때, store의 초기값인 initState를 받아와서 initState의 type으로 자동완성 기능을 사용하고 싶어서 Generic Type으로 받아오게 했다. 이 때문에 독립적으로 사용하지 못하게 되어서 store 쪽을 좀 수정할 필요가 생겼다.
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HeduxActionObj } from '../reducer/types';
export interface HeduxStore {
getState: () => any;
dispatch: (type: string, payload?: { [k: string]: any }) => void;
}
getState의 인자인 key를 자동완성으로 쓰기 위해 Generic Type을 적용한 부분을 수정했다. 그냥 store전체를 반환시키고, 값을 받는 useSelector 쪽에서 key를 이용해서 값을 가져오게 바꿨다. 그리고 Hedux라는 네이밍도 좀 모호한 느낌이 들어서 HeduxStore라고 수정했다. createStore에 더 어울리는 이름이라고 생각한다. 이제 타입을 독립적으로 사용할 수 있게 되었다.
hedux/compoent/HeduxContext.ts
import { createContext } from 'react';
import { HeduxStore } from '../core/types';
import { Subscription } from '../utils/subscription';
export type HeduxContextValue = HeduxStore & Subscription;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const HeduxContext = createContext<HeduxContextValue>(null as any);
HeduxContectValue라는 타입을 하나 만들었다. HeduxStore에서 반환해주는 타입과, Subscription에서 반환해주는 타입을 모두 포함하게 작성했다. null을 초기값으로 하여 HeduxContext를 만들었다. 이제 전역에서 HeduxContext를 불러서 사용하면 된다.
hedux/compoent/HeduxProvider.ts
import React, { ReactNode, useMemo } from 'react';
import { HeduxStore } from '../core/types';
import subscription from '../utils/subscription';
import { HeduxContext, HeduxContextValue } from './HeduxContext';
interface HeduxProviderProps {
store: HeduxStore;
children: ReactNode;
}
function HeduxProvider({ store, children }: HeduxProviderProps) {
const { reflect, subscribe } = subscription();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dispatch = (type: string, payload?: { [k: string]: any }): void => {
store.dispatch(type, payload);
reflect();
};
const context = useMemo<HeduxContextValue>(() => {
return {
reflect,
subscribe,
dispatch,
getState: store.getState,
};
}, [store]);
return <HeduxContext.Provider value={context}>{children}</HeduxContext.Provider>;
}
export default HeduxProvider;
HeduxProvider에서 할 일이 좀 추가됐다. 앞서 store와 subscription을 분리했었던 부분을 여기서 다시 합쳐주기로 했다. dispatch의 경우, store의 dispatch와 reflect동작이 함께 이루어져야 한다. 그래서 dispatch라는 함수를 다시 만들어서 두 가지 동작이 한 함수에서 동작하게 합쳐주었다. 그리고는 생성했던 HeduxContextValue에 맞춰서 Provider의 value로 넣어주면 된다.
원래는 그냥 객체를 만들어서 value로 넣어줬는데 에러가 났다. 그래서 useMemo함수로 감싸줬다. store를 dependency에 추가해서 store가 새로 할당되면 새로 context를 만들게 했다.
Provider 추상화 완료.
const heduxStore = createHeduxStore<HeduxInitState>(heduxInitState, { reducer });
root.render(
<HeduxProvider store={heduxStore}>
<App />
</HeduxProvider>
);
나도 이제 createHeduxStore의 리턴 값으로만 HeduxProvider를 구현할 수 있게 되었다.
combineReducers 구현하기.
해당 기능을 구현하기 전에 먼저 라이브러리를 살펴보자.
https://github1s.com/reduxjs/redux/blob/HEAD/src/combineReducers.ts
GitHub1s
github1s.com
코드 내용이 좀 많기도 했고 맥락을 이해하기 힘들어서 전체적인 틀만 봤다. 물론 위 사진이 해당 기능을 구현하는 코드는 아니지만, 내부 동작이 어떻게 되는지 추측할 수 있는 코드라고 생각한다. 우선 reducers를 객체의 형태로 받아와서 for loop를 돌려서 reducer를 찾는 작업을 하고 있었다. 단순하게 객체의 value에 담겨있는 함수들을 모두 수행시키면 되는 건가?라는 생각이 들어서 일단 구현해봤다.
hedux/reducer/combineReducer.ts
import { authReducer } from '../moudles/auth';
import combineReducers from './combineReducer';
export const reducer = combineReducers({ authReducer });
combineReducers에 authReducer를 객체 형태로 담아서 전달해봤다.
hedux/reducer/combineReducer.ts
import { HeduxActionObj } from './types';
interface CombineRedcuerArgs {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[k: string]: (state: any, action: HeduxActionObj) => any;
}
function combineReducers(reducers: CombineRedcuerArgs) {
return function rootReducer<T>(state: T, action: HeduxActionObj): T {
// eslint-disable-next-line no-restricted-syntax
for (const reducer of Object.values(reducers)) {
// eslint-disable-next-line no-param-reassign
state = reducer(state, action);
}
return {
...state,
};
};
}
export default combineReducers;
그 후에 combineReducer함수에서 reducers라는 인자로 reducer가 담긴 객체를 받아왔고, object.values로 reducer를 꺼냈다. reducer에는 state와 action을 전달했다. 우리가 작성한 reducer에서 action.type을 확인하고 action type에 맞는 동작을 수행하여 state를 수정할 것이다. 그리고는 마지막에 state를 반환한다. 반환된 state는 createStore의 dipatch함수에 의해 내부 state를 수정한다.
combineReducer 기능을 추가한 목적은 module단위로 코드를 작성하기 위함이다. module을 수정하러 가자.
hedux/modules/auth.ts
import { authTokenKey, persistStore } from '../../persistStore/persistStore';
import { HeduxActionObj } from '../reducer/types';
export const ACTION_LOGIN = 'auth/LOGGIN';
export const heduxAuthInitState = {
auth: { isLoggedIn: !!persistStore.get(authTokenKey) },
};
export type HeduxAuthType = typeof heduxAuthInitState.auth;
export const authReducer = <T>(state: T, action: HeduxActionObj): T => {
switch (action.type) {
case ACTION_LOGIN:
return {
...state,
auth: action.payload,
};
default:
return state;
}
};
module내의 파일에서 actionType과 Reducer를 모두 작성했다. 각 module에서 reducer를 작성해서 export를 시키기 때문에 유지보수가 좀 더 편해진 느낌이다.
이렇게 combineReducer까지 모두 간단하게 구현을 해보았다.
후기 : 이게 아니야! 와장창.
1편만 하더라도 어느 정도 만족스러운 결과라고 생각했다. 그런데 2편을 작성하면서 라이브러리를 더 뜯어보면 뜯어볼수록 내가 짠 코드에 대해 실망감만 커져갔다. React-Redux의 본질적인 동작은 전혀 적용하지 않고 내 맘대로 작성한 것 같았다.
React-Redux의 useSelector를 예로 들어보자.
useSyncExternalStoreWithSelector라는 함수가 보인다. 전에는 그냥 마법 같은 함수라고 생각했다. 여기에 이런저런 인자를 집어넣으면 값이 나오는구나! 그럼 state에서 값을 key로 조회해서 뽑아오는 거랑 똑같겠지? 이렇게 접근해서 지금의 내가 작성한 useSelector함수가 태어났다. 하지만 자세히 들여다 보니 React-Redux의 useSelector 함수와 내가 만든 함수는 근본이 달랐다.
useSyncExternalStoreWithSelector함수를 추적하다 보면 아래의 패키지가 등장한다.
use-sync-external-store
Backwards compatible shim for React's useSyncExternalStore. Works with any React that supports hooks.. Latest version: 1.2.0, last published: 3 months ago. Start using use-sync-external-store in your project by running `npm i use-sync-external-store`. Ther
www.npmjs.com
패키지에 discussion하나가 링크되어있다.
useMutableSource → useSyncExternalStore · Discussion #86 · reactwg/react-18
Since experimental useMutableSource API was added, we’ve made changes to our overall concurrent rendering model that have led us to reconsider its design. Members of this Working Group have also re...
github.com
React내부적으로 useSyncExternalStore이라는 API가 있다. 위의 패키지는 해당 기능만 빼서 따로 배포된 버전으로 보인다. useSyncExternalStore이라는 패키지를 통해서 store의 변화를 감지할 수 있다. 1편에서 어떻게 state의 변화를 감지하지?라는 의문이 있었는데 이제야 해결이 되었다.
예시로 든 useSelector 이외에도 본질에서 벗어난 코드가 많이 보인다. 라이브러리를 처음 열어봤던 순간을 떠올려보면, 어느 부분이 중요한지 캐치해내지 못하고 불필요한 부분에서 에너지와 시간을 낭비했다. 그래서 나도 모르게 처음의 계획이 점점 현실과 타협하게 되어가며 방향을 잃어가게 된 것 같다. 가장 안타까운 부분은 어떻게 구현할지부터 고민했다는 점이다. 라이브러리는 어떻게 문제를 해결했는지를 이해하고 받아들였어야 했는데, 구현이 어려운 부분에 대한 해답을 라이브러리가 아니라 내가 알고 있던, 내 머릿속에 있던 익숙한 방법에서 찾았다.
마치며.
2편을 만들면서 작성한 코드에는 Generic Type을 잘 활용하지 못해서 여기저기 any가 존재하고 있다. 물론 내부에서 사용하는 부분만 any를 적용하고, 외부에서 함수를 불러서 사용하는 부분은 type을 지정해서 자동완성을 할 수 있게 하긴 했지만 좀 아쉬운 건 사실이다. 1편에서 아쉬웠던 부분을 해결하기 위해서 2편을 시작했는데 아쉬운 부분이 더 늘어났다.
앞서 이야기한 React-Redux의 본질을 이해하지 못한 채로 작성한 부분도 너무 많다. 라이브러리를 구현하기보다 더 깊게 이해하려고 노력했더라면 결과는 조금 더 달랐을 것 같다. 이 과정이 부디 가치 있는 시행착오로 남았으면 좋겠다.
다음부터는 라이브러리를 열어보고 직접 구현할 일은 없을 것 같다. 만약 관련한 내용을 공부한다면 라이브러리를 열어서 원리를 이해하는 과정만 기록할 것 같다. 코드를 직접 작성해본 경험이 주는 장점은 라이브러리의 환상과 두려움이 사라진다는 점이다. 마법인 줄 알았던 라이브러리도 결국엔 그냥 코드였구나 하는 생각이 들었다. 단점으로는 내가 했던 시행착오처럼 구현에 집중하게 되고, 새로운 걸 배우기보다 익숙한 방법을 선택하게 된다. 이 부분을 항상 경계해야 올바르게 정보를 습득할 수 있을것 같다.
'라이브러리 탐방' 카테고리의 다른 글
React-Redux 간단하게 따라 만들어보기 - 1편. (0) | 2022.08.29 |
---|