구조만을 맡는 컴포넌트 만들기

이상현·2024년 7월 22일
0

들어가기

UI 개발자들 사이에서 자주 나오는 이야기 중의 하나가 "구조", "표현", "기능"을 분리하라는 말이 있습니다.

보통 html, css, js와 1:1로 매칭하여 서로 엉겨붙지 않도록 잘 나누라는 의미로 쓰이지만, css 선택자를 html의 태그네임으로 두거나 js의 선택자와 공유하여 어떤 하나의 수정이 다른 부문에 사이드 이펙트를 주고 일파만파 커지는 것을 경계하라...가 이 말의 출처에 가장 가까울 겁니다.

spa 프레임워크에서는 선택자에 대한 고민은 "기능" 면에서는 거의 사라졌고, "표현" 역시 css in js나 tailwind css의 사용이 늘면서 많이 희석되어 현재는 철 지난 화두가 되었으리라 보입니다.

그러나 여전히 html에 css와 js가 들러붙어야 하는 것은 이 언어들의 태생적인 이슈이기에 "구조"면에서는 크게 개선된 부분이 없습니다.

컴포넌트 안에 state가 jsx가 혼연일체가 되어 있거나, 중첩이 10회 이상 돌파하여 저 오른쪽 멀찍이 가버린 경우는 흔한 일이죠.

얼마전 이에 대한 한가지 아이디어가 생겨 아래와 같은 코드를 한번 구성해 보았습니다. "구조 " 컴포넌트를 만들면 "구조"와 "표현"과 "기능"의 관심사를 분리하고 코드를 계층화시킬 수 있을까...하는 호기심에서 말이죠.

그려보기

<form className={style.container}>
    <span>{head}</span>
    <div className={style.box-wrap}>
        <div className={style.box}>
            <label htmlFor={id}>{label}</label>
            <input type="text" id={id} name={name} value={state} onChange={({target})=>{
                    set_state(target.value);
                    set_validate(() => validator(target.value));
                }}/>
            <div className={style.msg_box}>
                {validate_result.map(({msg}) =>
                    <span>{msg}</span>
                )}
            </div>
        </div>
        <div className={style.box}>
            <label htmlFor={id}>{text}</label>
            <input type="text" id={id} name={name} value={state} onChange={({target})=>{
                    set_state(target.value);
                    set_validate(() => validator(target.value));
                }}/>
            <div className={style.msg_box}>
                {validate_result.map(({msg}) =>
                    <span>{msg}</span>
                )}
            </div>
        </div>
    </div>
</form>

위의 코드는 이미지의 레이아웃을 만들 때 가장 흔하게 만들 구조를 간단하게 넣어본 것입니다.

이 코드에서 관심사는 state와 validate_result로 대표되는 "입력 필드의 기능"과 이미지대로의 레이아웃을 구성할 수 있도록 하는 "마크업과 style"입니다. 현재는 두 가지가 하나의 코드로 붙어있습니다. 이 코드를 기능 요소만 남기고 다 제거한다면 아래와 같아질 겁니다.

<form>
    <span>{head}</span>
    <label htmlFor={id}>{label}</label>
    <input type="text" id={id} name={name} value={state} onChange={({target})=>{
            set_state(target.value);
            set_validate(() => validator(target.value));
        }}/>
    <span>{msg}</span>
    <label htmlFor={id}>{label}</label>
    <input type="text" id={id} name={name} value={state} onChange={({target})=>{
            set_state(target.value);
            set_validate(() => validator(target.value));
        }}/>
    <span>{msg}</span>
</form>

깔끔하게 보고 싶은 것만 추려서 볼 수 있게 되었지만, 레이아웃을 구성할 트리는 소멸되었습니다. 그런데 하나만 스펠링이 달라지면 코드의 컨텍스트가 달라질 것 같은 기분이 드네요.

숨기기

<FormComposite>
    <span>{head}</span>
    <label htmlFor={id}>{label}</label>
    <input type="text" id={id} name={name} value={state} onChange={({target})=>{
            set_state(target.value);
            set_validate(() => validator(target.value));
        }}/>
    <span>{msg}</span>
    <label htmlFor={id}>{label}</label>
    <input type="text" id={id} name={name} value={state} onChange={({target})=>{
            set_state(target.value);
            set_validate(() => validator(target.value));
        }}/>
    <span>{msg}</span>
</FormComposite>

form -> FormComposite로 바꾸었습니다. 컴포넌트를 만든 것인데요. 이전의 코드와 비교해본다면 없어진 트리가 FormComposite에 들어가 내재화된 것이 아닐까... 하고 갸우뚱할 수 있을 겁니다. 다만 그렇게 갸우뚱하기에는 흔적이 모자라 보입니다. 역시 무언가는 표현되어 있는 게 직관적인 인지를 위해서 더 좋을 것 같습니다.

Fragment를 placeholder처럼 사용하기

