grep

Engineering

CMS 모노레포 개선기: 빌드 시간 단축부터 번들 최적화까지

Giri여기어때

2026년 4월 24일

원문에서 보기 ↗

안녕하세요 여기어때컴퍼니 백오피스웹개발팀 기리입니다.

저희 팀은 여기어때 통합CMS를 관리하고 있습니다. 10여 개 Next.js 앱을 하나의 모노레포에서 운영하고 있는데, 배포할 때마다 약 14분을 기다려야 했습니다. 앱은 계속 추가되고 있었기 때문에, 손을 대지 않으면 배포 시간은 더 늘어날 수밖에 없었습니다.

코드 한 줄 수정하고 dev 환경에서 확인하려면 14분, 핫픽스도 14분. 하루에도 여러 번 배포하는 저희에게 이 대기 시간은 개발 생산성에 직접적인 영향을 주고 있었습니다.

이 글에서는 빌드 파이프라인을 분석하고, 배포 시간을 약 40% 단축한 과정을 정리해보았습니다.

통합CMS의 빌드 구조

저희 통합CMS는 아래와 같은 Spring Boot + Next.js가 결합된 구조로 되어있습니다.

간단히 설명하면, Spring Boot 서버가 인증/라우팅을 담당하고 프론트엔드 정적 파일을 Thymeleaf 템플릿으로 서빙하는 구조입니다.

정적 배포 구조이기 때문에 Next.js의 SSR은 사용할 수 없고, Static Export 모드로 빌드해야 합니다.

// next.config.mjs (각 앱 공통 패턴)
const nextConfig = {
  output: 'export',           // 정적 HTML/JS/CSS만 생성
  assetPrefix: '/js/service/app-name/', // Spring Boot 정적 리소스 경로
  basePath: '/app-name',      // Spring Boot 라우팅 경로
  ...
};

빌드된 정적 파일은 빌드 후처리 스크립트를 통해 Thymeleaf 템플릿으로 변환되고, 사용자 인증·권한 정보가 주입됩니다.

현재 모노레포에는 10여 개 Next.js 앱과 공유 패키지(@packages/ui, @packages/service 등)가 있으며, 모든 앱의 빌드 결과물이 하나의 Spring Boot JAR에 포함됩니다.

배포 시 기존 빌드 파일을 전부 삭제하고 새로 복사하기 때문에, 프론트엔드 앱 하나만 수정하더라도 전체 앱을 빌드해야 합니다. 마이크로서비스로 분리하면 앱별 독립 배포가 가능하지만, 인증/권한 체계와 운영 인프라를 고려하면 현실적으로 쉽지 않은 선택이었습니다.

그래서 저희는 현재 아키텍처를 유지하면서 빌드 시간을 줄이는 방법을 찾아야 했습니다.

개선 전 상황: 왜 약 14분이 걸렸나

빌드 파이프라인을 분석해 보니 병목은 명확했습니다.

이를 시각화하면 다음과 같습니다.

가장 큰 병목은 Next.js 빌드였습니다. 10여 개 앱을 하나씩 순서대로 빌드하고 있었고, TurboRepo의 concurrency가 1로 설정되어 있어 빌드 서버의 자원을 제대로 활용하지 못하는 상태였습니다.

개선 1: 빌드 환경 정비

빌드 환경부터 먼저 정리했습니다.

.dockerignore를 강화하여 node_modules, .git, 테스트 파일, 빌드 산출물 등 빌드에 불필요한 파일을 제외했습니다. 빌드 환경으로 전송되는 파일이 줄어들면서 초기 준비 시간이 체감될 정도로 단축됐습니다.

또한 이전 빌드 이미지를 캐시로 재활용하도록 설정하여, 시스템 패키지 설치나 의존성 설치처럼 변경 빈도가 낮은 단계는 빌드를 건너뛸 수 있게 됐습니다.

개선 2: TurboRepo 병렬 빌드 + 자동 재시도

가장 큰 효과를 본 변경입니다.

10여 개 앱의 Next.js 빌드를 순차에서 병렬로 전환했습니다.

기존 순차 빌드와 병렬 빌드의 차이는 다음과 같습니다.

TurboRepo 캐시 최적화

병렬 빌드와 함께, TurboRepo의 캐시 설정도 최적화했습니다.

turbo.json에서 어떤 파일이 바뀌었을 때 다시 빌드할지(inputs), 빌드 결과물은 어디에 있는지(outputs)를 지정하면 TurboRepo가 변경 여부를 정확히 판단하여 불필요한 재빌드를 건너뜁니다.

{
  "tasks": {
    "build": {
      "inputs": [
        "src/**", "pages/**", "public/**",
        "package.json", "next.config.*",
        "!**/*.test.*", "!**/*.spec.*",
        "!**/__tests__/**"
      ],
      "outputs": [".next/**", "!.next/cache/**", "out/**"]
    }
  }
}

