웹프론트개발팀에서 배민 커머스 어드민을 개발하는 방법

Dec.05.2023 이수형

Web Frontend

배민 커머스 웹 전반을 담당하고 있는 배민커머스웹프론트개발팀은 20명이 넘는 프론트엔드 개발자로만 구성된 팀입니다. B마트, 배민스토어 서비스와 각 서비스들의 어드민을 개발하고 있어요. 저는 현재 어드민을 주로 개발하는 플랫폼파트에서 10명의 개발자와 함께하고 있습니다.

이 글에서는 배민 커머스 웹 어드민의 탄생 일화부터 1년이 넘는 시간 동안 플랫폼파트에서 어드민을 어떻게 발전시켜나갔는지 소개합니다.

커머스 어드민의 탄생

2021년 12월, 셀러가 직접 상품을 등록하고 판매할 수 있는 커머스 서비스인 배민스토어가 강남을 기준으로 첫 시범 서비스를 시작했습니다. 배민스토어 오픈 초기에는 셀러를 위한 셀러어드민은 존재했지만 메뉴의 수가 지금보다 현저히 적었고 자체 구축된 입점프로세스의 부재로 Google 설문지를 이용하는 등 운영상 크고 작은 어려움이 있었습니다. 여러 문제점들과 개선사항을 정리하여 2022년 초 서비스 경험을 향상시키기 위해 커머스 플랫폼(운영자용 어드민, 셀러오피스) 웹 어드민을 완전히 새로 만들게 되었습니다.

빠르게 성장하기 위해 선택한 멀티레포 방식

프로젝트 1개당 레포지토리 1개. 레포가 무려 3개지요

배민스토어는 이미 사용자에게 오픈되어 운영 중인 서비스이므로 무엇보다 어드민 개발에 속도를 높여야 하는 상황이었습니다. 그래서 선택한 것이 멀티레포인데요. 멀티레포는 다른 레포와의 의존성을 가지고 있지 않아 각자 빠르게 개발할 수 있다는 장점이 있습니다. 멀티레포 방식을 통해 각 프로젝트에서 공통 구성 요소를 자체적으로 작성하여 폭발적으로 성장시킬 수 있었습니다.

멀티레포의 한계

어느 정도 어드민의 모양이 갖추어지고 각 프로젝트별 개발환경과 CI/CD 가 구성된 상태였지만 멀티레포 구조로는 아래와 같은 문제가 있었습니다.

  • 프로젝트별 공통 구성 요소 (UI 컴포넌트, 커스텀 훅, DevOps) 의 중복 개발
  • 한 레포에서 사용 중인 타사 라이브러리의 패키지 버전이 변경되었을 때, 해당 레포의 모듈을 사용하고 있는 다른 레포에서의 호환성 이슈 발생
  • 높은 자율성으로 인해 각 프로젝트들이 고유한 컨벤션이나 명령 집합을 사용함으로써 발생할 수 있는 일관되지 않은 DX 경험

이 중 가장 큰 문제라고 생각했던 부분은 UI 컴포넌트의 관리였는데요. 당시 디자인시스템 수준의 라이브러리가 아닌, 공통 UI를 컴포넌트화한 것에 불과했고 빠르게 개발하여 사용처에 적용할 수 있도록 패키지 저장소에 올리지 않고 전체 UI 컴포넌트를 각 프로젝트에 복사하여 사용하고 있었습니다.

셀러오피스의 Table 이 더 커보이는 건 착시현상이 아닙니다.

당장 빠른 개발을 위해서 멀티레포 방식을 유지하는 방법도 있었겠지만 점점 각자 도생해 커져가는 프로젝트들을 지금이라도 잠시 중지하고 전체적인 해결책을 마련해야한다!라고 결론을 내렸습니다. 그래서 어드민 지면 개발을 잠시 중단하고 팀 내 리소스를 두 그룹으로 나누어 약 2주 동안 아래 액션아이템을 정하기로 했습니다.

공통 구성 요소 패키지화

프로젝트별로 분산되어 있는 공통 구성 요소를 통합하고 이를 패키지화

멀티레포를 모노레포로 전환

3개의 어드민 프로젝트를 1개 레포로 합치고 패키지화된 공통 구성 요소를 각 프로젝트에서 사용할 수 있도록 구성

공통 구성 요소 패키지화

공통 구성 요소를 패키지화하면서 가장 중점을 두었던 것은 패키지 구조를 재정의하는 것이었습니다.

변경 전변경 후
컴포넌트– Form을 제외한 모든 UI 구성요소가 플랫하게 구성
– 컨텍스트 유무와 관계없이 모든 로직이 각 컴포넌트 내부에 존재
– base, core 등 컨텍스트 유무에 따라 계층으로 분리
– UI 컴포넌트의 경우 서브컴포넌트를 분리하여 디자인 확장을 일부 제한할 수 있는 인터페이스 확립
react-hook-form을 결합한 형태의 Form 컴포넌트 구성
유틸– 프로젝트별로 각각 생성되어있는 hooks, config, util, lib– 단일 구조화
빌드(None)– 모노레포내 원활한 코드 공유를 위해 린트 및 Typescript Compiler 설정

패키지 구조를 재정의한 배경에는 두 가지 이유가 있습니다.

  • 프로젝트(사용처)에서 패키지를 분리하기 위한 사전 준비
  • 향후 모노레포 내 패키지를 추가할 때 참고할 수 있는 보일러플레이트를 미리 구성

멀티레포를 모노레포로 전환

모노레포를 구성하기 위한 빌드시스템 도구로는 이미 널리 알려진 Nx, Turborepo, Lerna 등이 있습니다. 이러한 도구들은 분산 캐싱, 증분 빌드 등 많은 기능을 제공하지만 당시 플랫폼 레포는 아직 걸음마 수준의 프로젝트라 모노레포 빌드시스템 적용이 필요한 수준은 아니라고 판단했습니다.

당장 필요한 것은 공유 가능한 소스코드

코드 공유를 위한 프로젝트 별 종속성 트리를 구성하는 방법으로는 간단하게 패키지 매니저의 워크스페이스 기능을 이용할 수 있습니다. 워크스페이스를 이용해 모노레포를 구성하기 위해 패키지 매니저 중 하나인 pnpm을 채택했습니다.

pnpm을 채택한 이유(출처: “우리는 하나다! 모노레포 with pnpm”, 우아콘 2022)

패키지로 추가된 공통 구성 요소들은 프로젝트와 함께 워크스페이스에 위치하게 두고 별도 빌드 없이 각 패키지에서 모노레포의 루트를 거쳐 특정한 패키지로 도달하는 상대경로를 모노레포 공통의 alias 로 지정해 생각보다 간단히 코드 공유를 할 수 있을거라 생각했습니다. 그런데…

왜 안되지..ㅎ

