DevOps
여기어때 CI/CD 개선기 Part 2: CI Pipeline 설계
Jupiter여기어때
2025년 9월 8일
원문에서 보기 ↗
안녕하세요 여기어때컴퍼니 DevOps팀 주피터입니다
DevOps팀은 여기어때컴퍼니 Tech 조직내 개발자들의 업무 몰입을 위한 개발 환경과 체계를 만들어가는 조직으로 조직별로 상이한 개발 환경을 통합하고 운영하고 있습니다.
이번에 다룰 주제는 모두를 위한 CI/CD 개선기 2편, CI 공통화를 위한 모듈화 설계 입니다.
이번 편에서는 CI에 대한 내용, 특히 Gitlab CI를 이용해서 구현한 내용을 집중적으로 다뤄보겠습니다.
어떤 난관이 있었을까?
파악하는 것부터가 문제
지난 1편에 말씀드린 것처럼 기존 여기어때 CI Template은 30여개의 파일로 나눠져있었습니다.
때문에 아래와 같이 여러가지 template을 찾아 include 해야 했습니다.
include:
project: "gitlab/ci-template"
file:
- "workflow/....gitlab-ci.yml"
- "gradle/AAA.gitlab-ci.yml"
- "gradle/AAA-1gb.gitlab-ci.yml"
- "gradle/BBB.yml"
- "gitops/CCC.gitlab-ci.yml"
이러한 Template들은 사소한 차이에 의해서 발생했습니다.
누군가 사용하고 있을 기존 CI 코드를 수정할 순 없었기 때문이죠
# ex) AAA.gitlab-ci.yaml
variables:
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
# ex) AAA-1gb.gitlab-ci.yaml
variables:
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.jvmargs=-Xmx1024M -Dorg.gradle.logging.stacktrace=full"
그렇다면 이 옵션은 어떤 이유로 사용하는지, 왜 사용하게 되었는지 파악하기는 어렵습니다.
이 때문에 어디까지 공통화하고, 어디까지 커스텀할 수 있게 해야할지 범위 산정에 어려움을 겪었습니다.
팀마다 다른 전략에 모두 대응할 수 있을까?
한편 개발팀마다 서로 다른 브랜치 전략을 사용하는 경우는 빈번합니다.

출처 : ChatGPT 생성
개발 환경을 나타내는 브랜치도 어떤팀은 dev , 어떤팀은 develop 로 달랐습니다.
Feature 추가 시 사용하는 브랜치가 feature/<티켓번호> 인 경우도, epic/<티켓번호>인 경우도 있었습니다.
정답은 없는 모호한 부분이었기에 이를 모두 지원할 수 있게 해야 했습니다.
한편, 백엔드와 프론트엔트 프로젝트 간 CI는 명확하게 다른 형태를 띄고 있습니다.

출처 : ChatGPT 생성
구조면에서는 단순하게 보면 각각 파일의 확장자가 다르고, 소스 코드가 위치하는 디렉토리 명도 다릅니다.
더 깊게 들어가면 Cache가 생성되는 디렉토리, 의존성 파일 구조, Mono-Repo 구조도 상이합니다.
그렇다면 CI 동작은 어떨까요? 수행되는 CI Job의 내용도 일부 다릅니다.
백엔드는 공통 모듈 업로드를 위해 Nexus Publish를 수행하곤 합니다.
프론트엔드는 백엔드와 다르게 CloudFront Invalidation과 S3 Bucket Upload를 수행하기도 합니다.
(물론 반대인 경우도, 그렇지 않은 경우도 있겠지만요!)
위와 같은 Build, Test에 속하지 않는 특수한 Job들을 서로 다른 개발들팀이 어떻게 쉽게 추가할 수 있을까?
이러한 부분도 고려해서 CI 코드를 구현해야 했습니다.
그러기 위해선 재사용 가능한 코드를 설계하는 것이 중요하다고 생각했고 모듈화 설계를 고려하게 되었습니다.
CI 설계하기
DevOps팀이 언어별 모듈 설계 시 중요한 부분은 프로젝트와의 의존관계입니다.
모든 언어별, 빌드 도구별 CI를 구현하면 좋겠지만 현실적으로는 쉽지 않습니다.
그래서 전체 프로젝트들의 gitlab-ci.yml 스크립트를 분석하여 사용중인 도구들을 위주로 개발하기로 하였습니다.
Java / Spring의 경우 gradle, Node(react) 의 경우 yarn이 되겠네요
각각의 언어, 프레임워크들은 언어별로 서로 다른 CI 수행 방식을 가지고 있습니다.
가장 크게 차이가 발생하는 부분은 크게 세가지 입니다.
- 변경사항 (changes),
- 의존성 설치 및 빌드 결과물 (cache)
- CI 명령어 (Build, Test, Containerize)
이 세가지 부분의 조합만 잘 교체해준다면 어떤 프로젝트더라도 유사한 구조를 CI에서 생성해줄 수 있습니다.
변경사항 (changes)이 발생하면 CI 명령어를 수행하고 결과물을 전달한다

