우리 팀을 위한 ESLint, Prettier 공유 컨피그 만들어보기

Apr.24.2024 김하림

Web Frontend

최종 코드 예제는 Github 예제 프로젝트에서 확인할 수 있습니다.

서론

ESLint와 Prettier는 JavaScript나 TypeScript의 코드 품질을 높이고 일관된 형식을 유지하는 데 자주 사용하는 도구입니다. ESLint를 사용하면 잠재적인 문제를 빠르게 확인할 수 있고, Prettier를 사용하면 코드 서식에 신경쓰지 않고 코드 작성에만 집중할 수 있어 편리합니다.

하지만, 매번 프로젝트를 생성할 때마다 ESLint/Prettier 등을 설정하는 작업은 꽤 번거롭습니다. 컨피그 파일을 만들고, 플러그인을 설치하고, 추천 규칙을 적용하는 작업이 반복되며, 아예 다른 저장소 설정을 그대로 가져와서 쓰기도 합니다.

eslint-config-airbnb 같이 오래되고 유명한 라이브러리로 규칙을 통일하는 방법도 있지만, 사용 해보니 필요 이상으로 엄격한 규칙이 작업 흐름을 방해하고 생산성을 저하 시킨다고 느꼈습니다. ESLint가 고치라고 해서 고치긴 했는데, 이걸 왜 고쳐야 하지?라는 생각도 들었습니다. 또한, 저장소마다 다른 컨피그 설정 때문에 혼란을 겪기도 했습니다.

ESLint의 억압에서 벗어나려는 개발자의 흔적

코어웹프론트개발팀은 이런 문제점을 근본적으로 해결하기 위해, 우리에게 필요한 규칙들만 모아놓은 공유 컨피그 패키지를 도입하게 되었습니다. 그 결과, 각 저장소에서 일관된 개발 경험을 제공하게 되었고, 이는 개발자들의 생산성 향상에 큰 도움을 주었습니다.

공유 컨피그를 만들면서 팀원끼리 자연스럽게 규칙을 논의하기도 했습니다. 논의 과정에서 의견이 갈리는 규칙들에 대한 조율이 필요했는데, 이를 통해 코드 리뷰에서의 불필요한 논쟁을 줄이는 효과를 얻기도 했습니다.

패키지를 구현하는 것은 생각보다 어렵지 않았지만, 더 어려웠던 건 바로 팀원 간의 합의였습니다. 개발자마다 선호하는 규칙과 생각이 다르다 보니 의견을 조율하는 과정에 꽤 시간이 걸렸습니다. 긴 논의가 필요한 경우에는 Slack 투표나, 프론트엔드 개발자들이 모여있는 채널에서 의견을 모아 결정하기도 했습니다.

이 글에서는 eslint-config-airbnb 패키지의 대안으로 @rushstack/eslint-config를 선택하게 된 과정을 공유하려고 합니다. 또한, 공유 ESLint 컨피그 패키지를 만드는 과정과 컨피그에 대한 설명, 그리고 추천할 만한 규칙 몇 가지에 대해서도 다루어보겠습니다.

Airbnb 규칙의 대안

Airbnb의 eslint-config-airbnb 패키지는 많은 프론트엔드 개발자가 사용하는 대표적인 코딩 스타일 가이드입니다. 다양한 JavaScript와 React 관련 규칙들을 포함하고 있는 Airbnb 규칙은 2015년 5월에 오픈소스로 처음 공개되어 현재까지도 많은 프로젝트에서 사용되고 있습니다.

Airbnb 규칙을 사용하면 ESLint 설정 시간을 줄여준다는 장점이 있지만, Airbnb 조직이 정한 코딩 스타일 가이드를 따라야하는 단점이 있습니다. 코드의 세세한 부분까지 규칙이 적용되어있기 때문에, 개발 과정에서 ESLint 경고를 제거하기 위한 사투가 펼쳐지곤 합니다. Airbnb 규칙을 사용하면서 불편함을 느꼈던 사례는 글 마지막의 부록에 적어두었습니다.

Airbnb 규칙이 생산성을 떨어뜨린다고 느꼈던 저는, 팀에 우리만의 ESLint 컨피그를 만들자는 의견을 제안했습니다. 처음엔 백지부터 시작해서 필요할 때마다 규칙을 합의해서 만들어나가자는 의견도 제안했었지만, 베이스 컨피그가 있었으면 좋겠다는 팀의 의견을 수용해서 대체 패키지를 조사하게 되었습니다.
제가 찾은 Airbnb 컨피그의 대안은 Microsoft에서 관리하는 @rushstack/eslint-config 패키지입니다. rushstack은 모노레포 관리 도구이지만, 범용적으로 사용할 수 있는 ESLint 공유 컨피그 패키지도 제공합니다. 이 패키지를 대안으로 선택한 이유는 다음과 같습니다.

  • Microsoft가 관리함
  • 최근에 만들어짐 (2021년 12월 첫 커밋)
  • 독단적이지 않은 규칙
  • 주석에 각 규칙에 대한 명확한 근거가 명시되어있음 (예시)
  • 높은 코드 퀄리티와 상세한 문서

Github에서 실제 코드와 문서를 살펴본 결과, 품질이 상당히 좋다고 느꼈습니다. Prettier와의 충돌 방지를 고려한 부분이나, 우선순위 이슈를 고려해 recommended 템플릿 없이 컨피그를 구성한 부분, 각 규칙에 대해 명확한 근거를 제시한 점 등 디테일한 부분이 마음에 들었습니다. 따라서 이 패키지를 기본 베이스 컨피그로 잡고, 필요한 규칙과 플러그인들을 추가하기로 결정했습니다. 공유 컨피그 패키지를 팀 내에서 공통으로 사용하기 위해 팀원 분들을 설득하기 위한 발표 자료를 만들었고, 결과적으로 팀원 분들의 공감을 이끌어내는 데 성공하여 본격적으로 패키지 개발에 착수하게 됩니다.

