셀프서비스 디자인시스템 #2 – 개발자 편

Oct.20.2021 임보영

Web Frontend

안녕하세요. 우아한형제들에서 웹프론트엔드 개발을 담당하고 있는 이미라, 이윤희, 임보영입니다.
셀프서비스를 만들면서 적용했던 디자인시스템에 관한 이야기를 공유하려고 합니다.

디자이너 입장에서의 디자인시스템이 필요했던 이유, 팀의 일하는 방식의 변화, 적용후기 등 디자인시스템의 개념적인 이야기는 디자이너편에 있으니,
이 글에서는 디자인시스템을 개발자 관점에서 코드 중심으로 다뤄보겠습니다.

디자인시스템을 이해하기 전에 시스템이란 무엇인지 알아보겠습니다.

시스템이라고 하면 컴퓨터 구조 내에서의 무엇인가를 생각하기 마련인데요. 여기에서는 시스템이라는 단어를 좀 더 넓은 범위로 바라봐야 합니다.
시스템이라는 단어를 위키피디아에서 찾아보면 아래와 같이 소개되어 있습니다.

시스템(system)은 각 구성요소들이 상호작용하거나 상호의존하여 복잡하게 얽힌 통일된 하나의 집합체(unified whole)다. 또는 이 용어는 복잡한 사회적 체계의 맥락에서 구조와 행동을 통제하는 규칙들의 집합체를 일컫기도 한다.

디자인시스템에서의 시스템“복잡한 사회적 체계의 맥락에서 구조와 행동을 통제하는 규칙들의 집합체”에 가깝습니다.


출처: 오카와 부쿠부, “팝 팀 에픽”

이를 바탕으로 저희는 단순히 UI/UX 가이드라인이 정의된 UI 템플릿이 아닌,
프로토콜과 같은 매우 강력한 규칙으로 시스템을 만들고 이를 바탕으로 디자인시스템을 만들었습니다.

생산성을 위한 디자인시스템 채택

2020년 1분기에 배민사장님광장(사장님들을 위한 포털) 셀프서비스(사장님들이 직접 배민앱에서의 가게 설정 등을 수정할 수 있는 서비스) 전면 개편 프로젝트를 시작하게 되었습니다.

50여 개의 페이지, 140여 개의 다이얼로그, 10여 개의 API 호스트, 150여 개의 REST/GraphQL endpoint와의 연결이 있는 작지 않은 프로젝트였습니다. 덤으로 다양한 크기의 기기들에 대응하기 위한 반응형 제작도 기다리고 있었습니다.

거기에 일정은 어떠했는가! 2개월, 워킹데이로는 40일. 풀타임 프론트엔드 개발자 5명
단순히 계산하면 하루에 한 페이지씩 만든다면 가능합니다.
하지만!! 아직 디자인이 나오지 않은 상태였고 QA를 위한 시간도 미리 확보해야 했습니다.(할.. 수 있겠지??)

지면도 많고 복잡도도 높았지만, 정보를 노출하는 원칙을 정하고 이것들을 컴포넌트화해서 재사용성을 높이면 좀 더 효율적으로 빠른 시간 안에 개발할 수 있지 않을까라는 생각에 디자인시스템을 도입하기로 하였습니다.

디자인시스템 구조

셀프서비스 디자인시스템은 다음과 같은 구조를 가지고 있습니다.

코어 컴포넌트

코어 컴포넌트는 대부분 추상 클래스로 구성되어 있으며 이를 상속받아 스택을 쌓아 올리는 구조로 설계되었습니다.
이 중 BaseComponent와 PageContainer에 대해 자세히 살펴보겠습니다.

BaseComponent

BaseComponent는 모든 컴포넌트들이 상속받아야 하는 기본 클래스 컴포넌트입니다.
이 컴포넌트에서는 크게 세 가지 역할을 수행하고 있습니다.

  • 새로운 라이프사이클 정의
  • 이벤트 자동 해제
  • 로그 자동화
새로운 라이프사이클 정의

BaseComponent는 React.Component를 상속받는 추상 클래스입니다.
이러한 구조에서는 하위클래스에서 componentDidMount를 사용할 경우 super의 componentDidMount를 같이 호출해 줘야 합니다. 하지만 호출을 강제할 수 없기 때문에 componentDidMount 여부를 체크 후 호출하여 정의를 해야 합니다.
이 과정을 빠뜨리는 실수를 방지하고자 onMount라는 새로운 life cycle을 만들어 componentDidMount를 대체하였고 BaseComponent의 componentDidMount를 메소드가 아닌 변수로 선언하고 안에서 onMount를 호출하는 방식을 채택하였습니다.

