본문으로 바로가기

이번 글에서는 프로젝트에서 사용하고 있는 컴포넌트를 만드는 방법에 대해 이야기 해 보겠다. 이전 글에서도 설명하였듯 현재 진행중인 프로젝트에는 아토믹 컴포넌트을 적용하였다. 아토믹 컴포넌트는 단순히 디자인 패턴이기 때문에 컴포넌트 코드를 작성하는 방법까지 설명해주고 있지는 않다. 그래서 이번 글에서는 내가 아토믹 컴포넌트를 어떤 방식으로 구현했는지에 대해서 알아보고자 한다.

폴더 구조

프로젝트에서 사용중인 아토믹 컴포넌트는 세가지 계층으로 간략화 되어 있다. Atom, Molecule, Organism 순으로 컴포넌트를 분리하여 관리하고 있으며 각 계층마다 역할에 따른 컴포넌트를 저장하여 사용하고 있다. 만약 Gnb 컴포넌트를 만든다고 가정했을때

  • atoms
    • button
      • Button.tsx
      • ButtonStyles.ts
      • Button.stories.tsx
  • molecules
    • gnb
      • gnbMenu
        • GnbMenu.tsx
        • GnbMenuStyles.ts
        • GnbMenu.stories.tsx
  • organisms
    • gnb
      • Gnb.tsx
      • GnbStyles.ts
      • Gnb.stories.tsx

와 같은 방식으로 구현하게 될 것이다. 각 파일에 대한 설명은 뒤에서 하고, 먼저 폴더 명부터 보도록 하겠다.

 

컴포넌트를 사용할때는 JSX.Element를 불러와 사용하기 때문에 파일명은 대문자로 사용한다. 폴더명의 경우 카멜 케이스로 표현했는데, 컴포넌트 폴더와 컴포넌트 파일간에 표현 방식에서 차별성을 주어서 가독성을 높혔다.  만약 폴더 안에 index.tsx의 파일이 들어가서 import 위치를 폴더로 정해야 할 때에는 첫번째 문자도 대문자로 사용하는것이 좋을 것이다.

 

molecules의 GnbMenu를 보면 상위 폴더 명이 gnb, gnbMenu인데도 불구하고 파일명까지 GnbMenu인 것을 볼 수 있다. 왜 gnb/menu/Menu.tsx와 같이 작성하지 않고, 이런 방식을 사용했을까?

 

여기에는 두가지 이유가 있다. 첫번째 이유는 컴포넌트를 불러와 사용할때는 컴포넌트 변수의 이름으로 사용한다는 것 이다. 좀더 쉬운 설명을 위해 예제 코드를 보도록 하겠다.

import GnbMenu from "@/components/molecules/gnb/gnbMenu/GnbMenu";
import { Menu } from "./GnbStyles";

const Gnb = ({ ...rest }: Props) => {
    ...

  return (
    <GnbMenuWrap>
      {menuList.map((item, x) => (
        <Link key={x + "key"} href={item.href}>
          <GnbMenu
            icon={item.icon}
            content={item.content}
            active={item.active}
          />
        </Link>
      ))}
      <Menu/>
    </GnbMenuWrap>
  );
};
export default Gnb;

만약 컴포넌트 이름이 GnbMenu가 아니라 Menu일 경우 본 컴포넌트에서 오리지널 네이밍을 사용할 수 없게 되는 문제가 생긴다. 쉽게 말해서 Menu라는 이름의 엘리먼트를 사용하고 싶은데 외부 컴포넌트에서 이미 같은 이름의 컴포넌트를 불러와서 사용중이기 때문에 네이밍 중복이 발생하고, MenuStyled와 같이 이름을 바꾸어야 하는 상황이 발생한다. 이런 규칙을 정한 이유로는 div 엘리먼트가 div만 쓰면 되고, divStyled와 같은 이름이 아니기 때문이다. 코드 가독성 면에서도 Menu가 MenuStyled보다 더 직관적이고 말이다.

 

