홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

코드는 한 줄도 안 건드렸는데 모든 커밋이 막혔습니다 — Node 버전업과 require(ESM) 인터롭 함정

정기창·2026년 8월 2일

며칠 전까지 잘 되던 커밋이 어느 날 갑자기 전부 막혔습니다. 새 기능을 붙인 것도, 설정을 손본 것도 아니었습니다. 파일 하나를 고쳐 커밋하려 했을 뿐인데, pre-commit 훅이 제가 만진 적도 없는 파일들까지 전부 붙잡고 같은 에러를 뱉었습니다.

error  Definition for rule 'react-refresh/only-export-components' was not found  react-refresh/only-export-components

커밋 자체가 불가능했습니다. 그리고 이 에러는 제가 그날 건드린 코드와 아무 관련이 없었습니다. 처음 든 생각은 "설정 어딘가에 오타가 났나"였습니다. 에러 문구가 그렇게 읽혔으니까요. 규칙 이름을 못 찾겠다니, 규칙 이름을 잘못 적은 것처럼 보였습니다. 애석하게도 그 첫 짐작이 저를 한참 엉뚱한 곳에 붙들어 두었습니다. 범인은 설정이 아니라, 제가 그 주에 무심코 올린 Node 버전이었습니다.

증상: 만진 적 없는 파일까지 전부 실패했습니다

상황을 조금 더 적어 두겠습니다. 이 저장소는 모노레포이고, 커밋할 때 husky의 pre-commit 훅이 lint-staged를 부르고, lint-staged가 스테이징된 파일에 eslint --cache --fix를 돌립니다. 평소에는 조용히 지나가던 이 단계가, 이제는 손대는 파일마다 위 에러로 실패했습니다.

이상한 것은 실패의 범위였습니다. 제가 수정한 한두 파일만이 아니라, 린트가 훑는 파일이면 무엇이든 같은 규칙을 못 찾겠다고 했습니다. 규칙 하나가 통째로 사라진 것처럼 굴었습니다. 그러면서도 며칠 전 커밋에는 아무 문제가 없었습니다. 코드는 그대로인데 도구의 판단만 달라진 것입니다. 무언가 바뀌었다면 그것은 제가 아니었습니다.

이상한 점: 플러그인은 분명히 설치돼 있었습니다

규칙을 못 찾는다면 가장 먼저 의심할 것은 플러그인의 부재입니다. 그래서 node_modules를 확인했습니다. eslint-plugin-react-refresh는 멀쩡히 설치돼 있었습니다. 버전도 정상이고 폴더도 그대로였습니다. 설정 파일도 다시 읽어 봤지만 오타는 없었습니다.

// .eslintrc.cjs (legacy eslintrc)
module.exports = {
  extends: ['@your-scope/eslint-config/react'],
  plugins: ['react-refresh'],
  rules: {
    'react-refresh/only-export-components': 'warn',
  },
};

플러그인은 있고, 이름도 맞고, 규칙 선언도 그대로입니다. 그런데 ESLint는 그 규칙의 정의를 못 찾겠다고 합니다. 여기서 에러 메시지가 가리키는 방향과 실제 원인이 갈라지기 시작했습니다. "정의를 못 찾겠다"는 문구는 설정 문제를 가리키지만, 설정에는 아무 잘못이 없었습니다. 그렇다면 남는 것은 하나였습니다. ESLint가 저 플러그인을 불러오긴 했는데, 그 안에서 규칙을 꺼내지 못하고 있다는 것.

파고들기: require()가 실제로 무엇을 돌려주는지 뜯어봤습니다

추측을 멈추고, 플러그인을 직접 불러와 그 형태를 들여다보기로 했습니다. ESLint가 하는 것과 똑같이 require()로 불러온 뒤, 규칙이 담겨 있어야 할 .rules를 열어 봤습니다.

node -e "const p=require('eslint-plugin-react-refresh'); console.log(Object.keys(p.rules||{}))"
# → []   (비어 있음!)

비어 있었습니다. 플러그인은 로드됐는데 규칙 목록이 텅 빈 것입니다. 그러면 규칙은 대체 어디로 갔는가. 반환된 객체 자체의 키를 찍어 봤습니다.