public readonly componentDidMount = (): void => {
    ...
    this.onMount()
}

public readonly componentWillUnmount = (): void => {
    ...
    this.onUnmount()
}

public onMount() {}

public onUnmount() {}
이벤트 자동 해제

컴포넌트가 mount(componentDidMount)될 때 다양한 처리를 하게 됩니다. 그 중에는 DOM element나 컴포넌트에 이벤트를 설정하는 경우가 있습니다. 이런 경우 componentWillUnmount에서 설정된 이벤트를 해제해야 하는데 이를 빠뜨리는 경우가 종종 발생하기도 합니다. on 메소드는 이를 방지하고자 만들어진 메소드입니다.
컴포넌트가 EventEmitter 기능을 일부 수행하며, 해당 메소드로 이벤트 실행시 unmount에서 자동으로 해지합니다.

public on(
    target: EventTargetType,
    event: EventType,
    callback: EventCallbackType,
    options?: boolean | AddEventListenerOptions,
) {
    if (target instanceof EventEmitter) {
        target.on(event, callback as any);
    } else {
        target.addEventListener(event, callback, options);
    }
    this._events.push({ type: event, target, callback });
}
...
public readonly componentWillUnmount = (): void => {
    this._isMounted = false;

    if (this._events.length > 0) {
        this._events.map((event) => {
            try {
                if (event.target instanceof EventEmitter) {
                    event.target.off(event.type, event.callback as any);
                } else {
                    event.target.removeEventListener(event.type, event.callback);
                }
            } catch (e) {
                console.trace(e);
            }
        });
        this._events.splice(0, this._events.length);
    }

    this.onUnmount();
};
로그 자동화

웹로그 관련된 tracking 멤버 변수가 있으면 자동으로 로그를 전송해 줍니다.
이 값을 설정할 경우 마운트 시 자동으로 서버에 로그를 전송해서 데이터 시각화나 A/B 테스트에 사용할 수 있습니다.

protected tracking?: TrackLog;
...
if (this.tracking && this.tracking.screenName) TrackStore.push(this.tracking);

PageContainer

추상클래스인 BaseComponent를 상속받는 또 다른 추상클래스로 URL이 있는 모든 페이지가 상속받아야 하는 컴포넌트입니다.

tracking 변수는 abstract로 선언하여 PageContainer를 상속받는 컴포넌트는 필수적으로 정의하여 해당 페이지의 뷰 로그를 남길 수 있도록 강제하였습니다.
그리고 serverStatusKey 변수를 이용해 각 도메인 별로 페이지에 점검을 걸 수 있도록 최상위 페이지들을 PageContainer를 상속받아 구성하면 서버에서 점검이 진행될 경우 render 함수를 바꿔침으로써 점검화면을 보여주게 됩니다.

export abstract class PageContainer<P = {}, S = {}> extends BaseComponent<P, S> {
    abstract serverStatusKey: MaintenanceCategory = MaintenanceCategory.none;
    abstract tracking: TrackLog;

    public constructor(props: P) {
        super(props);
        this.hooks.onMount.push(() => {
            // 점검 메시지가 내려오면 render 함수를 점검 메시지 출력으로 교체
            this.maintenanceStore.onChangeStatus(this, (message?: string) => this.setMaintenanceMessage(message));
        });
        // ...
    }

    private setMaintenanceMessage(message?: string) {
        if (message) {
            this.maintenanceMessage = message;
            if (this.render !== this.renderMaintenance) {
                this._render = this.render;
                this.render = this.renderMaintenance;
            }
        } else {
            if (this.render === this.renderMaintenance) {
                this.render = this._render;
            }
        }
        this.forceUpdate();
    }

    private renderMaintenance(): React.ReactNode {
        return <Maintenance message={this.maintenanceMessage || '점검 중입니다'} />;
    }
}

코어 라이브러리

CachedEntity

CachedEntity는 서버 사이드에서 많이 쓰이는 캐시 관리 기법이 적용 된 추상클래스 입니다.

자주 변하지 않는 데이터의 경우 CachedEntity의 인터페이스에 맞춰서 구현하면 TTL(time to live)이 지원되는 디스크/메모리 캐싱 기능을 사용할 수 있습니다.

CachedEntity의 인터페이스를 통해 1)메모리 캐시 확인, 2)IndexedDB 확인, 3)API 호출 순서로 데이터를 조회하게 됩니다. 조회된 데이터는 IndexedDB와 메모리에도 캐시하고 재활용하게 됩니다.

