개요

최근 프로젝트에서는 radix-ui를 이용한 간단한 디자인 시스템을 구축하여 이를 공통 컴포넌트로 놓고, 필요할 때 계속해서 재사용하는 방식을 사용했었다.

그러나 이러한 프로젝트를 함에 있어서 공통 컴포넌트를 components 디렉토리에 넣어놓게 되었는데, 여기서 불편함을 느꼈다. 기존에 있던 다른 컴포넌트들의 경우 특정 도메인에서만 사용하는 경우의 컴포넌트도 분리해서 넣어놓고는 했는데, 결국 이러한 컴포넌트들은 공통 컴포넌트에서 더 추상화해서 만들어놓은 컴포넌트기 때문에 이러한 컴포넌트들이 공통 컴포넌트와 함께 같은 depth에 추상화되어 존재하고 있는 것이 맞을까? 에 대한 고민과 함께, 이 때문에 프로젝트 구조를 살펴보면서 특정 공통 컴포넌트를 찾을 때도 헷갈릴 경우도 종종 있었다.

이와 함께 다른 Provider나 범용적으로 쓰이는 suspenseBoundary(Suspense + error fallback을 주입받아 동시에 사용하는 래퍼) 등의 컴포넌트는 ui가 아닌 Common으로 가게 되었는데, 결국 common과ui를 명확히 가르는 기준이 점점 모호해지는 것 같다는 생각이 들었다. 만약 범용적으로 쓰이는 컴포넌트지만, 디자인 시스템이라고 부르기엔 모호한 단위의 컴포넌트에 대해서는 결국 common으로 가야할텐데, 결국 가독성이 낮아지는 구성이라고 생각했다.

그렇기에 여기서부터 디자인 시스템 등을 따로 분리하면서 조금 더 현명하게 관심사를 분리할 수 없을까?하는 생각에서 모노레포 구성 방식을 생각하게 되었고, 이번 프로젝트에서는 디자인 시스템을 따로 패키지로 분리하여 개발해보자는 생각을 하게 되었다.

모노레포란?

모노레포란 버전 관리 시스템에서 두 개 이상의 프로젝트 코드가 동일한 저장소에 저장되는 소프트웨어 개발 전략이다.

이 개발 전략은 고전적 소프트웨어 개발 방식인 모놀리식 애플리케이션(monolithic application)이 가졌던 한계에 대한 비판에서 출발한다.

모놀리식 애플리케이션 소프트웨어 엔지니어링에서 모놀리식 애플리케이션은 모듈화 없이 설계된 소프트웨어 애플리케이션을 말한다.

모듈화 없이 설계된 소프트웨어는 설계, 리팩토링, 배포 등의 작업에 대해서 많은 비용이 들고, 제약이 있었다.

이러한 모놀리식 애플리케이션의 문제를 해결하고자 나온 것이 모듈러 프로그래밍인데, 애플리케이션 로직의 일부를 재사용할 수 있게 모듈화시키는 방식이다. 이를 통해 모놀리식 애플리케이션이 가졌던 유지보수성, 재사용성 등의 문제를 해결할 수 있었다.

이렇게 모듈은 재사용성이 있기 때문에 기존 애플리케이션 뿐만 아니라 다른 프로젝트에서도 사용할 수 있다. 그렇기 떄문에 멀티레포 방식을 통해 독립적인 개발, 린트, 테스트, 빌드, 게시, 배포 파이프라인을 구축하고 따로 관리하는 방식을 택하기도 했다.

그래서 나 또한 이러한 멀티레포를 사용해서 디자인 시스템을 분리하여 멀티 레포로 관리할 수도 있었을 것이다. 하지만 이러한 멀티 레포의 경우 혼자 개발을 하는 나에게는 문제가 있었다. 바로 인력의 문제였다.

멀티 레포는 독립적인 개발 환경을 구성하기 때문에 저장소 생성 > 커미터 추가 > 개발 환경 구축 > CI/CD 구축 > 빌드 > 패키지 저장소에 publish 와 같은 일련의 프로젝트 구성을 각각 따로 해야한다. 이러한 독립적인 프로젝트의 구성을 혼자서 하게 된다면 결국 배보다 배꼽이 더 커지는 상황이 나올 수 있으므로 멀티레포 방식은 다소 어려움이 있겠다고 판단했다.

