![[NextJs] 하이아크 홈페이지 모노레포 적용기](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdna%2Fcz3a7f%2FbtsQMKEXvB2%2FAAAAAAAAAAAAAAAAAAAAAFIO-zUYjrLG6Z6JLbXmhUfUwE1rz7G8n_CUOuc2_Toa%2Fimg.png%3Fcredential%3DyqXZFxpELC7KVnFOS48ylbz2pIh7yKj8%26expires%3D1759244399%26allow_ip%3D%26allow_referer%3D%26signature%3Djj0quZdsPvIZoZcFiv93a7uYABs%253D)
개요
대규모 웹 프로젝트를 진행하다 보면 누구나 한 번쯤 이런 고민에 빠진다.
"서비스가 여러 개일 때, 프로젝트 구조는 어떻게 나눠야 할까?"
특히 여러 명의 개발자가 동시에 작업하는 협업 환경이라면, 구조적 통일성과 생산성, 유지보수 효율성은 무시할 수 없는 요소다. 이때 흔히 등장하는 해답이 바로 모노레포(Monorepo) 구조다. 한 레포에서 여러 앱을 관리하고, 공통 UI와 유틸리티를 공유하고, 통합된 빌드 환경과 테스트, 린트를 적용할 수 있다. 한 번에 관리되는 코드베이스라는 것은 꽤나 매력적으로 다가온다. (필자는 플러터를 했을 때 부터 느낀 거지만, 한 번에 많은 것이 관리되는 코드베이스에 매력을 느끼는 것 같다.)
이 글에서는 하이아크 인트라 서비스를 Next.js 기반으로 구성하고, admin, 공통 UI, utils 등을 함께 관리하는 pnpm 기반 모노레포를 구축하며 겪었던 다양한 실제 문제와 해결 방법을 공유하려 한다.
왜 모노레포인가?
하이아크의 서비스들은 여러 개의 서비스들이 각자의 레포지토리를 갖고 있는 구조였다. 문제는 레포 권한이 제각각이라 어떤 프로젝트는 A계정의 A' 레포에, 또 어떤 프로젝트는 B계정의 B' organization에 있어 관리가 어려웠다. 이 프로젝트의 난잡함에 갈증을 느끼던 몇 학회원들이 통합 서비스를 기획했고, 그 서비스 구현의 프론트엔드 개발 팀장으로 참여하게 되었다.
프로젝트는 크게 4가지의 기능이 존재한다. 학회 소개 사이트, 학회원들의 스터디 참석 및 공지 사항 사이트, 학회원들의 PS 경쟁 사이트, 마지막으로 운영진의 관리자 사이트 이렇게 4가지 사이트나 존재한다. 이 중 관리자 사이트와 스터디 사이트가 새롭게 개발에 들어갔고, 나머지 소개 사이트와 PS 사이트는 기존에 있던걸 유지보수하고 있다.
레포지토리가 4개나 되는데 4가지의 프로젝트 모두 React 기반에 비슷한 린트와 비슷한 컴포넌트를 사용하고 있었다. 유틸 메소드 역시도 겹치는 부분이 많았고, API 연동 역시도 겹치는 부분이 많았다. 이렇게 겹치는 부분이 많아서 모노레포를 선택하게 되었다.
모노레포 설정하기
모노레포 구조
이제 본격적으로 모노레포 구조를 설계해보자. 앞서 언급했듯이 4개의 서비스가 있고, 공통으로 사용할 UI 컴포넌트와 유틸리티들이 있다. 이를 효율적으로 관리하기 위해 다음과 같은 구조로 설계했다. ▼
HIARC-Platform-FE/
├── apps/ # 각각의 애플리케이션들
│ ├── admin/ # 관리자 사이트
│ ├── intra/ # 스터디 참석 및 공지사항 사이트
│ ├── intro/ # 학회 소개 페이지
│ └── rating/ # PS 경쟁 사이트
├── packages/ # 공유하는 패키지들
│ ├── ui/ # UI 컴포넌트 라이브러리
│ └── shared/ # 공통 유틸리티/타입
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── tailwind.config.js
이 구조의 핵심은 apps와 packages의 분리다. apps에는 실제 배포되는 애플리케이션들이, packages에는 여러 앱에서 공유하는 코드들이 들어간다.
pnpm 워크스페이스로 의존성 관리하기
모노레포에서 가장 중요한 것 중 하나가 패키지 관리다. 우리는 패키지 관리 툴로 pnpm을 선택했다. npm이나 yarn도 워크스페이스 기능을 제공하지만, pnpm을 선택한 이유는 속도와 디스크 효율성 때문이다. 특히 모노레포에서는 같은 의존성이 여러 패키지에서 중복으로 설치되는 경우가 많은데, pnpm은 이를 심볼릭 링크로 해결해 용량을 크게 절약할 수 있다.
pnpm 워크스페이스 설정
먼저 pnpm-workspace.yaml을 루트에 생성한다. ▼
packages:
- 'apps/*'
- 'packages/*'
그리고 package.json에서 pnpm 버전을 고정한다. ▼
{
"packageManager": "pnpm@8.15.6"
}
이렇게 하면 팀원들이 다른 패키지 매니저를 사용하더라도 pnpm으로 강제된다. 개발 환경 통일을 하지 않으면 각자 다른 개발환경을 가지고 개발하다 프로젝트가 꼬일 가능성이 높아지게 된다. 그런 불상사를 막기 위해 pnpm으로 강제하는 옵션을 넣었다.
워크스페이스 의존성 참조하기
각 앱에서 공유 패키지를 사용할 때는 `workspace:*` 프로토콜을 사용한다:
{
"dependencies": {
"@hiarc-platform/shared": "workspace:*",
"@hiarc-platform/ui": "workspace:*"
}
}
이렇게 하면 로컬의 패키지를 직접 참조하게 되어, 공유 패키지를 수정할 때마다 실시간으로 반영된다. 개발 생산성이 확실히 올라간다.
Turborepo로 빌드 최적화하기
단순히 워크스페이스만 사용하면 빌드할 때 모든 패키지를 순차적으로 처리해야 한다. 하지만 Turborepo를 사용하면 의존성 그래프를 분석해서 병렬 빌드와 증분 빌드가 가능해진다.
Turborepo 설정
turbo.json에서 빌드 파이프라인을 정의한다. ▼
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
}
}
}
여기서 dependsOn: ["^build"]가 핵심이다. 이것은 "현재 패키지를 빌드하기 전에 의존하는 패키지들을 먼저 빌드하라"는 의미다. 예를 들어, admin 앱이 @hiarc-platform/ui 패키지를 사용한다면, admin을 빌드하기 전에 ui 패키지가 먼저 빌드된다.
실제 사용해보기
이제 루트에서 다음과 같은 명령어들을 사용할 수 있다. ▼
pnpm dev # 모든 앱의 개발 서버를 동시에 실행
pnpm build # 의존성 순서대로 모든 패키지/앱 빌드
pnpm lint # 전체 프로젝트 린팅
특히 pnpm dev 명령어가 정말 유용하다. 4개의 앱을 각각 터미널에서 실행할 필요 없이, 한 번의 명령어로 모든 개발 서버가 올라간다. 물론 포트는 각각 다르게 설정되어 있다.
Tailwind CSS 설정 공유하기
UI 일관성을 위해서는 디자인 시스템의 공유가 필수다. 우리는 Tailwind CSS를 사용하고 있는데, 모든 앱에서 동일한 색상 팔레트와 스타일을 사용하도록 설정했다.
중앙집중식 Tailwind 설정
루트의 tailwind.config.js에서 공통 설정을 정의한다. ▼
module.exports = {
content: [
'./apps/*/src/**/*.{js,ts,jsx,tsx,mdx}',
'./packages/ui/src/**/*.{js,ts,jsx,tsx,mdx}'
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#00AAFF',
100: '#01B5C9',
200: '#276A91',
300: '#000947',
},
// 하이아크만의 브랜드 컬러들...
}
}
}
}
각 앱의 tailwind.config.ts에서는 이 설정을 상속받는다. ▼
const config: Config = {
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
'../../packages/ui/src/**/*.{js,ts,jsx,tsx,mdx}',
],
presets: [require('../../tailwind.config.js')], // 루트 설정 상속
}
이렇게 하면 모든 앱에서 동일한 디자인 토큰을 사용하게 되어 일관성 있는 UI를 만들 수 있다. 색상을 바꾸고 싶다면 루트 설정만 수정하면 모든 앱에 반영된다.
공유 패키지 구성하기
@hiarc-platform/ui
UI 컴포넌트 라이브러리는 Radix UI를 베이스로 구성했다. Radix UI는 headless 컴포넌트를 제공해서, 우리만의 디자인을 적용하기에 좋다. ▼
// packages/ui/src/components/Button.tsx
export const Button = React.forwardRef
React.ElementRef<typeof ButtonPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ButtonPrimitive.Root> & ButtonVariantProps
>(({ className, variant, size, ...props }, ref) => {
return (
<ButtonPrimitive.Root
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
})
각 앱에서는 이렇게 사용한다. ▼
// apps/admin/src/components/UserManagement.tsx
import { Button } from "@hiarc-platform/ui"
export function UserManagement() {
return (
<div>
<Button variant="primary">사용자 추가</Button>
</div>
)
}
@hiarc-platform/shared
공통으로 사용하는 타입 정의, 유틸리티 함수, 상수들을 모아둔 패키지다. API 응답 타입이나 날짜 포맷팅 함수 등이 여기에 들어간다. ▼
// packages/shared/src/types/api.ts
export interface User {
id: number
name: string
email: string
role: 'ADMIN' | 'MEMBER'
}
// packages/shared/src/utils/date.ts
export function formatDate(date: Date): string {
return date.toLocaleDateString('ko-KR')
}
이렇게 까지 설정을 마치고 개발에 들어갔다.
실제 개발에서 느낀 점들
코드 재사용성 증가와 사이드 이펙트
만약 intra와 admin이 다른 프로젝트였다면, 둘이 공유하는 컴포넌트에 업데이트가 생기면 각 프로젝트에 가서 따로 업데이트를 해줬어야했을 것이다. 그러나 지금은 @hiarc-platform/ui에서 컴포넌트를 가져와서 바로 사용할 수 있다. 중복 코드가 예상한대로 획기적으로 줄었다.
그러나 그로 인한 사이드 이펙트도 생겼다. 같은 컴포넌트인데, 실행시켜야할 메소드가 다른 경우가 꽤나 많았다. 버튼과 같이 작고 여러군데에서 사용되는 컴포넌트의 경우에는 메소드를 주입시키는 방법으로 구현하곤 했다. 하지만 intra와 admin 각각에서 사용하나 그 사용처가 한군데인 컴포넌트들의 경우에는 어떻게 처리할 지가 애매했다. 이전에는 사용처가 한군데인 컴포넌트라면 해당 컴포넌트 내부에 hook을 두어 실행하게 했는데, 지금은 두 도메인이 아예 다르기 때문에 내부에 hook을 둘 수가 없다. ▼
현재로서는 이런 컴포넌트도 @hiarc-platform/ui에서 관리를 한다. 결국엔 재사용이 되는 컴포넌트이며, 한 프로젝트의 특정 도메인에 국한된 게 아닌 여러 프로젝트에서 사용되는 점 때문에 이렇게 결정했다. 이렇게 어떤 컴포넌트를 어디에 정의를 해야하나 하는 고민을 깊게 만드는 사이드 이펙트가 존재하게 됐다.
브랜치 관리
매우 부끄럽게도 현재는 브랜치 관리가 통합적으로 이뤄지고 있다. intra, admin, rating, intro 모두 `main`과 `develop`브랜치에 푸쉬가 되면 배포가 진행되게 vercel cicd 설정이 되어있다. 머리로는 intra 브랜치 따로, admin 브랜치 따로, 각 앱 별로 브랜치를 따로 두는게 맞다고 생각하지만, 소규모 프로젝트에서 그렇게 까지 설정하는게 쉽지가 않았다. 일정 역시 빡빡해서 이런 저런 규칙들을 신경 쓸 수 없었는데, 브랜치까지 4개로 쪼개지게 되면 그로 인해 생기는 오버헤드가 말도 못할 만큼 커지게 될 거 같았다.
물론 브랜치 관리를 해야하는 이유는 명확하다. 결국 intra와 admin은 같은 레포에 있지만 다른 프로젝트다. 둘이 서로간의 의존성이 없어야하는게 당연하고 이를 브랜치 관리를 증명해야한다. intra 브랜치에 의존하는 admin 앱이 된다면, 그것은 잘못된 설계된 프로젝트가 된다. 허나, 일의 효율성을 생각해봤을 때는 모든 앱에 별도의 브랜치를 두는 것은 그렇게 좋지는 않은거 같다. package까지 총 10개 가량의 브랜치를 관리해야하기 때문이다.
결국에는 브랜치를 나눌 것이긴 하다. 그게 이상적으로 유지보수가 제대로 될 거라고 기대하기 때문이다. 하지만 지금 당장은 시기상조라고 생각한다.
package/ui, package/util
브랜치 관리와 일맥상통하는 이야기이다. package로 관리하는 리소스의 경우 업데이트가 생기면 어떻게 해야하는 지가 감이 안잡히기도 했다. 현재는 같은 브랜치를 사용하고 있으니 업데이트 반영이 양쪽 앱에 잘 되는데, 별도 브랜치로 가게 되면 업데이트를 어떻게 해야하는지에 대한 감이 잘 안잡혔다. ▼
현재로서는 package브랜치를 업데이트 한 후, intra와 admin에서 각각 pull을 받아와서 사용하는 방식이 그려진다. package 버전 관리가 잘 되어있다면 이게 가장 이상적인 방법이라고 생각이 된다. 결국 내부에서 사용하는 패키지라는 개념을 갖고 가면 명쾌해진다.
러닝커브
위의 package/ui, package/util에서 봤듯, 학습에 시간이 걸린다. 바로바로 떠오르지 않는 부분들이 생기고, 이는 팀원에게도 똑같이 적용된다. 같이 하던 팀원은 이 구조에 대한 이해가 많이 부족했어서 같이 하는데 어려움을 겪었다. 어느정도의 온보딩 시간이 주어진다면 해당 문제는 큰 요인은 아닌데, 이번 프로젝트에서는 그럴 시간이 없었기에 꽤나 큰 문제로 다가왔다.
마무리
이번 프로젝트를 통해 코드 재사용성 증가, 일관된 개발 환경, 통합된 빌드 시스템 등 모노레포가 주는 장점들을 직접 체감할 수 있었다. 특히 공통 컴포넌트와 유틸리티를 실시간으로 공유하며 개발할 수 있다는 점은 생산성 향상에 큰 도움이 되었다.
하지만 장점만 있지는 않았다. 컴포넌트 설계의 복잡성 증가, 브랜치 관리의 어려움, 그리고 팀원들의 러닝커브는 분명한 트레이드오프였다. 특히 소규모 프로젝트에서 이런 복잡성이 오히려 오버헤드가 될 수 있다는 점을 깨달았다. 도입과 변경에 대한 사이드 이펙트가 크다는 것 역시도 이번에 많이 느꼈다.
결론적으로 도구나 패키지가 늘 그렇듯, 모노레포는 분명 강력한 도구이지만 만능 해결책은 아니다. 프로젝트의 특성과 팀의 상황을 면밀히 검토한 후 도입을 결정하는 것이 중요하다. 우리의 경우 장기적으로는 분명 좋은 선택이었지만, 초기 도입 과정에서의 어려움들을 미리 예상하고 준비했다면 더 매끄러운 전환이 가능했을 것이다.
앞으로는 브랜치 관리 체계를 정립하고, 패키지 버전 관리를 체계화하며, 팀원들을 위한 개발 가이드를 만들어 나갈 계획이다. 모노레포는 단순히 코드를 한 곳에 모아놓는 것이 아니라, 지속적인 관리와 개선이 필요한 살아있는 시스템이라는 것을 다시 한번 깨달았다.
'Develop > Web' 카테고리의 다른 글
[React] 웹 프로젝트에서의 클린아키텍쳐 : 다들 이미 사용중이었다 (1) | 2025.09.10 |
---|---|
[Web][Issue] 이중 인코딩으로 인한 사진 누락 (0) | 2025.07.17 |
[Next.js][Develop] Next.js 클린 아키텍처 적용기 (0) | 2025.07.10 |
[React] Tailwind-css를 써보면서 느낀점 (0) | 2025.02.20 |
[React][Error] tailwind css 설치 오류 (2) | 2025.02.08 |