LY Corporation Tech Blog

LY Corporation과 LY Corporation Group(LINE Plus, LINE Taiwan and LINE Vietnam)의 기술과 개발 문화를 알립니다.

오픈소스 ABC User Feedback에 적용한 모노리포 구조 소개

안녕하세요. 저는 LINE+ ABC Studio의 프런트엔드 개발자 정치영입니다. 현재 오픈소스 프로젝트인 ABC User Feedback를 개발하고 있습니다. 이번 글에서는 풀 스택 웹 애플리케이션인 ABC User Feedback을 모노리포(monorepo) 구조로 개발한 경험을 공유하겠습니다.

모노리포 구조 선택

ABC User Feedback은 온프레미스(on-premise) 방식의 소프트웨어로 설치형 솔루션입니다. 사용자의 피드백을 받을 수 있는 API와 받은 피드백을 시각화해 볼 수 있는 관리자용 웹 화면을 제공하고 있습니다.

이와 같이 단순한 API - 웹 구조의 풀 스택 웹 애플리케이션을 개발하면서, 여러 개발자들이 효율적으로 협업하기 위해 모노리포 구조를 적용하고 이 구조를 더 잘 활용할 수 있도록 TypeScript라는 하나의 언어만 사용해서 개발했는데요. 먼저 여러 모노리포 구조 중 어떤 구조를 검토하고 선택했는지 말씀드리겠습니다. 

개별 프로젝트 구조

이와 같은 애플리케이션을 개발할 때 가장 먼저 간단하게 생각할 수 있는 구조는 프런트엔드와 백엔드로 나눈 모노리포 구조입니다. 각각을 개별 프로젝트로 구성해 단순히 한 저장소에서 관리하는 것인데요. 이 구조는 각 프로젝트를 명확하게 분리할 수 있다는 장점이 있지만, 프로젝트 간 코드 공유가 어렵다는 단점이 있습니다.

frontend
  ㄴ src
  ㄴ package.json
backend
  ㄴ src
  ㄴ package.json

단일 프로젝트 구조

단일 저장소의 하나의 프로젝트 안에 여러 개의 서브 패키지를 만드는 방식입니다. 모든 서브 패키지의 설정을 동일하게 맞춰서 코드의 일관성을 유지할 수 있으며, 코드 재사용성이 높아지고, 코드의 관심사를 분리할 수 있습니다. 또한 모노리포 관련 라이브러리를 사용하면, 빌드 및 배포 시 서브 패키지들에 대해 같은 태스크를 반복 수행하지 않고 보다 효율적으로 빌드하고 배포할 수 있습니다.

모노리포에 대한 자세한 설명과 관련 도구는 monorepo.tools 를 참고하시기 바랍니다.

단일 프로젝트 구조의 가장 간단한 예시로 apps 디렉터리와 packages 디렉터리로 구성하는 구조를 생각할 수 있습니다. apps 디렉터리는 각각이 하나의 애플리케이션으로 온전히 작동할 수 있는 패키지들로 구성하고, packages 디렉터리는 코드 공유를 위한 패키지들로 구성합니다.

apps
  ㄴ api
  ㄴ web
packages
  ㄴ ui
  ㄴ eslint

단일 프로젝트 구조에 tooling 디렉토리를 추가한 구조

현재 ABC User Feedback에서 사용하고 있는 모노리포 구조는 아래와 같습니다. 앞서 살펴본 단일 프로젝트 구조에 tooling 디렉터리를 추가해 apps, packages, tooling으로 디렉터리를 나눠 각 패키지를 관리하는 구조입니다. 

apps
  ㄴ web
  ㄴ api
pacakges
  ㄴ ufb-shared      // @ufb/shared
  ㄴ ufb-tailwind   // @ufb/tailwind
  ㄴ ufb-ui         // @ufb/ui
tooling
  ㄴ eslint          // @ufb/eslint-config
  ㄴ prettier        // @ufb/prettier-config
  ㄴ typescript      // @ufb/tsconfig