당연하게도 패키지에 있는 공통 구성 요소들은 대부분 React 로 이루어져 있는데 빌드를 하지 않으니 트랜스파일링 되지 않은 상태로 프로젝트에서 그대로 사용할 수 없었습니다. 또 플랫하지 않은 node_modules 를 가지고 있는 pnpm 의 특성 때문에 프로젝트를 번들링할 때 패키지 내부의 node_modules 에 실제 파일이 존재하지 않아 배포 환경에서 실행되지 않는 문제가 있었습니다.

pnpm은 왜 플랫하지 않을까? (Flat node-modules is not the only way)

pnpm은 npm, yarn 과 달리 설치된 패키지들을 node_modules 폴더 바로 아래에 모두 끌어올리지 않습니다.

두 문제를 해결하기 위해 아래와 같은 방법을 사용했습니다.

모듈 트랜스파일배포 파일 구성
– nextjs 프로젝트 : next-transpile-modules 플러그인 사용
(update Nextjs v13.1 에서 통합 : 참고)
– CRA 프로젝트 : craco 를 사용하여 웹팩 설정을 확장
– 번들링된 프로젝트와 프로젝트에서 사용하는 패키지들의 빌드 아티팩트를 추출하는 스크립트를 작성
– 배포 파이프라인에서 해당 스크립트를 실행

모노레포로 전환해서 얻은 성과

워크스페이스를 패키지와 프로젝트 두 개로 나누어 구성한 모습입니다.

모노레포가 적용된 프로젝트 구조

모노레포로 전환하면서 느낀 가장 큰 성과라고 생각했던 부분은 크게 두 가지입니다.

  • 패키지와 프로젝트가 분명하게 분리된 만큼, 업무 분배에 있어서도 보다 명확한 역할과 책임을 부여할 수 있다는 점
  • 여러 프로젝트의 변경 사항을 하나의 레포에서 관리함으로써 일관된 히스토리 유지, 개선된 코드리뷰 환경

그 외 UI 컴포넌트 설계 경험, pnpm 모노레포 환경 구성 경험 등 개개인의 성장에도 많은 도움을 주었습니다.

잠시 위의 이야기로 되돌아가면 비즈니스 개발이 한창 바쁜 와중에도 모든 어드민 지면 개발을 2주간 중지하고 모노레포 전환작업을 진행했다고 했었는데요, 정말 후회없는 선택이었던 것 같습니다. 모든 팀원 분들이 정말 많이 고생해 주었어요.


어드민을 보다 탄탄하게

모노레포로 전환한 이후 UI 컴포넌트 기능개선과 비즈니스 개발에 집중하며 몇 개월이 금방 지나갔습니다. 팀 내에서도 어드민이 어느 정도 안정화된 만큼 서비스 웹뷰 개발에 다시 집중하기 위해 약간의 업무 조정이 있었지만 열명 남짓한 인원이 여전히 어드민 개발을 위해 매일같이 커밋을 올리고 있었는데요, 코드의 양이 정말 날이 갈수록 늘어나는 것을 느꼈습니다.

다시 문제점을 찾아보기

앞서 구성한 코드 공유의 특징은 빌드를 하지 않는다 입니다. 로컬 개발환경에서 패키지의 변경사항을 프로젝트 로컬 실행 환경에 즉시 반영해주는 것은 무척 편리하지만 코드의 양이 늘어 갈수록 점차 HMR(Hot Module Replacement) 속도가 늦어지기 시작했고, 이는 곧 비즈니스 개발에 병목을 가져다 주게 되었습니다.

또, 프로젝트에서 사용하는 패키지의 의존성 파일의 버전 호환성을 유지하기 위해 동일한 외부 패키지 버전을 모든 프로젝트에 직접 입력해야 하는 번거로움이 있었습니다.

이것을 해결하기 위한 액션아이템은 다음과 같습니다.

  • 각 패키지 간의 코드 범위를 분리하기 위해 각 패키지를 독립적으로 구성하여 빌드한다.
  • 패키지 간의 복잡한 의존관계를 효과적으로 관리하기 위해 빌드 오케스트레이션을 구성한다.

코드 공유를 넘어 라이브러리로

0. 라이브러리 운용 방안 전략 세우기

패키지를 독립적으로 구성한다는 의미는 패키지의 위치가 모노레포 내 워크스페이스로 한정되는 것이 아니라 버저닝된 라이브러리로 사내 패키지 저장소에도 배포할 수 있다라는 것을 일컫습니다. 따라서 빌드와는 별개로 라이브러리로 배포했을 때 이를 어떻게 운용할지 사전에 전략을 세워야 했습니다.

이러한 전략이 없다면 누구든 정해진 제약 없이 쉽고 자유롭게 라이브러리나 패키지를 추가 및 배포할 수 있는 장점은 있지만, 어떠한 기준 없이 임의로 생성되는 라이브러리나 패키지가 많아질수록 관리포인트가 증가하고 빌드 오케스트레이션을 구성하는 데 있어 코드 통합을 어렵게 한다는 단점이 있습니다.

당시 라이브러리 운영방안을 마련하기 위해 논의한 프레젠테이션

여러 논의 끝에 아래와 같이 라이브러리 계층을 정의하고 운용 전략을 세울 수 있었습니다.

계층역할의존가능위치배포Prefix
외부 라이브러리전사 단위 라이브러리없음(최상위)별도 레포private registry@baemin
내부 라이브러리팀 >= && 전사 < 단위 라이브러리외부 라이브러리별도 레포private registry@commerce
단순 공유용
라이브러리
팀단위 라이브러리외부/내부private registry
workspace
자유
패키지모노레포 내에서만 사용모든 계층workspace@packages
의존 방향 : 외부 ← 내부 ← 단순 공유용 ← 패키지

이외 버저닝을 위한 Semver(유의적 버전이라고도 부릅니다.) 적용, 별도 레포로 분리된 외/내부 라이브러리의 브랜치 전략, canary 배포가 포함된 배포 자동화 등 라이브러리 운용을 위한 부가 작업을 진행했습니다.

1. 패키지 빌드하기

각 패키지를 독립적으로 구성하여 빌드하기 위해 선택할 수 있는 방법은 여러 가지가 있지만 저희는 번들링 도구인 Vite 를 사용하기로 결정했습니다.

Vite
Vite. 바이트가 아니라 비트로 발음합니다.

Vite는 프랑스어로 “빠르다(Quick)”를 의미하며, 빠르고 간결한 모던 웹 프로젝트 개발 경험에 초점을 맞춰 탄생한 빌드 도구입니다.

Vite의 특징은 이름 그대로 빌드와 로컬 구동 속도가 굉장히 빠르다는 점입니다. Native ESM을 이용한 HMR을 제공하기 때문입니다.

Vite를 사용해야 하는 이유

