개발자 이민재입니다.

POSTS

모노레포 개선 과정

12min read

Overview

    모노레포는 1개 레포지토리에 모든 코드를 관리하는 방식을 말합니다. 프로젝트의 규모가 커지면 패키지 간 모듈의 중복이 발생하는데, 이렇게 중복이 생기는 모듈들은 공통모듈로 묶어서 추출할 때가 많습니다. 이를테면 회사 프로젝트에서 전역적으로 계속 사용하는 날짜, 시간, 상수 코드들을 공통패키지로 추출하는 식입니다. 프로젝트 아키텍처의 레이어가 프론트엔드, 백엔드 등으로 나뉘는 경우 각각 레이어를 모노레포로 구성할 수도 있습니다.
    프론트엔드 레이어에서는 여러 패키지에서 사용할 공통 컴포넌트를 추출하는 경우가 많습니다. 백엔드 애플리케이션 같은 경우 한 프로젝트에서 외부 api, 어드민 전용 내부 api, 배치 서버 3개로 구성할 때가 있는데, 각각 패키지가 별도로 있는 상황이라면 엔티티 클래스나 서비스 비즈니스 로직들이 중복될 때가 많습니다. 이 때 엔티티 클래스나 서비스 로직들을 공통모듈로 빼서 관리하면 코드의 중복을 제거할 수 있습니다.
    어떤 프로젝트든 코드의 중복을 꾸준히 없애주어야 코드의 복잡성을 제어할 수 있으니 공통모듈은 필연적으로 등장할 수 밖에 없습니다. 그런데 공통모듈들을 추출하는 순간 관리 문제가 발생합니다. 하나는 개발 과정에서의 사용성 문제이고, 다른 하나는 CI/CD 과정에서의 통합 문제입니다. 즉, 공통모듈로 추출했더니 개발 과정에서 자동완성이 되지 않는다든가, 배포했더니 공통모듈이 배포 파일에 누락됐다든가, 혹은 배포 파이프라인이 동작하지 않는 등의 문제가 발생할 수 있습니다.
    따라서 공통모듈을 추출할 때는 항상 아래 세 가지의 조건을 만족해야 합니다.

  1. 코드 중복을 제거해야 한다.
  2. 개발 과정에서 공통모듈 변경 내용을 즉시 사용할 수 있어야 한다.
  3. 배포 과정에서 공통모듈이 자동으로 포함되어야 한다.

    세 가지 조건들을 바탕으로, 기존 모노레포의 문제점을 살펴보고 문제점을 해결하는 방식을 살펴보겠습니다.

기존 모노레포의 문제점

    기존 프로젝트의 패키지 구조를 간단히 나타내면 아래와 같습니다.

.
├── api
├── web
├── app
├── common
└── batch

    즉, 웹, 앱, api, batch 패키지를 모두 한 레포지토리에서 관리하고 있었습니다. 이 자체는 문제가 아니었습니다. 중복은 어쨌든 공통모듈로 추출하는대로 제거하고 있었고, 적절한 툴체인만 선택한다면 2번과 3번도 만족시킬 수 있을 것 같았습니다. 요즘 한창 많이 사용하는 turborepo를 실험하면서 prune 명령어 등을 통해 서버 패키지 배포까지 성공적으로 테스트하기도 했습니다.
    문제는 이렇게 프로젝트를 구성하게 된 전제가 프론트엔드와 백엔드 모두 타입스크립트라는 같은 언어를 사용했다는 것입니다. 두 레이어에서 같은 언어를 사용하니 공통모듈로 요청 응답 DTO 타입을 추출할 수 있었고, 이상적으로는 별도의 API 문서 없이도 공통모듈만 보면 모든 개발이 가능해야 했습니다. 다만 이 전제는 당장 회사 패키지에서 반례가 있었습니다. 크롤링 관련 로직을 담아놓은 서버는 파이썬으로 작성하고 있었는데, 앞서 설명한 공통모듈로 타입을 추출하는 방식의 작업이 불가능했습니다. 각 요청 객체와 응답객체는 타입스크립트 상에서 똑같이 생겼으니 중복처럼 보이지만, 코드 수준에서의 중복이라고 보기는 어려웠습니다.
    최종적으로 모든 조건을 만족한 상황에서 결과로 나올 프로젝트 구조 역시 바람직해 보이지 않았습니다. 백엔드 코드를 모노레포로 구성한다고 했을 때, 엔티티와 서비스들을 분리해야 했고, 프론트엔드 코드 역시 마찬가지로 모노레포로 구성하면 공통모듈로 ui 컴포넌트, 스타일 컴포넌트들을 분리해야 했습니다. 각 레이어에서 분리한 코드들은 결국 루트 디렉터리 아래에 모이는데, 서로 다른 논리로 분리한 패키지들이 한 디렉터리에 있어 구조를 파악하기 어려워졌습니다.

