자아가 없는 컴포넌트와 객체지향 컴포넌트
부제: 공통 컴포넌트는 허상이다
언제나 처음 상태 그대로 존재하는 컴포넌트는 없습니다. 디자이너도, 기획자도, 그리고 코드를 짜는 우리 개발자도 영원하지 않으니까요. 즉, 영원한 공통 컴포넌트란 존재하지 않습니다.
우리는 "그때는 맞고 지금은 틀리다"라는 사실을 가슴속에 품고 개발해야 합니다. 시간이 흐르면 옳고 그름의 기준은 바뀝니다. 우리가 지금 작성하는 코드도, 욕하면서 보고 있는 전임자의 코드도 당시에는 최선이었을 겁니다.
그렇다면 우리가 흔히 말하는 공통 컴포넌트란 도대체 무엇일까요? 사전을 찾아봤습니다.
공ː통, 共通
둘 또는 그 이상의 것에서 두루 해당되고 통용되는 일.
이 사전적 의미에 꼭 맞는 컴포넌트가 과연 리액트 생태계에서 가능한 것인지, 지금부터 파헤쳐 보겠습니다.
1. 용어 정리: 너와 나의 공통은 다르다
논의를 시작하기 전에, 제가 정의한 용어부터 정리하고 넘어가겠습니다. (업계 표준 용어가 아닌, 제 경험에서 나온 분류임을 미리 밝힙니다.)
- 뷰 컴포넌트 (View Component): HTML/CSS와 아주 작은 UX(클릭 등)만 담당하는 껍데기.
- 컨트롤러 컴포넌트 (Controller Component): 뷰 컴포넌트에 비즈니스 로직과 데이터(자아)를 주입하는 조합처.
- 공통 컴포넌트 (Common Component): 필자가 허상이라고 믿는 개념. 보통 '모든 곳에서 쓸 수 있는 만능 컴포넌트'를 꿈꾸며 만들어지지만, 결국 괴물이 되고 마는 존재.
2. 재사용이라는 달콤한 함정
우리는 리액트를 처음 배울 때 "컴포넌트는 재사용 가능하다"라고 배웁니다.
하지만 곰곰이 생각해 봅시다. 혹시 이 재사용이라는 단어에 너무 취해있는 건 아닐까요?
개발자는 본능적으로 중복 코드를 싫어합니다(DRY 원칙). 하지만 아이러니하게도, 때로는 똑같은 코드를 작성해야 복잡도를 줄일 수 있습니다.
리액트에서 진정으로 재사용 가능한 컴포넌트는 아주 단순해야 합니다.
뷰 컴포넌트만이 재사용의 자격을 갖습니다. 얘는 로직을 모르고, 아주 멍청하고, 순수해야 합니다. 언제든 변할 수 있는 비즈니스 로직을 품는 순간, 그 컴포넌트는 유통기한이 생겨버리니까요.
🤔 그럼 로직은 어디로 가나요?
💡 뷰 컴포넌트를 로직으로 둘둘 감싸면 됩니다. 이걸 컨트롤러 컴포넌트라고 부릅니다.
🤔 디자인이나 기능이 미묘하게 다른 건 어떻게 재사용하죠?
💡 재사용하지 마세요. 그냥컨트롤러 컴포넌트를 여러 개 만드세요.
"코드 중복을 줄이는 게 미덕 아니었나요?"라고 반문하실 수 있습니다. 하지만 Simple is the Best입니다. 읽기 좋은 코드가 결국 좋은 코드입니다. 어설픈 추상화보다 의도된 중복이 낫습니다.
3. 공통 컴포넌트가 괴물이 되는 과정
제가 면접에서 자주 던지는 질문이 있습니다.
Q. 공통 컴포넌트가 망가지는 시나리오
- 하나의 화면에서만 쓸 줄 알고
Form컴포넌트를 만들었습니다.- 어느 날 기획자가 "어? 이거 저쪽 화면에서도 씁시다"라고 합니다.
- 근데 API 스펙이 미묘하게 다르고, 디자인도 버튼 위치만 살짝 다르네요?
- 이 상황에서 어떻게 대처하시겠습니까?
여기서 "Props로 분기 처리를 해서..."라고 답하신다면, 아직 컴포넌트의 배신을 덜 당해 보신 겁니다. (웃음)
변화에 유연한 뷰 컴포넌트를 만들고 싶다면 Headless UI 패턴이 정답에 가깝습니다. Headless만이 리액트에서 유일하게 살아남을 수 있는 진짜 공통 컴포넌트입니다.
3-1. 뷰 컴포넌트 (Headless)
// Form.tsx - 뼈대만 제공하는 뷰 컴포넌트
const Form = ({ onSubmit, formValues, children }: Props) => (
<FormProvider value={{ onSubmit, formValues }}>{children}</FormProvider>
);
// 서브 컴포넌트 할당
Form.Input = Input;
Form.Selector = Selector;
Form.SubmitButton = SubmitButton;
export default Form;
이 Form 컴포넌트는 자신이 언제 제출되는지, 값이 뭔지 전혀 모릅니다. 그저 뼈대만 제공할 뿐이죠. 이게 올바른 뷰 컴포넌트입니다.
3-2. 컨트롤러 컴포넌트의 타락
이제 이 뷰 컴포넌트를 가져다 쓰는 컨트롤러의 비극을 보시죠. 처음엔 DefaultForm이라는 이름으로 깔끔하게 시작했습니다.
// v1. 깔끔했던 시절
const DefaultForm = () => {
// ...로직들
return (
<Form onSubmit={submit} formValues={data}>
<Form.Input label="이름" />
<Form.SubmitButton />
</Form>
);
}
하지만 요구사항은 늘 변합니다. "회원가입 폼도 이걸로 씁시다. 아, 이벤트 신청 폼도 비슷하니까 이걸로 하죠? 근데 이벤트 폼은 모바일에서만 이미지가 나와야 해요."
개발자는 DefaultForm을 재사용하고 싶은 욕망에 굴복하여 Props와 조건문(Flag)을 떡칠하기 시작합니다.
// v3. 끔찍한 혼종이 된 컨트롤러
const DefaultForm = ({ formType }) => {
// ...복잡한 로직들
const isCharacter = formType === '캐릭터생성';
const isSignUp = formType === '회원가입';
const isEvent = formType === '이벤트';
const isDesktop = useDesktop(); // 반응형까지?!
return (
<Form>
<FormContainer resize={isDesktop && isEvent}>
{/* 지옥의 조건부 렌더링 시작 */}
<Flex>
{isCharacter && <Form.label label="캐릭터 이름" />}
{(isSignUp || isEvent) && <Form.label label="이름" />}
<Form.Input />
</Flex>
{isSignUp && <EventBanner>광고 배너</EventBanner>}
{isEvent && <Form.Image src="..." />}
{/* ... 수십 줄의 조건문 ... */}
</FormContainer>
</Form>
);
};
이 코드를 본 신규 입사자는 생각할 겁니다. "도대체 누가 코드를 이렇게 짰어?" 네, 접니다. (과거의 나를 저주해 봅니다...)
4. 레거시는 처음부터 레거시가 아니었다
이 DefaultForm은 왜 레거시가 되었을까요? 뷰 컴포넌트와 분리도 잘 했는데 말이죠.
문제는 로직을 담은 컨트롤러 컴포넌트조차 '공통'으로 쓰려고 했기 때문입니다.
Default 따위는 없습니다.
비즈니스 로직이 들어간 컴포넌트는 반드시 목적별로 분리되어야 합니다. 이름이 DefaultForm, CommonForm인 순간부터 재앙은 예고된 것입니다.
중복 코드가 생기는 걸 두려워하지 마세요. 비슷해 보여도 목적이 다르면 다른 컴포넌트입니다.
코드는 위에서 아래로 물 흐르듯 읽혀야 합니다. 스크롤을 위아래로 왔다 갔다 하며 if 문을 해석해야 한다면, 그건 실패한 추상화입니다.
5. 객체지향 컴포넌트: 컴포넌트를 인스턴스처럼
저는 이 문제의 해답을 객체지향 프로그래밍(OOP)의 개념에서 찾았습니다. 특히 다형성(Polymorphism)과 비슷하다고 느꼈는데요.
어렵게 생각할 것 없습니다. 다형성은 쉽게 말해 "역할은 같지만, 행동은 다르게 한다"는 것입니다. 그림을 그리는 도구라는 역할은 같지만, 크레용과 마커가 그려내는 질감이 다른 것처럼 말이죠.
여기서 Headless Form은 일종의 인터페이스(규약) 입니다.
그리고 우리가 만들 컨트롤러 컴포넌트들은 이 규약을 준수하여 만들어진 인스턴스(실체) 들입니다.
우리가 클래스로 인스턴스를 찍어낼 때 new 키워드를 쓰는 것을 주저하지 않듯, 컴포넌트도 목적에 따라 새로 만드는 것을 두려워하면 안 됩니다.
// 마음속으로 이렇게 생각해보세요
const CharacterSelectForm = new Form('CharacterSelect')
const SignUpSelectForm = new Form('SignUp')
const EventForm = new Form('EventForm')
하나의 God Component에 모든 처리를 몰아넣지 마세요.
자아가 없는 뷰 컴포넌트를 도구 삼아, 뚜렷한 목적을 가진 여러 개의 컨트롤러 컴포넌트를 만드는 것.
그것이 제가 생각하는, 시간에 굴복하지 않는 지속 가능한 컴포넌트 설계입니다.