vite는 HMR을 지원합니다. 이는 번들러가 아닌 ESM을 이용하는 것입니다. 어떤 모듈이 수정되면 vite는 그저 수정된 모듈과 관련된 부분만을 교체할 뿐이고, 브라우저에서 해당 모듈을 요청하면 교체된 모듈을 전달할 뿐입니다. 전 과정에서 완벽하게 ESM을 이용하기에, 앱 사이즈가 커져도 HMR을 포함한 갱신 시간에는 영향을 끼치지 않습니다.

vite 와 기타 도구들을 활용한 빌드킷을 보다 쉽게 구성하기 위해 어드민의 기존 React 버전을 18 로 마이그레이션하는 작업을 사전에 진행했습니다. React JSX-runtime의 ESM 지원은 React 18부터 지원하기 때문이었죠. 구성한 빌드킷은 다음과 같습니다.

도구버전역할
vitev4JS 추출
rollupv3JS 추출
swcv1.3JS 추출
tsc + tsc-aliastype declare 추출
// package.json
{
  "name": "@packages/shared",
  "version": "0.0.0",
  "license": "MIT",
  "main": "./dist/index.cjs.js",
  "module": "./dist/index.esm.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.esm.mjs",
      "require": "./dist/index.cjs.js"
    },
    "./index.css": "./dist/assets/index.css"
  },
  "scripts": {
    "build": "vite build && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.dts.json",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "react-hook-form": ">=7.27.1 <7.32.0"
  },
  "devDependencies": {
    "@babel/core": "^7.18.0",
    "@swc/core": "^1.3.24",
    "@types/node": "16",
    "@types/react": "18.2.13",
    "@types/react-dom": "^18.2.6",
    "@vitejs/plugin-react": "^3.1.0",
    "prettier": "^2.5.1",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-query": "^3.34.16",
    "rollup": ">=3.0.0",
    "rollup-plugin-swc3": "^0.8.0",
    "tsc-alias": "^1.8.2",
    "typescript": ">=4.6.3 <4.7.0",
    "vite": "^4.0.4",
    "vite-plugin-dts": "^1.7.1",
    "vite-plugin-static-copy": "^0.13.0",
    "vite-tsconfig-paths": "^4.0.5"
  },
  "engines": {
    "node": "16",
    "pnpm": "7"
  },
  "volta": {
    "node": "16.14.2",
    "pnpm": "7.30.0"
  },
  "peerDependencies": {
    "@types/react": "18.2.13",
    "@types/react-dom": "^18.2.6",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-query": "^3.34.16",
    "rollup": ">=3.0.0"
  }
}
// vite.config.js
import { resolve } from 'path'
 
import react from '@vitejs/plugin-react'
import { swc } from 'rollup-plugin-swc3'
import { defineConfig } from 'vite'
import { viteStaticCopy } from 'vite-plugin-static-copy'
import tsconfigPaths from 'vite-tsconfig-paths'
 
import pkg from './package.json'
 
const makeExternalPredicate = (externalArr: string[]): ((id: string) => boolean) => {
  const excludeStorybooks = /\.?stories.ts(x)$/
  const excludeTests = /\.test.ts(x)$/
  const externalPackagesRegex = externalArr.length === 0 ? null : new RegExp(`^(${externalArr.join('|')})($|/)`)
 
  return (id: string) => {
    return (excludeStorybooks.test(id) || excludeTests.test(id) || externalPackagesRegex?.test(id)) ?? false
  }
}
 
const externals = makeExternalPredicate(Object.keys(pkg.peerDependencies))
 
export default defineConfig({
  plugins: [
    react({
         // @vitejs/plugin-react-swc가 아닌 @vitejs/plugin-react를 사용할 경우 jsxRuntime도 함께 설정 가능함. (번들러에 따라 보는 설정이 달라서 둘다 해주길 권합니다.)
         jsxImportSource: '@emotion/react',
    }),
    tsconfigPaths({ root: './' }),
    viteStaticCopy({
      targets: [
        {
          src: 'src/**/*.css',
          dest: 'assets',
        },
        {
          src: 'src/**/*.woff2',
          dest: 'assets',
        },
        {
          src: 'src/**/*.ttf',
          dest: 'assets',
        },
      ],
    }),
  ],
  build: {
    sourcemap: true,
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'Lib',
      formats: ['cjs', 'es'],
      fileName: (format) => {
        switch (format) {
          case 'es':
          case 'esm':
          case 'module':
            return 'index.esm.mjs'
          case 'cjs':
          case 'commonjs':
            return 'index.cjs.js'
          default:
            return 'index.' + format + '.js'
        }
      },
    },
    rollupOptions: {
      output: {
        interop: 'auto',
      },
      plugins: [swc()],
      external: externals,
    },
  },
})
// [root] tsconfig.json
{
  "compilerOptions": {
    "target": "es2015",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "noImplicitAny": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsxImportSource": "@emotion/react",
    "incremental": true,
    "strictNullChecks": true,
    "noImplicitThis": false,
    "allowSyntheticDefaultImports": true,
    "noFallthroughCasesInSwitch": true
  },
  "exclude": ["node_modules"]
}
// [project] tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "baseUrl": "src",
    "typeRoots": ["node_modules/@types", "types"],
    "jsx": "react-jsx"
  },
  "include": ["src/index.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}
// [project] tsconfig.build.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "declaration": true,
    "emitDeclarationOnly": true,
    "noEmit": false
  },
  "include": ["src/index.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules", "**/stories.tsx", "**/*.stories.tsx", "**/*.test.ts", "./*.ts"]
 }
// [project] tsconfig.dts.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "baseUrl": "./dist"
  },
  "include": ["dist/index.ts", "dist/**/*.ts", "dist/**/*.tsx"],
  "exclude": ["node_modules", "./*.ts"]
}

JS 트랜스파일 및 IDE 환경 작업 지원을 위한 tsconfig.json 과 더불어 DTS 파일 추출을 위한 tsconfig.build.json 과 tsc-alias 실행과 함께 상대경로 전환을 위한 tsconfig.dts.json 로 분리했습니다. 이후 package.json 에 선언된 빌드 스크립트 vite build && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.dts.json 를 실행하여 outDir 폴더에 빌드된 파일과 DTS 파일이 담기게 됩니다.

특이한 점은 vite.config.js 설정 중 build.rollupOptions.output.interop 옵션을 auto 로 설정했는데, 이는 emotion 의 CJS ↔ ESM 간의 상호 운용성을 보장하도록 설정한 것 입니다.

2. 빌드 오케스트레이션

빌드킷을 활용하여 패키지를 쉽고 빠르게 빌드할 수 있게 되었습니다. 이제 프로젝트에서 실제로 사용할 패키지의 버전을 명시하여 설치만 하면 되는데요. 사용하다가 패키지의 버전이 업데이트된 경우 프로젝트에서는 버전을 업데이트하고 의존성 설치만 다시 해주면 됩니다.

