프론트엔드

블로그 제작기#2 - 모노레포

이 프로젝트를 처음 만들 때에는 Next.js 단일 패키지였다. 당분간은 별도의 백엔드 없이 구현하도록 설계했었고, 웹사이트 규모를 고려하면 오히려 패키지를 분리하는 것이 오버엔지니어링처럼 느껴졌다. 그런데 작업을 하면 할수록 패키지를 분리할 필요성을 느끼게 됐다.

처음에는 괜찮을 줄 알았는데

일단, 처음에 세운 내 계획은 이랬다.
  1. 모든 콘텐츠는 빌드 시 파싱해 정적 페이지를 생성한다.
  2. 콘텐츠 수에 비례하여 빌드 시간이 늘어난다.
  3. 최소 1~2년간은 빌드 시간이 문제가 될 정도로 콘텐츠가 많아지지 않을 것이다.
  4. 그러므로 당장은 정적 생성하게 두고, 나중에 콘텐츠를 별도 서버로 분리하고 백엔드 API로 공급한다.
초기에는 프론트엔드에 집중해서 개발하다가, 나중에 필요해지면 백엔드로 마이그레이션하면 된다고 생각했다. 근데 중간에 문제가 생겼다. 남는 시간에 개발하다 보니 바쁠 땐 작업을 못하다가 며칠 만에 다시 작업하는 일이 자주 있었다. 이전에 왜 이렇게 작업했는지 기억이 안 나는 경우가 많아졌고, 점차 의도치 않게 의존성이 섞이는 일이 발생했다. 이러면 나중에 마이그레이션이 어려워질 것은 너무 당연한 사실이었다. 하지만 이 당시에는 그냥 알아서 의존성을 잘 관리하면 되겠지, 하고 생각했다.

내가 짠 코드는 반드시 까먹는다

사실 이 즈음 실무 경험이 쌓이고 여러 프로젝트를 동시에 굴리면서 세운 하나의 원칙이 있었다. 바로 "내가 짠 코드는 반드시 까먹는다"는 것이다. 당장 작성한 지 몇 주 되지 않은 코드도 기억이 안 나는데, 1~2년 뒤 마이그레이션할 코드가 기억날 리 만무했다. 그래서 프로젝트를 모노레포로 전환하고 웹에 직접 의존하지 않는 기능을 별도 패키지로 분리하기로 결정했다.

모노레포 구조

모노레포 구조로 전환한 뒤 갖추게 된 패키지 구조는 아래와 같다.
jinho-blog/
├── apps/
│   └── web/                  # Next.js 블로그 앱
├── content/
│   └── mdx/                  # MDX 콘텐츠, 에셋
└── packages/
    ├── shared/               # 공유 타입 및 상수
    ├── thumbnail-generator/  # 썸네일 이미지 생성기
    ├── mdx-handler/          # MDX 콘텐츠 처리
    └── nextjs-routes/        # 타입 안전 라우팅 생성기

apps/web

Next.js 기반 메인 웹 프론트엔드를 담당한다. 웹 프레임워크 생명주기에 직접 종속되는 것들이 들어있다.

content/mdx

메인 콘텐츠인 MDX 파일과 MDX에 들어가는 에셋을 저장한다. 지금 보고 있는 이 글도 MDX 파일로 작성되었다. 특별한 기능 없이 오로지 MDX 파일과 에셋들만 가지기 때문에 다른 패키지의 기능을 변경하더라도 콘텐츠는 독립적이므로 망가지지 않는다. 단, 프론트매터로 적절한 정보를 제공해야 하기 때문에 어느 정도의 컨벤션을 가진다. 그래서 컨벤션을 가이드할 템플릿도 포함되어있다.

packages/thumbnail-generator

블로그 글에는 적어도 썸네일이 있어야 한다고 생각했다. 근데 매번 썸네일을 직접 만들어서 공급하는 것은 너무 번거로웠다. 이 패키지는 정해진 규격의 썸네일을 생성해준다.

packages/mdx-handler

빌드 시 웹에 공급할 정적 데이터 및 에셋 생성과 런타임에 호출할 서비스 로직을 가지고 있다. 웹이 가장 크게 의존하는 패키지로, 특히 서비스 함수 로직이 변경되었을 때 런타임에서 문제가 발생할 확률이 높기 때문에 추상화 인터페이스에 의존하도록 만들었다.

packages/shared

mdx-handler가 web에 런타임 서비스 로직을 직접 제공하기 때문에 서로 공유하는 타입이나 상수가 있다. 이런 타입과 상수들을 보관한다.

packages/nextjs-routes

이 패키지는 기존 오픈소스 라이브러리 nextjs-routes
를 포크해 수정한 패키지다. 기존 라이브러리가 가진 문제가 두 가지 있었는데, 하나는 Next.js 15부터 지원하는
next.config.ts
를 지원하지 않는다는 점이고, 두 번째는 Next.js의 라우팅 관련 API의 타입을 직접 오버라이드하기 때문에 확장성이 좋지 않았다는 것이다. 그래서
next.config.ts
지원을 추가하고, 타입을 직접 오버라이드하지 않고 타입 생성 기능과 타입 안전 라우트 경로 생성 유틸 함수만 제공하도록 변경해서 사용하고 있다.
nextjs-routes-issue
  • 실제로
    next.config.ts
    미지원 이슈를 해당 라이브러리 깃허브에 올렸다.

패키지 의존성과 유지보수

전체 패키지들의 의존성은 아래처럼 단방향으로 구성되어 있다.
  • content, shared, thumbnail-generator → mdx-handler
  • shared, mdx-handler, nextjs-routes → web
간접적이든 직접적이든 깊게 의존하는 관계가 있기 때문에, 적절히 추상화 인터페이스를 적용하고 테스트 코드를 작성해서 어떤 기능이 바뀌더라도 다른 패키지에 끼치는 영향을 최소화하려고 했다. 또한 코드가 제대로 작동하는지 자동으로 검사하기 위해 PR 시 테스트 코드 검증을 수행하고, 빌드 시 데이터 파싱할 때 형식이 올바른지 검증하는 로직을 추가했다. 덕분에 심각한 버그가 포함된 채 커밋되더라도 PR이나 빌드 단계에서 미리 잡아낼 수 있었다.

불편한 점도 있다

모노레포를 통해 기능별로 패키지를 분리하는 것이 좋은 점만 있는 것은 아니었다. 단일 패키지에 비해 구조가 복잡하고 패키지 간 의존성을 직접 관리해야 하기 때문에 구조 설계에 더욱 신경 써야 했다. 또, 패키지별로 환경 설정도 따로따로 해줘야 하니 작업량도 늘어났다. 어떤 패키지의 변경이 다른 패키지에서 문제가 발생하지 않도록 사전 작업을 철저하게 할 필요도 있었다.
그럼에도 한 번 시스템을 안정화한 뒤에는 기능별로 집중해서 전문화된 작업을 할 수 있었고, 의존성이 과도하게 복잡해져 생길 수 있는 문제를 미연에 방지할 수 있는 것이 분명한 장점이었다.

마치며

모노레포를 도입하게 된 이유를 적어봤다. 언젠가는 아마 블로그에 백엔드 마이그레이션을 시도할 텐데, 그 때의 내가 모노레포 쓰길 잘했다고 생각하지 않을까 싶다. 여담으로 모노레포 구조가 의외로 AI 에이전트 작업에도 도움이 되었는데, 다음에 작성할 AI 관련 글에서 엮어보겠다.