공유 컨피그를 위한 모노레포 구성하기

공유 컨피그 패키지를 관리할 저장소를 하나 만듭니다. ESLint와 Prettier 공유 컨피그를 각각의 npm 패키지로 배포하기 위해 모노레포를 구성했습니다. 모노레포를 구성한 이유는 여러 개의 관련 패키지를 하나의 저장소에서 관리하여 개발의 효율성을 높이려는 목적입니다. 모노레포를 사용하면 공유 컨피그 패키지 간의 의존성 관리가 쉬워지며, 변경이 필요할 때 한 곳에서 테스트가 가능한 장점이 있습니다.

코어웹프론트개발팀은 모든 프로젝트와 저장소에서 일관된 패키지 매니저 사용을 위해 팀 내 합의를 통해 pnpm을 선택하게 되었으며, 이에 따라 pnpm과 pnpm workspace를 활용하여 모노레포 환경을 구성하게 되었습니다.

저장소 구조를 간단하게 도식화해보았습니다.

📁 example // 로컬 환경 및 CI에서 컨피그를 테스트하기 위한 Vite + React 프로젝트
📄 package.json
📁 packages
  📁 eslint-config
    📁 mixins
      📄 react.js // React를 사용하는 프로젝트를 위한 컨피그 파일
    📄 index.js // 공통 컨피그 파일
    📄 package.json
  📁 prettier-config
    📄 index.js
    📄 package.json
📄 pnpm-workspace.yaml

루트의 package.json은 다음과 같습니다.

{
  "name": "shared-config-example",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "example": "pnpm --filter example"
  },
  "devDependencies": {
    "prettier": "^3.2.5"
  },
  "pnpm": {
    "overrides": {
      "eslint": "8.57.0",
      "@typescript-eslint/eslint-plugin": "7.5.0",
      "@typescript-eslint/parser": "7.5.0"
    }
  }
}

scripts에는 예제 앱을 빠르게 실행하기 위한 example 명령어를 넣어두었습니다. 만약 예제 앱의 린트를 실행해보고 싶다면 pnpm --filter example lint 대신에 pnpm example lint 로 간단하게 린트를 실행할 수 있습니다. 다만, 후술할 내용에서는 이해를 돕기 위해 --filter 옵션을 포함한 예시를 제공할 예정입니다.

pnpm.overrides 필드는 의존성 버전을 재정의합니다. 구성 과정에서 TypeScript 버전 경고가 발생하여 넣었으며, 버전 경고가 발생하지 않는다면 생략해도 무방합니다.