CachedEntity 자체는 복잡한 구조들로 이루어졌지만 실제 활용 시에는 간단한 코드 작성만으로 뒤에 있는 복잡한 기능들을 동작시킬 수 있습니다.

abstract class CachedEntity<DATA_TYPE, ID_TYPE> {
    // 캐시 구분 및 동시 호출 제약을 위한 키
    protected abstract key: string;
    // entity 내부의 id로 사용하는 값을 리턴
    protected abstract getId(entity: DATA_TYPE): ID_TYPE;
    // 원본 데이터 조회. 복수의 키들을 인자로 받아서 복수의 엔티티를 리턴해야 함. 보통 API 호출이 내부에서 일어남
    protected abstract async getData(idList: ID_TYPE[]): Promise<DATA_TYPE[]>;

    // ... 공통, override 가능한 코드들
}

아래는 s2 정보를 조회하는 기능을 가진 클래스입니다. CachedEntity를 상속받으면 아래 정도의 코드만 구현하면 쉽게 캐싱 기능을 사용할 수 있습니다.

class CachedDongS2 extends CachedEntity<DongS2, string> {
    protected key = 'CachedDongS2-1';

    protected getId(entity: DongS2): string {
        return entity.id;
    }

    protected async getData(idList: string[]): Promise<DongS2[]> {
        return RegionAPI.api.post('/v3/geometry/s2', idList).then((data: { [key: string]: string[] })
    }
}

화면 구성요소

베이스를 만든 후 CARD, FORM 같은 구성요소들을 만들고 각 화면에 빠르게 적용했습니다.

단순 UI만을 구현한 컴포넌트가 아닌 컴포넌트 간의 관계에 따른 다양한 규칙들을 정의하여 화면 구성에 대한 고민의 시간을 줄일 수 있었습니다.

Card


Card Component는 유연하고 확장 가능한 Card 형식의 콘텐츠 컨테이너이며, 선택적으로 추가 가능한 카드 헤더와 카드 레이아웃(행/열 정렬) 컴포넌트를 제공합니다.

Card 하위에 있는 아이템들을 재구성해서 노출시키고 있어서 나중에 레이아웃이 변경되더라도 기존 코드들을 수정하지 않고 Card Component에서 재조립 할 수 있다는 장점이 있습니다.

export class Card extends BaseComponent<CardProps> {
    render() {
        ...
        const children = React.Children.toArray(this.props.children as JSX.Element) as JSX.Element[];
        const cardItems = [CardTitle, CardMenu, CardDescription, CardHint, Tooltip];
        const [title, menu, description, hint, rows] = [
            children.find((child) => child.type === CardTitle),
            children.find((child) => child.type === CardMenu),
            children.find((child) => child.type === CardDescription),
            children.find((child) => child.type === Tooltip || child.type === CardHint),
            children
                .filter((child) => !cardItems.includes(child.type) && React.isValidElement(child))
                .map((row, idx) => React.cloneElement(row as any, { key: idx })),
        ];

        return (
            <div
                className={'Card ' + (mobile ? 'mobile ' : '') + (rounded ? 'rounded ' : '') + (className || '')}
                style={style}
            >
                {(title || hint || menu) && (
                    <div className="card-header">
                        {(title || hint) && (
                            <h3>
                                {title}
                                {hint}
                            </h3>
                        )}
                        {menu}
                    </div>
                )}
                {description}
                {rows}
            </div>
        );
    }
}

상속 구조

구성요소들을 만들 때 공통적인 부분은 최대한 추상 클래스에 구현하여, 비즈니스 로직과 관련된 부분만을 작성할 수 있도록 영역을 격리시켰습니다.

Dialog 컴포넌트들은 비즈니스 로직이 들어가는 화면에만 집중할 수 있도록 만들어진 대표적인 추상 컴포넌트들입니다.

해당 컴포넌트들은 상속받는 곳에서 메소드를 오버라이드하여 커스텀이 가능하도록 설계되었습니다.