테스트 파일(*.test.*, *.spec.*)을 inputs에서 제외하여, 테스트 코드만 수정했을 때는 빌드 캐시가 유효하게 유지됩니다.

outputs에서 .next/cache/**를 제외한 것은 Next.js 내부 캐시가 TurboRepo 캐시와 충돌하는 것을 방지하기 위해서입니다.

병렬 빌드 설정

실제 빌드 스크립트에서는 다음과 같이 실행합니다.

yarn turbo run build \
  --filter={앱들} \       # 빌드할 앱 지정
  --concurrency=3 \       # 동시에 3개 앱까지 병렬 빌드
  --output-logs=full \    # 빌드 로그 전체 출력
  --log-order=stream \    # 로그를 실시간으로 출력
  --continue              # 한 앱이 실패해도 나머지 계속 빌드

--concurrency=3으로 설정한 이유는, Next.js 빌드가 앱당 메모리를 많이 사용하기 때문입니다. 빌드 서버 메모리를 고려했을 때 3개가 안정적인 상한이었고, 4개 이상 동시에 돌리면 메모리 부족으로 빌드가 죽기도 했습니다.

실패 시 자동 재시도

병렬 빌드의 부작용으로 메모리가 부족한 순간에 간헐적으로 빌드가 실패하기도 했습니다. 이를 해결하기 위해 실패 시 concurrency=1로 재시도하는 로직을 추가하게 됐습니다.

# 1차 시도: 3개 동시에 빌드
yarn turbo run build ... --concurrency=$TURBO_CONCURRENCY --continue
BUILD_EXIT="$?"          # 빌드 결과 저장
if [ "$BUILD_EXIT" -ne 0 ]; then  # 실패했으면
  # 2차 시도: 1개씩 안전하게 재시도
  yarn turbo run build ... --concurrency=1
fi

빠르게 빌드하되, 실패하면 안전하게 재시도하는 전략입니다.

TurboRepo는 빌드 성공한 앱의 결과를 .turbo 로컬 캐시에 저장합니다. 1차에서 3개 중 2개가 성공하고 1개가 실패한 경우, 2차 재시도 시 성공한 2개는 이미 빌드된 결과를 재사용하여 건너뛰고 실패한 1개만 다시 빌드합니다. 이 덕분에 재시도 비용이 매우 낮습니다.

--continue 플래그도 중요합니다. 한 앱이 실패해도 나머지 앱 빌드를 계속 진행하기 때문에, 재시도 시 이미 성공한 앱은 건너뛸 수 있습니다.

개선 3: 빌드 안정성 확보

빌드 속도만큼 중요한 것이 빌드 안정성입니다. 빠르지만 자주 실패하는 빌드는 의미가 없으니까요.

빌드 서버에서 Docker 이미지가 누적되면 디스크 부족으로 빌드가 실패하는 문제가 있었습니다. 빌드 전에 디스크 여유 공간과 가용 메모리를 사전 체크하고, 부족하면 자동으로 정리하도록 구성했습니다. 앞서 설명한 병렬 빌드 재시도 로직과 함께, 리소스 부족에 의한 빌드 실패를 최소화하게 됐습니다.

결과

개선 1~3을 적용한 뒤, 배포 시간이 약 14분에서 약 8분으로, 약 43% 단축되었습니다.

배포 한 번당 약 6분을 절약할 수 있었습니다.

한 걸음 더: 번들 사이즈 최적화

빌드 속도를 줄인 뒤, 자연스럽게 다음 질문이 떠올랐습니다. “빌드 결과물 자체도 줄일 수 있지 않을까?”

빌드 시간은 개발자의 대기 시간이지만, 번들 사이즈는 사용자의 로딩 시간에 직접 영향을 줍니다. 모노레포의 공유 패키지(@packages/ui)를 통해 10여 개 앱이 같은 의존성을 공유하고 있었기 때문에, 한 곳의 최적화가 전체 앱에 전파되는 구조였습니다.

AI를 활용한 대규모 코드베이스 분석

10여 개 앱, 수천 개 파일로 이루어진 모노레포에서 “어디서 무엇이 번들을 키우고 있는지”를 사람이 일일이 찾는 건 현실적으로 어렵습니다. 저희는 AI 코딩 도구를 활용해 분석을 진행했습니다.

먼저 모노레포 전체 구조와 빌드 설정, 의존성 관계를 파악한 뒤, lodash 사용처 확인, 사이드이펙트 분석, 네이티브 대체 가능성 판단을 동시에 진행했습니다. 이 분석 결과를 바탕으로 단계별 개선 계획을 세웠습니다.

기존에는 엄두조차 나지 않았던 “20여 개 파일에 걸친 lodash import 패턴 확인”을 AI 에이전트가 병렬로 수 분 만에 완료했습니다. 사람이 했다면 하루 이상 걸렸을 작업입니다.

분석 결과: 무엇이 번들을 키우고 있었나

번들 분석을 해보니 두 가지 명확한 문제가 보였습니다.

스피너 애니메이션에 사용하는 Lottie가 SVG, Canvas, HTML 세 가지 렌더러를 모두 포함한 채로 번들에 들어가고 있었습니다. 실제로는 SVG 렌더러만 사용하고 있었습니다.

// Before: 전체 번들 (~1.6MB)
import lottie from 'lottie-web';
// After: SVG 렌더러만 (~400KB)
import lottie from 'lottie-web/build/player/lottie_light';

이미 next/dynamic으로 lazy load 처리가 되어 있어 초기 로딩에는 영향이 없지만, light 빌드로 전환하면 해당 청크의 크기를 약 75% 줄일 수 있습니다.

lodash를 사용하는 파일들이 세 가지 패턴으로 나뉘어 있었습니다.

// 패턴 A: 전체 import - 번들에 lodash 전체(~70KB gzip) 포함
import _ from 'lodash';
_.debounce(fn, 1000);
// 패턴 B: named import - tree-shaking 불가, 여전히 전체 포함
import { isEmpty } from 'lodash';
// 패턴 C: 개별 import - 필요한 함수만 포함 (~4KB)
import debounce from 'lodash/debounce';

문제는 패턴 A, B가 혼재하면서 lodash 전체 번들이 청크에 포함되고 있었다는 점입니다. babel-plugin-lodash 같은 빌드 타임 최적화도 설정되어 있지 않았습니다.

실제로 사용처를 하나씩 확인해 보니 상황은 이랬습니다.

실제 사용되는 lodash 함수는 isEmpty, debounce, isEqual, orderBy 등 10종뿐 이었습니다. 체이닝(_.chain()), mixin, 동적 프로퍼티 접근 등 개별 import 전환을 차단하는 패턴은 단 하나도 없었습니다.

개선: 단계적 접근

분석 결과를 바탕으로, 안전성을 확보하면서 단계적으로 적용했습니다.

flatten은 JavaScript 기본 문법인 Array.flat()으로, compact는 .filter(Boolean)으로, isNil은 != null로 같은 결과를 낼 수 있습니다. 이처럼 lodash 없이도 되는 함수부터 전환했습니다.

import { isEmpty } from 'lodash' → import isEmpty from 'lodash/isEmpty'로 전체 전환했습니다. 특히 공유 패키지(@packages/ui)의 공유 컴포넌트 Context 파일에서 isEqual을 개별 import로 바꾸는 것이 가장 임팩트가 컸습니다. 이 파일은 10여 개 앱 모두가 참조하는 공유 컴포넌트이기 때문에, 이 한 줄 변경이 전체 앱의 번들에 영향을 미쳤습니다.

소스 코드에서는 lodash를 사용하지 않는데 package.json에만 남아 있는 의존성을 일부 앱에서 정리했습니다.

결과

3개 대표 앱에서 빌드 전후 비교를 진행했습니다.

가장 눈에 띄는 변화는 lodash 전체 번들 청크입니다. 세 앱 모두에서 동일한 청크(299KB)가 개별 import 후 164KB로 줄었습니다.

10여 개 앱 전체로 환산하면 약 1.6MB의 번들이 절감되었습니다.

시도했지만 효과가 제한적이었던 것들

모든 시도가 성공한 것은 아닙니다.

향후 계획

번들 사이즈 최적화에서 이번에 진행하지 않은 항목들과, 더 근본적인 구조 개선을 계획하고 있습니다.

마치며

빌드 최적화는 특별한 기술보다 현재 구조의 제약을 정확히 이해하는 것에서 시작했습니다. 아키텍처를 바꾸면 극적인 개선이 가능하지만, 현실에는 항상 바꿀 수 없는 것들이 있습니다.

프론트엔드 개발자에게 빌드 파이프라인은 익숙한 영역이 아닙니다. Dockerfile, Jenkins, Gradle, 셸 스크립트는 물론이고, Spring Boot와 결합된 배포 구조까지 이해해야 했기 때문에 처음에는 엄두가 나지 않았습니다.

하지만 매일 14분씩 배포를 기다리면서 “이걸 줄일 수 있지 않을까”라는 필요성을 계속 느꼈고, AI를 활용해 프로젝트 전체 구조를 분석하고 아키텍처를 파악한 뒤 개선 계획을 세우고 검토했습니다.

익숙하지 않은 부분이 많았지만, 문제를 정의하고 적절한 도구를 활용하니 오히려 시야를 넓히는 좋은 경험이 되었습니다.

빌드 시간 단축에 이어, 번들 사이즈 최적화도 일부 적용을 마쳤고, 나머지 항목은 순차적으로 진행할 예정입니다. 향후에는 프론트엔드 독립 배포 구조를 통해 배포 시간을 더 줄이는 것을 목표로 하고 있습니다.

비슷한 환경에서 빌드 시간 때문에 고민하고 계신 분들께, 저희의 경험이 조금이나마 도움이 되었기를 바랍니다.

감사합니다.