← 개발 로그 목록

website: 백엔드 병렬도 버그 수정 + FE/BE 잡 분리로 CI 단축

/ 10분 분량 / 개발 로그

멋사 경희대 사이트 프로젝트에서 CI가 느려지는 문제를 파고들다가, 테스트 병렬도가 의도한 대로 동작하지 않고 있던 버그를 발견하고 고친 김에 프론트/백엔드 잡까지 분리한 PR입니다.

요약

이 PR은 dev 브랜치를 대상으로 올라갔고, 2026년 7월 21일에 생성되어 같은 날 바로 병합됐습니다. 변경된 파일은 .github/workflows/ci.yml과 backend/build.gradle 단 두 개뿐이지만, CI 실행 시간에 직접적인 영향을 주는 핵심 설정들이라 실질적인 체감 효과는 작지 않은 PR입니다. 핵심은 두 가지입니다. 하나는 백엔드 테스트 병렬도가 주석의 의도와 다르게 CI에서 절반만 쓰이고 있던 버그를 고친 것, 다른 하나는 프론트엔드만 바뀐 PR도 무거운 백엔드 스위트를 그대로 돌리던 걸 잡 분리로 정리한 것입니다.

배경 및 목적

PR 설명에 따르면 테스트 케이스가 늘면서 CI가 점점 느려지는 걸 체감하고 원인을 찾아본 것이 출발점입니다. 최근 런 로그를 뜯어보니 체크아웃, JDK, Gradle 세팅 같은 워크플로 오버헤드는 5초 안팎으로 미미했고, "빌드 + 테스트" 스텝이 전체 시간(3~4분)을 거의 다 잡아먹고 있었습니다. 즉 병목은 워크플로 자체의 문제가 아니라 테스트 실행 방식과, 굳이 돌 필요 없는 잡이 계속 도는 데 있었다는 거죠.

