공통 Header 컴포넌트를 만드는 여정

공통 Header 컴포넌트를 만드는 여정

·

11 min read

여러 곳에서 사용하고 있는 Header 디자인

디자인 시안을 보다가 비슷하게 생긴 Header 디자인이 여러 곳에서 등장하는 것을 발견했다. 레이아웃은 비슷하고 내부 요소만 조금씩 다른 컴포넌트다. 내 담당 페이지에도 있어서 만들어야 하는데, 어차피 만드는 거 공통 컴포넌트로 제작하면 좋을 것 같다. 만약 Header 컴포넌트를 따로 만들게 되면 아래와 같은 문제가 있다.

  1. 매번 번거로운 작업을 해야 한다.
    Header 디자인은 약 20곳에서 등장한다. 매번 비슷한 컴포넌트를 만들기 번거롭고, 그만큼 작업 시간도 늘어난다.

  2. 디자인이 미세하게 달라질 수 있다.
    이전 프로젝트에서도 이런 경험이 있었는데, 각자 만들다 보면 디자인이 미세하게 달라질 때가 종종 있었다. 동일한 컴포넌트 디자인이 페이지마다 조금씩 다르게 보인다면 서비스의 완성도가 떨어져 보인다.

  3. 리뷰 시간이 늘어난다.
    각자 구현하면 리뷰 할 때도 컴포넌트 구현체까지 확인해야 한다. 디자인과 동일한지 매번 확인해야 하고 그러다 보면 리뷰 시간이 늘어난다. 피로도가 높아지는 건 덤이다.


공통 컴포넌트로 만든다면?

Header를 공통 컴포넌트로 만들면 어떨까? 그러면 위와 같은 문제를 해결할 수 있지 않을까? 공통 컴포넌트로 만든다면 아래와 같은 이점이 있을 것 같다.

  1. 작업 시간이 줄어든다.

    매번 Header를 구현할 필요가 없으니 작업 시간이 줄어든다. 필요한 곳에 컴포넌트를 불러오기만 하면 된다. 변경 사항이 있어도 Header 컴포넌트 하나만 수정하면 된다. 만약 각자 구현했다면? 여러 파일을 넘나들며 수정해야 하고 잘 적용되었는지 확인도 해야 한다. 생각만 해도 힘들다.

  2. 일관된 컴포넌트를 보여줄 수 있다.
    한 곳에서 관리하면 어느 곳에서나 일관된 Header 컴포넌트를 보여줄 수 있다. 변경 사항이 생겨도 공통 컴포넌트만 수정하면 되기 때문에 실수로 누락할 위험도 없다.

  3. 리뷰 시간이 줄어들고 다른 부분에 집중할 수 있다.
    매번 비슷한 컴포넌트를 확인하지 않아도 된다. 그만큼 다른 부분에 집중할 수 있어서 생산성이 올라갈 것이다.


Header를 공통 컴포넌트로 만들자! 그런데 복잡해지지 않을까...?

위와 같은 이유로 Header를 공통 컴포넌트로 만들자고 제안했다. 하지만 컴포넌트가 복잡해질 것 같다는 우려가 있었다. 아래 사진처럼 Header는 큰 레이아웃만 비슷하고 내부 요소는 조금씩 다르다. 다른 부분을 props로 받고 내부에서 분기 처리를 하면 컴포넌트를 사용하는 곳과 내부가 복잡해질 수 있다.

이를 해결하기 위해 namespace 패턴을 제안했다. namespace는 식별할 수 있는 범위를 의미하는 데 이를 사용하면 이름 충돌을 피할 수 있다. 다른 언어에는 namespace 기능이 있지만 JavaScript는 없다. 대신 언어의 특성을 활용해 비슷하게 구현할 수 있다.

React에서도 이를 활용할 수 있는데, 함수 컴포넌트에 다른 함수 컴포넌트를 넣는 것이다. 이렇게 하면 Header와 관련된 요소를 한 곳에 뭉쳐둘 수 있고, 내부 요소끼리는 독립적이라 비교적 복잡하지 않은 컴포넌트를 구성할 수 있을 것 같다.