하지만 private registry 에 배포하지 않는 모노레포 워크스페이스에 위치한 패키지의 경우 이야기가 다릅니다. 예전처럼 코드 공유 방식이 아니기 때문에 패키지가 업데이트되면 다시 빌드를 해야 합니다. 업데이트된 패키지가 많아질 수록 개발자는 아래와 같은 상황에 놓일 수 있습니다.

실제 제 말투는 이렇지 않아요.

위 상황을 코드로 표현하면 아래처럼 한 줄로 실행할 수 있는 커맨드 명령어를 네번이나 입력해야 한다는 것을 알 수 있습니다.

# A, B, C 패키지 빌드
$ pnpm --filter @packages/A build
$ pnpm --filter @packages/B build
$ pnpm --filter @packages/C build

# D 패키지 빌드
$ pnpm --filter @packages/D build

// 프로젝트 실행 명령어
$ pnpm --filter @projects/seller-admin start

빌드 오케스트레이션 구성을 위해 모노레포 전환 당시 빌드시스템 도구 후보로 고민했던 Turborepo 를 도입하기로 했습니다.

Without using a filter, test will be run across all packages
출처 : Turborepo docs

위 그림을 보면 turbo run test 라는 명령어 한줄로 모노레포 내 각각의 워크스페이스의 Task 를 실행시키고 있습니다. 터보레포의 멀티태스크 기능입니다. 그런데 우리가 원했던 것은 “프로젝트를 실행할 때, 프로젝트에서 의존하고 있는 패키지들을 먼저 빌드하는 것” 이었죠. 바로 터보레포에서 이러한 Task Dependencies 기능을 제공하고 있었습니다.

터보레포에서 워크스페이스간 Task 동작을 정의하려면 turbo.json 파일을 작성해야 합니다. 파일에 정의된 Task 들은 별도의 필터 옵션이 없는 한 모든 워크스페이스를 순회하면서 package.json 파일 내 script 를 실행합니다.

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      // A workspace's `build` command depends on its dependencies'
      // and devDependencies' `build` commands being completed first
      "dependsOn": ["^build"],
    }
  }
}

터보레포의 간단한 예시를 보면, 파이프라인에 선언된 build 는 각 워크스페이스 package.jsonscript 에 정의된 build 스크립트를 실행합니다. 그런데 dependsOn 값에 ^build 가 있죠. 이는 아래 두 가지를 의미합니다.

  • dependsOn 는 의존성 Task 의 집합을 의미합니다.
  • ^ 심벌은 해당 워크스페이스가 참조하는 dependencies, devDependencies 목록에 있는 패키지 워크스페이스의 Task 를 수행한다는 의미입니다.

이를 위의 배달이의 상황에 적용했다고 가정해보면, 프로젝트에 A, B, C, D 패키지가 모두 dependencies 에 존재 할 경우 run 파이프라인 실행시 A, B, C, D 패키지 워크스페이스의 build 명령어가 먼저 실행된 후 프로젝트가 실행된다는 점을 알 수 있습니다.

// 실제 프로젝트에 적용한 turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "run": {
      "persistent": true,
      "dependsOn": ["^build"]
    },
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    ...
  }
}

run 파이프라인은 로컬개발환경에서 프로젝트를 실행될 때, build 파이프라인은 배포 시 실행됩니다. 두 파이프라인 모두 "dependsOn": ["^build"] 를 통해 의존하고 있는 패키지들을 우선 빌드할 수 있도록 설정했습니다.

터보레포를 사용해 작업 종속간 파이프라인을 일부 자동화시켰지만 아직 무언가 부족합니다. 바로 실행 중인 프로젝트에서 의존 중인 다른 워크스페이스의 파일을 수정했을 경우 이를 감지하여 자동으로 빌드해줬으면 좋겠는데요. (마치 웹팩의 watch 처럼요.)

아쉽지만 현재 터보레포에서 공식적으로 watch 기능을 제공하고 있지 않습니다. (이슈) 해당 기능을 구현하려면 Turbowatch 같은 3rd-party 라이브러리를 사용하거나, chokidar 와 같은 크로스 플랫폼 file watcher 라이브러리를 사용하여 스크립트를 직접 구현해야 합니다. Turbowatch 의 경우 “터보레포의 dependsOn 과 같이 사용 할 경우 예상치 못한 이슈가 발생할 수 있다” 라고 명시되어있어 저희는 후자의 방법을 택했습니다.

// watch.mjs
import { getWorkspaceRoot, getWorkspaces } from 'workspace-tools'
import { watch } from 'chokidar'
import execa from 'execa'
import path from 'path'
import url from 'url'

const dirname = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '../')
const workspaceRoot = getWorkspaceRoot(dirname) ?? dirname
const workspaces = getWorkspaces(workspaceRoot)

const targets = workspaces.filter((w) => !w.path.startsWith('projects'))

// NOTE: Map<workspace: WorkspaceInfo, file paths: Set<string>>
const changedFilePathPool = new Map()
const watcher = watch(
  targets.map((t) => path.relative(workspaceRoot, t.path)).flatMap((p) => [`${p}/src/**/*`, `${p}/types/**/*`]),
  {
    ignoreInitial: true,
    atomic: true,
  },
)
const debouncedBuild = debounce(build, 1000)

function main() {
  console.log('모든 패키지와 라이브러리에 대해 watch를 시작합니다.')
  watcher
    .on('ready', () => console.log('watch task 준비 완료'))
    .on('add', onChange)
    .on('change', onChange)
}
main()

function onChange(filepath, stats) {
  const mappedWorkspace = targets.find((workspace) => path.join(workspaceRoot, filepath).startsWith(workspace.path))

  if (mappedWorkspace === undefined) {
    return
  }

  const prev = changedFilePathPool.get(mappedWorkspace)
  if (prev === undefined) {
    changedFilePathPool.set(mappedWorkspace, new Set([filepath]))
  } else {
    changedFilePathPool.set(mappedWorkspace, new Set([...prev, filepath]))
  }

  debouncedBuild()
}

async function build() {
  const pool = new Map(changedFilePathPool)
  changedFilePathPool.clear()

  let logBuffer = '[변경된 파일 목록]\n'
  for (const [workspace, filepaths] of pool.entries()) {
    logBuffer += `${workspace.name}\n`
    logBuffer += Array.from(filepaths)
      .map((p, i) => `  ${(i + 1).toString().padStart(2, '0')} : ${path.relative(workspace.path, p)}`)
      .join('\n')
    logBuffer += '\n'
  }

  console.log(logBuffer)

  const jobs = [...pool.keys()].map((workspace) => {
    return new Promise((res, rej) => {
      try {
        console.log(`[${workspace.name}]: 빌드 시작\n`)
        const command = `pnpm tr build --filter=${workspace.name}`
        const child = execa.command(command)

        child.stdout.on('data', (chunk) => {
          console.log(`[${workspace.name}]: ${chunk}`)
        })
        child.stderr.on('data', (chunk) => {
          console.error(`[${workspace.name}]: ${chunk}`)
        })

        child.finally(() => res())
      } catch (e) {
        rej(e)
      }
    })
  })

  await Promise.all(jobs)
    .then(() => {
      console.log('빌드 완료')
    })
    .catch((e) => {
      console.error(e)
    })
}

