지난 2, 3번째 글에서 만들어진 next-batch-router와 next-query-state는 이번 글의 내용을 위해 만들어졌던 패키지입니다.
이번 내용은 지난 글들과는 달리 유하 프로덕트와 밀접한 내용으로, 첫 번째 글에서 설명한 세 번째 리팩토링에 대해 더 자세하게 설명합니다.
기존의 복잡했던 구조와 개선 방향
첫 번째 글에서 설명해 드렸던 것처럼 유하의 유튜버 검색 화면은 SearchContainer 컴포넌트 안에 SearchFilter, SearchTable 이 들어있는 구조입니다.
기존의 코드는 SearchContainer에서 SearchFilter로 prop을 내려주며 두 컴포넌트가 결합하여 있었으며, SearchFilter 내에서는 카테고리 필터, 구독자수 필터 등 각 필터가 컴포넌트화되어있지 않고 필터의 모든 상태를 SearchFilter 컴포넌트가 관리하고 있었습니다.
이러한 복잡한 코드를 리팩토링하면서 먼저 SearchContainer와 SearchFilter의 코드상 결합을 끊었습니다.
이는 SearchContainer는 “유저가 지정한 필터 상태에 따라 데이터를 가져오는 것”에만 집중하고, SearchFilter에서는 필터 UI와 유저의 조작에만 집중할 수 있게 관심사와 책임을 분리해줍니다.
SearchFilter의 경우 내부 각각의 필터 UI를 컴포넌트로 추출했습니다. SearchFilter는 내부 필터들의 상태를 직접 관리하지 않고 필터 UI 컴포넌트들을 조합만 하게 됩니다.
전에는 URL에서 상태를 읽고 쓰는 것이 SearchContainer에서 API 호출을 위해 읽는 것과 SearchFilter에서 상태를 읽고 쓰는 것에 공통으로 필요하므로, 통일을 위해 의도적으로 서로를 결합했습니다. 그러나 이는 복잡하고 재사용이 어려운 코드를 만들었고, 오히려 둘 사이의 코드상 결합을 끊고 상태를 읽고 쓰는 방법을 각자 정의하는 것이 더 간단하고 버그 없는 코드를 만들 수 있었습니다.
URL에서 필터 표현식을 읽어오는 useFilter 훅
useFilter 훅은 URL에서 상태를 읽어와 필터표현식으로 만들어주는 커스텀 훅입니다.
유하에서 API 호출을 할 때 필터 조건을 넣게 됩니다. 이때 필터 조건을 필드명, 필터 오퍼레이터, 필터값으로 이루어진 3-튜플을 이용해 표현하고 이를 필터 표현식이라 부릅니다.
예를 들어 [”isContactable”, “=”, true] 는 isContactable 필드가 true인 채널만 보여달라는 것이고,
[”subscriberCount”, “>=”, 10000] 은 구독자수가 10000 이상인 채널만 보여달라는 것이 됩니다.
2번째 리팩토링 후 SearchContainer는 자체적으로 URL에서 필터 정보를 가져올 수 있었는데 아직 필터 표현식으로 만들어 가져오지는 않았습니다. 따라서 아래처럼 직접 필터 표현식을 만들고 있었습니다.
이전 코드 보기
이 중 같은 패턴을 보이는 경우가 많이 있었고 재사용 가능한 선언적인 형태로 만들어 재사용을 통해 코드 중복을 없애고 가독성을 높여 보았습니다. 그 결과가 아래의 코드입니다.
const filters = useFilter([
...filterDefFactory({
keyword: filterTypes.string.equal(),
subscriberCount: filterTypes.integer.range({ excludeNull: true }),
standardCommercialPrice: filterTypes.integer.range({ excludeNull: true }),
averageCommercialViewCount: filterTypes.integer.range({ excludeNull: true }),
priceVerified: filterTypes.boolean.equal(),
isContactable: filterTypes.boolean.equal(),
age: filterTypes.enum("10", "20", "40", "60").in(),
MCN: filterTypes.nullable.string().in(),
}),
{
queryTypes: { category: queryTypes.array(queryTypes.string) },
transform: (state) => {
if (!state.category) return [];
const categoryIdArray = getCategoryIdArrayFromLabelArray(
categoryCatalog,
state.category
);
return categoryIdArray.length ? [["channelCategory", "=", categoryIdArray]] : [];
},
},
{
queryTypes: { gender: queryTypes.stringEnum(["male", "female"]) },
transform: (state) => {
if (!state.gender) return [];
return [
["genderRatioInThread", "!=", null],
["genderRatioInThread", state.gender === "male" ? "<" : ">=", "0.5"],
];
},
},
]);
TypeScript
복사
일반적인 필터인 경우
보시면 filterDefFactory에 필터링할 필드명과 어떤 필터인지에 대해 선언적으로 넣어줄 수 있습니다. 몇 가지 예시를 보여드리면:
keyword: filterTypes.string.equal()
URL의 keyword 파라미터에 문자열이 저장되며, 채널의keyword 필드의 일치 조건으로 검색한다는 뜻입니다.
subscriberCount: filterTypes.integer.range({ excludeNull: true })
URL의 subscriberCountMin, subscriberCountMax 파라미터에 정수가 저장되며, 채널의 subscriberCount 필드가 해당 파라미터 범위 사이이며 null이 아닌 조건으로 필터링한다는 뜻입니다.
age: filterTypes.enum("10", "20", "40", "60").in()
URL의 age 파라미터에 “10”, “20”, “40”, “60” 문자열의 목록이 저장되며, age 필드가 선택한 문자열 중 일치하는 조건으로 필터링한다는 뜻입니다.
특이한 필터의 경우
위 예시를 보시면 queryTypes 와 transform 함수로 이루어진 오브젝트가 2개 있습니다.
useFilter는 원래 각 필터를 설명하는 오브젝트의 목록을 받습니다. 이 오브젝트 2개가 그것입니다.
첫 번째 오브젝트는 카테고리 필터를 선언하고, 두 번째는 성별 필터를 선언합니다.
오브젝트의queryTypes 속성은 이 필터를 표현하기 위해 URL에 저장되는 상태를 정의합니다.
위 예시의 카테고리 필터에서는 URL에 category라는 이름으로 카테고리들의 목록을 저장하게 되고, 성별 필터는 URL에 gender라는 이름으로 “male” 혹은 “female"이라는 문자열을 저장합니다.
하나의 필터가 URL에 여러 상태를 저장할 수도 있습니다.
transform 속성은 위처럼 선언한 상태를 받아 필터 표현식으로 바꾸어주는 함수입니다.
카테고리 필터에서는 URL에 저장된 카테고리의 라벨(”beauty”, “animal” 등)을 해당 카테고리의 id(uuid)로 바꾸어 필터 표현식으로 만들어줍니다.
성별 필터에서는 “male” 혹은 “female"이라는 문자열에 따라 genderRatioInThread 필드에 대한 대소 필터로 필터 표현식을 만들게 됩니다.
이처럼 URL에 저장되는 상태와,그것을 어떻게 필터 표현식으로 만드는지를 직접 정할 수 있어 유연하게 유즈케이스에 대응할 수 있도록 만들어져 있습니다. 위의 일반적인 필터의 경우에도 filterDefFactory가 결국 queryTypes와 transform 함수를 가진 오브젝트를 만들어주는 겁니다.
이처럼 일반적인 필터라면 keyword: filterTypes.string.equal() 처럼 한 줄로 쉽고 명확하게 선언하고 특이한 경우라 커스터마이징이 필요한 경우라도 유연성에 문제 없이 {queryTypes, transform} 오브젝트로 규칙적인 형태로 정의할 수 있도록 만들었습니다.
useFilter, filterDefFactory의 소스 코드
정렬 상태를 관리하는 useSort훅
정렬의 경우 어떤 필드에 대해 오름차순, 내림차순 정렬이 가능한지를 지정할 수 있는 기능을 추가해 훅으로 만들었습니다. URL에 잘못된 정렬이 들어있으면 빼고 가져옵니다.
allowed에 가능한 정렬의 종류를 넣을 수 있습니다. 앞에 +, -가 붙어있으면 해당 정렬만 가능, 안 붙어있으면 양방향으로 정렬이 가능합니다.
URL에 정렬 관련 정보가 없는 경우 사용할 정렬 기준은 defaultSort로 지정합니다.
const [sort, setSort] = useSort({
defaultSort: ["-subscriberCount"],
allowed: ["subscriberCount", "+viewCount"],
});
console.log(sort); // ["-subscriberCount"] 등 배열
TypeScript
복사
useSort의 소스 코드
페이지네이션 상태를 관리하는 usePagination 훅
페이지네이션 관련해 페이지 크기와 페이지 번호를 API 호출에 이용할 수 있게 limit, offset으로 변환해주는 기능과, 페이지 크기 변경 시 첫 번째 아이템이 계속해서 보이도록 페이저 번호를 알맞게 같이 바꿔주는 changePageSize 함수를 추가한 usePaginiation 훅을 만들었습니다.
const [{ page, pageSize, limit, offset }, setPagination, changePageSize] = usePagination({
defaultPageSize: 20,
});
// 페이지 번호, 페이지 크기 직접 변경
setPagination({page: 5, pageSize: 100});
// 페이지 크기 변경 시 첫 번째 아이템이 계속해서 보이도록 페이저 번호를 알맞게 같이 변경
changePageSize(25);
TypeScript
복사
usePagination의 소스 코드
재사용 가능한 필터 컴포넌트
필터 UI를 만들 때, 다른 컴포넌트와의 결합 없는 standalone(독립적, 자립적)한 컴포넌트들을 조합할 수 있도록 만들었습니다. SearchFilter 컴포넌트에서는 이러한 필터 컴포넌트들을 선언하기만 하지, 상태 관리에는 전혀 관여하지 않게 됩니다.
필터 UI에 필요한 로직으로는 아래와 같은 것들이 있습니다.
•
URL에서 상태를 읽어오는 로직, URL의 상태를 변경하는 로직
•
필터 변경 시 1 페이지로 이동시키는 로직
•
체크박스를 모두 선택하면 모두 선택 해제하는 로직
•
범위를 벗어난값을 입력하면 값을 제한하는 로직
이러한 로직들을 컴포넌트 안으로 숨겨 필터 UI를 구성하는 쪽을 매우 간단하게 만들었습니다. Prop으로는 필터가 URL에 저장될 이름과 옵션 몇 가지만 받습니다. 이는 부모가 여러 필터들의 상태를 관리하며 복잡해지는 문제를 해결해주고, 컴포넌트들을 쉽게 조합하어 전체 필터 패널을 구성할 수 있게 해줍니다.
이 컴포넌트들은 자체적으로 useState 등을 이용해 상태를 저장하며, 독립적으로 useQueryState를 이용해 URL에서 상태를 읽고 쓰며 동작합니다.
사용 예시
어드민의 필터 UI
<FilterContainer>
<span>캠페인 ID</span>
<TextFilter name="campaign" applyTrigger="onPressEnter" allowClear />
<span>상태</span>
<CheckboxFilter name="status" clearOnSelectAll options={STATUS_FILTER_OPTIONS} />
</FilterContainer>
TypeScript
복사
유하 유튜버 검색 화면의 필터 UI
<>
<CollapsePanel name="카테고리">
<CheckboxFilter name="category" options={categories} />
</CollapsePanel>
<CollapsePanel name="구독자수">
<NumberRangeFilter name="subscriberCount" subfix="명" min={0} />
</CollapsePanel>
<CollapsePanel name="예상 광고 단가">
<NumberRangeFilter name="standardCommercialPrice" subfix="원" min={0} />
</CollapsePanel>
<CollapsePanel name="광고 평균 조회수">
<NumberRangeFilter name="averageCommercialViewCount" subfix="회" min={0} />
</CollapsePanel>
<CollapsePanel name="단가 인증">
<CheckboxFilter
name="priceVerified"
options={checkBoxLists.priceVerified}
radioLike
/>
</CollapsePanel>
<CollapsePanel name="연락 여부">
<CheckboxFilter
name="isContactable"
options={checkBoxLists.isContactable}
radioLike
/>
</CollapsePanel>
<CollapsePanel name="시청자 설별">
<CheckboxFilter name="gender" options={checkBoxLists.gender} radioLike />
</CollapsePanel>
<CollapsePanel name="시청자 연령">
<CheckboxFilter name="age" options={checkBoxLists.age} clearOnSelectAll />
</CollapsePanel>
<CollapsePanel name="MCN">
<McnSelectFilter />
</CollapsePanel>
</>
JavaScript
복사
컴포넌트 예시: 체크박스 필터 컴포넌트
이러한 컴포넌트는 직접 구현하면 되는데, 가장 간단한 체크박스 필터의 코드를 가져와 봤습니다.
보시다시피 상태관리나 로직을 이 안에서 모두 처리하고, 여러 옵션들을 제공해 원하는 기능의 필터를 쉽게 만들 수 있도록 해줍니다.
import { CheckboxProps } from "@mui/material/Checkbox";
import Checkbox from "../atoms/Checkbox";
import { usePagination } from "@youha-eng/next-query-utils";
import { queryTypes, useQueryState } from "next-query-state";
export type CheckboxStringOptionType = { value: string; label: string; style?: React.CSSProperties };
type Props = {
/** The URL query string parameter to bind to */
name: string;
/** Whether to push or replace to url history.
* Default: push */
history?: "push" | "replace";
/** List of checkbox items. */
options: CheckboxStringOptionType[];
/** Clears all selection if all options are selected. Ignored if radiolike is selected */
clearOnSelectAll?: boolean;
/** Can select at most 1. Ignores clearOnSelectAll. */
radioLike?: boolean;
} & Omit<CheckboxProps, "value">;
export function CheckboxFilter({
name,
history = "push",
clearOnSelectAll = false,
radioLike = false,
options,
}: Props): React.ReactElement {
const [values, setValues] = useQueryState(name, queryTypes.array(queryTypes.string).withDefault([]));
const [, setPagination] = usePagination();
// When clearOnSelectAll is true, clears selection if all options are selected.
const settleValues = (values: string[]) => {
if (radioLike) return values.filter((c) => !values.includes(c));
if (clearOnSelectAll && options.every((option) => values.includes(option.value))) return [];
return values;
};
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const temp = values.includes(event.target.name) ? values.filter((v) => v !== event.target.name) : [...values, event.target.name];
const newValues = settleValues(temp);
setValues(newValues, { history });
setPagination({ page: 1 });
};
return (
<>
{options.map((option) => (
<Checkbox
key={option.label}
text={option.label}
name={option.value}
isSmall
onChange={onChange}
checked={values.includes(option.value)}
/>
))}
</>
);
}
TypeScript
복사
결과
원래의 800라인가량의 복잡하고 이해하기 힘들었던 코드에서 아래와 같이 100라인의 깔끔한 코드로 리팩토링이 되었습니다. 실제 유하의 코드는 다른 로직도 섞여있어 이것보다는 더 복잡하지만 가장 복잡했던 필터 관련 로직은 이처럼 깔끔하게 정리되었습니다.
function SearchComponent() {
// URL에서 필터 상태를 읽어오는 부분
const filters = useFilter([
...filterDefFactory({
keyword: filterTypes.string.equal(),
subscriberCount: filterTypes.integer.range({ excludeNull: true }),
standardCommercialPrice: filterTypes.integer.range({ excludeNull: true }),
averageCommercialViewCount: filterTypes.integer.range({ excludeNull: true }),
priceVerified: filterTypes.boolean.equal(),
isContactable: filterTypes.boolean.equal(),
age: filterTypes.enum("10", "20", "40", "60").in(),
MCN: filterTypes.nullable.string().in(),
}),
{
queryTypes: { category: queryTypes.array(queryTypes.string) },
transform: (state) => {
if (!state.category) return [];
const categoryIdArray = getCategoryIdArrayFromLabelArray(
categoryCatalog,
state.category
);
return categoryIdArray.length ? [["channelCategory", "=", categoryIdArray]] : [];
},
},
{
queryTypes: { gender: queryTypes.stringEnum(["male", "female"]) },
transform: (state) => {
if (!state.gender) return [];
return [
["genderRatioInThread", "!=", null],
["genderRatioInThread", state.gender === "male" ? "<" : ">=", "0.5"],
];
},
},
]);
// URL에서 정렬, 페이지 상태를 읽어오는 부분
const [sort, setSort] = useSort();
const [{ limit, offset }] = usePagination();
// API 호출을 통해 데이터를 가져오는 부분
const data = useDataFromAPI({ filters, sort, limit, offset });
return (
<>
<SearchFilter />
<SearchTable data={data} />
</>
);
}
function SearchFilter() {
return (
<>
<CollapsePanel name="카테고리">
<CheckboxFilter name="category" options={categories} />
</CollapsePanel>
<CollapsePanel name="구독자수">
<NumberRangeFilter name="subscriberCount" subfix="명" min={0} />
</CollapsePanel>
<CollapsePanel name="예상 광고 단가">
<NumberRangeFilter name="standardCommercialPrice" subfix="원" min={0} />
</CollapsePanel>
<CollapsePanel name="광고 평균 조회수">
<NumberRangeFilter name="averageCommercialViewCount" subfix="회" min={0} />
</CollapsePanel>
<CollapsePanel name="단가 인증">
<CheckboxFilter
name="priceVerified"
options={checkBoxLists.priceVerified}
radioLike
/>
</CollapsePanel>
<CollapsePanel name="연락 여부">
<CheckboxFilter
name="isContactable"
options={checkBoxLists.isContactable}
radioLike
/>
</CollapsePanel>
<CollapsePanel name="시청자 설별">
<CheckboxFilter name="gender" options={checkBoxLists.gender} radioLike />
</CollapsePanel>
<CollapsePanel name="시청자 연령">
<CheckboxFilter name="age" options={checkBoxLists.age} clearOnSelectAll />
</CollapsePanel>
<CollapsePanel name="MCN">
<McnSelectFilter />
</CollapsePanel>
</>
);
}
JavaScript
복사
@youha-eng/next-query-utils 패키지
위에서 설명한 커스텀 훅들을 이렇게 패키지로 만들었습니다. 그러나 유하에 한정된 코드가 들어있어 외부에 사용을 권하기에 애매해 일단은 내부용으로만 사용하고 있습니다.
필터 컴포넌트들의 경우 아직 충분히 성숙되지 않았고 디자인이나 요구사항이 프로젝트 별로 크게 다를 것이라 생각되어 아직 패키지로 만들지 않았습니다.
글 목록
저자소개
이동엽은 Full stack 개발자로서 유하 서비스의 기틀을 마련하였고, YOUHA market이란 시험 서비스의 Lead engineer를 맡았으며, YOUHA Boost 프로토타입의 백엔드를 구축하는 등 주로 새로운 제품을 설계하고 개척하는 임무를 많이 맡았습니다. 새로운 기술에 대한 도전을 끊임없이 하면서 깊이 있는 이해를 가지려고 노력하고 있습니다.