디자인 시스템에 Compound Component 적용하기

오형근·2023년 1월 26일
0

Project

목록 보기
10/10
post-thumbnail

오랜만에 글을 쓴다! 지난 달 동안 알고리즘 공부를 나름 열심히...해서 지금 백준 골드 4 티어를 달성했다!

아마 2/25 소마 첫 코테 이전까지 골드 3을 찍고 진입하는 것 같다!

그래서 1월 초까지는 알고리즘 공부를 지속했고, 이후에는 sql 기본 문법을 익히면서 기존의 디자인 시스템을 다시 손보고 있다.

그 중에서 가장 처음으로 한 것은 리액트 패턴 중 요즘 많이 대두되고 있는 Compound Component 패턴을 공부하는 것!

이 패턴은 사실 모르고 있었지만 디자인 시스템 중에서 Select(혹은 Dropdown) 컴포넌트를 제작하는 과정이 쉽지 않아 방법을 찾아보던 중 알게 된 패턴이다. 기존에는 Select 컴포넌트를 제작하여도 외부에서 선택된 요소를 어떻게 할지가 정의된 next()함수를 어떻게 전달하고 적용할지 고민을 꽤 오랜 시간 하고 있었는데, Compound Component 패턴이 이러한 고민을 말끔히 해결해주었다.

컴포넌트 내부적으로 React context API를 적용해서 지역적인 context를 사용해 각 하위 컴포넌트들을 더 강하게 묶고, 외부에서 온 인자 또한 손쉽게 적용할 수 있었다.

기존의 컴포넌트 코드는 일단 CSS-In-Js 방식을 적용하고 있어서 더 긴 코드이지만, 스타일 부분을 제거하고 보아도 코드가 너무 길었다.

그래서 이때부터 나름 규모가 있는 컴포넌트는 역할에 따른 구조 분리가 필수적이라고 느꼈다.

컴파운드 컴포넌트 패턴을 적용하고 나서, 내 코드 구조는 다음과 같다.

Select
├── hooks
│   ├── Selected.tsx
│   ├── SelectEx.tsx
│   ├── SelectMain.tsx
│   ├── SelectOption.tsx
│   └── SelectOptionBox.tsx
└── ui
│   ├── SelectedBoxArea.tsx
│   └── SelectArea.tsx
├── index.ts
└── Select.tsx

이렇게 하나의 컴포넌트 구현을 위한 폴더를 위처럼 구성하였다.

hooks

직접적인 로직이 담긴 컴포넌트. 로직을 구현한 뒤 ui에서 컴포넌트를 불러와 로직을 입혀 내보낸다.

Main

말 그대로 가장 메인이 되는 컴포넌트. useContext와 useReducer를 이용하여 컴포넌트 내부 상태 관리를 한다.

interface IContext {
    options?: string[],
    search?: string,
    selected?: string,
    toggled?: boolean,
    next: (e: any) => any,
    setOptions: (e: any) => any,
    setSearch: (e: any) => any,
    setSelected: (e: any) => any,
    setToggled: () => any
};

// next는 외부에서 받는 함수. 
// 새로운 선택지가 선택되면 next함수를 실행시켜 외부로 값을 전달하거나 이용할 수 있도록 해준다!
const initialContext: IContext = {
    options: [],
    search: "",
    selected: "",
    toggled: false,
    next: (e: any) => { },
    setOptions: (e: any) => { },
    setSearch: (e: any) => { },
    setSelected: (e: any) => { },
    setToggled: () => { }
};

export const SelectContext = createContext(initialContext);

SelectContext.displayName = 'SelectContext';

export const useSelect = () => {
    const context = useContext(SelectContext);

    if (context === undefined) {
        throw new Error("useSelect must be used within a <Select />")
    };
    return context;
};

type ActionType = {
    type: "ADD",
    value: string
} | {
    type: "SETSEARCH",
    value: string
} | {
    type: "SETSELECTED",
    value: string
} | {
    type: "SETBOOLEAN"
};