이 지점에서 두 가지 문제가 드러났습니다.

  1. backend/build.gradle의 maxParallelForks 설정이 주석과 실제 동작이 어긋나 있었습니다. 주석은 "CI(ubuntu-latest, 4vCPU)에 맞추되 상한 4"라고 되어 있는데, 실제 코드는 Math.max(1, Math.min(4, cores / 2))였습니다. CI 러너가 4코어라면 4 / 2 = 2가 되어버려서, 로컬용으로 만든 "절반만 쓰기" 규칙이 CI에도 그대로 적용되고 있었던 겁니다.
  2. .github/workflows/ci.yml에 트리거 경로로 frontend/**가 포함돼 있었지만 정작 잡은 build-and-test(백엔드 빌드+테스트) 하나뿐이었습니다. 그래서 프론트만 바뀐 PR(예시로 로그인 화면 PR #118을 들었습니다)도 3분대 백엔드 풀스위트를 그대로 돌리면서, 정작 프론트 코드는 아무런 검증도 받지 못하고 있었습니다.

구현 내용

1) 백엔드 테스트 병렬도 CI 분기

backend/build.gradle의 test 태스크 설정을 아래처럼 바꿨습니다.

// 로컬은 코어의 절반만 써서 과다구독을 피하고, CI(GitHub Actions)는 코어를 그대로 다 쓴다 —
// "절반" 규칙을 CI에도 그대로 적용하면 ubuntu-latest의 4vCPU가 2로 깎여 의도한 병렬도를
// 못 낸다(실측: 이전 공식으로 CI에서 실제 maxParallelForks=2였음).
def cores = Runtime.runtime.availableProcessors()
def isCi = System.getenv('CI') != null
maxParallelForks = isCi ? Math.max(1, cores) : Math.max(1, (int) (cores / 2))

GitHub Actions가 기본으로 제공하는 CI 환경변수로 분기해서, CI에서는 코어를 그대로(4개) 쓰고 로컬에서는 기존처럼 절반만 써서 과다구독을 피하는 방식입니다. PR 설명에 따르면 34개 테스트 중 22개가 @SpringBootTest인데, 이걸 forking 2-way에서 4-way로 돌리게 되면서 테스트 단계가 이론상 최대 절반까지 단축될 수 있다고 합니다. 인프라를 새로 늘린 게 아니라 원래 있던 CPU를 제대로 쓰게 만든 것뿐이라 공짜로 얻는 효과라는 설명이 인상적입니다.

2) FE/BE 잡 분리

.github/workflows/ci.yml은 기존 build-and-test 단일 잡 구조를 changes → backend/frontend → comment 구조로 재편했습니다.

먼저 dorny/paths-filter로 변경된 경로를 감지하는 changes 잡을 추가했습니다.

changes:
  runs-on: ubuntu-latest
  outputs:
    backend: ${{ steps.filter.outputs.backend }}
    frontend: ${{ steps.filter.outputs.frontend }}
  steps:
    - name: 변경 경로 감지
      uses: dorny/paths-filter@v3
      id: filter
      with:
        filters: |
          backend:
            - 'backend/**'
            - 'shared/**'
          frontend:
            - 'frontend/**'
            - 'shared/**'

그리고 이 결과에 따라 backend/frontend 잡이 조건부로 돌게 했습니다.

backend:
  needs: changes
  if: needs.changes.outputs.backend == 'true'
  ...

frontend:
  needs: changes
  if: needs.changes.outputs.frontend == 'true'
  ...
  steps:
    - name: Node 20 세팅
      uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: npm
        cache-dependency-path: frontend/package-lock.json
    - name: 의존성 설치
      working-directory: frontend
      run: npm ci
    - name: 린트
      working-directory: frontend
      run: npm run lint
    - name: 빌드 (타입체크 포함)
      working-directory: frontend
      run: npm run build

backend/**, shared/**가 바뀔 때만 기존 빌드+테스트가 돌고, frontend/**, shared/**가 바뀔 때만 새로 추가된 프론트 잡(npm ci → npm run lint → npm run build)이 돕니다. Next.js 빌드는 타입체크까지 포함하기 때문에 별도 타입체크 스텝 없이도 충분하다고 판단한 것 같습니다.

PR 코멘트를 남기는 로직도 손봤습니다. 기존에는 단일 잡의 성공/실패만 보고 코멘트를 달았는데, 이제는 백엔드/프론트엔드 각각의 실행 여부와 결과를 따로 집계해서 코멘트에 반영합니다.

const backendRan = '${{ needs.changes.outputs.backend }}' === 'true';
const frontendRan = '${{ needs.changes.outputs.frontend }}' === 'true';
const backendResult = '${{ needs.backend.result }}';
const frontendResult = '${{ needs.frontend.result }}';

const failed = (backendRan && backendResult !== 'success') || (frontendRan && frontendResult !== 'success');

돌지 않은 잡은 코멘트에서도 빠지고, 돈 잡만 ✅/❌로 표시되도록 만들었습니다.

변경된 파일

  • .github/workflows/ci.yml (+69/-6)
  • backend/build.gradle (+6/-2)

기술적 의사결정

병렬 실행 방식으로 JUnit5 네이티브 병렬 실행 대신 Gradle의 maxParallelForks(클래스 단위 별도 JVM 포크)를 그대로 유지한 점이 눈에 띕니다. build.gradle 주석에 이유가 남아있는데, @SpringBootTest 클래스마다 자기 JVM과 자기 jdbc:sqlite::memory: 인스턴스를 갖기 때문에, 같은 JVM 안에서 여러 스레드가 컨텍스트를 공유하는 JUnit5 네이티브 병렬 실행보다 격리가 확실하다는 판단입니다. 병렬도를 올리는 김에 실행 방식 자체를 바꿀 수도 있었지만, 안전성이 검증된 기존 방식을 유지하면서 병렬도 계산 로직만 고친 건 리스크를 최소화하는 선택으로 보입니다.

경로 필터링 도구로는 dorny/paths-filter를 선택했는데, GitHub Actions에서 가장 널리 쓰이는 검증된 액션이라 별도 스크립트를 직접 짜지 않고도 안정적으로 변경 경로를 감지할 수 있었을 것 같습니다.

배운 점 및 개선점

가장 인상적인 부분은 코드 리뷰나 버그 리포트가 아니라 "CI 로그를 직접 뜯어보는" 방식으로 문제를 찾아냈다는 점입니다. 워크플로 오버헤드가 5초 안팎이라는 걸 먼저 확인하고, 그렇다면 병목은 테스트 실행 자체에 있다는 식으로 범위를 좁혀간 접근이 체계적입니다. 그 과정에서 주석과 실제 코드가 어긋나 있는, 흔히 지나치기 쉬운 버그(/2 규칙이 CI에도 그대로 적용된 것)를 잡아낸 것도 좋은 사례입니다. 코드 리뷰 시 주석만 믿고 넘어가기 쉬운 부분인데, 실측(CI에서 실제 maxParallelForks=2였다는 것)까지 확인하고 고친 점이 특히 신뢰가 갑니다.

PR 설명에 있는 Test plan을 보면, 이 PR 자체(백엔드만 변경)에서 backend 잡만 돌고 frontend 잡은 스킵되는지, 머지 후 프론트 전용 PR에서 반대로 동작하는지, 그리고 실제 빌드+테스트 시간이 줄었는지를 체크리스트로 남겨뒀습니다. 워크플로 YAML의 실제 동작은 이 PR이 병합되어 이후 실제 PR들에 CI가 돌아봐야 확인 가능하다는 걸 솔직하게 인정하고 있는 부분도 눈여겨볼 만합니다. 설정을 고치는 걸로 끝나는 게 아니라, 이후 실제 PR들에서 잡이 의도대로 스킵/실행되는지, 시간이 정말 줄었는지를 Actions 로그로 검증하는 후속 확인이 필요해 보입니다.