시작하며

해당 글은 똑똑한개발자 github에 있는 next-ts을 기준으로 작성되었습니다.
VSCode 기준으로 설정 되었으며, 다른 IDE를 쓸 경우 ESLint와 Prettier가 다르게 보일 수 있습니다.

공통 사용 스택

- Next.js, PWA, TypeScript, styled-components, Storybook
- axios
  • 현재(글 마지막 업데이트 기준) 프로젝트 패키지 버전
{
  "dependencies": {
    "@material-ui/core": "^4.11.0",
    "@types/react-select": "^3.0.26",
    "@types/react-slick": "^0.23.4",
    "axios": "^0.20.0",
    "mobx": "^6.0.4",
    "mobx-react": "^6.2.2",
    "next": "^10.0.5",
    "next-pwa": "^3.1.4",
    "react": "^16.12.0",
    "react-device-detect": "^1.13.1",
    "react-dom": "^16.12.0",
    "react-flip-toolkit": "^7.0.13",
    "react-select": "^3.1.0",
    "react-slick": "^0.27.13",
    "slick-carousel": "^1.8.1",
    "styled-components": "^5.1.1",
    "styled-theming": "^2.2.0"
  },
  "devDependencies": {
    "@babel/core": "^7.12.3",
    "@babel/plugin-proposal-decorators": "^7.10.1",
    "@storybook/addon-actions": "^6.0.28",
    "@storybook/addon-essentials": "^6.0.28",
    "@storybook/addon-links": "^6.0.28",
    "@storybook/addon-storysource": "^6.1.14",
    "@storybook/addons": "^6.1.10",
    "@storybook/react": "^6.0.28",
    "@storybook/theming": "^6.0.28",
    "@svgr/webpack": "^5.5.0",
    "@types/node": "12.12.21",
    "@types/react": "16.9.16",
    "@types/react-dom": "16.9.4",
    "@types/styled-components": "^5.1.0",
    "awesome-typescript-loader": "^5.2.1",
    "babel-loader": "^8.1.0",
    "babel-plugin-module-resolver": "^4.0.0",
    "babel-plugin-styled-components": "^1.10.7",
    "cross-env": "7.0.2",
    "eslint-config-prettier": "^7.0.0",
    "eslint-plugin-prettier": "^3.2.0",
    "eslint-plugin-react": "^7.21.5",
    "fork-ts-checker-webpack-plugin": "^5.2.1",
    "prettier": "^2.2.1",
    "react-docgen-typescript-loader": "^3.7.2",
    "react-is": "^17.0.1",
    "tsconfig-paths-webpack-plugin": "^3.3.0",
    "typescript": "3.7.3"
  }
}
  • mobx는 원할 경우 v6로 업데이트 가능. 현재 글에서는 v5로 진행

상황별 사용할 라이브러리

  • Admin 페이지 같은 경우, material-ui 적극 활용
  • material-ui/datepicker: 날짜를 달력으로 선택
  • moment: 날짜
    ⇒ dayjs 사용, moment의 경우 라이브러리가 너무 크며 업데이트 중단

    • react-hook-form: form
    • react-use-gesture: 모바일 드래그 모션
  • component는 스토리북을 열어 직접 확인 부탁드립니다. [Storybook 활용법]

마지막 업데이트: 2021.01.25

ESLint, Prettier 설정

VSCode에 ESLint, Prettier Extension이 설치 되어 있어야 합니다.

  • ESLint는 코드의 잠재적 에러나 오류 탐지, Prettier는 코드 포매터에 집중하여 코드를 깔끔하게 만들어주는 역할을 합니다.
  • VSCode 설정 (window: ctrl + , mac: command + ,)에서 Format On Save를 체크하면 저장할 때마다 바뀝니다.

    참고: [VSCode Extension 추천 및 설정]

next-ts 폴더 구조

frontend_structure

  • .next: 프로젝트 build로 생성되는 폴더
  • .storybook: Storybook 설정 폴더
  • api: 공통으로 사용 되는 api (Axios 커스텀)
  • components: 공통으로 사용 되는 컴포넌트 모음
  • containers: 해당 프로젝트에서 사용 되는 화면 구현
  • layout: 레이아웃에서 공통으로 사용 되는 style
  • pages: 파일 이름으로 라우터 생성
  • public: 이미지, 아이콘 등
  • stores: 전역상태관리 폴더
  • utils: 프로젝트마다 공통으로 사용될 만한 hooks, format 등 모음