<FormComposite>
    <span>{head}</span>
    <>
        <label htmlFor={id}>{label}</label>
        <input type="text" id={id} name={name} value={state} onChange={({target})=>{
                set_state(target.value);
                set_validate(() => validator(target.value));
            }}/>
        <>
            <span>{msg}</span>
        </>
    </>
    <>
        <label htmlFor={id}>{label}</label>
        <input type="text" id={id} name={name} value={state} onChange={({target})=>{
                set_state(target.value);
                set_validate(() => validator(target.value));
            }}/>
        <>
            <span>{msg}</span>
        </>
    </>
</FormComposite>

이렇게 말이죠. 이렇게 보니 fragment가 마치 트리의 placeholder의 역할을 하는 듯 보이네요. 혹 트리의 관점에서도 요소들을 fragment로 표현하는 것이 가능하지 않을까요. 트리를 복구하며 그렇게 표현해보겠습니다.

<FormComposite 
	className={style.container} 
    tree={[
        <></>,
        <div className={style.box_wrap}>
            <>
                <></>
                <></>
                <div className={style.msg_box}>
                    <></>
                </div>
            </>
            <>
                <></>
                <></>
                <div className={style.msg_box}>
                    <></>
                </div>

            </>
        </div>,
    ]}
>
    <span>{head}</span>
    <>
        <label htmlFor={id}>{label}</label>
        <input type="text" id={id} name={name} value={state} onChange={({target})=>{
                set_state(target.value);
                set_validate(() => validator(target.value));
            }}/>
        <>
            <span>{msg}</span>
        </>
    </>
    <>
        <label htmlFor={id}>{label}</label>
        <input type="text" id={id} name={name} value={state} onChange={({target})=>{
                set_state(target.value);
                set_validate(() => validator(target.value));
            }}/>
        <>
            <span>{msg}</span>
        </>
    </>
</FormComposite>

정확하게 서로 대칭하는 구조를 가지게 되었습니다. 다만 이러한 방식은 중첩의 수, 구조적인 복잡도가 올라가면 코드를 리딩할 때 꽤나 피로를 안겨줄 것 같습니다. 그래서 이쯤에서 패턴화하는 방법을 적용해보겠습니다.

패턴화

    interface Composite {
        theme ?: {[key : string] : HTMLAttributes<HTMLElement>["className"] | Array<HTMLAttributes<HTMLElement>["className"]> }
        tree ?: Array<JSX.Element>
        children : React.ReactNode
    }

    // Composite 컴포넌트의 구현 코드는 생략.

    const layout = {
        field : {
            theme : {
                msg : style.msg_box
            },
            tree : [
                <key={"label"}></>,
                <key={"field"}></>,
                <div key={"message"}/>
            ]
        }
        fields : {
            theme:{
                wrap : style.box_wrap
                box : [style.box, style.box]
            },
            tree:[
                <key={box1}></>,
                <key={box2}></>
            ]
        },
        form : {
            theme:{
                wrap : style.container
                fields : style.box_wrap
            },
            tree:[
                <key="head"></>
                <div key={"fields"} />
            ]
        }
    }

    <Composite {...layout.form}>
        <span>{head}</span>
        <Composite {...layout.fields}>
            <Composite {...layout.field}>
                ...
            </Composite>
            <Composite {...layout.field}>
                ...
            </Composite>
        </Composite>
    </Composite>

구조와 관련된 코드는 모두 약식으로 표현되었고, 특정 객체에 정보를 모두 옮겼습니다.
어쩐지 div만큼이나 Composite를 사용할 것 같네요. 그렇지만 이 실험이 무의미해 보이진 않습니다.

  1. layout은 재사용할 수 있는 방식으로 바뀌었고,
  2. composite를 제외하면 기능 요소만 남아, state와 관련된 코드를 발라내어 보는 것이 좀 더 유리해졌습니다.
  3. 그리고 사용법이 익숙해진다면 Composite를 보는 빈도도 줄 것입니다.
    const layout = {
        form : {
            theme:{
                wrap : style.container,
                fields : style.box_wrap,
                wrap : style.box_wrap,
                msg : style.msg_box
            },
            tree:[
                <key="head"></>
                <Composite key={"fields"} tree={[
                    <key={box1}></>,
                    <key={box2}></>
                ]} />
            ]
        }
    }

    <Composite {...layout.form}>
        <span>{head}</span>
        <>
            <Field {...layout.field} onChange={....}>
            <Field {...layout.field} onChange={....}>
        </>
    </Composite>

마무리

일반적으로 컴포넌트는 기능을 독립적으로 떼어내거나 혹은 그 기능을 나누어주는 역할을 위해 만듭니다. 이 경우엔 기능과는 관련이 없고, 기능만 남겨놓고 나머지를 은닉 또는 분리하는 의도로 만들었습니다. 이런 의도도 컴포넌트가 충분히 반영할 수 있다는 게 이 실험의 가장 유의미한 결과인 것 같네요.

  • 사실 이미 기능 외의 것에 대해서만 집중한 컴포넌트가 생태계에 존재합니다. styled component가 있죠. 다만 styled component 류는 단일 요소에 대한 정의였고, Composite는 구조에 관심을 둔 차이가 있습니다. 그리고 동적 렌더링을 위해서는 Composite의 props도 configurarion 할 수 있도록 set 기능이 제공되어야 할 겁니다.
profile
이런 건 왜...

0개의 댓글