이 외에도 멀티레포가 가지는 문제성은

  • 패키지의 중복 코드 가능성
    • 위의 번거로움을 피하기 위해 각 프로젝트에서 공통 구성 요소를 자체적으로 작성한다면, 초기 시간을 아낄 수 있지만 시간이 지날수록 보안 및 품질 관리 부담을 증가
  • 관리 포인트 증가
    • 늘어난 프로젝트 저장소의 수만큼 관리 포인트가 늘어난다. 린트, 테스트, 개발 모드 실행, 빌드, 게시, 배포 등의 과정을 저장소의 수만큼 반복해야 함
  • 일관성 없는 개발자 경험(DX)
    • 각 프로젝트는 테스트 실행, 빌드, 테스트, 린트, 배포 등을 위해 고유한 명령 집합을 사용한다. 이러한 불일치는 여러 프로젝트에서 사용할 명령을 기억해야 하는 정신적 오버헤드를 만듦
  • 다른 패키지의 변경 사항 파악
    • 관련 패키지의 변화를 지켜보거나 통지받지 않으면 문제가 발생할 수 있음
  • 교차 저장소의 리팩터링 비용
    • 관련 패키지의 변화가 있을 때 여러 저장소에 걸쳐 변화를 반영하는 것은 쉽지 않음.

등이 있다.

아무튼 혼자서 멀티레포를 구성하기에는 무리가 있다고 판단하여, 그렇다면 모듈을 적절히 분리하여 관심 분리를 이루면서, 동시에 분리된 모듈을 쉽게 참조하고 테스트, 빌드, 배포 과정도 쉽게 한 번에 할 수 있는 방식을 고민하다가 생각한 것이 모노레포였다.

모노레포 구성하기

모노레포를 구성하기 위한 첫번째 단계는 package manger와 모노레포 build system tool을 결정하는 것이다.

모로레포를 구성하는 방식에는 크게 두가지로 나누어진다.

  1. 모노레포 빌드 시스템 도구 없이 package manager로 구성
  2. 모노레포 빌드 시스템 도구를 같이 사용하는 방식

나와 같은 경우는 혼자 하는 프로젝트이기도 하기 때문에 많은 패키지를 계속해서 관리하는게 아니다 보니까 패키지 매니저 외의 다른 도구에 의존하기 보다는 작게 패키지 매니저를 통해 관리하는게 낫다고 판단했다. 그렇다면 패키지 매니저를 선택해야 했는데, 선택지는 크게 yarn, npm, pnpm 정도가 있을 것 같다.

간단하게 패키지 매니저를 구성하면서 고려했던 문제들 또한 정리해보았다.

유령 의존성(Phantom dependencies)

유령 의존성은 npm과 yarn1 버전에서 가지는 가장 고질적인 문제였다. npm에서 pnpm이나 yarn에서 yarn berry가 나왔던 배경에는 비효율적인 의존성 목록 검색 문제였다. 우리가 쓰는 패키지들은 각각의 node_modules를 가지고 있다. 이런 경우에는 결국 보면 하나의 라이브러리를 사용하더라도 해당 라이브러리에서 사용하는 의존성들이 있을 것이고, 그 의존성에 또 다시 꼬리에 꼬리를 무는 의존성이 만들어지게 된다. 이 상태로 가게 된다면, 실제로 수십, 수백개의 패키지들이 서로 의존성을 가지고 있다면, 디렉토리 구조는 상당히 복잡해지게 된다.

그렇기에 npm 또는 yarn1 에서는 이렇게 의존성들이 트리형태로 분포해 있는 것을 호이스팅 방법을통해서 위로 끌어올린다. JS에서 호이스팅 개념과 같습니다.

패키지 매니저 선정 과정에 자주 봤던 사진들일 것이다. 트리처럼 되어 있는 중복된 패키지가 위로 올라오고, 독립적으로 가지고 있는 의존성만 해당 패키지가 들고 있게 된다. 모노레포의 경우도 비슷하다. 여러 패키지들을 node_modules로 넣는 과정에서 중복된 의존성을 위로 올린다. 이렇게 의존성을 관리할 때 나오는 문제는 유령 의존성(phantom dependencies) 문제이다. 만약 여러 패키지가 겹치는 의존성이 있어서 의존성 트리에서 위로 올라오게 되면, 이는 곧 해당 node_modules 가 있는 프로젝트 쪽에서 의존성이 설치된다는 의미이기 때문에 내가 의도적으로 설치하지 않았음에도 해당 의존성을 사용할 수 있는 환경이 된다.

결국 이러한 유령 의존성 문제는 나중에 의존성을 관리할 때 나오게 된다. 의존성을 관리할 때 마치 이렇게 된달까…

유령 의존성을 해결하기 위한 pnpm과 yarn berry