namespace 패턴을 사용해 Header 컴포넌트를 구현하면 아래와 같은 생김새가 된다. Header 컴포넌트를 불러와서 필요한 요소를 꺼내 조합하는 방식이다.

import Header from './Header';

// Header 내부 요소를 조합해서 사용
<Header>
    <Heaer.Title>제목</Heaer.Title>
    <Heaer.Button>버튼</Heaer.Button>
</Header>

하지만 실제 구현은 해보지 않아서 이 방식도 복잡해질 수 있다. 그래서 우선 빠르게 구현해 보고 괜찮으면 사용하기로 했다.


일단 빠르게 구현하기

우선 어떤 구성인지 파악해 보자. Header 내부에는 제목, 뒤로가기 버튼, 닫기 버튼, 텍스트 버튼이 있다. 이 요소를 조합하면 Header 하나가 완성된다. 하지만 항상 동일한 구성은 아니다. 뒤로가기 버튼만 있을 수도 있고, 닫기 버튼과 제목이 함께 있을 수도 있다. 사용하는 곳을 먼저 그려보면 아래와 같은 코드가 될 것 같다.

// 뒤로가기 버튼만 있음
<Header>
  <Header.PrevButton />
</Header>

// 닫기 버튼, 제목이 있음
<Header>
  <Header.CloseButton />
  <Header.Title>제목</Header.Title>
</Header>

구성 요소 파악 후 Header.tsx에 모든 컴포넌트를 감싸는 Header 컴포넌트와 하위 컴포넌트를 정의했다. Title 컴포넌트는 size props를 기준으로 스타일을 다르게 적용한다.

// 모든 컴포넌트를 감싸는 Header 컴포넌트
export default function Header({ children }: { children: ReactNode }) {
    return <div css={headerStyle.container}>{children}</div>;
}

// 뒤로가기 버튼
Header.PrevButton = function HeaderPrevButton() {
    return (
        <button
            type="button"
            css={headerStyle.iconButton}
        >
            <ArrowLeftIcon />
        </button>
    );
};

// 닫기 버튼
Header.CloseButton = function HeaderCloseButton() {
    return (
        <button
            type="button"
            css={headerStyle.iconButton}
        >
            <CloseIcon />
        </button>
    );
};

// 제목
Header.Title = function HeaderTitle({ size = "default", children }: HeaderTitleProps) {
    return <h1 css={size === "large" ? headerStyle.titleLarge : headerStyle.title}>{children}</h1>;
};

// 액션 버튼
Header.ActionButton = function HeaderActionButton({
    children,
    ...props
}: ComponentPropsWithoutRef<"button">) {
    return (
        <button css={headerStyle.actionButton} {...props}>
            {children}
        </button>
    );
};

사용할 때는 아래처럼 사용한다. 디자인과 동일하게 닫기 버튼, 제목, 액션 버튼을 순서대로 적용하면 된다.

// 닫기 버튼, 제목, 액션 버튼이 있는 Header
<Header>
  <Header.CloseButton />
  <Header.Title>습관 관리</Header.Title>
  <Header.ActionButton onClick={handleCompletionButton}>완료</Header.ActionButton>
</Header>

만들어보니 생각보다 그렇게 복잡하지 않아서 이렇게 하기로 했다.


컴포넌트 다듬기

이제 컴포넌트를 다듬어보자. 여러 곳에서 사용하는 컴포넌트라 이런저런 고민이 많았다. 사용하기 쉽고 이름으로 어떤 기능인지 바로 유추할 수 있기를 원했다.

직관적인 이름으로 변경

size → hasButton

Header.Title = function HeaderTitle({ size = "default", children }: HeaderTitleProps) {
    return <h1 css={size === "large" ? headerStyle.titleLarge : headerStyle.title}>{children}</h1>;
};

제목은 두 가지 종류가 있다.

  1. 중앙 정렬된 중간 크기 제목

  2. 왼쪽 정렬된 큰 크기 제목

처음에는 size props를 받아서 default면 1번, large면 2번 스타일을 적용하는 방식을 사용했다. 하지만 size라는 이름은 변경되는 스타일을 전부 표현하지 못한다. 이름만 보면 크기만 바뀔 것 같은데 스타일은 정렬까지 바꾸고 있으니 사용할 때 헷갈릴 수 있을 것 같다.

