포스트

모노레포 배포 시간 실측과 단축기, 35분 걸리던 백엔드 전체 배포를 13분으로

백엔드 10개·웹 13개 모노레포의 배포 73회를 실측해 보니 60%가 전체 배포였고, 원인은 공용 모듈의 응답 코드 enum 하나와 Dockerfile의 chown -R 한 줄이었습니다. 응답 코드를 서비스별로 나누고, 이미지가 나오는 대로 배포하고, 프런트·Flutter는 실제 의존 그래프로 영향 범위만 빌드·검사하게 바꿔 전체 배포를 35.6분에서 13.7분으로 줄인 과정을 정리합니다.

모노레포 배포 시간 실측과 단축기, 35분 걸리던 백엔드 전체 배포를 13분으로

요약

  • 문제: 백엔드 하나를 고쳐도 배포가 30분을 넘기는 일이 잦았습니다. 실측해 보니 dev 배포 73회 중 44회가 10개 서비스 전체 배포였습니다.
  • 원인: ① 모든 서비스가 쓰는 공용 모듈(core)에 응답 코드 enum 하나가 있어서, 기능 하나에 코드 하나만 추가해도 10개가 다 다시 배포됐고, ② Dockerfile 마지막 chown -R이 145MB 레이어를 매번 새로 만들었습니다.
  • 해결: 응답 코드를 서비스별 enum으로 나누고, 이미지가 나오는 대로 바로 배포하고, 빌드 캐시를 다시 켰습니다. 프런트와 Flutter는 실제 의존 그래프로 바뀐 패키지와 그 소비자만 빌드·검사합니다.
  • 결과: 백엔드 전체 배포 35.6분 → 13.7분, dev push 전체 CI 15.4분 → 10.5분. Flutter 기능 PR 검사 대상 10개 → 2개 패키지.

들어가며

Kamal로 무중단 배포를 도입한 뒤, 배포 중 공백은 없어졌는데 이번엔 배포 자체가 오래 걸리는 게 눈에 들어왔습니다. 백엔드 한 줄을 고치고 머지했는데 dev에 반영되기까지 30분 넘게 기다리는 일이 잦았습니다.

느낌으로 “core를 고치면 다 배포돼서 그렇겠지” 하고 넘어갈 수도 있었지만, 이번에는 먼저 재 보기로 했습니다. 어디가 느린지 모르고 고치면, 고친 게 효과가 있었는지도 알 수 없기 때문입니다.

구성은 이렇습니다.

  • 백엔드: Kotlin + Spring Boot 서비스 10개, Gradle 멀티 모듈(공용 core + 서비스별 -api/-domain)
  • 웹: Next.js 앱 13개(pnpm workspace + Turborepo), 모바일: Flutter 패키지 10개
  • CI/CD: GitHub Actions self-hosted runner → GHCR → Kamal, 서버 한 대

배포 시간은 어디서 새고 있었나 — 73회 실측 📏

배포 시간은 거의 “배포한 서비스 수”에 비례했고, 그 서비스 수를 키운 주범은 공용 모듈의 enum 하나였습니다.

측정은 단순하게 했습니다. 2026-10-15부터 11-08까지 성공한 dev 백엔드 배포 73회에 대해

  1. gh api .../runs, .../jobs로 잡별 시작·종료 시각을 받고
  2. 직전 성공 실행의 커밋과 이번 커밋 사이의 git diff로 바뀐 경로를 뽑아
  3. “어떤 경로가 바뀌어서 몇 개 서비스가 배포됐는지”를 짝지었습니다.

결과는 이랬습니다.

항목값
10개 전체 배포 비율44 / 73회 (60%)
전체 배포 1회 소요 (중앙값)35.6분 (이미지 빌드·push 24.9분 + Kamal 9.2분)
회귀식배포 시간 ≈ 5.9분 + 3.45분 × 서비스 수
서비스당 이미지 push (중앙값)55초 (최대 117초)

