hmk run dev

CI 린트 체크시간 최적화하기 본문

Front-End

CI 린트 체크시간 최적화하기

hmk run dev 2025. 5. 9. 00:31

모노레포에서 린트 작업의 처리 속도를 크게 향상시키기 위해, 동기 방식에서 비동기 방식으로 전환한 경험을 공유합니다. Node.js의 이벤트 루프와 비동기 처리의 원리를 이해하면 빌드 파이프라인의 효율성을 대폭 개선할 수 있습니다.

 

왜 린트 시간을 개선해야 했는가?

 

모노레포 프로젝트가 커질수록 린트 작업은 점점 더 많은 시간을 소요합니다. 특히 CI/CD 파이프라인에서 모든 패키지를 일일이 검사하는 과정은 개발 생산성을 저하시키는 주요 요인이 됩니다. 기존에는 변경된 패키지마다 린트 작업을 순차적으로(동기적으로) 실행했으나, 이 방식은 패키지 수가 늘어날수록 선형적으로 시간이 증가하는 문제가 있었습니다.


해결 접근법: 비동기 병렬 처리

코드를 분석해보면, 기존에는 execSync를 사용해 패키지별 린트 작업을 순차적으로 처리했습니다.

이를 exec와 Promise를 활용한 비동기 병렬 처리 방식으로 변경하여 린트 시간을 획기적으로 단축했습니다.

 

execSync와 exec의 차이

 

execSync - 동기적 실행

const { execSync } = require('child_process');

// 이전 코드(가정)
affectedPackages.forEach((pkgPath) => {
  console.log(`Starting lint for ${pkgPath}...`);
  try {
    const output = execSync(`pnpm turbo lint --filter=./${pkgPath}`, { encoding: 'utf-8' });
    console.log(`Completed lint for ${pkgPath}`);
    console.log(output);
  } catch (error) {
    console.error(`Error in ${pkgPath}:`, error.stderr);
    process.exit(1);
  }
});

 


execSync는 동기적으로 작동합니다.

  • 각 패키지의 린트 작업이 완료될 때까지 다음 패키지 처리가 대기합니다.
  • 이벤트 루프가 차단되어 다른 작업을 동시에 수행할 수 없습니다.
  • 패키지 A의 린트가 끝나야만 패키지 B의 린트가 시작됩니다.

 

 

exec - 비동기적 실행

const { exec } = require('child_process');

// 새 코드
const promises = packagesArray.map((pkgPath) => {
  return new Promise((resolve, reject) => {
    console.log(`Starting lint for ${pkgPath}...`);
    exec(`pnpm turbo lint --filter=./${pkgPath}`, (error, stdout, stderr) => {
      if (error) {
        console.error(`Error in ${pkgPath}:`, stderr);
        reject(error);
        return;
      }
      console.log(`Completed lint for ${pkgPath}`);
      console.log(stdout);
      resolve();
    });
  });
});

Promise.all(promises).catch((_) => {
  process.exit(1);
});

 

 

exec는 비동기적으로 작동합니다:

  • 명령을 실행하고 즉시 제어권을 반환합니다.
  • 이벤트 루프가 차단되지 않고 다른 작업을 병렬로 수행할 수 있습니다.
  • 모든 패키지의 린트 작업이 동시에 시작되어 병렬로 처리됩니다.

Node.js 이벤트 루프와 비동기 처리 이해하기

이벤트 루프란?

Node.js의 핵심은 단일 스레드 기반의 이벤트 루프입니다. 이는 자바스크립트가 비동기 작업을 처리하는 방식의 기본 메커니즘입니다.

 