const reducer = (state: IContext, action: ActionType): IContext => {
    switch (action.type) {
        case "ADD":
            return {
                ...state,
                options: state.options!.concat(action.value)
            };
        case "SETSEARCH":
            return {
                ...state,
                search: action.value
            };
        case "SETSELECTED":
            return {
                ...state,
                selected: action.value
            };
        case "SETBOOLEAN":
            return {
                ...state,
                toggled: !state.toggled
            }
        default:
            return state;
    };
};

위와 같이 context를 생성하고 관련하여 reducer를 만든다. 여기서 context에 해당 context를 조작할 수 있도록 함수들도 추가 정의 해주어야 한다.

export const SelectMain = ({ children, next }) => {
    const [state, dispatch] = useReducer(reducer, initialContext);
    return (
        <SelectContext.Provider value={{ ...state, next, setOptions: (value: string) => dispatch(({ type: "ADD", value })), setSearch: (value: string) => dispatch({ type: "SETSEARCH", value }), setSelected: (value: string) => dispatch({ type: "SETSELECTED", value }), setToggled: () => dispatch({ type: "SETBOOLEAN" }) }}>
            {children}
        </SelectContext.Provider>
    );
};

실제 context를 제공하는 provider가 적용된 Main 컴포넌트이다. value를 통해서 state를 단방향 제공해주는 시작점이 되고, 그 밖에 next함수(외부함수)를 가져오고 상태변화 함수들도 정의하는 곳이다.

export const SelectEx = ({ children, ...props }) => {
    const nextfc = (value: string) => console.log(value);

    return (
        <SelectMain next={nextfc} {...props}>
            <Selected />
            <MarginBox marginBottom='0.5rem' />
            <SelectOptionBox>
                {children}
            </SelectOptionBox>
        </SelectMain>
    )
}

세부 로직 구현이 완료된 컴포넌트들을 불러와 배치하는 곳이다. 이 컴포넌트가 실질적으로 가장 바깥쪽을 감싸는 컴포넌트가 된다.

UI

스타일 디자인 컴포넌트. 전체 컴포넌트의 뼈대가 되는 컴포넌트들이 있다. 로직이 들어가지 않으며, Emotion을 이용한 스타일링만 한다.

index.ts

부모 컴포넌트가 될 SelectMain 컴포넌트 아래에 export할 타 컴포넌트를 Object.assign메서드를 이용하여 객체화하고 내보낸다.

const Select = Object.assign(SelectMain, {
    Select: SelectEx,
    Option: SelectOption,
});

export default Select;

Select.tsx

index.ts에서 import한 Select 객체를 이용하여 실제 예시를 작성해보고, story 작성을 위한 예시 컴포넌트를 내보내는 곳.

실제 사용 예시는 다음과 같다!

const SelectComponenet = () => {
    const nextfc = (value: string) => console.log(value)

    return (
        <Select.Select next={nextfc}>
            <Select.Option>Option 1</Select.Option>
            <Select.Option>Option 2</Select.Option>
        </Select.Select>
    )
}

export default SelectComponenet;

어떤가? 이전보다 사용 방법이 훨씬 직관적이고 코드가 깔끔해진 것을 한 눈에 알 수 있다...

아직 파일 이름들이 너무 임시로 지은 탓에 좀 헷갈리는 감이 없지 않아 있지만, 추후에 다시 변경해줄 예정이다.


컴파운드 컴포넌트 패턴을 이용하여 구현한 Select 컴포넌트는 훨씬 사용 방법이 간단해지고 그 자체로 직관성과 일관성을 제공하기 좋은 컴포넌트가 되었다.

앞으로 복잡한 로직을 지닌 컴포넌트 구현은 컴파운드 컴포넌트 패턴을 이용하여 리팩토링하려고 한다.

이를 위해 기존 컴포넌트들에 대한 테스트 코드가 필요한데, 이를 어떻게 작성해야할지 다음 글에서 소개해보고자 한다!!!

profile
https://nohv.site

0개의 댓글