서비스 하나가 3.45분이니, 전체 배포를 줄이는 게 가장 큰 레버였습니다. 전체 배포를 일으킨 경로를 세 보면:

기능 하나에 응답 코드 하나를 추가해도 core가 바뀌어 10개 서비스가 모두 다시 배포됐다

  1. core의 ExceptionCode.kt·SuccessCode.kt — 응답 코드 1,350개가 든 enum 두 개. 기능을 만들 때마다 코드가 하나씩 추가되니, 결재 서비스 기능을 만들어도 영업 서비스까지 다시 배포됐습니다.
  2. 배포 파이프라인 파일 변경 — Kamal 전환 기간이라 워크플로를 자주 고쳤는데, 이것만으로 전체 배포된 게 13회였습니다.
  3. 내부 계정 클라이언트, 권한 enum(RoleType), 인증 설정처럼 일부 서비스만 쓰는데 core에 있는 것들.

그리고 서비스 수와 상관없이 매번 새는 시간이 두 개 더 있었습니다. 이미지 push가 이상하게 오래 걸렸고(뒤에서 다룹니다), Kamal 배포는 모든 이미지 push가 끝난 뒤에야 한 서비스씩 직렬로 시작했습니다.

고치기 전에 시뮬레이션부터

73회의 “바뀐 경로” 데이터가 있으니, 규칙을 바꿨다면 몇 개 서비스가 배포됐을지 다시 계산해 볼 수 있습니다(회당 평균 30.7분 기준).

개선안예상 단축
서비스별 src/main만 보고, 파이프라인만 바뀐 커밋은 제외-12%
core 안에서 실제로 쓰는 서비스만 매핑-15%
응답 코드를 서비스별로 분리-26%
위 전부 + Dockerfile 수정 + 이미지 나오는 대로 배포-47% (16.3분)

응답 코드 분리가 가장 크고, 나머지와 합치면 절반 가까이 줄어든다는 예측이 나왔습니다. 이 순서대로 고쳤습니다.

core의 응답 코드 enum을 서비스별로 나누기 ✂️

core에는 인터페이스만 남기고, 한 서비스만 쓰는 코드 962개를 각 서비스의 -api 모듈 enum으로 옮겼습니다. 응답 JSON의 code 문자열은 그대로라서 API 계약과 프런트 타입은 바뀌지 않습니다.

1
2
3
4
5
6
7
8
9
10
11
12
// core: 인터페이스만
interface ApiResponseCode { val name: String; val httpStatus: HttpStatus; val message: String }
interface ApiErrorCode : ApiResponseCode

// account-api: 이 서비스만 쓰는 코드
enum class AccountExceptionCode(
    override val httpStatus: HttpStatus,
    override val message: String,
) : ApiErrorCode {
    USER_TRANSFER_SAME_COMPANY(BAD_REQUEST, "..."),
    // ...
}

CustomException과 응답 DTO는 enum 대신 ApiErrorCode 인터페이스를 받게 바꿨습니다. core에 남긴 건 세 종류뿐입니다.

  • 여러 서비스가 함께 쓰는 코드
  • 필드 이름으로 코드 이름을 조립하는 검증 코드(*_FIELD_REQUIRED)
  • domain 모듈이 쓰는 코드 (domain에는 spring-web이 없어서 HttpStatus를 못 씁니다. 그래서 서비스 enum을 -api에 뒀습니다)

결과적으로 core의 응답 코드는 1,350개에서 388개로 줄었습니다.

enum이 흩어지면 이름이 겹칠 수 있다는 게 걱정이었습니다. 응답 code 문자열이 같은데 의미가 다르면 프런트가 헷갈리니까요. 그래서 모듈마다 ResponseCodeNamesTest를 두어 core와 이름이 겹치지 않는지 검사하고, 다른 -api 모듈은 컴파일 의존이 없으니 소스를 정규식으로 훑어서 확인합니다.

