이 글 UI로직과 비즈니스 로직을 명확하게 분리할 방법을 찾지 못하고 있던 과거의 제가 이걸 어떻게 하면 잘 분리할 수 있을까? 하는 생각에 작성하는 실험 일지입니다.
React를 통해 기업의 과제 테스트를 보거나 혼자 사이드 프로젝트를 할 때 기능 구현을하면서 항상 고려하는 부분이 UI는 UI만 렌더링 하고 비즈니스 로직은 custom hooks
를 통해 분리하는 것 입니다.
하지만 코드를 작성하다 보면 이것은 쉽게 되지 않습니다. 공통적으로 사용되는 부분은 도대체 어디까지 인지, 성급하게 작성했다가 롤백하기는 일쑤고, 이 컴포넌트의 역할이 무엇인지 등등 생각해 볼 것들이 너무 많이 있었습니다. 그래서 일단 돌아가는 쓰레기를 만들자
라는 생각에 정말 돌아가는 쓰레기를 만들었습니다.
const QuizForm = ({
quizState,
type,
cancelHandler,
changeHandler,
selectHandler,
}: QuizFormProps) => {
const { data: categories } = useGetCategory();
const { category, quiz, answer, favorite, explain } = quizState;
// ... 핸들러 코드들
return (
<form className={styles['quiz-form']}>
<InputLabel title='카테고리' htmlFor='category'>
<Selector
type='single'
list={categories.map((d) => d.name)}
placeholder='분류를 선택해주세요'
selected={selected}
onSubmit={categorySelectHandler}
/>
</InputLabel>
<InputLabel title='문제' htmlFor='quiz'>
<Input
id='quiz'
mode='text'
name='quiz'
value={quiz}
onChange={changeHandler}
placeholder='문제를 입력해주세요'
required
/>
</InputLabel>
<InputLabel title='답' htmlFor='answer'>
<Selector
type='single'
placeholder='선택해주세요'
list={Object.values(QUIZ_ANSWER)}
onSubmit={answerHandler}
selected={answer !== undefined ? [QUIZ_ANSWER[answer]] : []}
/>
</InputLabel>
<InputLabel title='해설' htmlFor='explain'>
<textarea
id='explain'
className={styles.explain}
value={explain}
onChange={changeHandler}
name='explain'
placeholder='해설을 입력해주세요'
required
/>
</InputLabel>
<InputLabel title='즐겨찾기 등록' htmlFor='favorite'>
<Selector
type='single'
placeholder='선택해주세요'
list={Object.values(FAVORITE_SELECT)}
onSubmit={favoriteHandler}
selected={favorite !== undefined ? [FAVORITE_SELECT[favorite]] : []}
/>
</InputLabel>
<div className={styles['button-container']}>
<Button type='button' size='small' onClick={cancelHandler}>
취소하기
</Button>
<Button type='submit' size='small' color='primary'>
{type === 'add' ? '등록하기' : '수정하기'}
</Button>
</div>
</form>
);
};
export default QuizForm;
DB에 퀴즈의 상태를 추가하거나 수정을 요청하는 역할을 가진QuizForm
컴포넌트 입니다.
카테고리, 문제 등등을 순서대로 입력 받고, 이를 퀴즈값 추가나 퀴즈 수정에 사용할 수 있는 form 컴포넌트 입니다. 하지만 이 컴포넌트는 카테고리 -> 문제 -> 정답 -> 해설 -> 즐겨찾기 -> 제출
의 순서를 가진 퀴즈 폼에만 적용할 수 있습니다. 즉 변경에 유연하지 못합니다
그렇다면 변경에 유연한 컴포넌트는 어떻게 만들어야 할까요? 좀 더 나은 방향으로 개선하기 위해 검색 중에 toss에서 발표한 컴포너넌트 관련 영상을 보고 영감을 받아서 한번 이 방향으로 시도했습니다.
위 3가지를 기준으로 위에 QuizForm
컴포넌트를 리팩터링 해보겠습니다.
QuizForm
의 역할은 말 그대로 퀴즈에 대한 정보를 입력받는 form
입니다. 하지만, form
의 형태를 굳이 quiz
에만 국한할 필요는 없습니다. 지금 QuizForm
의 문제는 너무 quiz
값을 입력받는데만 치중이 되어 있습니다. 즉, 다른 form
역할은 전혀 하지 못하고 있습니다.
이제 여기서 컴포넌트의 역할에 대해 한번 생각해 봐야 합니다. QuizForm
을 만드는게 아니라 아예 추상화된 form
컴포넌트를 만든다고 생각하고 컴포넌트를 다시 생각해봐야 합니다.
<form className={styles['quiz-form']}>
<InputLabel title='카테고리' htmlFor='category'>
<Selector
type='single'
list={categories.map((d) => d.name)}
placeholder='분류를 선택해주세요'
selected={selected}
onSubmit={categorySelectHandler}
/>
</InputLabel>
//... 아래 동일한 순서로 적용
</form>
form
에 특정 컴포넌트 혹은 요소가 존재한다면 추상화된 form
컴포넌트를 만드는데 분명 애로사항이 있습니다. 이 문제를 해결하기 위해서는 결국 QuizForm
을 구성하는 컴포넌트의 순서를 외부에서 주입할 수 있도록 변경한다면 좀 더 추상화된 form
컴포넌트를 만들 수 있습니다.
이러한 흐름은 자유로운 컴포넌트 배치로 이어집니다.
위에서도 언급했듯이 QuizForm
의 문제는 너무 내부에 의존적이라는 것입니다. 특정 역할만을 수행하는게 아니라 추상적인 컴포넌트를 만들기 위해서는 내부에서 모든 컴포넌트 순서를 결정하지 않고, 외부에서 주입받도록 설계를 하면서 컴포넌트에 어느정도 자유도를 주면서 만들 수 있습니다.
소프트웨어 설계를 할 때 많이 등장하는 용어입니다.
응집도
응집도는 독립적인 단위를 수행하는 모듈이 얼마나 독립적으로 하나의 기능만을 수행하기 위해 있는지를 나타내는 지표입니다.
응집도의 관점에서 QuizForm
을 바라본다면, 내부를 구성하는 LabelInput
, Select
,Button
등의 컴포넌트들이 QuizForm
의 역할을 수행하는데 UI적 관점에서 바라본다면 큰(??) 문제가 없습니다. 상태관리 측면에서는 부모에서 props로 handler랑 같이 전부 넘긴다는 점, 그렇다고 서버 상태로 받아오는 categories
는 내부에서 관리한다는 점, 핸들러를 외부에서 주입받고도 내부에서 따로 선언한다는 점 등 문제가 몇가지 있긴 합니다.
하지만, 좀 더 추상적으로 바라본 form
의 관점에서 바라본다면, UI 컴포넌트의 책임을 좀 더 분리해 볼 수 있습니다. 입력을 받는 컴포넌트와 제출받는 컴포넌트 그리고 입력을 받는 컴포넌트 중에서도 어떤 형태의 컴포넌트를 받아들일지 등등 하나의 역할만을 수행할 수 있도록 코드를 변경해 볼 수 있습니다.
결합도
결합도는 해당 모듈이 다른 모듈들과의 의존성을 나타내는 지표입니다. 결합도가 낮다면 하나의 모듈을 수정해도 그와 의존성이 높은 모듈들이 많이 없기 때문에 유지보수 하기 쉽습니다.
QuizForm
의 경우에는 다른 컴포넌트들과 적당한 결합도라고 생각합니다. 당장 Select
의 props만 수정해도 Select
는 form
이랑 크게 연관이 있는 컴포넌트가 아님에도 QuizForm
코드를 수정해야 합니다. 결합도를 좀 더 낮추기 위해서는 컴포넌트 내부에서 선언하는 컴포넌트들을 외부에서 주입함으로써 해결할 수 있습니다.
Form
컴포넌트에 공통으로 사용되는 컴포넌트와, 필요한 컴포넌트 기능을 먼저 정의하고, 그 안에 구성되는 내용은 외부에서 주입하는 방식으로 응집도를 높이고 결합도를 낮추는 방식으로 생각해 보겠습니다.
현재 Form
컴포넌트에는 불필요한(?) 훅이 존재합니다. selector
에 option으로 들어갈 카테고리 데이터를 불러오는 훅입니다. selector
가 없다면 해당 훅도 있을 필요가 없어집니다. 그리고 외부에서 각각의 handler와 상태를 주입하고 있는 형태로 코드가 작성되어 있습니다. 지금 Form
은 비즈니스 로직과 UI 로직이 같이 혼재되어 있는 상태입니다.
제가 원하는 방향은 form
에 필요한 로직은 외부에서 주입 받고, 컴포넌트 자체는 UI만을 렌더링 하도록 의도하는 것입니다. 그 과정에서 UI의 렌더링에 필요한 상태나 로직이 있다면 해당 로직을 훅으로 분리하고, 비즈니스 로직에 필요한 로직들은 또 다른 훅으로 분리해서 UI 로직 (컴포넌트 + 훅)
+ 비즈니스 로직 (submit 했을 때 동작)
으로 분리하는게 최적의 목적입니다.
그렇다면 이제 다음과 같이 분리해 보겠습니다.
form
의 역할을 하면서3가지 기준을 갖고 QuizForm
컴포넌트 리팩터링을 시작해보겠습니다. 제일 처음 할 것은 비워내기입니다. form의 역할과 상관 없다고 생각되는 코드들은 모두 쳐낼 생각입니다.
그렇게 된다면 전체적인 wrapper역할을 하는 form
태그와 해당 form
의 submit을 담당하는 버튼은 필요하게 됩니다. 그것들을 제외하고는 모두 제거하겠습니다.
const QuizForm = ({
children,
type,
}: PropsWithChildren & React.HtmlHTMLAttributes<HTMLFormElement>) => {
return(
<form>
{children}
<div>
<button type='button'>
취소하기
</button>
<button type='submit'>
제출하기
</button>
</div>
</form>
;
)};
Selector
나 InputLabel
과 같이 작성해 놓은 컴포넌트들은 선언부에서 children
으로 주입하도록 하고, 딱 하나의 역할에만 집중할 수 있도록 했습니다. 여기서 약간의 결합도를 추가하겠습니다. 제가 원하는 form
의 형태는 제목 - 콘텐츠 와 같이 항상 Title
이 존재합니다. 그리고 이 Title
은 InputLabel
컴포넌트에 children
을 할당함으로써 구현이 가능합니다.
하지만, InputLabel
은 QuizForm
컴포넌트와 완전히 독립적이지 않고 약간의 의존성을 넣고싶다면 어떻게 해야할까요? 이때 합성컴포넌트 패턴을 통해 해결할 수 있습니다.
저희는 JSX
의 반환 타입이 '객체'라는 것을 기억한다면 이 합성 컴포넌트 패턴을 쉽게 이해할 수 있습니다.
객체에 프로퍼티를 추가하는 방법으로 컴포넌트에 프로퍼티를 추가할 수 있습니다. 컴포넌트의 자유로운 배치를 위해서 Button
도 같은 프로퍼티로 할당하겠습니다.
const QuizForm = ({
children,
}: PropsWithChildren & React.HtmlHTMLAttributes<HTMLFormElement>) => {
return <form className={styles['quiz-form']}>{children}</form>;
};
type SubmitButtonProps = ButtonProps & {
type: 'reset' | 'submit';
};
const SubmitButton = (props: SubmitButtonProps) => {
return <Button {...props} />;
};
export default Object.assign(QuizForm, {
FormElement: InputLabel,
SubmitButton,
});
form
컴포넌트 작성을 끝내고 이제 사용한다면 다음과 같이 사용할 수 있습니다.
<QuizForm onSubmit={submitHandler}>
<QuizForm.FormElement title='카테고리' htmlFor='category'>
<Selector
type='single'
options={CATEGORIES.map((c) => c.name)}
placeholder='카테고리를 선택해주세요'
selected={categorySelected}
changeHandler={categoryChangeHandler}
isModal={isCategoryModal}
/>
</QuizForm.FormElement>
//... 아래에 똑같이 작성
<div className={styles['button-container']}>
<QuizForm.SubmitButton type='reset'>취소하기</QuizForm.SubmitButton>
<QuizForm.SubmitButton type='submit' color='primary'>
제출하기
</QuizForm.SubmitButton>
</div>
</QuizForm>
처음 코드와는 유지 보수와 확장성 측면에서 많은 차이가 생겼습니다. selector
내부에 category
값을 받기 위해, 내부에서 훅 호출을 분리할 수 있었던 점과 (비즈니스 로직 분리), QuizForm
컴포넌트에서 form
컴포넌트가 되었다는 점 (form
역할로 추상화). 마지막으로 자유로운 컴포넌트 배치가 가능해졌다는 점에서 많이 큰 이점을 얻을 수 있었습니다(외부에서 코드 주입).
이처럼 합성 컴포넌트 패턴을 사용한다면 UI로직과 비즈니스 로직 분리가 가능해지고, 이를 통해 유지보수성이 좋은 컴포넌트를 만들 수 있습니다.
어떤 컴포넌트를 만들지는 프로젝트에 따라 달라지겠지만, 컴포넌트별 역할을 명시하고 해당 컴포넌트에서 작동하길 바라는 동작을 정의하게 된다면 어떤식으로 UI로직과 비즈니스 로직 분리가 가능해지고, 이를 통해 변경에 유연한 컴포넌트를 구현할 수 있습니다.