어떤 이유로 이와 같은 구조를 채택했는지 각 디렉터리를 하나씩 살펴보며 말씀드리겠습니다.

tooling 디렉터리를 추가한 모노리포 구조의 각 디렉터리 소개

앞서 살펴본 것처럼 ABC User Feedback에 적용한 모노리포 구조는 apps, packages, tooling 디렉터리로 나뉜 구조인데요. 프로젝트의 기반이 되는 설정을 위한 패키지를 모아놓은 tooling 디렉터리부터 시작해 코드를 공유하기 위한 packages 디렉터리, 실제 애플리케이션으로 작동하는 패키지들이 위치한 apps 디렉터리 순으로 각 디렉터리에 어떤 패키지가 어떤 역할로 들어가 있는지 하나씩 살펴보겠습니다. 

전반적인 공통 설정을 위한 패키지들을 모아 놓은 tooling 디렉터리

tooling 디렉터리에는 각 패키지에 공통으로 적용하는 설정 패키지들을 모아 놓습니다. 코드 포맷을 설정하기 위한 @ufb/ESLint-config와 @ufb/prettier-config 패키지, tsconfig 설정 파일을 공유하기 위한 @ufb/tsconfig 패키지로 구성돼 있는데요. 각 패키지별로 기본적으로 설정해야 하는 ESLintPrettier, tsconfig 설정을 한 곳에 모아 관리하는 구조이기 때문에 유지 보수가 쉬워지고 전체 프로젝트에 일관된 설정을 적용할 수 있습니다. 또한 ESLint와 Prettier의 경우 다양한 설정을 적용하기 위한 플러그인 설치를 한 곳에서만 진행하면 되므로 플러그인 버전 관리 또한 용이해집니다. 

@ufb/eslint-config 패키지에는 각 패키지별로 설정해야 할 ESLint 규칙이 들어있습니다. 각 패키지에서 사용하는 프레임워크의 특성에 맞게 ESLint 설정 파일을 만들어 별도로 관리합니다.

eslint
  ㄴ base.js // 공통적으로 사용되는 eslint 설정 파일
  ㄴ nest.js // nestjs 프레임워크 기반의 패키지에서 사용하는 eslint 설정 파일
  ㄴ next.js // nextjs 프레임워크 기반의 패키지에서 사용하는 eslint 설정 파일
  ㄴ react.js // react 기반의 패키지에서 사용하는 eslint 설정 파일
  ㄴ package.json

@ufb/prettier-config에는 각 패키지에서 공통으로 사용하는 Prettier 설정 파일이 들어있습니다. 저희는 Prettier 기본 설정에 import 정렬 플러그인과 Tailwind CSS 관련 플러그인을 추가로 설치해 사용하고 있습니다.

/** @typedef  {import("prettier").Config} PrettierConfig */
/** @typedef {import("prettier-plugin-tailwindcss").PluginOptions} TailwindConfig */
/** @typedef  {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig */
 
/** @type { PrettierConfig | SortImportsConfig | TailwindConfig } */
const config = {
  singleQuote: true,
  trailingComma: 'all',
  plugins: [
    '@ianvs/prettier-plugin-sort-imports',
    'prettier-plugin-tailwindcss',
  ],
  tailwindConfig: fileURLToPath(
    new URL('../../packages/ufb-tailwind/index.js', import.meta.url),
  ),
  importOrder: [
    '^(@nestjs/(.*)$)|^(@nestjs$)',
    '^(react/(.*)$)|^(react$)',
    '^(next/(.*)$)|^(next$)',
    '<THIRD_PARTY_MODULES>',
    '',
    '^@ufb/(.*)$',
    '',
    '^@/',
    '^[../]',
    '^[./]',
  ],
  importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'],
};
export default config;