pnpm-workspace.yaml 파일은 모노레포 내에서 관리되는 패키지들의 위치를 pnpm에게 알려주는 역할을 합니다. 이는 컨피그 테스트를 위한 example 디렉터리와 packages/* 아래의 모든 패키지가 모노레포에 포함되는 것을 pnpm에게 알려주는 작업입니다. pnpm-workspace.yaml 파일은 아래와 같이 작성했습니다.

packages:  
  - 'example'  
  - 'packages/*'

다음으로 ESLint 공유 컨피그 패키지를 구성해보도록 하겠습니다.

ESLint 공유 컨피그 패키지 만들기

package.json 만들기

먼저 packages/eslint-config 하위에 package.json을 만듭니다.

// packages/eslint-config/package.json
{
  "name": "@org/eslint-config",
  "main": "index.js",
  "version": "1.0.0",
  "dependencies": {
    "@rushstack/eslint-config": "3.6.8",
    "@rushstack/eslint-patch": "1.10.1",
    "@tanstack/eslint-plugin-query": "4.38.0",
    "eslint-plugin-cypress": "2.15.1",
    "eslint-plugin-jsx-a11y": "6.8.0",
    "eslint-plugin-no-relative-import-paths": "1.5.3",
    "eslint-plugin-react": "7.34.1",
    "eslint-plugin-react-hooks": "4.6.0",
    "eslint-plugin-react-refresh": "0.4.6",
    "eslint-plugin-storybook": "0.8.0",
    "eslint-plugin-testing-library": "6.2.0"
  },
  "peerDependencies": {
    "eslint": ">= 8",
    "typescript": ">= 5"
  }
}

주요 내용을 살펴보겠습니다.

main 필드는 이 패키지가 메인으로 사용할 파일입니다. 여기에는 index.js를 지정했는데, 이 파일은 ESLint의 컨피그를 내보내는 역할을 합니다.

dependencies 필드엔 Rush Stack의 규칙을 사용하기 위한 패키지와, 공통 규칙 정의에 필요한 ESLint 플러그인들에 대한 의존성들을 명시했습니다. @rushstack/eslint-config 패키지는 필수이며, 나머지 패키지는 필요에 따라 설치합니다.

  • @rushstack/eslint-config : Rush Stack의 규칙을 사용하려면 반드시 설치해야 하는 패키지입니다.
  • @rushstack/eslint-patch: 사용처에서 ESLint 플러그인을 의존성으로 설치하지 않고도 사용할 수 있게 해줍니다.
  • @tanstack/eslint-plugin-query: React Query를 팀 표준 기술 스택으로 합의하였기 때문에 패키지에 포함했습니다. Cypress, Storybook, Testing Library 플러그인도 같은 이유로 포함되었습니다.
  • eslint-plugin-jsx-a11y: 개발자가 놓치기 쉬운 접근성 규칙들을 넣기 위해 포함하였습니다.
  • eslint-plugin-no-relative-import-paths: 팀에서 import 사용 시 절대 경로를 사용하기로 합의하여 규칙으로 추가하였습니다.

peerDependencies 필드는 사용처에서 ESLint >= 8 버전, TypeScript >= 5 버전 설치가 필요하다고 명시했습니다. 메이저 버전이 변경되면 Breaking Changes가 발생하기 때문에, 메이저 버전을 기준으로 최소 설치 버전을 명시했습니다.

컨피그 파일 만들기

ESLint 공유 컨피그 패키지는 일반적인 자바스크립트 프로젝트에 사용할 기본 컨피그 파일과, React 프로젝트에 사용할 컨피그 파일 두 가지를 구분했습니다. React를 사용하지 않는다면 React 관련 규칙이 필요하지 않기 때문입니다. React를 사용하는 프로젝트에서는 두 가지 컨피그를 모두 불러오고, React를 사용하지 않는 프로젝트에서는 자바스크립트 컨피그 파일만 불러오면 됩니다.

기본 컨피그

eslint-config/index.js 경로에 기본 컨피그를 정의합니다. extends 필드는 Rush Stack의 컨피그를 포함하고, 나머지는 공통으로 사용할 플러그인과 규칙들을 명시합니다.

module.exports = {
  // 필요한 플러그인을 여기에 정의합니다.
  plugins: ['no-relative-import-paths'],
  extends: [
    // ✅ (필수) rushstack 컨피그를 가져옵니다.
    '@rushstack/eslint-config/profile/web-app',
  ],
  rules: {
    // 필요한 커스텀 규칙을 여기에 정의합니다.
    '@typescript-eslint/explicit-function-return-type': 'off',
  },
  settings: {
    // 공통으로 넣고 싶은 설정이 있으면 추가합니다.
  },
};

React 컨피그

eslint-config/mixins/react.js 경로에 React 프로젝트에 사용할 컨피그를 정의합니다.

module.exports = {
  // 플러그인 문서:
  // https://www.npmjs.com/package/eslint-plugin-react
  // https://github.com/ArnaudBarre/eslint-plugin-react-refresh
  // https://www.npmjs.com/package/eslint-plugin-jsx-a11y
  plugins: ["react", "react-refresh", "jsx-a11y"],
  extends: [
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    "plugin:react/jsx-runtime",
    "plugin:@tanstack/eslint-plugin-query/recommended",
  ],

  settings: {
    react: {
      // 현재 React 버전을 명시합니다.
      // 명시하지 않을 경우(기본값 'detect') React 라이브러리 전체를 불러오므로
      // 린트 과정에서 속도가 느려질 수 있습니다.
      version: "detect",
    },
  },

  overrides: [
    {
      files: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
      extends: ['plugin:testing-library/react'],
      rules: {
        'react-refresh/only-export-components': 'off',
      },
    },
  ],

  rules: {
    "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
    // <img> 엘리먼트에 유의미한 대체 텍스트가 있는지 체크
    "jsx-a11y/alt-text": [
      "warn",
      {
        elements: ["img"],
      },
    ],
    // 유효한 aria-* 속성만 사용
    "jsx-a11y/aria-props": "warn",
    // 유효한 aria-* 상태/값만 사용
    "jsx-a11y/aria-proptypes": "warn",
    // DOM에서 지원되는 role, ARIA만 사용
    "jsx-a11y/aria-unsupported-elements": "warn",
    // 필수 ARIA 속성이 빠져있는지 체크
    "jsx-a11y/role-has-required-aria-props": "warn",
    // ARIA 속성은 지원되는 role에서만 사용
    "jsx-a11y/role-supports-aria-props": "warn",
    // DOM에 정의되지 않은 속성을 사용했는지 체크 (emotion css 속성 등 예외 케이스가 있으므로 기본은 off)
    "react/no-unknown-property": "off",
    // 정의한 props 중에 빠진게 있는지 체크 (NextPage 등 일부 추상화 컴포넌트에서 복잡해지므로 기본은 off)
    "react/prop-types": "off",
  },
};

extends 필드는 플러그인에서 제공하는 추천 규칙들을 정의했습니다.

extends: [
  "plugin:react/recommended",
  "plugin:react-hooks/recommended",
  "plugin:react/jsx-runtime",
  "plugin:@tanstack/eslint-plugin-query/recommended",
],

settings 필드는 React 버전에 대한 내용을 명시해두었습니다. eslint-plugin-react 문서 를 보면 React 버전을 자동으로 감지하는 detect가 기본값이기 때문에 명시하지 않아도 문제는 없지만, React 버전을 명시하지 않을 경우 React 라이브러리 전체를 불러오기 때문에 린트(소프트웨어 개발에서 코드의 문제를 식별하고 검사하는 도구) 속도가 저하됩니다. 모든 저장소에서 React 최신 버전을 사용하는 건 아니기 때문에 detect로 설정을 넣어두었고, 실제 React 프로젝트에선 version: '18.2'와 같이 프로젝트에서 사용 중인 버전을 명시해야 합니다.

  settings: {
    react: {
      // 현재 React 버전을 명시합니다.
      // 명시하지 않을 경우(기본값 'detect') React 라이브러리 전체를 불러오므로
      // 린트 과정에서 속도가 느려질 수 있습니다.
      version: "detect",
    },
  },

overrides 필드는 ESLint 설정을 특정 파일이나 폴더에 대해 다르게 적용하려고 할 때 사용할 수 있습니다. 저는 테스트 파일들(__tests__ 폴더 내 파일과 이름에 spec 또는 test를 포함하는 파일들)에 대해 특별한 규칙을 설정했습니다. 여기서는 plugin:testing-library/react를 확장하여 테스트 관련 추천 설정을 적용하고, react-refresh/only-export-components 규칙을 비활성화(off)합니다. 이렇게 프로덕션과 테스트 파일에 적용할 규칙을 구분해두면 개발할 때 불필요한 린트 경고가 발생하는 것을 방지할 수 있습니다.

  overrides: [
    {
      files: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
      extends: ['plugin:testing-library/react'],
      rules: {
        'react-refresh/only-export-components': 'off',
      },
    },
  ],

rules 필드는 Vite와 Next.js 프로젝트 생성 시 기본적으로 활성화된 규칙들을 참고해서 커스텀 규칙들을 정의해두었습니다. 어떤 규칙들이 정의되어 있는지 하나씩 살펴보겠습니다.

react-refresh/only-export-components 규칙은 파일에서 React 컴포넌트만을 export하도록 제한함으로써, Fast Refresh가 올바르게 작동할 수 있도록 돕습니다. create-vite 로 프로젝트를 생성하면 기본적으로 적용돼있는 규칙이기도 합니다.

"react-refresh/only-export-components": ["warn", { allowConstantExport: true }]

allowConstantExport 옵션 { allowConstantExport: true }은 컴포넌트 파일에서 컴포넌트 외에 다른 변수나 함수를 내보내는 것을 허용할 지 결정하는 옵션입니다. Vite의 경우 true로 설정하더라도 Fast Refresh 기능이 잘 작동하도록 지원하기 때문에 true로 설정했습니다.

jsx-a11y 플러그인에서 제공하는 규칙들은 create-next-app으로 프로젝트를 생성하면 기본적으로 적용되어 있는 eslint-config-next의 규칙을 참고했습니다. 저는 규칙에 대한 설명을 공식 문서를 보고 한글 주석으로 적어두었습니다. 이렇게 하면 문서를 확인하는 번거로움이 줄어들기 때문에, 가능하면 주석에 설명을 적는 것을 추천드립니다.

// <img> 엘리먼트에 유의미한 대체 텍스트가 있는지 체크
"jsx-a11y/alt-text": [
  "warn",
    {
      elements: ["img"],
    },
],
// 유효한 aria-* 속성만 사용
"jsx-a11y/aria-props": "warn",
// 유효한 aria-* 상태/값만 사용
"jsx-a11y/aria-proptypes": "warn",
// DOM에서 지원되는 role, ARIA만 사용
"jsx-a11y/aria-unsupported-elements": "warn",
// 필수 ARIA 속성이 빠져있는지 체크
"jsx-a11y/role-has-required-aria-props": "warn",
// ARIA 속성은 지원되는 role에서만 사용
"jsx-a11y/role-supports-aria-props": "warn",

no-unknown-property 규칙은 react 플러그인의 추천 규칙에 포함된 규칙으로, DOM에 정의되지 않은 속성 사용을 금지하는 규칙입니다. 프로젝트 중에 emotion 라이브러리의 css 속성을 사용하는 경우가 있어서 비활성화했습니다.

// DOM에 정의되지 않은 속성을 사용했는지 체크 (Emotion css 속성 등 예외 케이스가 있으므로 기본은 off)
"react/no-unknown-property": "off",

플러그인 패치 파일 만들기

ESLint는 8.5.7 버전 기준, ESLint 플러그인들을 패키지에 포함할 수 없는데 @rushstack/eslint-patch 패키지는 저장소에서 해당 패키지를 설치할 필요 없이, 패치를 바로 적용할 수 있도록 패치 파일을 만들어 줍니다. 여기서 만든 패치 파일은 사용처에서 불러오기만 해주면 바로 사용이 가능합니다. 실제 예시는 "ESLint/Prettier 동작 검증하기" 목차에서 확인할 수 있습니다.

eslint-config/patch.js를 만들고 아래와 같이 작성해 줍니다.

/*
* @rushstack/eslint-patch는 공유 컨피그 패키지에 ESLint 플러그인을 포함시켜줍니다.
*
* https://www.npmjs.com/package/@rushstack/eslint-patch
*/
require("@rushstack/eslint-patch/modern-module-resolution");

