ESLint 설정과 내부 동작: TypeScript·React·Prettier 조합, pre-commit, 규칙 엔진과 Autofix
이 글의 핵심
ESLint 9의 flat config(eslint.config.js)를 기준으로 팀 프로젝트에 설정하는 과정과 TypeScript·React·Prettier 조합, pre-commit 훅 연동, 그리고 AST 기반 규칙 엔진과 Autofix가 내부에서 어떻게 동작하는지 정리합니다.
ESLint는 처음 접했을 때 “빨간 줄”만 늘어나는 스트레스 도구로 보이기 쉽습니다. 하지만 팀 규모가 커질수록 규칙이 없는 코드베이스가 훨씬 더 위험하다는 사실을 체감하게 됩니다. 이 글에서는 ESLint가 실제로 어떤 일을 하는지, 왜 팀에 도입해야 하는지, 그리고 도입 과정에서 흔히 겪는 시행착오와 해결 방향을 정리합니다.
”ESLint 규칙 싸움”이 시작된 순간
한 팀에서 any를 습관적으로 쓰는 사람, ==를 고수하는 사람, console.log를 커밋에 그대로 남기는 사람이 한 코드베이스에 섞여 있는 경우를 자주 봅니다. 누군가 엄격한 규칙 셋을 PR에 올리는 순간 CI가 일제히 빨갛게 변하고, 슬랙에는 “이거 왜 갑자기 에러 나요?”라는 질문이 쏟아집니다. 그러면 eslint-disable을 남발하는 사람도 생기고, 로컬 설정에서만 규칙을 몰래 꺼버리는 사람도 나타납니다.
이런 혼란을 겪고 나서 얻은 결론은 “규칙 자체가 문제가 아니라, 한꺼번에 강하게 적용하려는 방식이 문제”라는 것입니다. 실무적으로는 다음 세 가지 원칙이 효과적입니다. 첫째, 처음부터 전부 error로 설정하지 말고 warn으로 시작해 점진적으로 강도를 높입니다. 둘째, 자동으로 고칠 수 있는 규칙은 --fix에 맡겨 사람이 신경 쓸 부분을 줄입니다. 셋째, 코드 포맷팅은 ESLint가 아니라 Prettier에 위임합니다. Prettier와의 역할 분담은 아래에서 자세히 다룹니다.
ESLint가 하는 일
ESLint는 자바스크립트(또는 TypeScript) 코드를 읽고, 팀이 합의한 규칙에 위반되는 부분을 찾아 표시해 주는 정적 분석 도구입니다. 동작 원리를 단계별로 보면, 먼저 파서(기본값은 Espree)가 소스 코드를 파싱해 AST(추상 구문 트리)를 만듭니다. 이후 각 규칙이 이 트리를 순회하면서 조건에 맞는 노드를 발견하면 context.report를 호출해 진단(diagnostic)을 발생시킵니다. --fix 옵션을 지원하는 규칙은 fixer 객체를 통해 해당 위치의 텍스트를 직접 수정하며, 지원하지 않는 규칙은 개발자가 직접 코드를 고쳐야 합니다.
여기서 중요한 점은 ESLint가 프로그램의 의미를 완벽하게 증명하는 컴파일러가 아니라는 것입니다. ESLint는 정적 힌트를 제공하는 도구에 가깝기 때문에, “이 코드가 왜 문제인지”를 팀의 룰북과 함께 이해하는 것이 훨씬 실용적입니다. 즉, ESLint 경고 자체를 목적으로 삼기보다는, 그 경고가 가리키는 팀의 합의를 이해하는 데 초점을 맞춰야 합니다.
처음 설정하기
ESLint를 프로젝트에 도입하는 가장 빠른 방법은 다음과 같습니다.
npm init @eslint/config@latest
이 명령은 대화형 프롬프트로 모듈 시스템, 프레임워크, TypeScript 사용 여부 등을 물어보고 필요한 패키지 설치와 초기 설정 파일 생성을 함께 해 줍니다. 예전 글에서 자주 보이는 npx eslint --init은 같은 초기화 도구를 호출하던 옛 방식입니다.
여기서 먼저 알아야 할 것은 설정 파일 형식이 바뀌었다는 점입니다. ESLint 9(2024년 4월)부터는 eslint.config.js(또는 .mjs/.cjs)를 쓰는 flat config가 기본이고, 예전의 .eslintrc.* 형식은 폐기 예정(deprecated) 상태입니다. ESLint 9를 설치한 프로젝트에 .eslintrc.json만 있으면 린트가 실행되지 않고 ESLint couldn't find an eslint.config.(js|mjs|cjs) file. 에러가 납니다. 인터넷의 설정 예제 상당수가 아직 옛 형식이라, 복사해 붙였는데 동작하지 않는 이유가 대부분 이것입니다. flat config는 다음처럼 생겼습니다.
// eslint.config.js
import js from "@eslint/js";
import globals from "globals";
export default [
{ ignores: ["dist/", "coverage/"] },
js.configs.recommended,
{
files: ["**/*.{js,mjs}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: { ...globals.browser, ...globals.node },
},
rules: {
"no-console": "warn",
"no-unused-vars": "error",
"prefer-const": "error",
},
},
];
flat config의 핵심 차이는 설정이 그냥 배열이라는 점입니다. extends에 문자열 이름을 적으면 ESLint가 node_modules에서 패키지를 찾아 이름 규칙대로 해석하던 방식 대신, 공유 설정을 import해서 배열에 직접 넣습니다. 배열의 뒤쪽 객체가 앞쪽을 덮어쓰고, files로 대상을 좁힐 수 있습니다. 이름 해석 규칙을 외울 필요가 없고, 어떤 설정이 어디서 왔는지가 코드에 그대로 드러납니다. env 대신 globals 패키지로 전역 변수를 지정하는 것도 달라진 부분입니다.
아직 옛 형식을 쓰는 프로젝트라면 설정은 대략 다음과 같은 형태이고, ESLint 8 이하이거나 9에서 ESLINT_USE_FLAT_CONFIG=false를 지정했을 때만 읽힙니다.
{
"env": { "browser": true, "es2021": true, "node": true },
"extends": ["eslint:recommended"],
"parserOptions": { "ecmaVersion": "latest", "sourceType": "module" },
"rules": {
"no-console": "warn",
"no-unused-vars": "error",
"prefer-const": "error"
}
}
어느 형식이든 eslint:recommended(flat config에서는 js.configs.recommended)를 넣는 것은 거의 표준적인 시작점입니다. 이 권장 세트는 no-undef, no-unreachable, no-dupe-keys처럼 거의 항상 버그를 뜻하는 규칙만 모아 두어서, 켜자마자 팀 논쟁이 생길 여지가 적습니다. package.json의 스크립트에는 eslint src와 eslint src --fix 정도만 등록해도 일상적인 개발 루프는 충분히 돌아갑니다.
규칙의 심각도는 문자열("off" | "warn" | "error") 대신 숫자(0, 1, 2)로도 표현할 수 있습니다. 팀이 “이건 에러까지는 아니다”라고 판단한 규칙은 warn으로 설정해 두는 것이 정신 건강에 좋습니다. 경고는 빌드를 막지 않으면서도 코드 리뷰에서 눈에 띄게 남기 때문입니다.
TypeScript, React와 함께 쓰기
TypeScript 프로젝트에서는 파서와 플러그인을 묶어 제공하는 typescript-eslint 패키지를 설치하는 것이 현재 권장 방식이고(예전에는 @typescript-eslint/parser와 @typescript-eslint/eslint-plugin을 따로 설치했습니다), React 프로젝트에서는 eslint-plugin-react-hooks를 추가하는 것이 일반적입니다.
// eslint.config.js (TypeScript + React Hooks)
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
export default tseslint.config(
{ ignores: ["dist/"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
plugins: { "react-hooks": reactHooks },
rules: {
...reactHooks.configs.recommended.rules,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
},
);
no-unused-vars를 끄고 @typescript-eslint/no-unused-vars를 켜는 부분은 TypeScript 프로젝트에서 흔히 빠뜨리는 설정입니다. 기본 규칙은 타입 문법을 이해하지 못해 타입으로만 쓰인 import나 인터페이스 선언을 “사용되지 않음”으로 잘못 보고합니다. tseslint.configs.recommended가 이 교체를 대신해 주지만, 규칙을 직접 다시 켜면서 기본 규칙까지 켜 버리면 같은 줄에 경고가 두 번 뜨고 하나는 오탐이 됩니다.
no-floating-promises처럼 타입 정보를 요구하는 엄격한 규칙을 쓰려면 타입스크립트 프로젝트를 ESLint에 연결하는 “type-aware” 린트 모드로 전환해야 합니다. typescript-eslint 8부터는 parserOptions.projectService: true가 권장 방식이며, 에디터의 TypeScript 서버와 같은 방식으로 파일마다 알맞은 tsconfig를 자동으로 찾아 줍니다. 예전 방식인 parserOptions.project에 tsconfig 경로를 직접 적으면, tsconfig의 include에 없는 파일(설정 파일, 스크립트 등)을 린트할 때 Parsing error: ... was not found by the project service 또는 The file must be included in at least one of the projects provided 같은 에러가 납니다. 이 모드는 타입 체커를 함께 구동하기 때문에 속도가 눈에 띄게 느려질 수 있습니다. 전체 파일에 무조건 적용하기보다는 overrides로 대상 범위를 좁히거나, CI에서만 실행되는 별도 스크립트로 분리하는 팀도 많습니다. 속도 저하의 원인을 파악하려면 TIMING=1 환경 변수를 붙여 실행해 어느 규칙이 시간을 많이 쓰는지 먼저 확인하는 것이 좋습니다.
Prettier와 함께 쓰기
여기서부터는 개인적인 의견을 조금 덧붙이겠습니다. 코드 포맷(줄바꿈, 세미콜론, 따옴표 스타일)은 Prettier에 맡기고, ESLint는 버그·코드 스멜·팀이 정한 논리적 제약에 집중하는 역할 분담이 장기적으로 훨씬 덜 피곤합니다. 두 도구가 같은 영역(예: 들여쓰기)을 각자의 방식으로 강제하면 저장할 때마다 서로 충돌하는 경험을 하게 되기 때문입니다.
따라서 eslint-config-prettier로 ESLint 쪽의 포맷 관련 규칙을 비활성화하는 설정이 흔히 쓰입니다. 이때 설정 목록의 맨 마지막에 넣어야 앞선 설정들의 포맷 규칙을 확실히 덮어쓸 수 있습니다. 참고로 ESLint 코어의 포맷 규칙(indent, semi, quotes 등)은 v8.53부터 폐기되어 @stylistic/eslint-plugin으로 옮겨졌으므로, 코어 권장 세트만 쓰는 프로젝트라면 충돌할 규칙이 거의 없습니다. 이 설정이 실제로 필요한 것은 포맷 규칙을 켜 두는 서드파티 공유 설정(예전 Airbnb 설정 등)을 함께 쓸 때입니다. 반대로 eslint-plugin-prettier를 사용해 “Prettier를 ESLint 규칙 하나로 실행”하는 방식은 실행 속도가 느리고 에러 메시지가 직관적이지 않은 경우가 있어, 개인적으로는 prettier CLI와 eslint를 완전히 분리해서 실행하는 조합을 선호합니다. 에디터에서는 저장 시 두 도구를 각각 실행하도록 설정하면 됩니다.
npm install -D prettier eslint-config-prettier
flat config에서는 배열 끝에 import한 설정을 추가하고, 옛 형식에서는 extends 배열 끝에 "prettier" 문자열을 넣습니다.
// eslint.config.js
import eslintConfigPrettier from "eslint-config-prettier";
export default [
// ...다른 설정들
eslintConfigPrettier, // 반드시 마지막
];
.prettierrc는 팀의 취향(세미콜론 사용 여부, 인용부호 종류, printWidth 등)만 정하고, 그 이후로는 포맷에 대해 더 이상 논쟁하지 않는 것이 규칙 싸움을 끊는 가장 확실한 방법입니다.
ignore 설정, 에디터 통합, pre-commit 훅
dist/, coverage/처럼 린트할 필요가 없는 디렉터리는 flat config의 ignores 필드로 제외합니다(node_modules/와 .git/은 기본으로 제외됩니다). 옛 방식의 .eslintignore 파일은 ESLint 9에서 더 이상 읽히지 않고 The ".eslintignore" file is no longer supported 경고만 나오므로, 마이그레이션할 때 내용을 ignores로 옮겨야 합니다. 이때 ignores만 가진 객체는 전역 무시로 동작하지만, 같은 객체에 rules나 files 같은 다른 키가 함께 있으면 그 객체의 적용 범위에서만 제외하는 뜻으로 바뀝니다. 빌드 산출물이 계속 린트된다면 이 차이를 먼저 확인하세요. VS Code에서는 저장 시 source.fixAll.eslint를 활성화해 두면 자동으로 고칠 수 있는 문제들이 저장과 동시에 정리됩니다. husky와 lint-staged를 조합해 커밋 직전에 변경된 파일만 eslint --fix와 prettier --write를 실행하는 패턴도 널리 쓰입니다.
이렇게 로컬에서는 변경된 파일만 가볍게 검사하고, 전체 코드베이스에 대한 린트는 CI에서 수행하도록 나누면 개발자 체감 속도와 코드 품질을 동시에 확보할 수 있습니다. CI에서는 eslint . --max-warnings 0으로 경고까지 실패 조건으로 삼을지, 아니면 warn은 통과시키고 error만 막을지를 팀 정책으로 명확히 정해 두는 것이 좋습니다.
규칙 엔진, AST, Autofix 내부 동작
조금 더 깊이 들어가 보면, ESLint 규칙은 create(context) 함수가 반환하는 visitor 객체로 구성됩니다. ESLint는 AST를 한 번 순회하면서 각 노드 타입에 맞는 visitor 함수를 호출하고, 그 안에서 조건을 검사해 문제를 발견하면 context.report를 실행합니다. fix 함수는 AST를 직접 재작성하는 것이 아니라, 소스 코드의 특정 범위(range)를 문자열 패치로 교체하는 방식으로 동작합니다. 이 차이를 이해하면 왜 일부 Autofix가 “완벽하지 않은” 결과를 낼 수 있는지도 짐작할 수 있습니다.
Autofix가 일부 문제만 고치고 나머지를 남기는 경우는, 여러 규칙의 수정 범위가 겹쳐서 충돌이 발생했거나, 해당 규칙 자체가 fix를 구현하지 않은 경우입니다. 이런 상황에서 eslint-disable-next-line으로 예외를 남길 때는 단순히 규칙만 끄지 말고, 이유와 관련 이슈 트래커 번호를 주석으로 함께 남기는 것이 좋습니다. 나중에 코드베이스에서 해당 예외를 검색(grep)할 때 맥락을 훨씬 빠르게 파악할 수 있기 때문입니다.
커스텀 규칙이나 플러그인을 직접 작성하는 단계까지 가게 된다면, package.json의 peerDependencies에 지원하는 ESLint 버전 범위를 정확히 명시해야 합니다. TypeScript 기반 규칙을 작성할 때는 @typescript-eslint/utils가 AST 노드 타입과 유틸리티 함수를 제공해 개발 경험을 크게 개선해 줍니다.
마무리
ESLint는 팀이 “이건 지양하자”고 합의한 내용을 코드 차원에서 강제하는 도구이고, Prettier는 코드의 “겉모습”을 통일하는 도구입니다. 이 둘을 올바르게 조합하면 리뷰에서 발생하는 빨간 줄이 줄어들고, “스타일 때문에 리뷰가 30분씩 걸리는” 상황도 크게 줄어듭니다.
물론 react-hooks/exhaustive-deps 규칙과 useEffect의 의존성 배열 때문에 골머리를 앓는 순간은 여전히 존재합니다. 그럴 때는 규칙을 바로 끄기보다는 먼저 의존성 배열을 실제로 맞춰보는 시도를 해보고, 정말 예외적인 상황이라면 이유를 주석으로 남기고 넘어가는 것을 권장합니다. 이 규칙이 경고하는 상황의 상당수는 실제로 오래된 값(stale closure)을 참조하는 버그이고, 경고를 끄는 대신 함수를 useCallback으로 감싸거나 effect 안으로 옮기면 경고와 버그가 함께 사라지는 경우가 많습니다.