function debounce(func, timeout = 1000) {
  let handler = null
  return () => {
    handler && clearTimeout(handler)
    handler = setTimeout(() => {
      func()
    }, timeout)
  }
}

위 스크립트는 chokidar 파일 와쳐를 통해 파일 변경이 일어날 경우 pnpm tr build --filter=${workspace.name} 명령어를 실행시키고 있습니다. 이 스크립트를 프로젝트 실행과 더불어 node 로 실행하면 빌드 오케스트레이션이 완성됩니다.

코드 아키텍처

사람이란(개발자는 더 특히) 서로 생각하는 것이 다르기 때문에 어떤 규칙을 정하지 않고 각자 본인만의 스타일로 작업을 진행할 경우 프로젝트 초반에는 어느 정도 효과를 볼 수 있겠지만 시간이 지나 규모가 커지면 점점 유지보수 지옥에 다다를 수 있는 가능성도 높아지게 됩니다.

화재 코드 사진, 69,000개 이상의 고품질 무료 스톡 사진
나는 개발자인가 소방수인가

이를 방지하기 위해 개발 조직마다 코딩컨벤션, 시스템 아키텍처, 디자인패턴 등 여러 제약사항과 함께 프로젝트를 보다 효율적으로 설계하고 구현하는 데 도움이 되는 방법을 정의하게 되는데요. 여기서는 배민 커머스 어드민의 아키텍처는 어떻게 구성되어 있는지 소개드리려고 합니다.

효율적인 아키텍처란?

개발자라면 면접에서 꼭 한 번은 받아봤을 법한 질문인데요. 틀에 박힌 정답보다는 보다 현실적으로 접근해보았을 때 배민 커머스 어드민에 꼭 필요한 아키텍처는 무엇일지 고민하기로 했습니다.

비즈니스 개발 과정을 보면 대개 MVP(제공 가능한 기능) → Phase X(기능 출시/릴리즈) 과정을 통해 진행됩니다. 말하자면 빠르게 개발해 최소 기능을 오픈하고 이후 확장을 하는 셈이죠. 이를 개발에 빗대어 보면 두 가지 키워드로 축약될 수 있습니다. 바로 생산성확장성인데요, 이 두 키워드를 가지고 아래와 같은 필요충분조건을 만들었습니다.

  • 누구나 이해하기 쉽고 구성하기도 쉬울 것 (생산성)
  • 아키텍처를 구성하는 각각의 요소들은 역할과 책임이 명확하게 구분되어야 할 것 (확장성)

플랫폼 아키텍처 맵

플랫폼 아키텍처 ver.0

아키텍처 맵을 구성하는 각각의 요소들을 레이어로 부르기로 했습니다. 각 레이어는 분리가 명확해야 하며, 각 계층 사이에는 DTO(Data Transfer Object) 가 있어야 하며 데이터 전달은 DI 를 통해 진행하기로 합니다. 각 레이어의 역할은 다음과 같습니다.

레이어범주역할
layout 레이어디자인시스템– 디자인시스템으로 관리되는 컴포넌트 요소
usecase 레이어프로젝트– 비즈니스 데이터를 가공하여 layout 레이어의 props 로 맞추어 전달해주는 역할
– 리액트의 커스텀 훅으로 해결 및 상응
business 레이어프로젝트– 비즈니스 로직을 가지고 있는 레이어
– 객체 기반으로 in-memory에서 서로간 참조를 통해 데이터를 가공하고 전달
persist 레이어BFF(Backend For Frontend)– 지속적으로 외부 데이터를 확인하고 통합
– 일련화된 데이터 포맷을 business 레이어에 제공

이렇게 보면 레이어가 4개로 그리 많지 않고 역할도 분명하게 분리되어 있어 보입니다. 하지만 누구나 이해하기 쉽고 구성하기 쉬운가 라는 관점으로 보았을 때 살짝 의구심이 들었습니다. business, usecase 두 레이어 역할의 범위가 사람마다 판단하는 기준이 애매할 것 같았습니다. business 레이어에서는 얼마만큼의 비즈니스 로직을 가지고 있어야 하는지, 비즈니스 로직이란 어디까지를 얘기하는 건지 등 말이죠. usecase 레이어에서는 비즈니스 데이터를 디자인시스템 컴포넌트로 전달하는 역할이라면 레이어가 아닌 함수만으로도 충분할 텐데 굳이 레이어 계층으로 나눌 필요는 없지 않을까 싶은 생각이 들기도 합니다.

플랫폼 아키텍처 ver.1

버전0 아키텍처 대비 대폭 개선된 플랫폼 아키텍처 버전 1입니다. 디자인시스템은 더이상 레이어 계층으로 취급되지 않고, 4개의 레이어는 안에 무언가를 품은 형태가 되었습니다. 화살표를 통해 의존 방향도 제시하고 있네요. 새로운 플랫폼 아키텍처 각 레이어의 역할은 아래와 같습니다.

레이어범주역할
디자인시스템디자인시스템– 더이상 레이어로 취급되지 않음
– 디자인시스템으로 관리되는 컴포넌트 요소
Web-service 레이어프로젝트– UI와 관련된 로직이 모인 곳으로 Pages와 Components를 조합해서 사용하는 레이어
– 주로 jsx스타일 관련 로직들이 모여있음
Bridge 레이어프로젝트– Data, Domain 레이어를 제외한 모든 Hook을 통칭하는 레이어
Data 레이어프로젝트3개의 모듈로 구성된 레이어
– Cache 모듈 : react-query 를 사용해서 상태를 관리하는 모듈
– Store 모듈 : 전역 store, 브라우저 스토리지 등을 관리하는 모듈
– API 모듈 : 서버에 요청해서 데이터를 가져오는 모듈
Domain 레이어프로젝트– 서버 요청/응답 Model 객체를 관리하는 레이어

버전0 아키텍처 대비 가장 큰 변화는 모듈이라는 계층이 추가되었다라는점 입니다. 여기서의 모듈이란, 단위 기능을 갖춘 코드의 모음을 말하며 클래스, 함수뿐만 아니라 React의 경우 Hook 으로 작성될 수 있습니다. 모듈은 여러 레이어에서 재사용이 가능하며 중복코드를 다수 제거할 수 있는 이점이 있습니다.

