이번 포스팅은 컴포넌트 설계와 관련된 이야기입니다.
현재 프로젝트에서 프론트엔드 부분을 혼자 맡으며 고군분투하다 보니 고민한 내용이 충분히 모자란 내용일 수 있습니다. 그럼에도 불구하고 제 고민을 공유하며 비슷한 고민을 하는 프론트엔드 개발자분들에게 조금이라도 도움이 되기를 기원합니다.
우선 제가 작성한 이 게시물에서도 밝혔지만 오늘 관심사 분리를 진행할 구조는 바로 퍼널 구조입니다.
퍼널은 마케팅 용어로 시작하여 토스 팀에서 재정의 한 구조인데요. 여러 페이지들을 통해 상태를 수집하고 결과 페이지를 보여주는 형태입니다. 오늘 제가 소개할 구조도 정확히 퍼널 구조입니다. 스크린샷을 통해 확인해볼까요?
위의 스크린샷에서 볼 수 있듯이 퍼널은 총 4단계로 이루어집니다.
Page
: 무언가를 누르면 모달을 호출할 수 있습니다.A_State
: form 구조를 가지며 form 의 내용을 서버에게 보내야 합니다.B_State
: form 을 보내고 나서 loading 을 맡습니다.C_State
: form 의 response 를 맡습니다. 최종 도착지입니다!이러한 구조를 가진 퍼널에 대해서 관심사를 분리하여 가독성이 높은 코드를 작성해 보겠습니다.
관심사를 분리한다는 것은 무엇을 의미할까요? 한 번에 한 가지 일만 처리할 수로 있도록 나누는 것을 의미합니다. 코드가 단위 별로 하나의 관심사에 충실할 수 있도록 만드는 것 입니다.
만약 관심사 분리가 이루어지지 않았다면 어떨까요? 하나의 컴포넌트안에 모든 로직을 때려박으니 엄청나게 긴 컴포넌트가 탄생합니다. 이는 분명히 좋지 못한 패턴으로 가독성과 유지보수성에서 모두 안좋은 영향을 미칩니다.
그에 반해 관심사를 분리하게 되면 어떤 좋은점들이 있을까요?
말로만 해서는 정확히 와닿지를 않습니다. 한번 실질적인 예제를 통해서 이를 분리해보겠습니다.
우선 관심사를 전혀 분리하지 않은 코드를 먼저 보고 이를 해결할 수 있는 방법에 대해 알아보겠습니다.
const PageComponent = () => {
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [form, setForm] = useState();
const onSubmitForm = async () => {
await axios.post(url, { form });
};
return (
<>
<Button onClick={() => setIsOpen(true)}>모달 열기</Button>
<Modal open={isOpen} setForm={setForm} onSubmitForm={onSubmitForm} />
</>
);
};
우선 PageComponent
입니다. 전혀 관심사 분리가 안된 모습입니다. Modal 의 상태도 관리하고, loading 상태도 관리하고, form 상태도 관리하고 있습니다. 최악의 모습입니다.
두번째로는 Modal 내에서 컴포넌트들입니다. 스크린샷은 토스의 개발 컨퍼런스중 한 장면인데요 위와 같이 설계하고 코드가 작성되어 있다면 어떤가요? 관심사가 비즈니스 로직에 의해 잘 분리된 것 같나요? 다음과 같은 문제점이 있다고 토스팀은 지적합니다.
1. 흩어져 있는 페이지 흐름 : 중간에 퍼널이 하나라도 늘어나게 된다면 코드를 왔다갔다 하면서 유지보수를 해야합니다.
2. 흩어져 있는 상태 : API 에 대한 기능을 추적하고 싶다면 상태를 사용하고 있는 집주소 뿐만 아니라 가입방식과 주민번호에 대해서도 추적을 해야합니다.
이러한 부분들을 이제 실전적 예제에서 어떻게 풀어나가는지 확인을 해봅시다.
선언적 프로그래밍
가장 먼저 Page 와 Modal 의 관심사를 분리해보겠습니다. 각각의 관심사가 무엇일까요?
Page
: 모달을 선언하는 곳으로 한정짓는게 좋아보입니다. 페이지에서는 굳이 모달 내부의 상태를 알 필요가 없습니다. 모달이 할 일은 모달이 할 수 있도록 놔둡시다.Modal
: State_A
~ State_C
를 관리합니다.따라서 이를 새롭게 코드를 짜면 다음과 같습니다. 아주 깔끔해졌고, Page
는 오롯이 Modal 을 선언적 프로그래밍 형식으로 선언하는 관심사만 가지게 됐습니다. 앞으로도 다른 Page
위계를 가진 컴포넌트도 이렇게 작성한다면 더욱 코드 관리가 쉬워질것 같네요.
const PageComponent = () => {
const {openModal} = useModal('Dialog');
return (
<>
<Button onClick={openModal}>모달 열기</Button>
</>
);
};
useModal
과 Modal 선언이 어떻게 되는지 궁금하신 분들은 제 글인 추상화 수준을 맞춘 클린한 Modal 개발하기 를 봐주세요!
상태 뭉치기
두 번째는 상태를 뭉쳐서 관심사를 분리하는 것인데요. 어떻게 하는 걸까요?
Modal
: State_A 부터 State_C 에 이르는 비즈니스 로직을 담당합니다.State_A
~ State_C
컴포넌트 : 오직 UI 로직만을 담당합니다.그러면 실제로 모달 컴포넌트의 코드를 볼까요?
const Modal = ({isOpen, onClose} : Props) => {
const [step, setStep] = useState<'state_a' | 'state_b' | 'state_c'>('state_a');
/* 중략 */
return (
<Modal open={isOpen} onClose={onClose}>
{step==='state_a' && (<StateAComponent {...stateAProps}/>)}
{step==='state_b' && (<StateBComponent {...stateBProps}/>)
{step==='state_c' && (<StateBComponent {...stateCProps}/>)}
</Modal>
);
};
이제 모든 비즈니스 로직과 관련된 관심사는 Modal
에만 집중되어 있습니다. 파일을 넘나들면서 유지보수를 할 필요가 없고, 새로운 퍼널이 필요할때 설계가 훨씬 간편해졌습니다.
custom hook
위의 예제에서 /*중략*/
이라고 쓴 부분이 불길하셔야 합니다. 왜냐하면 저기에 비즈니스 로직을 다 때려박으면 문제가 크게 생기거든요. 컴포넌트로는 응집도가 높을지 모르나, 코드로서는 응집도가 매우 떨어집니다. 저는 이 문제를 custom hook 으로 해결해보았습니다. 다음과 같이요.
const Modal = ({isOpen, onClose} : Props) => {
const [step, setStep] = useState<'state_a' | 'state_b' | 'state_c'>('state_a');
const {value, onChange, errors, isDiableButton, handleSubmit} = useChargeForm();
const {mutate, isPending} = useChargeMutation();
const stateAProps = {
value,
onChange,
errors,
isDisableButton,
onClickButton : ()=>{
handleSubmit(mutate)();
setStep('state_b');
}
}
/* stateB 및 stateC 생략*/
return (
<Modal open={isOpen} onClose={onClose}>
{step==='state_a' && (<StateAComponent {...stateAProps}/>)}
{step==='state_b' && (<StateBComponent {...stateBProps}/>)
{step==='state_c' && (<StateBComponent {...stateCProps}/>)}
</Modal>
);
};
이제는 훨씬 관심사가 분리되어 응집도가 높아졌습니다.