2024-07-09: 합성 컴포넌트 패턴(Compound Component Pattern)
🚨 만능 Input은 죄악이다.
‘준비물 챙겼어’에서 Input 컴포넌트 자체에 여러 가지 기능이 다 담긴 만능 Input 컴포넌트를 만들려고 했었다.
그러나 점점 기능들이 다양하고, 엮어있는 컴포넌트들이 다양해지면서 코드가 점점 스파게티가 되어가고 나중가서는 또 필요에 의해 변형해야 할 때 만능 Input이라면서, 해당 Input을 사용하지 않는 다른 Input을 써야 하는 케이스가 발생하기도 했다.
import { hasErrorMessage, hasLabelTextInProps, isShowErrorMessage, isShowValidationIcon, } from "../policies/input"; import { StInput } from "../styles/input"; import { InputPropType } from "../types/input"; import CorrectIcon from "../../assets/icons/svg-input-correct-icon.svg?react"; import IncorrectIcon from "../../assets/icons/svg-input-incorrect-icon.svg?react"; export default function Input(props: InputPropType) { const { id, title, errorMessage, firstInputCheck, errorCondition, colorTheme, isViewMark, } = props; return ( <StInput.Wrapper> {hasLabelTextInProps(title) && ( <StInput.Label htmlFor={id} $colorTheme={colorTheme}> {title} </StInput.Label> )} <StInput.TextInputBox> <StInput.TextInput id={id} $colorTheme={colorTheme} $checkErrorMessage={hasErrorMessage(errorMessage)} {...props} /> {isShowValidationIcon({ firstInputCheck, errorMessage, isViewMark, }) && ( <StInput.ValidIcon> {isShowErrorMessage({ firstInputCheck, errorCondition }) ? ( <IncorrectIcon /> ) : ( <CorrectIcon /> )} </StInput.ValidIcon> )} </StInput.TextInputBox> {isShowErrorMessage({ firstInputCheck, errorCondition }) && ( <StInput.ErrorMessage>{errorMessage}</StInput.ErrorMessage> )} </StInput.Wrapper> ); }
결국 내 만능 Input은 실패작이었고, 언젠가 이 Input에 대한 리팩토링을 하자며 벼르고 있었다. 그리고 최근 Next.js로의 마이그레이션을 진행하게 되면서 그 벼르고 있던 숙원을 해결했다.
우선 위의 Input 코드를 모두 지웠다.
그리고 나무가 아닌 숲을 보는 전략으로 합성 컴포넌트 패턴을 이용해 Form 컴포넌트를 만들고 Input에 쓰인 요소들을 모두 담도록 변경했다.
이번 글은 그 ‘합성 컴포넌트 패턴’에 대한 이야기와 실제 프로젝트에서 어떤 식으로 리팩토링을 했는지, 그리고 그 적용한 예시 코드들을 보여주고자 한다.
❓ 그래서 우선 합성 컴포넌트 패턴이 뭔데?
여러가지 디자인 패턴에 대해서 정리된 Pattern.dev 글에 따르면 합성 컴포넌트 패턴을 ‘여러 컴포넌트들이 모여 하나의 동작을 할 수 있게 해 준다.’고 말하고 있다.
또, 카카오 엔터테인먼트의 FE 기술 블로그에서는 합성 컴포넌트를 다음과 같이 이야기한다.
합성 컴포넌트 패턴은 하나의 컴포넌트를 여러 가지 집합체로 분리한 뒤, 분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 컴포넌트 패턴을 의미합니다.
합성 컴포넌트로 재사용성 극대화하기 | 카카오엔터테인먼트 FE 기술블로그
장난감 중에 가장 유명한 블록 장난감인 레고의 패키지를 한번 생각해보자.

