

이번 글에서는 "릴레이 스토리텔링 게임 개소릴레이 백엔드"에 적용한 Jenkinsfile을 기준으로,
멀티브랜치 파이프라인에서 CI와 CD를 어떻게 분리해 구성했는지를 단계별로 정리합니다.
특히,
위 경우에만 실행되며,
빌드와 테스트까지만 수행합니다.
MR 빌드에서는 실행되지 않으며,
실제 배포가 필요한 경우에만 수행됩니다.
이 구조를 통해
검증(CI)과 배포(CD)를 명확하게 분리합니다.
Jenkinsfile 상단에서는 파이프라인 전반에 적용되는 실행 방식과 공통 환경 변수를 정의합니다.

agent none은 파이프라인 전체에 공통 실행 노드를 지정하지 않겠다는 의미입니다.
대신 각 stage에서 필요한 실행 환경을 개별적으로 지정합니다.
이 설정을 통해 CI와 CD의 실행 환경을 명확히 분리합니다.
CI 단계에서는 node:20-alpine Docker 컨테이너를 사용합니다.
이유는 다음과 같습니다.
즉, CI 환경을 컨테이너로 고정합니다.
CD 단계에서는 Jenkins 기본 에이전트(agent any)를 사용합니다.
CD 단계에서는 다음 작업이 필요합니다.
이 작업들은 Docker daemon 접근과 SSH 인증이 필요합니다.
따라서 별도의 컨테이너를 사용하기보다,
이미 설정된 Jenkins 노드 환경을 그대로 사용합니다.
CI 단계는 코드 변경 사항을 검증하기 위한 단계입니다.
실제 배포는 수행하지 않습니다.
이 단계에서는
“이 커밋이 배포 가능한 상태인지”를 확인하는 데 집중합니다.
앞서 말했듯, CI 단계는 다음 조건에서만 실행됩니다.

이를 통해 feature 브랜치의 변경 사항이
develop에 병합되기 전에 반드시 빌드와 테스트를 거치도록 합니다.
CI 단계에서는 다음 작업을 순차적으로 수행합니다.
각 단계는 실패 시 즉시 파이프라인이 중단됩니다.

멀티브랜치 파이프라인에서 Jenkins가 감지한
현재 브랜치 또는 MR 기준의 소스를 체크아웃합니다.
이 시점의 커밋이
이후 모든 빌드 및 배포의 기준점이 됩니다.
소스 체크아웃 이후,
현재 커밋을 식별할 수 있는 Docker 이미지 태그를 생성합니다.

GIT_COMMIT 앞 7자리를 사용합니다..tag 파일로 저장합니다.이미지를 latest 태그만으로 관리하면
어떤 커밋이 배포되었는지 즉시 알기 어렵습니다.
반면 SHA 기반 태그를 사용하면,
BACKEND_TAG=sha-a1b2c3d docker compose up -d backend
배포 이력 관리와 롤백을 단순하게 만들기 위한 선택입니다.

CI 단계의 마지막에서는 실제 코드 검증을 수행합니다.
npm ci
package-lock.json을 기준으로 의존성을 설치합니다.package.json보다 엄격하게 버전을 일치시키므로, 어떤 환경에서 빌드해도 항상 동일한 의존성 구성을 보장합니다.

CI와 CD 단계는 동일한 워크스페이스를 공유하지 않습니다.
따라서 CI 단계에서 생성한 태그 정보를 별도로 전달해야 합니다.
이를 위해 Jenkins의 stash / unstash를 사용합니다.
CD 단계에서는 이 파일을 다시 불러와
CI에서 검증한 커밋 기준 태그를 그대로 사용합니다.
여기까지가 CI 파트입니다.
이후 CD 파트(이미지 빌드 → 배포)에 대해 진행하겠습니다.
CD 단계는 CI에서 검증된 결과물을 기반으로
Docker 이미지를 빌드하고 실제 서버에 배포하는 단계입니다.
이 단계는 실제 인프라에 영향을 주기 때문에,
실행 조건을 엄격하게 제한합니다.

CD 단계는 다음 조건을 모두 만족할 때만 실행됩니다.
즉,
이를 통해
검증되지 않은 코드가 배포되는 상황을 방지합니다.
CD 단계는 다음 순서로 진행됩니다.
각 단계는 이전 단계가 성공했을 때만 진행됩니다.
CD 단계의 첫 번째 작업은
배포를 위한 작업 환경을 정리하고, CI 결과를 복구하는 것입니다.

먼저 deleteDir()를 통해
이전 빌드의 잔여 파일을 모두 제거합니다.
이후 checkout scm으로
배포에 필요한 최신 소스 코드와 설정 파일을 다시 체크아웃합니다.
마지막으로 unstash 'meta'를 통해
CI 단계에서 생성한 .tag 파일을 복구합니다.
env.TAG = readFile('.tag').trim()
이 단계에서 읽어온 태그 값은
이후 Docker 이미지 빌드와 배포 과정 전반에서 사용됩니다.
이를 통해 CI와 CD가
동일한 커밋 기준을 공유하게 됩니다.

