
최근 FitLink 프로젝트에서 디자인 시스템을 구축하면서, 모노레포로 이루어진 프로젝트의 각 도메인 내에서 디자인 시스템을 활용한 공통 컴포넌트를 구현하며 HOC(Higher Order Component)를 활용했던 경험을 공유합니다.
우리가 코드를 인지 하는 간략한 과정
얼마전부터 '프로그래머의 뇌'라는 책을 읽으며, 코딩에 영향을 주는 인지 과정에 대해 알게 되었습니다. 책에서 말하는 인지 과정은 아래와 같습니다.
- 코드가 초래하는 혼란에는 코드에 대한 지식이 없거나, 필요한 정보를 가지고 있지 않거나, 코드가 너무 복잡한 경우다
- 지식이 없는 것은 LTM(장기 기억 공간)에 해당 내용이 없는 것
- 반면 지식이 아닌 어떤 정보가 부족한 경우 STM(단기 기억 공간)에 해당 내용이 없는 것
- 많은 정보를 처리할 때(복잡한 코드) 작업 기억 공간에 영향을 미치는데 우리는 사고할 때 작업 기억 공간 영역을 사용한다.
LTM 문제 = 지식의 부족
STM 문제 = 정보의 부족
작업 기억 공간의 문제 = 처리 능력의 부족
라고 책에서는 이렇게 말하고 있습니다.
우리가 팀 프로젝트를 하며. 코드를 작성할 때, 초급자~고급자까지 모든 레벨의 사람들에게 이해하기 쉬운 코드를 짜는 것은 힘듭니다. 쉽게 예를 들어 선언형 프로그래밍을 작성한다고 해보겠습니다.
// 명령형
function addStep (arr, step) {
let results = [];
for(let i = 0; i < arr.length; i += 1){
results.push(arr[i] + step);
}
return results;
}
// 선언형
function addStep (arr, step) {
return arr.map((i) => i + step);
}
위 예시는 너무나도 간단하고 쉬운 예시입니다. 두 함수 모두 배열과 증가 범위를 인자로 받아 각 요소 마다 증가 범위 만큼 더한 값을 배열로 리턴해주는 함수입니다.
극한의 예시로 만약 JavaScript 입문자와 함께 프로젝트를 한다고 했을 때 선언형으로 작성된 addStep의 내부 로직인 map 메소드는 JS 입문자인 팀원에게 조금 어려운 로직이 될 수 있습니다. 배열 메소드인 map 메소드에 대한 지식이 LTM에 존재하지 않기 때문이죠.
하지만 위와 같은 경우 map 메소드를 공부하여 LTM에 주입한다면 금방 해결될 문제입니다.
저는 여기서 생각했습니다. '그렇다면 특히나 동료들이 자주 사용하게 될 디자인 시스템 또는 공통 컴포넌트를 구현할 때, 빠른 이해를 돕기 위해서는 여러 방법이 존재하겠지만 저는 LTM 문제를 해결하는 방향으로 가야겠다' 라고요.
예를 들면 공통 컴포넌트를 만들 때는 이미 대단한 개발자 선배님들께서 많이 사용하시는 설계 패턴들이 있습니다. 여러 패턴 중, Compount Component 패턴, Render-Prop 패턴, Container/Presentational 패턴, HOC 패턴 등 다양한 패턴이 있죠.
그럼 우리가 공통 컴포넌트를 만들 때 이러한 패턴이나 기법을 사용하여 만든다면 동료들과의 협업에 있어 효율성을 높일 수 있지 않을까요?
하지만 여기서도 트레이드 오프가 있습니다. 현재 상황에서 가장 알맞다고 생각한 패턴 또는 기법을 팀원이 모를 때입니다. 반대 상황도 똑같습니다. 누군가 어떤 코드를 작성했는데, 이 코드를 작성한 사람은 현재 상황에 가장 알맞는 패턴 또는 기법을 사용했지만 제가 모를 때 말이죠.
여기서 우리는 결정해야합니다. 현재 상황에서 알맞는 패턴사용하는 게 맞을지, 조금 돌아가더라도 좀 더 쉬운 방법으로 구현 할지를요.
저는 이와 같은 상황에서는 성장 측면에서 팀원들이 조금 어려워하더라도 현재 상황에서 알맞는 패턴을 사용하는 것을 선호합니다. 언제까지나 서로가 아는 지식선에서 구현할 수는 없는 법이고, 특히나 어떤 기법 패턴을 사용했다면 그 기법 또는 패턴만 익히면 코드를 금방 이해할 . 수있기 때문이죠. 동시에 우리는 몰랐던 부분에 대해 알 것입니다.
HOC는 유용하다
이제 부터는 위 내용을 기반으로 하여 공통 컴포넌트에 HOC 패턴을 적용했던 경험을 공유 하겠습니다.
위 이미지는 피그마에 정의된 여러 바텀 시트 디자인 시스템 중 Stepper 컴포넌트를 공통으로 사용하는 바텀시트입니다. 여기서 공통으로 사용되는 UI를 뽑아보자면 아래와 같습니다
- Title
- Description
- Step Buttons
- Stepper
- fetch Button
이렇게 총 다섯 가지의 UI로 분류할 수 있을 것 같아요. 하지만 위 컴포넌트를 보면 상황에 따라 다른 UI를 가지고 있습니다. 어떤 경우에는 Description이 없고, 어떤 경우에는 Step Buttons이 없죠. 또한 fetch Button의 텍스트 또한 서로 다르며 각기 다른 데이터 fetching 로직을 가질 가능성이 높습니다.
여기서 저는 처음에 여러 가지를 생각했습니다.
우선 공통으로 Stepper와 Title은 모두 가지고 있으니, 공통 컴포넌트 내에 기본적으로 이 둘을 고정 시켜놓을까? 어떤 패턴이나 기법을 사용하지 말고, 간단하게 팀원들의 쉬운 이해를 위해 children을 사용하여 상위에서 fetch Button과 Step Buttons를 주입시킬까? 아니면 props로 fetch Button과 Step Buttons 컴포넌트를 주입 받을까? 등등 말이죠.
하지만 위와 같은 경우 Stepper와 Step Buttons로 조정된 value를 fetch Button을 통해 데이터 페칭을 할 확률이 높았고, 그러기 위해서는 value를 공통 컴포넌트로 부터 공유 받아야합니다.
하지만 children을 사용하거나, Props로 컴포넌트를 주입 받거나, 이 두 가지 설계 모두 공통 컴포넌트에서 상태 끌어올리기 또는 인터페이스 설계를 제어 컴포넌트 형식으로 상위에서 Stepper의 value를 핸들링 할 수 있도록 상태를 내려주어야 했습니다.
이는 당장 코드를 바라보는 입장에서 이해는 쉽겠지만 효율적이지 않다고 생각했습니다. 왜냐하면 항상 이 공통 컴포넌트를 사용하는 동료의 입장에서 '상태 관리'를 신경써야 하기 때문입니다.
저는 이와 같은 문제를 해결하기 위해 HOC 패턴을 사용하기로 결정했습니다. HOC를 모르는 팀원이 있다고 하더라도 해당 패턴만 익혀 LTM에 주입하고 코드를 본다면 이해가 빠를것으로 예상되었기 떄문입니다. 또한 HOC를 모르는 팀원이 있다면 내가 아는 지식을 공유할 . 수 있는 기회가 될 수도 있다고 생각했어요.
HOC란?
HOC는 함수형 프로그래밍에서 가져온 패턴으로 기본적으로 컴포넌트를 받아 추가적인 Props나 변경된 동작을 가진 새로운 컴포넌트를 반환하는 함수입니다. HOC를 사용하면 코드를 반복하지 않고 컴포넌트간에 공통 기능을 공유할 수 있어요.
- 코드 재사용: 동일한 코드를 여러 곳에서 작성하게 될때 해당 로직 추상화
- 렌더링 조작: 래핑된 컴포넌트가 어떻게 렌더링 될지 여부를 제어
- 상태 공유: 여러 컴포넌트가 상태를 공유하고 조작해야 할 때, 해당 상태를 공통 조상으로 올리지 않고 상태를 추상화
- props 조작: 래핑된 컴포넌트에 props를 읽고 추가하거나 수정 가능
- 라이프사이클 메서드 조작: 래핑된 컴포넌트의 라이프사이클 메서드에 접근하여 컴포넌트가 마운트 되기 전, 언마운트 될 때 정리 작업등 다양한 작업 수행
HOC 패턴은 위와 같은 시기에 사용하면 적합한데 저는 코드 재사용(핸들러 및 UI)과 상태 공유를 위해 사용했습니다.
아래 코드를 통해 어떤식으로 구현되었는지 확인해 보겠습니다.
"use client";
// hoc를 활용한 공통 컴포넌트
import { Button } from "@ui/components/Button";
// 기존에 구현했던 sheet 디자인 시스템
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@ui/components/Sheet";
// 기존에 구현했던 Stepper 디자인 시스템
import Stepper from "@ui/components/Stepper";
import { cn } from "@ui/lib/utils";
import { ComponentType, ReactNode, useState } from "react";
type WithBottomSheetStepperProps = {
className?: string;
title: string;
description?: string;
incrementOptions?: number[];
children: ReactNode;
};
type ApproveOrModifyCTAButtonProps = {
value: number;
onChangeClose: (isOpen: boolean) => void;
};
const INITIALSTEP = 0;
export const WithBottomSheetStepper = (
ApproveOrModifyCTAButton: ComponentType<ApproveOrModifyCTAButtonProps>,
) => {
return function BottomSheetWithStepper({
className,
title,
description,
incrementOptions,
children,
}: WithBottomSheetStepperProps) {
const [step, setStep] = useState(INITIALSTEP);
const [openBottomSheet, setOpenBottomSheet] = useState(false);
const handleChangeValueIncrementValue = (incrementValue: number) => () => {
setStep((previousValue) => previousValue + incrementValue);
};
const handleChangeValue = (value: number) => {
setStep(value);
};
const handleClickSheetVisible = (isOpen: boolean) => {
setOpenBottomSheet(isOpen);
};
return (
<Sheet open={openBottomSheet} onOpenChange={handleClickSheetVisible}>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent
side={"bottom"}
className={cn("flex h-fit w-full flex-col items-center", className)}
>
<SheetHeader className={cn("items-center", !description && "mb-0")}>
{title && <SheetTitle className={cn(!description && "mb-0")}>{title}</SheetTitle>}
{description && <SheetDescription>{description}</SheetDescription>}
</SheetHeader>
<div className={cn("mb-[1.25rem] flex gap-2.5", !description && "my-[1.26rem]")}>
{incrementOptions &&
incrementOptions.map((value) => (
<Button
key={value}
variant={"negative"}
className="text-headline h-[2rem] w-[4.875rem] rounded-full"
onClick={handleChangeValueIncrementValue(value)}
>
+{value}회
</Button>
))}
</div>
<Stepper value={step} onChangeValue={handleChangeValue} className="border-none" />
<SheetFooter className="w-full">
<ApproveOrModifyCTAButton value={step} onChangeClose={handleClickSheetVisible} />
</SheetFooter>
</SheetContent>
</Sheet>
);
};
};
우선 래퍼 컴포넌트로 들어올 컴포넌트의 인터페이스와 return 해 줄 컴포넌트의 인터페이스를 작성하였으며, 래퍼 컴포넌트로 버튼 컴포넌트가 들어올 수 있도록 네이밍을 유도하였습니다.
이후 내부적으로 step 상태를 통해 Stepper 컴포넌트로 증감되는 value를 관리하였고, openBottomSheet 상태를 통해 바텀시트의 open 여부를 컨트롤 하였습니다.
래퍼 컴포넌트가 들어오고 나면 Footer에 래퍼 컴포넌트를 주입해주고 Trigger, Title, Description, Step Buttons(incrementOptions), Stpper를 포함시킨 상태로 컴포넌트를 리턴시켜줍니다. 단, 여기서 래퍼 컴포넌트에는 value를 관리하는 step과 바텀 시트의 open 여부를 핸들링 할 수 있는 핸들러 함수를 주입해줍니다.
이렇게 하면 상위 컴포넌트에서는 리턴 받은 컴포넌트를 사용해 Props로 Title, Description, Step Buttons를 모두 정의할 수 있고, children으로 바텀시트의 트리거를 주입 해줄 수 있으며, 해당 HOC 컴포넌트에서 이미 상태와 핸들러를 래퍼 컴포넌트의 props로 주입시켜주고 있기 때문에 상위 컴포넌트에서는 상태 관리에 대한 신경을 전혀 쓰지 않아도 됩니다.
만약 fetch Button 컴포넌트를 children 또는 Props로 컴포넌트를 주입받아 사용했더라면 아래와 같이 인터페이스를 수정하거나 상태 끌어올리기를 했어야 했을 것입니다.
// 공통 컴포넌트의 인터페이스
// 공통 컴포넌트는 아래의 인터페이스를 활용해 제어 컴포넌트 형식으로 모든 제어권을 상위에게 넘겨줌
type WithBottomSheetStepperProps = {
step: number; //step 상태 주입
onChangeStep: (step: number) => void; // step이 변경할 때 호출 될 함수
isclose: boolean: // 바텀 시트 close 여부 주입
onChageClose: (isClose: boolean) => void; // 바텀 시트의 닫을 때 호출 될 함수
className?: string;
title: string;
description?: string;
incrementOptions?: number[];
children: ReactNode;
};
// ---------------------------------------------------------------------
// 상위 코드 간략화
function home() {
const [step, setStep] = useState(0);
const [close, setClose] = useState(true);
const handleClick(step: number) = {
setStep(step)
}
const handleClose = (isClose: boolean) => {
setClose(isClose)
}
return (
<WithBottomSheetStepper step={step} onChangeStep={handleClick} isClose={close} onChangeClose={handleClose}>
<button>승인</button>
</WithBottomSheetStepper>
)
}
아마 위와 같은 코드를 공통 컴포넌트를 사용할 때마다 매번 작성해주었어야 했을 것입니다. 매번 작성할 때마다 작업 효율은 떨어지고 상태관리를 신경써야 하기 때문에 생산성이 저하될 것입니다. 다른 방법으로는 전역 상태관리로 상태를 공유해주는 방법도 있습니다. 하지만 해당 컴포넌트의 경우 전역 상태 관리의 필요성을 전혀 느끼지 못했고, HOC 패턴 하나만 사용하면 상태공유와 UI 및 로직들을 재사용 할 수 있었기 때문입니다.
그렇다면 HOC 패턴을 사용한 상위 코드의 간략한 예시를 확인해보겠습니다.
"use client";
import { Button } from "@ui/components/Button";
import { WithBottomSheetStepper } from "@trainer/hoc/WithBottomSheetStepper";
export default function Home() {
const BottomSheetWithStepper = WithBottomSheetStepper(FetchButton);
return (
<div className="bg-background-primary relative flex h-screen w-full flex-col items-center justify-end">
<BottomSheetWithStepper title="PT 횟수를 설정해주세요">
<button>바텀시트 트리거</button>
</BottomSheetWithStepper>
</div>
);
}
type FetchButtonProps = {
value: number;
onChangeClose: (isOpen: boolean) => void;
};
function FetchButton({ value, onChangeClose }: FetchButtonProps) {
const handleClick = () => {
onChangeClose(false);
// value를 활용한 API 페칭 로직 작성
};
return <Button onClick={handleClick}>승인</Button>;
}
앞서 보여드렸던 사용 예시보다 훨씬 코드가 깔끔합니다. 상태 관리에 대한 신경을 전혀 쓰지 않아도 되고. Props도 더욱 단순해졌습니다. 이제 해당 공통 컴포넌트를 사용하는 개발자는 비즈니스 로직과 fetch Button의 스타일만 신경쓰면 됩니다.
이번 프로젝트에서는 최대한 지속 가능한 코드를 작성하기 위해 노력하고 있습니다. 최대한 많은 사람들이 인지하고 있는 기법 또는 패턴을 사용하는 것이 코드 리뷰의 시간을 줄이고, 동료 개발자의 이해를 도울 수 있다고 생각합니다. 앞으로 개발길에 있어 LTM에 많은 지식을 넣는 것은 정말 중요한 것 같습니다.
'회고' 카테고리의 다른 글
확장성 있는 디자인 시스템 개발하기: Radix useControllableState (0) | 2025.02.13 |
---|---|
MSW 도입기 (0) | 2024.09.10 |
지난 프로그래머스 데브코스(FE)에서 나는 무엇을 얻었을까 (6) | 2024.06.07 |
팀장은 무엇을 위해 존재할까 (0) | 2024.04.06 |
같은 실수를 반복하지 않고, 한 번 학습한 내용을 오래 기억하기 위해 작성합니다.