플랫폼 아키텍처에서는 레이어들이 위치할 폴더구조를 재정의하고 각 레이어들의 사용 용례 및 Bad/Good case 코드 스니펫을 가이드문서로 제공하여 플랫폼 아키텍처 본연의 역할 중 하나인 누구나 이해하기 쉽고 구성하기 쉬운가 를 만족할 수 있게 되었습니다. 배민 개발문화 중 하나인 피트스탑 행사를 통해 기존 어드민 프로젝트들의 코드 아키텍처를 새로운 플랫폼 아키텍처로 모두 전환하기도 했습니다.

아키텍처 맵을 한번 변경하면 기존 코드는 자동으로 레거시 코드가 됩니다. 개발자에게 있어 레거시 코드는 꿈과 희망이 아닙니다. 고통과 절망에 가깝죠. 그러나 잠깐의 고통으로 이후 개발 생산성이 조금이나마 향상될 수 있다면 바로 그것이 꿈과 희망이지 않을까요. 저희는 꿈과 희망을 가지고 세 번째 개선까지 진행하게 되는데 그것이 현재의 모습입니다.

플랫폼 아키텍처 ver.2

버전1 아키텍처와 거의 흡사하지만 Domain 레이어가 좀 더 이름값을 할 수 있도록 Model 레이어로 이름이 변경되었고, API 모듈을 가져왔습니다. 또 공통으로 사용되는 모노레포 내부 패키지나 모듈들은 레이어 내에서 벗어나 별개 그룹으로 분리했습니다.

이렇게 플랫폼 아키텍처 맵은 두차례의 개선을 통해 계속해서 발전하고 있습니다. 100% 정답을 맞히기 어려운 영역인 만큼 분기별로 사용성에 대한 의견을 주고받고 사소하게나마 업데이트를 하려고 노력 중에 있습니다.

플랫폼 보일러플레이트

새로운 비즈니스 과제를 진행하면서 앞서 소개한 모노레포 내 프로젝트와 빌드 오케스트레이션이 적용된 패키지(또는 라이브러리)들을 새로 만들어 워크스페이스에 추가해야 하는 일이 종종 발생했습니다. 신규 프로젝트, 패키지는 기존 코드를 복사하여 생성하는 것은 어렵지 않았지만 기능이 없는 실행 가능한 초기 상태의 환경을 구성하는 데 적지 않은 리소스가 소모되었습니다.

비즈니스 개발에 있어서도 비슷한 문제가 있었습니다. 앞서 정의한 플랫폼 아키텍처 맵을 코드에 적용하는 데 있어 매번 기존 코드를 레퍼런스 삼아 복사해서 사용하는 경우가 굉장히 잦았고, 불필요한 작업이 계속 반복되었습니다.

신규 프로젝트나 패키지를 생성할 때, 프로젝트에서 아키텍처 레이어를 생성할 때 모두 일괄된 규칙아래 정제된 코드가 자동으로 만들어질 수 있도록 플랫폼 보일러플레이트를 구성했습니다.

대화형 CLI

플랫폼 모노레포에는 매번 pnpm 또는 turbo 커맨드 명령어를 입력할 필요 없이 cli-selectchalk 를 활용한 대화형 CLI 를 통해 커맨드 명령어가 실행될 수 있도록 구성되어 있습니다. 대화형 CLI 에 필요한 실행 목록은 워크스페이스 내 package.jsonscript 를 기준으로 구성되며 각 스크립트에 해당하는 설명을 보여줄 수 있도록 워크스페이스마다 매핑 가능한 JSON 파일이 선언되어 있습니다.

대화형CLI 를 통해 프로젝트를 실행하는 모습

프로젝트 및 패키지 보일러플레이트 구성

프로젝트 및 패키지 생성을 자동화 하기 위해 모노레포 루트에 보일러플레이트 폴더를 생성하고 그곳에 project 와 package 초기 상태의 환경 파일들을 생성한 뒤 폴더 및 파일을 모두 복사 붙여넣기 할 수 있는 node 스크립트를 작성했습니다.

프로젝트 보일러플레이트 파일 트리구조

위 이미지에 있는 projects.default 하위 폴더 및 파일들이 모두 복사되어 기존 projects 워크스페이스에 추가로 생성됩니다. 이 과정에서 필요한 요소(프로젝트 이름, 타이틀, env 내 필수 환경변수, 서브도메인 등) 들은 대화형 CLI 를 통해 사용자가 선택하거나 입력할 수 있습니다. 패키지도 동일한 동작으로 생성되는데요, 프로젝트와 달리 생성 즉시 라이브러리로 배포할 수 있는 추가 구성이 포함되어 있습니다.

코드 제너레이터

말그대로 코드를 자동으로 생성하는 기능입니다. 마이크로 제너레이터 JS 프레임워크인 plopjs 를 사용하여 hbs 확장자로 만든 템플릿에 프롬프트 질문/답변을 동적으로 주입시킬 수 있습니다.

// web-service-layer.hbs
import React from 'react'

import { use{{name}} } from './hooks'
import * as Styled from './styles'

interface Props {
  content?: string
}

const {{name}}: React.FC<Props> = ({ content = '내용을 입력하세요' }) => {
  // TODO: 아래 eslint 주석을 제거해주세요.
  // eslint-disable-next-line no-empty-pattern
  const {} = use{{name}}({})

  return <Styled._Wrapper>{content}</Styled._Wrapper>
}

export default {{name}}

플랫폼 아키텍처의 웹서비스 레이어 중 컴포넌트를 생성해주는 hbs 템플릿 입니다. plopjs 의 프롬프트를 통해 사용자로부터 입력받은 name 값을 컴포넌트 이름으로 사용합니다. plop.setGenerator 함수를 통해 만든 제너레이터는 대화형 CLI 를 통해 실행시켜 아키텍처 레이어를 쉽고 빠르게 생성할 수 있습니다.

대화형CLI에서 코드 제너레이터를 실행한 모습

빠른 이슈 대응

플랫폼 어드민의 경우 운영 환경에서의 이슈 발생시 빠르게 대응하지 않으면 셀러분들의 지점 운영에 큰 문제를 끼칠 수 있습니다. 상품 재고가 수정되지 않는다거나, 전시에서 제외해야 할 상품이 있는데 설정 수정이 안 된다거나 하는 문제들은 결국 배민 앱 고객에게까지 영향을 미칠 수 있습니다.

API 응답 스키마 검증

플랫폼 어드민에서는 서버 API 를 호출할 때 응답받는 데이터 모델 스키마를 자체적으로 검증하고 있습니다. OpenAPI 스펙으로 작성된 Swagger 응답 인터페이스가 실제 응답 결과와 다르면 런타임 환경에서 에러를 발생시키는 것으로 말이죠. 스키마 검증을 위한 라이브러리로는 Superstruct 를 사용하고 있습니다.