이 PR 하나를 머지한 직후 전체 배포가 35.6분 → 18.2분이 됐습니다. 이후 같은 방식으로 권한 enum을 별도 :role 모듈로, 영업 서비스 내부 클라이언트를 :sales-client 모듈로, 한 서비스만 쓰는 Provider를 그 서비스로 옮겼습니다.

권한 enum은 계정·배치·MCP 서비스만 런타임에 의존하고, 나머지 서비스는 SalesRoles.ADMIN 같은 문자열 상수를 씁니다. 상수가 RoleType과 어긋나면 테스트가 깨지고, 혹시 어긋나도 권한이 “거부”되는 쪽으로 틀리게 해 두었습니다.

Dockerfile의 chown -R 한 줄이 145MB를 매번 올리고 있었다 🐳

COPY 다음에 RUN chown -R을 하면 복사한 파일 전체가 새 레이어로 다시 쓰여서, 의존성이 그대로여도 그 레이어는 매번 새로 push됩니다. COPY --chown으로 바꾸니 코드만 바뀐 배포에서는 14MB짜리 애플리케이션 레이어만 올라갑니다.

chown -R이 의존성 레이어까지 다시 써서 매번 145MB가 새로 올라갔다

1
2
3
4
5
6
7
8
9
10
11
12
# before — 마지막 chown -R 이 의존성(130MB)+앱(14MB)을 새 레이어(145MB)로 다시 쓴다
COPY --from=extract /extract/extracted/dependencies/ ./
COPY --from=extract /extract/extracted/application/ ./
RUN groupadd --system --gid 10001 app && useradd ... app && \
    chown -R app:app /app

# after — 사용자를 먼저 만들고, 복사할 때 소유자를 지정한다
RUN groupadd --system --gid 10001 app && useradd ... app
WORKDIR /app
RUN chown app:app /app
COPY --from=extract --chown=app:app /extract/extracted/dependencies/ ./
COPY --from=extract --chown=app:app /extract/extracted/application/ ./

서비스당 push가 55초 → 약 4초가 됐습니다. 서비스 10개면 이것만으로 몇 분입니다.

같은 맥락에서 BuildKit 레지스트리 캐시도 mode=max → mode=min으로 바꿨습니다. mode=max는 중간 단계(jar를 푸는 extract 단계)까지 캐시로 내보내는데, 그 단계의 산출물은 빌드마다 달라서 재사용은 안 되고 내보내기에만 60초가 걸리고 있었습니다.

1
2
- cache-to: type=registry,ref=ghcr.io/<owner>/<image>:buildcache-dev,mode=max
+ cache-to: type=registry,ref=ghcr.io/<owner>/<image>:buildcache-dev,mode=min

카나리 서비스 기준 docker 단계가 129초 → 44초, 캐시 내보내기가 60초 → 11초로 줄었습니다.

이미지가 나오는 대로 바로 배포하기 🚚

Kamal 배포를 composite action으로 빼서, 각 서비스의 이미지 push 잡이 끝나자마자 그 잡 안에서 바로 배포하게 했습니다. 예전에는 10개 push가 전부 끝난 뒤에 배포 잡이 한 서비스씩 직렬로 돌았습니다.

모든 push가 끝나길 기다리지 않고, 이미지가 나온 서비스부터 배포한다

같은 서버에 동시에 배포가 겹치는 건 원래 걸어 둔 서버 단위 flock이 줄을 세워 줍니다. 그래서 “push는 병렬, 배포는 도착한 순서대로 직렬”이 됩니다.

이때 무엇을 배포할지 정하는 규칙도 다시 짰습니다. 경로를 세 종류로 나눕니다.

1
2
3
4
5
6
7
shared:   ['backend/core/src/main/**', 'backend/core/build.gradle',
           'backend/build.gradle', 'backend/docker/**']          # → 전체 배포
pipeline: ['.github/workflows/backend-publish-dev.yml',
           '.github/actions/kamal-deploy/**']                    # → 카나리 하나만
account-api: ['backend/account-api/**', 'backend/account-domain/**',
              'backend/role/src/main/**',
              'deploy/kamal/deploy.account-backend*.yml']         # → 그 서비스만

