메일함에 “Node.js build workflow run failed”라는 알림이 하나 와 있었다. 만들고 있는 Obsidian 플러그인 리포에서 온 거였다. 처음엔 블로그 배포 쪽인 줄 알고 놀랐다가, 다시 보니 전혀 다른 리포 — Obsidian 플러그인 쪽 GitHub Actions였다.
크래시부터 잡자
로그를 열어보니 평범한 lint 에러가 아니라 ESLint 자체가 죽어 있었다. Oops! Something went wrong!로 시작하는, 규칙 위반이 아니라 도구가 뻗은 흔적이 뚜렷한 메시지였다.
원인을 따라가 보니 eslint.config.mts에 물려 있는 eslint-plugin-obsidianmd의 권장 설정
안에, 타입 정보가 있어야만 동작하는 규칙 5개가 들어 있었다. 문제는 tsconfig.json의
include가 src/**/*.ts만 잡고 있어서, tests/**/*.mjs 파일들은 애초에 타입 정보를 받을
방법이 없다는 거였다. 타입 정보가 필요한 규칙이 타입 정보가 없는 파일을 만나니, 에러를
리포트하는 대신 그냥 죽어버린 거다.
첫 시도로 tests/**/*.mjs에 tseslint.configs.disableTypeChecked를 씌워봤는데, 크래시가
사라지지 않고 다른 파일로 옮겨갔다. disableTypeChecked는 @typescript-eslint/* 규칙만
꺼줄 뿐, eslint-plugin-obsidianmd처럼 타입 정보를 요구하는 서드파티 규칙까지는 안 건드리는
거였다. 결국 문제의 5개 규칙을 직접 찾아서 그 파일 범위에서만 명시적으로 꺼주고 나서야
크래시가 완전히 멈췄다.
{
files: ['tests/**/*.mjs'],
extends: [tseslint.configs.disableTypeChecked],
rules: {
'obsidianmd/no-plugin-as-component': 'off',
'obsidianmd/no-view-references-in-plugin': 'off',
'obsidianmd/no-unsupported-api': 'off',
'obsidianmd/prefer-file-manager-trash-file': 'off',
'obsidianmd/prefer-instanceof': 'off',
},
},
npm run lint를 돌려보니 더는 죽지 않고, 정상적인 (다만 꽤 많은) 문제 목록을 뱉어냈다.
크래시 때문에 중간에 멈춰 있던 lint가 처음으로 끝까지 돌면서, 그동안 가려져 있던 794개의
기존 문제가 한꺼번에 드러난 거다. 이건 범위가 큰 별개 작업이라 일단 크래시 수정만 커밋하고
넘어가기로 했다.
그런데 뭔가 이상하다
여기까지 하고 나니 오히려 궁금한 게 생겼다. 이 크래시는 하루이틀 사이에 생긴 게 아니라 꽤
오래 거기 있었을 텐데, 그럼 그동안 main.js는 어떻게 계속 멀쩡하게 빌드되고 있었지? 실제로
플러그인은 문제없이 잘 돌아가고 있었다. CI가 “실패”라고 떴다면 뭔가는 진짜로 실패했어야
하는데, 정작 결과물엔 아무 영향이 없었다.
workflow 파일을 열어보니
.github/workflows/lint.yml을 열어보고서야 이유를 알았다. 워크플로우 이름은
“Node.js build”인데, 정작 안에서 하는 일은 이랬다.
- run: npm ci
- run: npm run build --if-present
- run: npm run lint
build와 lint가 완전히 분리된 별개 스텝이었다. 그리고 package.json을 보면 build
스크립트 자체가 애초에 ESLint를 부르지 않는다.
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
tsc로 타입 체크하고 esbuild로 번들링하는 게 전부다. ESLint는 그 다음 스텝에서 완전히
독립적으로 실행된다. 그러니 build 스텝은 항상 정상적으로 끝나서 main.js가 매번 멀쩡하게
만들어졌고, 크래시는 오직 그 뒤에 오는 lint 스텝에서만 일어나고 있었던 거다. 워크플로우
전체는 마지막 스텝이 실패하면 “실패”로 표시되니까, 이름만 보고는 마치 빌드 자체가 깨진
것처럼 보였을 뿐이다.
배포용 release.yml은 애초에 lint 스텝이 아예 없어서, 실제 릴리스에도 영향이 없었다. 결국
사용자 입장에서 체감할 수 있는 문제는 하나도 없었고, 오직 CI 대시보드 위에서 빨간 X 하나만
떠 있던 상황이었다.
그럼 순서가 이대로 맞나
여기까지 정리하고 나니 다른 의문이 들었다. build와 lint를 지금처럼 나눠서 순서대로 돌리는 게 맞는 걸까, 애초에 lint가 build 안에 포함돼 있어야 하는 게 아닐까?
package.json을 다시 보면 build와 lint는 원래 서로 다른 스크립트다.
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"lint": "eslint ."
build는 컴파일과 번들링만 담당하고, lint는 별도의 정적 분석 스텝이다. 이렇게 나눠두는 건
자바스크립트/타입스크립트 쪽에서 흔한 구성이다. “결과물을 만드는 것”과 “코드 품질을 검사하는
것”을 의도적으로 분리해두면, 로컬에서 lint 경고가 남아 있어도 일단 빌드해서 동작부터 확인하고
싶을 때 lint 통과 여부가 빌드를 막지 않는다. 그러니 lint가 build 안에 끼워져 있지 않은 건
실수가 아니라 의도된 구조다.
다만 CI에서의 순서는 조금 다른 이야기다. lint는 보통 컴파일+번들링보다 훨씬 빨리 끝난다. 지금처럼 build를 먼저 돌리고 lint를 나중에 돌리면, lint에서만 걸리는 문제가 있어도 build가 끝날 때까지 기다려야 그 사실을 알 수 있다. 반대로 lint를 먼저 돌렸다면 더 빨리 실패 신호를 받았을 거다. 둘 사이에 실제 의존 관계는 없으니(lint는 build 산출물을 필요로 하지 않는다) 순서를 바꾼다고 뭔가 깨질 일은 없다 — 다만 지금까지는 거기까진 미처 신경 쓰지 못한 부분이다.
그렇다면 지금 당장 순서부터 바꿔야 할까? 그건 아니라고 판단했다. 지금은 이 크래시 때문에 가려져 있던 기존 lint 에러 794건이 그대로 남아 있는 상태라, lint는 어차피 항상 실패한다. 이 상태에서 순서만 바꾸면 “어느 스텝에서 먼저 실패하느냐”만 바뀔 뿐, CI가 통과하는 일은 없다. 순서를 바꿔서 얻는 이득(가벼운 검사를 먼저 돌려 빨리 실패 신호를 받는 것)은 lint가 평소엔 통과하는 정상 상태일 때만 의미가 있다. 그래서 결론은: 794건의 기존 lint 에러부터 다 잡고, 그 다음에 워크플로우 순서를 lint 먼저 → build로 바꾸는 것. 지금 순서를 먼저 바꾸는 건 원인과 결과가 뒤바뀐 셈이라 보류했다.
남는 교훈
“빌드가 실패했다”는 알림을 받았을 때, 실제로 실패한 게 뭔지 워크플로우 파일을 열어보기 전까진 확신할 수 없다는 걸 다시 확인했다. 워크플로우 이름과 실제로 그 안에서 도는 스텝은 다른 이야기다. 이번 경우엔 이름은 “build”였지만 크래시는 lint 스텝에서 났고, build 스텝은 lint와 아무 관계가 없었다.
증상만 보고 원인을 짐작하기보다, 워크플로우 정의를 먼저 열어서 어떤 스텝들이 순서대로 도는지, 그중 어디서 실패했는지부터 확인하는 게 결국 더 빨랐다.