goodchuck 블로그

모노레포 CI 에서 바뀐 앱만 검사하기 — turbo 필터, pnpm 필터와 무엇이 다른가

제 개인 모노레포에는 앱이 42개 있고, GitHub Actions 워크플로도 33개 있습니다. 그런데 33개가 전부 배포 · 릴리스용이었습니다. 머지 전에 도는 검사는 하나도 없었습니다. 로컬에서 돌려 본 결과가 전부였습니다.

그래서 PR 마다 검사를 붙였습니다. 앱이 많으니 전부 돌리면 느리고, 바뀐 앱만 골라 돌리는 게 핵심입니다.

  • turbo run … --filter="...[origin/dev]" 한 줄로 바뀐 앱과 그 앱에 기대는 앱만 검사합니다.
  • 앱 하나만 바꾼 PR 은 태스크 3개, 약 1분 10초에 끝났습니다.
  • 이미 깨져 있던 앱은 목록으로 빼서, 켜자마자 무관한 PR 이 빨갛게 뜨지 않게 했습니다.

쓰고 나서 "turbo 가 꼭 필요했나?" 싶어 pnpm 필터와 직접 비교도 해 봤습니다. 대부분 같았고, 차이는 한 군데였습니다.

바뀐 앱만 고르는 필터

turbo 와 pnpm 은 둘 다 git 기록을 보고 바뀐 패키지를 고르는 필터 문법이 있습니다.

pnpm turbo run lint check-types test --filter="...[origin/dev]"
부분뜻
[origin/dev]origin/dev 이후 파일이 바뀐 패키지
앞의 ...그 패키지에 기대는 패키지까지 (공유 설정을 바꾸면 그걸 쓰는 앱도)
뒤에 ... 를 붙이면그 패키지가 기대는 패키지까지

앞의 점 세 개가 중요합니다. 공유 eslint 설정만 바꿨어도 그 설정을 쓰는 앱들의 린트 결과가 달라질 수 있어서, 그 앱들까지 같이 돌아야 합니다.

워크플로

실제로 쓰는 ci.yml 의 핵심입니다.

on:
  pull_request:
    branches: [dev]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true # 같은 PR 에 새로 push 하면 앞 실행은 취소

permissions:
  contents: read # 검사만 하니 읽기 권한만

jobs:
  check:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@<커밋 SHA> # v4
        with:
          fetch-depth: 0 # base 와 견줄 기록이 있어야 바뀐 앱을 고른다
      - uses: pnpm/action-setup@<커밋 SHA> # v4
      - uses: actions/setup-node@<커밋 SHA> # v4
        with:
          node-version: 24
          cache: pnpm
      - run: pipx install uv # Python 앱용
      - run: pnpm install --frozen-lockfile
      - env:
          BASE: ${{ github.base_ref }}
        run: |
          pnpm turbo run lint check-types test --continue \
            --filter="...[origin/$BASE]" $KNOWN_BROKEN

몇 가지를 짚으면 이렇습니다.

  • fetch-depth: 0: checkout 은 기본으로 커밋 하나만 받습니다. 그러면 origin/dev 와 비교할 수 없어 필터가 실패합니다. 전체 기록을 받게 해야 합니다.
  • --continue: 앱 하나가 실패해도 나머지를 끝까지 돌립니다. 한 번에 실패 목록을 다 보려는 것입니다.
  • 액션은 커밋 SHA 로 고정: @v4 같은 태그는 나중에 다른 코드를 가리키게 바뀔 수 있습니다. 검사 워크플로는 PR 코드를 돌리니 더 조심합니다.
  • Python 앱도 같이: Python 앱에도 얇은 package.json 을 두고 "lint": "uv run ruff check ." 처럼 적어 두면, turbo 가 다른 앱과 똑같이 고르고 돌립니다. 그래서 러너에 uv 를 깔아 둡니다.

한 가지 빠뜨리기 쉬운 것이 있습니다. turbo.json 에 test 태스크가 없어서 turbo 로 테스트를 돌릴 수가 없었습니다. 태스크 이름이 turbo.json 에 있어야 turbo 가 그 스크립트를 실행합니다.

{
  "tasks": {
    "lint": { "dependsOn": ["^lint"] },
    "check-types": { "dependsOn": ["^check-types"] },
    "test": {}
  }
}

켜자마자 빨간불을 막는 법

검사가 없던 리포에 검사를 처음 켜면, 이미 깨져 있던 것들이 한꺼번에 드러납니다. 켜기 전에 로컬에서 전체를 한 번 돌려 봤더니 태스크 58개 중 39개만 성공했습니다.

그대로 켜면, 오래 손대지 않은 앱이 깨져 있다는 이유로 상관없는 PR 까지 실패합니다. 그러면 결국 아무도 CI 결과를 보지 않게 됩니다.

그래서 이미 깨진 앱을 목록으로 빼고, 목록 옆에 "고치면 여기서 지운다"고 적어 뒀습니다.

