이 연재는 PeekCart를 네 단계로 진행했습니다. Phase 1은 단일 Spring Boot 모놀리스로 도메인을 구현한 단계, Phase 2는 캐시, 분산 락, Kafka와 Outbox로 성능과 정합성을 보강한 단계, Phase 3은 CI, Kubernetes, 관측성, 부하 테스트로 운영 환경을 검증한 단계, Phase 4는 서비스별로 나눈 MSA 단계입니다.
지금까지의 글(도메인 구현, 이어서 성능과 정합성 보강)은 “무엇을 어떻게 구현했는가”가 주제였습니다. 인프라를 본격적으로 다루는 Phase 3부터는 질문이 바뀝니다. “이 코드가 정말 동작하는지를 내 노트북 바깥에서 어떻게 증명하는가”, 그리고 “검증된 코드를 어떻게 배포 가능한 한 덩어리로 만드는가” 를 다룹니다.
확인할 문제는 분명합니다. 로컬에서 ./gradlew build(프로젝트를 빌드, 테스트하는 명령)가 성공해도 그건 내 머신의 JDK, 내 Docker 데몬에서 성공한 것일 뿐입니다. 게다가 빌드의 산출물인 app.jar(실행 가능한 자바 패키지 한 개)도 그 자체로는 “어디서나 똑같이 뜨는 무언가”가 아닙니다. 두 문제는 아래 「문제 상황」에서 하나씩 풀어 봅니다.
그래서 두 가지가 필요합니다. CI는 앞 문제(검증 자동화, 강제)를, Docker 이미지는 뒤 문제(실행 환경까지 함께 묶은 산출물)를 맡습니다. 이 둘은 ci.yml 이라는 설정 파일 하나에서 만납니다. 테스트를 통과한 커밋에서만 이미지를 만들어 레지스트리에 올린다.
이 글에서 쓰는 용어의 뜻은 다음과 같습니다.
| 용어 | 이 글에서의 의미 |
|---|---|
| CI (Continuous Integration) | 코드를 합칠 때마다 자동으로 빌드와 테스트를 돌려 “합쳐도 깨지지 않음”을 기계가 검증하는 것. PeekCart에선 GitHub Actions가 push/PR마다 ci.yml을 실행 |
| 워크플로우 / job / step | GitHub Actions 단위. 워크플로우(ci.yml) 안에 job(build)이 있고, job은 step(체크아웃, JDK 설치, 빌드 등)의 순차 나열 |
| runner | job이 실제로 도는 가상 머신. PeekCart는 ubuntu-latest(GitHub 호스티드, amd64). Docker 데몬이 미리 깔려 있다 |
| 품질 게이트(quality gate) | 머지 전에 통과해야 하는 자동 검사. 여기선 lint + 단위/통합 테스트. 실제로 머지를 막으려면 branch protection 설정이 따로 필요하다(뒤의 「증명」절) |
| 멀티스테이지 빌드 | 한 Dockerfile을 빌드 단계(JDK로 jar 생성)와 런타임 단계(JRE로 jar 실행)로 나눠, 최종 이미지에 빌드 도구를 남기지 않는 기법 |
| 레이어 캐싱(Docker) | Dockerfile의 각 명령이 만드는 이미지 레이어를, 입력이 바뀌지 않으면 재사용하는 것. 의존성 설치를 소스 변경과 분리하면 매 빌드 의존성 재다운로드를 피한다 |
| Testcontainers | 테스트 시작 시 실제 MySQL/Redis/Kafka를 Docker 컨테이너로 띄우는 라이브러리. mock 없이 진짜 DB로 통합 테스트(앞선 글들의 *IntegrationTest) |
@ServiceConnection | Testcontainers가 띄운 컨테이너의 접속 정보를 Spring DataSource/Redis/Kafka 설정에 자동 주입하는 Spring Boot 애너테이션. 수동 @DynamicPropertySource 배선을 없앤다 |
| GHCR | GitHub Container Registry (ghcr.io). 빌드한 Docker 이미지를 올려 두는 저장소 |
| 이미지 태그 | 이미지의 이름표. :latest(최신 편의용)와 :<git sha>(커밋 추적용)를 함께 단다. 둘 다 덮어쓸 수 있는 가변 포인터라 추적은 되지만 바이트 동일성은 보장하지 않는다(그건 digest의 몫) |
| concurrency (GHA) | 같은 ref에 워크플로우가 겹쳐 돌 때 이전 실행을 자동 취소하는 설정. top-level이면 PR뿐 아니라 main push에도 적용된다 |
이번 학습에서는 다음 질문을 확인하려고 합니다.
- “로컬 빌드 성공”과 “검증됨”은 왜 다를까? CI 는 그 간극 중 무엇을 해결할까?
- CI 를 단일 job으로 둔 이유는 무엇일까? test 와 이미지 push 를 분리하지 않은 트레이드오프는 무엇일까?
- Testcontainers 통합 테스트를 CI 에서 추가 설정 없이 돌릴 수 있는 이유는 무엇일까?
- 멀티스테이지 Dockerfile 은 무엇을 분리할까? 빌드 단계와 런타임 단계를 나누면 무엇이 좋아질까?
- Dockerfile 의 COPY 순서와 의존성 레이어 캐싱은 무슨 관계일까? 왜 파일명을
app.jar로 고정했을까? - 이미지 빌드를 main push에서만 하는 결정은 무엇을 보호하고 무엇을 검증하지 못하게 할까?
- 통합 테스트 인프라가 흩어진 문제는 왜 생겼고 1차 해결은 무엇을 고쳤으며 무엇을 남겼을까?
- Phase 4에서 서비스가 N개로 나뉘면 이 단일 파이프라인은 어떻게 바뀌어야 할까?
문제 상황: “빌드가 된다”가 증명하지 못하는 것
./gradlew build 가 로컬에서 성공한다고 하겠습니다. 이 성공이 증명하지 못하는 것부터 짚어 보겠습니다.
- 환경 독립성: 내 머신엔 JDK 17이 깔려 있고 Docker 데몬이 떠 있어, 통합 테스트가 Testcontainers로 MySQL/Redis/Kafka 를 붙입니다(미리 띄워 둔 compose MySQL 은 앱 로컬 실행용이라 쓰지 않습니다). 동료 머신, 혹은 깨끗한 서버에서도 같은 결과인지는 모릅니다. “내 머신에선 되는데”는 여기서 출발합니다.
- 합쳤을 때의 정합성: 내 브랜치 단독으로는 통과해도, main 에 합친 상태에서 통과하는지는 별개입니다. 두 PR 이 각자 통과해도 합치면 깨지는 충돌이 있습니다.
- 검증의 강제성: 통합 테스트를 돌렸는지 자체가 사람 손에 달려 있습니다. 바쁘면 단위 테스트만 돌리고 통합은 건너뛴 채 PR 을 올립니다. “통과했다”는 자기 보고이지 기계 검증이 아닙니다.
CI 는 이 간극에 이렇게 대응합니다. 깨끗한 runner(매번 새로 뜨는 ubuntu VM)에서, PR과 main push마다, 사람 개입 없이 빌드와 전체 테스트를 실행합니다. 다만 해결하는 정도는 간극마다 다릅니다. 환경 독립성은 runner 가 해결합니다. 합쳤을 때의 정합성은 PR 을 main 에 합친 결과를 검증하는 데까지이고, 각자 통과한 두 PR 이 함께 머지되어 깨지는 경우는 main push 뒤에야 드러납니다. 검증의 강제성은 결과를 보여주는 데까지이고, 머지를 막는 설정은 아직 없습니다(뒤의 「증명」절). 같은 ref 의 앞선 실행은 취소될 수 있습니다(바로 아래). PeekCart 의 CI 트리거는 이렇습니다.
# .github/workflows/ci.yml
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
pull_request 로 머지 전 게이트를, push(main)로 머지 후 산출물 생성을 잡습니다. concurrency 는 같은 ref 에 커밋이 연이어 올라오면 이전 실행을 취소해 runner 낭비를 막습니다. PR 브랜치에서는 최신 커밋만 검증하면 되기 때문입니다. 다만 이 설정은 top-level이라 main push 에도 적용되어, 앞선 커밋의 실행(이미지 발행 포함)이 취소될 수 있습니다. 이 설정이 이미지 정책과 충돌하는 이유는 「한계」절에서 봅니다.
그런데 검증된 코드를 확보해도 문제가 하나 더 남습니다. 그 코드를 어떻게 “어디서나 똑같이 뜨는 한 덩어리”로 만드는가. app.jar 하나만으로는 부족합니다. 실행하려면 JRE 가 필요하고 JRE 버전, OS 라이브러리, 실행 유저가 환경마다 다르면 또 “내 머신에선 되는데”가 재발합니다. 그래서 jar 를 JRE, OS, 실행 유저까지 함께 묶은 Docker 이미지로 만듭니다. 그리고 ci.yml 이 테스트를 통과한 커밋에서만 그 이미지를 GHCR 에 push 합니다. 이미지 안의 jar 는 테스트를 거친 jar 를 그대로 옮긴 것이 아니라, 같은 커밋의 소스로 Dockerfile 이 다시 빌드한 것입니다(아래 3절).
선택한 설계
1. 단일 job. 조건부 step으로 단순하게 유지한다
CI 를 설계할 때 흔한 갈림길은 “test job과 build/push job을 나눌까”입니다. 관심사를 분리하려면 둘로 나눌 수 있습니다. PeekCart 는 단일 build job을 선택했습니다.
flowchart TD
A["checkout"] --> B["JDK 17 setup<br/>(+gradle 캐시)"]
B --> C["observability lint 3종"]
C --> D["./gradlew build<br/>(단위 + 통합 테스트)"]
D --> E["upload test-reports<br/>(always)"]
E --> F{"main push?"}
F -->|"yes"| G["GHCR 로그인 →<br/>buildx → 이미지 build+push"]
F -->|"PR / 그 외"| H["여기서 끝"]
단일 job 의 실질적인 근거는 **“조건부 step 하나로 끝나는 구성의 단순함이, 분리로 얻는 병렬성과 관심사 분리보다 지금 규모에서는 이득”**이라고 보는 편이 정확합니다. 작업할 때는 이유를 “test와 Docker push를 분리하면 artifact 전달이 필요해 복잡도가 증가한다. 조건부 step으로 해결.” 이라 적었는데, 이 근거는 test job 이 만든 jar 를 upload-artifact/download-artifact 로 image job 에 넘기는 jar 전달형 분리를 가정한 것입니다(job 간에는 새 runner 가 뜨고 워크스페이스를 공유하지 않으므로 연결 설정이 늘어납니다). 그런데 엄밀히 따지면 artifact 전달은 필수가 아니다. 현재 Dockerfile 은 stage 1에서 소스로부터 jar 를 다시 빌드하므로(bootJar), image job 이 checkout 후 docker build 만 해도 됩니다. jar 를 넘길 필요가 없습니다(대신 빌드를 두 번 하는 낭비는 생깁니다). 이미지 관련 step 에 if: github.ref == 'refs/heads/main' && github.event_name == 'push' 를 달아, PR 에서는 빌드, 테스트까지만 실행하고 이미지 step 은 모두 건너뜁니다.
2. Testcontainers를 CI에서 추가 설정 없이 실행한다
PeekCart 의 통합 테스트(앞선 글들의 ProductCacheIntegrationTest, OutboxKafkaIntegrationTest, ShedLockIntegrationTest 등)는 mock 없이 실제 MySQL/Redis/Kafka를 Testcontainers로 띄웁니다. 보통 CI 에서는 Docker-in-Docker(DinD) 설정이 필요한지 고민하게 되지만 PeekCart 에는 추가 설정이 하나도 없습니다.
ubuntu-latestrunner엔 Docker 데몬이 이미 깔려 있다. Testcontainers 가 그 데몬에 바로 컨테이너를 띄웁니다. 별도 DinD 는 필요하지 않습니다.- 테스트가
@ServiceConnection을 씁니다. Testcontainers 가 띄운 컨테이너의 host/port 를 Spring 설정에 자동 주입하므로 “CI에선 접속 정보가 다르다” 같은 환경 분기 코드가 없습니다.
그래서 ci.yml엔 Testcontainers 를 위한 step 이 따로 없습니다. ./gradlew build 한 줄이 단위 테스트와 통합 테스트를 함께 실행하고 통합 테스트가 알아서 컨테이너를 띄웁니다. “CI 환경 = 로컬 환경”이 거의 그대로 맞습니다. Testcontainers 가 테스트 코드 안에 인프라 요구사항을 선언하기 때문에 가능한 구조입니다. CI YAML 은 인프라를 알 필요가 없습니다.
3. 멀티스테이지 Dockerfile: 런타임에는 실행 파일만 남긴다
이미지를 만드는 가장 단순한 방법은 “JDK 이미지 하나에서 빌드하고 그대로 실행”하는 것입니다. 단일 stage 에서 ./gradlew bootJar 를 실행한 뒤 같은 이미지에 ENTRYPOINT 를 거는 식입니다. 하지만 그러면 최종 이미지에 Gradle, 소스코드, JDK 컴파일러, 빌드 캐시가 모두 남습니다. 실행에는 필요 없지만 이미지 크기와 공격 표면을 키웁니다. (참고로 JDK 베이스에 jar 만 COPY하면 Gradle, 소스는 남지 않지만 JRE 대신 JDK 를 사용하므로 컴파일러 등 런타임에 불필요한 요소는 여전히 남습니다.) 멀티스테이지는 빌드와 런타임을 두 stage로 나눕니다.
# Stage 1: Build, JDK로 jar를 만든다
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY gradlew settings.gradle build.gradle ./
COPY gradle/ gradle/
RUN chmod +x gradlew && ./gradlew dependencies --no-daemon # ← 의존성만 먼저
COPY src/ src/
RUN ./gradlew bootJar -x test --no-daemon
# Stage 2: Runtime, JRE로 jar만 실행한다
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/build/libs/app.jar app.jar # ← stage 1 산출물만 가져옴
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
USER appuser # ← non-root 실행
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
COPY --from=build 덕분에 최종 이미지(stage 2)는 stage 1에서 app.jar 한 파일만 가져옵니다. Gradle 도 소스도 JDK 도 따라오지 않습니다. 베이스도 jre(실행 전용, 컴파일러 없음)라 더 가볍습니다. linux/amd64 로 빌드해 레지스트리에 올린 결과 이미지는 약 193MB였습니다.
설계 결정 세 가지를 짚습니다.
jre베이스, 그러나 alpine은 아니다. 더 작은jre-alpine을 쓰지 않은 것은 의도된 타협입니다. Alpine 자체가 ARM64를 지원하지 않는 것은 아니지만eclipse-temurin:17-jre-alpine공식 태그가 amd64만 제공해 Apple Silicon(ARM64) 맥에서는 로컬 빌드가 불가능합니다. CI(amd64)에서는 빌드할 수 있지만 로컬 개발 호환성을 우선해 non-alpine 을 선택했습니다. 이미지 크기 수십 MB보다 “개발자가 자기 맥북에서 이미지를 빌드해 볼 수 있는가”를 우선했습니다.- non-root 실행.
appuser를 만들어USER appuser로 전환합니다. 컨테이너가 침해되더라도 root 권한은 없습니다. 컨테이너 보안의 기본기입니다. - stage 1에서
bootJar -x test로 테스트를 건너뛴다. 이미지 빌드 경로는 테스트를 다시 돌리지 않습니다. 테스트 책임은 CI 의./gradlew build가 이미 맡았으므로 이미지 빌드에서 또 실행하면 중복이고 느립니다. “테스트는 CI가, 패키징은 Dockerfile이” 책임을 나눈 것입니다.
4. 의존성 레이어 캐싱과 .dockerignore
Dockerfile 의 COPY 순서는 우연이 아닙니다. build.gradle/gradlew 를 먼저 COPY하고 ./gradlew dependencies 를 소스 COPY 전에 실행한 뒤, src/ 를 COPY합니다. Docker 는 각 명령을 레이어로 캐싱하고 입력이 바뀌지 않은 레이어는 재사용합니다. 소스만 수정하고 build.gradle 은 그대로라면, 이 의존성 레이어가 캐시에 hit 해 대부분의 의존성 재다운로드를 건너뛴다. 다만 dependencies 태스크는 본래 의존성 트리를 출력, 해석하는 리포트 태스크라 bootJar 가 실제로 쓰는 모든 artifact 를 100% 선다운로드한다고 보장하지는 않습니다. 그래서 이는 부분적인 캐시 최적화입니다. 그래도 소스는 자주 바뀌고 의존성은 드물게 바뀌므로 변하는 것을 뒤로 미루는 배치만으로도 hit rate 가 크게 오릅니다.
.dockerignore 는 빌드 컨텍스트를 줄입니다. Docker 는 빌드 시작 시 디렉터리 전체를 데몬에 보내는데 .git, build, docs, k8s, *.md 처럼 이미지에 불필요한 것을 제외합니다.
.git
.github
.gradle
.idea
build
docs
k8s
docker-compose.yml
*.md
여기에 더해 CI 는 GitHub Actions 캐시로 Docker 레이어를 빌드 간에도 공유합니다(cache-from: type=gha, cache-to: type=gha,mode=max). runner 는 매번 새로 뜨므로 로컬 레이어 캐시가 없는데 GHA 캐시가 그 공백을 메웁니다.
마지막으로 JAR 파일명을 app.jar로 고정했습니다. build.gradle 에서 bootJar { archiveFileName = 'app.jar' } 로 지정하고 jar { enabled = false } 로 plain JAR 을 끕니다. Dockerfile 의 COPY --from=build /app/build/libs/app.jar 이 글로브(*.jar) 없이 정확한 한 파일을 가리키게 하기 위해서입니다. 버전이 붙은 파일명(peekcart-0.0.1-SNAPSHOT.jar)이나 plain JAR까지 섞이면 *.jar 가 복수 매칭돼 빌드가 깨집니다. 이름을 고정해 그 모호성을 원천 제거했습니다.
구현 구조: ci.yml 한 줄씩
전체 흐름을 step 단위로 따라가 보겠습니다.
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # ← GHCR push에 필요한 최소 권한
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
cache: gradle # ← Gradle 의존성/래퍼 캐시
- run: chmod +x gradlew
먼저 코드를 받고(checkout), JDK 17을 설치하고(setup-java, cache: gradle 로 Gradle 의존성 캐싱), gradlew 에 실행 권한을 줍니다. permissions 를 contents: read + packages: write 로 좁혔습니다. 이 job 이 할 수 있는 일은 코드 읽기와 패키지(이미지) 쓰기뿐입니다. 다만 권한은 job 단위라, 이미지 step 을 건너뛰는 PR 실행에도 packages: write 가 주어집니다. 이미지 push 에만 쓰기 권한을 주려면 job 을 나눠야 합니다.
- name: Install lint dependencies
run: python3 -m pip install --user pyyaml
- name: Set up kubectl (for ServiceMonitor lint)
uses: azure/setup-kubectl@v4
- name: Run observability lints
run: |
bash scripts/observability-ssot-lint.sh
bash scripts/servicemonitor-selector-lint.sh
bash scripts/observability-promql-lint.sh
./gradlew build 앞에 “관측성 lint” 스크립트 3종이 있습니다. lint 는 코드를 실행하지 않고 설정 파일만 훑어 규칙 위반을 찾는 정적 검사를 뜻하고 여기서 검사 대상은 모니터링 설정입니다(메트릭 수집, 대시보드, 알림 설정이 서로 어긋나지 않는지 확인). 모니터링 관련 설정이 여러 파일에 흩어져 한 곳만 고치면 전체 일관성이 깨지기 쉬웠던 문제를 막기 위해 추가했습니다. 자세한 내용은 관측성 글로 넘깁니다.
이 글에서는 배치 순서만 봅니다. lint 는 몇 초면 끝나고 빌드, 테스트는 몇 분이 걸립니다. 그러니 싸고 빠른 검사를 비싼 빌드 앞에 두면, 설정 규칙을 어겼을 때 빌드 비용을 치르기 전에 바로 실패합니다. 이렇게 문제를 가능한 한 일찍 실패로 드러내는 배치를 fail-fast 라고 부릅니다. 비싼 단계일수록 뒤로 미룹니다.
- run: ./gradlew build --no-daemon
env:
SPRING_PROFILES_ACTIVE: test
- uses: actions/upload-artifact@v4
if: always() # ← 테스트가 실패해도 리포트는 올린다
with:
name: test-reports
path: build/reports/
이 한 줄(./gradlew build)이 단위 테스트와 통합 테스트를 함께 실행합니다(통합 테스트는 Testcontainers로 실제 인프라 기동). 바로 뒤 upload-artifact 의 if: always() 덕분에 빌드가 실패해도 테스트 리포트를 아티팩트로 올립니다. 실패했을 때야말로 리포트가 필요하기 때문입니다. 성공 시에만 올리면 정작 디버깅이 필요한 순간 리포트를 확인할 수 없습니다.
- uses: docker/login-action@v3
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
- uses: docker/setup-buildx-action@v3
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
- name: Set image name lowercase
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- uses: docker/build-push-action@v5
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
with:
context: .
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
${{ env.IMAGE_NAME_LC }}:latest
${{ env.IMAGE_NAME_LC }}:${{ github.sha }}
이미지 관련 step 4개는 모두 같은 if 로 묶여 main push에서만 실행됩니다. PR 에서는 모두 건너뜁니다. IMAGE_NAME 은 워크플로우 상단 env 에 ghcr.io/${{ github.repository_owner }}/peekcart 로 정의돼 있고, ${IMAGE_NAME,,} 는 bash 의 소문자화 문법입니다. GHCR 은 이미지 경로를 소문자로만 받으므로 owner 이름이 들어간 경로를 소문자로 바꿉니다. 태그는 :latest(편의용 최신 표시)와 :<sha>(그 이미지를 만든 git 커밋 해시)를 함께 답니다. 운영에서 “지금 떠 있는 이미지가 어느 커밋인가”를 SHA로 역추적할 수 있게 하는 추적 장치입니다(이후 부하 측정에서 실제로 이 SHA 태그로 “이 측정 결과가 어느 빌드였나”를 되짚었습니다. 캐시 효과 측정 글과 동시 주문 부하 테스트 글에서 다룹니다).
정리하면 ci.yml 의 동작 방식은 이렇습니다. PR은 “게이트”까지(lint + build + test), main push는 “게이트 + 산출물”까지(+ 이미지 build/push). 같은 파일이 두 역할을 조건부 step으로 겸합니다.
한계와 트레이드오프
Dockerfile은 PR에서 검증되지 않는다. 가장 큰 사각지대
이것이 단일 job + 조건부 step 설계에서 가장 먼저 짚어야 할 공백입니다. 이미지 빌드 step 은 if: ... == 'main' && push 라 PR에서는 실행되지 않는다. 그래서 누군가 Dockerfile 을 깨뜨리는 PR(예: COPY 경로 오타, 베이스 이미지 변경 실수)을 올려도 PR CI는 초록불입니다. Dockerfile 을 검증하지 않았기 때문입니다. 문제는 그 PR 이 main 에 머지되고 push가 트리거된 뒤에야 드러납니다. 즉 main 의 이미지 발행이 실패하는 형태로 늦게 발견됩니다.
게다가 Dockerfile 의 빌드 경로(bootJar -x test)는 CI 의 ./gradlew build 와 다른 명령입니다. CI 가 검증하는 것은 “테스트 포함 빌드가 되는가”이고 Dockerfile 이 하는 일은 “테스트 없이 jar를 패키징하는가”입니다. 둘은 겹치지만 같지 않아서 CI가 초록불이어도 이미지 빌드가 깨질 수 있다(반대도 가능). 이 공백을 보완하려면 PR 에서도 docker build 를(push 없이) 한 번 실행해 Dockerfile 자체를 검증하는 step 이 필요하지만 현재는 없습니다. Dockerfile 이 거의 바뀌지 않아 비용 대비 우선순위를 낮췄지만 명백한 검증 공백으로 남아 있습니다.
top-level concurrency가 “모든 main 커밋 = 이미지 1개”를 깬다
concurrency 가 top-level 이고 그룹 키가 ci-${{ github.ref }} 라, main 에 대한 모든 push 가 refs/heads/main 이라는 하나의 그룹을 공유합니다. cancel-in-progress: true 와 합쳐지면, 커밋 A의 워크플로우가 이미지 빌드 step 에 도달하기 전에 커밋 B가 push될 경우 A의 실행이 모두 취소된다. A의 이미지는 GHCR 에 올라가지 않습니다. 그래서 “main push마다 이미지가 남는다”는 보장이 없습니다. 이는 곧 이미지 정책 결정과 직결됩니다.
- **“최신 main만 배포하면 된다”**면 지금 설정이 맞습니다. 앞선 커밋의 이미지는 남기지 않아도 됩니다.
- **“모든 main 커밋의 이미지를 추적과 보존해야 한다”(롤백과 재현)**면 지금 설정은 버그입니다. concurrency 는 워크플로우나 job 단위로만 걸 수 있으므로, 이미지 push 를 별도 job 으로 나눠 그 job 에는 취소를 걸지 않거나, 그룹 키에
github.sha를 섞어 push 단위로 격리해야 합니다. 앞의 방법은 1절의 단일 job 결정을 다시 여는 일입니다.
뒤의 「증명」절에서 말하는 SHA 태그 추적성은 이미지가 일단 올라갔을 때 유효한 이야기입니다. 반면 이 절은 그 이미지가 애초에 올라가지 않을 수도 있다는, 한 단계 더 앞의 공백입니다. 이 둘을 함께 봐야 “추적 가능한 이미지를 빠짐없이 남긴다”를 비로소 지킬 수 있습니다.
이미지가 “실제로 뜨는가”는 CI가 검증하지 않는다
CI 는 이미지를 **빌드하고 레지스트리에 올리기(push)**까지만 합니다. 그 이미지를 실제로 실행해 /actuator/health(앱이 살아 있는지 알려주는 상태 점검 엔드포인트)가 200을 응답하는지, 앱이 정말 부팅되는지는 확인하지 않는다. 작업 당시에도 “이미지 기능성 미검증, 실제 환경에서 뜨는지는 한참 뒤 부하 테스트 때 처음으로 확인”이라고 남아 있습니다. 빌드가 성공해도 런타임이 정상이라는 뜻은 아닙니다. ENTRYPOINT 가 틀렸거나 런타임에만 필요한 설정이 빠져도 이미지 빌드는 성공합니다. smoke test(컨테이너를 띄우고 health 를 한 번 호출하기)를 CI 에 넣으면 이 공백을 보완할 수 있지만 지금은 그 책임이 배포 시점으로 미뤄져 있습니다.
Testcontainers의 편의성이 만든 부채
「선택한 설계」에서 “Testcontainers를 CI에서 추가 설정 없이 실행한다”는 점을 장점으로 들었습니다. 그 편의성에는 대가가 있었습니다. 통합 테스트가 각자 인프라를 선언하기 쉽다 보니, 통합 테스트 클래스 7개가 클래스마다 따로 MySQL/Redis/Kafka 컨테이너를 띄우게 됐습니다. 공통 베이스 클래스가 없어 설정도 제각각이고 그 결과 Spring 컨텍스트 캐시 hit 이 나지 않는다. 풀어 쓰면, Spring 은 테스트가 같은 설정을 쓰면 무거운 애플리케이션 컨텍스트(스프링이 띄우는 객체 묶음)를 한 번만 만들어 여러 테스트가 재사용하게 해 주는데 테스트마다 설정이 달라 재사용하지 못하고 테스트 클래스마다 컨텍스트를 처음부터 새로 띄운다는 뜻입니다. 통합 테스트가 늘수록 CI 시간과 유지보수 비용이 함께 악화될 구조입니다.
**1차 해결(2026-04-16)**에서는 AbstractIntegrationTest(cleanup 규약: cleanDatabase() FK 역순 DELETE + cleanCaches())와 IntegrationTestConfig(no-op SlackPort mock)를 도입해 7개 테스트를 마이그레이션했고, 이후 단위 테스트를 포함한 전체 테스트 244건이 통과했습니다.
| 1차에서 고친 것 | 1차에서 안 고친 것 (2차 목표로 남음) |
|---|---|
cleanup 정책 단일화(cleanDatabase/cleanCaches) | 컨테이너 수명 모델: 여전히 per-class 기동 |
| SlackPort mock 공통화(inner TestConfig 2개 제거) | Spring 컨텍스트 캐시 hit: @Import/@TestPropertySource 차이로 miss |
| 신규 테스트 작성 규약 확립 | unit/integration job 분리, 병렬 전략 |
이 작업을 시작하기 전 “지금 손볼 가치가 있는가”를 검토하면서 한 원칙을 정했습니다. “컨테이너 재사용과 컨텍스트 재사용을 한 번에 하지 말 것.”
테스트마다 설정과 정리(cleanup) 방식이 달라서 처음부터 모든 테스트를 하나의 완전 공유 구조로 밀어붙이면 속도를 얻기는커녕 flaky 테스트(실행할 때마다 결과가 달라지는 불안정 테스트) 위험만 커집니다.
그래서 1차에서는 규약 표준화만 하고 공유 최적화는 2차로 미뤘습니다. 불안정을 피하기 위해 의도적으로 단계를 나눈 것입니다. “표준화됨”은 달성하되 “최적화됨”은 후속 과제로 놔두었습니다.
단일 job의 비용: fail-fast가 약하다
단위 테스트와 통합 테스트가 한 test 태스크에서 함께 실행됩니다. 그래서 단위 테스트만 먼저 빠르게 실행해 일찍 실패시키는 fail-fast 가 약합니다. 단위 테스트가 실패하더라도, 느린 통합 테스트(컨테이너 기동 포함)가 끝나야 결과를 확인할 수 있습니다. job 을 unit/integration으로 나누면 개선되지만 이는 통합 테스트 인프라 정리의 2차 목표에 묶여 있습니다. 단일 job 은 현재 규모에는 충분하지만 규모가 커지면 비용이 커지는 구조입니다.
Gradle 캐시와 Docker 레이어 캐시는 별개다
setup-java 의 cache: gradle 은 Gradle 의존성을 캐싱합니다(테스트, 빌드 단계용). Docker type=gha 캐시는 이미지 레이어를 캐싱합니다(이미지 빌드 단계용). 둘은 서로 다른 캐시이며 작동하는 단계도 다릅니다. 게다가 이미지 빌드는 Dockerfile stage 1에서 Gradle 의존성을 다시 받는다. setup-java 캐시는 runner 의 Gradle 홈에 있고 빌드 컨텍스트 안에는 없으므로 Dockerfile 의 ./gradlew dependencies 에는 쓰이지 않습니다. 그래서 main push 시에는 의존성을 (CI 빌드용 + 이미지 빌드용) 사실상 두 번 받는 비효율이 있습니다. Docker 레이어 캐시가 두 번째 다운로드를 보완하지만 “한 빌드 안에서 Gradle이 두 맥락으로 실행되는” 중복은 단일 job + 멀티스테이지 조합의 구조적 비용입니다.
CI가 증명한 것과 못한 것
CI 파이프라인이 테스트로 무엇을 확인하는지 보면, 보장하는 것과 보장하지 못하는 것이 갈립니다.
증명하는 것
- 깨끗한 환경에서의 빌드와 테스트 검증. 매번 새 ubuntu runner 에서 단위 + 통합 테스트(실제 MySQL/Redis/Kafka) 전체가 통과함을 PR, push마다 자동 실행합니다(통합 테스트 인프라 1차 정리 시점에 단위 테스트를 포함해 244건, 이후 증가). “내 머신에선 되는데”의 환경 의존 문제를 해결합니다.
- 머지 전 가시성(단, 차단은 아직 아니다). PR마다 CI 가 실행돼 빌드, 테스트, lint 결과를 빨간불/초록불로 보여줍니다. 그러나 현재 main 에는 branch protection, ruleset 이 설정돼 있지 않아(
protected: false), 빨간불이어도 머지를 기계적으로 막지는 못한다. 막으려면build를 required status check로 지정해야 합니다. 지금은 “검증을 보여주는” 단계이지 “검증을 강제하는” 단계가 아닙니다. - 검증된 커밋의 산출물 추적성. push된 main 커밋에
:latest+:<sha>태그로 이미지를 GHCR 에 올려, “어느 커밋이 어느 이미지냐”를 SHA로 역추적할 수 있습니다. 단 이건 추적성이지 재현성이 아닙니다.:<sha>태그도,eclipse-temurin:17-jre같은 base 태그도 덮어쓸 수 있는 가변 포인터입니다. “이 이미지가 바이트 단위로 그 빌드와 동일함”을 보장하려면 image digest(@sha256:...)나 base image digest 고정이 따로 필요합니다(현재 미적용). 게다가 「한계」절의 concurrency 때문에 모든 main 커밋이 이미지를 남긴다는 보장도 없습니다.
증명하지 못하는 것
- Dockerfile의 정상성: PR 에서 실행하지 않으므로 깨져도 main push 전에는 모릅니다.
- 이미지의 런타임 기동: 빌드만 하고 실행하지 않으므로 “뜨는가”는 배포 시점까지 검증하지 않습니다.
- 통합 테스트의 효율적 구조: 통과는 하지만 per-class 컨테이너와 컨텍스트 캐시 miss 때문에, 통합 테스트 클래스가 늘수록 컨테이너와 컨텍스트 기동 시간이 그만큼 더해집니다(2차 정리 대상).
- 머지 차단: 위 「머지 전 가시성」에서 본 것처럼 branch protection 이 없어 빨간불을 기계적으로 막지는 못합니다.
- 모든 main 커밋의 이미지 보존: 「한계」절의 concurrency 취소로 일부 커밋의 이미지는 올라가지 않을 수 있습니다.
Consumer 멱등성 글(중복 메시지를 한 번만 처리)과 ShedLock 글(스케줄러 중복 실행 방지)에서도 같은 패턴이 반복됩니다. ShedLock 테스트는 “락이 배선돼 동작한다”까지만 검증하고 “여러 인스턴스 중 하나만 실행된다”는 핵심 보장은 공백으로 남겼습니다. 핵심 동작은 단단히 검증하되, 가장 중요한 보장의 일부는 테스트 공백으로 정직하게 남겨 둔다. CI 에서 그 공백은 위 목록의 Dockerfile, 런타임, 테스트 효율, 머지 차단, 이미지 보존입니다.
Phase 4 MSA에서는 어떻게 바뀌는가
지금까지의 단일 파이프라인은 “모놀리스 하나를 빌드해 이미지 하나를 만든다”는 전제를 따랐습니다. Phase 4에서 서비스가 N개로 나뉘면 이 전제도 달라집니다.
flowchart TD
subgraph Now["Phase 3 (모놀리스)"]
R1["1 repo / 1 ci.yml"] --> J1["1 build job"] --> I1["1 image<br/>peekcart:sha"]
end
subgraph P4["Phase 4 (MSA)"]
R2["멀티모듈 repo"] --> M["변경 감지<br/>(path filter)"]
M --> JA["order build job"] --> IA["order-svc:sha"]
M --> JB["payment build job"] --> IB["payment-svc:sha"]
M --> JC["...각 서비스"] --> IC["...:sha"]
end
그대로 가는 것
- 멀티스테이지 + non-root +
.dockerignore의 이미지 빌드 패턴. 서비스가 늘어도 각 서비스 이미지를 만드는 방식 자체는 동일합니다. Dockerfile 하나를 서비스마다 재사용하거나 공통 base 이미지로 추출합니다. - “테스트는 CI가, 패키징은 Dockerfile이”의 책임 분리. 서비스별로 나뉘어도 이 원칙은 유지됩니다.
- SHA 태그 추적성. 오히려 더 중요해집니다. 서비스 N개의 버전 조합이 늘어나, “지금 운영의 order-svc는 어느 커밋, payment-svc는 어느 커밋”을 SHA로 묶어 추적해야 롤백, 디버깅이 가능합니다.
바뀌는 것
- 빌드가 멀티모듈로 갈라진다. 여러 서비스가 공통으로 쓰는 코드를 모으는
common모듈에 무엇을 넣느냐가 빌드에 직결됩니다(이 학습 로드맵의 Phase 4 점검 항목에 해당).common이 바뀌면 모든 서비스를 다시 빌드해야 하고order모듈만 바뀌면order만 빌드하면 됩니다. 이를 구분하려면 변경된 경로의 서비스만 골라 빌드하는 방식(path filter)이나 빌드 의존 그래프 기반의 선택 빌드가 필요합니다. 지금처럼 “무조건 전체 빌드”를 하는 방식은 서비스가 여럿이면 너무 느립니다. - 단일 job 에서 서비스별 job 으로. 단일 job 은 더 이상 맞지 않습니다. 서비스마다 독립 job으로 병렬 빌드, 테스트하고 변경된 서비스만 골라 실행하는 구조(한 정의로 여러 대상을 병렬 실행하는 GitHub Actions 의 matrix 기능)가 필요합니다. 앞서 미룬 2차 목표(unit/integration 분리)도 여기서 자연스럽게 합류합니다.
- 이미지가 N개, 레지스트리 경로도 N개.
peekcart:sha하나가order-svc:sha,payment-svc:sha등으로 늘어납니다. 태그 전략, 캐시 공유, 이미지 크기 관리의 부담도 서비스 수만큼 늘어납니다.
그래서 Phase 4 진입 전 짚을 점
- Dockerfile, 런타임 검증 공백을 메우고 가야 한다. 모놀리스 하나일 때는 “main이 깨지면 바로 안다”로 대응할 수 있었지만 서비스가 N개일 때는 한 서비스 이미지가 뜨지 않아도 다른 서비스에 가려 늦게 발견됩니다. PR 에서
docker build(push 없이) + smoke test(health 200 확인)를 게이트에 넣는 것이 부채 점검 글에서 챙길 항목입니다. - 통합 테스트 2차 정리(컨텍스트 재사용)를 멀티모듈 전환과 묶는다. 모듈이 갈라지면 각 모듈의 통합 테스트도 갈라지므로 지금 per-class 컨테이너, 컨텍스트 캐시 miss 를 그대로 가져가면 서비스 수만큼 CI 시간이 급증합니다. 멀티모듈 분리 시점이 컨텍스트 재사용 구조를 도입할 자연스러운 지점입니다.
common모듈 변경의 폭발 반경을 관리한다.common을 한 줄 바꿀 때마다 모든 서비스가 다시 빌드, 배포되면 모놀리스보다 나을 게 없습니다. 무엇을common에 넣고 무엇을 서비스 안에 두는가가 곧 CI 빌드 시간과 배포 단위를 결정합니다. 같은 부채 점검 글의 핵심 질문과 직결됩니다.
자료는 어떤 질문에 연결해서 읽을까
| 질문 | 같이 읽을 자료 | 이 글에서 연결되는 지점 |
|---|---|---|
| CI 워크플로우 문법(트리거/job/step/if) | GitHub Actions 공식 문서, Workflow syntax | ci.yml 전반 |
| 멀티스테이지 빌드와 레이어 캐싱 | Docker 공식 Multi-stage builds / Build cache | Dockerfile 절 |
Testcontainers + @ServiceConnection | Spring Boot Reference, Testcontainers at Development Time | ”추가 설정 없이 실행한다” 절 |