오래 굴러온 Jenkins 파이프라인은 대개 하나의 거대한 생명체처럼 자라 있다. 수십 개의 Jenkinsfile, 손으로 설치한 플러그인 더미, 특정 노드에만 존재하는 도구 버전, 아무도 전체를 이해하지 못하는 공유 라이브러리(shared library). 저장소가 GitHub로 옮겨간 뒤에도 CI만 별도의 Jenkins 서버에 남아 있다면, GitHub Actions로의 이전은 언젠가 반드시 마주하는 과제가 된다.
문제는 이 마이그레이션을 “한 번에 갈아엎기”로 접근하는 순간 거의 실패한다는 점이다. 빌드가 멈추면 개발 전체가 멈추기 때문에, 현실적인 이전은 기존 Jenkins를 살려둔 채 잡을 하나씩 옮기고 결과를 대조하는 점진적 방식이어야 한다. 이 글에서는 개념 매핑부터 시크릿 이전, 셀프호스티드 러너, 병렬 검증, 함정까지 실전 로드맵을 정리한다.
먼저 개념부터 매핑한다
두 시스템은 이름만 다른 게 아니라 실행 모델이 다르다. Jenkins는 상태를 가진 장기 실행 서버이고 잡은 그 서버(또는 에이전트)의 워크스페이스에서 순차적으로 돈다. GitHub Actions는 이벤트 기반의 무상태 워크플로이며 잡마다 깨끗한 러너가 새로 뜬다. 용어 대응을 먼저 넣어두면 이후 작업이 훨씬 빠르다.
- Jenkinsfile → 워크플로 파일 (
.github/workflows/*.yml). 하나를 여러 워크플로로 쪼개는 편이 낫다. - stage → job: 각
job은 독립된 러너에서 병렬 실행된다. stage가 워크스페이스를 공유하던 것과 다르다. - agent/node → runs-on, shared library → composite action / reusable workflow
- credentials → secrets / OIDC, post 블록 → if: always() 스텝
가장 자주 발목을 잡는 건 두 번째다. Jenkins에서 build stage가 만든 산출물을 test stage가 그냥 참조하던 코드는, Actions에서는 job이 러너를 공유하지 않으므로 아티팩트로 명시적으로 넘기거나 하나의 job으로 합쳐야 동작한다.
선언형 Jenkinsfile을 워크플로로 옮기기
가장 흔한 선언형(declarative) 파이프라인부터 보자. 아래는 전형적인 예다.
// 기존 Jenkinsfile — 선언형 파이프라인
pipeline {
agent { label 'linux' }
environment { REGISTRY = 'registry.example.com' }
stages {
stage('Build') { steps { sh 'npm ci && npm run build' } }
stage('Test') {
steps { sh 'npm test' }
post { always { junit 'reports/*.xml' } } // 실패해도 리포트 수집
}
}
}
이를 GitHub Actions로 옮기면 다음과 같다. stage가 순차 의존이므로 하나의 job 안에 스텝으로 두거나, 독립 job으로 나누되 needs와 아티팩트로 연결한다. 산출물 공유가 잦다면 같은 job 안에 두는 편이 단순하다.
# .github/workflows/ci.yml
name: CI
on:
push: { branches: [main] }
pull_request:
env:
REGISTRY: registry.example.com
jobs:
build-and-test:
runs-on: [self-hosted, linux] # Jenkins의 label 'linux' 에 대응
steps:
- uses: actions/checkout@v4 # Jenkins는 자동 체크아웃, Actions는 명시 필요
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run build
- run: npm test
- name: 테스트 리포트 수집
if: always() # post { always } 대응 — 실패해도 실행
uses: actions/upload-artifact@v4
with: { name: junit-reports, path: reports/*.xml }
여기서 초심자가 가장 많이 놓치는 두 가지가 드러난다. 첫째, 체크아웃은 자동이 아니다. actions/checkout을 넣지 않으면 러너에 소스가 없다. 둘째, Jenkins의 post { always }는 if: always()로 표현하며, 이 조건이 없으면 앞 스텝이 실패한 순간 리포트 수집이 건너뛰어진다.
병렬 stage와 매트릭스
Jenkins의 parallel 블록(노드 버전별 병렬 테스트 등)은 매트릭스(matrix)로 옮기면 훨씬 간결하다. 각 조합이 별도 러너에서 병렬 실행된다. 이때 fail-fast: false가 실무에서 중요하다. 기본값 true는 한 조합이 실패하면 진행 중인 다른 조합을 취소해 버리는데, 이전 초기에는 “어떤 버전에서 깨지는지”를 전부 봐야 하므로 꺼두는 편이 낫다.
# 매트릭스로 대체 — 조합이 각각 별도 러너에서 병렬 실행
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # 한 조합 실패해도 나머지 계속 (전량 결과 확보)
matrix: { node: [18, 20, 22] }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ matrix.node }}" }
- run: npm ci
- run: npm test
시크릿과 크리덴셜 이전
Jenkins의 credentials() 헬퍼로 주입하던 값들은 GitHub Secrets로 옮긴다(${{ secrets.REGISTRY_PASS }}). 하지만 여기서 단순 이전으로 끝내지 말고 한 단계 업그레이드할 기회가 있다. 클라우드 배포에 쓰던 장기 액세스 키를 그대로 시크릿에 넣는 대신 OIDC 기반 단기 토큰으로 바꾸는 것이다. AWS라면 IAM에 GitHub의 OIDC 프로바이더를 신뢰하도록 등록해 두면 워크플로가 영구 키 없이 실행 시점마다 단기 자격증명을 발급받아, 유출 위험이 큰 장기 키를 아예 없앨 수 있다.
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC 토큰 발급에 필수
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: ap-northeast-2 # 정적 AK/SK 없음 — OIDC 로 role assume
- run: aws s3 sync ./dist s3://my-bucket --delete
여기서 OIDC 신뢰 관계를 좁게 거는 것이 핵심이다. IAM 신뢰 정책의 조건에서 sub 클레임을 repo:my-org/my-repo:ref:refs/heads/main처럼 특정 저장소·브랜치로 못 박아, 포크나 다른 브랜치가 그 역할을 가로채지 못하게 한다.
셀프호스티드 러너: 무상태 전제를 지켜라
특정 하드웨어, 내부망 접근, 라이선스 도구 때문에 호스티드 러너로 다 못 옮기는 경우가 많다. 이때 셀프호스티드 러너를 쓰되 Jenkins 에이전트처럼 다루면 반드시 사고가 난다. Jenkins 에이전트는 상태가 누적되는 것을 전제로 했지만 GitHub Actions job은 매번 깨끗한 환경을 가정하기 때문이다. 영속 러너(persistent runner)를 쓰면 이전 job이 남긴 파일·캐시·프로세스가 다음 job에 새어 들어가 “내 PC에서는 됐는데” 유형의 비결정적 실패를 낳는다.
# ephemeral 러너 — 단일 job 후 자동 언레지스터로 상태 누적 차단
# 오토스케일러(예: Actions Runner Controller)와 결합해 매번 새 러너 확보
./config.sh --url https://github.com/my-org --token "$RUNNER_TOKEN"
--labels linux,gpu --ephemeral
./run.sh # job 하나 처리하고 종료
따라서 가능하면 위처럼 ephemeral 러너로 job마다 러너를 폐기·재생성한다. 영속 러너를 불가피하게 써야 한다면, actions/checkout의 clean: true(기본값)에 더해 도커 데몬·전역 캐시·임시 디렉터리처럼 워크스페이스 밖 상태까지 정리하는 스텝을 앞에 둔다.
가장 안전한 이전: 병렬 실행으로 대조 검증
로드맵의 핵심은 여기 있다. 새 워크플로를 만들었다고 곧바로 Jenkins를 끄면 안 된다. 일정 기간 두 시스템을 나란히 돌리고 결과를 대조한 뒤에야 전환을 확정한다. Actions 워크플로를 먼저 “비차단(non-blocking)”으로 붙여 실패해도 머지를 막지 않게 하고, 신뢰가 쌓이면 필수 체크로 승격한다. 순서는 다음과 같다.
- 1단계: Actions 워크플로 추가, 단 브랜치 보호의 필수 상태 체크에는 넣지 않음. Jenkins가 여전히 머지 게이트.
- 2단계: 두 시스템의 결과(성공/실패, 산출물 해시)를 며칠~몇 주 대조. 불일치는 대개 환경 차이에서 온다.
- 3단계: Actions를 필수 체크로 승격하고 Jenkins 잡은 알림만 모드로 강등.
- 4단계: 관측 기간 뒤 Jenkins 잡 비활성화. 서버는 롤백 대비로 유지.
대조를 눈으로 하지 말고 자동화하면 신뢰가 훨씬 빨리 쌓인다. 아래는 두 시스템의 실행 결론을 API로 끌어와 비교하는 스크립트 골격이다.
import requests
def gha_conclusion(repo, sha, token):
# 특정 커밋의 GitHub Actions 실행 결론 조회 (success / failure / None)
r = requests.get(f"https://api.github.com/repos/{repo}/actions/runs",
headers={"Authorization": f"Bearer {token}"},
params={"head_sha": sha}, timeout=10)
runs = r.json()["workflow_runs"]
return runs[0]["conclusion"] if runs else None
def compare(gha, jk, sha): # jk: 같은 커밋의 Jenkins 결과 (SUCCESS/FAILURE)
# 두 시스템 결론이 갈리면 알림 — 여기서 대부분의 환경 차이가 드러난다
if (gha == "success") != (jk == "SUCCESS"):
print(f"불일치! sha={sha[:8]} GHA={gha} Jenkins={jk}")
결론이 갈리는 커밋을 모아 원인을 분류하다 보면 대개 세 패턴으로 수렴한다. Jenkins 노드에만 있던 전역 도구, ~/.npmrc나 환경변수 같은 암묵적 설정, Jenkins가 워크스페이스에 남겨둔 이전 산출물에 의존하던 잡이다. 이 셋만 잡으면 대부분의 불일치가 사라진다.
이전에서 자주 빠지는 함정
마지막으로 실무에서 반복적으로 목격되는 함정이다.
- 워크스페이스 공유 가정: 서로 다른 job은 파일을 공유하지 않는다. build 산출물을 test가 쓰려면
upload-artifact/download-artifact로 넘기거나 합쳐야 한다. - 암묵적 도구 버전: Jenkins 노드에 깔려 있던 특정 JDK·Python·CLI 버전에 의존했다면, Actions에서는
setup-*액션으로 버전을 명시해야 재현된다. - 시크릿은 포크 PR에 노출되지 않는다:
pull_request는 포크에서 시크릿을 받지 못하므로, 시크릿이 필요한 검증은 머지 후로 분리한다. - 동시성 제어 누락: Actions는 기본적으로 겹쳐 실행된다. 배포처럼 겹치면 안 되는 워크플로는 아래
concurrency로 직렬화한다. - 기본 권한 과다:
permissions: { contents: read }로GITHUB_TOKEN권한을 좁혀 최소 권한을 지킨다.
# 배포 워크플로 겹침 방지 + 진행 중이던 이전 실행 취소
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
마무리
Jenkins에서 GitHub Actions로의 이전은 문법 변환이 아니라 실행 모델의 전환이다. 상태를 가진 장기 서버에서 무상태 이벤트 기반 러너로 옮겨가는 것이므로, 워크스페이스 공유·암묵적 도구 버전·상태 누적처럼 Jenkins가 조용히 감싸주던 전제들이 표면으로 드러난다. 이를 명시적으로 바꾸는 것이 이전의 실체다.
안전한 경로는 정해져 있다. 개념을 매핑하고, 시크릿은 가능한 한 OIDC로 격상하며, 무엇보다 두 시스템을 병렬로 돌려 결과를 대조한 뒤에야 전환을 확정한다. 이전을 이벤트가 아니라 기간으로 다루는 것, 그것이 빌드를 멈추지 않고 옮기는 검증된 방법이다.
자주 묻는 질문
Q. Jenkinsfile을 자동으로 워크플로로 변환하는 도구를 믿어도 되나요?
A. 초안 생성용으로는 시간을 아껴주지만 그대로 쓰면 위험합니다. 변환기는 문법은 옮겨도 체크아웃 누락, job 간 워크스페이스 공유 가정, post 블록의 조건 실행 같은 실행 모델 차이를 완벽히 반영하지 못하니, 반드시 병렬 대조 단계로 검증하세요.
Q. 병렬 검증 기간은 얼마나 잡아야 하나요?
A. 두 시스템의 결론 불일치가 연속으로 0에 수렴하고, 릴리스·핫픽스처럼 드물게 도는 경로까지 한 번씩 겪은 시점이 승격 기준입니다. 달력이 아니라 불일치 지표로 판단하세요.