프론트 개발의 경우, 주로 containers폴더에서 진행되며, 이미지는 public 폴더에 저장합니다.
아래부터는 프론트 개발 흐름에 맞춰 설명을 진행합니다.

Git branch

  • 2명 이상이서 작업할 경우, feature/이름이니셜-작업하는컴포넌트 혹은 페이지 로 생성하여 작업합니다.
git branch -m feature/kmj-modal
git branch -m feature/kmj-main

공통

  • tsx가 없을 경우 .ts 확장자를 사용합니다. (styled, type만 선언하는 코드)
  • 한 파일 당 최대 300줄이 넘지 않게 작성합니다.
  • 이미지는 img 태그가 아니라 div의 background로 주어 깨지지 않도록 합니다.
  • 변수, 함수 명은 카멜 케이스로 작성합니다. (ex. dailyUserTable)

    • 함수 첫 글자는 동사로 시작합니다 (예시)
  • 최대한 누가 봐도 바로 알아볼 수 있는 변수, 함수 명을 짓습니다.

    • Bad

      import UpperContainer from "./UpperContainer";
      import One from "./One";
      
    • Good

      import UserLoginContainer from "./UserLoginContainer";
      import LoginStepOne from "./LoginStepOne";
      
  • 폴더명, 파일명

    • 폴더명
      • 위의 next-ts 의 최상단 폴더와 pages안의 폴더를 제외하고 모두 대문자로 시작합니다.
      • 단, index는 소문자로 시작합니다.
    • 파일명

      • pages 안의 모든 파일은 소문자입니다.
      • 각 폴더의 import 할 메인 파일은 index.tsx로 합니다.
      // container/Main/index.tsx 일 경우
      import Main from "container/Main"; // 자동으로 index.tsx를 불러옴
      
  • 가급적 import * as React 을 줄입니다.
    • 전부다 import 해오기 때문에 크기가 크며, 자칫하면 다른 라이브러리, 모듈과 겹쳐 overriding 이슈가 있을 수 있습니다.
    • 어떤 것을 사용할 건지 알 수 없어 가독성이 떨어집니다. 따라서 정확히 어떤 것을 import 했는지 나열하는 것이 좋습니다.
  • import 순서는 아래와 같으며, 순서 마다 개행(enter)합니다.

    1. 패키지, 라이브러리
    2. layout, api, utils
    3. container, components, 내부 폴더의 컴포넌트
    4. 나머지 (ex. 이미지, store, styled, types)
    import React from "react";
    import Router from "next/router";
    import styled, { css } from "styled-components";
    
    import theme from "layout/theme";
    import * as AuthAPI from "api/Auth";
    import { StorageSetToken } from "utils/Storage";
    
    import MainContainer from "container/Main";
    import Layout from "components/layout";
    import HomeIcon from "./HomeIcon";
    import SearchProducts from "./fragment/SearchProducts";
    
    import CountStore from "stores/Count";
    import { Button, Icon } from "./Social.styled";
    
    • 최대한 같은 카테고리 끼리 묶는 것을 권장하지만, 컴포넌트 구조에 따라 import 순서를 바꾸는 것이 더 알아보기 쉽다면 유동적으로 순서를 바꾸어도 괜찮습니다.
  • 타입 정의

    • 컴포넌트 안에 사용될 props의 타입을 import와 컴포넌트 사이에 정의하고, 컴포넌트 옆에 컴포넌트 타입과 해당 props 타입을 작성합니다. (ex. React.FC<ButtonProps>)

      // components/Button/index.tsx
      export type ButtonProps = {
        outline?: boolean;
        disabled?: boolean;
        round?: boolean;
        color?: string;
        style?: any;
        onClick?: any;
        children?: any;
      };
      
      const ButtonComponent: React.FC<ButtonProps> = ({ children, ...props }) => {
        return <Button {...props}>{children}</Button>;
      };
      
    • 다른 곳에서도 다중으로 사용하는 것이라면, 해당 폴더에 따로 타입 파일을 만드는 것이 좋습니다.

      Button
        ├─ Social/
        ├─ index.stories.tsx
        ├─ index.tsx
        └─ types.ts (type 파일 생성)
      
    • 최대한 any가 없도록 작성합니다.
    • 배열 안에 객체가 있다면 아래와 같은 형식으로 작성합니다.

      type User = {
        id: number;
        name: string;
      };
      
      type Props = {
        items: User[];
      };
      
      const List = ({ items }: Props) => (
        <ul>
          {items.map((item) => (
            <li key={item.id}>
              <ListItem data={item} />
            </li>
          ))}
        </ul>
      );
      
    • styled-components에서 사용 될 type은 export default 컴포넌트 아래 작성합니다.

      style 타입 작성 법에는 두 가지가 있습니다.

      첫 번째:

      // ... 생략
      interface actionProps {
        search?: boolean;
      }
      
      const ActionButton = styled.div<actionProps>`
        cursor: pointer;
        width: 100px;
        height: 40px;
        font-size: 15px;
        display: flex;
        justify-content: center;
        align-items: center;
        border: 1px solid ${(props) => props.theme.color.PRIMARY};
        background-color: ${(props) =>
          props.search ? props.theme.color.PRIMARY : "white"};
        color: ${(props) => (props.search ? "white" : props.theme.color.PRIMARY)};
        &:first-child {
          margin-right: 10px;
        }
      `;
      

      두 번째:

      // ...생략
      type styleProps = {
        active?: boolean;
        noMobile?: boolean;
        centered?: boolean;
      };
      
      const TabsBox = styled.div`
        z-index: 999;
        border-bottom: 1px solid ${theme.color.GRAY1};
        /* Mobile */
        ${theme.window.mobile} {
          > div {
            width: 100%;
            padding: 0px;
          }
          ${(props: styleProps) =>
            props.noMobile &&
            css`
              display: none;
            `}
        }
      `;
      
      • 두 번째 방법의 경우, 사용하려는 곳에서만 styleProps를 넘겨주면 되지만, 첫 번째 처럼 전역의 props를 같이 사용할 경우 타입 에러가 뜹니다. 상황에 맞게 사용하면 됩니다.
  • type vs. interface

    • 가장 큰 차이점은 확장 여부입니다. interface는 확장이 가능하지만, type은 불가능합니다.

      // components/Icon/types.ts
      export interface SvgProps {
        color?: string;
        style?: any;
        className?: any;
      }
      
      export interface SvgWithFillProps extends SvgProps {
        fill?: boolean;
      }
      
      // 예시입니다
      interface Person {
        name: string;
        age: number;
      }
      
      interface Developer extends Person {
        language: string;
      }
      
      const joo: Developer = { name: "joo", age: 20, language: "ts" };
      
    • 따라서 type 보다는 interface 사용을 권장합니다.

