2월 중 가장 기억에 남는 일은 context와 HoC을 사용하여 어드민 사이트를 리팩토링 한 일이다.
리팩토링을 결심한 이유는 다음과 같다.
어드민 사이트에서는 필터와 리스트 테이블로 구성된 템플릿 구조가 여러 페이지에서 반복적으로 사용되고 있다. 이러한 구조를 Board라고 부르겠다. 하지만 기존 코드에서는 각 페이지마다 필터 컴포넌트를 재정의하고 있어 같은 마크업(Switch, Select, Search...)이 여러번 중복되어 사용되었고, 컴포넌트가 필터를 반영하는 로직(url query 파싱, 필터 초기화 등) 또한 조금씩은 다르지만 비슷한 흐름의 코드가 여러번 작성 되어 있었다.
이러한 이유로 리팩토링을 결심했고, 비슷한 컴포넌트들은 응집도가 높은 컴포넌트로 분리하여 재사용 가능하게 하고 각 Board가 비슷한 stateful 로직을 사용하므로 context와 HOC을 도입하여 더욱 정교한 구조의 코드를 작성하고자 했다!
사실 2-3일이면 끝날 것 같았는데 일주일 동안이나^^ 리팩토링을 진행했다ㅎㅎ
처음으로 React context랑 HOC을 사용해 본 거라 먼저 개념 이해가 필요했다.
context를 사용하면 단계마다 일일이 props를 넘겨주지 않고도 컴포넌트 트리 전체에 데이터를 제공할 수 있다. 따라서 애플리케이션 안의 여러 컴포넌트들에 전해줘야 하는 props이 여러 개 존재할 때 사용하면 유용하다!
Board를 구성하는 컴포넌트인 테이블과, 필터, 그리고 select, switch, input 필터 등이 모두 전역적으로 볼 수 있는 filter(적용된 필터정보를 담는 state), data(테이블에 보여줄 데이터) 등에 접근해야하므로 IBoard라는 타입을 갖는 context를 만들었다.
export type IBoard = {
state: {
filter: { [propName: string]: any };
selectedRowKeys: number[];
data: unknown | any;
loading: boolean;
page: number;
};
action: {
handleFilterChange: (input: any) => void;
reload: () => void;
handleRowSelectionChange: (selectedKeys: number[]) => void;
handleRowDelete: (selectedKey: number) => void;
initFilter: () => void;
handlePageChange: (page: number) => void;
prefetchPageData: (page: number) => void;
};
};
const BoardContext = createContext<IBoard>(undefined);
export const useBoardContext = () => useContext(BoardContext);
컴포넌트에서 사용할 때 :
return (
<BoardContext.Provider value={boardStore}>
<WrappedComponent />
</BoardContext.Provider>
);
Provider 컴포넌트가 WrappedComponent에 해당하는 하위 컴포넌트들(context를 구독하는 컴포넌트)에게 타입이 IBoard인 boardStore값을 전달한다.
function BoardFilter({ filters }: BoardFilterProps) {
const {
state: { filter },
action: { initFilter, handleFilterChange },
} = useBoardContext();
// 이하 중략
}
이렇게 되면 WrappedComponent에 속하는 BoardFilter와 같은 컴포넌트에서 useBoardContext 훅으로 context의 값을 사용할 수 있다.
고차 컴포넌트(HOC, Higher Order Component)는 컴포넌트 로직을 재사용하기 위한 React의 고급 기술이라고 한다.
위 자료는 네이버 쇼룸의 세션3 '안정민 - UI 모듈화로 워라밸 지키기' ppt의 일부이다.
어드민 사이트의 Board 구조는 마크업이 다르지만 stateful한 로직이 같았기 때문에 HOC을 적용하기 적합하다고 판단했다.
Board는 필터와 테이블이 반복되기는 하지만 어떤 필터에는 브랜드, 대분류, 중분류 필터가 필요했고, 어떤 필터에는 검색어, 예/아니오로 된 switch 필터가 필요했다. 따라서 필요한 마크업은 달랐다.
반면, 필터를 적용하는 로직은 다음과 같이 모두 같았다.
(1) 필터 변경를 변경하면 filter state 변경하고, url query를 적용된 필터와 동일하게 변경(replace) 시킨다. (2) 필터 초기화를 초기화 할 때는 url query를 읽어와서 filter state에 적용한다.
따라서 이러한 부분을 withBoardContext라는 HOC에서 처리하도록 하고, 위 같은 로직을 사용하는 Board 컴포넌트를 HOC으로 감싸 로직을 재사용할 수 있도록 했다.
export const withBoardContext = (
WrappedComponent: React.FunctionComponent<Record<string, unknown>>,
defaultFilter: any,
requestConfig: any,
filterNames: string[]
) => () => {
const router = useRouter();
const [filter, setFilter] = useState<any>(defaultFilter);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [page, setPage] = useState<number>(
router.query.page ? Number(router.query.page) - 1 : 0
);
const pageSize = TABLE_PAGE_SIZE;
const offset = page * pageSize;
const limit = pageSize;
const { data, mutate, error, response } = useRequest<ListResponse<any>>(
requestConfig({
offset: offset,
limit: limit,
page: page,
...filter,
})
);
const loading = !data && !error;
/**
* 페이지가 처음 로딩될 때 url의 router query에 따라 필터를 초기화한다.
*/
useEffect(() => {
if (Object.keys(router.query).length === 0) {
return;
}
const queryFilter = getQueryFilter(router.query);
setFilter(queryFilter);
if (router.query.page !== undefined) {
const queryPage = Number(router.query.page) - 1;
if (queryPage !== page) {
setPage(queryPage);
}
}
}, [Object.keys(router.query ?? {}).length === 0]);
const getQueryFilter = (query) => {
const queryFilter = { ...query };
delete queryFilter[page];
filterNames.map((name) => {
if (!query[name]) {
delete queryFilter[name];
return;
}
queryFilter[name] = parseValue(query[name]);
});
return queryFilter;
};
// 필터가 변경되었을 때 url query를 변경된 필터로 replace한다.
const replaceUrl = (input: { [propName: string]: any }) => {
const query = {
...filter,
...input,
};
Object.keys(query).forEach((key) => {
if (query[key] === null) {
delete query[key];
}
});
router.replace({
pathname: location.pathname,
query,
});
};
const handlePageChange = (page: number) => {
const newPage = page >= 0 ? page : 0;
setPage(newPage);
replaceUrl({ page: newPage + 1 });
};
// 필터가 변경되었을 때 호출하는 함수
const handleFilterChange = (input: { [propName: string]: any }) => {
const newFilter = { ...filter, ...input };
handlePageChange(0);
setFilter(newFilter);
replaceUrl(newFilter);
};
// 필터 초기화
const initFilter = () => {
setFilter({});
handlePageChange(0);
};
const handleRowSelectionChange = (selectedKeys: number[]) => {
setSelectedRowKeys(selectedKeys);
};
const handleRowDelete = (selectedKey: number) => {
mutate(
{
...response,
data: {
...data,
count: data.count - 1,
results: data?.results.filter((item) => item.id !== selectedKey),
},
},
false
);
};
const prefetchPageData = (page: number) => {
requestConfig({
offset: page * pageSize,
limit: pageSize,
...filter,
});
};
const boardStore: IBoard = {
state: {
filter,
selectedRowKeys,
data,
loading,
page,
},
action: {
handleFilterChange,
reload: mutate,
handleRowSelectionChange,
handleRowDelete,
initFilter,
handlePageChange,
prefetchPageData,
},
};
return (
<BoardContext.Provider value={boardStore}>
<WrappedComponent />
</BoardContext.Provider>
);
};
필터, 액션, 테이블로 구성된 Board에 withBoardContext HOC 적용
function QuestionBoard() {
const {
state: { selectedRowKeys },
action: { handleRowSelectionChange, reload },
} = useBoardContext();
const [selectedQuestion, setSelectedQuestion] = useState<IQuestion>(null);
const rowSelection: TableRowSelection<any> = {
onChange: handleRowSelectionChange,
getCheckboxProps: (record) => ({
id: record.id,
}),
selectedRowKeys,
};
return (
<>
<BoardFilter filters={questionFilters} />
<QuestionManageAction />
<BoardTable
columns={questionColumns}
rowSelection={{
type: 'checkbox',
...rowSelection,
}}
onRow={(record) => ({
onClick: () => {
setSelectedQuestion(record);
},
})}
/>
</>
);
}
export default withBoardContext(QuestionBoard, {}, listConfig, [
'isAnswered',
'brandId',
'keyword',
]);
(1) 새로운 개념을 익히고 사용하는 것이 어려웠다.
(2) 각각의 컴포넌트가 자신의 명확한 역할을 갖고, 추상적인 메세지로 소통할 수 있게 끔하는 구조를 코드로 구현하기 전에 먼저 설계해보는 것이 어려웠다.
(💩 잠시 반성 중...
늘 기능을 구현할 때 설계를 건너뛰고 그냥 머리속으로 대충 생각해서 바로 코드로 작성하는 것이 습관화 되어 왔다. 그래서 코드를 작성하면서 필요하다는 생각이 들 때 함수들을 만들어버렸고, 한 80%까지 왔을 때 갑자기 새로운 구조가 필요해져서 뇌정지가 와서.. 구조를 다시 갈아 엎어 버리는 경험을 꽤 자주 했었다. 이게 다 설계를 제대로 안하고 넘어갔기 때문에 발생한거라고 한다...ㅎ)
필터, 테이블, 액션이라는 객체가 각각 정확히 어떤 역할을 갖는지, 어떤 state와 action을 필요로하는지에 대해 충분히 먼저 생각해본 후 코드를 작성했다. 이러한 설계 과정을 통해 객체지향적 사고로 컴포넌트를 설계하는 것의 중요성을 알게되었다.
리팩토링이 끝나고 cto님이 추천해주신 '객체지향의 사실과 오해'라는 책을 읽으며 평소 객체지향이라고 하면 class밖에 생각안났던 과거의 내 생각들을 환기시키는 계기가 되었다. 또, 객체 간 인터페이스는 어떻게 설계할지 등 객체의 캡슐화나 다형성 등을 고려하며 설계하는 태도를 기를 수 있었다.
(3) 구현 부분에서는 url에서 query parameter가 모두 string인데, 이들을 각각의 타입에 맞게 저절로 파싱해주는 함수를 작성하는 것이 어려웠다.