뿐만 아니라 컴포넌트 명은 해당 컴포넌트의 정보를 가장 직관적으로 표현하는 단위인데, 이것을 Menu등으로 두루뭉실하게 표현했을 경우 한번에 어떤 컴포넌트인지 알아보기 어렵다는 문제가 있다. 그에비해 GnbMenu라는 이름은 Gnb라는 Organism에 종속된 하위 컴포넌트(Molecule)라는 것을 나타낸다.

 

두번째 이유는 컴포넌트의 소속을 명확히 하기 위해서다. GnbMenu 컴포넌트는 molecules/gnb/gnbMenu/GnbMenu에 위치하고 있다. 이처럼 상위 폴더 명만 보더라도 gnb에 소속된 컴포넌트인것을 알 수 있게 된다.

 

대부분은 아토믹 패턴에 대해 컴포넌트를 모두 재사용할 수 있는 디자인 패턴 정도로만 인식하고 있다. 만약 모든 컴포넌트를 재사용하고자 하면 모든 계층별 컴포넌트를 share폴더에 담듯이 소속없이 관리해야 하는데, 실제로 개발을 하는 입장에서 이는 굉장히 까다로운 일이다. 위에서 말했던 오리지널 네이밍 이슈 뿐만 아니라, 한곳에서만 사용하는것이 명확한 컴포넌트임에도 불구하고 범용성을 적용해야 하다 보니 추가로 개발을 더 해야 하지만 실제로 사용하는 곳은 한군데 밖에 없는 상황이 발생하게 된다.

 

다른 개발 블로그에서는 해당 이슈를 해결하기 위해서 종속 컴포넌트 폴더와 share 컴포넌트 폴더를 나누어 관리하던데, 이럴 경우 그냥 기존의 컴포넌트 폴더 구조에서 계층을 나눈것에 불과하다. 아토믹 패턴에 충실하지도 못하고, 그 이점을 살리고 있지도 못하고 있는 것이다. 그에 비해 atom에 속하는 컴포넌트만 완전한 범용성을 고려하여 개발했을 경우, 최소 단위가 생기므로 계층의 구분이 더욱 뚜렷해지고 molecule에 컴포넌트를 작성할때 organism의 소속을 표기하기 때문에 더욱 확실하게 컴포넌트를 구분할 수 있게 된다. atom은 최소 단위이고 범용성이 적용된 컴포넌트, molecule는 atom을 조합하여 구성하며 organism에 종속된 컴포넌트, organism은 context가 사용된 컴포넌트로 구분할 수 있겠다.

컴포넌트 파일

아토믹 패턴에 따라 컴포넌트를 만들때, 총 3개의 파일을 작성하고 있다. 만약 RoundButton라는 이름의 컴포넌트를 생성한다고 했을때, RoundButton.tsx, RoundButtonStyles.ts, RoundButton.stories.tsx 의 3개 파일을 생성하여 컴포넌트를 구성한다. 테스트를 적용하게 된다면 여기에 테스트 슈트 파일이 추가가 되어야 하겠지만, 아직은 구현하지 않았기 때문에 컴포넌트 구성 파일에서 제외하였다. 먼저 RoundButton.tsx부터 보도록 하자.

RoundButton.tsx

// atoms/roundButton/RoundButton.tsx
import React from "react";
import { RoundButtonStyled } from "./RoundButtonStyles";

export interface Props {}

const RoundButton = ({ ...rest }: Props) => {
  return <RoundButtonStyled {...rest}></RoundButtonStyled>;
};
export default RoundButton;

export const defaultProps: Props = {};
RoundButton.defaultProps = {};

실제로 컴포넌트를 불러올때 사용되는 tsx파일이다. 현재 프로젝트에서는 styled-component을 사용하여 스타일링을 구현하고 있다. RoundStyles에서 엘리먼트를 가져온다는 것 빼고는 일반적인 리액트 컴포넌트와 비교해서 크게 다른점이 없다고 할 수 있다.

RoundButtonStyles.ts

// atoms/roundButton/RoundButtonStyles.ts
import styled, { css } from "styled-components";
import { Props } from "./RoundButton";

export const RoundButtonStyled = styled.div<Props>``;