node -e "const p=require('eslint-plugin-react-refresh'); console.log(Object.keys(p))"
# → [ '__esModule', 'default', 'reactRefresh' ]

node -e "const p=require('eslint-plugin-react-refresh'); console.log(Object.keys(p.default.rules))"
# → [ 'only-export-components' ]

여기서 갭이 드러났습니다. 최상위 .rules는 비어 있지만, .default.rules에는 문제의 only-export-components가 멀쩡히 들어 있었습니다. 규칙은 사라진 것이 아니라 한 겹 안쪽, .default에 갇혀 있었습니다. ESLint는 최상위 .rules에서 규칙을 찾는데 규칙은 그 자리에 없었으니, "정의를 못 찾겠다"고 한 것입니다. 에러 문구는 정직했습니다. 다만 제가 그 문구를 설정 오타로 읽었을 뿐입니다.

근본 원인: require(ESM) 인터롭이 default를 풀어 주지 않습니다

왜 규칙이 .default에 갇혔는가. 서로 무관해 보이는 세 가지 사실이 겹친 결과였습니다.

첫째, eslint-plugin-react-refresh는 ESM 전용 패키지입니다(0.5.x 계열). package.json에 "type": "module"과 exports가 선언돼 있고, CJS 빌드가 아예 없습니다.

{
  "type": "module",
  "exports": { ".": "./dist/index.js" }
}

둘째, 이 프로젝트의 ESLint 설정은 여전히 legacy eslintrc(.eslintrc.cjs)였습니다. ESLint 8은 플러그인을 require()로 불러옵니다. import이 아니라 require()입니다. 이 차이가 이야기의 핵심입니다.

셋째, 그 주에 저는 Node를 최신으로 올렸습니다(실측 26.5.0). Node 22.12부터 require()가 ESM 모듈을 불러올 수 있게 됐는데, 이때 돌아오는 값은 CJS의 module.exports가 아니라 모듈 네임스페이스 객체입니다. 그리고 export default {...}로 내보낸 값은 이 객체의 .default에 담깁니다. 최상위로 자동으로 풀리지 않습니다.

이 지점이 인터롭이라는 메커니즘이 예고한 결과와 정확히 맞아떨어집니다. require(ESM)이 네임스페이스 객체를 돌려주고 default를 풀어 주지 않는다면, 최상위 .rules를 읽는 도구는 아무것도 찾지 못합니다. ESLint 8은 require()가 곧 module.exports를 건네준다는 CJS 시절의 가정 위에서 쓰인 도구입니다. Node의 인터롭이 그 가정을 조용히 깨뜨린 것입니다. 로드 방식에 따라 반환 형태가 이렇게 갈립니다.

로드 방식돌아오는 값최상위 .rules
CJS require()module.exports (곧 규칙 객체)정상
require(ESM), Node 22.12+모듈 네임스페이스 객체비어 있음 (규칙은 .default.rules)
import (flat config)default가 풀린 값정상

그래서 "왜 하필 지금"의 답도 분명해졌습니다. 코드도 설정도 그대로였고, 바뀐 것은 Node 하나였습니다. 구 Node에서는 require(ESM)이 아예 예외를 던지거나 다르게 동작했습니다. 신 Node의 인터롭은 "성공은 하되 default를 풀지 않는" 쪽으로 바뀌었고, 그 결과 규칙이 빈 채로 조용히 통과됐습니다. 실패가 시끄러웠다면 오히려 빨리 잡았을 텐데, 이 인터롭은 조용해서 며칠을 끌었습니다.

해법: 다운그레이드로 막고, flat config로 끝냅니다

급한 불은 플러그인을 CJS 빌드가 있는 버전으로 내리는 것으로 껐습니다. eslint-plugin-react-refresh ^0.4.26에는 CJS 빌드가 있습니다. require()가 CJS를 불러오면 module.exports 자체가 규칙을 담은 객체이므로, 최상위 .rules가 정상적으로 채워집니다.

pnpm add -D eslint-plugin-react-refresh@^0.4.26

