ESLint Flat Config 마이그레이션 실패 일지와 살아남는 체크리스트
ESLint 9의 flat config로 넘어가면서 extends가 사라지고, 플러그인 호환성 문제, VS Code ESLint 확장과의 설정 불일치, 글로벌 변수 선언 방식 변화 등 현장에서 마주치는 장애물을 해결 순서대로 정리한다. 삽질을 줄이는 실전 체크리스트. 2025년 4월, ESLint 9가 정식 릴리스되면서 파일은 deprecated 경고를 넘어 아예 무시되기 시작했다.
DevInsight 편집팀 발행
AI 보조 초안과 편집 검수를 거쳐 발행했습니다.
2025년 4월, ESLint 9가 정식 릴리스되면서 .eslintrc 파일은 deprecated 경고를 넘어 아예 무시되기 시작했다. 마이그레이션 가이드를 읽고 npx @eslint/migrate-config .eslintrc.cjs를 돌렸다. 결과물은 겉보기에 그럴듯했다. 터미널에서 npx eslint .을 치자 통과. 문제는 VS Code에서부터 시작됐다.
빨간 줄이 전혀 뜨지 않았다.
eslintrc가 죽었다고 extends가 사라진 건 아니다
flat config에서 가장 먼저 혼란을 주는 지점은 extends 키워드의 소멸이다. 기존 설정이 이랬다면:
// .eslintrc.cjs module.exports = { extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint'], };
마이그레이션 도구는 이걸 eslint.config.mjs로 풀어내는데, extends 체인이 사라지면서 발생하는 부작용이 하나 있다. extends는 단순히 규칙을 복사하는 게 아니라, 플러그인이 제공하는 configs 객체를 참조해 프로세서, 언어 옵션, 파서까지 함께 주입한다. flat config에서 이걸 직접 ...tseslint.configs.recommended처럼 스프레드해야 하는데, 여기서 languageOptions.parser가 누락되는 경우가 흔하다.
마이그레이션 도구가 생성한 설정을 보면 @typescript-eslint 플러그인을 객체 임포트로 바꾸면서 파서 참조를 plugin.configs.recommended 안에 묻어두는데, 이게 다른 플러그인과 충돌하면 파서가 날아간다. 실제로 eslint-plugin-react와 조합했을 때 VS Code에서 JSX 파싱이 깨지는 현상을 겪었다. languageOptions.parserOptions.ecmaFeatures.jsx가 true로 설정됐음에도 파서가 espree로 롤백된 탓이다.
해결은 명시적이다. tseslint.configs.recommended를 스프레드한 뒤에도 languageOptions.parser를 직접 한 번 더 지정한다. 중복이 아니라 보험이다. flat config는 마지막에 적용된 값이 이긴다.
플러그인은 plugins에 넣었는데 왜 규칙은 못 찾을까
flat config 이전에는 plugins: ['react']라고 선언한 뒤 rules: { 'react/jsx-boolean-value': 'error' }라고 쓰면 규칙을 찾았다. 플러그인이 글로벌 네임스페이스에 등록됐기 때문이다. flat config에서는 플러그인을 직접 임포트해서 plugins: { react: reactPlugin }처럼 객체로 넘겨야 하는데, 공식 문서의 코드 샘플을 그대로 베꼈는데도 규칙을 못 찾는 상황이 생긴다.
범인은 대부분 eslint-plugin-react 같은 전통적인 플러그인을 flat config 호환 래퍼 없이 그냥 가져오는 경우다. import reactPlugin from 'eslint-plugin-react'로 가져온 객체에 configs 프로퍼티가 없다면, 해당 플러그인의 flat/recommended 같은 flat config 전용 엔트리포인트를 대신 써야 한다. eslint-plugin-react는 eslint-plugin-react/configs 경로로 flat config를 제공 중이고, @typescript-eslint는 typescript-eslint 패키지의 config() 헬퍼를 권장한다.
문제는 이 사실을 모르고 plugins에 넣기만 하면 규칙 네임스페이스는 살아있지만 configs 체인이 끊긴 상태가 된다는 점이다. CLI에서는 조용히 통과하지만 VS Code ESLint 확장(2.x)은 이 불일치를 잡아내지 못한다. 파일을 열 때마다 "규칙을 찾을 수 없음"이라는 경고를 표시하거나, 더 나쁜 경우 모든 규칙을 무시하고 아무 오류도 표시하지 않는다.
검증 방법은 간단하다. npx eslint --print-config src/index.ts를 실행해서 최종적으로 병합된 설정을 덤프해본다. rules 섹션에 react/jsx-boolean-value가 없으면 플러그인 연동이 실패한 거다.
VS Code ESLint 확장이 말썽인 진짜 이유
마이그레이션 후 터미널에서는 잘 되는데 에디터에서만 조용해지는 패턴. 이건 설정 문제가 아니라 ESLint 확장의 내부 동작 방식 차이다.
VS Code ESLint 확장 2.x는 작업 디렉터리에서 ESLint 인스턴스를 로드하는데, eslint.config.mjs가 있으면 자동으로 flat config 모드로 전환한다. 여기서 함정은 확장이 사용하는 eslint 버전과 프로젝트의 로컬 eslint 버전이 다를 때 발생한다. 확장이 번들링한 ESLint 8.x를 쓰면서 flat config를 인식하지 못하는 경우, 아무 경고 없이 린트가 비활성화된다.
확인 순서는 이렇다. 먼저 cmd+shift+p → "ESLint: Show Output Channel"을 열어서 ESLint 서버 로그를 본다. "Flat config is not supported with ESLint v8.x" 같은 메시지가 있으면 확장이 오래된 ESLint를 사용 중이다. VS Code 설정에서 eslint.useFlatConfig를 true로 강제하고, eslint.nodePath를 프로젝트의 node_modules로 지정한다. 그래도 안 되면 VS Code를 워크스페이스 루트에서 재시작한다. ESLint 서버가 캐싱한 이전 설정을 물고 있으면 설정 파일을 바꿔도 반영되지 않는다.
또 하나. .eslintignore가 flat config에서는 무시된다. 대신 eslint.config.mjs의 ignores 배열을 글로벌 스코프에 선언해야 한다. 이걸 놓치면 node_modules와 dist까지 전부 린트 대상이 되어 VS Code 확장이 CPU를 100% 치는 상황이 연출된다. ignores: ['**/node_modules/**', '**/dist/**']를 가장 먼저 선언하지 않으면 다른 설정이 먼저 매칭돼서 ignores가 씹힌다.
globals와 env의 소멸이 만드는 사소한 폭발
.eslintrc의 env: { browser: true, node: true } 한 줄은 사실 엄청난 양의 전역 변수를 주입한다. window, document, process, Buffer까지 수십 개의 예약어를 룰에서 제외해주는 역할이다. flat config에서는 env 키가 사라졌고, globals 패키지를 직접 임포트해서 languageOptions.globals에 넣어야 한다.
import globals from 'globals'; export default [ { languageOptions: { globals: { ...globals.browser, ...globals.node, }, }, }, ];
이걸 빠뜨리면 'process' is not defined나 'window' is not defined가 파일 열 때마다 수십 개씩 튀어나온다. 테스트 파일에서 describe, it, expect를 못 찾는 것도 같은 이유다. globals.jest 또는 globals.mocha를 테스트 전용 config 객체에만 선택적으로 주입하는 패턴으로 해결한다.
files 필드로 대상을 좁히는 게 flat config의 장점이지만, 동시에 실수하기 쉬운 지점이기도 하다. files: ['**/*.test.ts']로 테스트 전역 변수를 제한했는데 .spec.ts 파일은 방치하는 식이다. .test.ts와 .spec.ts를 모두 커버하려면 files: ['**/*.{test,spec}.ts']처럼 써야 한다. Glob 매칭에서 중괄호가 지원되지 않는 줄 알고 따로따로 객체를 만들었다가, 병합 순서가 꼬여서 한쪽이 덮어씌워지는 버그도 흔하다.
마이그레이션 직후 검증 루틴
체크리스트 하나로 정리한다. 각 항목이 false이면 그냥 넘어가면 안 된다.
npx eslint가 종료 코드 0으로 끝나는가? → CI에서 통과해도 플러그인 일부가 무시된 상태일 수 있으니--print-config로 최종 병합 결과를 반드시 확인한다.npx eslint --print-config src/index.ts | grep "react/"같은 식으로 플러그인별 규칙 존재 여부를 검사했는가? → 출력이 없으면 해당 플러그인은 마이그레이션에 실패한 거다.- VS Code ESLint Output Channel에 "Invalid config"나 "Failed to load plugin" 메시지가 없는가? → 터미널과 달리 VS Code 확장은 설정 오류를 조용히 삼키는 경우가 많다.
.eslintignore가.gitignore에 흡수된 줄 알고 ignores 설정을 안 한 건 아닌가? →ignores는 반드시 최상단 독립 객체로 선언한다.globals임포트를 했는가? →npx eslint --print-config | grep globals로 전역 변수가 주입됐는지 확인한다.- 모든 플러그인이 flat config 호환인가? →
eslint-plugin-import처럼 아직 flat config를 지원하지 않는 플러그인은compat레이어(@eslint/compat)의fixupPluginRules로 감싸거나,eslint-plugin-import-x같은 포크로 교체한다.
마지막 항목은 특히 함정이다. compat 레이어는 플러그인 내부의 context.getScope() 호출 같은 레거시 API를 flat config 모델에 맞게 중계해주는데, 이게 100% 동작한다는 보장은 없다. fixupPluginRules로 감싼 뒤에도 특정 규칙이 조용히 실패하는 경우가 있다. 이때는 포크로 갈아타든지, 아니면 ESLint 8을 유지할 기간을 정하고 점진적으로 옮기는 수밖에 없다.
마이그레이션의 진짜 적은 CLI가 아니라 조용한 실패다. 터미널에서 초록불이 떴다고 다 된 게 아니다. --print-config로 병합 결과를 확인하고, VS Code Output Channel을 열어 ESLint 서버 로그를 읽고, 플러그인별 규칙 존재 여부를 grep하는 세 단계를 습관화하지 않으면, 규칙이 절반만 적용된 채 몇 달을 지나칠 수 있다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
에이전트의 블랙박스를 노트북 위로 끌어내리는 법
AI agent가 실패하거나 조용히 비용을 태울 때, 필요한 건 더 긴 prompt보다 실행 흔적을 읽는 눈이다. Lookspan은 local-first 방식으로 span, 비용, replay, eval을 한자리에 묶어두며 AI agent 개발의 디버깅 루프를 훨씬 짧고 선명하게 만들 가능성을 보여준다.
타입스크립트 배포의 마지막 런타임을 지우는 실험
Rust 위에 SWC와 LLVM을 얹어 TypeScript를 곧바로 네이티브 실행 파일로 바꾸려는 시도는 단순한 성능 경쟁이 아니다. Node와 Electron 의존성을 덜어내고, 배포 단순화와 크로스플랫폼 전략을 다시 계산하게 만드는 도구로 읽을 만하다.
Decap CMS 유저가 Sveltia로 넘어올 때 가장 많이 묻는 질문
Netlify CMS의 진정한 후계자로 불리는 Sveltia CMS는 Svelte로 처음부터 새로 작성된 Git 기반 헤드리스 CMS다. 번들 300KB, GraphQL 즉시 로딩, 모바일 완벽 지원, 다국어 내장 등으로 기존 Decap CMS의 고질적 문제를 해결하며 2.6k 스타를 기록 중이다.
이전 글
1년 동안 DB 테이블로 큐 대신 쓰면서 내가 놓친 것들
다음 글
당신의 RLS 정책은 조용히 거짓말을 하고 있다
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.