이 파일에서는 styled-component를 사용해서 스타일링이 적용된 엘리먼트를 선언해주는 파일이다. 여기서 유심히 보아야 할 부분은 엘리먼트 명칭의 뒤에 붙는 단어이다. 필자는 styled-component를 사용하면서 접두사에 따라 엘리먼트의 역할을 구분해주고 있다.

  • styles : styled-componenet 파일명
  • styled : style이 적용된 엘리먼트
  • wrap : 컴포넌트를 감싸거나 배치하는 엘리먼트

이처럼 엘리먼트마다 명칭을 붙혀줌으로써 역할을 확실하게 분리해주는 방식을 사용하고 있다. 물론 LogoImageAlertIcon과 같이 고유의 이름을 가지고 있는 엘리먼트는 그대로 사용한다.

RoundButton.stories.tsx

// atoms/roundButton/RoundButton.stories.tsx
import React from "react";
import { ComponentStory, ComponentMeta } from "@storybook/react";
import RoundButton, { defaultProps } from "./RoundButton"; 👈 3번 줄

export default {
  title: "Atoms/RoundButton",
  component: RoundButton,
} as unknown as ComponentMeta<typeof RoundButton>;

const Template: ComponentStory<typeof RoundButton> = (args) => <RoundButton {...args}></RoundButton>;

export const Default = Template.bind({});
Default.args = {};

storybook 파일은 프로젝트의 환경마다 판이하게 다르기 때문에 필자가 사용하는 파일이 모든 프로젝트에 적용될 수 있는 형식은 아니다. 이 또한 구글링을 통해 가장 적합한 코드로 작성한것이기 때문이다.

 

3번째 줄을 보면 RoundButton.tsx에서 defaultProps을 불러와주고 있는 부분이 있다. 이는 다음과 같이 사용될 수 있다.

// atoms/roundButton/RoundButton.tsx
...
export const defaultProps: Props = {
  chlidren: "Submit"
};
RoundButton.defaultProps = {};
...

// atoms/roundButton/RoundButton.stories.tsx
import RoundButton, { defaultProps } from "./RoundButton";
...
const Template: ComponentStory<typeof RoundButton> = (args) => <RoundButton {...args}></RoundButton>;

export const Default = Template.bind({});
Default.args = {
  chldren: defaultProps.children,
};

이와 같이 구성하면 컴포넌트와 스토리북 파일 간에 괴리를 줄일 수 있다. 기본값으로 컴포넌트에서 선언해둔 prop을 가지고 다시 사용하는것이기 때문에 코드를 두번 작성하거나, 스토리북에서 보았던 컴포넌트와 실제로 dev환경에서 보는 컴포넌트가 다르게 나오는 것을 피할 수 있다.

컴포넌트 빨리 만들기

프론트엔드 개발자는 다른것도 중요하지만, 시안대로 나온 디자인을 빠르게 쳐내는 것도 중요하다. 이러한 컴포넌트 양식이 이미 있기 때문에 매번 코드를 작성하는 것은 비효율적이라고 할 수 있겠다. 일종의 보일러 플레이트와 같은 것이기 때문이다.

 

redux에서는 redux-toolkit이라는 라이브러리를 사용해서 보일러 플레이트를 줄여주고 있다. 아마 비슷한 기능이 angular에서도 있던것으로 기억한다. ng 명령어로 컴포넌트를 생성할 수 있었다. 하지만 필자가 작성하는 컴포넌트는 redux-toolkit처럼 다양한 기능이 필요하지 않기 때문에 vscode에서 지원하는 기능인 코드 조각 모음으로 개발 편의성을 개선할 수 있었다.

