/ 개발

eslint config 분리

#개발

강력하게 참고한 블로그

각 프로젝트마다 eslint를 따로 설정해주었는데, 어느 프로젝트에서 어떤 설정이 되어있는지 트래킹이 안돼서 eslint, prettier 같은 협업 설정들을 한 번에 적용할 수 있는 패키지를 만들고자 함.

우선 eslint 부터, 그 다음으로 prettier 설정 예정.
어쨌든 eslint.config.js, prettier.config.js 와 같은 형태가 필요하기 때문에 배포는 따로인데, 관리는 한 번에 하는게 편할 것 같아서 모노레포 형식으로 구성하고자 함. 설정이 겹치기도 하고 (eslint 내부에 prettier 필드가 있음.)

모노레포 생성하고 packages/eslint-config 생성
그리고 일단 index.js를 하나 두고, 기존에 쓰던 config 파일을 그대로 넣어보기로 했다.
그리고 실행 해보기 전에 고민해야 할 몇 가지:

어떻게 빌드해서 패키지화 하지?
익숙한 turbo 를 사용했고, 사실 turbo에서 빌드 도구를 제공하지 않을까 했는데, 그건 아니었다.
빌드는 react 프로젝트 할 때마다 vite를 사용해서 별 생각이 없었는데, vite는 config 설정만 빌드할 목적으론 적합하지 않은 것 같아서 찾아보다가 nanobuild에 대해 알아봤다.
좀 더 정확하게 정리하하자면,

  1. Vite는 "개발용 + 번들러" 성격이 강하다. vite는 개발 서버에서 HMR을 제공하는 것이 핵심이다. 내부적으로는 nanobuild와 같은 esbuild를 사용하긴 하나, 번들링과 플러그인 시스템까지 갖춘 풀스택 빌드 도구이기 때문에 단순히 config만 빌드할 목적이라면 과할 수 있다.
  2. nanobuild는 경량화된 빌드 툴로, 특정 파일을 빠르게 번들링하거나 단순 빌드용으로 쓸 때 적합하다. 개발 서버나 HMR과 같은 기능은 없고, 오로지 빌드 속도와 최소 설정에 집중한다.

그으으으런데 현재까지는 eslint-config 만 패키지화 한 상황이고, config 파일은 항상 js 파일이다.
여기서 빌드가 필요한 경우를 고려해보자면 다음과 같다:

  1. typescript를 사용하는 경우: ts는 node.js가 바로 실행할 수 없으므로, tsc 또는 번들러로 .js 파일을 만들어야 한다.
  2. React 컴포넌트 라이브러리: JSX/TSX 문법이 포함되어 있으면 빌드해서 JS로 변환해야 한다.
  3. 최신 ES 문법: import.meta, decorators, top-level await 같은 문법을 사용하여 개발했는데 소비자 프로젝트가 해당 문법을 지원하지 않는 상황이 있을 수 있다. 따라서 다운 컴파일하는 경우 빌드 과정이 필요하다.
  4. tree-shaking/번들링 최적화: 배포 패키지 크기를 줄이려는 경우 빌드 과정에서 불필요한 코드를 제거한다.
    이 경우에 해당하지 않으므로, build를 신경쓰지 않고 package.json 를 통해 cjs, esm 환경 등만 설정하면 된다.

패키지가 여러 개로 늘어날 때, 패키지 별로 build 를 어떻게 하지? -> turbo가 해결
루트에서 turbo run build -> 모든 패키지에서 build 스크립트를 찾아 실행한다.

패키지 릴리즈/버전 관리는 어떻게 하지? -> changesets 사용
사실 릴리즈 관리를 해 본 적이 없는데, 근래 여러 프로젝트를 진행하며 버전 관리의 필요성을 느껴서 이 config 프로젝트부터 설정해보려고 한다.


암튼 가장 간단하게 구성한 폴더 구조는 다음과 같다:

📂packages
┗ 📂eslint-config
┃ ┣ 📜CHANGELOG.md
┃ ┣ 📜README.md
┃ ┣ 📜index.js
┃ ┗ 📜package.json

index.js

