뱅크샐러드 Web chapter에서 GitHub Action 기반의 CI 속도를 개선한 방법

뱅크샐러드 Web chapter에서 GitHub Action 기반의 CI 속도를 개선한 방법

이 글은 대부분의 웹 프론트엔드에서 사용하는 Node.jsNpm 을 사용하는 사례를 바탕으로 작성되었습니다.

안녕하세요 뱅크샐러드 웹 프론트엔드 엔지니어 조성동입니다. Web chapter 내 개발 과정의 많은 부분에서 GitHub Action을 활용하고 있습니다. action이 실행되고 이를 기다리는 시간은 지루하고 아깝습니다. 하나의 action이 3분이 걸린다면 20명의 동료들이 한 번씩만 사용한다고 하더라도 1시간이 허비될 수 있습니다. 이 시간을 최대한 줄이되 이보다 더 중요한 일에 집중하는 것은 어떨까요?

기존에 구성된 action을 캐싱을 활용하고 변경사항만 수행하도록 개선하여 action이 시작되고 종료되기까지의 시간을 40s대로 개선하는 과정을 공유드리며, 이 글이 생산성 향상에 관심이 많은 분에게 도움이 되셨으면 좋겠습니다.

예시

무엇이 문제일까요?

push가 될 때마다 test를 수행하는 하나의 workflow를 만든다고 가정해봅시다.

name: CI

on:
  - push

jobs:
  ci:
    runs-on: ubuntu-20.04
    steps:
      # 1. 현재의 commit HEAD에 위치하게 합니다.
      - name: Checkout
        uses: actions/checkout@v3

      # 2. Node.js를 사용합니다.
      - name: Setup Node.js
        uses: actions/setup-node@v3

      # 3. 의존성을 설치합니다.
      - name: Install Dependencies
        run: npm ci

      # 4. 테스트를 수행합니다.
      - name: Run test
        run: npm run test

위 예시는 평범해 보입니다. 하지만 많은 의존성을 가지고 있으며 설치가 완료되는데 5분이 걸린다고 한다면 다른 step을 제외해도 최소한 5분 이상의 시간이 걸린다고 예상할 수 있습니다.

의존성 캐싱

하지만 우리는 변경사항을 추가할 때 항상 의존성을 함께 변경하진 않습니다. 의존성에 변경사항이 있는 경우에만 새로 설치하고 그렇지 않을 때는 이전에 이미 설치했던 의존성을 재사용할 수는 없을까요?

네 할 수 있습니다. 아래는 actions/cache를 활용하여 아래처럼 의존성을 캐싱하는 예시입니다.

name: CI

on:
  - push

jobs:
  ci:
    runs-on: ubuntu-20.04
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3

      - name: Cache dependencies
        id: cache
        uses: actions/cache@v3
        with:
          # cache의 대상을 정합니다. npm에서 의존성이 설치되는 디렉터리인 node_modules를 대상으로 합니다.
          path: '**/node_modules'
          # cache를 무효화하를 결정하는 기준은 의존성이 변경되면 함께 변경되는 파일인 package-lock.json을 기준으로 합니다.
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          # key가 유효하지 않은 경우 runner의 운영체제 값과 node라는 suffix를 key로 복구합니다.
          # 결과적으로 package-lock.json이 변경되지 않았다면 캐싱된 node_modules를 사용합니다.
          # 만약 복구될 캐시가 없다면 아래에서 사용할 cache-hit는 false가 됩니다.
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install Dependencies
        # 이전의 cache가 없다면 의존성을 설치합니다.
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci

      - name: Run test
        run: npm run test

의존성이 많을수록 캐싱을 받아오는 시간도 비약적으로 늘어나지만 20s 안팎으로 의존성 설치가 완료됩니다. 캐싱이 되지 않을 때는 1분 이상이 걸리는 것과 대조적입니다.

캐싱되기 전 (1m 8s 소요)

캐싱이 수행되지 않음

캐싱된 후 (21s 소요)

캐싱이 수행됨

한계

하지만 위 내용도 아래와 같은 한계가 존재합니다.

  • GitHub Action은 각 브랜치의 job마다 새로운 가상머신(runs-on에 명시된 운영체제)이 생성됩니다. 따라서 생성된 직후인 첫번째 commit에는 cache가 없으며 이후 두번째 commit 부터 cache가 존재하여 이를 활용할 수 있습니다.
  • runner의 최대 저장공간과 저장 기간은 plan에 따라 제한이 있습니다(e.g. Free plan: 10GB). 따라서 생성된 cache가 저장공간의 크기를 넘어서게 되면 오래된 cache부터 순차적으로 자동 삭제됩니다.
  • workflow의 event type이 deployment 일 때는 cache를 사용할 수 없습니다.