Prepare Workspace 단계에서 복구한 태그를 사용해
백엔드 Docker 이미지를 빌드하고 Docker Hub로 푸시합니다.
deploy/backend/Dockerfile을 기준으로 이미지를 빌드합니다.Docker Hub 인증 정보는
Jenkins Credentials로 관리합니다.
Jenkinsfile에는 실제 인증 정보가 노출되지 않으며,
필요한 시점에만 안전하게 주입됩니다.
두 가지 태그를 함께 push합니다.
sha-xxxxxxxlatest이 구조를 통해
가시성과 운영 편의성을 동시에 확보합니다.
Docker Hub에 이미지가 준비되면,
배포 서버(EC2)에서 해당 이미지를 pull 받아 컨테이너를 재기동합니다.
이 과정은 SSH를 통해 원격으로 수행됩니다.

GMS_API_KEY)모든 민감 정보는
Jenkins Credentials로 관리합니다.

배포 서버에는
이미지에 포함되지 않은 설정 파일들이 필요합니다.
전달하는 파일은 다음과 같습니다.
docker-compose.ymlDocker 이미지는
애플리케이션 실행에 필요한 파일만 포함하도록 구성되어 있기 때문에,
환경별 설정 파일은 서버에 별도로 전달합니다.

배포 서버에서는 다음 순서로 작업을 수행합니다.
--no-deps 옵션을 사용해
다른 서비스에는 영향을 주지 않고
backend 컨테이너만 교체합니다.
이를 통해
서비스 중단 범위를 최소화합니다.
이번에는 GitHub Actions가 아닌 Jenkins를 사용해
CI/CD 파이프라인을 직접 구성해보았습니다.
도구의 차이만 있을 뿐이지
처음 접하는 것보다는 훨씬 수월하게 CI/CD 파이프라인을 구축할 수 있었던 것 같습니다.
앞으로 이 흐름에 맞게 파이프라인을 구축하는 연습을 꾸준히 해야할 것 같습니다.
pipeline {
agent none
environment {
IMAGE_NAME = "zhy2on/gaesorelay-backend-api"
REGISTRY_CREDENTIAL_ID = "dockerhub-auth" // Username/Password (Docker Hub ID + Access Token)
DEPLOY_SSH_CREDENTIAL_ID = "deploy-server-ssh" // SSH Username with private key
DEPLOY_USER = "ubuntu"
DEPLOY_DIR = "/home/${DEPLOY_USER}/gaesorelay/backend/deploy"
}
stages {
stage('CI: Build & Test') {
when {
anyOf {
branch 'develop'
expression { return env.CHANGE_ID && env.CHANGE_TARGET == 'develop' }
}
}
agent {
docker {
image 'node:20-alpine'
args '-u root'
}
}
steps {
checkout scm
script {
env.TAG = "sha-${env.GIT_COMMIT.take(7)}"
writeFile file: '.tag', text: env.TAG
echo "TAG=${env.TAG}"
echo "CHANGE_ID=${env.CHANGE_ID}, CHANGE_TARGET=${env.CHANGE_TARGET}, BRANCH_NAME=${env.BRANCH_NAME}"
}
sh 'npm ci'
sh 'npm test'
sh 'npm run build'
stash name: 'meta', includes: '.tag'
}
}
stage('CD: Image & Deploy') {
when {
allOf {
branch 'develop'
expression { return !env.CHANGE_ID }
}
}
agent any
stages {
stage('Prepare Workspace') {
steps {
deleteDir()
checkout scm
unstash 'meta'
script {
env.TAG = readFile('.tag').trim()
echo "Deploy TAG=${env.TAG}"
}
}
}
stage('Build & Push Docker Image') {
steps {
script {
docker.withRegistry("", "${REGISTRY_CREDENTIAL_ID}") {
def img = docker.build("${IMAGE_NAME}:${env.TAG}", "-f deploy/backend/Dockerfile .")
img.push()
img.push("latest")
}
}
}
}
stage('Deploy (pull & run on EC2)') {
steps {
sshagent(credentials: ["${DEPLOY_SSH_CREDENTIAL_ID}"]) {
withCredentials([
usernamePassword(
credentialsId: "${REGISTRY_CREDENTIAL_ID}",
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS'
),
string(
credentialsId: 'deploy-server-ip',
variable: 'DEPLOY_SERVER_IP'
),
string(
credentialsId: 'gms-api-key',
variable: 'GMS_API_KEY'
)
]) {
sh """
set -e
ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_SERVER_IP} 'mkdir -p ${DEPLOY_DIR}'
scp -o StrictHostKeyChecking=no deploy/docker-compose.yml ${DEPLOY_USER}@${DEPLOY_SERVER_IP}:${DEPLOY_DIR}/docker-compose.yml
scp -o StrictHostKeyChecking=no deploy/swagger/public/openapi.yaml ${DEPLOY_USER}@${DEPLOY_SERVER_IP}:${DEPLOY_DIR}/swagger/public/openapi.yaml
ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_SERVER_IP} '
set -e
cd ${DEPLOY_DIR}
echo "${REG_PASS}" | docker login docker.io -u "${REG_USER}" --password-stdin
BACKEND_IMAGE="${IMAGE_NAME}" BACKEND_TAG="${env.TAG}" docker compose pull backend
BACKEND_IMAGE="${IMAGE_NAME}" BACKEND_TAG="${env.TAG}" GMS_API_KEY="${GMS_API_KEY}" docker compose up -d --no-deps backend
docker image prune -f
'
"""
}
}
}
}
}
}
}
}