200여개 서비스 모노레포의 파이프라인 최적화

정석호 · 토스 Frontend Platform Engineer
2024년 6월 14일

프론트엔드 모노레포

모노레포는 여러 프로젝트나 라이브러리를 하나의 저장소에서 관리하는 단일 레포를 말합니다. 이를 통해 코드 공유와 의존성 관리가 용이해지고, 많은 개발자에게 일관된 개발 경험을 줄 수 있어요. 또한, 단일 저장소로 모든 프로젝트를 관리하기 때문에 CI/CD 설정을 같이 가져갈 수 있어서 편리해요.

토스 프론트엔드 챕터에서는 하나의 모노레포에서 200여개가 넘는 서비스를 관리하고 있어요. 이 레포의 기여자는 50~60명이며, 일평균 머지되는 PR은 평균 60개가 넘어요!

이렇게 많은 서비스가 관리되면서 모노레포가 점점 커지게 되었고, 결국 레포를 단순히 클론받는 것 자체가 불가능한 경지까지 왔어요. 그래서 종종 필요하기 전까지 blob을 내려받지 않도록 filter=blob:none 을 애용하곤 합니다.

filte=blob:none 없이는 클론 불가, 너무나 거대해진 모노레포!

토스에서는 이렇게 커진 모노레포에서도 git push 부터 배포까지 5분을 유지할 수 있었는데, 그 비결을 소개합니다!

비법 1: 병렬화를 통한 CI/CD 속도 최적화

모노레포에서는 여러 서비스가 동시에 변경되는 경우가 종종 있는데, 이때 서비스의 빌드는 1번이 아닌 N 수행됩니다. 토스 프론트엔드 챕터에서는 개발 , 스테이징 , 라이브 환경 빌드를 모두 따로 하고 있어서 (변경된 서비스의 수) x (빌드해야하는 환경의 수) 만큼 수행이 필요해요.

한번 모노레포에서의 서비스 빌드는 얼마나 걸리는지 계산해 볼게요. 만약 쇼핑, 송금, 만보기와 같이 3개의 서비스에 변경이 발생했다면, 총 9번의 빌드를 하게 됩니다. 각 서비스가 CI/CD에서 빌드되는 시간을 5분이라고 가정할 때, 순차적으로 빌드한다면 총 45분이 걸리죠.

이렇게 변경되는 서비스가 많을수록 빌드 시간이 더 오래 걸리고, 개발자는 더 많은 시간을 기다리거나 다른 작업을 하는 등 컨텍스트 스위칭 비용이 발생해요. 😢

yarn을 쓰고 있다면, yarn workspace since run --jobs 옵션을 통해 빌드를 병렬로 수행할 수 있어요. 하지만 각 서비스가 빌드되는 환경이 하나의 컴퓨팅 환경이라면, CPU와 Memory와 같은 컴퓨팅 리소스를 공유하게 되는 문제가 있어요. 한정된 CPU와 Memory를 쓰는 환경에서, 병렬의 숫자를 늘리면 오히려 더 느려지게 됩니다. 결국 빌드 대상이 많아지면, 병렬을 해도 순차적으로 실행한 경우와 비슷한 시간이 소요됩니다.

모든 빌드를 서로 독립된 환경에서 실행하도록 파이프라인을 고치면 이런 문제를 뿌리째 뽑을 수 있어요! 프론트엔드 챕터에서는 CircleCI의 Dynamic Configuration 을 이용해서 손쉽게 시도해 볼 수 있었습니다.

각 서비스가 독립적으로 빌드되도록 설정한 후, 6개의 빌드 파이프라인이 동적으로 생성되어 병렬로 실행되는 모습
각 파이프라인은 독립된 리소스가 할당된 가상 Machine에서 따로 실행됨

이상적인 상황이라면, 서비스가 아무리 많아도 모든 서비스는 6분 내외로 모든 빌드가 완료됩니다.

  • 2개 서비스: 1분 (트리거 파이프라인) + 5분 (병렬 파이프라인)
  • 40개 서비스: 1분 (트리거 파이프라인) + 5분 (병렬 파이프라인)
  • 200개 서비스: 1분 (트리거 파이프라인) + 5분 (병렬 파이프라인)