그래서 팀원들에게 물어보았는데 버튼 유무를 기준으로 스타일을 바꾸자는 의견을 들었다. Header는 버튼을 기준으로 크게 두 종류로 나뉘는데, 버튼이 있으면 항상 '중앙 정렬 + 중간 크기' 제목이고 버튼이 없으면 '왼쪽 정렬 + 큰 크기' 제목이다.

바뀐 코드는 아래와 같다. hasButton이 true면 '중앙 정렬 + 중간 크기' 제목을 적용하고, false면 '왼쪽 정렬 + 큰 크기' 제목을 적용한다. 디자인상 true인 경우가 훨씬 많기 때문에 hasButton의 기본값을 true로 설정했다. 이렇게 하면 사용할 때 버튼이 있는 경우 굳이 hasButton을 전달해 주지 않아도 된다.

// 변경된 코드
Header.Title = function HeaderTitle({ hasButton = true, children }: HeaderTitleProps) {
    return <h1 css={hasButton ? headerStyle.title : headerStyle.titleLarge}>{children}</h1>;
};
// (1) 버튼이 있는 경우
<Header>
  <Header.CloseButton />
  <Header.Title>습관 관리</Header.Title>
</Header>

// (2) 버튼이 없는 경우
<Header>
  <Header.Title hasButton={false}>습관 관리</Header.Title>
</Header>

지금은 제목 스타일이 두 가지라 이렇게 했지만, 나중에 다른 크기와 정렬을 가진 디자인이 나온다면 그때는 이 둘의 스타일을 분리해야 하지 않을까 싶다.

ActionButton → TextButton

Header.ActionButton = function HeaderActionButton({
    children,
    ...props
}: ComponentPropsWithoutRef<"button">) {
    return (
        <button css={headerStyle.actionButton} {...props}>
            {children}
        </button>
    );
};

ActionButton은 사진에서 동그라미 표시된 부분이다. 처음에는 이 버튼을 누를 때 어떤 '액션'이 일어나서 ActionButton이라고 지었다. 하지만 생각해 보니 뒤로가기, 닫기 버튼도 눌렀을 때 액션이 일어나는 건 동일하다. 뒤로가기나 닫기 버튼처럼 컴포넌트의 행동이 명확하지 않은데 억지로 연관 지으려 하니 모호한 이름이 되어버렸다. 그래서 외형을 묘사하는 이름인 TextButton으로 변경했다.

props는 어디까지 지정해야 할까?

버튼의 props는 어떻게 지정해야 할지 고민했다. 뒤로가기, 닫기, 텍스트 버튼은 클릭했을 때 어떤 행동을 한다. 그래서 onClick props만 받는 게 맞는 걸까? 결론부터 말하면 뒤로가기, 닫기는onClick만 받고 텍스트는 버튼 컴포넌트에 올 수 있는 모든 props를 허용했다.

뒤로 가기 버튼의 기능은 이전 페이지로 이동하는 기능이다. 그래서 처음에는 onClick props 없이 컴포넌트 내부에서 구현했다.

하지만 특정 위치에서는 이전 페이지로 가는 게 아닌 이전 상태로 돌아가야 한다. 그래서 onClick props를 받을 수 있게 수정했다. 기본 기능은 이전 페이지로 돌아가는 것이지만 onClick이 있으면 전달한 함수가 동작한다.

import { useNavigate } from "react-router-dom";

Header.PrevButton = function HeaderPrevButton({ onClick }: IconButtonProps) {
    const navigate = useNavigate();

    return (
        <button type="button" css={headerStyle.iconButton} onClick={onClick || (() => navigate(-1))}>
            <ArrowLeftIcon />
        </button>
    );
}

닫기 버튼은 보통 풀스크린 모달에서 동작한다. 그래서 기본 기능이 없고 onClick 이벤트 핸들러를 전달받게 했다.