다른 패키지에서는 아래와 같이 package.json 파일에서 간단히 설정하는 것으로 @ufb/eslint-config와 @ufb/prettier-config 패키지를 손쉽게 사용할 수 있습니다. ESLint와 Prettier 설정 파일을 각 패키지에 따로 생성하지 않고도 각각을 설정할 수 있어 파일 관리에도 도움을 줍니다.

// package.json
{
  ...
  "prettier": "@ufb/prettier-config",
  "eslintConfig": {
    "extends": [
      "@ufb/eslint-config/base",
      "@ufb/eslint-config/react"
    ],
    "root": true
  },
}

코드를 공유하기 위한 packages 디렉터리

packages 디렉터리는 코드를 공유하기 위한 패키지들로 구성돼 있습니다. 관리자용 웹의 UI를 만들기 위한 @ufb/tailwind와 @ufb/ui 패키지가 있고, apps 디렉터리 내부 패키지 간 공통 코드를 공유하기 위한 @ufb/shared 패키지가 있습니다. 이 디렉터리를 통해 코드를 공유함으로써 코드 재사용성을 향상시킬 수 있으며, 코드 관심사를 분리해 코드를 더욱 효율적으로 관리할 수 있습니다.

일반적으로 코드를 공유할 때에는 패키지를 빌드해 나온 산출물을 다른 패키지에서 가져오는(import) 방식을 사용하는데요. 저희는 Turborepo라는 라이브러리를 채택해서 내부 패키지를 빌드하지 않고도 코드를 공유할 수 있도록 지원하고 있습니다.

모노리포에서 모듈 시스템에 따른 코드 공유 방식

코드를 공유할 때 유의할 점은 코드 공유를 하는 두 패키지들이 서로 같은 모듈 시스템을 사용하는지 아니면 다른 모듈 시스템을 사용하는지 확인해야 한다는 것입니다. JavaScript에는 CJS(CommonJS)와 ESM(ECMAScript Modules)이라는 두 가지 모듈 시스템이 있는데요. 이 두 모듈 시스템은 서로 호환되지 않습니다. 따라서 모노리포에서 이 두 패키지가 어떤 모듈 시스템을 사용하고 있는지에 따라 빌드해서 사용해야 하거나 빌드하지 않고 사용할 수도 있습니다. 같은 모듈 시스템을 사용할 경우 빌드 없이 코드를 공유할 수 있지만, 다른 모듈 시스템을 사용할 경우 같은 모듈 시스템으로 맞춰야 하기 때문에 빌드를 해야 코드를 공유할 수 있습니다. 

ABC User Feedback에서 API 패키지는 NestJS 프레임워크를, 웹 패키지는 NextJS 프레임워크를 사용하고 있습니다. NestJS 프레임워크는 CJS 모듈 시스템을, NextJS는 ESM 모듈 시스템을 사용하기 때문에 packages 디렉터리에서 하나의 패키지를 API와 웹 패키지에서 동시에 공유를 하려면 반드시 빌드를 해서 코드 공유를 해야하는 상황이 발생합니다.

구체적으로 @ufb/ui 패키지의 경우 웹 패키지에서만 사용하기 때문에 같은 ESM 모듈 시스템을 사용해 빌드 없이 사용할 수 있다는 효율을 챙겼습니다. 반면 @ufb/shared 패키지의 경우 API 패키지와 웹 패키지에서 모두 사용하기 때문에 tsup이라는 라이브러리를 이용해 CJS와 ESM 모듈 기반으로 각각 빌드해 각 앱에서 사용합니다.

두 가지 방식 모두 장단점이 있습니다. 같은 모듈 시스템을 사용하는 경우 바로 import하면 별도로 빌드하지 않아도 되기 때문에 빌드 의존성을 신경쓰지 않아도 되고 애플리케이션으로 빌드하기 위한 시간도 단축되지만, 동일한 모듈 시스템을 사용하도록 신경 써야합니다. 다른 모듈 시스템을 사용하는 경우에는 서브 패키지를 별도로 빌드해야 하기 때문에 애플리케이션을 빌드하기 위한 시간이 더 필요하지만, 모듈 시스템 호환성은 신경 쓰지 않고 코드 개발에만 집중할 수 있습니다(참고).