물론 서버 응답값이 정의된 스키마와 다르다고 하여 운영환경에서도 런타임에러가 발생하면 안되겠죠. 스키마 검증은 개발/베타 환경에서만 수행하도록 하여 운영환경에서의 이슈를 사전에 예방하려는 것이 목적입니다.

API 응답스키마 중 일부

위와 같은 서버 응답 필드의 타입이 Swagger 를 통해 프론트 개발자에게 전달됩니다. 여기서 processStatus 는 4개의 Enum 값을 가진 string 타입 이라는 것을 알 수 있습니다. 프론트에서는 superstruct 를 통해 아래와 같이 스키마를 검증합니다.

import { object, union, literal, assert } from 'superstruct'
import axios from 'axios'

const API_PATH = '...'

// Superstruct 스키마
export const Schema = object({
  processStatus: union([literal('PENDING'), literal('PROCESSING'), literal('COMPLETED'), literal('FAILED')])
})

// API 모듈
export const getAPI = async () => await axios.get(API_PATH).then((res) => {
  if (process.env.APP_ENV !== 'production') {
    assert(res.data, Schema) // 스키마 검증
  }

  return res
})

프론트에서는 processStatus 의 값을 가지고 버튼 요소나 레이아웃을 조건부로 렌더링할 수도 있고 어떠한 상태를 제어하고 있을 수도 있습니다. 그런데 만일 서버에서 예상치 못한 Enum 값이 추가되어 실제 응답값으로 넘어온다면 어떨까요. 물론 대개의 경우 서버 응답스펙이 변경되면 사전에 공유가 되겠지만 이번엔 모종의 이유로 공유가 누락 된 것으로 가정해 보겠습니다.

프론트에서는 당연하게도 새로 추가된 Enum 값에 대한 조치가 되어있지 않기 때문에 의도하지 않은 동작이 발생하게 됩니다. 문제는 이러한 이슈의 존재 유무 자체를 모를수도 있다라는 점입니다. Superstruct 를 통한 스키마 검증은 바로 이런 상황에서 런타임 에러를 발생시키므로 이슈 파악에 큰 도움이 됩니다. 간혹 히스토리를 모르는 담당자가 해당 지면을 개발하다가 아래와 같은 스낵바 에러를 만난다면, 전체적인 코드 흐름을 파악하지 않고도 어떤 에러인지 알 수 있습니다.

processStatus 값에 정의되지 않은 ‘SUCCESS’ 응답 값이 넘어 올 경우 런타임 에러와 함께 스낵바 에러를 보여준다.

하지만 스키마 검증을 너무 엄격하게 정의하는것은 오히려 추가/변경이 잦은 비즈니스 개발에 병목을 일으킬 수 있는 요인이 되기도 합니다. 현재 플랫폼 어드민에서는 struct 필드 타입에 nullable을 허용하거나 응답객체의 경우 superstruct.object 대신 추가 프로퍼티를 허용하는 superstruct.type을 사용하여 유동적으로 검증을 수행하고 있습니다.

Sentry 및 Grafana 모니터링

Superstruct 사용과 같이 이슈를 사전에 예방하는 방법도 있지만 더 중요한 것은 실제로 이슈가 발생했을 때 이를 얼마나 빨리 인지할 수 있는가입니다. 특히 구성원 모두가 출근하지 않은 공휴일이나 새벽 시간대는 더욱 인지하기가 어렵습니다. 플랫폼 어드민에서는 Sentry 로 에러를 트래킹하고 Grafana 모니터링을 통해 인스턴스의 상태를 체크하여 실시간으로 알림을 받고 있습니다.

인프라 구성

플랫폼 어드민은 모두 Next.js 로 개발돼 있으며 프로젝트 규모나 운영 목적에 따라 아래 두 가지 방식 중 하나를 골라 구성하고 있습니다.

  • SSR & CSR 렌더링 기반의 Web Application 서버
  • CSR 렌더링 기반의 Static Web 페이지

사용자 및 배포 프로세스

Web Application 서버 인프라 구성

웹 애플리케이션 서버의 경우 EC2 인스턴스에 Docker Compose 를 이용하여 애플리케이션을 구성합니다. 아래 배포 프로세스에 Harbor 가 있는데 이는 Private registry 로서, 도커 이미지 저장소를 의미합니다.

GitLab CI 를 통해 프로젝트 빌드 및 pnpm deploy 를 실행하고 도커로 빌드하여 저장소에 푸시합니다. 이후 Jenkins REST API 를 통해 Jenkins 파이프라인을 실행합니다. Jenkins에서는 배민 내부 배포시스템을 통해 AWS 리소스를 생성하고 CodeDeploy 를 통해 도커 컨테이너를 EC2 내부에서 실행되도록 합니다. 이러한 웹 애플리케이션 서버 인프라 구성은 추후 EKS Cluster를 통한 배포 파이프라인 구성으로 전환할 예정입니다.

Static Web 인프라 구성

정적 웹 배포의 경우 훨씬 간단합니다. 웹 애플리케이션 서버가 필요하지 않기 때문에 Jenkins에서 곧바로 S3 버킷으로 빌드 아티팩트를 전송합니다. CloudFront의 create-invalidation 명령 역시 Jenkins에서 실행됩니다.

원격 캐싱

GitLab CI/CD에서 캐싱을 설정하면 패키지 install 속도나 프로젝트 빌드 속도를 향상시킬 수 있습니다. 그러나 이러한 캐싱은 한계가 있습니다. 결국 캐시는 로컬 컴퓨터에 위치하기 때문에 동일한 작업을 다른 환경에서 실행했을 때 캐시를 가져오지 못하기 때문이죠. 이를 분산 캐싱을 통해 해결할 수 있지만 보다 빠른 캐싱 방법이 있습니다.

출처 : Turborepo remote caching

빌드 오케스트레이션을 적용하기 위해 앞서 터보레포를 적용한 바가 있었습니다. 터보레포에서는 원격 캐시를 지원하여 CI 상에서도 단일 터보레포 캐시를 공유할수가 있습니다. 터보레포는 Vercel(Next.js 를 개발한 미국의 클라우딩 컴퓨터 회사) 이 제공하는 리모트 캐시를 이용하거나, 자체 호스팅을 통한 리모트 캐시 환경을 직접 만들어서 사용할 수 있도록 지원하고 있습니다. Vercel에서 지원하는 리모트 캐시는 외부 서비스이기 때문에 보안 검토가 필요하고, 사용 인원당 매월 비용이 발생하는 이유로 저희는 자체 호스팅을 이용하기로 했습니다.

터보레포에서는 커스텀 원격 캐시 서버를 오픈소스 형태로 제공하고 있습니다. Git 이나 도커 이미지를 통해 쉽게 서버를 구성할 수 있도록 가이드 문서를 제공합니다. 원격 캐시 서버를 생성하여 호스팅하고 CI에서 아래와 같이 설정했습니다.

