안녕하세요. 프론트엔드 개발자 송상현입니다.
프론트엔드 개발을 하다보면 복잡한 요구사항의 컴포넌트를 제작할 일이 종종 있습니다.
복잡한 컴포넌트라는게 무엇인지 생각을 해본다면 서버에서 내려준 데이터를 UI로 표현하는게 복잡할 수도 있고, 유저의 액션에 따라 생겨나는 서버 통신 및 UI 변경점들이 복잡할 수도 있습니다. 사실 눈으로 딱 봤을때 "아 이거 어떻게 만들어야하나" 생각이 먼저 들면 복잡한 컴포넌트가 아닐까 싶네요.
복잡한 컴포넌트는 작은 변경사항들에도 대처하기 어렵습니다. 기능을 새로 추가하기도 어렵고, 추가하더라도 어떻게 추가하지... 라는 고민이 길어집니다. 만약 컴포넌트에 버그라도 일어날 땐 남의 코드라면 물론이거니와 심지어 내 코드라도 원인 파악하는게 어려운 경우가 비일비재하죠.
만들어 놓고 잘 돌아는 가는거 같은데... 뭔가 더 손쓰기는 무섭고, 수정사항이 없기를 바라며 봉인해 놓은 복잡한 컴포넌트들은 그렇게 하나씩 생겨납니다.
회사 업무를 통해 제작한 꽤 복잡했던 컴포넌트를 실례로, 복잡한 컴포넌트를 "어떻게 잘 설계할 수 있을까"에 대한 저의 고민과 경험들을 풀어보고자 합니다.
어떻게 하면 잘 설계할 수 있을지 고민하기 앞서, 반대로 잘못 설계한 컴포넌트는 어떤 건지 먼저 알아봅시다.
const 컴포넌트_A_Button = ({ name }) => (
<Button>
{name}
</Button>
)
const 컴포넌트_A = () => {
const alphaData = useAlphaData();
return (
<Wrapper>
{alphaData.map((alpha) => <컴포넌트_A_Button name={alpha.name} />}
</Wrapper>
)
}
컴포넌트의 잘못된 설계가 눈에 들어오시나요? 만약 그렇다면 어떤 근거로 설계가 잘못됐다고 말할 수 있을까요?
위와 같은 컴포넌트를 만든 후 다음과 같은 요청사항들을 받았다고 생각해봅시다.
<컴포넌트_A_Button>
대신 <B_Button>
도 그릴 수 있게 수정해주세요!<B_Button>
어디에 만들어야 할까요? 컴포넌트 안에 만들기엔 다른 곳에서 재사용될 컴포넌트인데...<컴포넌트_A_Button>
이거 이 페이지랑 디자인 똑같은데 갖다 쓸 수 있을까요?<컴포넌트_A_Button>
밖으로 빼야할까요? 그런데 이름이 <컴포넌트_A>
와 연관돼있어서...<컴포넌트_A>
에 alphaData
대신 betaData
데이터도 적용할 수 있을까요?요청사항들이 그렇게 유별나지 않는데도 대처하기가 쉽지 않습니다. 온갖 변경사항들에 유연하지 못하고 다른 곳에서 재사용하기도 어렵습니다. 컴포넌트를 설계한 사람이 이러한 변경사항들을 예상하지 못한 것 같네요.
이번엔 다행히 10줄 내외의 코드라 뜯어고쳐도 금방이지만, 만약 복잡한 컴포넌트였다면 같은 상황이 굉장히 스트레스일 것 같습니다.
복잡한 컴포넌트라면 설계부터 잘 해야된다는 마음이 더더욱 강하게 드네요.
잘 설계한다는 말이 사실 참 거창하죠? 오히려 단순하게 생각해봅시다. 이 글에서는 딱 하나의 기준을 두고 이 거창한 목표를 향해 나아가려고 해요.
바로 "의존성" 입니다.
복잡한 컴포넌트를 설계할 때 가장 중요하게 생각해야 하는 키워드는 "의존성" 입니다. 좀 더 정확히 말하면 "의존성 최소화" 입니다.
쉽게 말해 다른 것에 의존하지 않을수록 좋은 컴포넌트입니다. 의존 관계가 컴포넌트와 컴포넌트일수도, 데이터와 컴포넌트일수도, 유저 액션과 컴포넌트일수도 있지만 뭐가됐든 다른 것에 의존하지 않을 수록 잘 설계된 컴포넌트라고 할 수 있습니다.
컴포넌트가 다른 것에 의존하지 않는다면 얻는 장점이 뭐가 있을까요? 그 장점들이 과연 복잡한 컴포넌트를 만들 때 충분히 좋은 장점이 될까요?
컴포넌트가 다른 것에 의존하지 않는다는 것은, 다시 말하면 주어진 하나의 역할만을 잘 수행한다는 뜻입니다. 다른것에 의존하지 않고 자신의 역할만 잘 하는 컴포넌트는 코드의 구현부를 빠르게 파악할 수 있고, 심지어 컴포넌트 이름만으로 그 목적을 직관적으로 이해할 수 있습니다.
서비스는 변화합니다. 언제든지 요구사항이 추가되거나 변경될 수 있기에 모든 컴포넌트는 변경 가능성을 염두에 두고 설계해야합니다. 의존성이 적을수록 컴포넌트를 수정하거나 유지보수하는데 더 적은 비용이 듭니다. 또한 의존성이 적을수록 특정 컴포넌트를 수정할 때 코드 변경이 다른 컴포넌트에 영향을 미치지 않기 때문에 컴포넌트를 수정하더라도 다른 부분에 문제가 발생할 확률이 줄어듭니다.
한 가지 역할만 하고 다른 것에 의존하지 않는 컴포넌트는 다른 곳에 재사용할 수 있는 가능성이 높아집니다. 이는 생산성 증가로 이어지고, 덤으로 코드 중복이 줄어들면서 코드의 양도 줄어듭니다.
의존성이 적은 컴포넌트는 다른 컴포넌트에 영향을 받지 않기 때문에 해당 컴포넌트만을 테스트할 수 있고, 테스트 케이스를 작성하기 쉽습니다. 다양한 환경에서 UI가 잘 동작하는지를 확인해야 하기 때문에, 테스트하기 쉬운 컴포넌트를 만드는 것은 중요합니다.
어디서 많이 들어본...
어디서 많이 들어본 교과서적인 장점들이지만, 의존성을 최소화하는 방향으로 컴포넌트를 설계한다면 위에서 잘못 설계한 컴포넌트의 문제들을 모두 해결할 수 있습니다.
이제 추상적인 이야기를 벗어나 직접 컴포넌트를 설계하며 이야기해볼까요?
"아 이거 어떻게 만들어야하나"
우리가 만들 복잡한 컴포넌트는 여러 단계로 펼쳐지는 체크박스 기능이 있는 필터입니다. 회사에서 한국 스타트업의 빅데이터를 제공하는 웹 서비스를 만들 때, 유저가 원하는 분야의 기업만 필터링해서 볼 수 있게 하기 위한 목적으로 제작했습니다.
마우스를 hover 하면 하위 분류의 리스트가 펼쳐지고, 아이템을 클릭하면 클릭한 아이템 뿐만 아니라 상/하위 분류의 아이템 체크박스 UI도 함께 변경됩니다.
이와 같은 아이템 리스트를 각각 '콤보박스 (Combobox)' 라고 하고, 콤보박스가 여러 단계로 펼쳐지므로 '중첩 콤보박스 (NestedCombobox)'라고 이름 짓겠습니다.
위 영상의 컴포넌트를 바탕으로 요구사항들을 뽑아내 봅시다.
- 모든 아이템 데이터는 id, name, count(기업 수) 정보를 가진다.
- 모든 아이템 데이터는 isChecked 또는 isIndeterminate(미정) boolean 상태를 가진다.
- 아이템 데이터는 하위 분류가 있으면 children(아이템 데이터 리스트)을 가진다.
- 중첩 콤보박스(NestedCombobox)는 각 depth에 해당하는 아이템 리스트가 보여진다.
- 아이템(Item)은 펼쳐질 수 있는 'DropdownItem', 더 이상 펼쳐지지 않고 클릭만 가능한 'OptionItem' 두 종류가 있다.
- Item 의 체크 상태는 '체크 X', '체크 O', '미정' 중 하나의 상태를 가진다.
- DropdownItem 은 체크박스, 이름, ArrowIcon이 표시된다.
- OptionItem 은 체크박스, 이름, count가 표시된다.
- DropdownItem 에 hover 하면 하위 depth의 Item 리스트가 펼쳐진다.
- hover 중인 Item은 active-background-color가 적용된다.
- NestedCombobox가 펼쳐져 있을 때, 펼쳐진 리스트의 모든 상위 분류 Item도 active-background-color가 적용된다.
- 모든 Item은 클릭가능하고 체크박스를 on/off 할 수 있다.
- 상위 Item이 클릭되면 모든 하위 Item도 따라 on/off 된다.
- 하위 콤보박스의 일부 Item이 클릭되면 상위 아이템의 체크박스는 'indeterminate(미정)' 상태가 된다.
.....
복잡하고... 많다...
복잡한 컴포넌트 속에서 요구사항을 뽑아내고, 뽑아낸 요구사항을 바탕으로 비즈니스 로직과 컴포넌트를 분류하는 건 프론트엔드 개발자의 중요한 역량 중 하나입니다.
컴포넌트가 완성된 후 변경사항에 얼마나 유연하게 대처할 수 있는가는 결국 처음에 어떻게 분류하고 설계했냐에 달려있습니다.
협업 측면에서도, 개발해야 할 꼭지들이 서로 의존성 없이 잘 분류된다면 개발 전 테스크 분배를 명확히 할 수 있습니다. 의존성 최소화에 대한 관점이 컴포넌트 설계 자체 뿐 아니라 협업 과정에도 도움이 되네요!
실제로 중첩 콤보박스를 개발할 때 동료 프론트개발자는 비즈니스 로직을 개발하고, 저는 UI 컴포넌트를 개발했습니다. 맡은 업무들이 서로 의존성이 없었기 때문에 영향범위를 신경 쓰지 않고 개발할 수 있었습니다.
이제 뽑아낸 요구사항들을 설계할 컴포넌트 및 비즈니스 로직에 할당해봅시다.
1. 비즈니스 로직
서버에서 가져온 데이터를 중첩 콤보박스 컴포넌트에 삽입할 수 있는 데이터 형태로 파싱하고, 유저 액션에 따라 데이터를 수정함
id
, name
, count(기업 수)
정보를 가진다.isChecked
또는 isIndeterminate(미정)
상태를 가진다.children
(아이템 데이터 리스트)을 가진다.Item
은 클릭가능하고 체크박스를 on/off 할 수 있다.Item
이 클릭되면 모든 하위 Item
도 따라 on/off 된다.Item
이 클릭되면 상위 아이템의 체크박스는 미정 상태가 된다.2. 체크박스 아이템 <CheckboxItem>
중첩 콤보박스에서 리스트로 보여지는 아이템 컴포넌트
DropdownItem
, 더 이상 펼쳐지지 않고 클릭만 가능한 OptionItem
두 종류가 있다.<CheckboxItem>
의 체크 상태는 '체크 X', '체크 O', '미정' 중 하나의 상태를 가진다.DropdownItem
은 체크박스
, name
, ArrowIcon
이 표시된다.OptionItem
은 체크박스
, name
, count
가 표시된다.hover
중인 Item
은 active-background-color가
적용된다.3. 중첩 콤보박스 <NestedCombobox>
트리 형태의 데이터를 재귀적으로 펼쳐 보여주는 컴포넌트
<NestedCombobox>
는 각 depth
에 해당하는 아이템 리스트가 보여진다.DropdownItem
에 hover
하면 하위 depth
의 아이템 리스트가 펼쳐진다.<NestedCombobox>
가 펼쳐져 있을 때, 펼쳐진 리스트의 모든 상위 분류 아이템도 active-background-color
가 적용된다.컴포넌트를 <CheckboxItem>
과 <NestedCombobox>
둘로 나누었습니다.
<CheckboxItem>
은 콤보박스 내부에 있는 유저가 클릭할 수 있는 최소 단위 컴포넌트고, <NestedCombobox>
는 주어진 아이템 리스트를 중첩 콤보박스 규칙에 따라 보여주는 상자와 같은 컴포넌트입니다.
굳이 분리해야하나?
<NestedCombobox>
와 <CheckboxItem>
은 언뜻 보면 강하게 얽혀 있어 분리가 어려워 보이기도 하고 굳이 분리해야 하나 생각이 들기도 합니다. 하지만 잘 분리해 두 컴포넌트가 서로 의존성 없게 만들어지면 위에서 말한 의존성이 없는 컴포넌트의 장점들을 활용할 수 있습니다.
프론트엔드의 전체 사이클을 컴포넌트 관점에서 보면 결국 다음 두 가지의 반복입니다.
이 중에서 비즈니스 로직에 해당되는 부분은
가 됩니다.
비즈니스 로직을 설계할 때 최소한으로 필요한 구성 요소는 다음과 같습니다.
이 중 가장 먼저 생각해야 하는 건 1번인 데이터 자체입니다. 우리가 읽고 수정할 데이터 형태가 결정되어야 후에 어떻게 가져오고 변경할지 결정할 수 있기 때문입니다.
UI를 다시 보면서 중첩 콤보박스 컴포넌트에 쏟아줄 데이터 형태를 구상해봅시다.
UI를 구성하는데 필요한 기본적인 상태들(id
, name
, count
, 체크상태
, 미정상태
)을 가지고 있고 하위 분류가 존재하면 children
에 같은 형태의 데이터가 재귀 형태로 반복되게 설계하면 될 것 같네요.
type CheckboxItemData = {
id: string;
name: string;
isChecked: boolean;
isIndeterminate: boolean;
count: string;
children?: CheckboxItemData[];
}
[
{
id: '1',
name: 'IT',
isChecked: false,
isIndeterminate: false, // 자식 콤보박스의 일부가 select 돼 있는 경우 true
count: 123,
children: [
{
id: '4',
name: '데이터',
//...
children: [],
},
{
id: '5',
name: '소프트웨어/앱/웹',
//...
children: [
{
id: '6',
name: '소프트웨어',
//...
children: [],
},
{
id: '7',
name: '앱',
//...
children: [],
},
//...
],
},
],
},
{
id: '2',
name: '교육',
//...
children: [
//...
],
},
{
id: '3',
name: '금융',
//...
children: [
//...
],
},
];
중첩 콤보박스에 필요한 데이터의 형태가 정해졌습니다.id
, name
과 같은 기본적인 메타 데이터는 서버에서 받아오고, 받아온 데이터들을 위와 같은 형태의 트리 형태로 파싱하는 과정이 필요할 것 같습니다.
1. 데이터 자체
2. 특정 데이터를 가져오는 기능
3. 특정 데이터를 변경하는 기능
첫 단계를 마쳤으니 이제 데이터를 가져오고 변경하는 비즈니스 로직을 만들어봅시다.
중첩 콤보박스에 부어질 데이터를 관리하는 Controller Hook 입니다.
컴포넌트 설계에 관한 글이기에 비즈니스 로직의 자세한 내부 구현은 생략합니다.
useCheckboxItemDataController.ts
export type FindById = (id: string) => Item;
export type SelectOne = (id: string) => void;
const useCheckboxItemDataController = (key: string) => {
// 서버에서 key에 해당하는 데이터를 받아와 위에 정의한 Item 타입의 Tree 형태로 파싱하는 로직 생략
const items: CheckboxItemData[] = // ...
// id에 해당하는 아이템을 반환한다.
const findById: FindById = (id: string) => {
// ...생략
return item;
}
// id에 해당하는 아이템을 action한다. checkbox on/off 기능. items가 update 됨.
const selctOne: SelectOne = (id: string) => {
// ...생략
}
return {
items, // data
findById, // 가져오기
selctOne, // 변경하기
}
}
export default useCheckboxItemDataController;
데이터를 수정하는 selectOne
메소드는 특정 아이템이 선택되면 아래처럼 상위 분류와 하위 분류 데이터가 함께 변경되도록 로직을 작성해야합니다.
아래처럼 특정 key에 따른 데이터와 데이터를 관리하는 메소드들을 받아 사용합니다.
const { items, findById, selectOne } = useCheckboxItemDataController('비즈니스_분야');
const { items, findById, selectOne } = useCheckboxItemDataController('활용기술');
데이터를 관리하는 비즈니스 로직이 완성됐으니, 이제 실제 데이터가 보여지고 유저 액션이 일어나는 컴포넌트를 제작할 차례입니다.
만들어야 할 컴포넌트는 '중첩 콤보박스(<NestedCombobox>
)'와 '체크박스 아이템(<CheckboxItem>
)'인데 체크박스 아이템 컴포넌트를 먼저 만들도록 하겠습니다.
만드는 순서가 중요할까?
라는 생각이 들 수 있지만 복잡한 컴포넌트를 제작할 때는 가장 작은 컴포넌트를 시작으로 상위 컴포넌트로 올라가며 제작하는 "상향식(bottom-up)" 으로 제작하는 것이 좋습니다.
상향식의 특징은 작은 단위의 컴포넌트들 만들 때 상위 컴포넌트의 형태에 얽매이지 않고 그 자체로 필요한 UI 요구사항만을 고려해서 만들 수 있다는 점입니다. 비유하자면 완성품을 신경쓰지 않고 그 자체로 완전한 레고 조각을 먼저 만드는 과정이라고 할까요.
상향식으로 작업하면 컴포넌트 이름도 더 잘 지을 수 있습니다. 컴포넌트를 빠르게 이해할 수 있는 첫걸음은 컴포넌트의 이름입니다. 컴포넌트 역할에 부합하는 직관적인 이름은 컴포넌트를 빠르게 파악할 수 있게 하고, 협업을 고려하면 더욱 더 중요합니다.
컴포넌트 자체 요구사항들만 고려해서 만들면 그 요구사항들이 나타내는 역할로서의 이름을 먼저 떠올리게 됩니다. 우리의 경우에는 NestedComboboxItem
같은 상위 컴포넌트의 의존하는 이름보다 CheckboxItem
같은 그 UI 자체를 나타내는 이름을 먼저 떠올리게 될 가능성이 높습니다.
이렇게 작업한 컴포넌트는 구현부는 물론이고 이름까지도 다른 컴포넌트와 의존성이 전혀 없는 컴포넌트가 됩니다. 재사용성이 좋아짐은 물론이고 UI 단위의 테스트를 하기도 쉬워집니다.
이제 실제로 만들어 볼까요?
<CheckboxItem>
의 유형은 아래 두 가지 입니다. 만들어 봅시다.
css-in-js로는
emotion
을 사용했고, Checkbox 및 Icon은 실제로는 사내 디자인 시스템을 사용했지만 예시 코드에서는 mui로 대체했습니다.
<CheckboxItem>
구현스타일 코드들은 파일을 분리해 한 곳에서 관리하면 좋은 경우가 많습니다. 컴포넌트 파일 자체가 깔끔해지기도 하고, 여러 컴포넌트 파일에서 공통된 스타일을 가져다 쓰기 용이합니다.
CheckboxItem.styled.ts
import styled from '@emotion/styled';
export const Wrapper = styled.div`
width: 100%;
height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
&:hover {
background-color: #e8eaed;
}
`;
export const Left = styled.div`
display: flex;
align-items: center;
`;
export const Right = styled.div``;
export const Count = styled.span`
font-size: 14px;
color: #989898;
`;
css-in-js
는 자체 의존성이나 성능 측면 등의 단점이 있다곤 하지만, css를 js 모듈로 관리할 수 있다는 장점만으로도 충분히 사용할 가치가 충분합니다. React를 사용할 때 css-in-js
는 React 컴포넌트와 같은 레벨로 추상화가 되기에 컴포넌트와 함께 유연하게 사용할 수 있고 여러 컴포넌트에서 재사용하기에도 좋습니다.
css-in-js
를 사용하다가 겪을 수 있는 단점 중 하나는 CSS 컴포넌트와 React 컴포넌트의 사용 방식이 똑같기 때문에 구현부로 들어가지 않고는 둘 중 무엇인지 파악할 수 없다는 점입니다.
// React 컴포넌트? Styled 컴포넌트?
<Button>버튼1</Button>
그럴 땐 아래와 같은 방식으로 style 파일을 import 할 때 명시적인 모듈로 묶어서 객체처럼 사용하면 좋습니다. css를 js 모듈로 사용하는 장점을 살리면서, 사용부만 보고 컴포넌트 종류를 파악할 수 있습니다. 협업에서 개발경험이 꽤 올라갔던 방식 중 하나입니다.
import * as Styled from './CheckboxItem.styled';
<Button>버튼1</Button> // React 컴포넌트
<Styled.Button>버튼2</Styled.Button> // Styled 컴포넌트
CheckboxItem.tsx
import * as Styled from './CheckboxItem.styled';
import { Checkbox } from '@mui/material';
import { NavigateNextIcon } from '@mui/icons-material';
type CheckboxTextWithCountItem = {
id: string;
name: string;
isChecked: boolean;
count: number;
onClick: React.MouseEventHandler<HTMLElement>;
};
type CheckboxDropdownItem = {
id: string;
name: string;
isChecked: boolean;
isIndeterminate: boolean;
onClick: React.MouseEventHandler<HTMLElement>;
};
const TextWithCount = ({ name, isChecked, count, onClick }: CheckboxTextWithCountItem) => (
<Styled.Wrapper>
<Styled.Left>
<Checkbox checked={isChecked} onClick={onClick}>
{name}
</Checkbox>
</Styled.Left>
<Styled.Right>
<Styled.Count>{count}</Styled.Count>
</Styled.Right>
</Styled.Wrapper>
);
const Dropdown = ({ name, isChecked, isIndeterminate, onClick }: CheckboxDropdownItem) => (
<Styled.Wrapper>
<Styled.Left>
<Checkbox indeterminate={isIndeterminate} checked={isChecked} onClick={onClick}>
{name}
</Checkbox>
</Styled.Left>
<Styled.Right>
<Styled.Count>
<NavigateNextIcon />
</Styled.Count>
</Styled.Right>
</Styled.Wrapper>
);
const CheckboxItem = {
TextWithCount,
Dropdown,
};
export default CheckboxItem;
두 가지 유형의 <CheckboxItem>
구현부입니다. 언뜻보면 우리가 만들 중첩 콤보박스와 전혀 관계없는 컴포넌트처럼 보여집니다. 어디에 쓰일지 상관없이 '체크박스 아이템'의 역할만을 수행하는 의존성 없는 컴포넌트입니다.
이제 앞서 만든 두 파일을 한 폴더에 묶고 외부에서 필요한 <CheckboxItem>
만 export 하는 index 파일을 만듭니다. 비로소 <CheckboxItem>
이라는 컴포넌트 모듈이 완성됐다고 할 수 있겠네요.
index.ts
export { default as CheckboxItem } from './CheckboxItem';
<CheckboxItem>
완성!
import { CheckboxItem } from './CheckboxItem';
<CheckboxItem.TextWithCount {...props} />
<CheckboxItem.Dropdown {...props} />
실제로 완성된 CheckboxItem 컴포넌트는 중첩 콤보박스 외 다른 곳에서 많이 재사용되었습니다.
핵심이라고 할 수 있는 중첩 콤보박스 컴포넌트(<NestedCombobox>
)를 만들 차례입니다.
<NestedCombobox>
컴포넌트는 유저의 마우스 호버 액션에 따라서 하위 분류의 리스트 UI가 재귀적으로 계속 펼쳐져야 하는 꽤 복잡도가 있는 컴포넌트입니다.
<NestedCombobox>
의 역할완성된 위의 UI 화면에서 중첩 콤보박스의 역할은 어디부터 어디까지일까요? 컴포넌트의 역할을 정확히 정의해야 그 역할만을 수행하는 독립적인 컴포넌트를 만들 수 있습니다.
곰곰이 생각해보면 <NestedCombobox>
안에서 '아이템이 어떻게 생겼는가'나 '아이템을 클릭할 때 어떤일이 일어나는지'는 <NestedCombobox>
컴포넌트 자체와는 전혀 상관이 없다는 걸 알 수 있습니다.
<NestedCombobox>
컴포넌트는 트리 형태의 데이터를 마우스 호버 액션에 반응하여 재귀적 콤보박스 형태로 예쁘게 보여줄 뿐, 그 외 다른 액션이나 아이템 생김새에는 관심이 없습니다. 아이템의 텍스트가 가운데 정렬로 변경되어도, 아이템의 체크박스 기능이 없어져도 <NestedCombobox>
컴포넌트는 어떤 수정도 일어나지 않아야 합니다.
사실 이 <NestedCombobox>
컴포넌트 역할의 정의는 컴포넌트를 어떤 흐름으로 설계했느냐에 따라 달라질 수 있습니다.
만약 하향식(top-down)으로 상위 컴포넌트인 <NestedCombobox>
를 먼저 제작하고 내부에 넣어줄 체크박스 아이템을 콤보박스 컴포넌트 하위에 만들었다면 어땠을까요?
<Combobox> <- 1. 콤보박스를 만는다.
{items.map((item) => (
<ComboboxItem item={item} /> <- 2. 콤보박스 내부에 아이템 리스트를 그려준다.
)}
</Combobox>
음, 나쁘지 않아 보이는데?
괜찮아 보이나요? 하지만 이런 흐름으로 컴포넌트를 만들면 콤보박스의 <내부_아이템_컴포넌트>
를 <NestedCombobox>
에서 분리하는 "의존성을 끊어내는 관점" 을 갖기 어렵습니다.
<NestedCombobox>
컴포넌트 내부에 <체크박스_아이템>
외의 다른 아이템도 쓰일 수 있다는 생각이 자연스럽게 들지 않고, 컴포넌트 이름도 콤보박스 컴포넌트에 굉장히 의존적인 <ComboboxItem>
과 같은 이름으로 지어질 가능성도 높습니다.
이렇게 만들어진 <NestedCombobox>
컴포넌트는 "아이템 리스트를 콤보박스에서 보여주는 것" 과 "각 아이템의 형태과 액션 처리" 까지 여러 역할을 담당하게 됩니다.
다양한 역할을 하는 컴포넌트는 코드 파악과 수정이 어렵고 재사용성이 떨어집니다. 만약 이런 식으로 컴포넌트가 완성된다면 콤보박스에 대한 변경이든, 내부 아이템에 대한 변경이든 매번 복잡한 <NestedCombobox>
컴포넌트를 열어보아야 합니다.
이런, 수정 요청이 굉장히 스트레스겠네요.
다행히 우리는 컴포넌트들을 "상향식(bottom-up)"으로 만들어 <CheckboxItem>
컴포넌트를 제작한 상태입니다. 덕분에 <NestedCombobox>
의 역할을 정의할 때, 이미 만들어진 <CheckboxItem>
의 역할인 "아이템의 형태"와 "아이템 클릭 액션"에 대한 부분을 제외하고 생각할 수 있어 <NestedCombobox>
컴포넌트 자체의 역할을 뽑아내기 쉬워집니다.
좋습니다. 정리한 <NestedCombobox>
컴포넌트의 역할은 다음과 같습니다.
children
이 있는 아이템과 children
이 없는 아이템 두 종류가 있다.children
이 있는 아이템을 마우스 호버하면 하위 분류 아이템 리스트가 펼쳐진다. 본격적으로 <NestedCombobox>
컴포넌트를 만들기 전에 한 가지 짚고 넘어갈 것이 있습니다. <NestedCombobox>
내부에 <CheckboxItem>
리스트를 그려줘야 하는데 이걸 어떻게 그려줘야 할지 고민해보죠.
다음과 같이 <CheckboxItem>
을 import 해서 사용하면 어떨까요?
// 1단계
import CheckboxItem from './CheckboxItem.tsx';
const NextedCombobox = () => (
<Combobox>
{items.map((item) => <CheckboxItem item={item} >)}
</Combobox>
)
음... 코드의 기능은 문제 없어 보이지만 잘못된 점이 보이네요. 우리의 설계 목적 중 하나는 중첩 콤보박스 내부에 <CheckboxItem>
외의 다른 컴포넌트도 사용할 수 있는 유연함이었는데, 위와 같이 작성하면 <NestedCombobox>
는 <CheckboxItem>
컴포넌트에 의존하는 형태가 되버립니다.
이대로면 <NestedCombobox>
컴포넌트 안에 <CheckboxItem>
대신 <AmazingItem>
리스트를 그려달라는 요청사항이 없기만을 바랄 수밖에 없겠네요.
위와 같은 요구사항이 두렵지 않도록 코드를 수정해봅시다. 위 코드와 같은 동작을 하면서 <NestedCombobox>
와 <CheckboxItem>
의 의존성을 분리해야 합니다. 다시 말하면 <NestedCombobox>
와 <CheckboxItem>
는 서로의 존재를 알 수 없어야 합니다. 함께 그려져야 하지만 서로의 존재를 알 수 없게 하려면 어떤 방법이 있을까요?
<CheckboxItem>
를 그려주는 역할을 <NestedCombobox>
외부로 빼주면 간단하게 해결할 수 있습니다.
// 2단계
const NextedCombobox = (renderItem) => (
<Combobox>
{items.map((item) => renderItem(item))}
</Combobox>
)
<NestedCombobox>
내부에 <CheckboxItem>
이 사라졌습니다. 이제 <CheckboxItem>
이든 <AmazingItem>
이든 아이템을 랜더하는 함수를 <NestedCombobox>
컴포넌트 외부에 만들고 <NestedCombobox>
의 prop으로 renderItem
이라는 함수를 내려주면 되겠네요!
의존성, 해치웠나?
언뜻 해결된 듯 보이지만 아쉽게도 해치우지 못했습니다. 아직 저 코드에는 <CheckboxItem>
의 의존성이 숨어있습니다. 바로 items
안에요.
items
가 왜 숨은 의존성인지는 이전에 설계했던 item
의 타입을 보면 알 수 있습니다.
type CheckboxItemData = {
id: string;
name: string;
isChecked: boolean;
isIndeterminate: boolean;
count: string;
children?: CheckboxItemData[];
}
별도의 type을 만들지 않았기 때문에 items
는 자연스럽게 위에서 만든 CheckboxItemData
타입이 됩니다. CheckboxItemData
타입은 "체크박스" 컴포넌트를 만들기 위한 타입들(isChecked
등)을 포함하고 있기 때문에 이 타입을 <NestedCombobox>
에서 사용한다면 이것 또한 외부 의존성이라고 볼 수 있습니다.
이러한 Type 의존성까지 떼버리기 위해선 중첩 콤보박스 내부에서 사용할 제너럴한 아이템 타입을 새로 정의해야합니다.
// <NestedCombobox> 내부에 정의
type ComboboxItem = {
id: string;
children?: ComboboxItem[];
}
어떤가요? ComboboxItem
타입은 <NestedCombobox>
컴포넌트 내부에서 정의한 제너럴한 콤보박스 아이템 타입입니다. 콤보박스의 제너럴한 아이템 타입은 아이템을 특정할 수 있는 식별자 id
와 트리구조를 표현하기 위한 재귀적 타입 children
만으로 충분합니다.
이 타입을 사용하면 <CheckboxItem>
이든 <AmazingItem>
이든 id
와 children
데이터만 있으면 어떤 종류의 아이템 컴포넌트도 그릴 수 있는 유연한 중첩 콤보박스 컴포넌트가 됩니다.
추가로 renderItem
함수를 사용할 때, item
객체 대신 식별자인 id
만 넘기도록 수정합니다. 아이템을 특정하는건 id
로 충분하고 외부 함수인 renderItem
가 ComboboxItem
타입을 알 필요가 없기 때문입니다.
// 3단계
type ComboboxItem = {
id: string;
children?: ComboboxItem[];
}
const NextedCombobox = (renderItem) => (
<Combobox>
{items.map((item) => renderItem(item.id))} <- item의 id를 넘김
</Combobox>
)
이번엔 정말로 해치운 것 같네요!
<NestedCombobox>
구현이제 위의 설계를 바탕으로 중첩 콤보박스(<NestedCombobox>
) 컴포넌트를 제작해봅시다.
중첩 콤보박스에서 그려줘야 할 Item 타입은 두 가지 입니다. 펼쳐지는 Item과 펼쳐지지 않는 Item. 두 Item을 각각 dropdownItem
과 optionItem
이라고 정의하겠습니다. 그럼 외부에서는 두 종류의 Item을 랜더하는 메소드를 각각 받아야겠네요.
코드 구현부를 볼까요?
NestedCombobox.tsx
import React, { useEffect, useRef } from 'react';
import * as Styled from './NestedCombobox.styled';
import {
NestedComboboxProvider,
useNestedComoboxContext,
RenderItemFn,
} from './NextedCombobox.contexts';
type ComboboxItem = {
id: string;
children?: ComboboxItem[];
};
// 현재 depth의 Item 리스트를 그려준다
const ComboboxItems = ({ items }: { items: ComboboxItem[] }) => (
<>
{items.map((item) =>
item.children?.length ? (
<DropdownItem key={item.id} item={item} />
) : (
<OptionItem key={item.id} item={item} />
),
)}
</>
);
const OptionItem = ({ item }: { item: ComboboxItem }) => {
const { renderOptionItem } = useNestedComoBoxContext();
return <Styled.OptionItem>{renderOptionItem(item.id)}</Styled.OptionItem>;
};
const DropdownItem = ({ item }: { item: ComboboxItem }) => {
const { renderDropdownItem } = useNestedComoBoxContext();
const dropdownItemRef = useRef<HTMLLIElement>(null);
const comboboxRef = useRef<HTMLUListElement>(null);
// 자식 Combobox의 Position을 계산해주는 effect
useChildComboboxPositionEffect({
dropdownItemRef,
comboboxRef,
});
return (
<Styled.DropdownItem ref={dropdownItemRef}>
{renderDropdownItem(item.id)}
// item의 children을 하위 분류 콤보박스로 그린다.
{item.children?.length && (
<Styled.ChildCombobox ref={comboboxRef}>
<ComboboxItems items={item.children} />
</Styled.ChildCombobox>
)}
</Styled.DropdownItem>
);
};
const NestedCombobox = ({
items,
renderDropdownItem,
renderOptionItem,
}: {
items: ComboboxItem[];
renderDropdownItem: RenderItemFn;
renderOptionItem: RenderItemFn;
}) => {
return (
<NestedComboboxProvider
renderDropdownItem={renderDropdownItem}
renderOptionItem={renderOptionItem}
>
<Styled.RootCombobox>
<ComboboxItems items={items} />
</Styled.RootCombobox>
</NestedComboboxProvider>
);
};
export default NestedCombobox;
재귀적 컴포넌트 구조가 눈에 들어 오시나요?
<NestedCombobox>
컴포넌트는 트리 구조 Item 데이터와 DropdownItem
, OptionItem
을 랜더하는 메소드를 prop으로 받습니다.
renderItem
메소드는 React Context를 사용하여 prop 드릴링을 최소화 했습니다.
<OptionItem>
과 <DropdownItem>
컴포넌트에서 해당하는 Item을 render 합니다. <DrodownItem>
컴포넌트는 재귀적으로 하위 분류의 Item 리스트를 그려줍니다.
마우스를 호버했을 때 하위 콤보박스가 보여지는 것과, 현재 호버된 아이템의 모든 상위 분류 Item의 background-color
가 변경되는 건 css로 처리했습니다.
useChildComboboxPositionEffect
는 모든 <DropdownItem>
컴포넌트에 적용된 훅으로 하위 분류 콤보박스의 position을 계산합니다. 구현부는 아래에 있습니다.
NextedCombobox.styled.tsx
import styled from '@emotion/styled';
// 콤보박스 공통 css
const comboboxCss = `
flex-direction: column;
width: 250px;
border: 1px solid #e8eaed;
`;
// 기본으로 보여지는 1depth 콤보박스
export const RootCombobox = styled.ul`
${comboboxCss};
`;
// 마우스 호버 시 보여지는 2depth 이하 콤보박스
export const ChildCombobox = styled.ul`
${comboboxCss};
position: absolute;
display: none;
`;
// 콤보박스 아이템 공통 css
const itemCss = `
display: flex;
align-items: center;
`;
// 호버했을 때 하위 분류 리스트의 display를 on 하고, 상위 분류의 모든 backgorund-color를 변경
export const DropdownItem = styled.li`
${itemCss};
&:hover {
background-color: #e8eaed;
& > ul {
display: flex;
}
}
`;
export const OptionItem = styled.li`
${itemCss};
`;
중첩 콤보박스 컴포넌트를 만들면서 css로 생각보다 많은 것들을 할 수 있다는 걸 새삼 느꼈습니다. 펼쳐진 하위 분류 Item에 hover 하면 상위 분류 아이템의 hover도 유지되기 때문에, 각 depth 마다 선택된 아이템의 background-color
변경이 동시에 모두 적용됩니다.
복잡한 style 요구사항을 javascript나 React 상태를 이용해서 처리하는 경우가 있는데, 조금만 연구하면 생각보다 css만으로 많은 것을 해결할 수 있습니다.
NestedCombobox.context.tsx
NestedComboboxProvider
구현부입니다. id를 받아 해당하는 아이템 컴포넌트를 그려주는 인터페이스가 정의돼있습니다.
import React, { createContext, useContext } from 'react';
export type RenderItemFn = (id: string) => JSX.Element;
const NextedComboboxContexts = createContext({
renderDropdownItem: (id: string) => <></>,
renderOptionItem: (id: string) => <></>,
});
export const useNestedComboboxContext = () => useContext(NextedComboBoxContexts);
export const NestedComboboxProvider = ({
renderDropdownItem,
renderOptionItem,
children,
}: {
children: React.ReactNode;
renderDropdownItem: RenderItemFn;
renderOptionItem: RenderItemFn;
}) => {
const value = React.useMemo(
() => ({
renderDropdownItem,
renderOptionItem,
}),
[renderDropdownItem, renderOptionItem],
);
return (
<NextedComboboxContexts.Provider value={value}>
{children}
</NextedComboboxContexts.Provider>
);
};
useChildComboboxPositionEffect
<DropdownItem>
(dropdownItemRef
) 컴포넌트의 위치정보로 하위 분류 콤보박스(childComboboxRef
)의 위치를 계산해주는 hook입니다.
// NestedCombobox.tsx
const useChildComboboxPositionEffect = ({
dropdownItemRef,
childComboboxRef,
}: {
dropdownItemRef: React.RefObject<HTMLLIElement>;
childComboboxRef: React.RefObject<HTMLUListElement>;
}) => {
useEffect(() => {
const calcPosition = () => {
if (!dropdownItemRef.current || !childComboboxRef.current) {
return;
}
const { offsetTop = 0, offsetLeft = 0, offsetWidth = 0 } = dropdownItemRef.current;
childComboboxRef.current.style.left = `${offsetLeft + offsetWidth}px`;
childComboboxRef.current.style.top = `${offsetTop}px`;
};
if (dropdownItemRef.current) {
// DropdownItem에 mouseover event가 일어나면 listener 함수 실행
dropdownItemRef.current.addEventListener('mouseover', calcPosition);
return () => {
dropdownItemRef.current?.addEventListener('mouseover', calcPosition);
};
}
}, []);
};
index.ts
export { default as NestedComboBox } from './NestedCombobox'
마찬가지로 index로 묶어 내보내주면 <NestedCombobox>
컴포넌트 모듈이 완성됩니다. 중첩 콤보박스의 재료인 두 컴포넌트가 모두 준비됐네요!
중첩 콤보박스를 구현하기 위해 필요한 모든 것을 만들었습니다.
useCheckboxItemDataController
<CheckboxItem>
컴포넌트<NestedCombobox>
컴포넌트이것들은 의존성 없이 독립적으로 만들어졌기 때문에 다른 곳에 재사용이 가능하고 기능을 확장하기도 쉽습니다. 좋은 컴포넌트라고 말할 수 있겠네요!
위 3가지를 잘 조합해서 "체크박스 기능이 있는 중첩 콤보박스" 컴포넌트를 만들어봅시다.
'비즈니스 분야'와 '활용기술' 데이터가 부어진 두 개의 컴포넌트를 만들겠습니다.
CheckboxNestedCombobox.tsx
import { NestedComboBox } from '~/components/NestedComboBox';
import { useCheckboxItemDataController } from '~/hooks/useCheckboxItemDataController';
import {
renderDropdownItemWith,
renderOptionItemWith,
} from './MyCombobox.utils.tsx';
export const 비즈니스_NestedCombobox = () => {
const { items, selectOne, findById } = useCheckboxItemDataController('비즈니스_분야');
const renderDropdownItem = renderDropdownItemWith(selectOne, findById);
const renderOptionItem = renderOptionItemWith(selectOne, findById);
return (
<NestedComboBox
items={items}
renderDropdownItem={renderDropdownItem}
renderOptionItem={renderOptionItem}
/>
);
};
export const 활용기술_NestedComboBox = () => {
const { items, selectOne, findById } = useCheckboxItemDataController('활용기술_분야');
const renderDropdownItem = renderDropdownItemWith(selectOne, findById);
const renderOptionItem = renderOptionItemWith(selectOne, findById);
return (
<NestedComboBox
items={items}
renderDropdownItem={renderDropdownItem}
renderOptionItem={renderOptionItem}
/>
);
};
useCheckboxItemDataController
hook을 이용해 <NestedComboBox>
컴포넌트에 items
과 renderItem
함수들을 내려줍니다.
여기서 renderItem
함수들은 renderDropdownItemWith
와 같은 함수를 반환하는 함수를 이용해 만들어줍니다.
함수를 반환하는 함수? 이게 왜 필요할까요?
함수를 반환하는 함수는 굉장히 유용합니다. javascript에서는 함수를 함수의 인자로 넘기거나 함수의 반환값으로 사용할 수 있는데요. 함수가 객체와 같이 취급된다고 이해해도 될 것 같습니다. 이것을 "일급함수" 라고 표현하고 이러한 특성을 가진 프로그래밍 언어를 두고 일급함수를 지원한다 라고도 말합니다. javascript 외에 일급함수를 지원하는 언어는 Java(버전 8이상), Swift, Kotlin, Python 등이 있습니다.
그렇다면 일급함수는 어떻게 활용될까요? renderDropdownItem
함수를 반환하는 함수인 renderDropdownItemWith
의 생김새를 살펴봅시다.
const renderDropdownItemWith = (selectOne: SelectOneFn, findById: FindByIdFn)
=> (id: string) => <CheckboxItem />
이 함수는 이렇게 똑같이 표현할 수 있습니다.
const renderDropdownItemWith = (selectOne: SelectOneFn, findById: FindByIdFn) => {
return (id: string) => <CheckboxItem />
}
조금 더 이해하기 쉬워졌네요. 읽어보면 renderDropdownWith
에 selectOne
, findById
메소드를 넣어 실행시키면, id
를 받아 특정한 컴포넌트를 반환하는 함수를 반환합니다.
여기서 "id
를 받아 특정한 컴포넌트를 반환하는 함수"는 <NestedCombobox>
에서 사용되는 renderDropdownItem
함수가 되겠죠?
const renderDropdownItem = renderDropdownItemWith(selectOne, findById);
용법을 이해했다면 장점도 눈에 들어오나요? 함수를 반환하는 함수의 장점은 공통된 함수 로직을 추상화 할 수 있다는 것입니다. 타입이 같고 데이터만 다른 경우에, 아래처럼 renderDropdownItem
함수를 찍어낼 수 있습니다.
const renderDropdown_과일_Item = renderDropdownItemWith(selectOne_과일, findById_과일);
const renderDropdown_채소_Item = renderDropdownItemWith(selectOne_채소, findById_채소);
const renderDropdown_고기_Item = renderDropdownItemWith(selectOne_고기, findById_고기);
자세한 구현부는 아래와 같습니다.
MyCombobox.utils.ts
import { CheckboxItem } from '~/components/CheckboxItem';
import type { SelectOneFn, FindByIdFn } from '~/hooks/useCheckboxItemDataController';
export const renderDropdownItemWith =
(selectOne: SelectOneFn, findById: FindByIdFn) => (id: string) => {
const { name, isChecked, isIndeterminate } = findById(id);
const handleItemClick = () => {
selectOne(id);
};
return (
<CheckboxItem.Dropdown
id={id}
name={name}
isChecked={isChecked}
isIndeterminate={isIndeterminate}
onClick={handleItemClick}
/>
);
};
export const renderOptionItemWith =
(selectOne: SelectOneFn, findById: FindByIdFn) => (id: string) => {
const { name, isChecked, count } = findById(id);
const handleItemClick = () => {
selectOne(id);
};
return (
<CheckboxItem.TextWithCount
id={id}
name={name}
count={count}
isChecked={isChecked}
onClick={handleItemClick}
/>
);
};
좀 더 명시적인 네이밍으로 renderDropdownItemWith
-> renderDropdownCheckboxItemWith
도 괜찮을 수 있겠네요. renderDropdownAmazingItemWith
를 만들 수도 있으니까요!
완성된 폴더 구조는 다음과 같습니다.
MyPage.tsx
import { 비즈니스_Combobox, 활용기술_Combobox } from './MyCombobox';
const MyComponent = () => (
<div>
<비즈니스_Combobox />
<활용기술_Combobox />
</div>
)
export default MyComponent;
중첩 콤보박스 컴포넌트를 완성했습니다. 좋은 설계와 의존성 분리를 고민하는 과정들이 있어 더 긴 여정을 거친 기분이네요. 이렇게 완성한 컴포넌트로 우리는 무엇을 얻을 수 있을까요?
이런 변경사항에 대한 유연함과 높은 재사용성,
그리고 "왜 이렇게 설계한거야" 대신 "이렇게하면 금방 되겠네" 라고 말하는 미래의 나와 팀원들의 모습인 것 같습니다.
시간이 지날수록, 서비스가 커질수록, 팀원이 많아질수록 의존성 최소화라는 법칙은 어떤 변경사항이든 유연하게 대처할 수 있는 최소한의 안전장치가 됩니다.
이거 1px만 옮겨주세요.
비개발자들의 입장에선 별 거 아닌 수정일지라도 개발적으로는 큰 영향을 끼칠 수 있다는 걸 표현한 유머입니다. 그동안 그 개발자를 공감하며 웃고 넘겼는데 이젠 조금 의문이 들기 시작합니다.
어쩌면 안된다고 말하는 그 개발자는 의존성 최소화를 하지 못하고 컴포넌트를 잘못 설계하지 않았을까요?
워크샵 참여한것처럼 재밌게 읽었습니다👍