  • Dialog
  • StepDialog

Dialog

Dialog 컴포넌트는 정보 또는 경고 메시지를 표시하여 사용자에게 보여주기 위한 추상클래스입니다.
전역 스토어로 관리되고 화면에 그려주는 부분은 Root Component에서 담당합니다.
모든 다이얼로그는 Dialog 컴포넌트를 상속받고 추상 메소드(renderBody)를 구현해야 합니다.
특수한 상황에서는 renderTitle, renderFooter 를 오버라이드하여 다른 형태로 보이게 할 수 있고, 그보다 더 큰 변화가 필요한 경우 renderContent 전체를 오버라이드하여 다이얼로그의 open/close 기능만 사용하고 완전히 다른 표현 형식을 가지게 할 수도 있습니다.
이처럼 하나의 상위 클래스를 만들어 놓고 오버라이드를 통해 중복구현해야 하는 부분들을 최대한 줄였습니다.

export abstract class Dialog<P = {}, S = {}> extends BaseComponent<P, S> implements IDialog {
    public abstract renderBody(): ReactNode;
    protected onShow() {}
    protected onShown() {}
    protected async onClose(): Promise<boolean> {
        return true;
    }
    ...
    protected renderContent() {
        return (
            <>
                {this.renderTitle()}
                <div className="wrap">
                    <a className="content-begin" />
                    <div className="content">{this.renderBody()}</div>
                </div>
                {this.renderFooter()}
            </>
        );
    }

    protected renderTitle(): ReactNode {
                    ...
    }

    protected renderFooter(): ReactNode {
                    ...
    }
}

StepDialog

StepDialog는 Dialog를 상속 받은 추상클래스입니다. 긴 호흡의 UX 컨텍스트를 가진 화면들이 이전/다음 버튼으로 연결된 다이얼로그를 구현하는데 사용됩니다.
StepDialog는 대부분 여러 화면을 구성하는데 하나의 데이터 객체를 사용합니다. 그래서 진행 상태, 사용할 데이터 등을 포함한 로컬 store를 가지고 있으며 다이얼로그 내의 UI들은 이 store 정보들의 변화에 반응하게 되어 있습니다.

StepDialog를 만들때 Step 이라는 추상 클래스 컴포넌트를 상속받아 각 화면을 구현합니다.
그리고 StepDialog의 추상 메소드인 renderSteps를 구현할 때 각 화면의 컴포넌트를 배열로 리턴하여 스텝들을 배열 순으로 노출시키고 있습니다.

abstract class StepDialog<StepState, P extends StepDialogProps<StepState>, S = {}> extends Dialog<P, S> {
    ...
    protected renderContent() {
        ...
        // Dialog의 renderContent를 오버라이드하여 다른 표현 형태를 구현
        return (
            <>
                {this.renderTitle()}
                {this.renderProgress()}
                <div className="wrap StepDialog">
                    <a className="content-begin" />
                    <div className="content">{this.renderBody()}</div>
                </div>
                {this.renderFooter()}
            </>
        );
    }

    abstract renderSteps(): JSX.Element[];

    readonly renderBody = (): React.ReactNode => {
        return (
            <Steps
                ...
                initialState={this.props.initialState}
                onStepChange={...}
                onComplete={...}
            >
                {this.renderSteps()}
            </Steps>
        );
    };

    protected async onStepChange(store: StepStore<StepState>): Promise<void> {
        ...
    }

    protected renderFooter(): ReactNode {
        ...
    }

    protected async onComplete(store: StepStore<StepState>) {
        ...
    }

    protected renderProgress() {
        ...
    }
}

적용 결과

이처럼 디자인시스템에서는 대부분의 코드들이 객체지향 추상 클래스들로 구성되어 셀프서비스 프로젝트를 진행할 때는 비즈니스 로직을 처리하는데 집중할 수 있도록 했습니다.
좀 더 구체적으로는 공통된 UI/UX와 규칙을 정의하여 스타일에 대한 고민없이 큰 틀에서의 디자인은 그대로 이어가면서 새로운 컨텍스트가 필요한 부분들만 cascading하여 사용함으로써 커뮤니케이션 시간을 줄이고 업무 속도도 향상되었습니다.

마치며

지금까지 디자인시스템의 도입 과정과 생산성을 높이기 위한 시스템 설계에 대해 소개해 보았습니다.
디자인시스템을 구축하고 끝나는 것이 아닌 지속적으로 보완하고 개선하기 위한 장치로서 매주 워크숍을 진행하고 있고, 여기서 다양한 코드를 함께 만들고 논의하며 신뢰를 쌓아가고 있습니다.
지금은 사이드 프로젝트로서 E2E 테스팅 플랫폼 개발(백엔드 개발 포함)을 진행하고 있습니다.

이 글을 읽고 관심이 생겼다면 [사장님서비스실] 배민셀프서비스팀에 많은 지원 부탁드립니다.
저희 팀에 합류하여 함께 성장하는 개발자가 되어 주세요. 🙇🏻‍♂️