# turbo.sh (CI 환경에서만 원격 캐시를 적용한다.)
if [[ "$CI" = "true" ]];
then
  turbo --token commerce run $*;
else
  turbo run $*;
fi

# .turbo/config.json (저장소 루트 디렉토리에 위치해야 하며 아래 환경설정은 필수사항.)
{
  "teamid": "team_webfront",
  "apiurl": "https://turbocache.{HOST}"
}

# package.json (아래 스크립트를 통해 turbo ... 대신 pnpm tr ... 으로 실행할 수 있다.)
{
  ...
  "scripts": {
    "tr": "sh ./turbo.sh",
    ...
  }
}

# .gitlab-ci.yaml (CI 환경에서만 원격 캐시를 적용하기 위해 글로벌 변수값을 설정한다.)
variables:
  CI: 'true'

터보레포의 원격 캐시를 사용하면 거의 모든 작업을 캐시할 수 있습니다.

8개의 패키지 빌드 결과물을 원격으로 캐시

플랫폼의 미래

지금까지 플랫폼 어드민의 탄생부터 현재까지 많은 이야기를 드렸는데요, 이제 앞으로의 플랫폼 어드민의 미래는 무엇일지 소개드리고자 합니다. 여기서 말하는 미래란 수년 후 미래가 아닌 1년 후 플랫폼에서 달성하고 싶은 기술적인 목표를 말합니다.

MFA (Micro Frontend Architecture) 를 적용한 통합어드민

현재 플랫폼 어드민의 경우 공통 구성 요소에 대해 대부분이 패키지 또는 라이브러리화 되어있습니다. 이를 통해 많은 중복코드를 제거할 수 있었죠. 하지만 배포 의존성에 대한 문제는 여전히 존재합니다. @baemin/admin-footer 라는 UI 라이브러리가 버전업 되어 새로 배포된다면, 해당 라이브러리를 사용하고 있는 프로젝트를 새로 배포하기 전 까지는 페이지에 반영되지 않습니다. 반대로 UI 라이브러리의 문제가 발생할 경우에도 마찬가지 일 겁니다.

현재 플랫폼 어드민을 구성하는 이러한 방식은 결국 빌드 타임에 코드가 통합된다는 한계가 존재합니다. 즉 다수로 분리된 UI 나 모듈 등의 배포가 발생하면 사용처 모두가 다시 빌드를 해야하는 상황을 근본적으로 제어 할 수가 없습니다.

Module Federation

하지만 분리된 UI 나 모듈을 빌드타임이 아닌 런타임에 통합시키면 어떨까요? webpack5에서는 Module Federation을 통해 이것을 가능하게 합니다.

Module Federation · GitHub
출처: Webpack

어드민 구성 요소에 어느 단위까지 배포 단위를 쪼갤지까지는 아직 논의되지 않았습니다만, 대략적인 구성안을 그려본다면 아래와 같습니다.

(좌)현재 (우)MFA
  • 어드민의 Header, Sidebar에 해당하는 부분을 Host Container로 관리합니다.
  • 페이지는 컴포넌트 단위로 분리하며 다이내믹 Remote Container로 관리합니다.

프로젝트는 더이상 어드민 구성요소를 전부 가지고 있지 않습니다. 보여줄 페이지 컴포넌트만 expose 해주는 역할로 분리할 수 있습니다. 호스트 컨테이너에서도 각기 다른 어드민 여러개를 스위칭해주는 통합 사이드바/헤더 가 될 수도 있을 것 같은데요. 이처럼 MFA 를 도입하면서 얻을 수 있는 이점은 정말 많을 것 으로 기대하고 있습니다.

e2e 통합

플랫폼 어드민에는 이미 e2e 테스팅 도구인 playwright를 사용한 테스팅 환경이 구축되어 있습니다. 웹 대시보드 페이지를 통해 어드민별 전체 테스트, 각 어드민의 메뉴별 테스트를 수행할 수 있습니다. 다만 어드민 하나에 만 개가 넘는 TC를 모두 테스트코드로 전환하기 어려운 물리적인 이유로 잠시 소강상태로 전환된 상태입니다.

실제 구현된 TC 의 일부
playwright Traces 기능을 통해 TC 수행 장면을 직접 볼 수 있다.

신규 페이지의 테스트 코드는 ChatGPT의 도움을 받아 작성하고, 이후 변경된 커밋을 감지하여 자동으로 테스트 코드를 업데이트 해주는 기능을 추가할 수도 있을 것 같습니다. (과연?) 어쨌든 테스트 코드가 모두 채워진다면 QA 리소스는 물론이고 향후 유지보수에 대한 모든 비용을 줄일 수 있을 거라 기대하고 있습니다.

보일러플레이트 고도화

현재 보일러플레이트를 통한 신규 프로젝트나 패키지의 생성은 단순 코드 생성에 그치고 있습니다. 앞으로 프로젝트를 생성하면 코드 뿐만 아니라 코드형 인프라(laC)를 이용한 인프라 프로비저닝, 센트리와 그라파나 등 로깅시스템 구성까지 제공하는 것을 목표로 하고 있습니다. 전사 차원에서 활용할 수 있는 프론트엔드 통합 CLI 솔루션을 제공하는 것이 최종 목표라고 할 수 있을 것 같습니다.

R=VD 꿈은 이루어진다

마치며

글을 쓰고 보니 지난 1년간 정말 수 많은 개선 작업을 진행한 것 같습니다. 비즈니스 개발을 병행했던 것을 감안하면 더 놀라운 결과인 것 같아요. 미처 이 글에서 담지 못한 내용도 매우 많은데요, 신규 디자인시스템을 기존 프로젝트에 반영하는 대규모 작업, 디자인 및 기획 커뮤니케이션 비용을 줄이고자 시작된 어드민 전용 컴포넌트 개발, 작업물을 미리볼 수 있는 배포 환경인 Preview Deployment 구축 등 여러 개선 작업을 진행했습니다.

플랫폼 어드민 구축을 위해 구상부터 시작하여 수많은 논의, 구현 결과물 까지 모든 팀원분들이 함께해 주었고, 저는 팀원들의 결과물을 그저 정리한 것에 불과합니다.

팀 워 크

플랫폼 어드민을 만들기까지 배민커머스웹프론트개발팀 말고도 수많은 유관부서 분들이 함께했습니다. 기능 하나를 오픈하기 위해 70명 가까이 되는 인원이 한데 모여 배포를 한 경험이 특히 기억에 남는데요, 이토록 모두의 노력이 실린 플랫폼 어드민을 그저 정성으로 가꾸는 것이 우리 팀의 역할이 아닐까 싶습니다.

계속 성장중인 배민스토어와 B마트 등 배민커머스를 향한 열정은 내일도 계속됩니다! 열정가득한 배민커머스웹프론트개발팀에서 함께하고 싶으시다면 언제든 채용 문을 두들겨주세요. 감사합니다.