위의 이미지를 보면 ‘슈퍼 마리오’라는 테마의 레고 패키지임을 알 수 있다.
여기에는 토관, 구름, 블록, 깃발, 캐릭터들이 보이는데 이 구성들을 다 배치해도 좋고, 일부분을 제외해도 ‘슈퍼 마리오’라는 테마가 달라지지 않는다.
합성 컴포넌트 패턴은 이런 레고 패키지처럼, 분리된 요소들을 여러가지 측면으로 조합해서 하나의 테마를 온건히 유지하도록 해주는, 하나의 구성 혹은 동작을 만들어내는 패턴이라고 보면 되겠다.
👍 합성 컴포넌트 패턴의 장점
여러가지가 있을 거지만 개인적으로 느꼈을 때의 장점들에 대해서 나열해보자면 다음과 같다.
컴포넌트 간의 유연성이 높다.
이 부분이 합성 컴포넌트의 가장 큰 강점이라고 여기는데, 각 컴포넌트를 하나의 구성으로 연결하고 만들기만 하면, 구성을 해치지 않는 선에서 유연하게 다양한 형태로 UI를 구성할 수 있다는 점이 강점이라고 생각한다.
연결된 컴포넌트를 그룹핑할 수 있다.
처음에 합성 컴포넌트를 채용했던 이유는 이 부분이었다.
같은 테마로 묶인 컴포넌트들을 하나로 묶음으로서 그룹핑을 할 수 있는데, 이걸 통해서 해당 요소와 엮인 컴포넌트가 어떤 것인지를 알게 해주기 때문에 가독성 등에 좋을 것이라고 판단했다.
UI 만을 담당하는 컴포넌트와 동작을 관리할 컴포넌트를 분리할 수 있다.
합성 컴포넌트는 하나의 구성에서 여러 컴포넌트를 조합하는 방식이다 보니 그 구성이 되는 최상위 컴포넌트에서 동작을 관리하게 된다.
이 동작에 대한 값들을 하위 컴포넌트에서는 대부분 props를 통해 값을 받아오거나 배치만 하면 되기 때문에 하위 컴포넌트에서는 복잡한 로직을 만들 필요 없이, UI 만을 그려내면 된다는 장점이 생긴다.
이 세 가지가 합성 컴포넌트의 가장 큰 장점이라고 생각한다.
사실 처음에는 Vite와 React로 작업할 당시, styled-components를 통해서 UI들을 그룹핑하는 것으로 합성 컴포넌트 방식을 처음 도입했는데 하면 할 수록 제대로 알고 쓰는 건가라는 의문이 들었고 결국 Next.js로 마이그레이션을 진행하면서 이런 UI 컴포넌트들을 전부 폐기하고 다시 만들게 됐다.
const Wrapper = styled.div` display: flex; flex-direction: column; gap: 10px; color: ${colors.black}; box-sizing: border-box; margin: 24px 0 8px 0; `; const Title = styled.h2` ${fontsStyle.bold.bold24}; color: ${(props) => props.color ?? colors.black}; margin: 0; `; const Context = styled.span` ${fontsStyle.medium.medium12}; color: ${(props) => props.color ?? colors.black}; `; export const StDescription = Object.assign({}, { Wrapper, Title, Context });
아래의 내용은 이후 합성 컴포넌트 패턴에 대해서 공부를 해보고, 내 입맛에 맞게 수정한 Form 컴포넌트다.
실제로 만들고 나니 엄청 마음에 들었고 그룹핑으로 쓰는 것만이 전부가 아님을 깨달았기 때문에 혹시 이런 부분에 고민이 많은 사람이라면 이 내용이 참고가 될 수 있기를 바란다.
📝 합성 컴포넌트 구현 예시 - Input에서 Form으로 마이그레이션
합성 컴포넌트를 만들 때 두 가지를 생각했다.
- 합성 컴포넌트를 만들기 위해서는 최상위에서 로직을 관리할 컴포넌트가 있어야 한다.
- 하위 컴포넌트들은 합성 컴포넌트에 귀속될 수 있지만, 별도로 써도 무방할 수 있도록 별개의 컴포넌트로 만들어져야 한다.
그래서 우선 최상위인 Form 컴포넌트를 보면 아래와 같다.
// 최상위 컴포넌트에서는 Context API를 이용해 Provider를 만들고, // 하위 컴포넌트에서는 필요한 경우 Provider에 속해있으므로 해당 값을 받아와서 // 적용할 수 있도록 했다. type FormContextType = { formData: Record<string, any>; handleFormData: (name: string, value: any) => void; }; export const FormContext = createContext<FormContextType | undefined>( undefined ); type FormPropType = FormHTMLAttributes<HTMLFormElement> & PropsWithChildren & { onSubmit: (formData: Record<string, any>) => void; formData: Record<string, any>; setFormData: Dispatch<SetStateAction<Record<string, any>>>; styles?: string; }; // 아래가 최상위에서 쓰이는 Form 컴포넌트이다. function Form(props: FormPropType) { const { children, onSubmit, formData, setFormData, styles } = props; const handleFormData = (name: string, value: any) => { setFormData({ ...formData, [name]: value }); }; const handleSubmit = (e: FormEvent) => { e.preventDefault(); onSubmit(formData); }; return ( <FormContext.Provider value={{ formData, handleFormData }}> <form className={"flex flex-col w-full px-0 box-border " + styles} onSubmit={handleSubmit} > {children} </form> </FormContext.Provider> ); } // Form 컴포넌트와 엮일 하위 컴포넌트는 아래와 같이 연결짓도록 했다. Form.Label = Label; Form.Input = Input; Form.Counter = Counter; Form.Button = Button; Form.ErrorText = ErrorText; Form.VaildIcon = ValidIcon; export default Form;
위의 컴포넌트 내용을 보면 다음과 같다.
- 최상위 컴포넌트에서 Context API를 통해서 Provider로 묶고 value 안의 내용을 공유받도록 한다.
- 하위 컴포넌트에서는 이 Context에 있는 value를 필요한 경우 반영해주기만 하면 된다.
여기서는 useState를 최상위에 뒀고, 이와 관련한 handler 함수를 만들어 Provider에 묶어 쓸 수 있도록 했다.
이와 연관된 컴포넌트 중에 문제가 됐던 Input 컴포넌트를 보자.
type InputPropType = InputHTMLAttributes<HTMLInputElement> & { colorTheme?: "black" | "white"; styles?: string; value?: string; onChange?: () => void; }; export default function Input(props: InputPropType) { const { id, colorTheme = "black", styles, value, onChange, ...rest } = props; const formContext = useContext(FormContext); const inputTheme = { black: "border-black ", white: "border-white ", }; // Form 컴포넌트와 엮인 상태가 아닌 일반 Input일 경우에는 하단의 내용들을 적용한다. if (!formContext) { return ( <input id={id} className={ "bg-invalidLight w-full font-medium16 text-black border rounded m-0 outline-none box-border p-3 mt-1 " + inputTheme[colorTheme] + styles } {...rest} value={value} onChange={onChange} /> ); } // Form 컴포넌트와 엮인 Input일 경우에는 하단의 내용들을 적용한다. const { formData, handleFormData } = formContext; const handleChange = (e: ChangeEvent<HTMLInputElement>) => { handleFormData(id!, e.currentTarget.value); }; return ( <input id={id} className={ "bg-invalidLight w-full font-medium16 text-black border rounded m-0 outline-none box-border p-3 mt-1 " + inputTheme[colorTheme] + styles } {...rest} value={formData[id!] || ""} onChange={handleChange} /> ); }
Input 컴포넌트는 케이스에 따라서 분리해서 쓸 수도 있고, 같이 쓸 수도 있기 때문에 두 케이스를 나눠서 썼다.
이 내용만 보면 사실 아까 전 언급한 장점인 ‘최상단에서 동작을 분리하고 하단에서는 UI 만을 그려낼 수 있다.’라는 내용과는 동떨어질 수 있는데, Input 컴포넌트는 동작과 관련한 컴포넌트인 만큼 Form에 귀속되어서 쓰이지 않을 경우를 고려해 케이스를 분리했다.
반면, 만능 Input에서 연결지었던 다른 컴포넌트인 Label과 ErrorText는 아래와 같다.
export default function Label(props: LabelPropType) { const { children, colorTheme = "black", styles, ...rest } = props; const labelTheme = { black: "text-black ", white: "text-white ", }; return ( <label className={"font-bold12 mb-1 " + labelTheme[colorTheme] + styles} {...rest} > {children} </label> ); }
type ErrorTextPropType = HTMLAttributes<HTMLSpanElement> & PropsWithChildren & { styles?: string; }; export default function ErrorText(props: ErrorTextPropType) { const { children, styles, ...rest } = props; return ( <span className={"font-medium10 text-error mt-[1px] " + styles} {...rest}> {children} </span> ); }
이처럼 Label과 ErrorText 컴포넌트는 동작이 들어갈 컴포넌트가 아니기 때문에 사실상 UI만을 그리는 컴포넌트로 적용됐다.
이제 해당 합성 컴포넌트가 활용된 예시로서 비밀번호 변경 페이지의 Form 컴포넌트를 보자.
export default function ResetForm() { . . . return ( <> <Form onSubmit={handleSubmit} formData={resetData} setFormData={setResetData} styles="px-4" > <div className="flex flex-col mb-3"> <Form.Label htmlFor="password">새 비밀번호</Form.Label> <Form.Input id="password" type="password" /> <Form.ErrorText> {changePasswordErrorMsg(resetData.password)} </Form.ErrorText> </div> <div className="flex flex-col mb-3"> <Form.Label htmlFor="confirmPassword">새 비밀번호 재확인</Form.Label> <Form.Input id="confirmPassword" type="password" /> <Form.ErrorText> {changeConfirmPasswordErrorMsg( resetData.password, resetData.confirmPassword )} </Form.ErrorText> </div> <Form.Button colorTheme="primary" type="submit" styles="mt-3"> 새 비밀번호로 변경 </Form.Button> </Form> . . . </> ); }
이런 식으로 위와 같이 컴포넌트 정리가 깔끔하게 배치되었고, 각 Form에 어떤 요소가 배치되었는지를 한눈에 알 수 있어 보기가 쉽다.
그럼 여기서 같은 페이지의 과거 버전은 만능 Input을 통해 어떻게 만들어졌는지 확인해보자.
export default function ChangePasswordForm() { . . . return ( <StChangePasswordForm.Form onSubmit={(e) => postChangePasswordProcess(e)}> <Description title="새 비밀번호를 입력해주세요." context="가입하신 계정에 적용할 새로운 비밀번호를 입력해주세요." /> <Input id="password" title="비밀번호" placeholder="영문, 숫자, 특수 문자 포함 8~20자 이내" onChange={(e) => updateFormInput("password", e.currentTarget.value)} value={formInput.password} type="password" errorMessage={convertPasswordErrorMessageByValue(formInput.password)} firstInputCheck={errorMsgState.password} errorCondition={isTrueCompareWithValueAndCondition( formInput.password, passwordRegExp )} isViewMark={true} /> <Input id="confirmPassword" title="비밀번호 재확인" placeholder="영문, 숫자, 특수 문자 포함 8~20자 이내" onChange={(e) => updateFormInput("confirmPassword", e.currentTarget.value) } value={formInput.confirmPassword} type="password" errorMessage={convertConfirmPasswordErrorMessageByValues( formInput.confirmPassword, formInput.password )} firstInputCheck={errorMsgState.password} errorCondition={isTrueCompareWithValueAndCondition( formInput.confirmPassword, passwordRegExp, formInput.password )} isViewMark={true} /> <StChangePasswordForm.ButtonBox> <Button text="새 비밀번호로 변경" type="submit" /> </StChangePasswordForm.ButtonBox> {openState.state && ( <Portal children={ <Modal title="비밀번호 변경 중 에러 발생" context={openState.message} modalType="alert" primary={{ text: "비밀번호 찾기 페이지로 돌아가기", func: () => moveToFindPasswordPage(), }} /> } container={document.body} /> )} </StChangePasswordForm.Form> ); }
… 그만 알아보겠다^^
확실히 위와 아래를 비교하면 오히려 props가 너무 나열되어 정신이 없고, Input 코드를 완전히 알고 있지 않다면 어떤 내용인지도 잘 모를 것이다.
이런 이유로 만능 Input 컴포넌트를 만드는 걸 포기했고, 합성 컴포넌트로 전부 넘기는 방식으로 개발을 진행했는데 이번 마이그레이션에서 리팩토링을 진행하면서 가장 만족스러운 결과가 바로 이 합성 컴포넌트 패턴을 채택한 것이다.
실제로도 이로 인해서 Form과 관련한 컴포넌트의 복잡성이 낮아졌고, Form 컴포넌트를 페이지별로 일일이 만들고 할 필요도 없어졌을 정도로 재사용성이 높아졌다.
혹시 공용적으로 많이 쓰게 되는 컴포넌트에 관한 고민이 많은 사람이 있다면 합성 컴포넌트를 이용해보는 건 어떨까.
분명 비즈니스 로직에 관해서도 많은 도움이 될 것이라고 생각한다.