애플리케이션을 위한 apps 디렉터리

apps 디렉터리는 하나의 애플리케이션으로 빌드되는 패키지들로 구성된 디렉터리로, API 서버를 위한 API 패키지와 관리자용 웹을 위한 웹 패키지가 위치합니다. 이 디렉터리에 있는 패키지들은 코드 공유를 위해 가져오는 서브 패키지가 많기 때문에 빌드 의존성을 잘 관리해야 하고, 서브 패키지의 동일한 결과물에 대해 같은 작업을 반복하지 않도록 최적화를 고려해야 합니다.

저희는 현재 빌드 시스템으로 Tureborepo를 사용하고 있는데요. 다음으로 Tureborepo에서 제공하는 빌드 시스템을 어떻게 활용하고 있는지, 각 태스크에 대한 파이프라인을 어떻게 관리하고 있는지 공유하겠습니다.

Turborepo를 이용해 패키지 간 파이프라인 구성 및 빌드 캐시 설정

Turborepo는 JavaScript와 TypeScript 코드 기반의 모노리포를 위한 고성능 빌드 시스템입니다. 캐시를 활용해 같은 작업을 두 번 이상 진행하지 않으며, 각 태스크는 병렬로 수행합니다. 태스크 간 의존성이 있는 경우 파이프라인을 이용해 태스크 간 의존성을 정의해서 최적화해 수행합니다.

ABC User Feedback은 다음과 같이 파이프라인을 설정했습니다.

{
  "$schema": "https://turbo.build/schema.json",  
  "pipeline": {
    "topo": {
      "dependsOn": ["^topo"]
    },
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "next-env.d.ts", "!.next/cache/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },    
    "web#dev": {
      "dependsOn": ["@ufb/tailwind#build", "@ufb/shared#build"]
    },
    "api#dev": {
      "dependsOn": ["@ufb/shared#build"]
    },
    "@ufb/tailwind#build": {
      "outputs": ["dist/**"]
    },    
    "format": {
      "outputs": ["node_modules/.cache/.prettiercache"],
      "outputMode": "new-only"
    },
    "lint": {
      "dependsOn": ["topo"],
      "outputs": ["node_modules/.cache/.eslintcache"]
    },
    "typecheck": {
      "dependsOn": ["topo"],
      "outputs": ["node_modules/.cache/tsbuildinfo.json"]
    },
    "test": {
      "dependsOn": ["topo", "@ufb/shared#build"]
    },
    "clean": {
      "cache": false
    },
    "//#clean": {
      "cache": false
    }
  },
  "globalDependencies": ["**/.env"],
  "globalEnv": [ ... ]
}

여기서 주목해야 할 태스크는 topo입니다. 이 태스크는 연관된 서브 패키지의 테스크를 올바르게 캐싱하기 위한 태스크인데요. 예를 들어 웹 패키지가 UI 패키지를 import한 상황에서 typecheck (turbo run typecheck) 태스크(전체 패키지 대상 typecheck)를 실행한 후 UI 패키지를 수정한 다음 typecheck (turbo typecheck --filter=web) 태스크(웹 패키지 대상 typecheck)를 실행하면 웹 패키지에 대해서는 수정 사항이 없기 때문에 캐시에서 결과를 가져올 수 있습니다. 하지만 웹 패키지에 의존하는 UI 패키지는 수정됐기 때문에 웹 패키지에 대해서는 typecheck 태스크가 다시 실행돼야 합니다.