바뀐 경로를 shared·pipeline·서비스로 분류해 전체·카나리·선택 배포를 정한다

  • core라도 src/main과 빌드 파일만 봅니다. core 테스트만 바꾼 커밋은 배포하지 않습니다.
  • 배포 절차만 바뀐 커밋은 카나리 서비스 하나만 배포합니다. 배포 스크립트가 깨졌는지 확인하는 데는 하나면 충분합니다.
  • 비교 기준 커밋은 “마지막으로 성공한 push 이벤트 실행”입니다. 수동으로 일부만 배포한 실행이 기준이 되면, 그 사이의 배포 안 된 변경을 건너뛰게 되기 때문입니다.
  • 그래도 전체가 필요할 때를 위해 workflow_dispatch에 modules 입력을 두었습니다(비우면 전체).

컴파일은 캐시, 테스트는 캐시 없이

dev push와 야간 CI는 그동안 --no-build-cache로 돌고 있었습니다. “날짜에 따라 깨지는 테스트를 잡으려면 캐시 없이 돌려야 한다”는 이유였는데, 그 바람에 core와 domain도 매번 처음부터 컴파일했습니다. 필요한 건 테스트만 캐시 없이 도는 것이었습니다.

1
2
3
4
5
6
tasks.withType(Test).configureEach {
    if (project.hasProperty('noTestCache')) {
        outputs.cacheIf { false }
        outputs.upToDateWhen { false }
    }
}
1
2
GRADLE_CACHE: --build-cache
GRADLE_TEST_CACHE: ${{ github.event_name == 'pull_request' && '-PtestCache' || '-PnoTestCache' }}

프런트·Flutter: 실제 의존 그래프로 영향 범위만 📦

프런트와 Flutter는 경로 규칙 대신 패키지 매니저가 아는 실제 의존 그래프로 “바뀐 패키지 + 그걸 쓰는 앱”을 계산합니다. 백엔드처럼 경로를 손으로 매핑하면 workspace 패키지가 늘어날 때마다 규칙을 고쳐야 하기 때문입니다.

웹은 package.json의 workspace 의존을 따라 전이 소비자까지 넓힙니다. 의존을 지운 변경도 놓치지 않도록 이전 그래프와 현재 그래프를 둘 다 봅니다.

1
2
3
4
5
6
7
8
while True:
    expanded = selected | {
        d["name"] for d in graph.values()
        if any(selected.intersection(d.get(k, {})) for k in DEPENDENCIES)
    }
    if expanded == selected:
        break
    selected = expanded

lockfile, Turborepo 설정, 공용 Dockerfile처럼 판단이 어려운 경로는 전체로, 테스트·README만 바뀌면 아무것도 빌드하지 않습니다. 이미지는 turbo prune <앱> --docker로 앱별 컨텍스트만 보내서 영업 서비스 앱의 Docker 컨텍스트가 약 98MB → 16.7MB가 됐습니다.

Flutter는 flutter pub deps --json과 package_config.json을 읽어서 같은 계산을 합니다. pubspec을 직접 파싱하지 않는 게 포인트입니다.

공용 ui 패키지는 앱 11개가 쓰지만, 떼어 낸 records 패키지는 3개만 쓴다

그래프가 생기니 패키지를 어떻게 나누느냐가 곧 빌드 범위가 됩니다. 레코드 공유 UI는 앱 3개만 쓰는데 공용 ui 패키지에 들어 있어서, 고칠 때마다 ui를 쓰는 앱 11개가 다 검사·배포됐습니다. 이걸 @my/records 패키지로 떼어 내 영향 범위를 3개로 줄였습니다(ui가 records를 다시 export하지 못하게 테스트로 막았습니다).

대상전후
Flutter 기능 패키지 변경 시 PR 검사패키지 10개2개 (기능 + 셸)
레코드 UI 변경 시 영향 앱11개3개
웹 앱 Docker 컨텍스트(영업 서비스)~98MB16.7MB
앱과 무관한 push(.gitignore만 변경)모바일 빌드 전체16초에 스킵