다만 이건 문제를 피한 것이지 푼 것은 아닙니다. 근본 해결은 flat config로 옮기는 것입니다. ESLint 9의 flat config(eslint.config.js)는 플러그인을 require()가 아니라 import으로 불러옵니다. import은 ESM의 default를 정상적으로 풀어 주므로, 규칙이 .default에 갇히는 일 자체가 생기지 않습니다.

// eslint.config.js (flat config)
import reactRefresh from 'eslint-plugin-react-refresh';

export default [
  {
    plugins: { 'react-refresh': reactRefresh },
    rules: {
      'react-refresh/only-export-components': 'warn',
    },
  },
];

legacy eslintrc를 버리는 순간 이 삼각 함정의 한 변이 사라집니다. 세 번째 선택지로 플러그인을 설정에서 아예 빼 버리는 방법도 있지만, 그건 HMR 관련 경고 규칙을 포기하는 대가라 저는 권하지 않습니다.

확인은 다시 node -e 한 줄이면 됩니다. 다운그레이드 뒤 최상위 .rules에 only-export-components가 다시 잡혔고, 커밋도 정상으로 돌아왔습니다.

남은 생각

이번 일로 몇 가지를 다시 새겼습니다.

먼저, Node 버전업은 코드가 아니라 로더를 바꿉니다. 제 소스는 한 글자도 안 변했는데, 모듈을 불러오는 방식이 달라지면서 도구 전체가 흔들렸습니다. 런타임을 올릴 때 우리는 흔히 "내 코드가 잘 도나"만 살피지만, 정작 바뀌는 것은 코드가 아니라 그 코드를 불러오는 층인지도 모르겠습니다.

다음으로, 에러 메시지가 정직하다고 해서 올바른 방향을 가리키는 것은 아닙니다. "Definition for rule not found"는 거짓말을 하지 않았습니다. 규칙은 정말로 그 자리에 없었으니까요. 다만 그 문구는 설정을 의심하게 만들었고, 실제 원인은 모듈 로딩 한 겹 안쪽에 있었습니다. 이럴 때는 추측을 접고 node -e "require(...)"로 실제 반환 형태를 뜯어보는 것이 가장 빠른 길이었습니다.

끝으로, 이건 ESM 전용 패키지 · legacy require 기반 도구 · 새 Node가 겹쳐야만 나타나는 삼각 함정이었습니다. 셋 중 하나만 달랐어도 조용했을 것입니다. 그리고 하필 모노레포의 공유 config였기에, 한 번 발현되자 전 패키지의 커밋이 동시에 막혔습니다. 같은 증상을 만난다면, 아래 한 줄로 세 자리를 한꺼번에 비교해 보시길 권합니다. 인터롭 갭은 대개 즉시 드러납니다.

node -e "const p=require('eslint-plugin-react-refresh'); console.log(Object.keys(p), Object.keys(p.rules||{}), Object.keys(p.default?.rules||{}))"

패키지 이름만 바꾸면 다른 플러그인에도 그대로 씁니다. 최상위와 .rules와 .default.rules — 이 세 곳을 나란히 놓고 보면, 규칙이 어느 겹에 갇혀 있는지가 한눈에 보입니다.

ESLintNode.jsESMreact-refresh트러블슈팅모노레포디버깅

관련 글

CommonJS와 ESM — require와 import는 어떻게 다르게 동작하는가 (8편)

Node.js의 두 가지 모듈 시스템 — CommonJS의 require와 ESM의 import가 내부에서 어떻게 다르게 동작하는지 추적합니다. 로딩 시점, 캐싱, 순환 참조 처리의 차이를 살펴봅니다.

관련도 92%

삭제한 기능이 남긴 참조가 새 빌드를 죽였습니다

서버를 옮기며 오래된 코드를 새로 빌드하자, 잘 돌던 화면이 첫 렌더부터 하얗게 죽었습니다. 5개월 동안 배포하지 않은 main에 숨어 있던, 삭제된 기능이 남긴 dangling 참조를 추적한 기록입니다.

관련도 92%

Prettier + husky + lint-staged로 팀 코드 스타일 자동화하기

코드 스타일 논쟁을 없애고 git commit 시 자동으로 포매팅되는 환경을 구축한 경험. Prettier 설정부터 husky + lint-staged 연동, git-blame-ignore-revs까지 실제 적용 과정을 정리했습니다.

관련도 91%