텍스트 버튼은 뒤로 가기나 닫기 버튼처럼 정해진 기능이 없다. 현재는 '관리'와 '완료' 버튼이 있는데 추후 다른 기능을 가진 버튼이 올 수 있다. 그래서 타입을 예상하기 힘들기 때문에 버튼의 기본 props를 전부 허용한다. 버튼의 props에 ComponentPropsWithoutRef<"button"> 타입을 지정하면 버튼 요소에 올 수 있는 이벤트나 속성을 모두 받을 수 있다.

Header.TextButton = function HeaderTextButton({
    children,
    ...props
}: ComponentPropsWithoutRef<"button">) {
    return (
        <button css={headerStyle.textButton} {...props}>
            {children}
        </button>
    );
};

완성 🎉

위 내용 외에 Header를 상단에 고정하기 위해 isFiexd props도 추가되었고 가독성을 위해 타입, 스타일 코드를 다른 파일로 분리했다. 그래서 최종 완성된 코드는 아래와 같다.

export default function Header({ children, isFixed = true }: HeaderProps) {
    return <div css={getContainerStyle(isFixed)}>{children}</div>;
}

Header.Title = function HeaderTitle({ children, hasButton = true }: TitleProps) {
    return <h1 css={getTitleStyle(hasButton)}>{children}</h1>;
};

Header.PrevButton = function HeaderPrevButton({ onClick }: IconButtonProps) {
    const navigate = useNavigate();

    return (
        <button type="button" css={iconButtonStyle} onClick={onClick || (() => navigate(-1))}>
            <ArrowLeftIcon />
        </button>
    );
};

Header.CloseButton = function HeaderCloseButton({ onClick }: IconButtonProps) {
    return (
        <button type="button" css={iconButtonStyle} onClick={onClick}>
            <CloseIcon />
        </button>
    );
};

Header.TextButton = function HeaderTextButton({
    children,
    ...props
}: ComponentPropsWithoutRef<"button">) {
    return (
        <button css={textButtonStyle} {...props}>
            {children}
        </button>
    );
};

목표는 달성했을까?

작업시간 줄이기

  • 매번 비슷한 컴포넌트를 만들지 않아도 된다. Header 컴포넌트가 필요하면 그냥 import해서 디자인에 맞게 조립하면 끝이다.

  • 코드 리뷰할 때도 Header 컴포넌트의 구현체까지 파악하지 않아도 돼서 다른 로직에 집중할 수 있다.

어느 곳에서나 동일한 컴포넌트 보여주기

  • 한 곳에서 관리하기 때문에 디자인 변경이 생겼을 때 Header 컴포넌트만 변경하면 된다. 이제 실수로 변경 사항을 누락할 일이 줄었다.

컴포넌트 복잡도 줄이기

  • namespace 패턴과 children을 사용해 전체 prop의 양을 줄여 내부 로직과 외부 인터페이스가 간결해졌다.

추가 작업: 컴포넌트 사용법 문서로 정리하기

Header 컴포넌트 작업이 거의 마무리됐을 때쯤 팀원 중 한 분이 주석으로 컴포넌트 설명을 달면 좋을 것 같다는 요청이 있었다. 이왕 하는 거 JSDoc으로 다는 게 좋을 것 같아 예시로 간단하게 작성해서 보여주었다.

타입스크립트를 사용하고 있으니 타입 정보를 제외하고 컴포넌트 설명, props의 용도 위주로 설명을 작성했다.

/**
 * Header 컴포넌트
 * 
 * @property isFixed - 화면 상단 고정 여부를 결정함(true: 기본값, 화면 상단에 고정 / false: 상단에 고정하지 않음)
 * @description Header 내부에 있는 하위 컴포넌트를 조합해 구성
 * @example
 * <Header>
    <Header.PrevButton />
    <Header.Title>제목</Header.Title>
   </Header>
 */

export default function Header({ children, isFixed = true }: HeaderProps) {
    return <div css={getContainerStyle(isFixed)}>{children}</div>;
}

// 나머지 코드...

JSDoc으로 설명을 달면 확인하기 편하다. 반면 일반 주석은 설명을 보고 싶을 때마다 컴포넌트 파일로 이동해야 해서 불편하다. 그래서 컴포넌트에 커서만 올리면 설명을 바로 볼 수 있게 JSDoc을 사용하는 게 좋을 것 같았다.

