Python&FastAPI 위주로 MSA 를 구성하면서 codebase 는 monorepo 형태를 차용했습니다. 이 글에서는 Python으로 MSA를 만들 때 uv
를 통해 어떻게 monorepo 형태로 구성할 수 있는지 경험을 공유하고자 합니다.
monorepo 란?
monorepo 는 polyrepos(혹은 multi-project)에 대응되는 개념으로, 하나의 Git 저장소에서 여러 모듈(서비스‧라이브러리)을 함께 관리하는 전략을 말합니다. 동일한 논리적 변경을 하나의 Commit 또는 PR(Review)로 묶을 수 있고, 저장소별 권한·CI/CD 파이프라인을 중복 정의할 필요가 없다는 점이 가장 큰 장점입니다.
반면 “거대한 모놀리식 시스템(모놀리식 코드베이스)과 같다”는 오해도 있지만, 코드 관리 방식과 런타임 아키텍처는 분리해서 봐야 합니다. 모놀리스는 배포 단위가 하나지만, monorepo는 여러 독립 실행 단위(컨테이너·람다·패키지)를 한 저장소에 둘 뿐입니다.
CircleCI의 글에서는 monorepo 채택에 따른 이점을 다음과 같이 제시합니다.
- 원자적 변경(Atomic change): API 스펙 변경과 각 서비스 클라이언트 수정이 한 커밋에 들어가므로 깨지는 중간 단계가 사라짐
- 일관된 개발 경험: 공통 린터, 테스트, 릴리스 스크립트를 한곳에 유지
- 쉬운 코드 리팩터링: 대규모 네임스페이스 변경·공통 모듈 추출을 한 PR에서 끝낼 수 있음
실제 우리 팀은 monorepo 를 도입했을때 위의 이유로 개발 편의성이 향상되고 이로인해 human error 발생 빈도를 줄일 수 있었습니다.
monorepo 와 multi-module
Gradle 기반 SpringBoot를 쓰던 저에게는 multi-module이 더 익숙한 개념이었고 monorepo 라는 개념을 받아들일때 도대체 multi-module과 다를게 뭔가 궁금했습니다. 결국 저는 “단일 코드베이스에 여러 모듈”이라는 점은 동일하지만, • multi-module은 빌드 시스템(Gradle) 관점, • monorepo는 저장소‧협업 관점의 용어라는 차이가 있을 뿐이라고 결론을 내렸습니다. 이 글에서는 통일하여 monorepo라 부르겠습니다.
패키지, 프로젝트 관리의 필요성
monorepo 구조까지는 좋았습니다. 하지만 우리 팀은 { polyrepo, Monolithic, Java&SpringBoot }에서 { monorepo, MSA, Python&FastAPI }로 전환을 하였고 대대적인 패러다임·언어·아키텍처 변화를 겪다보니 제대로된 “패키지 관리와 프로젝트 관리”를 하지 못하고 있었습니다.
처음에는 pip-tools 를 이용해 개별 서비스마다 별도의 requirements.in 과 requirement.txt 파일을 두고 local 개발환경에서 개별 서비스마다 가상환경(venv)을 별도로 지정하게 되었습니다. 처음 두세번의 배포까지는 아무런 문제가 없었습니다. 그런데 점점 서비스간 패키지 버전이 불일치하는 일이 발생했습니다. 서비스간 패키지 버전이 불일치한다는 것은 A 서비스에서는 제공되는 기능이 B 서비스에서는 제대로 동작하지 않는문제가 발생할 수 있다는 것입니다.
개별 서비스마다 별도의 가상환경을 두는 선택이 문제를 더욱 심각하게 만들었습니다. local 개발환경에서 A 서비스의 가상환경을 환경을 활성화해놓고 B 서비스의 코드를 수정, 테스트하면 분명 문제없이 동작하던 기능이 (A 가상환경이었기 때문에) 컨테이너화하여 Pod 에 올라가면 K8s의 Liveness probe 에서 막히는 일이 발생했으며, 더 심각하게는 전혀 예상치 못한 RuntimeException 이 발생하게 되었습니다.
물론 컨테이너화해서 테스트하는 절차를 철저하게 지킨다면 위의 문제가 발생하기 전에 알아차릴 수 있었겠지만 Test Coverage 를 적정 수준으로 유지해야하므로 현실적으로 이런 상황에서 모든 RuntimeException을 막기는 불가능합니다.
따라서 패키지간 버전 불일치 문제를 해결하고 local 환경에서 하나의 가상환경에서 여러 서비스를 개발할 수 있는 패키지, 프로젝트관리 체계가 필요해졌습니다.
uv 로 monorepo 만들기
왜 uv인가?
결과적으로 우리 팀은 uv 와 workspace, build-system을 활용하여 이러한 문제를 해결했습니다. 그런데 Python 생태계에는 많은 패키지 관리 툴이 존재합니다. 그 중에서 왜 uv 를 선택했을까요? 저희는 제대로된 패키지 관리 체계를 도입하기 위해 아래의 요구사항을 세웠습니다.
우리가 세운 최소 요구사항
요구사항을 만들다보니 "공통 모듈" 이라는 개념이 추가 되었는데 이것 역시 MSA를 운용하다보면 필요성이 절실해지는 부분입니다.
- 아키텍처(arm64/amd64)·OS·Python 버전별 재현 가능한(Immutable) Lockfile
- 모든 마이크로서비스가 단일 Lockfile을 공유해 버전 불일치를 막을 것
- 공통 모듈을
shared/
하위 패키지로 두고 필요한 서비스가 “로컬(Editable) 의존성”으로 끌어다 쓸 것 - 의존성 해석과 설치 속도가 빠를 것(로컬 캐시·병렬 빌드 지원)
- Docker나 CI 환경에서 pip만큼 가볍게 쓸 수 있을 것(
curl /uv
한 줄 설치)
패키지 관리툴 간단 비교
이러한 기준을 바탕으로 알려져있는 몇몇 패키지 관리툴을 비교하였습니다.
툴 | Lockfile | Workspace/Monorepo | 패키지 설치속도 |
pipenv | pipfile.lock | 미지원 | 느림 |
Poetry | poetry.lock | Partial(패키지 경로) | 보통 |
PDM | pdm.lock | PEP 582 기반 (글로벌 Cache) | 빠름 |
Hatch | hatch.lock | 플로그인으로 지원 | 보통 |
uv | uv.lock | Cargo Workspace 개념 지원 | 매우 빠름 |
uv
는 Rust로 작성되어 pip 대비 8~10배, Poetry 대비 4~5배 빠른 의존성 해석·설치 속도를 보여줍니다. 또한 Cargo와 같은 workspace 기능을 제공해, 여러 패키지가 하나의 uv.lock
을 공유하도록 강제합니다.
Reddit 등 커뮤니티 피드백에서도 “Poetry와 기능 동등성(parity)을 달성했고, monorepo 이동 시 버전 지옥을 해소했다”는 후기가 많았으며, 한국의 Python 사용자들도 uv 를 적극권장한다는 이야기를 전했습니다.
우리가 원하는 요구사항을 모두 충족하면서, 속도도 빠르고, 국내외 사용자들의 후기도 좋았기에 uv를 제외한 다른 툴을 선택할 이유가 없었습니다.
uv 의 주요 개념
1. pyproject.toml, uv.lock 파일
사실 pyproejct.toml 파일과 lock 파일은 uv 만의 개념은 아닙니다. 하지만 Python 패키지 관리 툴을 처음 접하는 사람에게는 둘다 생소한 개념이므로 이 글에서 설명합니다.
pyproject.toml 파일은 개념적으로 이해하자면 개발자가 원하는 명세를 작성하는 파일입니다. 이 프로젝트의 이름과 설명, 상황별로 원하는 라이브러리와 그 버전을 명시합니다. 필요하다면 Lint 설정도 가능합니다. 그리고 이러한 개발자가 작성한 명세에 따라 실제 원하는 것들을 모아서 그 상태를 저장한 파일이 uv.lock 파일입니다.
실제 Python 어플리케이션이 구동하기 위한 가상환경의 구성은 1차적으로 uv.lock 파일에 의존합니다. 만약 uv.lock 파일이 없다면 pyproject.toml 파일을 통해 바로 uv.lock 파일을 생성할 수 있습니다.
이 글에서는 개념정도만 잡고 두 파일에 대한 구체적인 설명은 pyproject.toml 공식문서, uv.lock 공식문서를 참조하시는걸 추천드립니다.
2. workspace
uv에는 Rust Cargo Workspace 개념이 적용되었습니다. Rust의 패키지관리 툴인 Cargo에서 Workspace란 크기가 커진 Rust 라이브러리를 잘게 쪼개서 관리할 수 있도록, 즉 크기가 작아진 개별 라이브러리간 상호 참조를 손쉽게 할 수 있도록 도와주는 장치입니다.
uv에서 동일한 workspace에 '공통 모듈'과 '마이크로서비스'들이 등록된다면 동일한 uv.lock 파일을 공유하게되고 마이크로서비스들이 공통 모듈을 손쉽게 참조할 수 있는 관계를 만들 수 있게 됩니다. 이러한 참조 관계는 path 참조를 통해서도 이뤄질 수 있으며 혹은 가상환경안에 해당 공통모듈을 설치하여 참조할 수도 있습니다.
3. build-backend
build-backend 역시 대부분의 Python 패키지 관리 툴이 공유하는 개념으로 어떤 방식으로 해당 패키지를 빌드해서 가상환경에 추가할지를 지정하는 것입니다. 그런데 설명이 좀 이상합니다. Python은 인터프리터 언어이므로 빌드라는 과정이 없는데 도대체 뭘 빌드한다는 걸까요?
더 엄밀하게 설명하자면 build-backend 들은 Python 으로 쓰여진 코드를가진 소스 디렉터리를 다른 환경에서 설치가 가능한 아카이브(.whl, .tar.gz)로 변환하는 과정을 빌드라고 하는 것입니다. 이렇게 아카이브로 변환하는 과정을 거쳐야 패키지 설치 속도와 운영체제 호환성이 확보됩니다.
uv 를 이용한 MSA monorepo 예시
예시 코드는 깃헙에서 확인하실 수 있습니다.
핵심은 공통모듈은 모두 build-backend 를 활용하여 아카이브 파일 형태로 가상환경에 포함된다는 것입니다. 이러한 조치를 통해 각 서비스가 배포되는 환경이 변화되어도 안정한 동작을 보장할 수 있고, 무엇보다 IDE가 해당 패키지의 존재 여부를 명확하게 파악할 수 있습니다.
필요하다면 마이크로서비스로 아카이브파일로 바꿔서 사용하는 방법을 구현할 수도 있겠으나, 현재는 Dockerfile 에서 해당 파일을 직접 호출하는 형태이므로 불필요하다고 판단했습니다.
참고
https://blog.nrwl.io/misconceptions-about-monorepos-monorepo-monolith-df1250d4b03c
https://engineering.linecorp.com/ko/blog/mono-repo-multi-project-gradle-plugin
https://techblog.lycorp.co.jp/ko/python-multi-project-application-with-poetry
'탐구 생활 > Python' 카테고리의 다른 글
Python asyncio에 대한 탐구 (0) | 2025.03.23 |
---|---|
Python의 Type System (0) | 2025.03.20 |
python 사용자 지정 불변 객체를 만드는 3가지 방법 (0) | 2024.11.17 |
Python 가변 객체와 불변 객체 (0) | 2024.11.17 |