eslint-patch 없이 ESLint 플러그인을 포함하는 방법은 없나요?

이미 2015년 8월에 ESLint Github에 Support having plugins as dependencies in shareable config라는 제목으로 플러그인을 peer dependencies가 아닌 직접적인 종속성으로 포함할 수 있게 해달라는 요청이 있었습니다. 2022년 8월에 ESLint 창시자인 Nicholas C. Zakas가 남긴 댓글에 의하면, ESLint의 새 컨피그(Flat config)에서는 플러그인을 직접 종속성으로 지정할 수 있다고 언급했습니다.
2023년 11월 7일에 ESLint 블로그에 올라온 What’s coming in ESLint v9.0.0에 의하면, 9.0.0 부터는 Flat config가 디폴트로 채택될 예정으로, 추후에는 patch 패키지 없이도 플러그인을 공유 컨피그에 포함할 수 있을 것으로 보입니다.

다음으로, Prettier 공유 컨피그 패키지를 만들어보겠습니다.

Prettier 공유 컨피그 패키지 만들기

packages 하위에 prettier-config 폴더를 추가하고, 폴더 하위에 package.jsonindex.js 파일을 생성합니다.

📁 example
📄 package.json
📁 packages
  📁 eslint-config
    📁 mixins
      📄 react.js
    📄 index.js
    📄 package.json
  📁 prettier-config // 여기에 패키지를 구성합니다.
    📄 index.js
    📄 package.json