page

pages/index.tsx

import React from "react";

import MainContainer from "containers/Main";
import Layout from "components/Layout";

const IndexPage = () => {
  return (
    <Layout title="똑똑한개발자">
      <MainContainer />
    </Layout>
  );
};

export default IndexPage;
  • pages 안에는 어떠한 로직도 넣지 않으며, 모두 containers에 작성합니다.
  • Layout은 사용하는 기기에 따라 유동적으로 height를 바꾸기 위해 사용합니다.
    • 페이지에 중복되어 사용 되는 컴포넌트(ex. Nav, Footer)도 포함되어 있습니다.
    • components 활용법은 스토리북 글에서 작성할 예정입니다.

container

  • page 하나 당 container 안에 하나의 폴더가 생성됩니다.
  • 내부 폴더 관리

    • 한 폴더에 세 개 이상의 컴포넌트가 생긴다면, 그 안에 _fragments폴더를 생성하여 관리합니다.

    • 만약 fragments 안의 컴포넌트에서 또 다른 파일이 2개 이상 생긴다면, 따로 폴더를 생성합니다.

(예시로 만든 것이며, 해당 repo에는 없습니다.)

container
  └─ Main
      ├─ _fragments (하나 혹은 두개만)
      │		├─ Banner (폴더 생성)
      │		│   ├─ index.tsx
      │		│   ├─ BannerAction.tsx
      │		│   └─ BannerSlide.tsx
      │		├─ Menu.tsx
      │		└─ Title.tsx
      ├─ index.tsx
      └─ types.ts
  • 만약, Main에 event라는 하위 페이지가 필요하다면 아래와 같이 폴더를 생성하여 작업합니다. (url은 /main/event)
(예시로 만든 것이며, 해당 repo에는 없습니다.)

