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에 대해 알아봤다.
좀 더 정확하게 정리하하자면,
- Vite는 "개발용 + 번들러" 성격이 강하다. vite는 개발 서버에서 HMR을 제공하는 것이 핵심이다. 내부적으로는 nanobuild와 같은 esbuild를 사용하긴 하나, 번들링과 플러그인 시스템까지 갖춘 풀스택 빌드 도구이기 때문에 단순히 config만 빌드할 목적이라면 과할 수 있다.
- nanobuild는 경량화된 빌드 툴로, 특정 파일을 빠르게 번들링하거나 단순 빌드용으로 쓸 때 적합하다. 개발 서버나 HMR과 같은 기능은 없고, 오로지 빌드 속도와 최소 설정에 집중한다.
그으으으런데 현재까지는 eslint-config 만 패키지화 한 상황이고, config 파일은 항상 js 파일이다.
여기서 빌드가 필요한 경우를 고려해보자면 다음과 같다:
- typescript를 사용하는 경우: ts는 node.js가 바로 실행할 수 없으므로, tsc 또는 번들러로 .js 파일을 만들어야 한다.
- React 컴포넌트 라이브러리: JSX/TSX 문법이 포함되어 있으면 빌드해서 JS로 변환해야 한다.
- 최신 ES 문법:
import.meta,decorators,top-level await같은 문법을 사용하여 개발했는데 소비자 프로젝트가 해당 문법을 지원하지 않는 상황이 있을 수 있다. 따라서 다운 컴파일하는 경우 빌드 과정이 필요하다. - 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 파일을 분리하려고 함.
- base.js <- import order랑 prettier 설정 등
- react.js <- react 관련 설정
- 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 돌려가면서 해결을 했는데, 그 과정을 요약해달라고 했다.
- 문제 정의
- 초기 상태: React 프로젝트의 특정 컴포넌트(TimerSlot/index.tsx)에서 import 없이
useFryStore hook을 사용했음에도 불구하고, TypeScript 컴파일러(tsc)와 ESLint 모두 이
에러를 감지하지 못하는 문제가 발생했습니다. - 목표: tsc와 ESLint가 "정의되지 않은 변수 사용" 또는 "잘못된 import 경로"와 같은 에러를
올바르게 감지하도록 개발 환경을 설정하는 것.
- 디버깅 과정 및 시도
시도 1: import/no-unresolved 규칙 확인
- 가설: import 경로가 잘못되었을 때 발생하는 문제이므로, eslint-plugin-import의
import/no-unresolved 규칙으로 해결할 수 있을 것이다. - 조사:
- 공유 설정(@helper-robotics/eslint-config)을 확인한 결과, eslint-plugin-import는 사용
중이었으나 import/no-unresolved 규칙은 비활성화 상태였습니다. - 또한, 경로 별칭(@/)을 해석하기 위한 eslint-import-resolver-typescript 설정이
누락되어 있었습니다.
- 공유 설정(@helper-robotics/eslint-config)을 확인한 결과, eslint-plugin-import는 사용
- 실패 및 깨달음: 이 규칙을 활성화하는 것은 좋은 시도였지만, 이 규칙은 잘못된
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 에러를 감지하는 데
성공했습니다.
- 새로운 문제 발생 및 해결
- 새로운 문제: no-undef 규칙을 활성화하자, 이번에는 console, document, setInterval 등
브라우저에서 기본적으로 제공하는 전역 변수들까지 모두 에러로 처리되는 새로운 문제가
발생했습니다. - 원인: ESLint가 해당 코드가 브라우저 환경에서 실행된다는 사실을 몰라서, 브라우저의 기본
전역 변수들을 인식하지 못했기 때문입니다. - 해결: globals 패키지를 설치하고, eslint.config.mjs의 languageOptions에 globals.browser를
추가하여 브라우저 전역 변수들을 ESLint에 알려주었습니다.
- 최종 성공
- 최종 확인: 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 }}
순서
- main 브랜치에 push 되었을 때 실행
- node, pnpm dependencies 설치 및 빌드 과정
- 패키지 배포
배포에서는 크게 1) 버전 업데이트, 2) npm에 배포 이 과정을 수행한다.
- version: "changeset version && pnpm install"
- changeset version: 현재 변경 사항에 따라 버전을 업데이트하고 커밋 생성, CHANGELOG.md가 갱신됨.
- pnpm install: 위에서 버전 변경으로 인해 package.json이나 pnpm-lock.yaml이 바뀌었기 때문에 의존성을 다시 설치함.
- release: "pnpm changeset publish"
- 변경된 패키지들의 새 버전을 npm에 배포
- 배포 후 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 하면 되니 더 쉬울듯.
일단 끝!