
인트로
레거시 Spring Framework 기반 프로젝트를 Spring Boot 3와 Gradle 기반으로 고도화하면서
GitLab CI/CD 빌드 환경을 맞춰야 하는 일이 생겼다.
처음에는 단순히 “JDK 버전과 Gradle 버전만 알려주면 되는 일”이라고 생각했다.
하지만 메일을 다시 읽고 .gitlab-ci.yml을 따라가다 보니
내가 제대로 이해해야 하는 것은 빌드 도구 버전이 아니라
소스가 어떤 서버에서 빌드되고, 어떤 프로필로 WAR가 만들어지고, 어떤 경로로 운영 서버까지 이동하는지였다.
이 글은 사내 프로젝트의 실제 값은 모두 익명화하고, GitLab CI/CD와 Gradle 프로필 빌드를
이해해 간 과정을 공개 가능한 수준으로 정리한 기록이다.
개발환경
| 구분 | AS-IS | TO-BE |
|---|---|---|
| Java | Java 1.7 계열 | Java 17 |
| 프레임워크 | Spring Framework 4 계열 | Spring Boot 3 계열 |
| 빌드 도구 | Maven | Gradle |
| 배포 단위 | WAR | WAR |
| CI/CD | 일부 수동 빌드/배포 | GitLab CI/CD 기반 자동화 |
| 운영 방식 | 운영 서버 직접 배포 | 빌드 산출물 생성 후 운영 공유 저장소를 거쳐 배포 |
문제 상황
CI/CD 담당자로부터 빌드 환경을 맞추기 위한 요청을 받았다. 요청의 핵심은 다음과 같았다.
개발용 빌드 실행 서버에서 WAR 파일을 생성한 뒤,
운영 공유 저장소를 거쳐 운영 서버의 배포 경로로 복사하는 구조다.
기존 Maven 빌드 때처럼, 이번 Gradle 빌드도
개발자 로컬 환경과 빌드 실행 서버 환경을 맞추고 싶다.
Gradle 빌드에 필요한 환경 정보를 공유해 달라.
처음에는 이 문장을 보고 세 가지가 헷갈렸다.
- “개발용 빌드 실행 서버”가 내가 평소 접속하는 DEV 서버와 같은 의미인지 헷갈렸다.
- 예시로 받은
java -version,mvn -version결과가 빌드 서버의 값인지 담당자 로컬 PC의 값인지 헷갈렸다. .gitlab-ci.yml안에git pull이 없는데, GitLab Runner가 어떻게 최신 소스를 받아 빌드하는지 몰랐다.
이때 내가 놓친 핵심은 GitLab CI/CD에서는 개발자 PC,
GitLab 서버, GitLab Runner가 설치된 서버,
운영 서버의 역할이 서로 다르다는 점이었다.
[개발자 PC]
- git push
[GitLab 서버]
- push 이벤트 감지
- .gitlab-ci.yml 해석
- Runner에 job 할당
[빌드 실행 서버]
- GitLab Runner가 job 실행
- 소스 checkout
- Gradle 빌드
- WAR artifact 생성
[운영 서버 A/B]
- WAR 교체
- 애플리케이션 재기동
- 헬스체크
즉 “빌드 환경을 맞춘다”는 말은 단순히 내 PC 정보를 알려주는 일이 아니었다.
내 PC에서 성공하는 빌드가 GitLab Runner가 실행되는 서버에서도
같은 방식으로 성공하도록 보장하는 작업이었다.
1번째 시도: 담당자 로컬 PC 환경을 빌드 서버 환경으로 이해하기 (실패)
시도한 이유
처음에는 요청 메일에 포함된 명령 결과를 보고, 그것이 빌드 서버의 환경이라고 생각했다.
C:\Users\[계정]>java -version
openjdk version "17"
C:\Users\[계정]>mvn -version
Apache Maven 3.x.x
그래서 “Java 17은 이미 맞춰져 있으니 Gradle만 확인하면 되겠다”고 답하려고 했다.
문제점
다시 보니 프롬프트가 Windows 형식이었다.
C:\Users\[계정]>
반면 실제 빌드와 배포가 일어나는 서버는 Linux 계열 서버였다.
이 값은 빌드 실행 서버의 값이 아니라, 담당자 로컬 PC에서 확인한 값이었다.
이 상태로 답했다면 “빌드 서버에는 Java 17이 있다”고 잘못 전제했을 수 있었다.
빌드 환경 확인에서 가장 위험한 실수는 어디에서 실행한 명령인지 확인하지 않고 결과만 보는 것이다.
배운 점
버전 확인 결과를 공유받으면, 먼저 실행 위치를 확인해야 한다.
java -version보다 중요한 질문은 “이 명령을 어느 서버에서 실행했는가”다.
2번째 시도: GitLab Runner가 하는 일을 직접 따라가기 (성공)
시도한 이유
.gitlab-ci.yml에는 git pull 명령이 보이지 않았다.
그런데도 GitLab CI/CD에서는 push한 코드가 빌드 대상이 된다.
이 부분을 이해하지 못하면, 빌드 실행 서버의 작업 디렉터리와 최신 소스 반영 방식을 계속 오해할 수밖에 없었다.
확인한 내용
GitLab Runner는 job을 실행하기 전에 저장소 준비 단계를 수행한다.
프로젝트 설정과 Runner 설정에 따라 clone, fetch, none, empty 같은 Git 전략을 사용할 수 있다.
우리 프로젝트에서는 기존 작업 디렉터리를 재사용하면서 변경분을 가져오는 방식으로 동작하고 있었다.
공개용으로 단순화하면 흐름은 다음과 같다.
1. 개발자가 main 또는 develop 브랜치에 push한다.
2. GitLab 서버가 .gitlab-ci.yml의 rules를 평가한다.
3. 조건이 맞으면 build-runner 태그를 가진 Runner에 job을 할당한다.
4. Runner가 빌드 실행 서버의 작업 디렉터리에서 소스를 준비한다.
5. Runner가 script 블록에 적힌 Gradle 명령을 실행한다.
6. 생성된 WAR 파일을 GitLab artifact로 업로드한다.
7. deploy job이 artifact를 내려받아 운영 배포를 진행한다.
이제 .gitlab-ci.yml에 git pull이 없어도 최신 소스로 빌드되는 이유가 이해됐다.
script 블록은 전체 CI 작업 중 “내가 직접 적은 명령”에 해당하고, 그 앞뒤로 Runner가 수행하는 준비 단계와 정리 단계가 존재한다.
결과
CI/CD가 더 이상 “GitLab 안에서 알아서 되는 일”처럼 보이지 않았다. GitLab Runner는 GitLab 서버의 지시를 받아, 정해진 서버에서 정해진 명령을 실행하는 실행자였다.
배운 점
.gitlab-ci.yml을 읽을 때는 script 블록만 보면 안 된다.
Runner가 job을 받기 전후로 수행하는 저장소 준비, artifact 처리, cache 처리까지 함께 봐야 한다.
3번째 시도: 운영 프로필을 하나로 묶어 빌드하기 (실패 직전)
시도한 이유
처음 CI 설정을 봤을 때 운영 빌드 프로필이 하나로 잡혀 있었다.
variables:
APP_MODULE: "sample-web"
PROFILE_PROD: "prod"
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.jvmargs=-Xmx2g"
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
ARTIFACT_GLOB: "sample-web/build/libs/*.war"
build:sample-web:prod:
stage: build
tags:
- build-runner
script:
- chmod +x ./gradlew
- ./gradlew :${APP_MODULE}:clean :${APP_MODULE}:build -Pprofile=${PROFILE_PROD}
겉으로 보기에는 자연스러웠다. 운영 환경이니 prod 프로필로 빌드하면 될 것 같았다.
문제점
문제는 Gradle에서 프로필별 리소스를 가져오는 방식이었다.
프로젝트의 build.gradle은 공개용으로 단순화하면 다음 구조였다.
def profile = project.hasProperty("profile") ? project.getProperty("profile") : "local"
sourceSets {
main {
resources {
srcDir "src/main/resources"
srcDir "src/main/resources-${profile}"
}
}
}
이 구조에서는 -Pprofile=prod로 빌드하면
Gradle이 다음 디렉터리를 리소스 대상으로 추가한다.
src/main/resources
src/main/resources-prod
그런데 실제 프로젝트에는 운영 서버별 리소스 디렉터리가 따로 있었다.
sample-web/src/main/
├── resources
├── resources-local
├── resources-dev
├── resources-prod-a
└── resources-prod-b
resources-prod 디렉터리가 없다면 빌드가 바로 실패한다고 단정할 수는 없다.
Gradle은 존재하지 않는 리소스 디렉터리를 조용히 지나갈 수 있기 때문이다.
더 위험한 점은 빌드는 성공했는데 운영에 필요한 설정 파일이 WAR 안에 빠질 수 있다는 것이었다.
예를 들어 운영 서버 A와 운영 서버 B가 서로 다른 외부 연동 주소나 서버별 설정을 사용한다면,
하나의 prod 프로필로 만든 WAR를 두 서버에 그대로 배포하는 방식은 맞지 않을 수 있다.
배운 점
Gradle의 -Pprofile=prod는 Spring의 spring.profiles.active=prod와 같은 개념으로 자동 연결되는 값이 아니다. build.gradle에서 그 값을 어떻게 사용하는지 반드시 확인해야 한다.
최종 해결: 운영 서버별 Gradle 프로필 빌드로 분리하기
최종적으로는 운영 서버별 리소스 디렉터리 구조에 맞춰
빌드 job도 분리하는 방향이 가장 안전하다고 판단했다.
공개용으로 단순화한 예시는 다음과 같다.
stages:
- build
- deploy
variables:
APP_MODULE: "sample-web"
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.jvmargs=-Xmx2g"
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
cache:
key: "${CI_PROJECT_NAME}-${CI_COMMIT_REF_SLUG}"
paths:
- .gradle/wrapper
- .gradle/caches
.build_template: &build_template
stage: build
tags:
- build-runner
script:
- chmod +x ./gradlew
- ./gradlew :${APP_MODULE}:clean :${APP_MODULE}:build -Pprofile=${BUILD_PROFILE}
artifacts:
name: "${BUILD_PROFILE}-${CI_PROJECT_NAME}-${CI_COMMIT_SHORT_SHA}"
paths:
- ${APP_MODULE}/build/libs/*.war
expire_in: 30 days
build:sample-web:prod-a:
<<: *build_template
variables:
BUILD_PROFILE: "prod-a"
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
changes:
- sample-web/**/*
when: on_success
- when: never
build:sample-web:prod-b:
<<: *build_template
variables:
BUILD_PROFILE: "prod-b"
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
changes:
- sample-web/**/*
when: on_success
- when: never
배포 job도 각 서버가 자신에게 맞는 artifact를 사용하도록 분리한다.
.deploy_template: &deploy_template
stage: deploy
tags:
- build-runner
resource_group: "production-deploy-lock"
environment:
name: production
script:
- test -n "$TARGET_HOST"
- test -n "$DEPLOY_USER"
- WAR_FILE=$(find "${APP_MODULE}/build/libs" -name "*.war" | head -n 1)
- test -n "$WAR_FILE"
- scp "$WAR_FILE" "${DEPLOY_USER}@${TARGET_HOST}:/deployments/sample-web/new/ROOT.war"
- ssh "${DEPLOY_USER}@${TARGET_HOST}" "/opt/sample-web/bin/deploy_app.sh"
deploy:sample-web:prod-a:
<<: *deploy_template
needs:
- job: build:sample-web:prod-a
artifacts: true
variables:
TARGET_HOST: "$PROD_SSH_HOST_A"
when: manual
deploy:sample-web:prod-b:
<<: *deploy_template
needs:
- job: build:sample-web:prod-b
artifacts: true
- job: deploy:sample-web:prod-a
variables:
TARGET_HOST: "$PROD_SSH_HOST_B"
여기서 중요한 점은 IP, 계정, 비밀번호, SSH 키 같은 민감 정보가
.gitlab-ci.yml에 직접 들어가지 않는다는 것이다.
이런 값은 GitLab CI/CD Variables에 저장하고,
yml에서는 $PROD_SSH_HOST_A처럼 변수명만 참조한다.
또 하나의 안전장치는 resource_group이다.
운영 배포처럼 동시에 실행되면 위험한 job에는 같은 resource group을 지정해,
같은 환경으로 향하는 배포가 겹치지 않도록 제한할 수 있다.
다만 이 구조만으로 완전한 무중단 배포가 보장된다고 말할 수는 없다.
운영 서버 A를 재기동하는 동안 운영 서버 B가 트래픽을 정상 처리하려면
로드밸런서, 헬스체크, 세션 처리 방식까지 함께 맞아야 한다.
그래서 이 글에서는 “무중단 배포”라고 단정하기보다
운영 서버 A/B를 순차적으로 배포하는 구조라고 정리하는 것이 정확하다.
Maven 프로필과 Gradle 프로필이 달랐던 지점
이번에 가장 크게 배운 부분은 Maven과 Gradle의 프로필 처리 방식 차이였다.
Maven에서는 pom.xml 안에 <profiles>를 정의하고,
특정 프로필이 활성화되면 해당 설정이 빌드 모델에 반영된다.
<profiles>
<profile>
<id>prod-a</id>
<properties>
<db.url>jdbc:mariadb://[DB_HOST_A]:3306/app</db.url>
</properties>
</profile>
<profile>
<id>prod-b</id>
<properties>
<db.url>jdbc:mariadb://[DB_HOST_B]:3306/app</db.url>
</properties>
</profile>
</profiles>
반면 이번 Gradle 프로젝트에서는 -Pprofile=prod-a 값을
build.gradle에서 직접 읽고, 그 값으로 리소스 디렉터리를 조립하고 있었다.
./gradlew :sample-web:build -Pprofile=prod-a
포함 대상:
- src/main/resources
- src/main/resources-prod-a
그래서 Maven을 오래 사용한 사람에게는 “
프로필별 빌드 파일이 어디 있느냐”는 질문이 자연스러울 수 있었다.
하지만 이 프로젝트의 Gradle 구조에서는 프로필별 빌드 파일이 따로 있는 것이 아니라
프로필 값에 맞는 리소스 디렉터리를 동적으로 포함하는 방식이었다.
|
구분 |
Maven |
Gradle |
|---|---|---|
| 프로필 활성화 | mvn -Pprod-a |
./gradlew -Pprofile=prod-a |
| 설정 위치 | pom.xml의 <profiles> |
build.gradle의 로직과 리소스 디렉터리 |
| 환경별 리소스 | filtering으로 값 치환 | resources-${profile} 디렉터리 포함 |
| 확인 포인트 | 활성화된 profile의 properties | sourceSets와 processResources 대상 |
이 차이를 이해하고 나니, CI/CD 담당자에게 공유해야 할 정보도 명확해졌다.
필요한 것은 “prod 빌드 명령어” 하나가 아니라,
운영 서버별로 어떤 Gradle profile 값을 사용해야 하는지다.
빌드 실행 서버를 DEV 서버와 함께 쓰는 구조에서 주의할 점
이번 환경에서는 별도 빌드 전용 서버가 아니라,
개발용 서버와 같은 장비에서 GitLab Runner가 함께 동작하는 구조였다.
이 방식은 현실적인 장점이 있지만, 주의할 점도 분명했다.
| 항목 | 주의할 점 |
|---|---|
| CPU/메모리 | Gradle 빌드 중 애플리케이션 서버 성능에 영향을 줄 수 있다. |
| 디스크 | Gradle cache, GitLab 작업 디렉터리, WAR artifact가 누적될 수 있다. |
| 권한 | Runner 계정이 필요한 배포 경로에 접근할 수 있어야 한다. |
| 장애 영향 | 개발용 서버 장애가 CI/CD 장애로 이어질 수 있다. |
그래서 CI 설정에는 최소한의 안전장치가 필요했다.
variables:
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.jvmargs=-Xmx2g"
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
resource_group: "production-deploy-lock"
-Dorg.gradle.daemon=false는 CI처럼 일회성 빌드가 반복되는 환경에서
Gradle 데몬을 계속 남기지 않기 위한 설정이다.
-Xmx2g는 빌드 JVM의 메모리 상한을 두기 위한 설정이다.
GRADLE_USER_HOME을 프로젝트 디렉터리 아래로 잡으면
GitLab cache 설정과 함께 관리하기 쉬워진다.
다만 cache는 빌드 속도를 높이는 최적화 수단이지, 항상 존재한다고 가정하면 안 된다.
배운 점
첫째, CI/CD에서 “환경을 맞춘다”는 말은 JDK 버전 하나를 맞춘다는 뜻이 아니었다.
개발자 로컬, GitLab Runner, 빌드 명령어, 리소스 프로필, artifact 경로가 모두 같은 전제 위에서 움직이도록 맞추는 일이었다.
둘째, .gitlab-ci.yml은 배포팀만 보는 파일이 아니었다.
애플리케이션을 개발하는 사람도 최소한 빌드 job과 deploy job의 흐름은 읽을 수 있어야 했다.
특히 어떤 프로필로 빌드되는지는 런타임 장애와 바로 연결될 수 있었다.
셋째, Gradle의 -Pprofile 값은 프로젝트마다 의미가 다르다.
Spring profile과 같은 이름을 쓴다고 해서 자동으로 같은 동작을 한다고 보면 안 된다.
build.gradle에서 그 값이 어디에 쓰이는지 확인해야 한다.
넷째, “잘 모르겠다”고 멈춰서 확인한 덕분에 더 큰 실수를 피할 수 있었다.
담당자 로컬 PC의 버전 정보를 빌드 서버 정보로 착각한 것도, prod 프로필 하나로
운영 서버 A/B를 모두 빌드할 뻔한 것도, 다시 확인하지 않았다면 그냥 지나쳤을 수 있었다.
이번 일을 통해 CI/CD는 더 이상 “운영 쪽에서 알아서 돌아가는 영역”이 아니라,
내가 읽고 검증해야 하는 코드라는 감각이 생겼다.
결국 파이프라인도 코드이고, 빌드도 코드이고, 배포도 코드다.
참고 링크
- GitLab Docs - GitLab Runner
- GitLab Docs - Configure runners: Git strategy
- GitLab Docs - CI/CD YAML syntax reference
- GitLab Docs - CI/CD variables
- GitLab Docs - Job artifacts
- GitLab Docs - Caching in GitLab CI/CD
- GitLab Docs - Resource group
- Gradle Docs - SourceSet DSL
- Gradle Docs - Gradle-managed directories
- Maven Docs - Introduction to build profiles