만약 돈이 무한하다면 시간을 무한으로 아껴볼 수 있지만, 현실적으로 최대 러너 수를 조절해서 파이프라인을 구성해야 비용도 아낄 수 있다는 점 잊지 말아 주세요!

결국 저희는 2개 서비스 변경이 생겼을 때를 기준으로 약 5배 정도의 시간을 아낄 수 있어요.

  • AS-IS: 2개 서비스(6개 배포): 6 * 5 = 30분
  • TO-BE: 2개 서비스(6개 배포): 1 + 5 = 6분

CircleCI뿐만 아니라 Jenkins를 통해서도 동적으로 파이프라인을 구성할 수 있습니다.

이처럼 다양한 CI/CD의 기능을 통해 각 파이프라인을 독립된 컴퓨팅 환경에서 수행하는 것이 가장 중요합니다.

비법 2: Daily Docker Base Image

거대해진 모노레포의 사이즈는 40GB를 훌쩍 넘기고 있어요. 빌드가 실행되는 러너에서 이 레포를 매번 checkout 하려면 매우 오랜 시간이 걸리거나 타임아웃 오류가 발생해요.

어떻게 하면 CI에서 오류를 내지 않으면서 checkout 시간을 줄일 수 있을까요?

그 방법은 바로 모노레포를 미리 복제하기입니다. 미리 복제한 환경으로 빌드를 시작하면, 처음부터 git을 내려받을 필요가 없게 되어 오래 기다릴 필요도 없습니다! 2022년 SLASH 발표를 통해 잠깐 소개해 드렸던 내용인데요, 모노레포의 컨텐츠를 미리 도커 이미지로 구워 변경된 부분만 새로 받는 방법입니다.

모노레포의 git 컨텐츠를 미리 굽는 방법은 Dockerfile을 다음과 같이 작성하여 간단하게 따라 해볼 수 있습니다.

FROM docker.io/cimg/node:20.14.0
SHELL ["/bin/bash", "-c"]

# 중략
WORKDIR ${HOME}/project

# 가볍게 50커밋 내역만 먼저 clone 받기
RUN git clone --depth 50 $CIRCLE_REPOSITORY_URL

# 코드 변경 계산에 적당할 정도로 1000커밋 내역까지 이어서 내려받기
RUN git fetch --depth 1000 --force origin main

# 현재 환경을 main 브랜치로 설정
RUN git checkout --force -B main

# yarn install 미리 해두기 (생략 가능)
RUN yarn

위와 같은 Dockerfile을 매일 오전 7시 마다 동작하도록 예약해 두었습니다. 36분이 걸리는 것을 확인할 수 있습니다. 이를 통해 매번 빌드할 때마다 checkout을 해야 했다면 매번 36분씩 더 소요되는 것을 알 수 있어요!

이렇게 만든 도커 이미지는 CircleCI의 executor를 이용해서 사용할 수 있어요. CircleCI의 executor를 간단하게 소개드리자면, 아래처럼 특정 이미지로 Job을 실행할 수 있는 설정을 말해요.

version: 2.1
excutors:
  my-executor:
    docker:
      - image: cimg/ruby:3.0.3-browsers
jobs:
  my-job:
    executor: my-executor
    steps:
      - run: echo "Hello executor!"

토스에서는 AWS ECR을 이용해서 Docker 이미지를 관리하고 있는데, 이걸 CircleCI executor로 쓰려면 다음과 같이 작성해 볼 수 있어요.

executors:
  toss_frontend_excutor:
    docker:
      - image: xxxxxx.dkr.ecr.ap-northeast-2.amazonaws.com/ci-base-image:latest
        aws_auth:
          aws_access_key_id: $AWS_ACCESS_KEY_ID
          aws_secret_access_key: $AWS_SECRET_ACCESS_KEY
        environment:
          TZ: 'Asia/Seoul'
          AWS_DEFAULT_REGION: ap-northeast-2
          ECR_REGISTRY: xxxxxx.dkr.ecr.ap-northeast-2.amazonaws.com
          ECR_REPOSITORY: ci-base-image
          ECR_LATEST_TAG: 'latest'

