린트가 돌고 있지 않았다

아린·2026년 4월 22일

📝 TL;DR

저장소 품질 스캔 중에 .github/workflows 는 있지만 typecheck·lint 를 실제로 검증하는 잡이 없는 걸 발견했고, CI 를 추가하려고 로컬에서 ESLint 를 먼저 돌려보니 오래 잠복해 있던 위반 3건이 튀어나왔다.

🔍 상세

배경

레거시 개선 거리를 찾느라 프론트 저장소 여러 개를 훑었다. 의존성 버전, TypeScript strict 설정, CI 워크플로우 파일 수, any 타입 수, @ts-ignore 수, eslint-disable 수, 큰 파일 분포 같은 지표를 뽑고 "어디가 가장 손볼 여지가 많은가" 를 보려던 목적이었다.

결과를 표로 정리하다 한 저장소가 눈에 띄었다. 해당 저장소에는 워크플로우 파일이 2개뿐이었고 그것도 전부 PR 제목 검증과 머지 자동화였다. typecheck, lint, test 를 돌리는 잡이 아예 없었다😇

기능 변경이 잦은 저장소가 아니다 보니 여러모로 방치되었던 것 같은데,
아예 CI 가 빠져 있는 줄은 몰랐다.(내 얼굴에 침뱉기인듯ㅜㅜ)

"그래도 Jenkins 에서 뭔가 돌지 않나?" 를 파봤다

처음엔 GitHub Actions 에 없어도 Jenkins 배포 파이프라인에서 린트가 돌 거라고 믿었다. 그런데 Jenkins 배포 스크립트를 뜯어보니 순서가 이랬다.

run "Install"     "yarn install --immutable --immutable-cache"
run "Build"       "yarn env-cmd ... craco build"
run "Check Build" "yarn env-cmd ... kn-check-cra-build"
ssh_deploy_server

명시적 lint 단계가 없었다. "그럼 craco build 안에서 CRA 가 내부적으로 ESLint 를 돌리니까 거기서 걸러지지 않을까?" 싶었는데 이것도 제약이 있었다.

  • CRA 내부 ESLint 는 react-app 프리셋만 강제로 사용한다. 프로젝트 package.json 에 깔려있는 @typescript-eslint/* 플러그인 규칙들은 이 경로에서 적용되지 않는다.
  • CI=true 환경변수가 있어야 warning 이 error 로 승격된다. 없으면 warning 으로만 출력되고 빌드는 성공한다.
  • Jenkins 는 보통 CI=true 를 자동 설정하지만, 승격된다 해도 그물 자체가 좁은 프리셋이라 프로젝트에 있는 더 엄격한 규칙은 빠져나간다.

즉 어느 경로에서도 실제로 원하는 수준의 린트가 돌지 않고 있었다.

검사 경로돌긴 했나잡았나
로컬 craco startO (react-app 프리셋)X, @typescript-eslint/* 는 프리셋 밖
Jenkins craco buildO (동일)X, 동일
Jenkins 명시 lint 단계X(해당 없음)
GitHub Actions CIX(해당 없음)

잠복한 위반 3건

로컬에서 ./node_modules/.bin/eslint src --ext .ts,.tsx,.js,.jsx 를 직접 돌렸더니 곧바로 3개의 에러가 떴다. 모두 @typescript-eslint/return-await 규칙 위반이었다.

// 수정 전
export default async function getData(params) {
  return await fetchApi(params);
}

// 수정 후 (--fix 로 자동 수정)
export default async function getData(params) {
  return fetchApi(params);
}

return await 는 대부분의 경우 불필요한 한 단계를 더 거친다. async 함수 안에서 return somePromise 는 Promise 를 그대로 호출자에게 넘겨주는데, return await somePromise 는 한 번 풀어서 값을 받고 다시 Promise 로 감싸서 돌려준다. 호출자 입장에서 결과는 같지만, 안에서 try/catch 로 reject 를 잡고 싶을 때만 await 가 실제로 의미가 있다.

async function f() {
  try {
    return await apiCall();  // reject 가 여기서 잡힘
  } catch (e) {
    handle(e);
  }
}

try/catch 없는 곳에서 return await 는 마이크로태스크 한 번 추가 + 의도 혼란만 남긴다. 이번 3건 모두 try/catch 밖이었고 --fix 로 안전하게 제거됐다.

게이트는 "치우고 나서" 설치

다음 순서로 진행했다.

  1. 기존 위반을 먼저 정리. 자동 수정 가능했기 때문에 eslint --fix 한 방으로 해결!
  2. 그 다음 CI 워크플로우 추가. 새 위반이 들어오는 순간부터 막도록!

만약 순서를 뒤집었다면, CI 를 추가한 첫 날부터 3건의 과거 위반이 파이프라인을 막아서 아무도 머지를 못 한다. 규모가 3건이 아니라 수백 건이었다면 autofix 대량 적용, 정말 못 고칠 건 파일 단위 예외 처리 같은 순차적 작업이 필요해진다. 벌써 아찔하다.

워크플로우는 병렬 + 좁은 트리거

ci.yml 은 typecheck 와 lint 를 두 개의 병렬 job 으로 분리했다. 한 job 안 스텝으로 묶지 않은 이유:

  • 병렬 실행으로 총 소요 시간 단축
  • 한쪽이 실패해도 다른 쪽 결과를 끝까지 볼 수 있어 원인 파악 빠름

on.pathssrc/**, package.json, yarn.lock, tsconfig*.json, .github/workflows/ci.yml 에만 반응하도록 제한했다. README 같은 문서만 바꾼 PR 에 CI 크레딧을 태우지 않는다.

concurrency.group: ${{ github.workflow }}-${{ github.ref }} 로 같은 브랜치에 연속 커밋이 들어오면 이전 실행을 취소한다. group${{ github.workflow }} 만으로 두면 A 브랜치 CI 가 돌고 있을 때 B 브랜치에 커밋해도 A 까지 취소되므로 조합이 중요하다.

주의할 점

  • "CI 가 있다 = 검증이 된다" 는 착시. 실제 어떤 잡이 무엇을 검증하는지 워크플로우 내용까지 들여다봐야 한다. 파일 수만 봐서는 알 수 없다.
  • CRA 의 내부 ESLint 는 react-app 프리셋 고정. 프로젝트에 @typescript-eslint/* 같은 추가 규칙을 깔아도 빌드 타임에는 일부만 적용된다. 독립 lint 스텝이 필요하다.
  • 게이트 도입 시 기존 위반 정리와 게이트 활성화를 한 PR 에 묶는 게 아니라 순서 있는 2개 커밋으로 분할하면, 나중에 revert 나 blame 이 훨씬 깔끔하다.

💡 적용 포인트

  • 저장소 건강 체크할 때 .github/workflows/ 파일 개수 만 보고 안심하지 말기. 실제로 타입체크·린트·테스트·e2e 중 무엇이 돌고 있는지 내용까지 확인하기.
  • CRA 프로젝트에서 CI 게이트 도입할 때는 로컬 eslint 를 먼저 돌려서 잠복 위반부터 발굴하기. autofix 로 해결되는 건 단일 커밋으로 빠르게 정리.
  • 게이트 설정 자체는 ci.yml 몇 줄로 끝나지만, 도입 순서가 전부다. 기존 위반 청산 → 게이트 설치 → 이후 회귀 차단.
  • 다음 프로젝트 맡을 때는 먼저 워크플로우에 뭐가 들어있고 어떻게 동작하는지부터 확인해보면 좋겠다.

📚 참고

profile
💻 FE Developer

0개의 댓글