- 이렇게 컴포넌트 위에 커서를 올리면 설명이 등장한다.

그래서 이렇게 작성하는 게 어떤지 의견을 물었는데 컴포넌트보다 주석이 더 길어지는 게 보기 힘들다는 의견이 나왔다. 이와 관련해서 의논하다가 타협점으로 팀 노션에 문서화를 해두고 JSDoc을 사용해 문서 링크를 걸어두기로 했다.

문서 작성하기

노션에 문서로 만들면 주석보다 더 자세하게 작성할 수 있다. 추후 팀원 변동이 생겨 다른 사람이 올 수 있으니, 그때를 위해서라도 상세히 설명을 작성하는 게 좋겠다.

어떻게 구성할지 고민하다가 평소 개발 문서를 읽을 때 불편했던 경험을 떠올리며 작성했다. 예를 들어, 예제 코드가 부족하다거나, 화면에 컴포넌트가 어떻게 보이는지 바로 알 수 없거나, 특정 요소와 관련된 속성을 쉽게 찾지 못할 때가 있다. 그래서 문서에 이 부분을 최대한 반영하려고 했다.

아래 사진처럼 컴포넌트 설명, 사용 방법, 관련 props와 예시를 작성했다. 예시는 케이스별 예제 코드와 함께 사진을 삽입해 어떤 UI인지 확인할 수 있게 했다.

@see를 사용해 링크 달기

코드를 작성하다 설명이 필요할 때 사용 방법이 적힌 문서를 찾아야 해서 귀찮을 수 있다. 그래서 JSDoc의 @see 태그를 사용해 문서 링크를 달아두었다.

/**
 * @see https://www.notion.so/connecting-star/Header-9bf6a5c36d19428cac03aada13732d61
 */

export default function Header({ children, isFixed = true }: HeaderProps) {
    return <div css={getContainerStyle(isFixed)}>{children}</div>;
}

@see 태그는 관련 참조를 명시할 때 사용하는데, 툴팁에서 링크를 클릭 하면 바로 이동할 수 있어서 편하다.


새로 알게된 것

PropsWithChildren

처음에는 children을 직접 지정했는데, 팀원이 PropsWithChildren을 추천해 줘서 알게 되었다. 이름에서 드러나듯이 children을 가진 props 타입을 지정할 수 있다.

import { PropsWithChildren } from "react";

export interface HeaderProps extends PropsWithChildren {
    isFixed?: boolean;
}

export interface TitleProps extends PropsWithChildren {
    hasButton?: boolean;
}

이 타입을 사용하면 아래처럼 반복적으로 children 타입을 지정해야 하는 번거로움을 줄일 수 있다.

// 이런식으로...
export interface HeaderProps {
    children: React.ReactNode;
    isFixed?: boolean;
}

export interface TitleProps {
    children: React.ReactNode;
    hasButton?: boolean;
}

주의할 점은 children이 필수 타입이 아니다. 그래서 children을 전달하지 않아도 에러가 발생하지 않는다. 만약 children을 필수로 지정하고 싶다면 해당 타입을 쓰는 것보다 직접 지정하는 게 좋다. 지금은 괜찮지만 추후 children을 지정하지 않는 실수가 반복된다면 필수로 지정하는 게 좋을 것 같다.

ComponentPropsWithoutRef<"element">

element에서 사용할 수 있는 props를 지정하는 타입인데 ref 타입은 없다. TextButton 타입을 지정할 때 사용했다.

Header.TextButton = function HeaderTextButton({
    children,
    ...props
}: ComponentPropsWithoutRef<"button">) {
    return (
        <button css={textButtonStyle} {...props}>
            {children}
        </button>
    );
};

이렇게 하면 button 요소에 사용할 수 있는 기본 props를 사용할 수 있다. 아래 사진처럼 버튼 속성 중 하나인 type을 작성하면 관련 타입이 추론된다.

