eslint config 분리

2025.09.10

강력하게 참고한 블로그

각 프로젝트마다 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.js
import { 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 Packages

on:
  push:
    branches:
      - main

jobs:
  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.png|Pasted image 20250910175249.png]]


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

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

일단 끝!