deployment일때는 캐싱 안됨

job 분리

우리는 하나의 job에 여러 step을 작성할 수도 있고 여러개의 job으로 분리할 수도 있습니다. 아래 예시는 하나의 job에 lint와 test 그리고 build를 모두 수행합니다.

하나의 job과 여러개의 step

name: CI

on:
  - push

jobs:
  ci:
    runs-on: ubuntu-20.04
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3

      - name: Cache dependencies
        id: cache
        uses: actions/cache@v3
        with:
          path: '**/node_modules'
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install Dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci

      # 각 step를 순차적으로 수행합니다.
      - name: Run lint
        run: npm run lint

      - name: Run test
        run: npm run test

      - name: Run build
        run: npm run build

위 예시는 lint가 끝나면 test를 실행하고 그 다음 build를 실행합니다. 만약 이 세가지 스크립트의 순서가 반드시 보장되야한다면 위처럼 해야할 수도 있습니다(또는 job을 나누고 needs를 활용할 수도 있습니다 ). 하지만 위 예시에서 각 step의 순서가 반드시 보장될 필요가 없다면 순차적으로 진행되지 않아도 됩니다.

여기서 만약 lint step이 실패했다고 가정해봅시다. 그렇다면 다른 step의 결과는 확인하지 못하는 문제가 생깁니다. 그리고 하나의 step이 걸리는 시간이 아무리 길어도 뒤에 있는 step은 이를 기다려야 합니다. lint를 수정하는 커밋을 추가하고 다시 test, build가 성공적으로 마치길 기대하지만 이번엔 test에서 실패할 수 있습니다. 그렇다면 다시 test를 수정하는 커밋을 추가하고 기다리게 됩니다.

그렇다면 앞에 있는 step이 실패하더라도 다른 step들의 결과를 확인할 수 있고 기다리지 않게 하려면 어떻게 해야할까요?

job으로 나누어 병렬로 실행

name: CI

on:
  - push

jobs:
  lint:
    runs-on: ubuntu-20.04
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3

      - name: Cache dependencies
        id: cache
        uses: actions/cache@v3
        with:
          path: '**/node_modules'
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install Dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci

      - name: Run lint
        run: npm run lint

  test:
    runs-on: ubuntu-20.04
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3

      - name: Cache dependencies
        id: cache
        uses: actions/cache@v3
        with:
          path: '**/node_modules'
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install Dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci

      - name: Run test
        run: npm run test

  build:
    runs-on: ubuntu-20.04
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3

      - name: Cache dependencies
        id: cache
        uses: actions/cache@v3
        with:
          path: '**/node_modules'
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install Dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci

      - name: Run build
        run: npm run build

job을 분리한 위 예시는 어떤가요? 이전의 lint, test, build를 각 job으로 분리하였습니다. 그에 따라 각각의 runner가 할당되므로 의존성 설치하는 step도 존재해야 합니다.

각 step이 10s가 걸린다고 가정해봅시다. 처음 보여드린 예시(step을 이용한 순차적 실행)에서는 30s가 걸릴 것이라 예상할 수 있습니다. 하지만 job을 분리하여 병렬로 실행하면 각 step은 병렬로 수행되기에 모든 CI 결과를 보는 시간은 10s가 걸립니다. 또한 가장 처음에 보여드렸듯이 각 job의 의존성 설치는 해당 브랜치의 두번째 commit 부터 캐싱을 사용하기에 속도가 더욱 빨라집니다.

의존성 설치의 step은 한번만 수행되게하고 각 step이 병렬로 실행될 수 있다면 위 내용도 더 개선할 수 있습니다. 하지만 아직 이 부분은 GitHub Community에서 지원하고 있지 않습니다(discussion).

비용

job이 추가될 때마다 각 job을 실행하기 위한 가상머신 생성 시간이 늘어나기 때문에 각 runner들의 수행시간의 총합은 job으로 분리하기 전보다 길어질 수 있습니다. 기본적으로 한달에 10GB, 2,000분까지는 무료로 사용 가능하며, 사용하고 있는 GitHub Plan에 따라 과금 될 수 있습니다(billing for GitHub Actions).

이 글을 작성하는 현재 환율 기준으로는 무료 사용량을 초과한 1분을 초과할 때마다 약 10원의 과금이 이루어집니다.

변경된 사항만 테스트하기