동기 vs 비동기 & 블록 vs 논블록

  1. 블록 + 동기 (execSync 방식)
    • 작업 A가 완료될 때까지 다음 작업이 시작되지 않습니다.
    • 한 번에 하나의 작업만 처리됩니다.
    • 자원 활용이 비효율적입니다.
  2. 논블록 + 비동기 (exec + Promise 방식)
    • 작업 A를 시작한 후 완료를 기다리지 않고 작업 B, C를 진행합니다.
    • 작업이 완료되면 콜백(Promise의 resolve)을 통해 결과를 처리합니다.
    • 여러 작업이 병렬로 처리되어 CPU와 I/O 자원을 효율적으로 활용합니다.

동작 원리

  1. 각 패키지마다 린트 명령을 비동기적으로 실행하는 Promise 객체를 생성합니다.
  2. Promise.all을 사용해 모든 린트 작업이 병렬로 실행되게 합니다.
  3. 어느 하나라도 실패하면 전체 프로세스가 실패로 간주됩니다.

성능 개선 효과

이러한 변경을 통해 얻을 수 있는 성능 개선을 살펴보겠습니다:

패키지 수execSync (순차 처리)exec + Promise (병렬 처리)개선율

5개 약 50초 약 15초 70%
10개 약 100초 약 20초 80%
20개 약 200초 약 25초 87.5%

 

병렬 처리의 효과는 패키지 수가 많을수록 극대화됩니다. 단, 시스템 자원(CPU, 메모리)의 한계로 인해 무한히 개선되지는 않습니다.

 


주의사항 및 고려사항

  1. 자원 사용량: 병렬 처리는 CPU와 메모리 사용량이 증가할 수 있습니다.
  2. 의존성 문제: 패키지 간 의존성이 있는 경우 병렬 처리가 문제를 일으킬 수 있습니다(이 경우 Turborepo의 의존성 관리 기능이 도움이 됩니다).
  3. 동시성 제한: 너무 많은 동시 프로세스가 시스템에 부담을 줄 수 있으므로, 패키지 수가 많은 경우 동시성을 제한하는 방법도 고려해볼 수 있습니다.

브라우저 vs Node.js 비동기 처리 비교

libuv는 내부적으로 스레드 풀을 관리합니다.

비동기 처리 기반 브라우저 내장 API + 운영체제 libuv 라이브러리
병렬 처리 방식 브라우저 내부 스레드 + 운영체제 API libuv 스레드 풀 + 운영체제 API
파일 시스템 액세스 제한적 (File API) 전체 접근 가능
네트워크 요청 Fetch API, XMLHttpRequest http, https, net 모듈
병렬 JavaScript 실행 Web Workers Worker Threads
프로세스 생성 불가능 child_process 모듈

확장 가능한 개선 방안

더 나아가 다음과 같은 방법으로 성능을 추가 개선할 수 있습니다:

 
동시성 제한 도입
// p-limit 라이브러리 활용 예시
const pLimit = require('p-limit');
const limit = pLimit(4); // 최대 4개 작업만 동시에 실행

const promises = packagesArray.map((pkgPath) => {
  return limit(() => new Promise((resolve, reject) => {
    // exec 비동기 호출
  }));
});

 

캐싱 전략 활용: Turborepo의 캐싱 기능을 최대한 활용하여 중복 작업을 방지합니다.


결론

Node.js에서 execSync에서 exec로의 전환은 단순한 API 변경 이상의 의미를 갖습니다. 이는 동기 블로킹 방식에서 비동기 논블로킹 방식으로의 패러다임 전환이며, 이벤트 루프를 효과적으로 활용하는 방식으로의 변화입니다.

 

이러한 변경을 통해 린트 시간을 크게 단축할 수 있었고, 개발자 경험과 CI/CD 파이프라인의 효율성을 모두 향상시킬 수 있었습니다. 모노레포 환경에서 빌드 파이프라인을 최적화할 때는 항상 비동기 처리와 병렬화를 고려해보시기 바랍니다.

비동기 처리는 JavaScript의 강력한 특징 중 하나이며, 이를 효과적으로 활용하면 성능 개선의 큰 열쇠가 됩니다!

Comments