만난 함정들 🪤

KSP가 오류 없이 QueryDSL Q 클래스 일부를 빠뜨리고, 그게 빌드 캐시에 들어가 버렸습니다. 빌드 캐시를 다시 켜고 나서 같은 입력의 빌드가 계속 이렇게 실패했습니다.

1
e: Unresolved reference 'Q…'

--parallel로 같은 데몬에서 KSP가 동시에 돌 때 생기는 것으로 보이는데, 문제는 오류 없이 끝난 불완전한 산출물이 캐시에 저장된다는 점이었습니다. 한 번 들어가면 같은 입력은 계속 그 산출물을 받아 옵니다. KSP 태스크는 캐시하지 않게 하고(outputs.cacheIf { false }), 엔티티가 없는 모듈은 KSP를 껐습니다. 모듈당 몇 초~20초를 더 쓰는 대신 재현 안 되는 실패를 없앴습니다.

한글 파일명은 git diff --name-only에서 따옴표로 감싸여 나옵니다. "app/\355\225\234..."처럼 C 스타일로 이스케이프되면 startswith("app/")에 안 걸려서 검사 대상에서 빠집니다. -z로 받아 NUL로 자르게 고쳤습니다.

1
2
3
changed = check_output(
    ["git", "diff", "--name-only", "--no-renames", "-z", merge_base, "HEAD"]
).split("\0")

GitHub API의 status=success 필터가 몇 주 전 실행을 돌려준 적이 있습니다. 비교 기준이 몇 주 전으로 가면 그 사이 모든 변경이 “바뀐 것”이 되어 전체 배포가 됩니다. 필터 없이 최근 실행을 받아 jq에서 고르게 바꿨습니다.

결과 ✨

측정전후
백엔드 10개 전체 배포35.6분 (중앙값)13.7분
dev push 전체 CI15.4분 (중앙값)10.5분
서비스당 이미지 push55초약 4초
웹 13개 전체 배포32.4분 (중앙값, 6회)19분 (1회)
Android AAB 잡 전체19분11분 26초 (1회)

단계별로 보면 응답 코드 분리 후 18.2분, 빌드 캐시 후 15.9분, core 정리 후 13.7분이었습니다. 시뮬레이션의 예측(16.3분)보다 조금 더 줄었습니다.

다만 이 숫자들은 통제된 실험이 아닙니다. “후” 값은 대부분 1회 관측이고, 러너 부하나 캐시 상태가 같지 않습니다. 특히 웹은 앱 수만 맞춰 비교했고 새 캐시를 처음 채운 실행이라, “41% 단축” 같은 비율로 말하지 않으려고 합니다. 반복 측정은 앞으로 쌓아 갈 일입니다.

남은 일도 있습니다. 내부 계정 클라이언트는 아직 core에 있어서(9개 모듈이 다른 메서드를 씀) 소비자별로 나눠야 하고, PR CI와 배포 잡이 같은 러너의 Gradle 캐시를 공유하는 건 배포 전용 러너를 따로 둬야 풀립니다.

정리

  • 배포가 느리다면 먼저 실행 기록과 git diff를 짝지어 “무엇이 바뀌어서 몇 개가 배포됐는지”를 세 보세요. 저는 그게 공용 모듈의 enum 하나였습니다.
  • 모노레포에서 공용 모듈에 무엇을 두느냐가 곧 배포 범위입니다. 한 서비스만 쓰는 코드는 그 서비스에 둡니다.
  • Dockerfile에서 COPY 뒤 chown -R은 COPY --chown으로 바꾸세요. 레이어가 통째로 다시 쓰입니다.
  • “테스트를 캐시 없이”가 필요하면 빌드 캐시 전체를 끄지 말고 Test 태스크만 캐시에서 빼세요.
  • 영향 범위 계산은 손으로 쓴 경로 규칙보다 패키지 매니저의 실제 의존 그래프가 오래 갑니다.

참고

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.