📄 pnpm-workspace.yaml

package.json을 살펴봅시다.

// packages/prettier-config/package.json
{
  "name": "@org/prettier-config",
  "main": "index.js",
  "version": "1.0.0",
  "peerDependencies": {
    "prettier": ">= 3"
  }
}

ESLint 패키지와 동일하게, Prettier 컨피그를 내보내기 위해 index.jsmain 필드에 지정했습니다.

peerDependencies 필드는 이 공유 컨피그를 사용하고자 하는 프로젝트가 반드시 Prettier 버전 3 이상을 설치하고 있어야 함을 명시하고 있습니다. 3 미만의 버전을 사용해도 실행에 문제는 없지만, 모든 저장소에서 동일한 버전을 사용하여 동일한 실행 결과를 얻기 위해 최소 설치 버전을 엄격하게 설정하였습니다.

index.js 파일은 공통으로 사용할 Prettier 설정을 정의합니다.

// packages/prettier-config/index.js
module.exports = {  
  printWidth: 100,  
  trailingComma: 'all', // 기본값  
  tabWidth: 2, // 기본값  
  semi: true, // 일부 코드에서 라인의 시작 부분에 세미 콜론 추가  
  singleQuote: true,  
  bracketSpacing: true, // 기본값. true인 경우 {foo:bar}는 { foo: bar }로 변환됨  
  arrowParens: 'always', // 기본값  
  useTabs: false, // 기본값 
};

대부분 라이브러리의 기본값을 따르지만 몇몇 설정값은 팀 내 선호도와 토론을 기반으로 합의되었습니다.

printWidth는 코드 한 라인에 대략적으로 몇 글자가 들어갈지 Prettier에게 알려주는 역할을 합니다. 공식 문서 권장 설정값인 80으로 하려고 했지만, 큰 화면에서 보기 편하게 120 또는 100 으로 설정하면 좋겠다는 의견이 있었습니다. 120의 경우 14인치 맥북에서 보기엔 너무 길고, 80은 너무 짧다는 의견이 있어서 그 중간값인 100으로 합의가 되었습니다.

semi는 라인의 끝에 세미콜론을 자동으로 붙여줄지를 결정합니다. 이 부분은 호불호가 많이 갈리는 영역이었기 때문에, 투표를 진행해보았습니다.

결과는 반반이 나왔지만, 원만한 합의를 위해 저의 선호도를 포기하고 세미콜론을 넣기로 결정하였습니다. 정답이 없는 문제이기 때문에 불필요한 논의를 이어가는 것보다는 제 의견을 희생(?)하는 방안을 선택하여 원만한 합의를 이룰 수 있었습니다.

다음으로, 지금까지 만든 공유 컨피그 패키지들이 실제로 잘 동작하는지 확인하기 위해 React 예제 앱을 만들고 설정을 진행한 뒤 명령어를 실행하여 검증해보도록 하겠습니다.

ESLint/Prettier 동작 검증하기

예제 앱 만들기

실제로 ESLint와 Prettier 규칙이 잘 동작하는지 검증하기 위해 Vite 기반의 React 예제 앱을 생성하겠습니다. 여기서 만든 예제 앱은 공유 컨피그에 변경 사항이 생겼을 때와 CI에서 오류를 검증하는 목적으로도 사용이 가능합니다.

$ pnpm create vite example --template react-swc-ts

.eslintrc.cjs 설정하기

프로젝트 생성이 완료됬으면 생성된 폴더 하위에 .eslintrc.cjs 파일을 만들고 아래와 같이 ESLint 컨피그를 설정합니다.

// ✅ 앞서 정의한 patch 파일을 불러옵니다.
// 이렇게 하면 ESLint 플러그인들을 프로젝트에서 일일이 설치할 필요가 없어집니다.
require("@org/eslint-config/patch");

module.exports = {
  env: { browser: true, es2020: true },
  extends: [
    "@org/eslint-config", // 공통 ESLint 컨피그 불러오기
    "@org/eslint-config/mixins/react", // React용 ESLint 컨피그 불러오기
  ],
  settings: {
    react: {
      // 현재 React 버전을 명시합니다.
      // 명시하지 않을 경우(기본값 'detect') React 라이브러리 전체를 불러오므로
      // 린트 과정에서 속도가 느려질 수 있습니다.
      // 예: '16.9', '17.0', '18.0' 등
      version: "18.2",
    },
  },
  // Rush Stack은 @typescript-eslint 플러그인을 내장하고 있으므로
  // 타입스크립트 파서에 대한 설정이 필요합니다.
  parserOptions: {
    project: true,
    tsconfigRootDir: __dirname,
  },
};

컨피그 파일의 최상단에는 공유 컨피그 패키지에서 만든 패치 파일을 불러옵니다. 패치 파일을 불러와야 ESLint 플러그인을 설치 없이 사용할 수 있습니다.

require("@org/eslint-config/patch");

extends 필드에는 공유 컨피그를 불러옵니다. React를 사용하고 있으므로 React 컨피그도 같이 포함합니다.

extends: [
  "@org/eslint-config", // 기본 ESLint 컨피그 불러오기
  "@org/eslint-config/mixins/react", // React용 ESLint 컨피그 불러오기
],

settings.react.version 필드에는 React 버전을 명시합니다. React 버전을 명시하지 않으면 React 버전을 감지하기 위해 React 라이브러리 전체를 불러오므로 린트 실행 속도가 느려질 수 있습니다.

settings: {
  react: {
    version: "18.2",
  },
},