이런 경우를 방지하고자 아무 작업도 하지 않는 topo라는 태스크를 추가했습니다. 이 태스크는 서브 패키지 간의 의존성 토폴로지를 생성해서 의존하고 있는 특정 패키지가 변경된다면 토폴로지의 경로에 따라 태스크의 캐싱 여부를 확인합니다. 자세한 내용은 Turborepo에서 제공하는 Dependencies outside of a task 문서를 참고하시기 바랍니다.

lint와 format 태스크의 경우 캐싱을 위해 캐싱 파일 경로를 설정했습니다. 루트 디렉터리에 위치한 package.json 파일에 아래와 같이 cache-location을 지정하고, 앞서 살펴본 파이프라인 설정 파일에 outputs로 캐싱 파일을 설정했습니다.

"format": "turbo format --continue -- --cache --cache-location='node_modules/.cache/.prettiercache'",
"lint": "turbo lint --continue -- --cache --cache-location 'node_modules/.cache/.eslintcache'",

각 패키지에 대한 typecheck 태스크 또한 각 패키지 내의 tsconfig.json 설정 파일에 "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 옵션을 추가해 typecheck 태스크를 실행한 후 캐싱 파일을 설정해서 관리합니다.

또한 파이프라인 설정 파일의 globalDependencies에는 의존성 파일을 지정할 수 있습니다. Turborepo는 이곳에 지정한 파일이 변경되면 캐싱해 놓은 것을 버리고 다시 캐싱을 시작하는데요. 저희는 이곳에 각 패키지의 데이터베이스 정보나 서버의 엔드포인트 등이 지정돼 있는 .env 파일을 설정해 개발 환경에 따라 캐싱을 다시 하도록 설정했습니다. devbuild 같은 태스크에 유용합니다.

원격 캐시

앞서 살펴본 설정으로 로컬에서 캐싱되는 것은 확인했지만, Docker 빌드를 하거나 CI를 위한 태스크를 진행할 때에는 위 방법의 캐싱은 사용할 수 없습니다. 같은 코드에 대한 반복 작업을 서로 다른 여러 컴퓨터에서 진행하기 때문인데요. 현대의 모노리포 도구에서는 분산 계산 캐싱(distributed computation caching)을 중요하게 생각하며, 이에 따라 Turborepo도 원격 캐시를 지원합니다. Turborepo를 사용해 Docker 빌드할 때 어떻게 원격 캐시를 설정하는지 살펴보겠습니다. 

Tureborepo에서 Vercel 플랫폼을 사용한다면 별도 설정 없이 배포할 때 자동으로 원격 캐싱이 됩니다. 하지만 Vercel을 사용하지 않는다면 별도로 캐시 서버를 운영하면서 이 서버 설정을 추가해 줘야 하는데요. 이때 오픈소스 turborepo-remote-cache를 활용하면 간편하게 캐시 서버를 운영할 수 있고, 이 서버 설정을 .turbo/config.json과 Dockefile에 추가하면 원격 캐시를 활성화할 수 있습니다.

// .turbo/config.json
{
  "teamid": "team_myteam",
  "apiurl": "http://localhost:3000"
}
// Dockerfile
 
ENV TURBO_TOKEN=
 
COPY turbo.json ./
COPY .turbo/config.json ./.turbo/
 
RUN --mount=type=bind,source=.git,target=.git \
    pnpm turbo build

마치며

ABC User Feedback은 API - 웹 구조를 TypeScript 언어 기반으로 개발해서 하나의 리포지터리에서 관리하기 위해 모노리포 구조를 채택했고, 이 구조의 특성을 적극적으로 활용하기 위해 디렉터리를 세 개로 나눠 운영하고 있습니다. 이번 글에서는 저희가 각 디렉터리를 어떤 역할로 사용하고 있는지, 이와 같이 디렉터리를 나눴을 때 얻을 수 있는 장점이 무엇인지 살펴봤는데요. 아래 GitHub에 방문하면 직접 코드를 보실 수 있습니다. 관심 있으신 분들의 많은 방문을 바라며 이만 마치겠습니다.