ButtonHTMLAttributes<HTMLButtonElement> 이렇게 작성하는 것도 봤는데 기능상 차이점은 없다고 한다. ComponentPropsWithoutRef<'button'> 타입이 더 간결한 것 같아서 이걸 사용했다.

HTMLAttributes<HTMLButtonElement>도 있는데 이 타입을 사용하면 button 속성인 type 지정 시 타입 에러가 발생한다.


새로 시도한 것

초반에 제작 방향 공유하기

이번 프로젝트에서는 공통 컴포넌트를 만들면서 초반부터 어떻게 접근할지 팀원들에게 생각을 공유했다. 이전에는 공통 컴포넌트 개발을 완료한 후에야 결과를 공유하곤 했는데, 몇 개월 전 수강했던 강의에서 초반부터 팀원과 의논하는 것을 추천했던 게 기억나 이번에 처음으로 시도해 봤다. 그리고 이 경험으로 초반부터 의견을 나누며 방향성을 설정하는 것의 중요성을 깨달았다.

왜 중요하냐면 내가 생각하는 방향과 팀원이 생각하는 방향이 다를 수 있기 때문이다. 만약 모든 것을 완성한 뒤에야 팀원과 공유했다면? 나와 팀원의 생각이 맞지 않았다면 어떨까? 아마도 처음부터 다시 작업을 하거나, 팀원이 사용할 때 불편함을 느낄 수 있다. 그렇기 때문에 초기부터 아이디어를 공유하고 의견을 나누는 것이 더 효율적인 작업 방식이라고 느꼈다.

Header 컴포넌트 제작을 예로 들어보자면, 처음에는 props를 받아 내부에서 분기 처리를 해도 아주 복잡하지 않다고 생각했다. 하지만 팀원들은 다른 의견을 가지고 있었다. JSDoc을 사용해 컴포넌트 설명을 작성할 때도 비슷한 상황이었다. 일부는 이 방식이 좋다고 생각했지만, 다른 일부는 그렇지 않았다. 모든 사람의 생각이 같을 수 없고, 이러한 차이는 대화를 통해서 파악할 수 있다. 그래서 가능하면 초반부터 대화를 나누며 서로의 의견을 주고받는 것이 중요하다고 생각한다.

Draft PR 사용하기

Draft PR은 코드 초안을 올려 의견을 주고받을 수 있는 PR이다. 일반 PR과는 달리 머지할 수 없으며, 자동으로 검토를 요청하지 않는다. 또한 아이콘을 통해 작업 중임을 나타낼 수 있다. 이 기능을 사용하면 초기 피드백을 받고 수정을 할 수 있으며, 작업이 완료되면 일반 PR로 전환하여 최종 리뷰를 받고 머지할 수 있다.

초기에 구현한 Header 컴포넌트 코드를 공유하고 피드백을 받기 위해 이 방법을 사용했다. 그런데 실제로 사용했을 때 큰 이점을 느끼지 못했다. 어차피 dev 브랜치에는 모든 팀원의 승인이 있어야만 머지할 수 있어서 머지를 막는 기능 자체는 쓸모가 없었다. 다만 현재 작업 중인 상태를 알릴 수 있다는 점은 유용했다.


마무리

이렇게 해서 Header 컴포넌트가 마무리되었다. 만들면서 어떻게 하면 더 편하게 사용할 수 있을지 신경 썼다. 그래서 이름도 평소보다 더 고민하고 HTML 요소를 사용할 때와 동일한 느낌을 줄 수 있게 (나름대로) 의도했다.

그리고 처음으로 컴포넌트 사용 방법에 대한 문서를 남겼는데, 평소 라이브러리 문서를 자주 들여다본 게 도움이 되었다. 이제 문서가 생겼으니 컴포넌트 변경할 때 이 문서도 같이 관리해야겠다..😃

특히 좋았던 건 이전 강의에서 해준 조언을 적용해 볼 수 있었다는 점이다. 그땐 ‘그렇구나…’하고 넘어갔지만 직접 시도해 보니 대화의 중요성을 체감할 수 있었다.

아무튼 공통 Header 컴포넌트를 만드는 여정은 여기서 마무리하고, 사용하면서 개선할 부분은 없는지 고민해 봐야지.