NDC Oslo 2025 - 빌드 파이프라인이 느리고 답답한 진짜 이유 (그리고 해결법)
여기 아주 좋은 유튜브 강연이 있는데요, 개발자라면 누구나 공감할 '빌드 파이프라인'에 대한 내용이라 이 강연의 핵심만 전체적으로 살펴볼까 합니다.
발표자인 Ronis는 소프트웨어 컨설턴트로 일하면서 정말 많은 회사의 빌드 파이프라인을 봐왔다고 하더라고요.
그런데 대부분의 회사들이 이 파이프라인을 소홀히 다루고, 그 결과는 고스란히 개발자들의 고통으로 이어진다는 거죠.
그래서 오늘은 우리 개발자들의 삶을 더 행복하게 만들어 줄, 몇 가지 파이프라인 패턴과 안티패턴에 대해 깊이 있게 다뤄보려고 합니다.
왜 파이프라인을 개선해야 할까?
발표자는 세 가지를 줄이는 데 집중하자고 제안하는데요.
첫째는 '좌절감'입니다.
파이프라인 실패했는데 '재시도' 버튼 눌러서 해결된 경험, 다들 있으시죠?
이건 정말 기분 나쁜 경험이잖아요.
둘째는 '지루함'이에요.
느린 파이프라인은 그냥 기다리는 시간 낭비이거나, 아니면 다른 작업을 시작하게 만들어서 'Work In Progress(WIP)'만 늘리게 되죠.
켄트 벡이 '익스트림 프로그래밍'에서 '10분 빌드'를 강조했는데, 현실은 1시간, 심지어 4시간이 넘는 경우도 허다합니다.
빌드가 4시간 걸리면 하루에 메인 브랜치에 코드를 반영할 기회가 딱 두 번뿐이라는 거거든요.
이건 생산성에 정말 치명적이죠.
마지막으로, 이건 비즈니스와도 직결되는 문제인데요.
생산성 높은 개발자가 더 많은 가치를 만들고, 이건 곧 더 나은 비즈니스로 이어집니다.
'Accelerate'라는 책에서도 과학적으로 증명된 사실이라고 하니, 이건 이제 거스를 수 없는 진리예요.
보너스로, 불필요한 파이프라인 실행을 줄이면 CPU 사용량이 줄어들어 환경 보호에도 기여할 수 있습니다.
안티패턴 1: 의식 (The Ritual)
첫 번째 안티패턴은 바로 '의식'처럼 모든 걸 항상 다 해버리는 파이프라인입니다.
처음엔 간단하게 시작했을 거예요.
백엔드, 프론트엔드 테스트를 돌리고 통합 테스트로 마무리하는 거죠.
그런데 코드를 머지하고 나니 예상치 못한 빌드 실패가 발생하더라고요.
그래서 머지 후에도 똑같은 파이프라인을 돌리기 시작합니다.
그러다 외부 시스템 연동 문제로 또 빌드가 깨지니, 이제는 매일 밤마다 같은 파이프라인을 돌리게 되죠.
여기에 프로덕트 매니저가 실행 가능한 파일을 요청해서, 빌드와 테스트가 끝난 뒤에 아티팩트를 만드는 과정까지 추가됩니다.
이제 똑같은 파이프라인이 PR, 머지, 야간 빌드에서 계속 반복적으로 실행되는 거예요.
하지만 잘 생각해보면 이건 엄청난 낭비거든요.
PR에서 굳이 아티팩트를 만들 필요도 없고, 야간 빌드에서 매번 유닛 테스트를 돌릴 이유도 없죠.
패턴 1: 작업에 맞는 올바른 파이프라인 사용하기
그래서 필요한 게 바로 '작업에 맞는 올바른 파이프라인'입니다.
PR 파이프라인은 아티팩트 발행을 빼고, 머지 파이프라인은 모든 테스트를 실행하되 린팅은 생략하고, 야간 빌드는 외부 연동을 확인하는 통합 테스트만 실행하는 식으로 분리하는 거죠.
패턴 2: 조건부 파이프라인 단계
이걸 구현하는 좋은 방법이 바로 '조건부 실행'인데요.
예를 들어 PR 파이프라인에서 프론트엔드 코드만 수정했다면, 굳이 백엔드 테스트를 다시 실행할 필요가 없는 겁니다.
이럴 때 변경된 파일 경로를 감지해서 특정 작업만 실행하거나 건너뛸 수 있죠.
GitHub Actions에서는 보통 이런 식으로 구현하는데요.
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: 'Get changed files'
id: changed-files
uses: tj-actions/changed-files@v35
with:
files: |
backend/**
frontend/**
- name: 'Run backend tests'
if: steps.changed-files.outputs.any_changed == 'true' && contains(steps.changed-files.outputs.all_changed_files, 'backend/')
run: ...
이 코드는 tj-actions/changed-files
라는 액션을 사용해서 어떤 파일이 변경되었는지 확인하는 건데요.
backend/
폴더 아래에 변경이 감지되면 if
조건문이 참이 되어서 백엔드 테스트를 실행하게 되는 구조입니다.
만약 README.md
파일만 수정했다면, 파이프라인 전체를 건너뛰게 만들 수도 있겠죠.
안티패턴 2: 사재기 (Hoarding)
두 번째 안티패턴은 빌드 결과물, 즉 아티팩트를 영원히 보관하는 '사재기'입니다.
16년 전 화요일 밤에 만든 빌드가 언젠가 필요할지도 모른다는 생각에 모든 걸 저장하는 거죠.
하지만 실제로 2주 이상 지난 아티팩트를 사용하는 경우는 거의 본 적이 없어요.
이건 단순히 저장 공간 문제뿐만 아니라, 원하는 아티팩트를 찾기 어렵게 만드는 '발견 가능성'의 문제이기도 합니다.
발표자가 보여준 예시에서는 도커 레지스트리에 7,000개가 넘는 이미지가 있었는데, 검색 기능도 없어서 원하는 걸 찾으려면 63페이지를 수동으로 뒤져야 했다고 하더라고요.
그러니 제발, 더 이상 필요 없다고 확신이 들면 정리하는 스크립트를 꼭 만드세요.
생각보다 훨씬 빨리 정리해도 괜찮습니다.
안티패턴 3: 심미주의자 (The Aesthete)
이건 '실용적인 순서' 대신 '자연스러운 순서'를 고집하는 경우인데요.
보통 테스트 피라미드에 따라 유닛 테스트, 통합 테스트, E2E 테스트 순서로 파이프라인을 구성하죠.
이게 아주 자연스러워 보이거든요.
하지만 발표자는 TDD 스타일로 개발하기 때문에 유닛 테스트는 로컬에서 이미 다 통과시키고 커밋한다고 합니다.
그래서 파이프라인에서 실패하는 건 주로 깜빡하고 확인하지 못한 '통합 테스트' 단계라는 거죠.
그런데 통합 테스트가 실패했다는 피드백을 받기까지 14분이나 걸린다면, 이건 너무 느린 피드백 루프예요.
패턴 3 (실용적 순서): 실패 확률이 높은 것부터 실행하기
해결책은 간단합니다.
가장 자주 실패하는 통합 테스트를 맨 앞으로 옮기는 거죠.
물론 컴파일은 해야겠지만, 유닛 테스트나 린팅보다 먼저 실행해서 실패 피드백을 4분이나 더 빨리 받을 수 있게 된 겁니다.
이건 'fail-fast' 전략의 아주 좋은 예시죠.
여러분의 프로젝트에서 어떤 단계가 가장 자주 실패하는지 분석하고, 그 단계를 가능한 한 앞으로 배치하세요.
안티패턴 4: 절대반지 파이프라인 (The One Pipeline)
이건 모든 것을 순차적으로 처리하는 단 하나의 파이프라인을 사용하는 경우입니다.
백엔드 빌드하고, 프론트엔드 빌드하고, 통합 테스트를 돌리는 거죠.
이러면 총 25분이 걸릴 수 있는데요.
하지만 백엔드와 프론트엔드는 서로 독립적으로 빌드할 수 있잖아요?
패턴 4: 파이프라인 분리하고 병렬화하기
그래서 이걸 각각의 파이프라인으로 쪼개서 병렬로 실행하는 겁니다.
백엔드와 프론트엔드를 병렬로 빌드하고 테스트한 뒤, 두 결과물을 아티팩트로 만들어서 다음 단계인 통합 테스트 잡(job)에 전달하는 거죠.
이렇게 구조를 최적화하면, 총 실행 시간은 거의 그대로 유지하면서도 최종 성공 피드백을 받는 시간은 25분에서 12분으로 절반 이상 줄일 수 있습니다.
13분이나 일찍 피드백을 받는다는 건, 다른 작업을 시작할 필요 없이 바로 코드 리뷰를 요청할 수 있다는 뜻이죠.
안티패턴 5: 선물 포장 (Gift Wrapping)
이건 실제 커맨드라인 도구 대신, 그걸 감싸고 있는 '래퍼(wrapper) 태스크'를 사용하는 걸 말합니다.
특히 Azure DevOps 같은 플랫폼에서 흔히 볼 수 있는데요.
YAML 파일에서는 예쁘게 보일지 몰라도, 이 래퍼 태스크 안에서 무슨 일이 일어나는지 정확히 알 수 없다는 게 문제입니다.
만약 도구의 새로운 버전에서 추가된 특정 옵션을 쓰고 싶은데 래퍼가 지원하지 않으면 사용할 방법이 없거나, 이상한 방식으로 인자를 넘겨줘야 하죠.
발표자는 실제로 .NET 패키지 발행 태스크가 내부적으로 이상한 동작을 해서 패키지를 못 찾았던 경험이 있다고 하더라고요.
그냥 커맨드라인 명령어로 바꾸자마자 모든 게 정상적으로 작동했다고 합니다.
물론 Python 버전 설정 같은 '셋업'용 태스크는 유용하지만, 단순히 명령어를 실행하는 거라면 그냥 직접 명령어를 쓰세요.
숨겨진 '마법' 없이, 로컬에서 실행하는 것과 똑같이 동작해서 예측 가능하고 디버깅도 쉬워집니다.
안티패턴 6: 복잡성 (It's Complicated) & 얽힘 (Entanglement)
파이프라인이 그 자체로 하나의 거대한 소프트웨어 프로젝트가 되어버리는 경우입니다.
발표자가 겪은 최악의 사례는 5,000줄짜리 Groovy 라이브러리로 만들어진 Jenkins 파이프라인이었다고 해요.
수정하려면 Groovy 코드를 고치고, jar 파일로 패키징해서 Jenkins에 올리고, 설정 파일 버전 바꾸고, 다시 실행해야 했죠.
피드백 루프가 끔찍하게 길었던 겁니다.
이건 특정 CI 플랫폼에 너무 깊숙이 '얽히게(entangled)' 되는 문제로도 이어지는데요.
만약 Jenkins에서 GitHub Actions로 마이그레이션해야 한다면, 그 5,000줄짜리 Groovy 코드는 재앙이 되는 거죠.
패턴 5: 조합 가능한 파이프라인 라이브러리 & 추상화
해결책은 파이프라인 코드를 작고, 재사용 가능하며, 조합할 수 있는 단위로 만드는 겁니다.
그리고 더 중요한 건, 파이프라인의 핵심 로직을 CI 플랫폼의 YAML 파일이 아니라 Makefile
이나 셸 스크립트 같은 외부 도구로 옮기는 거예요.
CI 플랫폼은 그냥 make ci
같은 명령어를 호출하는 역할만 하는 거죠.
이렇게 하면 가장 큰 장점이 생기는데요.
바로 파이프라인 전체를 로컬에서 실행하고 디버깅할 수 있다는 겁니다.
15분씩 기다리면서 커밋을 쌓아가는 대신, 내 컴퓨터에서 즉시 피드백을 받을 수 있으니 생산성이 폭발적으로 증가하겠죠.
안티패턴 7: 그라운드호그 데이 (Groundhog Day)
매번 모든 걸 처음부터 다시 시작하는 걸 말합니다.
요즘 CI 환경은 대부분 '일회성(ephemeral)'이라, 빌드가 시작될 때마다 깨끗한 새 서버에서 코드를 받고, 모든 도구를 설치하고, 의존성을 다운로드하거든요.
그리고 빌드가 끝나면 그 모든 걸 그냥 버립니다.
이건 엄청난 시간 낭비죠.
패턴 7: 똑똑하게 캐싱하기
이 문제를 해결하는 열쇠는 바로 '캐싱'입니다.
그런데 많은 개발자들이 의존성 캐싱만 생각하는데요.
사실 요즘 같은 시대에 인터넷 속도가 빨라서 의존성 다운로드 시간은 그리 길지 않습니다.
발표자의 Rust 프로젝트 예시에서는 의존성 캐싱으로 고작 1.5초를 절약했다고 해요.
진짜 중요한 건 '빌드 결과물 캐싱'입니다.
Rust의 target
디렉토리, .NET의 obj
, bin
디렉토리처럼 컴파일된 결과물을 캐싱하는 거죠.
이렇게 하면 CI 서버에서도 로컬처럼 '증분 컴파일(incremental compilation)'이 가능해져서, 컴파일 시간을 5분 30초에서 2분 30초로 줄이는 등 엄청난 효과를 볼 수 있습니다.
도커 이미지를 빌드할 때도 마찬가지예요.
로컬에서는 레이어 캐시 덕분에 빌드가 빠른데, CI에서는 매번 처음부터 빌드하죠.
이때 buildx
같은 도구를 사용해서 '원격 캐시(remote cache)'를 지정해주면, CI에서도 레이어 캐시를 활용해 빌드 시간을 극적으로 단축시킬 수 있습니다.
안티패턴 8: 간섭 (Interference)
동시에 실행되는 여러 파이프라인이 서로 충돌하는 경우입니다.
가장 흔한 예는 도커 이미지를 빌드하고 :latest
태그를 붙이는 건데요.
1번 빌드가 이미지를 만들어서 :latest
로 푸시하고, 그 이미지로 테스트를 시작하려는 찰나에 2번 빌드가 새로운 이미지를 :latest
로 덮어써 버리는 거죠.
그럼 1번 빌드의 테스트는 2번 빌드의 이미지로 실행되면서 예상치 못한 오류를 뿜게 됩니다.
이럴 때 필요한 게 바로 '재시도' 버튼이죠.
패턴 8: 고유한 태그와 리소스 사용하기
해결책은 간단합니다.
메인 브랜치에 머지되기 전까지는 절대로 :latest
같은 고정된 태그를 사용하지 말고, Git 커밋 SHA처럼 항상 고유한 값을 태그로 사용하는 거예요.
테스트 데이터베이스를 공유해서 쓰는 경우에도 마찬가지입니다.
각 빌드마다 고유한 이름의 데이터베이스를 생성하고, 빌드가 끝나면 꼭 정리해주세요.
미래의 파이프라인: Dagger와 Earthly
마지막으로 발표자는 Dagger와 Earthly라는 두 가지 도구를 소개하는데요.
둘 다 도커의 빌드킷(BuildKit)을 기반으로 해서 강력한 캐싱 기능을 제공하고, 로컬에서 먼저 실행하고 디버깅할 수 있다는 큰 장점을 가집니다.
Dagger는 Python이나 Go 같은 프로그래밍 언어 SDK를 제공하고, Earthly는 Dockerfile과 Makefile을 합친 듯한 자체 DSL을 사용한다고 하네요.
CI 플랫폼 종속성에서 벗어나고 싶다면 주목해볼 만한 도구들입니다.
정리하며
결국 좋은 파이프라인은 개발자의 좌절과 지루함을 줄여주고, 더 나아가 비즈니스 성과와 환경 보호에도 기여하는 핵심 인프라라고 할 수 있겠네요.
오늘 소개된 여러 패턴과 안티패턴들을 참고해서, 여러분의 파이프라인을 한 단계 더 업그레이드해 보시면 어떨까요?
분명 개발 경험이 훨씬 더 즐거워질 거예요.