CI/CD 파이프라인은 왜 느려지는가
테스트 피라미드 배치부터 컨텍스트 재사용, 품질 게이트, 성능 회귀 감지, 보안 스캐닝까지 — 파이프라인 설계의 다섯 가지 결정을 추적한다.
- 01 CI/CD는 자동화 도구인가, 운영 철학인가
- 02 GitHub Actions의 설계 철학 — 격리, 계층, 명시성
- 03 Docker 이미지 빌드, 순서가 전부다
- 04 배포 전략의 진짜 선택 기준은 무엇인가
- 05 GitOps는 배포 도구가 아니라 감사 시스템이다
- 06 CI/CD 파이프라인은 왜 느려지는가
- 07 CI/CD 파이프라인의 다섯 가지 신호
CI/CD 파이프라인은 처음엔 빠르다. 그러다 어느 순간 60분짜리 빌드가 된다. 테스트가 간헐적으로 실패하고, 보안 스캐너가 경고를 쏟아내고, 성능이 언제 나빠졌는지 아무도 모른다. 이 모든 문제는 사실 하나의 질문으로 수렴한다 — 파이프라인의 각 단계에 무엇을 어느 순서로 배치할 것인가?
테스트는 피라미드대로 쌓아야 한다
단위 테스트 → 통합 테스트 → E2E 테스트. 이 순서가 파이프라인에서 지켜지지 않으면, 비싼 테스트가 싸게 잡을 수 있는 버그 앞에서 시간을 낭비한다.
단위 테스트는 10-50ms, 통합 테스트는 500ms-2s, E2E는 5-30s. 순차 실행하면 60분이 걸리는 파이프라인이, 단계별로 나누고 병렬 실행을 적용하면 25분으로 줄어든다.
jobs:
fast-tests: # 단위 테스트: 5분 이내
runs-on: ubuntu-latest
steps:
- run: ./gradlew test --parallel --max-workers=4
integration-tests:
needs: fast-tests # 단위 테스트 통과 후에만 실행
strategy:
matrix:
shard: [1, 2, 3] # 3개로 샤딩해 병렬 실행
원칙은 단순하다. 실패할 확률이 높은 빌드를 최대한 빨리 차단하라. E2E 테스트가 실패할 빌드라면, 단위 테스트에서 먼저 차단됐어야 한다.
Spring Boot 컨텍스트 재사용이 핵심이다
통합 테스트가 느린 가장 큰 이유는 Spring 컨텍스트를 매번 새로 로딩하기 때문이다. 컨텍스트 하나 로딩에 3초. 테스트 클래스가 50개라면, 컨텍스트 설정이 조금씩 달라 캐시가 계속 미스 난다면, 150초가 순식간에 날아간다.
컨텍스트 캐시의 키는 MergedContextConfiguration 객체의 해시다. @MockBean 하나만 추가해도 해시가 달라져 새 컨텍스트가 생성된다. 그래서 공통 베이스 클래스를 만들고, MockBean이 필요한 테스트만 별도로 분리해야 한다.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class BaseIntegrationTest {
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
@DynamicPropertySource는 컨테이너가 랜덤 포트로 뜨더라도 Spring 설정에 동적으로 주입해준다. 같은 베이스를 상속한 테스트들은 컨텍스트를 공유하므로 첫 번째 테스트만 3초가 걸리고, 이후는 0.2초다.
품질 게이트는 숫자가 아니라 신뢰다
JaCoCo 커버리지 60%, SpotBugs, Checkstyle, SonarQube. 이것들을 파이프라인에 넣는 이유는 숫자를 높이기 위해서가 아니다. 버그가 프로덕션에 도달하기 전에 자동으로 차단하기 위해서다.
커버리지 임계값을 너무 높게 설정하면 개발자가 로직 검증 없이 커버리지만 채우는 테스트를 작성한다. 신규 코드 80%, 서비스 패키지 75%, 유틸리티 60% 수준이 현실적이다.
SonarQube Quality Gate가 실패했을 때 continue-on-error: true로 처리하면 게이트가 아니라 리포트가 된다. 파이프라인을 실제로 차단해야 게이트다. 긴급 배포가 필요하면 예외 프로세스를 별도로 만들고, 예외를 쓸 때마다 기술 부채 이슈를 생성하게 한다.
성능 회귀는 조용히 쌓인다
“이번 PR에서 API가 느려졌나?”라는 질문에 답하려면 이전 기준(Baseline)이 있어야 한다. k6로 현재 브랜치를 테스트하고, main 브랜치의 마지막 성능 데이터와 비교하면 된다.
# 성능 저하율 계산
P95_DIFF=$(echo "scale=2; ($CURRENT_P95 - $BASELINE_P95) * 100 / $BASELINE_P95" | bc)
if (( $(echo "$P95_DIFF > 10" | bc -l) )); then
echo "❌ P95 performance regression: ${P95_DIFF}%"
exit 1
fi
P95가 10% 이상 나빠지면 파이프라인을 차단한다. 기준은 팀마다 다르게 잡을 수 있지만, 기준 자체가 없으면 성능 저하는 PR마다 조금씩 쌓이다가 어느 날 갑자기 장애가 된다. Google 연구에 따르면 응답 시간 1초 증가가 전환율 7%를 낮춘다.
보안은 배포 직전이 아니라 커밋 시점에 잡아야 한다
보안 취약점 대응의 비용은 발견 시점에 따라 기하급수적으로 달라진다. 커밋 시점에 Secret이 노출됐다면 git revert 한 번으로 끝난다. 프로덕션에 배포된 후라면 계정 교체, 감사, 알림이 뒤따른다.
4개 레이어로 방어한다.
- Trivy: Docker 이미지의 OS 패키지 및 라이브러리 CVE 스캔.
CRITICAL,HIGH발견 시 빌드 실패. - OWASP Dependency-Check: Gradle/Maven 의존성의 알려진 취약점. CVSS 7.0 이상이면 빌드 실패.
- TruffleHog: git 히스토리 전체에서 API 키, 토큰, 개인 키 패턴 탐지.
--only-verified로 검증된 토큰만 잡으면 거짓 양성이 줄어든다. - git-secrets: 로컬 커밋 훅. 커밋 전에 AWS 키, GitHub 토큰 등의 패턴을 차단한다.
정리
- 테스트는 빠른 것 먼저, 단계별로 통과한 것만 다음 단계로 보낸다.
- Spring 컨텍스트 재사용은 공통 베이스 클래스 +
@DynamicPropertySource로 구현한다. - 품질 게이트는 실제로 파이프라인을 차단해야 게이트다. 리포트만 생성하면 의미 없다.
- 성능 회귀는 Baseline 없이는 감지할 수 없다. PR마다 비교하라.
- 보안은 커밋 훅 → 빌드 스캔 → 이미지 스캔 순서로 최대한 앞단에서 잡는다.
파이프라인 설계의 핵심은 결국 하나다 — 문제를 가장 싸게 잡을 수 있는 시점에 배치하라.