첫 걸음 - 분리하기

    모노레포 개선 과정에서 첫 번째로 선택한 작업은 패키지 분리였습니다. 위에서 설명했던 대로 프론트엔드와 백엔드의 패키지는 분리하는 것이 더 바람직하다고 판단했기 때문이었습니다. 사실 모노레포 개선 작업 자체가 큰 마이그레이션이어서 실제 작업시에 일단 작업 범위를 좁힐 필요도 있었습니다.
    그래서 서버 패키지를 별도 레포지토리로 분리하고, 아래와 같이 구성했습니다.

.
├── external-api
├── internal-api
├── batch
├── common
├── config
├── types
├── infra
├── models
└── services

    프로젝트 구성 방법은 권용근님의 멀티모듈 설계 이야기 글을 많이 참조했습니다. 해당 글의 로직을 그대로 따라가지는 못하더라도, 최대한 각 레이어의 역할을 분명히 했고, 레이어 간 의존성이 복잡하게 꼬이지 않도록 구성했습니다.
    external-api, internal-api는 각각 외부 api와 내부적으로 사용하는 전용 api를 포함합니다. batch 패키지는 배치서버에서 실행시킬 job들을 포함시켰습니다. api와 batch는 공통모듈의 사용자(client)들입니다. common, config, types, infra, models, services는 모두 공통모듈입니다. common은 코드베이스 전체에 사용되는 공통모듈 로직을 포함합니다. config는 서버 설정관련 파일, types는 타입 선언을 모아두었습니다. infra는 외부 의존성, 이를테면 알림, api요청, db 커넥션과 같은 것들을 포함합니다. models는 도메인 객체를, services는 비즈니스 로직을 포함하는 패키지입니다.

모노레포 툴 선택

    모노레포 관리 툴은 turborepo를 사용했습니다. 모노레포를 관리할 때 특정 툴의 로직에 종속되는 것이 그다지 내키지는 않았지만 성능면에서나 사용 편의성 측면에서나 turborepo가 가장 적절한 선택지였습니다. yarn workspace는 제공하는 기능 자체가 워낙 없다보니 커스터마이징 해야하는 부분이 많았고, nx는 간단히 테스트용도로 사용해본 결과 약간 무거운 것 같았습니다.

배포 자동화

    웹 프론트엔드의 경우 CloudFront + S3 배포과정이어서 복잡할 것이 없었습니다. 모노레포 툴이 의존성을 해결해주는대로 배포 zip파일을 만들어서 기존 CI/CD 파이프라인에 그대로 적용할 수 있었습니다.
    다만 서버쪽이 작업이 조금 복잡했는데, ElasticBeanstalk 특유의 동작 방식 때문이었습니다. ElasticBeanstalk은 디폴트 설정이 패키지 단위로 배포 파일을 만드는 것이었는데, 별도 설정을 통해 이를 turborepo 빌드 파이프라인 결과물로 바꿔서 배포할 수 있었습니다.

후기

    스타트업에서는 밀려드는 기능 요구사항을 최대한 빠르게 구현하고 프로덕트 오너가 가설을 신속하게 검증할 수 있게 도와야 하다보니, 그 틈에서 개발자 경험을 개선시키기란 쉽지 않은 것 같습니다. 하지만 결국 요구사항은 점점 복잡해질 수 밖에 없고, 기능 개발 속도 역시 느려질 수 밖에 없어 이렇게 개발자 경험을 직접적으로 개선시키는 작업은 반드시 필요한 것 같습니다.

References