ESLint 설정에서 parserOptions는 ESLint가 코드를 분석할 때 사용하는 파서의 옵션을 설정합니다. Rush Stack의 규칙은 typescript-eslint를 의존성으로 갖고 있기 때문에, tsconfig.json의 경로를 설정하는 작업이 필요합니다.

parserOptions: {
  project: true,
  tsconfigRootDir: __dirname,
},

projecttrue로 설정하는 것이 좋습니다. true 옵션은 typescript-eslint 5.52.0 버전에서 추가된 설정으로, 린팅되는 소스 파일이 해당 경로에서 가장 가까운 tsconfig.json를 기반으로 해석되도록 설정합니다. 이는 특히 저장소 안에 여러 개의 tsconfig.json 파일이 존재하는 모노레포 구조에서 유용합니다.

tsconfigRootDir는 프로젝트의 루트 디렉터리(가장 일반적으로 __dirname)로 설정하는 것이 좋습니다. 이렇게 하면 실수로 루트의 tsconfig.json 파일을 삭제하거나 이름을 변경하는 경우 @typescript-eslint/parser가 상위 경로에서 상위 tsconfig.json 파일을 찾는 것을 막아줍니다.

package.json 수정하기

packages/example/package.json 파일을 수정하여, 공유 컨피그 패키지 의존성 및 Prettier 컨피그 설정을 추가해보겠습니다.

{
  "name": "example",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "prettier": "prettier --write \"**/*.{js,jsx,ts,tsx,css,html}\""
  },
  "dependencies": {
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  "devDependencies": {
    // ✅ ESLint, Prettier 컨피그에 대한 의존성을 명시해줍니다.
    "@org/eslint-config": "workspace:*",
    "@org/prettier-config": "workspace:*",
    "@types/react": "18.2.74",
    "@types/react-dom": "18.2.24",
    // ✅ @typescript-eslint/* 패키지는 rushstack에 포함되어있으므로 포함하지 않습니다.
    "@vitejs/plugin-react-swc": "3.6.0",
    "eslint": "8.57.0",
    "typescript": "5.4.4",
    "vite": "5.2.8"
  },
  "prettier": "@org/prettier-config"
}

먼저, devDependencies에 아래와 같이 공유 컨피그 패키지를 각각 추가해줍니다.

"devDependencies": {
  "@org/eslint-config": "workspace:*",
  "@org/prettier-config": "workspace:*",
},

혹은 루트에서 명령어를 실행하여 패키지를 추가할 수도 있습니다.

$ pnpm add -D @org/eslint-config@workspace:* @org/prettier-config@workspace:* --filter example

의존성 중에 공유 컨피그에 이미 포함된 의존성들은 제거했습니다.

  • eslint-plugin-react-hooks: 공유 컨피그 의존성에 포함돼 있으므로 제외했습니다.
  • eslint-plugin-react-refresh: 공유 컨피그 의존성에 포함돼 있으므로 제외했습니다.
  • @typescript-eslint/*: Rush Stack에 포함되어있으므로 제외했습니다.

prettier 필드에 Prettier 컨피그 패키지 이름을 명시합니다.

"prettier": "@org/prettier-config"

검증하기

검증 전, 루트 경로에서 패키지 설치를 진행합니다.

$ pnpm install

example/src/App.tsx에서 의도적으로 린트 에러가 발생하는 상황을 만듭니다. 저는 useEffect에 들어가야 할 종속성을 일부러 누락시켜 보았습니다.

function App() {  
  const [count, setCount] = useState(0);  

  useEffect(() => {  
    console.log(count);  
  }, []);
// ...

터미널에서 example 워크스페이스의 lint 명령어를 실행하여 ESLint가 동작하는지 확인해 봅니다.

$ pnpm --filter example lint

> shared-config-example@0.0.0 example /shared-config-example
> pnpm --filter example "lint"

> example@0.0.0 lint /shared-config-example/example
> eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0

/shared-config-example/example/src/App.tsx
  11:6  warning  React Hook useEffect has a missing dependency: 'count'. Either include it or remove the dependency array  react-hooks/exhaustive-deps

✖ 1 problem (0 errors, 1 warning)

ESLint found too many warnings (maximum: 0).
/shared-config-example/example:
 ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL  example@0.0.0 lint: `eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0`
Exit status 1
 ELIFECYCLE  Command failed with exit code 1.

설정한 규칙에 맞게 경고를 출력하는 것으로 보아 ESLint가 정상적으로 동작하는 것을 확인할 수 있습니다.

TypeScript 버전 경고 해결 방법

> WARNING: You are currently running a version of TypeScript which is not officially supported by @typescript-eslint/typescript-estree.
> SUPPORTED TYPESCRIPT VERSIONS: >=3.3.1 > YOUR TYPESCRIPT VERSION: 5.2.2`

위와 같은 경고가 발생한다면 루트의 package.json에서 typescript-eslint 패키지 버전들을 최신 버전으로 오버라이드 하는 방식으로 해결할 수 있습니다.

"pnpm": {
   "overrides": {
     "@typescript-eslint/eslint-plugin": "7.5.0",
     "@typescript-eslint/parser": "7.5.0"
    }
}

터미널에서 example 워크스페이스의 prettier 명령어를 실행하여 Prettier도 잘 되는지 확인해봅니다.

$ pnpm example prettier
> example@0.0.0 prettier /shared-config-example/example
> prettier --write "**/*.{js,jsx,ts,tsx,css,html}"

index.html 19ms (unchanged)
src/App.css 18ms (unchanged)
src/App.tsx 114ms
src/index.css 5ms (unchanged)
src/main.tsx 3ms (unchanged)
src/vite-env.d.ts 2ms (unchanged)
vite.config.ts 3ms (unchanged)