이 executor를 이제 Job에서 써볼까요?

jobs:
  trigger-publish:
    executor: toss_frontend_excutor
    steps:
      - trigger-publish-service

미리 받은 컨텐츠 이후에 변경된 부분만 내려받는 시간을 확인해 보면 22초로 단축된 것을 확인할 수 있어요!

이러한 방법을 통해 저희는 36분의 소요 시간을 22초로 줄일 수 있었습니다. git checkout이 필요한 모든 Job마다 약 36분 정도의 시간을 아낄 수 있어요!

비법 3: SSR Standalone Docker Image

마지막으로 소개해 드릴 내용은 SSR 배포 시간을 획기적으로 줄여주는 Standalone 모드입니다. 사실 눈치채신 분들도 계시겠지만, 이 부분도 2022년 SLASH 발표에서 한번 소개해 드린 적 있는 내용이에요.

Node File Trace를 통해서 애플리케이션 런타임에 필요한 의존성만 뽑아낼 수 있는데, 최소한의 JavaScript 파일만 모아서 빌드 환경을 만들면 획기적으로 가벼워 Docker 빌드와 K8S로의 배포 시간이 빨라집니다.

2년 전쯤부터 Next.js 에서 이러한 아이디어를 next.config.js 의 output: 'standalone' 으로 제공해 주고 있습니다. 더 자세한 내용은 automatically-copying-traced-files 공식 문서를 참고하세요.

하지만 저희가 쓰기에는 한 가지 아쉬운 점이 있었는데요, 이 기능은 node_modules라는 특별한 디렉토리에 의존되어 있었어요. 저희는 Yarn PnP 를 사용해 node_modules 가 아닌 .yarn/cache라는 곳에 의존성이 저장되어서 이 기능을 그대로 사용할 수가 없었어요.

그래서 PnP API를 활용하여 Next.js standalone 기능과 비슷한 역할의 함수를 작성했습니다.

실제로는 더 복잡하지만, 아이디어만 뽑아서 간단하게 모습을 보여드릴게요.

async function createSSRBundle(options: Options): Promise<SSRBundle> {

위와 같은 함수를 통해 만든 bundle.zip파일은 SSR Dockerfile에서 활용할 수 있습니다.

FROM node:20.11.1-alpine3.19

WORKDIR /app
COPY ./bundle.zip ./bundle.zip

RUN unzip -i ./bundle.zip -d workspace

WORKDIR /app/workspace
CMD node -r ./.pnp.cjs --experimental-loader ./pnp.loader.mjs ./server.js

이제 bundle.zip만 있으면 어디서든 SSR 서버를 실행할 수 있어 git과 yarn이 없는 곳에서도 문제없이 서버를 띄울 수 있어요.

SSR Docker Image의 사이즈도 4GB에서 약 200MB로 줄어들어 20배가량의 최적화를 이룰 수 있어요.

SSR Docker Image의 사이즈는 배포 속도와도 관련이 있는데, 바로 Kubernetes에서 PodInitializing 시간을 감소시켜 줍니다. (PodInitializing란, K8S pod를 띄울 때 필요한 도커 이미지를 pull 하는 과정을 말해요)

총 정리

위에 소개해 드린 3가지의 비결을 통해 저희는 다음과 같은 최적화를 이룩할 수 있었어요

  • 병렬화를 통한 CI/CD 속도 최적화 → 배포 시간 5배 개선
  • Daily Docker Base Image → Job 당 36분 이상 개선
  • SSR Standalone Docker Image → Pod 뜨는 시간 20배 개선

오늘은 토스 프론트엔드 챕터에서 거대한 모노레포를 운영하면서 적용해 본 최적화 사례를 공유드렸어요. 이 최적화 방법들을 통해 여러분의 모노레포에서도 CI/CD 시간을 획기적으로 단축할 수 있을 거에요. 지금 바로 도입해 보세요!

댓글 0댓글 관련 문의: toss-tech@toss.im
연관 콘텐츠
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.