Python 도구 생태계가 Hypermodern Python 시대 이후 어떻게 단순해졌는지, uv를 중심으로 설정·린팅·타이핑·테스팅·문서화·CI/CD·모노레포까지 한 번에 정리한다.
하이퍼모던을 넘어: 이제 Python은 쉽다 19 Jul 2024


📢 업데이트: 이 글을 쓴 뒤, Python 모노레포를 쉽게 만들어주는 Una라는 새 프로젝트를 공개했다. 아직 알파 품질이고 현재는 uv에서만 동작하지만, 계속 개선되고 성장하고 있다.
_📢 업데이트 2: uv가 Rye와 기능 동등성에 가까워지고 있어서, 머지않아 이 글도 그걸 쓰도록 업데이트할 예정이다. 거의 드롭인 대체가 될 것이다.
📢 업데이트 3: 이제 uv를 사용하도록 업데이트했다.
영겁처럼 느껴지지만, 사실 Hypermodern Python이 유행하며 최신 Best Practises™를 훑고 지나간 게 고작 4년 전이다. 나는 그걸 읽으며 공황에 가까운 기분을 느꼈다. Python 좀 쓰려면 패키지 20개를 설치하고, 30개를 더 설정하고, 이런저런 것들 을 해야 한다고?
하지만 지금은 2024년이고, 마침내 다 쉬워졌다! Go와 Rust에서 이런 일이 얼마나 쉬운지 본 사람들이 Python 생태계를 앞으로 끌고 오기 위해 놀라운 일을 해냈다. 이제 이런 일을 하는 게 똑똑하거나 특별한 게 아니라, 모두가 해야 하는 기본이 됐다.
템플릿만 원하면 아래 TLDR에 있다. 아니라면 잠깐만 더 따라오자. 원래 Hypermodern 글들과 거의 같은 구조를 따를 예정이며, 내용은 다음과 같다.
이미 uv를 쓰고 있다면, 이 글의 많은 부분은 새롭지 않을 수도 있다. 하지만 모노레포 섹션에서 더 흥미로워지고, 거기에 새로운 아이디어가 있을 수 있다!
TLDR 템플릿 리포지토리는 여기다: carderne/postmodern-python. 거기서 시작하면 README에 필요한 모든 커맨드가 있다.
pyenv와 Poetry는 치우고, Rye를 들이자. 아니, Rye도 치우고 uv를 들이자! Rye는 Armin Ronacher(Flask 제작자이자 그 외에도 많은 것들)에게서 시작되어 Astral에 의해 채택되었고, 지금은 대체로 uv로 대체되었다. Astral에서 풍기는 VC 지원 분위기에도 불구하고, 실제로는 굉장히 잘 설계되어 있고 새 Python 패키징 표준 위에 전적으로 구축되어 있다. 표준이 없던 시절에 필요했던 것을 해냈지만, 이제는 조금 이상하고 불필요하게 달라진 Poetry와는 다르다.
uv는 Python도 설치해주며, 그 과정에서
.python-version
을 만들고 존중한다. 그 다음에는
pyproject.toml
의 의존성을 표준 방식으로 관리하도록 도와주거나(혹은 직접 하게 놔두고), 락 파일도 만들어준다(다만
pip
이 이해할 수 있는 “일반적인” 락 파일이지, Poetry 전용이 아니다). 그리고 대체로 방해하지 않는다. 무엇보다 Rust로 작성되어(당연히), 빠르다.
설득됐나? 시작하자. 먼저 uv를 설치한다.
# 이런 방식이 싫다면, 웹사이트로 가서 다른 설치 방법을 찾으면 된다
curl -LsSf https://astral.sh/uv/install.sh | sh
이제 새 프로젝트를 시작할 수 있다.
그러니 새 프로젝트를 시작해보자.
uv init postmodern
cd postmodern
uv sync # lockfile 생성, Python deps 설치
uv가 기본 구조와 설정 파일을 만들어준다.
$ tree -a .
.
├── .git # uv init가 git repo를 만들었고
├── .gitignore # 표준 ignore들도 넣어줬고
├── .python-version # Python 버전도 넣어줬고
├── .venv
│ └── ... # deps는 여기 설치된다
├── README.md
├── hello.py # 곧 옮길 것이다
├── pyproject.toml # 의존성과 설정 관리
└── uv.lock # lockfile
기본적으로 uv는 맨몸의
hello.py
가 있는 “app”을 만든다.
uv init --lib
를 실행하면 조금 다른 것을 만든다. 여기서 정말 흥미로운 건
pyproject.toml
뿐이다. 짧은 역사 수업을 하자면, Python은 예전에 라이브러리를 설치하기 위해
setup.py
스크립트를 썼고, 모두가 그게 미친 짓이라는 데 동의했다. 잠깐
setup.cfg
와 바람을 피우기도 했지만, PEP-518/PEP-621/PEP-631이 등장해
pyproject.toml
로 표준화하면서 구원해줬다. Poetry는 이 모든 일이 진행 중일 때 시작했기 때문에, 자체 시스템을 발명해야 했다. 하지만 이제 표준이 있으니, 한 번 보자.
[project]
name = "postmodern"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
# Public libraries should be more lenient.
# Internal stuff shoulduse 3.13!
requires-python = ">= 3.13"
# Your empty (for now) dependency table
dependencies = []
이제 예를 들어
uv add pydantic
를 실행해 의존성을 설치할 수 있고, 의존성 테이블에 추가된다. uv는 기본적으로
~=
가 아니라
>=
를 사용한다는 점에 주의하자. 하지만 아마 후자를 쓰는 게 좋다(차이는 여기에 설명되어 있다). 그렇게 하면 실수로 Pydantic v3로 업그레이드되는 걸 막을 수 있다! Ruff도 추가하자:
uv add --dev ruff
.
dependencies = [
"pydantic>=2.10.4",
]
[dependency-groups]
dev = [
"ruff>=0.8.5",
]
이 의존성들은 수동으로 직접 편집해도 된다(나는 보통 이렇게 한다). 편집한 뒤에는
uv sync
를 실행해
uv.lock
(이건 수동 편집하면 안 된다)과 venv를 업데이트하면 된다. 락 파일에 관해서는, 부담 없이 한 번 열어봐도 좋다.
다음으로 넘어가기 전에, uv의 철학에서 핵심이 되는 내용을 굵게 적어두고 싶다: 이제는 가상환경을 “activate”하지 않는다! 필요 없고, 모두의 삶만 복잡하게 만든다. 그냥
uv run
을 써라. 현재 venv 기반의 Python REPL이 필요하면:
uv run python
. Ruff를 실행하려면:
uv run ruff
. Python 스크립트를 실행하려면:
uv run ./hello.py
. 감이 오지?
먼저 코드를 폴더로 옮기고 패키지로 만들자.
mkdir postmodern
touch postmodern/__init__.py
mv hello.py postmodern/
어떤 종류의 프로젝트를 만들고 있는지에 따라 위에 추가하고 싶은 것들이 달라질 수 있다. CLI 도구 같은 것을 만들고 있다면, 사람들이 어떻게 쓰길 기대하는지에 따라 두 가지를 추가하고 싶을 수 있다. 하나는
__init__.py
옆에 두는
__main__.py
다. 그러면 사람들이
python -m postmodern
으로 코드를 실행할 수 있는데, 그들이
$PATH
를 건드리고 싶지 않을 때 매우 편리하다.
# postmodern/__init__.py
def main() -> None:
print("Hello!")
# postmodern/__main__.py
from postmodern import main
main()
다음은 표준 Python 방식으로 스크립트를 추가하는 것이다. 아래 예시는
pip
으로 라이브러리/앱을 설치한 뒤,
$PATH
에 추가되어 커맨드 라인에서
postmodern
으로 실행할 수 있게 해준다.
# add these to your pyproject.toml
[uv.tool]
# tell uv we're building a package
# (i.e. something we can distribute for others to use)
package = true
[project.scripts]
# running `postmodern` will run the `postmodern.main` function
postmodern = "postmodern.hello:main"
물론 코드가 import만 될 거라면 엔트리포인트는 필요 없다. 하지만 공개 패키지(즉, pypi에 배포할 것)를 만든다면, 지원할 Python 버전을 결정하고
requires-python
값을 그에 맞춰 설정해야 한다. Python 3.9는 앞으로 10개월 더 지원된다고 하니
>= 3.9
를 지원하는 것도 합리적이라고 본다. 사용자가 더 최신을 쓴다고 생각한다면 더 올려도 된다. 구버전을 지원할 때의 유일한 단점은 3.10, 3.11, 3.12, 3.13의 수많은 개선점을 놓친다는 것이다.
사내 라이브러리나 앱(예: 회사 내부용)이라면, 라이브러리 전반에서 단일 Python 버전을 써야 하며(그리고 그건 Python 3.13이어야 한다), 가능하다면 전역 락 파일을 관리하는 게 좋다(이에 대해서는 아래에서 더 다룬다).
(그리고 포매팅). 원래 Hypermodern 시리즈는 다음에 테스팅을 다뤘지만, 내 생각엔 린팅/포매팅/타이핑이 테스팅보다 자연스럽게 앞선다. 이 섹션은 아주 짧다.
black
,
isort
,
flake8
등등 전부 치워라. 이제 Ruff가 그들이 하던 걸 전부 한다.
설정 섹션에서 이미 설치했다. 그러니 필요한 건 이것뿐이다.
uv run ruff format # format (예전의 black)
uv run ruff check --fix # lint (예전의 flake8)
거의 끝이다! 다만, 동작 방식을 조금 제어하고 싶을 테니 다음 설정을 pyproject에 추가할 수 있다(취향대로 조정하면 된다). 필수는 아니지만, 알아두면 좋다.
[tool.ruff]
# if this is a library, enter the _minimum_ version you
# want to support, otherwise do py313
target-version = "py313"
line-length = 120 # use whatever number makes you happy
[tool.ruff.lint]
# you can see the looong list of rules here:
# https://docs.astral.sh/ruff/rules/
# here's a couple to start with
select = [
"A", # warn about shadowing built-ins
"E", # style stuff, whitespaces
"F", # important pyflakes lints
"I", # import sorting
"N", # naming
"T100", # breakpoints (probably don't want these in prod!)
]
# if you're feeling confident you can do:
# select = ["ALL"]
# and then manually ignore annoying ones:
# ignore = [...]
[tool.ruff.lint.isort]
# so it knows to group first-party stuff last
known-first-party = ["postmodern"]
어쨌든 린팅(과 포매팅)에 대해 알아야 할 건 이게 전부다. 당연히 에디터에 통합해야 하지만, 나는 그걸 어떻게 하는지 알려주지 않겠다.
타입! 어떤 사람들은 타입을 싫어한다(다만 그는 나중에 입장을 바꿨다). 하지만 2024년에 타입 없이, 여러 사람이 기여하는 유지보수 가능한 소프트웨어를 작성하는 건 일종의 흑마술에 가깝다(즉, 피해야 한다). Python의 타이핑 접근법의 장단점에 대해서는 수많은 글이 쓰였다. 빠른 스크립트나 실험에서는 타입을 무시할 수 있다는 점은 훌륭하다. 하지만 몇 주 뒤에도 신경 쓰게 될 무언가를 시작한다면, 첫날부터 strict 모드로 시작해라. 빚이 쌓일 때까지 기다리지 마라.
Hypermodern Python은 mypy를 추천했지만, 이제는 특정한 경우를 제외하면 그렇게 하기 어렵다. Pyright가 더 빠르고 전반적으로 조금 더 유용하며, LSP(에디터)와도 훨씬 잘 맞는다. 즉각적인 타입 피드백은 그곳에서 가장 유용하다. 단점은 Node 위에서 돌아가고 동작하려면 우주의 나머지를 다운로드해야 한다는 점인데, 누군가 Rust로 다시 쓰기 전까지는 현실이 그렇다.
그러니 먼저 설치하자.
uv add --dev pyright
# Then you can check your work
cat pyproject.toml | grep pyright -B1
# dev = [
# "pyright~=1.1.391",
그 다음 아래처럼 pyproject에서 설정한다. 멀티 컨트리뷰터 프로젝트에서 가장 유용한 strict 체크를 켠다는 점에 주목하자. 고칠 수 없는 줄에는
type: ignore[errorCodeHere]
를 추가하거나, 거슬리는 규칙을 비활성화할 수도 있다.
[tool.pyright]
venvPath = "." # uv installs the venv in the current dir
venv = ".venv" # in a folder called `.venv`
strict = ["**/*.py"] # use 'strict' checking on all files
pythonVersion = "3.13" # if library, specify the _lowest_ you support
이제
uv run pyright
로 실행할 수 있다. 그리고 포매터/린터처럼, 에디터에 통합하자.
좋은 옛날 pytest는 아직도 대체되지 않았다. 테스트를 어떻게, 어디에, 왜 작성할지는 큰 주제라서 지금은 다루지 않겠다. 린팅과 엄격한 타입체크는 인생에서 많은 걸 해결해주지만, 빠른 테스트 묶음은 코드가 계속 동작하도록 유지하는 데 기적 같은 도움을 준다. 그리고 테스트가 충분히 간결하다면 문서 역할도 꽤 한다.
실무적으로 우리가 해야 할 일은 pytest를 설치하는 것뿐이다.
uv add --dev pytest
테스트 작성은 여러분에게 맡기되, pytest가 뭔가 하고 있다는 걸 알 수 있도록 실패하는 테스트부터 시작해보자.
# tests/test_nothing.py
from postmodern.hello import main
def test_hello():
main("what?") # main doesn't accept any args 😉
그 다음:
uv run pytest
...
FAILED tests/test_import.py::test_hello -
TypeError: main() takes 0 positional arguments but 1 was given
테스트를 고치는 건 독자의 연습문제로 남겨두겠다.
이제 도구를 몇 개 설정했으니, 실행 방법을 기억하기 쉽게 만들어보자.
Makefile
을 써도 되지만, 태스크 러너로만 쓰기엔 지나치게 복잡하다. 팀이 추가 도구 도입에 동의한다면, mise가 환상적이다.
안타깝게도 Rye에는 작은 태스크/스크립트를 실행하는 좋은 지원이 있었지만, uv는 아직 그 기능을 추가하지 않았다. 그때까지는(비록 Poetry를 위해 만들어졌지만) Poe the Poet가 꽤 쓸 만하다.
uv add --dev poethepoet
로 설치하고, pyproject.toml 맨 위쪽에 다음처럼 설정하자.
[tool.poe.tasks]
# run with eg `uv run poe fmt`
fmt = "ruff format"
lint = "ruff check --fix"
check = "pyright"
test = "pytest"
# run all the above
all = [ {ref="fmt"}, {ref="lint"}, {ref="check"}, {ref="test"} ]
이제 변경을 좀 했거나 커밋 준비 중이라면
uv run poe test
를 실행하거나, 그냥
uv run poe all
을 실행하면 도구 전체가 한 번에 돌아간다!
물론 여전히
uv run ruff format
같은 걸 직접 실행할 수도 있다. 하지만 이런 태스크들은 어떤 도구가 있는지, 그리고 태스크가 복잡해질수록 더더욱 유용한 “기억장치”가 되어준다.
내 전임자는 여러 Python 환경에서 자동 테스트를 돌리기 위해 nox 설정을 추천했다. 이게 무슨 말인지 모르거나 nox를 들어본 적이 없다면, 아마 필요 없다. 공개 라이브러리를 만들면서 여러 Python 버전을 대상으로 하지 않는 이상 필요 없다. 심지어 그 경우에도, 프로젝트 복잡도에 따라 CI/CD만으로도 충분할 수 있다(아래에서 더 설명한다). 그리고 nox의 단순한 부분(린팅/타입체크/테스팅 체이닝)은 위의 다섯 줄짜리 pyproject 설정으로 이미 처리했다.
여기서는 툴링을 조금 벗어나 잡초밭으로 들어가는 느낌이지만, 일반적으로 내 조언은 가능한 많은 정보를 함수/클래스 이름과 타입 시그니처에 담으라는 것이다.
예를 들어:
# instead of
def filter_stuff(accounts, include_closed):
...
# or even
def filter_accounts(
accounts: list[dict],
include_closed: boolean
) -> list[dict]:
...
# why not try try
class Status(Enum):
CLOSED = 0
OPEN = 1
@dataclass
class Account:
id: str
status: Status
def filter_account(
accounts: Sequence[Account],
include: Sequence[Status],
) -> list[Account]:
...
그리고 함수와 클래스에 docstring을 추가하라. 코드는 완전히 타입이 붙어 있으니, 각 파라미터에 무엇이 들어가고 무엇을 반환하는지까지 굳이 과하게 설명할 필요는 없을 수도 있다. 하지만 반드시 해야 하는 한 가지는, 함수가 어떤 종류의 Exception을 발생시킬 수 있는지 설명하는 것이다! 언젠가 언어가 그걸 표현하는 내장 방식도 제공해주면 좋겠다…
def filter_accounts(...) -> list[Account]:
"""
Filters accounts based and returns a copy.
Be careful if calling with Accounts that bla bla...
Raises:
AccountBalanceException: an Account was found that foo bar...
"""
다음으로 해야 할 일은 테스트를 작성하는 것이다! 알아, 이미 다뤘다. 하지만 코드가 무엇을 하는지 가장 명확하게 보여주는 방법은, 그 코드가 무엇을 하는지 보여주는 것 이다! 그리고 무엇을 하지 않는지(혹은 하면 안 되는지)도 보여주는 것이다. 온갖 상태 조건을 검증하려면 초복잡한 다단계 테스트가 필요할 수도 있지만, 이해하기 쉬운 아주 단순한 테스트 몇 개로 시작할 수 있다면, 미래의 나와 사용자 모두가 동작 방식을 이해하기 훨씬 쉬워진다. 그리고
filter_accounts
가 어떻게 동작하는지 궁금하면
test_filter_accounts
를 grep해서 답을 찾을 수도 있다.
더 나은 방법이 있는데, Rust 생태계에서 매우 효과적으로 사용된 방식처럼 아래 예시처럼 docstring에 테스트를 넣는 것이다. 이건 사용자에게 엄청 유용할 뿐 아니라, pytest가 테스트가 실패하면 테스트 스위트를 실패 처리해줘서 문서가 최신 상태로 유지되도록 강제한다!
# postmodern/adder.py
# you can ignore the fancy Python 3.12 generic syntax if you like
def add_two[T: (int, float)](num: T) -> T:
"""
Adds two to the given `num`
>>> res = add_two(0.5)
>>> assert res == 2.5
>>> res = add_two(1)
>>> assert res == 4 # note this is wrong!
"""
return num + 2
pytest에서 이를 활성화하려면 설정에 다음을 추가하라.
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--doctest-modules"
그러면
uv run poe test
를 실행했을 때 아래 같은 에러를 받게 되고, 테스트를 고쳐서 다시 초록색으로 만들 수 있다.
_________ [doctest] postmodern.adder.add_two _________
005 >>> res = add_two(0.5)
006 >>> assert res == 2.5
007
008 >>> res = add_two(1)
009 >>> assert res == 4
UNEXPECTED EXCEPTION: AssertionError()
공개 라이브러리를 만든다면, 언젠가는 공개 문서도 필요할 것이다. 최신 에디터에서는 소스에서
goto-definition
이 너무 쉬워서, GitHub README와 좋은 docstring만으로도 꽤 멀리 갈 수 있다. Mkdocs(또는 Sphinx)는 그 다음 단계다. 필요해지는 순간이 오면 알게 될 것이고, 그때 전까지는 수고 대비 가치가 없다.
거의 다 왔다! 눈썰미 좋은 독자는 위에서 pre-commit을 어디에도 넣지 않았다는 걸 알아챘을 것이다. 이건 팀과 취향에 달렸지만, pre-commit이 설정 부담이 되거나, 다중 언어 코드베이스(예: TypeScript 코드도 pre-commit 하고 싶을 때)와 잘 맞지 않거나, 그냥 성가신 경우가 있다. 그래서 기본값으로는 제외했고, 대신 CI에 의존하겠다. 즉, 푸시 전에 모든 것이 린팅/포매팅 되어 있는지는 각 개발자의 책임이다. 그리고 그렇지 않다면 CI가 main으로의 머지를 허용하지 않을 것이다.
pre-commit을 쓰든 말든, 반드시 필요한 것은 리포지토리에 대한 “main에 직접 커밋 금지”를 강제하고, PR에서 돌아가는 좋은 CI 파이프라인을 만들어, main으로 머지되기 전에 반드시 녹색이어야 하도록 하는 것이다.
아래는 Github Actions에서 위의 모든 도구를 실행하는 간단한 예시다. 가능한 한 짧게 유지했지만, 완전한 버전은 리포지토리에서 볼 수 있다. pre-commit보다 더 엄격하게, main에 들어가는 모든 것이 반짝이도록 보장해준다.
# .github/workflows/pr.yml
name: pr
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
version: "0.5.14"
- run: | # abort if the lockfile changes
uv sync --all-extras --dev
[[ -n $(git diff --stat requirements.lock) ]] && exit 1
- run: uv run poe ci:fmt # check formatting is correct
- run: uv run poe ci:lint # and linting
- run: uv run poe check # typecheck too
- run: uv run poe test # then run your tests!
그리고 nox는 (필요하다는 걸 알기 전까지는) 쓰지 말자고 권했으니, 다버전/다플랫폼 테스트도 여기서 설정하면 된다. Github Actions는 matrix strategies를 제공해서 버전, 플랫폼 등의 조합으로 테스트할 수 있다. 전략을 추가하려면
pr.yml
워크플로우에 아래 두 가지를 추가하면 된다.
# after runs-on: ubuntu-latest
strategy:
matrix:
py: ['3.10', '3.11', '3.12']
# replace the uv setup step
- uses: astral-sh/setup-uv@v5
with:
version: "0.5.14"
python-version: $
이게 전부다! 이제 코드는 Python 버전 두 개를 추가로 더 대상으로 테스트된다.
라이브러리를 만들고 있다면, 코드가 머지되면 끝일 수도 있다. 공개 라이브러리라면 버전을 태그하고 릴리즈해서 PyPI에 올려야 한다. 버전도 설정해야 한다. 수동으로 설정할 수도 있다.
[project]
name = "postmodern"
version = "0.1.0"
...
또는 동적으로 설정할 수도 있다.
[project]
name = "postmodern"
dynamic = ["version"]
...
[tool.hatch.version]
source = "vcs"
후자는 git 태그에서 버전을 가져오며, 여기저기 수동으로 버전을 올릴 필요를 줄여준다. 또한 코드 어디에도
__version__ = "0.1.0"
를 설정할 필요가 없다. 필요하다면 다음처럼 가져오면 된다.
from importlib.metadata import version
version("postmodern")
이제 실제로 배포해야 한다. 아래 Github Actions 워크플로우는 그 방법을 보여준다. 이게 동작하려면 PyPI에서 “Trusted Publisher”를 설정해야 한다. 그러면 키를 복사-붙여넣기 할 필요 없이 배포할 수 있다(아래 워크플로우에도 키가 없다!).
name: release
on:
release:
types: [published]
jobs:
publish:
environment: release # needed for PyPI OIDC
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
version: "0.5.14"
- run: uv build
- uses: pypa/gh-action-pypi-publish@release/v1
여기까지가 라이브러리다. 하지만 앱을 만들고 있고, 그 앱이 어딘가에서 돌아가야 한다면, 아마 Dockerfile이 필요할 것이다. 아래 Dockerfile은 그게 얼마나 단순해질 수 있는지 보여준다.
# always nice to pin as precisely as possible
FROM python:3.13.1-slim-bookworm
ENV PYTHONUNBUFFERED=True
WORKDIR /app
# "Install" uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# First we copy just the project definition files
# so these layers can be cached
COPY pyproject.toml uv.lock ./
# install dependencies
RUN uv sync --frozen
# now copy in all the rest as late as possible
# and depending on how we're running this, we don't even need
# to install it, just copy-paste and run
COPY . /app
# if you DO want to install it, do that here
# or however else you bootstrap your app
CMD ["/app/.venv/bin/python", "/app/postmodern/server.py"]
또한 개발용 가상환경을 Docker 이미지에 복사하지 않도록
.dockerignore
파일도 만들어야 한다.
.venv
Poetry 방식과 비교해보라. 그리고 최종 Docker 이미지에 필요 없는 Poetry 찌꺼기가 잔뜩 남지 않도록 멀티스테이지 빌드를 어떻게 설정하는지도 스스로 알아내보라.
이미지 최적화에 유용한 팁은 Docker 관련 uv 문서를 읽어보면 된다.
TLDR: 내가 만든 예시를 그냥 보자: carderne/postmodern-mono.
보너스 섹션이다! 라이브러리 하나를 만들거나 일회성 작업이라면 이미 끝났을 수도 있다. 하지만 큰 팀에서 뭔가를 만들고 있고 모놀리스가 아니라면, 여러 앱과 라이브러리가 뒤섞여 있을 가능성이 크다. Python의 모노레포 지원은 훌륭하다고 하긴 어렵지만, 동작은 하고, 많은 팀이 택하는 “뭐든지 리포지토리 하나씩” 접근보다 훨씬 낫다. 별도 리포가 그나마 말이 되는 경우는, 팀 간 코드 기여 패턴이 매우 다른 경우다. 예를 들어 GitHub로 Jupyter 노트북을 협업하는 데이터 사이언스 팀: 최소한의 테스트나 CI, 의미 없을 수도 있는 커밋 메시지. 그 외에는 여러 언어와 배포 패턴이 섞여 있더라도 단일 리포가 repo-per-thing 접근보다 훨씬 낫다.
그렇다면 Python으로 모노레포를 어떻게 할까? 더 큰 조직이라면, 라이브러리와 의존성 그래프를 빌드하기 위해 이미 Bazel 같은 것을(혹은 Pants?) 쓰고 있을 수도 있다. Python은 엄밀히 말해 “빌드”가 필요하진 않지만, 설치 및 복사 등 필요한 작업이 있고, 의존성과 연결을 제대로 통제하는 건 가치가 크다.
요구사항이 그렇게 복잡하지 않다면, 표준 현대 툴링만으로도 꽤 멀리 갈 수 있다. uv는 Go/Rust에서 “workspace” 개념을 가져왔는데, 이는 공통 루트를 공유하는 여러 패키지를 포함한다. 중요한 점은 락 파일을 공유한다는 것이다(여기서는
uv.lock
). 팀을 얼마나 강하게 결합시킬지 결정해야겠지만, 더 큰 팀이라면 하나의 모노레포 안에 여러 워크스페이스가 필요할 가능성이 높다. 예를 들어 Team A는
pydantic<1.0
을 강제하는 라이브러리를 쓰는데, Team B는
pydantic>=2.0
를 요구하는 다른 라이브러리를 꼭 쓰고 싶어하는 식으로, 버전 충돌이 쉽게 생긴다.
모두를 얼마나 보조를 맞추게 할지, 아니면 서로 다른 버전을 쓸 유연성을 줄지의 정도는 조직적 선택이다. 다만 별도의 락 파일 수는 반드시 가능한 한 적게 유지해야 한다. 어쨌든 결과는 대략 아래처럼 된다.
> tree
.
├── pyproject.toml # root pyproject defines the workspace
├── uv.lock
├── libs
│ └── greeter
│ ├── pyproject.toml # package dependencies here
│ └── postmodern # all packages are namespaced
│ └── greeter
│ └── __init__.py
└── apps
└── server
├── pyproject.toml # this one depends on libs/greeter
├── Dockerfile # this one gets a Dockerfile
└── postmodern
└── server
└── __init__.py
가장 좋은 시작점은 uv의 Workspace 문서를 한 번 훑어보는 것이다. 그 다음에는 내가 만든 예시 carderne/postmodern-mono를 살펴보길 권한다. 이 글에서 논의한 모든 것을 따르되, 워크스페이스 지원을 추가로 포함한다.
끝이다. 유용했길 바란다! Hypermodern Python이 나왔을 때에 비하면 정말 많은 게 변했고, 유지보수 가능한 Python을 작성하는 일은 그 어느 때보다 쉬워졌다.
_업데이트: 이 글을 쓴 뒤, uv로 모노레포 작업을 훨씬 쉽게 만들어주는 Una라는 새 프로젝트를 공개했다. 기본적으로 빌드 시점에 모든 공동 의존성을 알아서 계산해주기 때문에 Dockerfile도
RUN pip install my\_app.whl
처럼 단순해질 수 있다. 아직 초기 개발 단계지만 점점 성장하고 있다!_