들어가며
안녕하세요. LINE GAME PLATFORM Dev2 팀의 이현섭, 이형중입니다. LINE GAME PLATFORM에서는 게임 개발에 필요한 다양한 플랫폼 서비스를 개발합니다. 저희 팀은 그중에서도 게임 데이터를 수집하고 분석하기 위한 프로덕트와 MLOps, LLMOps를 개발하고 있는데요. 이번 글에서는 LLMOps를 개발하기 위해 Python을 기반으로 한 대규모 프로젝트를 진행하면서 겪었던 프로젝트의 규모가 커지면서 발생하는 구조적인 문제를 살펴보고 이를 해결한 방법을 소개하겠습니다.
대중화되면서 거대해진 Python 프로젝트
Python은 간결하고 유연하다는 장점에 힘입어 오랫동안 사용되고 있는 스크립트 언어입니다. 초기에는 주로 간단한 자동화 작업이나 프로토타입 개발에 사용했는데요. 최근 몇 년간 데이터 과학과 인공지능(AI) 분야가 급격히 발전하면서 Python의 역할도 크게 변화해 최근에는 단순한 스크립트 언어를 넘어 대규모 프로젝트의 핵심 언어로 사용되고 있습니다.
이는 데이터 분석과 머신러닝, 딥러닝 등 다양한 AI 프레임워크가 Python을 중심으로 개발되고 있고, 강력한 라이브러리와 활발한 커뮤니티의 지원을 받은 덕분입니다. pandas와 NumPy, TensorFlow, PyTorch와 같은 강력한 라이브러리 및 도구들이 Python 생태계 내에서 발전하면서 Python은 데이터 과학과 AI 프로젝트의 필수 언어로 자리 잡았습니다.
그에 따라 앞서 말한 라이브러리와 도구를 사용하는 Python 애플리케이션 역시 점점 더 복잡하고 다양한 비즈니스 로직으로 구현되고 있습니다. 이와 같이 비즈니스 로직이 복잡해지고 그 규모가 커지면 필연적으로 프로젝트도 복잡해지면서 그 규모가 커집니다. 단일 프로젝트의 크기가 커지기도 하고, 외부 라이브러리 혹은 내부에서 직접 개발한 라이브러리를 참조하면서 프로덕트 단위의 비대화가 발생합니다.
거대해진 Python 프로젝트의 문제
저희 역시 Python 기반의 LLM Ops를 개발하면서 기존에 저희가 관리하던 프로젝트보다 큰 규모의 Python 프로젝트를 개발하게 되었습니다. 그런데 LLM Ops는 데이터와 프롬프트, 모델, 배포 등 다양한 요소로 구성돼 있기 때문에 개발 및 유지 보수를 고려해 거대한 단일 프로젝트가 아닌 멀티 프로젝트로 만들어 진행해야 합니다. 이와 같이 멀티 프로젝트로 각 컴포넌트를 구성하려다 보니 프로젝트의 구조와 공통 기능 공유, 함께 사용하는 라이브러리의 버전 관리 문제 등을 고민하게 되었습니다.
이와 같은 프로젝트의 코드 및 의존성 관리에 대한 문제는 Python뿐 아니라 C++이나 Java, Kotlin 같은 다른 언어에서도 발생합니다. 다만 해당 언어들은 이미 여러 시행착오를 거쳐 우아한 해결 방법을 찾아냈습니다. 예를 들어 프로젝트 관리의 경우 JVM 진영에서의 Gradle이 그 예가 될 수 있겠습니다. Gradle은 프로젝트의 선언을 명시하고 라이브러리의 의존성을 명확하게 표현합니다. 애플리케이션 패키징도 손쉽게 할 수 있도록 관련 기능을 제공하고 있습니다.
저희 조직은 Gradle에 익숙해진 상태에서 Python 개발을 시작했기에 이 문제를 Gradle과 비슷한 방식으로 풀기를 원했고, Python 생태계에도 비슷한 기능을 제공하는 도구가 있는지 조사한 결과 Poetry를 발견해 사용하기로 결정했습니다.
또한 프로젝트의 규모가 커지면 특정 기능을 캡슐화해서 사용하게 되는데 이때 재사용에 필요한 설정과 객체 생성 등을 주로 의존성 주입(dependency injection, 이하 DI)을 이용해서 해결합니다. DI는 잘 구현된 라이브러리와 그 객체를 손쉽게 사용하기 위해서 사용하는 것으 로 Java 환경에서는 Spring 프레임워크를 이용해 매우 쉽게 사용할 수 있는데요. 저희 프로젝트 역시 공통 기능이 많이 존재했기에 DI를 이용할 필요가 있었고, Python에서 이를 쉽게 사용하기 위해서 Dependency Injector를 사용했습니다. Dependency Injector는 DI를 Spring 프레임워크와 거의 유사한 형태로 사용할 수 있게 제공하기 때문에 개발 과정에서 각 프로젝트별로 의존성을 분리해 개발을 조금 더 쉽고 편하게 할 수 있도록 많은 도움을 주었습니다.
이와 같이 프로젝트가 거대해졌을 때 발생할 수 있는 문제를 해결하기 위해 도입한 Poetry와 Dependency Injector를 조금 더 자세히 살펴보며 어떤 방식으로 사용했는지 공유하려고 하는데요. 그전에 먼저 이해를 돕기 위해 Python 환경에서 자주 사용하는 가상 환경인 venv와 conda를 간단히 소개하겠습니다.
Python 프로젝트에서 가상 환경 사용
통상적으로 Python 프로젝트를 진행할 때 Python을 위한 가상 환경을 생성해 진행합니다. Python의 패키지 관리 방식은 설치된 패키지에 많이 종속되기 때문에 이를 프로젝트별로 분리해서 관리하기 위함인데요. 이때 주로 사용하는 것이 venv와 conda입니다.
venv는 가장 기본적인 가상 환경 기능을 제공하며, Python 표준 라이브러리에 포함돼 있어서 추가 설치가 필요 없습니다. 속도가 빠르지만 의존성 문제를 해결해 주지는 않고, 저희가 원하던 프로젝트 구조 관리 기능도 제공하지 않습니다.
conda의 경우 Anaconda 배포판의 일부로 제공됩니다. Python뿐 아니라 다른 여러 언어의 패키지도 관리할 수 있으며, venv의 단점이었던 패키지 간 의존성 문제를 해결해 주는 기능도 있는데요. 하지만 conda 역시 프로젝트 구조 관리 기능은 지원하지 않습니다.
이에 저희는 프로젝트 구조 관리 기능을 지원하는 도구를 찾기 위해 리서치를 진행했고, 의존성 관리 및 프로젝트 관리 기능이 모두 포함된 Poetry를 사용하기로 결정했습니다.
Poetry 소개
Poetry는 Python 환경에서 의존성 관리 및 패키징에 사용하는 도구로, 아래와 같이 다양한 장점이 있습니다.
- 패키지 관리: pyproject.toml 파일을 통해 패키지 정보(작가, 버전, 패키지 이름, 빌드 정보, 의존성 등) 관리 가능
- 상대 경로를 이용한 의존성 관리: pip 리포지터리를 통한 패키지 설치뿐 아니라 상대 경로를 이용한 패키지 설치도 지원
- 일관적인 실행 환경 관리: poetry.lock 파일을 이용한 버전 관리 기능을 제공해 어느 곳에서나 동일한 환경에서 실행 가능
- 유연한 버전 관리: 시맨틱 버저닝(semantic versioning)을 지원하기 때문에 버전의 범위를 유연하게 설정해서 호환 가능한 다양한 버전 설정 및 관리 가능
- 간편한 패키징 및 배포
위 장점 중 멀티 프로젝트에서 중요한 의존성 관리에 대해 조금 더 자세히 설명하겠습니다.
의존성 관리
Poetry에서 의존성을 관리하는 방법은 매우 직관적입니다. 아래 세 가지 파일을 통해 의존성과 실행 환경을 관리합니다.
pyproject.toml와 poetry.lock
JavaScript 개발자에게는 익숙할 수도 있는 구조입니다. JavaScript에서는 package.json 파일로 의존성 패키지의 버전을 관리하고, package-lock.json 파일로 일관적인 실행 환경을 제공하는데요. Poetry에서는 pyproject.toml 파일과 poetry.lock 파일을 이용합니다.
먼저 기본적으로 프로젝트의 루트에 위치한 pyproject.toml 파일을 이용해 패키지를 관리합니다. 이 파일을 이용해 패키지의 기본적인 설명과 의존성을 관리하고 빌드 시스템을 설정할 수 있습니다. poetry.lock 파일은 일관적인 실행 환경을 제공하기 위해 사용합니다.
아래는 pyproject.toml 파일의 예시입니다.
[tool.poetry]
name = "poetry-demo"
version = "0.1.0"
description = ""
authors = ["maxlee <maxlee@linecorp.com>"]
readme = "README.md"
packages = [{include = "poetry_demo"}]
[tool.poetry.dependencies]
python = "^3.7"
[tool.poetry.group.dev.dependencies]
black = "^23.12.1"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
pyproject.toml은 여러 개의 섹션으로 구성되며 각 섹션의 역할은 다음과 같습니다.
섹션 | 설명 |
---|---|
tool.poetry | 패키지 정보 |
tool.poetry.dependencies | 의존성 리스트 |
tool.poetry.group.dev.dependencies | 개발 의존성 리스트 |
build-system | 빌드를 위한 시스템 설정 |
Poetry에서는 의존성을 관리할 수 있는 다양한 방법을 제공하는데요. 그중에서 멀티 프로젝트를 구축하고 관리하기 위해 사용한 두 가지 방법을 살펴보겠습니다.
그룹별 의존성 관리
Poetry에서는 그룹을 구성해 의존성을 그룹별로 관리하는 방법을 제공합니다(참고). 의존성을 각 사용 특성에 맞게 조합해서 그룹을 구성한 뒤 특정 그룹에 대한 의존성만 설치하거나 제외할 수 있습니다. 이를 이용하면 각 프로젝트를 그룹으로 추가해서 루트에서 원하는 그룹의 의존성만 설치하는 방식으로 의존성을 분리할 수 있습니다.
pyproject.toml 파일에 tool.poetry.group.<group>
설정을 추가해 섹션을 나누고 그룹을 추가할 수 있습니다.
[tool.poetry.group.service1.dependencies]
black = "^23.12.1"
[tool.poetry.group.service1]
optional = true
위와 같이 나눈 그룹 섹션에는 아래와 같은 명령을 실행할 수 있습니다.
# test 그룹에 pytest을 설치
poetry add pytest --group test
# test 그룹의 의존성만 설치
poetry install --with test
# test 그룹의 의존성만 제외하고 설치
poetry install --without test
이와 같이 멀티 프로젝트를 개발할 때 의존성 그룹의 특성을 활용해 여러 패키지를 하나의 리포지터리에서 관리할 수 있는데요. 이와 관련해서 아래 멀티 프로젝트 적용 섹션에서 조금 더 자세히 살펴보겠습니다.
내부 라이브러리 관리
Poetry의 또 다른 특징으로는 내부 패키지에 대한 의존성도 상대 경로를 통해 지정할 수 있다는 것입니다(참고). Poetry에서는 pyproject.toml을 기준으로 패키지 디렉터리의 루트가 지정되며, 이를 기준으로 한 상대 경로로 패키지를 찾아가도록 설계돼 있습니다. 라이브러리 패키지를 메인 프로젝트의 루트 디렉터리에 두고, 이를 기준으로 각 서비스 패키지에서 원하는 라이브러리 패키지를 상대 경로로 참조해서 사용할 수 있습니다.
[tool.poetry.group.groupName.dependencies]
library1 = { path = "../../libraries/library1", develop = true }
저희는 이 두 가지 기능을 활용해 멀티 프로젝트를 구축했습니다. 그럼 이제 예시와 함께 멀티 프로젝트 구축 방법에 대해서 알아보겠습니다.
Poetry를 이용한 멀티 프로젝트 구축
앞서 살펴본 것처럼 Poetry를 이용해 하나의 프로젝트를 쉽고 직관적으로 관리할 수 있게 되었습니다. 그렇다면, 이를 이용해 여러 개의 프로젝트를 쉽고 직관적으로 관리하려면 어떻게 해야 할까요?
저희는 멀티 프로젝트를 구성하기 위해 일단 전체를 아래와 같이 크게 두 가지 디렉터리로 분리했습니다.
- libraries: 공통으로 사용할 라이브러리
- services: 하나의 컴포넌트로 제공할 서비스
디렉터리를 분리한 뒤 각 디렉터리에 위치한 하위 패키지를 다시 독립적으로 분리하고 의존성을 나눴습니다.
이와 같이 구조를 나눴는데요. 이제 각 서비스별로 개별적으로 의존성 패키지를 설치해 독립적으로 실행하려면 어떻게 해야 할까요? 독립적으로 실행할 수 있는 환경을 구축할 때에는 앞서 살펴본 Poetry의 의존성 그룹 기능을 사용하며, 의존성 그룹을 이용해 각 서비스를 독립적으로 실행하기 위해서는 먼저 루트 경로에 pyproject.toml 파일을 작성해 관련 설정을 해야 합니다.
루트의 pyproject.toml 파일 작성
그럼 이제 루트에 위치할 pyproject.toml 파일을 작성해 봅시다. 설정할 내용을 그림으로 표현하면 아래와 같습니다.
서비스별로 그룹을 추가하고 각 그룹은 해당 서비스의 의존성만 지니도록 설정하는데요. 아래는 위 그림과 같이 설정한 pyproject.toml입니다.
[tool.poetry]
name = "sample-multi-project"
version = "0.1.0"
description = ""
authors = ["maxlee <maxlee@linecorp.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.10"
[tool.poetry.group.library1.dependencies]
library1 = { path = "libraries/library1", develop = true }
[tool.poetry.group.library1]
optional = true
[tool.poetry.group.library2.dependencies]
library2 = { path = "libraries/library2", develop = true }
[tool.poetry.group.library2]
optional = true
[tool.poetry.group.library3.dependencies]
library3 = { path = "libraries/library3", develop = true }
[tool.poetry.group.library3]
optional = true
[tool.poetry.group.service1.dependencies]
service1 = { path = "services/service1", develop = true }
[tool.poetry.group.service1]
optional = true
[tool.poetry.group.service2.dependencies]
service2 = { path = "services/service2", develop = true }
[tool.poetry.group.service2]
optional = true
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
음? 뭔가 이상하지 않나요? 왜 각 서비스의 세부 패키지의 의존성은 그룹에 추가하지 않을까요? 그 이유는, 멀티 프로젝트를 구성할 때 관리 편의성을 높이기 위해 가상 환경을 통합해서 사용하도록 설정하는데 여기서 루트 경로의 pyproject.toml 파일은 이 통합 가상 환경의 관리만 담당하기 때문입니다. 각 프로젝트의 세부 의존성은 각 패키지의 독립성을 확보하면서 루트의 역할 최소화하기 위해서 각 패키지의 pyproject.toml 파일에서 관리합니다.
또한 모든 그룹은 기본 패키지에 포함되지 않도록 optional로 설정합니다. 이는 루트의 그룹 의존성 패키지를 설치할 때 다른 그룹의 의존성 패키지가 함께 설치되는 것을 피하기 위함입니다. 이렇게 설정하면 루트를 통해 특정 그룹의 의존성만 설치하도록 명령하는 방식으로 하나의 가상 환경에서 여러 프로젝트의 의존성을 관리하며 실행할 수 있습니다.
가상 환경 설정
기본적으로 Poetry는 ~/Library/Caches/pypoetry/virtualenvs 경로에서 가상 환경을 관리합니다. 이 경로에 각 패키지의 이름에 맞게 가상 환경이 생성되며, 패키지별로 다른 이름의 가상 환경이 생성되기 때문에 완전히 분리된 환경에서 의존성이 관리됩니다.
가상 환경에 어떤 의존성이 포함되는지 확인하고 싶을 때에는 위 경로에서 확인할 수 있는데요. 보다 쉽고 편하게 확인할 수 있도록 아래와 같이 ~/Library/Caches/pypoetry/virtualenvs 경로 대신 로컬 경로에 가상 환경이 생성되도록 설정했습니다.
[virtualenvs]
in-project = true
Poetry의 다양한 설정은 poetry.toml 파일을 이용해 설정할 수 있는데요. 보다 자세한 사항은 Poetry 공식 문서를 참고하시기 바랍니다.
각 서비스의 pyproject.toml 작성
루트에서 서비스별로 가상 환경을 선택할 수 있는 준비가 다 됐다면 이제 각 서비스에서 사용하는 의존성을 정의할 차례입니다. 각 서비스의 pyproject.toml에서는 각 서비스에서 사용하는 패키지의 세부 버전 등의 의존성을 관리합니다.
아래는 service1의 pyproject.toml 예시입니다.
[tool.poetry]
name = "service1"
version = "0.1.0"
description = ""
authors = ["maxlee <maxlee@linecorp.com>"]
[tool.poetry.dependencies]
python = "^3.10"
library1 = {path = "../../libraries/library1", develop = true}
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
service1 패키지에서는 library1 의존성을 가지도록 구성했습니다.
[tool.poetry]
name = "service2"
version = "0.1.0"
description = ""
authors = ["maxlee <maxlee@linecorp.com>"]
[tool.poetry.dependencies]
python = "^3.10"
library2 = {path = "../../libraries/library2", develop = true}
library3 = {path = "../../libraries/library3", develop = true}
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
service2 패키지는 library2와 library3 의존성을 가지도록 구성했습니다.
가상 환경 설치 및 실행
이렇게 서비스 단위로 그룹을 여러 개 추가했다면, 각 그룹별로 독립적으로 의존성 패키지를 설치할 준비가 됐습니다. 특정 그룹만 설치하고 싶다면 아래와 같이 명령을 실행하면 됩니다.
poetry install --sync --with service1
--sync
: 가상 환경에 설치돼 있는 의존성 패키지를 현재 명령 기준으로 동기화합니다(과거에 설치된 의존성 패키지 중 필요 없어진 것들은 제거합니다).--with
: 특정 그룹(예: service1)을 기본 설치 옵션과 함께 설치합니다. 기본 설치 옵션이 없다면 명시된 특정 그룹만 설치합니다.
참고로 각 그룹이 독립적으로 설치되기를 원한다면 아래와 같이 optional = true
로 설정해야 합니다. 기본적으로는 필수 설치로 설정돼 있습니다.
[tool.poetry.group.service1]
optional = true
위에서 설명한 루트 패키지를 도식화하면 아래와 같습니다. service1을 설치하면 library1이 같이 설치되고, service2를 설치하면 library2와 library3가 같이 설치됩니다.
service1을 설치한 뒤 service2 그룹으로 의존성 패키지를 설치하면, 아래와 같이 기존에 service1과 관련해 설치됐던 의존성 패키지는 제거되고 service2의 의존성 패키지가 새로 설치됩니다.
실제 .venv 경로에 설치된 패키지 목록을 확인하면 아래와 같습니다.
설치가 완료됐다면 이제 실행할 차례인데요. 실행할 때 주의해야 할 사항이 있습니다. Poetry를 실행하는 위치가 루트 경로로 잡히기 때문에 아래와 같이 실행하려는 Python 파일의 상대 경로를 입력해야 합니다.
poetry run python /path/to/your-package
DI 도입
Poetry를 이용해 여러 프로젝트를 한곳에서 관리할 수 있는 방법과 배포 일관성을 확보했다면 이제 대규모 프로젝트 개발을 시작할 준비가 반 정도 되었다고 할 수 있습니다. 그런데 여기서 한 가지 더, 코드 수준에서의 의존성 관리 역시 장기적으로 서비스를 유지 보수하기 위해서 반드시 필요한 부분입니다.
동적 타이핑 언어인 Python은 매우 유연해서 때로는 이 유연함이 단점으로 느껴지기도 합니다. Python은 의존성을 가진 모든 모듈을 참조할 수 있으며, 이런 특성 때문에 어떻게 코드를 작성하냐에 따라서 매우 강한 결합(coupling)이 발생할 수도 있는데요. 단적인 예로 아래와 같이 글로벌 변수를 선언해서 사용했을 때 코드를 변경하려면 모든 변경점을 찾아서 변경해야 하는 불편함이 있습니다.
# settings.py
GLOBAL_CONFIG = {"feature_flag": True}
# module_a.py from settings.py
import GLOBAL_CONFIG
def feature_a():
if GLOBAL_CONFIG["feature_flag"]:
print("Feature A enabled")
# module_b.py from settings.py
import GLOBAL_CONFIG
def toggle_feature():
GLOBAL_CONFIG["feature_flag"] = not GLOBAL_CONFIG["feature_flag"]
이 문제를 해결하기 위해 저희는 DI를 도입하기로 했습니다. DI는 서비스 간 결합도를 낮추고 응집력을 높입니다. 객체 생성과 관리를 컨테이너에 위임하고, 서비스는 컨테이너 로부터 필요한 리소스를 제공받아 사용합니다.
Dependecy Injector
DI를 위한 많은 패키지 중 아래와 같은 장점을 고려해 Dependency Injector를 선택했습니다.
- 쉬운 용어:
Container
,Provider
,Resource
,Configuration
등 이해하기 쉬운 용어 사용 - 컨테이너 구조: 리소스나 서비스를 컨테이너 단위로 관리
- 통합성: Flask나 Django, Boto3, FastAPI 등 다양한 패키지와 통합 가능
- 사용성: 클래스나 메서드, 데코레이터 수준에서도 DI를 제공하기에 쉽게 적용 가능
그럼 이런 장점이 있는 Dependency Injector를 어떻게 사용하는지 알아보겠습니다. 사용하기 위해서는 먼저 아래와 같이 의존성 패키지를 설치합니다.
# pip install dependency-injector
poetry add dependency-injector
Dependency Injector 패키지를 구성하는 컴포넌트는 크게 두 가지로 구성됩니다.
Containers
:Provider
의 집합으로, 역할에 따라Provider
를 묶는 단위입니다.Providers
:Provider
는 아래와 같은 여러 가지를 바로 사용할 수 있는 객체 상태로 만들어 제공합니다.Configuration
: YAML이나 JSON, OS 환경 변수 등을 이용해 설정값을 관리하는 객체Resource
: 리소스를 관리하는 객체Singleton
: 싱글턴 객체Factory
: 팩토리 객체
아래는 Container
하나를 간단히 구성해 본 예제 코드입니다.
from dependency_injector import containers, providers
from dependency_injector.wiring import Provide, inject
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
api_client = providers.Singleton(
ApiClient,
api_key=config.api_key,
timeout=config.timeout,
)
service = providers.Factory(
Service,
api_client=api_client,
)
@inject
def main(service: Service = Provide[Container.service]) -> None:
...
if __name__ == "__main__":
container = Container()
container.config.api_key.from_env("API_KEY", required=True)
container.config.timeout.from_env("TIMEOUT", as_=int, default=5)
container.wire(modules=[__name__])
main() # <-- dependency is injected automatically
with container.api_client.override(mock.Mock()):
main() # <-- overridden dependency is injected automatically
Container
를 이용하면 위와 같이 쉽게 모듈화할 할 수 있으며, 여러 Provider
를 선언해서 사용할 수 있습니다.
멀티 프로젝트에서는 라이브러리와 서비스의 각 패키지를 감싸는 컨테이너를 구성하고, 서비스 컨테이너에서 라이브러리 컨테이너를 참조해 사용할 수 있도록 아래와 같이 구성합니다.
위 코드와 같이 service2Container
에 library2Container
를 선언하고 사용합니다. Dependency Injector의 와이어링(wiring)으로 @inject
와 Provide
를 이용해 Container
의 서비스를 주입할 수 있습니다. 보다 자세한 코드는 Dependency Injector의 Decoupled packages example 문서에 멀티 패키지를 구성하는 예제가 잘 작성돼 있으니 참고하시기 바랍니다.
마치며
이번 글에서는 규모가 큰 Python 프로젝트를 관리할 때 발생하는 여러 문제와, 이런 문제들을 Poetry와 Dependency Injector를 활용해 해결한 사례를 소개했습니다. Python 언어 기반의 모노리포, 멀티 프로젝트 환경을 구축해 대규모 비즈니스 로직을 구현할 때, 특히 Java와 Spring 환경에 익숙해진 상태에서 Python으로 작업할 필요가 있으신 분들이 참고하시면 도움이 될 것 같습니다. 긴 글 읽어주셔서 감사합니다.