인턴 기간 동안 검색 패널 컴포넌트를 만드는 작업을 진행했었다.
인턴을 마무리할 때쯤 '더 좋은 다른 구조가 있을 것 같은데...'라는 생각을 했었는데, 인턴 끝나고 이것저것 하다보니 제대로 보지 못해서 한번 전체적으로 정리해두려 한다.
비슷한 UI를 가지고 같은 기능(1. 검색 필터 설정, 2. 검색어 입력, 3. 검색)을 하는 검색 패널
이 각 페이지 별로 구현되어 있었다.
검색 패널 구성은 굉장히 다양했는데, 그 중 일부를 예시로 그려왔다.
검색 패널 구성 예시
이 검색 패널을 컴포넌트로 만들어서 여러 페이지에서 사용할 수 있게 해야 했다.
그리고 기존에는 각 검색 필드들이 state로 관리되고 있어 상태가 변경될 때마다 검색 api가 호출되고 결과가 바로 보이는 형태였는데,
때문에 각 검색 필드들을 ref로 관리하게끔 수정하기로 했다.
요구사항을 정리해보면 다음과 같다.
일단은 한 페이지의 검색 패널을 기준으로 삼아 컴포넌트를 만들어보고, 다른 페이지에서 필요한 요소들을 추가해가면서 추상화해보려고 했다.
위에서 본 검색 패널 구성 예시 사진에서와 같이, 검색 패널은 DatePicker, Selector, Input, RangeSelector 등 다양한 요소를 가진다.
그래서 DatePicker
, Selector
, Input
등의 컴포넌트들을 조각조각 만들어놓고, 이것들을 조합해서 쓰면 되지 않을까? 하는 생각이 들었다.
마침 원티드 프리온보딩 강의에서 CCP에 대해 들었었고, shadcn/ui
에서도 CCP로 코드가 짜여 있었다는게 생각나서 이 패턴을 적용해보기로 했다.
검색 패널의 틀에 해당하는 컴포넌트이다.
검색 필드에 해당하는 컴포넌트들은 children으로 들어오고, 검색 버튼과 검색 조건 초기화 버튼을 클릭하면 props로 전달한 함수를 실행한다.
지금 보니 검색/검색 초기화 버튼도 하나의 컴포넌트로 빼는 게 나았을까? 하는 생각이 든다.
import { DateFilter } from './DateFilter';
import { SelectFilter } from './SelectFilter';
import { RangeFilter } from './RangeFilter';
import { Input } from './Input';
import { StatusFilter } from './StatusFilter';
interface Props {
onSearch: () => void;
onReset?: () => void;
children: React.ReactNode;
}
const SearchContainer = ({
onSearch,
onReset,
children,
}: Props) => {
return (
<div className="input_area">
{children}
{onReset ? (
<div className="btn_area">
<button className="btn white" onClick={onReset}>
초기화
</button>
<button className="btn black" onClick={onSearch}>
검색
</button>
</div>
) : (
<button className="btn" onClick={onSearch}>
검색
</button>
)}
</div>
);
};
SearchContainer.SelectFilter = SelectFilter;
SearchContainer.DateFilter = DateFilter;
SearchContainer.Input = Input;
SearchContainer.RangeFilter = RangeFilter;
SearchContainer.StatusFilter = StatusFilter;
export default SearchContainer;
검색 필드는 상위 컴포넌트에서 받은 ref로 참조한다. 이 ref에 담기는 값이 곧 검색 api 요청에 필요한 파라미터들이다.
그 외에 컴포넌트 내부에서 관리될 수 있는 것들은 최대한 외부로 노출시키지 않으려고 했다.
(아래는 검색 필드 컴포넌트 중 SelectFilter를 가져왔다.)
// ...
interface SelectFilterType {
label?: string;
options: Options;
// ...
}
export const SelectFilter = forwardRef(
(
{
label,
options,
// ...
}: SelectFilterType,
ref: React.ForwardedRef<string | null>,
) => {
const [selectedValue, setSelectedValue] = useState<Option | null>(
options.find(
(option) => option.value === '' || option.label === '전체',
) || null,
);
const handleChange = (option: Option | null): void => {
setSelectedValue(option);
if (ref && typeof ref === 'object' && 'current' in ref) {
ref.current = option?.value ?? null;
}
};
return (
<>
{label && <label>{label}</label>}
<Select<Option>
className={`react-select-container ${selectClassNames}`}
classNamePrefix="react-select"
options={options}
onChange={handleChange}
value={selectedValue}
// ...
/>
</>
);
},
);
SelectFilter.displayName = 'SelectFilter';
검색 패널 컴포넌트이다. 검색에 필요한 파라미터들의 ref를 선언하고, SearchContainer 내부에 필요한 컴포넌트들을 조합하고 필요한 props를 넘겨주어 구성했다.
const SearchForm = ({ onSearch, searchParamsRef }: Props) => {
const { options } = useOptions();
return (
<SearchContainer onSearch={onSearch}>
<div className="input_box calendar">
<SearchContainer.DateFilter
label="라벨명"
startDateRef={searchParamsRef.startDateRef}
endDateRef={searchParamsRef.endDateRef}
/>
<SearchContainer.SelectFilter
label="라벨명"
ref={searchParamsRef.optionRef}
options={options}
isSearchable={false}
/>
<div className="input_box sch">
<SearchContainer.SelectFilter
label="검색조건"
ref={searchParamsRef.searchConditionRef}
options={[
{ value: '값', label: '라벨명' },
{ value: '값', label: '라벨명' },
{ value: '값' label: '라벨명' },
]}
/>
<SearchContainer.Input
ref={searchParamsRef.searchInputRef}
placeholder="검색어를 입력해주세요."
minLength={1}
maxLength={50}
/>
</div>
</div>
</SearchContainer>
);
};
export default SearchForm;
그리고 나서 검색 관련 로직들을 처리할 훅을 만들었는데, 이 훅은 3가지를 담당한다.
검색을 하는데 필요한 파라미터 중 일부 고정적인 값들이 있었는데 이 값들은 useSearchForm 내에서 선언했고, 페이지마다 달라지는 필드는 추가적으로 선언하는 형태이다.
const useSearchForm = () => {
const { userId } = useUserSession();
const [searchResultTotalCnt, setSearchResultTotalCnt] = useState(0);
const [searchResult, setSearchResult] = useState([]);
// 기본 필드
const id = userId;
// ...
const baseParams: BaseParams = {
id,
// ...
};
// 추가 검색 필드는 ref로 관리
const startDateRef = useRef<string | null>(null);
const endDateRef = useRef<string | null>(null);
const optionRef = useRef<string | null>(null);
const searchConditionRef = useRef<string | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const searchParamsRef = {
startDateRef,
endDateRef,
optionRef,
searchConditionRef,
searchInputRef,
}
const searchParams = {
startDate: startDateRef.current || '',
endDate: endDateRef.current || '',
option: optionRef.current || '',
searchCondition: searchConditionRef.current || '',
searchInput: searchInputRef.current || '',
}
// 검색 api 요청
const onSearch = async (page: number = 1, limit: number = 20) => {
if (id === null) return;
const formData = { ...baseParams, ...searchParams, page, limit };
try {
const response = await fetch(apiUrl, {
method: 'POST',
body: JSON.stringify(formData),
headers: {
'Content-Type': 'application/json',
},
});
const res = await response.json();
if (res.status == '200') {
setSearchResultTotalCnt(res.countResult.cnt);
setSearchResult(res.mainResult);
} else {
// 에러 처리
}
} catch (error) {
console.log('에러 안내 문구');
}
};
useEffect(() => {
onSearch();
}, [id]);
return {
baseParams,
searchParams,
searchParamsRef,
onSearch,
searchResult,
searchResultTotalCnt,
};
};
export default useSearchForm;
실제 페이지에서는 아래와 같이 선언하여 사용할 수 있다.
export default function Page() {
const {
baseParams,
searchParams,
searchParamsRef,
onSearch,
searchResult,
searchResultTotalCnt,
} = useSearchForm();
// ...
return (
/* ... */
<SearchForm onSearch={onSearch} searchParamsRef={searchParamsRef} />
/* ... */
)
}
처음 구현을 마치고 보니 CCP를 적용한 것과 state를 ref로 바꾼 것 외에는 이전과 큰 차이가 없었다.
가장 큰 문제는 여전히 페이지 별로 SearchForm
과 useSearchForm
을 새로 만들어야 한다는 점이었다.
지금까지는 코드를 잘 구조화했을 뿐이고, 이제 문제는 SearchForm과 useSearchForm을 다른 페이지에서도 사용할 수 있도록 하는 것이었다.
그러려면 SearchForm과 useSearchForm 내부에 존재하던 값들을 외부에서 주입받는 형태로 수정해야 했다.
useSearchForm 훅을 수정하는 것은 어렵지 않았다.
검색 필드들을 가리키는 ref는 상위 컴포넌트에서 선언하고, props로 내려받은 getter 함수를 사용해 ref의 current를 참조하여 검색 요청을 날리면 된다. 그리고 apiUrl 또한 상위 컴포넌트에서 props로 받아온다.
interface Props<T> {
getSearchParams: () => T;
apiUrl: string;
}
const useSearchForm = <T extends Record<string, string | number>>({
getSearchParams,
apiUrl,
}: Props<T>) => {
const { userId } = useUserSession();
const [searchResultTotalCnt, setSearchResultTotalCnt] = useState(0);
const [searchResult, setSearchResult] = useState([]);
// 기본 필드
const id = userId;
// ...
const baseParams: BaseParams = {
id,
// ...
};
// 검색 api 요청
const onSearch = async (page: number = 1, limit: number = 20) => {
if (id === null) return;
const formData = { ...baseParams, ...getSearchParams(), page, limit };
try {
const response = await fetch(apiUrl, {
method: 'POST',
body: JSON.stringify(formData),
headers: {
'Content-Type': 'application/json',
},
});
const res = await response.json();
if (res.status == '200') {
setSearchResultTotalCnt(res.countResult.cnt);
setSearchResult(res.mainResult);
} else {
// 에러 처리
}
} catch (error) {
// 에러 처리
}
};
useEffect(() => {
onSearch();
}, [id]);
return {
baseParams,
onSearch,
searchResult,
searchResultTotalCnt,
};
};
export default useSearchForm;
이놈이 아주 골치가 아팠다...
최대한 이미 만들어져 있는 css를 변경하지 않고 만들어야 했는데, 이 때문에 구조를 어떻게 잡아야 할 지 고민을 많이 했다.
퍼블리시 담당 과장님께 도움을 요청했는데, 사실상 과장님께서도 공통화를 하려고 하셨는데 여건 상 어려워서 점점 class가 늘어나게 되었다고...
나는 이때 정말.. 좌절했다..
그렇지만 어떻게든 해보자는 생각으로 일단 해봤다.
일단 내가 세운 목표를 달성하려면 검색 패널을 구성하는 검색 필드를 표현할 데이터의 구조를 만들어야 했다.
결과적으로 SearchForm은 다음과 같이 수정했다.
interface Props {
onSearch: () => void;
fields: FieldProps;
inputAreaClassNames?: string;
}
const SearchForm = ({ onSearch, fields, inputAreaClassNames }: Props) => {
// type에 따라 검색 필드를 렌더링하는 함수
const renderField = (field: Field, index: number) => {
// ...
}
};
return (
<SearchContainer
onSearch={onSearch}
inputAreaClassNames={inputAreaClassNames}>
{fields.map((fieldGroup: Field[], index: number) => {
if (index === fields.length - 1) {
return (
<div key={index} className="input_row input_box sch">
{fieldGroup.map((field: Field, index: number) =>
renderField(field, index),
)}
</div>
);
} else {
return (
<div key={index} className="input_row">
{fieldGroup.map((field: Field, index: number) =>
renderField(field, index),
)}
</div>
);
}
})}
</SearchContainer>
);
};
export default SearchForm;
검색 필드에 관한 정보를 담을 fields의 타입은 다음과 같이 구성했다.
export type FieldProps = Field[][];
export type Field =
| {
type: 'DATE';
label?: string;
ref: [React.RefObject<string | null>, React.RefObject<string | null>];
}
| {
type: 'SELECT';
label?: string;
ref: React.RefObject<string | null>;
options: Options;
}
| {
type: 'INPUT';
ref: React.RefObject<HTMLInputElement>;
placeholder?: string;
minLength?: number;
maxLength?: number;
};
FieldProps를 2차원 배열로 만든 이유는 스타일링 때문이다.
내부 배열은 하나의 input_row
요소 내부(검색 패널 구성 예시에서 한 행이라고 보면 된다.)에 존재하는 필드들이 되는 것이다.
그리고 renderField()
함수를 통해 type에 따라 필요한 컴포넌트를 내보내준다. 이때 각 컴포넌트에 따라 필요한 class가 달라서 div로 래핑되어 있는 것을 볼 수 있다.
const renderField = (field: Field, index: number) => {
switch (field.type) {
case 'DATE':
const [startDateRef, endDateRef] = field.ref;
return (
<div key={index} className="input_box calendar">
<SearchContainer.DateFilter
label={field.label}
startDateRef={startDateRef}
endDateRef={endDateRef}
/>
</div>
);
case 'SELECT':
return (
<div key={index} className="input_box">
<SearchContainer.SelectFilter
label={field.label}
options={field.options!}
ref={field.ref}
/>
</div>
);
case 'INPUT':
return (
<Fragment key={index}>
<SearchContainer.Input
ref={field.ref}
placeholder={field.placeholder || ''}
minLength={field.minLength || 1}
maxLength={field.maxLength || 50}
/>
</Fragment>
);
default:
return null;
}
};
최종적으로 SearchForm이 리턴하는 것은 renderField() 함수로부터 받은 컴포넌트들을 조합해 만든 검색 패널이다. 여기에서도 css가 다르게 적용되어야 하기에 하이라이트 한 부분이 다른 것을 볼 수 있다.
<SearchContainer
onSearch={onSearch}
inputAreaClassNames={inputAreaClassNames}>
{fields.map((fieldGroup: Field[], index: number) => {
if (index === fields.length - 1) {
return (
<div key={index} className="input_row input_box sch">
{fieldGroup.map((field: Field, index: number) =>
renderField(field, index),
)}
</div>
);
} else {
return (
<div key={index} className="input_row">
{fieldGroup.map((field: Field, index: number) =>
renderField(field, index),
)}
</div>
);
}
})}
</SearchContainer>
결과적으로 검색 패널이 필요한 컴포넌트에서는 다음과 같이 사용할 수 있다.
필요한 검색 필드들의 ref
, getSearchParams 함수
, fields
를 작성하고, 이 값들을 useSearchForm과 SearchForm에 잘 넘겨주기만 하면 된다.
export default function Page() {
const router = useRouter();
const { options } = useOptions();
// ref로 검색 필드 관리
const startDateRef = useRef<string | null>(null);
const endDateRef = useRef<string | null>(null);
const optionRef = useRef<string | null>(null);
const searchConditionRef = useRef<string | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
// ref의 current 값 참조
const getSearchParams = () => ({
startDate: startDateRef.current || '',
endDate: endDateRef.current || '',
option: optionRef.current || '',
searchCondition: searchConditionRef.current || '',
searchKeyword: searchInputRef.current?.value || '',
});
const { baseParams, onSearch, searchResult, searchResultTotalCnt } = useSearchForm({ getSearchParams, apiUrl: 'api 주소' });
const fields: FieldProps = [
[
{
type: 'DATE',
label: '라벨명',
ref: [startDateRef, endDateRef],
},
{
type: 'SELECT',
label: '라벨명',
ref: optionRef,
options: options,
},
],
[
{
type: 'SELECT',
label: '검색조건',
ref: searchConditionRef,
options: useMemo(
() => [
{ value: '값', label: '라벨명' },
{ value: '값', label: '라벨명' },
{ value: '깂', label: '라벨명' },
],
[],
),
},
{
type: 'INPUT',
ref: searchInputRef,
placeholder: '검색어를 입력해주세요.',
minLength: 1,
maxLength: 50,
},
],
];
// ...
return (
<>
<div>
{/* ... */}
<SearchForm
onSearch={() => onSearch(currentPage, itemsPerPage)}
fields={fields}
inputAreaClassNames="type1"
/>
{/* ... */}
</div>
</>
);
}
이렇게 해서, 일단 큰 목표는 달성할 수 있었다.
그런데 다시 보면 각 훅, 컴포넌트들이 제 역할만 제대로 하고 있는지, 불필요하게 서로 간의 결합이 있지는 않은지 확신을 가지지 못하겠는 부분들이 많다.
그리고 백지에서 시작해 필요한 값들을 추가해가면서 확장한 게 아니라, 이미 작성된 코드가 있는 상태에서 그것들을 잘 숨기고 포장하는 느낌으로 간 것이라서 확장성이 있는 코드인지는 모르겠다. 무엇보다 css 스타일링 때문에 코드에 명확하지 않은 제약들이 많이 추가된 것 같아 아쉽다.
검색 컴포넌트를 만드는 과정에서 해결하기 어려웠던 점들이 많았다.
그중에서도 어려웠던 과제들이 있었는데, 3가지 정도 뽑아볼 수 있겠다.
이 작업을 통해 실제 프로덕션 환경에서의 리팩토링이 얼마나 복잡할 수 있는지 배웠다. 특히 리팩토링의 목적을 항상 염두에 두어야 한다는 점을 깨달았다. 목적을 잊어버리는 순간 리팩토링을 위한 리팩토링을 하게 되기 때문이다.
비록 완벽하지는 않지만, 이 경험을 통해 컴포넌트 설계와 추상화에 대해 많이 배울 수 있었다. 앞으로도 이런 도전적인 과제들을 통해 더 성장할 수 있기를 기대한다.