이러한 npmyarn에서 나오는 문제를 해결하기 위한 대안으로는 pnpmyarn berry가 있다.

pnpm(Performant NPM)

pnpm은 말그대로 성능적으로 야무진 npm이라는 뜻이다. pnpm은 개선된 npm처럼 의존성 트리를 무조건 평평하게 만들려고 하지 않는다.

  • pnpmnode_modules를 일반적인 트리(tree) 구조가 아닌 symlink 기반의 플랫(flat) 구조로 만든다.
  • 의존성 충돌 방지를 위해, 각 패키지는 node_modules/.pnpm/<pkg>@<version>에 존재하며, symlink로 node_modules/<pkg>에 연결된다.

심볼릭 링크(symlink) 링크를 연결하여 원본 파일을 직접 사용하는 것과 같은 효과를 내는 링크이다.  윈도우의 바로가기와 비슷한 개념

Yarn-Berry 초기 설정

yarn 버전 업

yarn set version berry​

.yarnrc.yml 설정

nodeLinker: pnp

패키지 인식 및 관리

yarn dlx @yarnpkg/sdks vscode​

zipFS 설치하기

zipFS extension은 zip파일로 된 yarn의 의존성을

gitignore 추가하기

.yarn/*
!.yarn/cache
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
항목dependenciespeerDependencies
설치 대상패키지를 설치할 때 함께 설치됨직접 설치되지 않고, 사용자가 설치해야 함
사용하는 경우라이브러리 내부에서 직접 사용하는 경우호스트 프로젝트와 버전을 공유해야 하는 경우
버전 충돌 시별도로 설치됨 (중복 가능)버전 충돌 발생 시 경고
주로 사용되는 상황CLI, 내부 기능 구현 등에 필요한 패키지React, TypeScript 등 호스트에 이미 있어야 하는 경우

클라이언트 패키지 세팅하기

{
  "name": "@kokomen/client",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@kokomen/ui": "workspace:*",
    "next": "15.3.2",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}
 

Ui 패키지 초기세팅

ui 패키지에서는 가장 원자적인 단위의 컴포넌트를 따로 분리해서 보관하기로 했다. 이와 함께 공통 컴포넌트의 경우에는 디자인에서 중요한 역할을 함께 차지하므로 이에 대한 스토리북 환경 또한 세팅해주기로 했다.

yarn berry 환경에서는 npx가 먹히지 않는다.

yarn dlx sb init

dlx를 통해 스토리북을 init 시켜준다.

{
  "name": "@kokomen/ui",
  "version": "0.0.1",
  "main": "./src/index.ts",
  "type": "module",
  "exports": {
    ".": "./src/index.ts"
  },
  "devDependencies": {
    "@storybook/addon-essentials": "^8.6.12",
    "@storybook/addon-interactions": "^8.6.12",
    "@storybook/addon-onboarding": "^8.6.12",
    "@storybook/blocks": "^8.6.12",
    "@storybook/react": "^8.6.12",
    "@storybook/react-vite": "^8.6.12",
    "@storybook/test": "^8.6.12",
    "@types/prop-types": "^15",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "autoprefixer": "^10.4.21",
    "postcss": "^8.5.3",
    "prop-types": "^15.8.1",
    "react": "^19.1.0",
    "react-dom": "^19.1.0",
    "storybook": "^8.6.12",
    "tailwindcss": "^4.1.6",
    "vite": "^6.3.5"
  },
  "peerDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "typescript": "^5.8.3",
    "tailwindcss": "^4",
    "@tailwindcss/postcss": "^4"
  },
  "scripts": {
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build"
  }
}
 

exports를 명시적으로 설정해준다. 또한 이 컴포넌트를 Import해서 사용하기 위해서 필요한 PeerDependencies를 명시해준다.

package.json에서의 mainexports의 역할 mainexports은 모두 패키지 사용자가 패키지를 사용할 때 진입 되는 경로이다. 이러한 패키지 진입 경로 설정은 module 설정을 통해서도 할 수 있다. Node 10 버전 이하에서는 main 필드를 사용해야 하고, 11 버전 이상에서는 exports 필드와 main 필드가 모두 정의되어 있는 경우 exports 필드가 우선된다. module같은 경우 Yarn 2+에서 + ES6호환 환경에서 사용할 때 진입되는 경로이다.

여러 Dependency는 어떻게 구별할까?

  • devDependencies : 개발 종속성으로 패키지 개발자의 로컬에는 설치되지만 패키지를 사용하는 사용자에게는 설치되지 않음
  • peerDependencies : 상속받아야 하는 종속성으로, 패키지 개발에 필요한 종속성을 명시하여 종속성을 제공받는다.
  • dependencies : 패키지에서 사용되는 종속성

이 세 개의 종속성 특징들을 꼭 잘 확인한 다음에 사용하자. 불필요한 종속성의 설치로 종속성의 충돌을 방지할 수 있으며, 불필요한 리소스를 받지 않아도 되기 때문에 중요하다.

next에서 가져와서 사용하기

const nextConfig: NextConfig = {
  reactStrictMode: true,
  transpilePackages: ["@kokomen/ui"],
  output: "standalone",
};

Next.config.ts에서 transpilePackages 을 선택하면 모노레포 환경에서 자동으로 컴파일 한 다음에 의존성을 번들링 해준다.

export { Button } from "./Button";

디자인 시스템 하나씩 만들어보기

현재는 MVP를 개발하면서 최소한의 컴포넌트들에 대해서만 컴포넌트를 만들어놓은 상태이다. components 의 각 디렉토리에서는 사용될 수 있는 각 컴포넌트들이 디렉토리 별로 구분되어 있으며, Index를 통해 Import 경로를 조금 더 간소화시키도록 했다. 결국 디렉토리명이 해당 컴포넌트 자체를 의미한다고 생각했기 때문이다. 또한 이와 함께 시간이 될 때마다 각 컴포넌트들에 대해서 스토리를 정의하고, 스토리를 정의하는 과정에서 내가 코드를 쓰는데 불편한 점이 있다면 그때마다 수정을 하는 과정을 반복했다.

각 컴포넌트의 경우 최소한의 것들만을 담으면서도 어느정도의 디자인적 요소와 편의적 요소를 고려하면서 설계하였다.

export const Textarea = ({
  className,
  variant,
  size,
  border,
  name,
  ref,
  autoAdjust = false,
  ...props
}: TextareaProps) => {
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
  const currentRef = ref || textareaRef;
  useEffect(() => {
    if (
      autoAdjust &&
      currentRef &&
      currentRef.current &&
      props.value !== undefined
    ) {
      const textarea = currentRef.current;
      textarea.style.height = "auto";
      textarea.style.height = `${textarea.scrollHeight > 400 ? 400 : textarea.scrollHeight}px`;
    }
  }, [props.value, autoAdjust, ref]);
 
  return (
    <textarea
      className={cn(textareaVariants({ variant, size, border }), className)}
      ref={ref}
      name="textarea"
      placeholder="Type your text here..."
      {...props}
    />
  );
};

대표적인 예로 textarea 같은 경우에는 기본적으로 텍스트의 줄 길이에 따라 자동적으로 올라가도록 설계한 컴포넌트와 class authority variants를 통한 tailwind className들의 우선순위 조합, ref의 전달(해당 컴포넌트는 리액트 19버전 이상을 PeerDependencies로 해놨기 때문에 forwardRef가 아닌 일반 RefObject를 전달하는 방식이다) 등을 처리해놓은 컴포넌트를 클라리언트 패키지에서 편하게 불러와서 반복되는 코드의 양을 줄일 수 있었다.

결론

반복적인 코드를 줄이고 추상화된 컴포넌트의 관심사 분리를 디자인 시스템 패키지를 나누면서 해결하게 되었고, 그 결과 반복되는 스타일 속성을 휴먼 오류로 인한 일관성을 해치지 않고, 최소한의 커스텀과 이런 원자적인 컴포넌트의 조합을 통해서 내가 원하는 도메인의 컴포넌트를 만들면서 편의적인 부분에서 크게 이점을 가진다고 생각했다. 처음에 하나하나 원래 있는 html 태그에 대해서 왜 굳이 한번 더 씌울까? 라는 생각을 했었는데, 결국 이 작업 자체가 나중에 CSS를 하나하나 피그마 보고 고치고.. 피그마 보고 고치고… 하면서 소요되는 시간을 확실하게 줄여주면서 결과적으로는 생산성이 크게 향상되었다.

또한 컴포넌트 추상화같은 부분도 이제는 프로젝트에서는 해당 프로젝트 클라이언트 내에서 필요한 컴포넌트 관리에만 집중할 수 있도록 관심사를 분리하면서 개발할 수 있었기에 만족도가 높은 개선 작업이었다. 결국 이러한 아키텍처를 통해 모노레포가 어떤 개발을 지향하는지에 대한 방향성 또한 직접 몸으로 느낄 수 있었다.