container
  └─ Main
      ├─ _fragments (하나 혹은 두개만)
      │		├─ Banner (폴더 생성)
      │		│   ├─ index.tsx
      │		│   ├─ BannerAction.tsx
      │		│   └─ BannerSlide.tsx
      │		├─ Menu.tsx
      │		└─ Title.tsx
      ├─ Event (페이지는 대문자로 시작)
      │	   ├─ index.tsx
      │    ├─ EventBanner.tsx
      │ 	 └─ EventContents.tsx
      ├─ index.tsx
      └─ types.ts
  • 함수 내 선언 순서 (ex. useState, useEffect, 함수 등)

    • 내부 import > 외부 import > 내부 함수
    • 내부 import는 보편적으로 store를 의미합니다.
    • 외부에서 import 해온 것을 먼저 사용합니다. (ex. useState, useEffect, useRef 등)
    • 그 후, 내부에서 선언한 함수를 차례로 작성합니다.

      // 예시이며, import 구문은 생략합니다.
      const TestComponents: React.FC = () => {
        const countStore = useContext(CountStore);
        const [number, setNumber] = useState<number>(0);
        const divRef = useRef<any>(null);
        const handleClick = (e: any) => {
          console.log(e);
        };
        // ...이하 생략
      };
      
  • 중복되는 코드는 최대한 줄입니다.

    • 묶을 수 있다면 최대한 묶습니다.
    • Bad
  // container/Main/fragment/Menu
  {
    <>
      <Item>
        <div>
          <ColorIcon name="search" />
        </div>
        <T.Text>AI 스마트검색</T.Text>
      </Item>
      <Item>
        <div>
          <ColorIcon name="fix" />
        </div>
        <T.Text>시공업체</T.Text>
      </Item>
      <Item>
        <div>
          <ColorIcon name="ai" />
        </div>
        <T.Text>AI 가상시공</T.Text>
      </Item>
      <Item>
        <div>
          <ColorIcon name="add" />
        </div>
        <T.Text>업체등록</T.Text>
      </Item>
    </>
  }
```

  - Good

    ```tsx
    const itemsData = [
      {
        id: 1,
        name: "search",
        text: "AI 스마트검색",
      },
      {
        id: 2,
        name: "fix",
        text: "시공업체",
      },
      {
        id: 3,
        name: "ai",
        text: "AI 가상시공",
      },
      {
        id: 4,
        name: "add",
        text: "업체등록",
      },
    ];

    // ...생략
    {
      itemsData.map((item) => (
        <Item key={item.id}>
          <div>
            <ColorIcon name={item.name} />
          </div>
          <T.Text>{item.text}</T.Text>
        </Item>
      ));
    }
    ```

### api

- 각 페이지에서 사용하는 api들을 하나의 파일로 만들어 사용합니다.
- 예시

Api ├─ API ├─ Auth ├─ Payment.ts └─ Product.ts


Payment.ts

```tsx
import API from "./API";

export const postCreate = () => {
  return API.post(`/api/v1/order/`);
};

export const postConfirm = (req: any) => {
  return API.post(`/api/v1/order/confirm/`, req);
};

export const putLaterCrate = (req: any) => {
  return API.put(`/api/v1/order/payment/${req.payment}/`, req);
};

사용하려는 컴포넌트에서 import 하여 사용합니다.

import * as PaymentAPI from 'api/Payment'

// ...

PaymentAPI.postCreate().then( res =>
 // ...
)

store

- 해당 글은 똑똑한개발자의 mobx 컨벤션을 기점으로 작성되었으며 언제든 바뀔 수 있습니다.
- 2021.01에 mobx v6로 변경되었습니다.

stores/Count.ts

import { makeAutoObservable } from "mobx";

class Count {
  number: number = 0;

  constructor() {
    makeAutoObservable(this);
  }
  increase = () => {
    this.number++;
  };
  decrease = () => {
    this.number--;
  };
}

const countStore = new Count();
export default countStore;

pages/count.tsx

import React, { ReactNode, useContext } from "react";
import Link from "next/link";
import { observer } from "mobx-react"; // state를 실시간으로 감지

import Layout from "components/Layout";

import countStore from "stores/Count";

type Props = {
  children?: ReactNode;
};

// 함수 전체를 observer로 감싸줌
const AboutPage: React.FC<Props> = observer(() => {
  return (
    <Layout title="About | Next.js + TypeScript Example">
      <div style=>
        <h1>Count</h1>
        <p>This is the count page</p>
        {countStore.number} {/* 전역의 number state 가져오기 */}
        <button onClick={countStore.increase}>+</button>
        <button onClick={countStore.decrease}>-</button>
        <p>
          <Link href="/">
            <a>Go home</a>
          </Link>
        </p>
      </div>
    </Layout>
  );
});