따라서, 빌드 도구별 모듈로 정의하여 재사용 가능하게 했습니다.
예를 들면 Java Spring의 Gradle Build는 아래와 같은 형태로 정의됩니다.
include:
- project: devops/pipeline-standard
ref: master
file: java.gitlab-ci.yml
inputs:
app-name: <배포될 App 이름>
ecr-repository-name: <업로드할 ECR 경로>
manifest-project-path: <배포 설정이 담긴 Manifest Repository>
jdk-version: "21"
gradle-version: "8.8"
changes:
- .gitlab-ci.yml
- build.gradle
- ...
한편, node 계열의 yarn Build는 아래와 같이 나타낼 수 있습니다.
include:
- project: devops/pipeline-standard
ref: dev
file: js.gitlab-ci.yml
inputs:
app-group-name: <모노레포의 디렉토리 명(Optional)>
app-name: <배포될 App 이름>
ecr-repository-name: <업로드할 ECR 경로>
manifest-project-path: <배포 설정이 담긴 Manifest Repository>
node-version: '20'
yarn-version: '1.22.22'
s3-sync-source-path: 'apps/web-builder/public'
...
이런 설정이 나오기 까지 다양한 고민과 아이디어가 있었습니다.
한번 자세히 살펴보도록 하겠습니다.
공통 기능 모듈
일단 모듈화 설계에 앞서 불변하는 설정과 공통적인 설정을 정의하는 것이 중요했습니다.
저희가 생각했을 때, 어떤 팀이든 전체 CI의 stage 와 workflow 자체는 동일했습니다.
stages:
- build
- test
- containerize
- deploy
- alarm
workflow:
rules:
- if: !reference [ .default-rules, .when-merge-request-event ]
- if: !reference [ .default-rules, .when-merge-complete-event ]
...
- when: never # 이 외의 조건에는 실행 방지
auto_cancel:
on_new_commit: interruptible # 새로운 커밋이 있을 경우 이전 작업 중단
MR이 생성되면 Build와 Test를 수행하고, Merge가 완료되면 배포한다는 Workflow는 동일했습니다.
브랜치명은 아래와 같은 방식의 정규식을 이용해서 처리했습니다.
.default-rules:
.when-merge-request-event: >-
$CI_PIPELINE_SOURCE == "merge_request_event" &&
$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^(dev|stage|release)$/
.when-merge-complete-event: >-
$CI_COMMIT_BRANCH =~ /^(dev|stage|release)$/ &&
($CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "web") .when-merge-complete-event: >-
$CI_COMMIT_BRANCH =~ /^(dev|stage|release)$/ &&
($CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "web")
feature나 epic 과 같은 Source Branch 이름을 강제로 정하는 것보다는 배포할 Target Branch 이름을 잘 정의하는 게 더 중요하다고 생각했습니다.
그래서 상대적으로 쉬운쪽의 변경을 선택했습니다. (develop → dev)
추후 설명에서도 종종 언급하겠지만 Target Branch 이름의 균일화는 배포할 Profile과도 관계되기 때문에 중요합니다.
한편, 환경변수 및 실행 스크립트 설정은 상당히 어려웠습니다.
여러 구현이 추가될 때마다 영향을 받을 수 있는 부분이기 때문에 상당히 신중하게 결정해야 했습니다.
공통 변수인 variables는 인증정보 혹은 자체 부가 기능 일부를 제외하고는 최대한 설정하지 않으려고 했습니다.
혹시라도 변경될 경우에 전체 CI/CD에 영향을 줄 수도 있기 때문입니다.
인증 관련 정보는 Gitlab CI/CD Variables를 이용해서 주입했고 Mask 기능을 이용해 노출되지 않도록 했습니다.
GIT_DEPTH 설정은 git clone 시 불필요한 depth를 제거하여 속도를 높이는데 사용했습니다.
variables:
# 공용 변수
AWS_REGION: ap-northeast-2
GIT_DEPTH: 1
# 자동 배포
DEV_AUTO_DEPLOY: 'true'
...
# 모니터링 채널
DEV_REPORT_SLACK_CHANNEL: 'tech-devops-monitoring'
...
마찬가지로 공통 스크립트 또한 제약 사항이 많습니다.
Container Image에 따라서 지원되지 않은 동작이 많기 때문입니다.
어떤 이미지에서는 apt-get install 동작이 수행되지만 어떤 이미지에서는 apk add 동작을 수행해야 합니다.
이를 컨테이너 이미지를 실행하기 전에 구분해서 실행하는 것은 상당히 어려운 일입니다.
따라서 저희는 before_script와 after_script 내에 복잡한 동작은 지양하기로 했습니다.
echo, export 정도의 기본적인 동작만을 수행하고, 다른 Job에서 이를 덮어씌우지 않도록 했습니다.
그 예시로 before_script에서는 profile을 설정하고, after_script에서는 Job 간의 성공, 실패 여부를 확인하는 정도의 설정을 아래와 같이 추가했습니다
.default-log:
.artifacts:
paths:
- artifacts/**
expire_in: 1h # job 상태를 1시간 동안 유지
when: always
.after_script:
- ...
- echo "CI_JOB_STATUS=$CI_JOB_STATUS" >> $ARTIFACT_LOG_PATH
- ...
default:
artifacts: !reference [ .default-log, .artifacts ]
after_script: # Script 실행 시 10개 이하로 실행이 제한되어 있음(Spec)
- !reference [ .default-log, .after_script ]
before_script: # Script 실행 시 10개 이하로 실행이 제한되어 있음(Spec)
...
- |
export PROFILE=${PROFILE:-$CI_COMMIT_BRANCH} # Commit Tag가 없으면 Branch 이름으로 Profile 설정
export PROFILE=${PROFILE:-$CI_MERGE_REQUEST_TARGET_BRANCH_NAME} # Branch 이름이 없으면 Merge Request Branch 이름으로 Profile 설정
...
이러한 공통 설정을 바탕으로 언어별 모듈 설계를 진행했습니다.
언어별 모듈 설계는 공통 설정과는 다르게 언어별 특성이 고스란히 녹아져있어야 합니다.
언어별 기능 모듈
먼저 Gradle 모듈 (modules/gradle.gitlab-ci.yml) 에서는 각 Stage에 맞는 가장 기본 동작을 정의합니다.
.gradle:
.default-config: &default-config
- export GRADLE_USER_HOME=`pwd`/.gradle
- export GRADLE_OPTS="-Dorg.gradle.daemon=false"
- ...
.build:
- *default-config
- |
...
gradle --build-cache ${GRADLE_APP_NAME}assemble -x bootJar -Pprofile=$PROFILE
- |
...
gradle --build-cache ${GRADLE_TASKS}
.containerize:
- !reference [ .aws-get-profile ] # AWS 모듈에서 가져온 설정
- !reference [ .aws-set-ecr ] # AWS 모듈에서 가져온 설정
- *default-config
- |
...
gradle ${GRADLE_APP_NAME}jib \
-Djib.to.image=$ECR_REGISTRY/$ECR_PROFILE/$ECR_IMAGE \
...
-Pprofile=$PROFILE
.publish:
- *default-config
- gradle ${GRADLE_APP_NAME}publish
이 모듈을 활용하여 Input을 입력받아 조합하면 아래와 같은 java.gitlab-ci.yml이 완성됩니다.
"build-$[[ inputs.app-name ]]-with-gradle-$[[ inputs.gradle-version ]]-jdk$[[ inputs.jdk-version ]]":
stage: build
image: gradle:$[[ inputs.gradle-version ]]-jdk$[[ inputs.jdk-version ]]
rules:
- if: !reference [ .default-rules, .when-merge-request-event ]
changes: $[[ inputs.changes ]]
- if: !reference [ .default-rules, .when-merge-complete-event ]
changes: $[[ inputs.changes ]]
- ...
cache:
- !reference [ .cache, .pull-and-push-install-cache ]
- !reference [ .cache, .pull-and-push-build-cache ]
variables:
APP_GROUP_NAME: $[[ inputs.app-group-name | expand_vars ]]
APP_MODULE_NAME: $[[ inputs.app-name | expand_vars ]]
...
script: !reference [ .gradle, .build ]
이렇게 빌드 도구 별로 모듈을 분리하면 하면 추후 빌드 도구가 Maven 과 같이 변경되는 경우도 쉽게 대응할 수 있습니다!
또한 유지보수되지 않는 코드는 결국 죽은 코드이기 때문에 자체적인 Naming Convention에 따라 CI 코드를 작성하였습니다. 이를 통해 CI를 구현하는 사람도, 분석하는 사람도 쉽게 분석하고 조립할 수 있게 하였습니다.
전용 Agent 이미지 작성
한편 Slack 알람 정책이나, Gitlab MR 생성 API 등 로직들이 추가되기 시작하면서 작성해야할 Shell Script의 양이 기하급수적으로 늘어나기 시작했습니다.
전용 Agent를 만들기로 결심한 때는 1개 Job에 스크립트가 100줄이 넘어가기도 했습니다
이를 해결하기 위해 가볍고 빠른 Go언어를 채택하여 전용 CI Agent 이미지를 작성하였습니다
다양한 커맨드 기능을 수행할 수 있는 CLI 구조로 구현하여 100줄의 Script는 아래와 같이 1줄로 실행하게 개선했습니다.
alarm-ci-failure:
stage: alarm
image:
name: .../pipeline-agent:123957
rules:
- ...
script:
- /main slack --action=alarm --template=failed --profile=$PROFILE
1,000줄이 넘어가는 코드임에도 Container 이미지 크기가 12MB 밖에 되지 않아 상당히 만족스럽게 사용하고 있습니다.

이렇게 구현한 CI 전용 Agent는 현재 Profile에 따라 알람을 전송하고 Manifest를 자동으로 업데이트하는 등의 다양한 역할을 수행하고 있습니다.

CI를 위한 CI
한편, CI에 기능이 추가되거나 변경되는 경우에도 이를 확인하고 테스트 하는 작업도 필요했습니다.
CI를 확인하려면 MR을 생성하고 Merge하는 등 CI를 발생시켜야 했습니다.
이를 자동화하기 위해서 Gitlab CI의 Trigger 기능과 Workflow, Rules 기능을 조합하여 테스트를 자동화 했습니다
# Trigger를 이용한 CI 테스트 예시 (.gitlab-ci.yml)
workflow:
rules:
- if: >-
$CI_PIPELINE_SOURCE == "merge_request_event" &&
$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^(master)$/
- if: >-
$CI_COMMIT_BRANCH =~ /^(dev)$/ &&
($CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "web")
trigger-java-spring-gradle-success:
stage: test-case-java-success
rules:
- changes: !reference [ .ci-base, .changes-common ]
- changes: !reference [ .ci-base, .changes-java ]
variables:
PIPELINE_STANDARD_MR_TRIGGER: 'true'
trigger:
strategy: depend
project: devops/pipeline-testbed/java/spring-gradle
branch: dev

개선 결과
지금까지 CI에 대한 개선 내용을 살펴보았습니다.
개선한 결과를 정리하자면 아래와 같이 이야기할 수 있습니다.
일관된 사용 경험 제시
PolyRepo와 MonoRepo, Frontend와 Backend 모든 프로젝트 구조에 동일한 CI 구조를 사용할 수 있게 되었습니다.
이를 통해 개발팀이 CI에 대한 고민을 덜어내고자 했습니다.
주기적인 패치
이제 개발팀은 DevOps팀에서 관리하는 Pipeline의 master 브랜치를 구독하는 형태가 되었습니다.
따라서, CI 기능 추가 및 변경 시 자동으로 전파가 가능합니다.
이를 통해 코드 개발 이외에 CI에 대한 개발자의 관심도 최소화했습니다.
IDP를 위한 첫걸음
전용 Agent를 구현하여 복잡한 로직 추가 및 변경 용이하게 되었습니다.
아직 구현하지 못한 부분인 AI 코드 리뷰, Manifest 설정 자동화 등을 개발자가 쉽게 이용할 수 있도록 할 예정입니다.
다음 편에는 공통 Helm Chart를 개선하면서 겪었던 어려움과 고민을 두 편에 걸쳐 같이 나눠보고자 합니다.
감사합니다.