탐구 생활/Python

Python Monorepo - namespace 활용하기

개발프로브 2025. 6. 14. 12:47

문제점: 잘못된 이름으로 만들어진 공통모듈

직전 글에서 uv 를 이용해서 Python Monorepo 를 구성하였습니다. 대략 이런 모습이었죠. shared 에 있는 모듈을 services 에서 끌어다 쓰는 구조입니다.

이 구조 자체는 괜찮은데, 저의 미숙함때문에 치명적인 문제가 생겼습니다. 바로 너무 일반적인 이름을 사용했다는 점입니다.

services 하위의 app에서도 언제든지 exception, logger 와 같은 파일을 정의해서 사용할 수 있기에 충돌 가능성이 있습니다. 즉, 공통모듈을 사용하는 개발자 개개인이 조심해야하는 문제점이 생깁니다. 실무에 집중해야하는 개발자에게 큰 스트레스겠죠?

 

이 문제를 해결했던 과정을 공유하겠습니다.


원하는 결과물

가장 일반적으로 문제를 해결하는 방식은 shared 밑에 각각의 모듈을 두는 것입니다. 현재 디렉터리 구조상으로만 그런게 아니라 실제 Python 가상환경 안에 설치된 패키지의 구조가 그렇게 인식되어야 하는 것입니다.

 

그렇게 된다면 IDE가 아래와 같은 가이드라인을 제공해줄 수 있겠죠.

shared 밑에 공통 모듈들이 있습니다.

패키지 자체를 계층화하는 방법

어떻게 이런 결과를 얻을 수 있을까요? 아래와 같이 shared 라는 디렉터리 자체를 패키지로 만들고 exception, logger 를 내부 패키지로 만들어야 하는 걸까요? 물론 유효한 접근입니다.

.shared
├── __init__.py
│   ├── config
│   └── proto
├── exception
│   ├── __init__.py
│   └── ...
├── logger
│   ├── __init__.py
│   └── ...
...

 

하지만 이렇게 된다면  실제로는 모든 서비스가 공통으로 사용하지 않는 기능이 공통 모듈에 포함되어 공통 모듈이 불필요하게 커질 우려가 있습니다. 그리고 개별 단위 모듈을 분리해서 관리하고자하는 monorepo 의 철학과도 맞지 않습니다.


Build Target을 이용한 패키지 빌드

이전 글에서 Python 패키지 관리에서 사용되는 빌드라는 용어를 아래와 같이 설명했습니다. 

Python 코드를 가진 소스 디렉터리를 다른 환경에서 설치가 가능한 아카이브(.whl, .tar.gz)로 변환하는 과정을 말합니다.

 

그렇다면 코드는 따로따로 작성하지만 그들을 빌드하는 과정에서 하나의 아카이브로 모을 수 있다면, 그리고 그 아카이브의 최상위 모듈이 shared 라면 우리가 원하는 형태의 결과물을 만들 수 있지 않을까요?

 

다행히도 Python build-backend 는 build target 이라는 spec으로 이러한 기능을 지원해야함을 명시하였고, hatchling은 이 spec을 따르기 때문에 [tool.hatch.build.targets.<TARGET_NAME>] 테이블을 통해 이러한 기능을 제공하고 있습니다. 

Target Wheel 을 이용하기 위한 디렉터리 구조

hatcgling 자체가 src 디렉터리를 요구하는 등 디렉터리 구조에 영향을 받습니다. 그러다보니까 사용하고자 하는 namespace 를 {module_name}/src/{namespace}/{module_name} 식으로 구성해 줘야합니다. 그 예시는 아래와 같습니다.

shared 가 namespace 로 빠지면서 packages라는 이름을 최상단으로 만들었습니다. 이러한 이름은 프론트엔드 진영의 예시를 따른 것으로 백엔드에서는 최적의 네이밍이 아닐 수 있습니다.

 

여기서 중요한 사실이 있습니다. 바로 각 shared namespace 를 공유할 모듈들이 /src/shared/__init__.py 를 만들지 않는다는 것입니다. 그 이유는 PEP 420, Implicit Namespace 와 깊은 관계가 있습니다. 암묵적 네임스페이스(Implicit Namespace)로 인해 디렉터리만 있으면 패키지로 인식되게 되었습니다. 따라서 일반 패키지는 하나의 디렉터리와 하나의 __init__.py 만 가질 수 있습니다.

 

따라서 만약 모든 shared namespace 를 공유하는 상황이 온다면 N 개의 wheel 안에 동일 경로 + 동일 파일명이 중복되고, 실제 서비스 코드에서는 shared.logger, shared.middleware 를 통한 import 가 ModuleNotFoundError 를 발생시키게 됩니다.

Target Wheel 을 이용하기 위한 pyproject.toml

이제 디렉터리 구성을 맞췄다면 해당 공통 모듈의 pyproject.toml 이 아래와 같이 세팅되어야 합니다.

[project]
name = "shared.middleware"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.12"
dependencies = [
    "asgiref>=3.8.1",
    "shared.logger",
]

[tool.uv.sources]
"shared.logger" = { workspace = true }


[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/shared"]

 

다소 낯설어졌습니다. middleware 라는 공통 모듈은 앞에 shared. prefix 가 붙었으며 심지어 동일한 workspace 에 있는 다른 모듈을 참고할때도 해당 모듈의 prefix 인 shared 를 명시해줬습니다.

 

이러한 세팅을 통해 이제 동일한 packages 안에 있는 모듈이라고 할지라도 어떤 모듈은 shared namespace 를 공유하며, 어떤 모듈은 grpc_client 라는 모듈을 공유할 수 있게 되었습니다. 덕분에 packages 를 끌어다 쓰는 다른 개발자들도 더 명시적으로 자신이 끌어다 쓰는 모듈이 어떤것인지 파악할 수 있게되어 불편이 해소되었습니다.