import path from "path";import js from "@eslint/js";import tsParser from "@typescript-eslint/parser";import eslintConfigPrettier from "eslint-config-prettier";import importPlugin from "eslint-plugin-import";import prettierPlugin from "eslint-plugin-prettier";import reactPlugin from "eslint-plugin-react";import reactHooks from "eslint-plugin-react-hooks";import tseslint from "typescript-eslint";export default tseslint.config([  js.configs.recommended,  reactHooks.configs["recommended-latest"],  ...tseslint.configs.recommended,  eslintConfigPrettier,  {    languageOptions: {      parser: tsParser,      parserOptions: {        project: [          path.resolve(process.cwd(), "tsconfig.node.json"),          path.resolve(process.cwd(), "tsconfig.app.json")        ],                tsconfigRootDir: process.cwd(),      },      ecmaVersion: 2020,    },    plugins: {      import: importPlugin,      prettier: prettierPlugin,      react: reactPlugin,      "@typescript-eslint": tseslint.plugin,    },  },  {    rules: {      /* ------------------------------       * 🔹 일반 코드 스타일 (JavaScript)       * ------------------------------ */      "no-var": "warn", // var 대신 let/const 사용 권장      "no-multiple-empty-lines": "warn", // 연속된 빈 줄 방지      "no-console": [        "warn",        { allow: ["warn", "error", "info"] }, // log 금지, warn/error/info 허용      ],      eqeqeq: "warn", // == 대신 === 사용      "dot-notation": "warn", // obj['key'] 대신 obj.key 권장      "no-unused-vars": "off", // 기본 unused-vars 끔 (TS 플러그인에서 대체)      /* ------------------------------       * ⚛️ React 관련       * ------------------------------ */      "react/destructuring-assignment": "warn", // props/state 구조분해 할당      "react/jsx-pascal-case": "warn", // 컴포넌트명 PascalCase      "react/no-direct-mutation-state": "warn", // state 직접 수정 금지      "react/jsx-key": "warn", // 리스트 key 필수      "react/self-closing-comp": "warn", // 내용 없는 태그 <Comp /> 형태      "react/jsx-curly-brace-presence": "warn", // 불필요한 {} 금지      "react-hooks/exhaustive-deps": "off", // useEffect deps 검사 끔 (수동 관리)      /* ------------------------------       * 📘 TypeScript 관련       * ------------------------------ */      "@typescript-eslint/no-unused-vars": [        "error",        {          argsIgnorePattern: "^_", // _로 시작하는 변수 허용          varsIgnorePattern: "^_", // _로 시작하는 변수 허용          ignoreRestSiblings: true, // 구조분해 나머지 변수 무시        },      ],      "@typescript-eslint/no-explicit-any": "warn", // any 사용 경고      /* ------------------------------       * 🎨 Prettier 관련       * ------------------------------ */      "prettier/prettier": [        "error",        {          trailingComma: "all",          tabWidth: 2,          semi: true,          printWidth: 100,          singleQuote: true,          bracketSpacing: true,          useTabs: false,          endOfLine: "auto",          arrowParens: "avoid",        },      ],      /* ------------------------------       * 📦 Import 순서/정렬       * ------------------------------ */      "import/order": [        "error",        {          groups: [            "builtin", // Node 내장            "external", // npm 패키지            "internal", // 프로젝트 내부(alias)            "parent", // ../            "sibling", // ./            "index", // index.js            "object", // require            "type", // 타입 import          ],          pathGroups: [            {              pattern: "react",              group: "builtin",              position: "before", // react는 항상 최상단            },            {              pattern: "@/**",              group: "internal",              position: "after", // alias는 내부 import 뒤            },          ],          pathGroupsExcludedImportTypes: ["react"],          "newlines-between": "always", // 그룹 간 줄바꿈          alphabetize: {            order: "asc",            caseInsensitive: true, // 알파벳 순 정렬          },        },      ],    },  },  {    settings: {      react: {        version: "detect",      },    },  },]);

package.json

{  "name": "@helper-robotics/eslint-config",  "version": "1.0.3",  "publishConfig": {    "access": "public"  },  "main": "index.js",  "files": [    "index.js"  ],  "devDependencies": {    "@typescript-eslint/parser": "^8.42.0",    "eslint": "^9.35.0",    "eslint-plugin-import": "^2.32.0",    "eslint-plugin-prettier": "^5.5.4",    "eslint-plugin-react": "^7.37.5",    "eslint-plugin-react-hooks": "^5.2.0",    "prettier": "^3.6.2",    "typescript-eslint": "^8.43.0"  }}

최상단 turbo.json

{  "$schema": "https://turborepo.com/schema.json",  "ui": "tui",  "tasks": {    "build": {      "dependsOn": ["^build"],      "inputs": ["$TURBO_DEFAULT$", ".env*"],      "outputs": ["lib/**"]    },    "lint": {      "dependsOn": ["^lint"]    },    "check-types": {      "dependsOn": ["^check-types"]    },    "dev": {      "cache": false,      "persistent": true    }  }}

npm publish하여 eslint config가 잘 적용되는 것을 확인했다.

여기서 고민해볼게, 만약 js 파일에서 eslint 검사를 했을 때 ts 설정이 적용될 필요는 없음.
사용하는 환경마다, 어떤 config 가 필요할지 구분이 필요하다.

그래서 먼저 config 파일을 분리하려고 함.

  1. base.js <- import order랑 prettier 설정 등
  2. react.js <- react 관련 설정
  3. typescript.js <- typescript 관련 설정
    하고 루트에 있는 index.js에서 합치기

여기서 좀 헤맨게, 참고한 글이 23년도 글인데 현재는 eslint가 flat config를 사용해서 config 작성 방식이 변경됐다. 뭐 근데 에러 로그를 잘 읽어보면 어디를 수정해야 할지는 보이긴 함.

결과적으로 이런 구조:

📂packages
┗ 📂eslint-config
┃ ┣ 📂src
┃ ┃ ┣ 📜base.js
┃ ┃ ┣ 📜react.js
┃ ┃ ┣ 📜typescript.js
┃ ┣ 📜CHANGELOG.md
┃ ┣ 📜README.md
┃ ┣ 📜index.js
┃ ┗ 📜package.json

base.js

import eslintJs from "@eslint/js";import importPlugin from "eslint-plugin-import";import prettierPlugin from "eslint-plugin-prettier";import globals from "globals";export default {  languageOptions: {    globals: {        ...globals.browser,    }  },  plugins: {    import: importPlugin,    prettier: prettierPlugin,    js: eslintJs,  },  extends: ["js/recommended"],  rules: {    "no-var": "warn",    "no-multiple-empty-lines": "warn",    "no-console": ["warn", { allow: ["warn", "error", "info"] }],    eqeqeq: "warn",    "dot-notation": "warn",    "no-unused-vars": "off",    "prettier/prettier": [      "error",      {        trailingComma: "all",        tabWidth: 2,        semi: true,        printWidth: 100,        singleQuote: true,        bracketSpacing: true,        useTabs: false,        endOfLine: "auto",        arrowParens: "avoid",      },    ],    "import/order": [      "error",      {        groups: [          "builtin",          "external",          "internal",          "parent",          "sibling",          "index",          "object",          "type",        ],        pathGroups: [          { pattern: "react", group: "builtin", position: "before" },          { pattern: "@/**", group: "internal", position: "after" },        ],        pathGroupsExcludedImportTypes: ["react"],        "newlines-between": "always",        alphabetize: { order: "asc", caseInsensitive: true },      },    ],  },};

react.js

import reactPlugin from "eslint-plugin-react";import reactHooks from "eslint-plugin-react-hooks";export default {  plugins: {    react: reactPlugin,    "react-hooks": reactHooks,  },  rules: {    "react/destructuring-assignment": "warn",    "react/jsx-pascal-case": "warn",    "react/no-direct-mutation-state": "warn",    "react/jsx-key": "warn",    "react/self-closing-comp": "warn",    "react/jsx-curly-brace-presence": "warn",    "react-hooks/exhaustive-deps": "off",  },  settings: {    react: { version: "detect" },  },};

typescript.js

import tsParser from "@typescript-eslint/parser";import path from "path";import tseslint from "typescript-eslint";export default {  languageOptions: {    parser: tsParser,    parserOptions: {      project: [        path.resolve(process.cwd(), "tsconfig.node.json"),        path.resolve(process.cwd(), "tsconfig.app.json"),      ],      tsconfigRootDir: process.cwd(),    },    ecmaVersion: 2020,  },  plugins: {    "@typescript-eslint": tseslint.plugin,  },  rules: {    "import/no-unresolved": "error",    "@typescript-eslint/no-unused-vars": [      "error",      {        argsIgnorePattern: "^_",        varsIgnorePattern: "^_",        ignoreRestSiblings: true,      },    ],    "@typescript-eslint/no-explicit-any": "warn"  },  settings: {    "import/resolver": {        typescript: { project: path.resolve(process.cwd(), "tsconfig.app.json") },    }  },};

하고 이 설정들을 적용하는 경우를 index.js에 작성해준다.

// index.jsimport { defineConfig } from "eslint/config";import base from "./src/base.js";import react from "./src/react.js";import typescript from "./src/typescript.js";export default defineConfig([  base,   {    files: ["**/*.ts", "**/*.tsx"],    ...typescript,  },  {    files: ["**/*.{jsx,tsx}"],    ...react,  },]);

적용했더니 잘 됨.

참고로 개발 환경에서 npm 패키지를 테스트할 때, 변경사항이 있을 때마다 배포할 수가 없으니 이 개발 환경 그대로 다른 프로젝트에서 패키지처럼 가져와 테스트할 수 있는 방법이 있다.
pnpm link 를 통해 하려고 했는데, 잘 안됨.
그래서 package.json에 직접 로컬 경로를 기재한다.

사용하고자 하는 환경의 package.json

"dependencies": {  "@helper-robotics/eslint-config": "file:C:/Users/hprob/git/configs-frontend/packages/eslint-config"}

이렇게 하면 로컬 경로를 바로 참조해서 연결된다.


파일 분리하고 eslint가 적용되는 건 확인했다.
또 참고로 내가 설정한 속성대로 eslint가 잘 적용되는지 확인하려면
npx eslint --print-config src/components/Timer/TimerSlot/index.tsx
이런 식으로 검사하고자 경로 혹은 파일 하나를 집어서, config를 출력하면 된다.

그런데 자꾸 타입 검사했을 때의 에러를 잡아내지 못함.
특히 "Can't find~"와 같은 에러. 타입 에러인가 싶어 tsc 로 돌려서 잡으려고 했는데, 얘도 못 잡음.
여기서 꽤 시행착오가 있었다. gemini 돌려가면서 해결을 했는데, 그 과정을 요약해달라고 했다.

  1. 문제 정의
  • 초기 상태: React 프로젝트의 특정 컴포넌트(TimerSlot/index.tsx)에서 import 없이
    useFryStore hook을 사용했음에도 불구하고, TypeScript 컴파일러(tsc)와 ESLint 모두 이
    에러를 감지하지 못하는 문제가 발생했습니다.
  • 목표: tsc와 ESLint가 "정의되지 않은 변수 사용" 또는 "잘못된 import 경로"와 같은 에러를
    올바르게 감지하도록 개발 환경을 설정하는 것.
  1. 디버깅 과정 및 시도

시도 1: import/no-unresolved 규칙 확인

  • 가설: import 경로가 잘못되었을 때 발생하는 문제이므로, eslint-plugin-import의
    import/no-unresolved 규칙으로 해결할 수 있을 것이다.
  • 조사:
    1. 공유 설정(@helper-robotics/eslint-config)을 확인한 결과, eslint-plugin-import는 사용
      중이었으나 import/no-unresolved 규칙은 비활성화 상태였습니다.
    2. 또한, 경로 별칭(@/)을 해석하기 위한 eslint-import-resolver-typescript 설정이
      누락되어 있었습니다.
  • 실패 및 깨달음: 이 규칙을 활성화하는 것은 좋은 시도였지만, 이 규칙은 잘못된 import
    경로를 잡는 것이 주 목적이었습니다. 현재 문제는 import 구문 자체가 없는, 정의되지 않은
    변수를 사용한 것이므로 이 규칙만으로는 해결할 수 없다는 것을 깨달았습니다.

시도 2: @typescript-eslint/no-undef 규칙 적용

  • 가설: "정의되지 않은 변수 사용" 문제는 @typescript-eslint/no-undef 규칙으로 해결할 수
    있다.
  • 조사: 해당 규칙을 ESLint 설정에 추가했습니다.
  • 실패 및 깨달음: ESLint 설정 에러(Could not find "no-undef" in plugin
    "@typescript-eslint")가 발생했습니다. 이를 통해, 사용 중인 최신
    @typescript-eslint/eslint-plugin 버전에서는 해당 규칙이 더 이상 존재하지 않고, ESLint의
    기본 규칙인 no-undef로 통합되었다는 사실을 알게 되었습니다. (이 과정에서 제가 잘못된
    정보를 제공했습니다.)

시도 3: 기본 no-undef 규칙 적용

  • 가설: 최신 @typescript-eslint/parser는 ESLint 기본 규칙인 no-undef와 호환되므로, 이
    규칙을 활성화하면 문제를 해결할 수 있다.
  • 조사: eslint.config.mjs에 'no-undef': 'error' 규칙을 추가했습니다.
  • 부분적 성공: 이 조치로 인해 드디어 'useFryStore' is not defined 에러를 감지하는 데
    성공했습니다.
  1. 새로운 문제 발생 및 해결
  • 새로운 문제: no-undef 규칙을 활성화하자, 이번에는 console, document, setInterval 등
    브라우저에서 기본적으로 제공하는 전역 변수들까지 모두 에러로 처리되는 새로운 문제가
    발생했습니다.
  • 원인: ESLint가 해당 코드가 브라우저 환경에서 실행된다는 사실을 몰라서, 브라우저의 기본
    전역 변수들을 인식하지 못했기 때문입니다.
  • 해결: globals 패키지를 설치하고, eslint.config.mjs의 languageOptions에 globals.browser를
    추가하여 브라우저 전역 변수들을 ESLint에 알려주었습니다.
  1. 최종 성공
  • 최종 확인: pnpm run lint 명령을 실행하여, console과 같은 전역 변수 에러는 더 이상
    발생하지 않으며, 원래 잡으려고 했던 (그리고 현재는 수정된) useFryStore 에러만 올바르게
    감지할 수 있는 환경이 구축되었음을 확인했습니다.
  • 현재 상태: ESLint 설정이 완벽하게 구성되어, 의도했던 모든 종류의 에러를 정확하게 감지할
    수 있습니다.

뭐 이런 과정이 있었다.
그래서 이 상태로 1.0.4 버전 배포하려고 함.

changesets 작성
아놔 근데 changesets을 잘 못 쓰겠음.

일단 main에 push가 되면 github actions으로 자동 배포되도록 워크플로우를 만들었음.

name: Release Packageson:  push:    branches:      - mainjobs:  release:    name: Release Packages    runs-on: ubuntu-latest    permissions:      contents: write      pull-requests: write    steps:      - name: Checkout Repo        uses: actions/checkout@v3        with:          # 전체 히스토리 가져와서 changesets changelog 생성 가능하도록 설정          fetch-depth: 0      - name: Setup Node.js 18.x        uses: actions/setup-node@v3        with:          node-version: 18.x      - name: Setup pnpm        uses: pnpm/action-setup@v2        with:          version: 9      - name: Install Dependencies        run: pnpm install --no-frozen-lockfile      - name: Build        run: pnpm build      - name: Publish Packages        id: changesets        uses: changesets/action@v1        with:		  version: pnpm run version          publish: pnpm run release        env:          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}          NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}

순서

  1. main 브랜치에 push 되었을 때 실행
  2. node, pnpm dependencies 설치 및 빌드 과정
  3. 패키지 배포

배포에서는 크게 1) 버전 업데이트, 2) npm에 배포 이 과정을 수행한다.

  1. version: "changeset version && pnpm install"
    1. changeset version: 현재 변경 사항에 따라 버전을 업데이트하고 커밋 생성, CHANGELOG.md가 갱신됨.
    2. pnpm install: 위에서 버전 변경으로 인해 package.json이나 pnpm-lock.yaml이 바뀌었기 때문에 의존성을 다시 설치함.
  2. release: "pnpm changeset publish"
    1. 변경된 패키지들의 새 버전을 npm에 배포
    2. 배포 후 github release를 자동으로 생성

이렇게 해주면 자동으로 npm package에 배포까지 완료된다.


이렇게 만든 패키지 ....... 다른 레포에서 사용하는 법은 ?

pnpm add -D @helper-robotics/eslint-config

설치 후 eslint.config.js 파일 생성

import eslintConfig from '@helper-robotics/eslint-config';export default [	...eslintConfig,]

하면 ~ 잘 동작하는 것 알 수 있다.
Pasted image 20250910175249


사실 커스텀을 목적으로 둔 것보다 모든 레포지토리에 동일한 eslint 설정을 적용하고자 하는 목적이 있어서 커스텀의 기능은 염두에 두지 않았다. 하지만 이 설정으로는 모든 환경에 적용 가능하다고 할 수 없을 것 같으니, 커스텀 방향도 고려해봐야 한다.

추가로 prettier-config도 패키지화 하려고 한다. prettier는 json 하나만 export 하면 되니 더 쉬울듯.

일단 끝!