Prettier 역시 정상 동작하는 것을 확인할 수 있습니다.

다음으로, 코어웹프론트개발팀에서 사용하는 규칙 중 추천하는 규칙과 플러그인들을 소개해보도록 하겠습니다.

추천 규칙 및 플러그인

네이밍 컨벤션 규칙

플러그인: @typescript-eslint/eslint-plugin (@rushstack/eslint-config을 설치했다면 별도 설치 필요 없음)

@typescript-eslint/naming-convention 규칙은 이름(변수, 함수, 클래스, 타입 등)에 컨벤션을 정의할 수 있는 규칙입니다. 만약 타입 이름에 헝가리안 표기법을 쓰지 않기로 팀에서 합의했다면, 이 규칙을 통해 누군가 헝가리안 표기법을 실수로 사용하는 것을 방지할 수 있습니다.

참고로, 헝가리안 표기법은 변수나 함수의 인자 이름 앞에 데이터 타입을 접두어로 명시하는 표기법입니다. 예를 들어, TypeScript의 Interface는 I를 접두어로 사용해서 IVariable, Type은 T를 접두어로 사용해서 TVariable로 표현할 수 있습니다.

사용법은 먼저 셀렉터(ex: 변수, 타입..)를 정하고, 셀렉터에 해당되는 포맷(ex: camelCase, 정규식..)을 지정합니다. 아래는 코어웹프론트개발팀에서 쓰고 있는 규칙들의 예시입니다.

{
  "rules": {
    "@typescript-eslint/naming-convention": [
      "warn",
      // camelCase 변수, PascalCase 변수, UPPER_CASE 변수 허용
      {
        "selector": "variable",
        "format": ["camelCase", "PascalCase", "UPPER_CASE"]
      },
      // camelCase 함수, PascalCase 함수 허용
      {
        "selector": "function",
        "format": ["camelCase", "PascalCase"]
      },
      // PascalCase 클래스, interfaces, type aliases, enums 허용
      {
        "selector": "typeLike",
        "format": ["PascalCase"]
      },
      // interface 앞에 I 사용 불가
      {
        "selector": "interface",
        "format": ["PascalCase"],
        "custom": {
          "regex": "^I[A-Z]",
          "match": false
        }
      },
      // typeAlias 앞에 T 사용 불가
      {
        "selector": "typeAlias",
        "format": ["PascalCase"],
        "custom": {
          "regex": "^T[A-Z]",
          "match": false
        }
      },
      // typeParameter 앞에 T 사용 불가
      {
        "selector": "typeParameter",
        "format": ["PascalCase"],
        "custom": {
          "regex": "^T[A-Z]",
          "match": false
        }
      }
    ]
  }
}

절대 경로 강제 규칙

플러그인: eslint-plugin-no-relative-import-paths

import 경로를 절대 경로를 사용하기로 합의했을 때 사용하기 좋은 규칙입니다. 이 규칙을 도입하기로 했을 때 팀원분들로부터 가장 많이 받았던 질문이 ‘같은 폴더에서 import할 때는 상대 경로를 쓸 수 있나요?’였는데, allowSameFolder라는 속성을 true로 하면 가능합니다. 아래는 예시입니다.

{
  "rules": {
    // 같은 폴더인 경우를 제외하고 import 경로는 항상 절대 경로를 사용
    "no-relative-import-paths/no-relative-import-paths": [
      "warn",
      { "allowSameFolder": true, "rootDir": "src", "prefix": "@" }
    ]
  }
}

prefix를 지정하면, fix 실행 시 eslint가 자동으로 prefix를 넣어서 import 경로를 고쳐줍니다.

// { "prefix": "@" } 옵션을 지정하면
import Something from "../../components/something";

// 아래와 같이 고쳐줍니다.
import Something from "@/components/something";

구조 분해 할당 강제 규칙

이 규칙은 팀원 분이 제안 주셔서 도입하게 된 규칙입니다. ESLint 내장 규칙인 prefer-destructuring을 통해 구조 분해 할당을 강제할 수 있습니다. 구조 분해 할당에 대한 컨벤션을 맞추면, 일관적인 코드를 유지할 수 있어서 가독성에 도움이 됩니다.

ESLint에서 기본적으로 제공하는 규칙이기 때문에 별도 플러그인 설치는 필요하지 않습니다. 코어웹프론트개발팀은 변수 선언식에서 객체에 대해서만 구조 분해 할당 규칙을 강제하도록 설정했습니다.

{
  "prefer-destructuring": [
    "error",
    {
      "VariableDeclarator": {
        "array": false,
        "object": true
      },
      "AssignmentExpression": {
        "array": false,
        "object": false
      }
    }
  ]
}

참고로, VariableDeclarator.object 옵션의 경우 ESLint의 --fix 옵션을 넣어서 실행하면 구조 분해 할당을 적용하는 코드로 고쳐줍니다.

이 규칙을 적용하면, 아래 상황에서 에러가 발생합니다.

const user = {
  name: 'john',
  age: 25
};

// 🚨 Error: Use object destructuring.
const name = user.name;

올바른 코드는 다음과 같습니다.

const user = {
  name: 'john',
  age: 25
};

// ✅
const { name } = user.name;

Tailwind CSS 클래스 자동 정렬 플러그인

2022년 1월 Tailwind CSS에서 공식으로 발표prettier-plugin-tailwindcss은 Tailwind CSS의 클래스 네임을 권장 클래스 순서에 맞게 자동으로 정렬해주는 유용한 도구입니다. 적용하면 클래스 네임의 가독성을 높여주기 때문에 추천드리는 플러그인입니다.