{
  "generate react.tsx": {
    "prefix": "rtsx",
    "body": [
      "import React from \"react\";",
      "import { $1Styled } from \"./$1Styles\";",
      "",
      "export interface Props {}",
      "",
      "const $1 = ({ ...rest }: Props) => {",
      "  return <$1Styled {...rest}></$1Styled>;",
      "};",
      "export default $1;",
      "",
      "export const defaultProps: Props = {};",
      "$1.defaultProps = {};",
      ""
    ],
    "description": "react.tsx를 생성합니다."
  },
  "generate reactStyles.ts": {
    "prefix": "rstyles",
    "body": [
      "import styled, { css } from \"styled-components\";",
      "import { Props } from \"./$1\";",
      "",
      "export const $1Styled = styled.div<Props>``;",
      ""
    ],
    "description": "reactStyles.ts를 생성합니다."
  },
  "generate react.stories.tsx": {
    "prefix": "rstories",
    "body": [
      "import React from \"react\";",
      "import { ComponentStory, ComponentMeta } from \"@storybook/react\";",
      "import $1, { defaultProps } from \"./$1\";",
      "",
      "export default {",
      "  title: \"$2/$1\",",
      "  component: $1,",
      "} as unknown as ComponentMeta<typeof $1>;",
      "",
      "const Template: ComponentStory<typeof $1> = (args) => <$1 {...args}></$1>;",
      "",
      "export const Default = Template.bind({});",
      "Default.args = {};",
      ""
    ],
    "description": "react.stories.tsx를 생성합니다."
  },
  "styled-component const syntax": {
    "prefix": "stcConst",
    "body": ["export const $1 = styled.div<Props>``;"],
    "description": "styled-component const 문법을 생성합니다."
  },
  "styled-component import next image": {
    "prefix": "impNextImg",
    "body": ["import Image from \"next/image\";"],
    "description": "next/image를 import합니다."
  },
  "import mui icon": {
    "prefix": "impMuiIon",
    "body": ["import {$1} from \"@material-ui/icons\";"],
    "description": "material ui icon을 import합니다."
  }
}

위의 코드는 코드 조각 모음을 사용해서 컴포넌트를 손쉽게 생성할 수 있는 도구이다. 프로젝트의 루트 폴더에 .vscode라는 폴더를 생성하고, 그 안에 [원하는 이름].code-snippets이라는 파일명으로 해당 코드를 저장하기만 하면 된다.

 

컴포넌트를 생성하고자 할때 먼저 폴더부터 생성한 후, rtsx, rstyles, rstories만 치더라도 자동완성으로 컴포넌트 탬플릿 코드를 작성할 수 있게 된다. 코드를 보면 $1이라고 쓰인 부분이 있는데, 명령어를 실행한 후 마우스 포인터가 생기는 지점이다. 여기에 컴포넌트 파일로 사용할 이름, 예를 들어 RoundButton등의 이름을 붙혀 넣기만 하면 완성이다.

 

rstories는 스토리북 파일을 생성하는 명령어이다. 여기에는 예외적으로 $1뿐만 아니라 $2도 작성되어 있는데, 첫번째로 파일명을 붙혀 넣은 후 Tab키를 눌르면 $2가 있는 위치로 포인터가 이동해 곧바로 스토리북 경로를 작성할 수 있게 된다.

 

이외에도 styled-component의 엘리먼트를 생성하는 명령어, next/image, materila ui icon를 사용하는 명령어가 포함되어 있다. 도움이 되었으면 해서 해당 파일을 공유하고자 한다.

마치며

필자가 사용한 컴포넌트 방법론이 정답은 아닐 것이다. 실무에서 사용되는 코드는 또 다를 것이고, 아키텍쳐에 따라 상이할것이라 생각한다. 테스트가 적용되어야 한다면 또 달라야 할지도 모른다. 그럼에도 아토믹 컴포넌트를 적용하여 프로젝트를 개발하였고, 그 과정에서 내 방식대로 컴포넌트를 작성해 보았다. 지금까지 애기한 프로젝트의 깃허브 링크는 하단에 기재하도록 하겠다.

 

https://github.com/demain18/linkedin-clone

'개발 > 프론트엔드' 카테고리의 다른 글

리액트의 라이프 사이클  (0) 2023.03.14
CORS에 대하여  (0) 2023.03.09
웹팩(Webpack)이란?  (0) 2023.01.11
화살표 함수 사용하기  (0) 2022.12.30
아토믹 컴포넌트를 적용하고 느낀 것들  (0) 2022.12.11