export default AboutPage;
  • 각 기능이 생길 때마다 새로운 Store를 생성하고, 필요한 컴포넌트 안에서 import 해서 사용합니다.
  • createContext 를 사용해야 하는 경우

    • contextAPI는 context 안의 값이 변경될 때마다 컴포넌트는 useContext를 부릅니다.

      ⇒ 이는 mobx의 observable, observer와 중복되는 기능이라 사용하지 않아도 됩니다.

    • context는 주로 props drilling을 피하기 위해 사용되며, 최상위 컴포넌트에서 최하위 컴포넌트까지 store에 있는 state를 props로 넘겨줄 때 사용합니다.

      const TimerView = observer(() => {
        const timer = useContext(TimerContext);
        return <span>Seconds passed: {timer.secondsPassed}</span>;
      });
      
      ReactDOM.render(
        <TimerContext.Provider value={new Timer()}>
          <TimerView />
        </TimerContext.Provider>,
        document.body
      );
      
      • 위의 count 예제를 createContext 할 경우

        sotres/Count.ts

        import { createContext } from "react";
        import { observable, action } from "mobx";
        
        class Count {
          // 전역에서 관리할 state를 type과 함께 지정
          @observable number: number = 0;
        
          // increase 됐을 때
          @action increase = () => {
            this.number++;
          };
          // decrease 됐을 때
          @action decrease = () => {
            this.number--;
          };
        }
        
        const countStore = new Count();
        export default createContext(countStore);
        

        page/count.tsx

        import React, { ReactNode, useContext } from "react";
        import Link from "next/link";
        
        import Layout from "components/Layout";
        
        import { observer } from "mobx-react-lite";
        import CountStore from "stores/Count";
        
        type Props = {
          children?: ReactNode;
        };
        
        const AboutPage: React.FC<Props> = observer(() => {
          const countStore = useContext(CountStore);
        
          return (
            <Layout title="About | Next.js + TypeScript Example">
              <h1>Count</h1>
              <p>This is the count page</p>
              {countStore.number}
              <button onClick={countStore.increase}>+</button>
              <button onClick={countStore.decrease}>-</button>
              <p>
                <Link href="/">
                  <a>Go home</a>
                </Link>
              </p>
            </Layout>
          );
        });
        
        export default AboutPage;
        
  • mobx-react, mobx-react-lite 차이

    • mobx-react v6부터 mobx-react-lite를 사용하지 않고도 hooks를 지원합니다
    • mobx-react는 함수형, 클래스형을, mobx-react-lite는 함수형만 지원
    // 함수형 예시
    import { observable } from "mobx";
    
    export interface TodoData {
      id: number;
      content: string;
      checked: boolean;
    }
    
    interface Todo {
      todoData: TodoData[];
      currentId: number;
      addTodo: (content: string) => void;
      removeTodo: (id: number) => void;
    }
    
    export const todo = observable<Todo>({
      todoData: [],
      currentId: 0,
    
      addTodo(content) {
        this.todoData.push({ id: this.currentId, content, checked: false });
        this.currentId++;
      },
      removeTodo(id) {
        const index = this.todoData.findIndex((v) => v.id === id);
        if (id !== -1) {
          this.todoData.splice(index, 1);
        }
      },
    });
    

components

  • components는 어느 프로젝트에서든 재사용 할 수 있는 것을 만들어 두는 곳입니다.
  • 기존에 만들어 둔 것은 npm run storybook or yarn storybook을 하면 확인할 수 있습니다. (추후 components 활용 방법도 스토리북 Docs에 업데이트 예정)
universal_link
  • components/Button을 예로 들겠습니다.

    • Button의 기반이 되는 것은 index.tsx이며 이를 스토리북 서버에 띄울 수 있는 것이 index.stories.tsx입니다. stories는 모두 index.tsx import하여 만들어 졌습니다.

프로젝트 초기 세팅

  • 디자인, 기획, 개발까지 똑똑한개발자에서 하는 프로젝트라면 내부 디자인 시스템을 사용합니다.
  • 위에 해당하는 경우, 디자이너님이 공유해주신 제플린을 확인하고 공통으로 사용되는 컴포넌트를 찾은 후, color를 설정 해주는 것이 좋습니다.
universal_link
  • 제플린에서 상단 메뉴에 ‘Styleguide’ 탭을 클릭하면 재사용되는 색상을 저장했습니다.
  • 이를 layout/theme에서 수정하면 됩니다.
  • 다크 모드의 경우 해당 프로젝트를 담당하는 디자이너와 상의하여 추가합니다. (제플린에 바로 올릴 경우, 이름이 중복되어 덮어씌워지는 에러가 있다고 합니다.)
universal_link
  • 다크모드는 라이트모드와 1:1 매칭이 되어야 정상 작동합니다.
minjung.kim's profile image

minjung.kim

2020-08-30 17:45

Read more posts by this author