env:
  # 이 워크플로를 만들 때 이미 깨져 있던 앱 — 고치면 여기서 지운다
  KNOWN_BROKEN: >-
    --filter=!backtest-api --filter=!coin-research --filter=!painmap ...

turbo 필터에 !앱이름 을 주면 그 앱을 뺍니다. 린트 규칙 위반, 타입 오류, 실패하는 테스트가 섞여 있었고, 12개 앱이 이 목록에 들어갔습니다.

러너 환경 탓인 것도 있었습니다. 첫 실행에서 한 앱의 테스트가 실패했는데, 그 테스트가 ffmpeg 를 쓰는데 러너에 없었기 때문입니다. 자주 바뀌지 않는 앱이라, 매번 ffmpeg 를 까는 대신 목록에 넣었습니다.

실제로 돌려 보니

켠 날(11월 11일)의 실행 기록입니다.

PR고른 패키지태스크걸린 시간
CI 를 추가한 PR (turbo.json 도 바뀜)21개35개 성공약 4분 13초
앱 하나만 바뀐 PR1개3개 성공약 1분 10초

turbo.json 을 바꾼 PR 에서는 목록에서 뺀 앱을 빼고 전부 돌았습니다. turbo 는 자기 설정 파일이 바뀌면 모든 패키지가 바뀐 것으로 보기 때문입니다. 평소처럼 앱 하나를 고친 PR 은 그 앱만 돌았습니다.

turbo 가 꼭 필요했을까 — pnpm 필터와 비교

pnpm 도 같은 문법을 지원합니다.

pnpm -r --filter "...[origin/dev]" run lint

그래서 같은 리포를 복제해, 커밋을 하나씩 만들어 가며 두 필터가 고르는 패키지 수를 비교했습니다.

바꾼 파일turbopnpm
앱 하나 (apps/blog)11
공유 eslint 설정 (packages/eslint-config)1414
turbo.json33 (전부)0
루트 README.md00

앱과 공유 패키지가 바뀔 때는 똑같았습니다. 다른 건 루트 설정 파일 하나였습니다.

  • turbo 는 turbo.json 처럼 빌드 전체에 영향을 주는 파일이 바뀌면 전부 다시 돌립니다.
  • pnpm 필터는 패키지 폴더 안의 변화만 봐서, 루트 설정이 바뀌어도 아무것도 고르지 않습니다.

이 리포는 공유 패키지가 설정 둘과 UI 하나뿐이고, 앱끼리는 서로 가져다 쓰지 않습니다. 그래서 turbo 의 큰 장점인 의존 관계 추적이 여기서는 크게 다르지 않았습니다. 이미 turbo.json 이 있어서 turbo 를 썼지만, turbo 가 없는 pnpm 모노레포라면 pnpm 필터로도 충분합니다. 루트 설정이 바뀐 PR 만 따로 챙기면 됩니다.

하나 더 겪은 것이 있습니다. 리포 안에 만든 git worktree 폴더에서는 pnpm 필터가 패키지를 하나도 고르지 못했습니다. 일반 체크아웃과 복제본에서는 정상이었습니다. 원인은 아직 찾지 못했고, CI 러너는 일반 체크아웃이라 영향은 없었습니다.

남은 빈틈: 워크스페이스 밖 앱

이 CI 가 보지 못하는 앱도 있습니다. 앱 42개 중 12개는 pnpm 워크스페이스에서 빼 두었습니다. 주로 Electron 데스크탑 앱인데, 다른 앱과 의존성 버전이 충돌해 각자 lockfile 을 따로 갖고 있습니다.

워크스페이스 밖 앱은 turbo 도 pnpm 필터도 모릅니다. 이 앱들까지 검사하려면 바뀐 폴더를 직접 찾아, 그 폴더에서 따로 설치하고 돌리는 작업을 하나 더 두어야 합니다.

git diff --name-only origin/dev...HEAD | cut -d/ -f1-2 | sort -u
# 이 중 워크스페이스 밖 앱이면 그 폴더에서
pnpm install --ignore-workspace && pnpm test

아직 만들지 않았습니다. 이 앱들은 지금도 앱 폴더에서 직접 검사합니다.

정리

할 일방법
바뀐 앱만 고르기--filter="...[origin/dev]" (앞의 ... 로 기대는 앱까지)
비교할 기록 받기checkout 에 fetch-depth: 0
같은 PR 의 앞 실행concurrency 로 취소
이미 깨진 앱목록으로 빼고 "고치면 지운다"
turbo vs pnpm앱 · 공유 패키지는 같음, 루트 설정 변경만 다름
워크스페이스 밖 앱따로 찾아서 따로 검사해야 함

모노레포 CI 를 처음 붙인다면, 먼저 로컬에서 전체를 한 번 돌려 이미 깨진 것을 목록으로 만들어 두세요. 그 목록이 있어야 CI 가 켜진 첫날부터 믿을 만한 신호가 됩니다.