모든 내용을 작업의 대상으로 수행하지 않고 변경사항만을 대상으로 하도록 구성할 수도 있습니다. lint의 경우 git diff를 활용해 변경사항만을 확인하여 수행하도록 구성할 수 있습니다. test의 경우 Jest를 사용한다면 쉽습니다. Jest는 이러한 내용을 자체적으로 지원하며 changedSince option을 활용할 수 있습니다.

  "scripts": {
    "lint:changed-since": "eslint $(git diff --name-only --diff-filter=d origin/main | grep  -E \"(.js$|.ts$|.tsx$)\")",
    "test:changed-since": "jest --changedSince=origin/main",
  },
name: CI

on: [push]

jobs:
  build:
    runs-on: ${{ matrix.os }}
      # checkout하는 github ref의 tree에 비교 대상이 존재해야합니다. (e.g. origin/main을 찾을 수 있어야 함)
      - name: Checkout
        uses: actions/checkout@v3
        # 0을 대입하여 모든 github ref를 읽을 수 있도록 합니다.
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v3

      - name: Cache dependencies
        id: cache
        uses: actions/cache@v3
        with:
          path: '**/node_modules'
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install Dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci

      # 변경사항을 대상으로 수행
      - name: Run test (changedSince)
        run: npm run test:changed-since

      - name: Run test (changedSince)
        run: npm run lint:changed-since

어떠한 변경사항을 가지고 있느냐에 따라 수행시간은 비례하여 증가하거나 감소합니다. 작은 변경사항에 대해서는 lint step에서는 26s 감소하고 test step에선 2m 18s 가 감소하는 모습을 볼 수 있었습니다.

(좌) 변경사항만을 대상으로 수행, (우) 모든 파일을 대상으로 수행

변경사항만 수행

Nx 활용

하지만 위의 예시는 지속적으로 스크립트를 관리해야할 필요가 있고 실수할 여지가 있습니다. 이러한 부분은 더 전문적인 도구에 의존하는 것도 좋은 방법이 될 수 있습니다.

Nx는 모노레포에 활용되는 도구이며 프론트엔드 개발환경의 많은 부분들을 지원해주고 있습니다. 모노레포를 위한 도구이지만 하나 또는 적은 수의 프로젝트에서도 충분히 활용할만한 가치가 있습니다. 개발하며 직면하는 복잡한 환경구성들을 신경쓰지 않게 해줌과 동시에 Lint, Test, Build 등의 대부분의 CI에서 수행하는 동작들의 결과를 저장하고 이를 캐싱하여 활용할 수 있도록 도와줍니다.

name: app - CI

on:
  push:

jobs:
  test:
    name: Test
    runs-on: [self-hosted, default]
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3

      - name: Derive appropriate SHAs for base and head for `nx affected` commands
        uses: nrwl/nx-set-shas@v2

      - name: Cache dependencies
        id: cache
        uses: actions/cache@v3
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install Dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GH_WEB_PACKAGES_TOKEN }}

      # 변경사항에 영향을 받는 프로젝트에 대해서만 테스트를 수행하며 이전에 캐싱한 내용이 있다면 이를 활용
      - name: Test
        run: npx nx affected --target=test --parallel=3
        # lint, build도 물론 가능합니다.
        # run: npx nx affected --target=lint --parallel=3
        # run: npx nx affected --target=build --parallel=3

현재의 커밋과 그전의 커밋을 비교

nx를 활용한 캐싱_1

다시 수행할 필요가 없다면 캐싱된 결과물을 사용

nx를 활용한 캐싱_2

캐싱 데이터는 Nx Cloud에 자동으로 저장되며 매달 500시간 동안의 계산 시간을 무료로 제공합니다.

캐싱은 정말 어려운 주제이고 잘못 수행되기 십상입니다. Nx는 이 내용을 내부적으로 C++로 작성된 모듈과 Node.js로 작성된 코어 로직에 의해 많은 계산을 수행합니다. 이러한 복잡한 내용을 직접 관리하고 만들 수도 있겠지만 이를 Nx에 위임하고 변경사항의 영향을 받는 내용만 다시 빌드하고 다시 테스트 하도록 구성할 수도 있습니다.

요약

  • 의존성을 캐싱하여 이를 설치하는 시간을 줄일 수 있습니다.
  • 순서가 보장되야하는 것이 아니라면 job을 병렬로 실행하여 더 빠르게 결과를 확인할 수 있습니다.
  • lint와 test step는 변경사항만 수행되도록 구성할 수 있습니다.
  • Nx를 활용하여 캐싱에 대한 많은 부분을 위임할 수 있습니다.

보다 빠르게 뱅크샐러드에 도달하는 방법 🚀

지원하기
Share This:

Featured Posts

post preview
post preview
post preview
post preview
post preview

Related Posts