적용하려면 먼저 prettier-plugin-tailwindcss 패키지를 설치합니다.

$ pnpm add -D prettier prettier-plugin-tailwindcss

다음으로, Prettier 설정 파일의 plugins 필드에 패키지 이름을 명시합니다.

{
  plugins: ['prettier-plugin-tailwindcss']
}

예시는 다음과 같습니다.

<!-- 적용 전 -->
<button
  class="text-white px-4 sm:px-8 py-2 sm:py-3 bg-sky-700 hover:bg-sky-800"
>
  ...
</button>

<!-- 적용 후 -->
<button
  class="bg-sky-700 px-4 py-2 text-white hover:bg-sky-800 sm:px-8 sm:py-3"
>
  ...
</button>

플러그인을 공용으로 사용하고 싶다면, Prettier 공유 컨피그 패키지에 포함하면 됩니다. Prettier 공유 컨피그 패키지에 플러그인을 설치하고 plugins 필드에 패키지 이름을 명시하면, 사용처에서 별도 설정없이 플러그인이 적용됩니다.

결론

ESLint/Prettier 공유 컨피그 패키지를 만들어 배포하고, 코어웹프론트개발팀의 모든 저장소에 적용하여 일관된 개발 경험을 얻을 수 있었습니다. 이 과정에서 팀원들끼리 나눴던 대화와 합의를 통해 통일된 개발 문화를 형성할 수 있었고, 팀 내 생산성이 향상되는 효과를 누릴 수 있었습니다.

합의하는 과정에서 다소 시간이 소요됐지만, 패키지를 적용하고 나니 다른 저장소에서 작업할 때도 의문의 ESLint 에러가 발목을 잡는 일은 없어졌습니다. 그뿐만 아니라, 코드 리뷰 과정에서도 불필요한 논쟁을 줄이고, 커뮤니케이션 비용을 아끼는 효과도 덤으로 얻을 수 있었습니다.

결과적으로, Airbnb의 ESLint 규칙들이 생산성을 저해하는 문제를 해결하고, 코어웹프론트개발팀만의 규칙들로 채워진 공유 컨피그 패키지를 도입함으로써 개발 효율성을 크게 개선할 수 있었습니다. 이 과정을 통해, 좋은 도구 선택과 팀원 간의 의사소통의 중요성을 다시 한번 느낄 수 있었습니다.

부록: 생산성을 저해하는 Airbnb 규칙들

사소하다고 여겨질 수 있지만, ESLint의 경고를 피하기 위해 코드를 여러 번 다시 작성하다 보면 작업 흐름이 끊기고 꽤나 많은 시간이 소요됩니다. 생산성을 저해한다고 느꼈던 Airbnb 규칙을 정리해보았습니다.

for..of 사용 금지 규칙

for (const key of obj) {
               // ~~~
               // ESLint: iterators/generators require regenerator-runtime, which is
               // too heavyweight for this guide to allow them. Separately, loops should
               // be avoided in favor of array iterations. (no-restricted-syntax)
}

for..of는 인덱스나 키 값에 관계없이 단순한 반복문을 작성할 때 유용한 문법입니다. Airbnb에 정의된 no-restricted-syntax 규칙은 특정 구문(ex: for..of) 사용 시 에러를 표시하도록 설정하고 있습니다. "구형 브라우저에서 호환성이 떨어지고 regenerator-runtime 폴리필이 무거우니 호환성 좋은 forEach를 쓰는게 낫다"는 배경에서 제한되었다고 합니다.

forEach의 경우 break, continue, await와 같은 키워드 사용에 제약이 있어 불편함을 겪었습니다.

for..of 제한 규칙이 불필요한 이유

2017년 1월에 Github 이슈에 올라와서 이 규칙이 정말 필요한지에 대해서 굉장히 많은 의견이 있었습니다. 여기서 얘기하는 구형 브라우저의 기준은 async 함수를 사용할 수 없는 환경, 즉 Safari 11 버전 미만과 IE 환경 얘기입니다. 2023년 8월 기준으로 한국에서의 iOS 버전 11 미만 사용자 비율이 0.06%인 것을 고려하면(출처: StatCounter) 현재 시점에서는 이 규칙의 중요성이 상대적으로 줄어들었다고 볼 수 있습니다. 이 규칙은 이제는 고려하지 않아도 되는 구형 브라우저를 위한 것이므로, 지금 시점에서는 불필요하다고 볼 수 있습니다.

화살표 함수의 중괄호 강제 생략 규칙

const bar = () => {
               // ~
               // ESLint: Unexpected block statement surrounding arrow body;
               // move the returned value immediately after the '=>'. (arrow-body-style)
  return 0;
}

arrow-body-style규칙은 화살표 함수에서 중괄호를 생략할 수 있는 경우에 생략하도록 강제하는 규칙입니다. 이 규칙은 코드를 간결하게 만들어주지만, 호불호가 갈리는 규칙이기도 합니다. 예를 들어 빈 함수를 만들어놓고 나중에 로직을 추가하려는 경우, 코드를 지우고 다시 중괄호를 작성해야 합니다.

중괄호를 처음부터 작성해두면 나중에 추가적인 로직이 필요할 경우 바로 코드를 작성할 수 있어 편리합니다. 비슷한 사례로 Prettier 2.0.0에서도 이러한 장점을 얻기 위해 기본 설정을 화살표 함수 파라미터의 괄호를 항상 포함하도록 변경된 히스토리가 있습니다. 예를 들어, x => x를 작성하면 (x) => x로 변환해줍니다.