왜 툴체인 버전이 문제가 되는가
모노레포에는 Python, Node, Go, Rust처럼 서로 다른 언어의 프로젝트가 한 저장소에 모인다. 문제는 각 언어의 런타임·패키지 매니저 버전이 개발자 로컬, CI 러너, 프로덕션 컨테이너에서 제각각이라는 점이다. "내 로컬에선 되는데 CI에서만 깨진다"의 대부분은 Node 18 vs 20, Python 3.11 vs 3.12 같은 패치·마이너 버전 불일치에서 온다. 버전이 문서(README)에만 적혀 있으면 아무도 강제할 수 없고, 시간이 지나면 실제 사용 버전과 어긋난다.
핵심 원칙은 하나다. 버전을 코드로 선언하고, 도구가 그 선언을 읽어 자동으로 맞추게 한다. 사람이 기억하거나 수동 설치하는 순간 통일은 깨진다.
단일 진실 소스: 버전 선언 파일
가장 먼저 저장소 루트에 언어별 버전을 명시한 선언 파일을 둔다. mise(구 rtx)나 asdf는 여러 언어 툴체인을 하나의 파일로 관리한다. 아래는 mise 설정 예시다.
#!/usr/bin/env bash
# .mise.toml (저장소 루트)
[tools]
python = "3.12.4"
node = "20.14.0"
go = "1.22.5"
rust = "1.79.0"
[env]
# 서브 프로젝트별 가상환경 자동 활성화
_.python.venv = { path = ".venv", create = true }
개발자는 mise install 한 번으로 네 언어의 정확한 버전을 받는다. 디렉터리에 진입하면 자동으로 해당 버전이 shim으로 활성화되므로, 프로젝트마다 다른 버전이 필요해도 충돌하지 않는다.
도구별 비교
| 도구 | 다중 언어 | 버전 파일 | 특징 |
|---|---|---|---|
| asdf | O (플러그인) | .tool-versions | 가장 널리 쓰임, 셸 통합 느림 |
| mise | O (내장+플러그인) | .mise.toml | Rust 구현, 빠름, env 관리 겸용 |
| Docker/devcontainer | O | Dockerfile | 완전 격리, 무겁고 느림 |
| 언어 네이티브 (nvm 등) | X | .nvmrc 등 | 단일 언어엔 단순 |
CI에서 동일 버전 강제하기
로컬에서 선언한 버전을 CI가 그대로 재사용해야 한다. 별도로 버전을 하드코딩하면 또 어긋난다. GitHub Actions에서는 mise를 그대로 읽게 한다.
# .github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2 # .mise.toml을 그대로 읽어 설치
with:
cache: true
- run: mise exec -- python --version
- run: mise exec -- node --version
# 선언 파일과 실제 버전이 다르면 여기서 즉시 실패
CI 러너의 기본 이미지에 깔린 버전을 절대 신뢰하지 말고, 항상 선언 파일 기반으로 설치·활성화하는 것이 핵심이다.
드리프트 방지: 버전 일치 검사
선언 파일과 각 프로젝트의 부가 설정(예: pyproject.toml의 requires-python, Dockerfile의 베이스 이미지 태그)이 어긋나는 것을 막으려면, 이를 검증하는 스크립트를 pre-commit이나 CI에 넣는다.
import re, sys, tomllib, pathlib
# .mise.toml의 python 버전을 기준값으로 삼는다
mise = tomllib.loads(pathlib.Path(".mise.toml").read_text())
want = mise["tools"]["python"] # "3.12.4"
want_minor = ".".join(want.split(".")[:2]) # "3.12"
# 각 서브 프로젝트 pyproject.toml과 대조
bad = []
for f in pathlib.Path(".").rglob("pyproject.toml"):
txt = f.read_text()
m = re.search(r'requires-python\s*=\s*"[^0-9]*([0-9.]+)', txt)
if m and not m.group(1).startswith(want_minor):
bad.append(f"{f}: requires-python={m.group(1)} != {want_minor}")
if bad:
print("\n".join(bad)); sys.exit(1)
print("toolchain versions OK")
이 검사 하나로 "누군가 로컬만 3.13으로 올렸는데 선언은 3.12" 같은 은근한 드리프트를 커밋 시점에 잡는다.
운영 시 주의점
- 패치 버전까지 고정하되, 정기적으로(예: 월 1회) 의도적으로 올린다. 고정만 하고 방치하면 보안 패치를 놓친다.
- 프로덕션 이미지도 같은 선언을 참조해야 한다. Dockerfile 베이스 태그를 별도 관리하면 CI와 런타임이 달라진다. 빌드 인자로 버전을 주입하거나, 검증 스크립트 대상에 Dockerfile을 포함시켜라.
- 버전 업그레이드는 단일 PR로 올려 diff를 명확히 한다. 여러 언어를 한 번에 올리면 회귀 원인 추적이 어렵다.
- lockfile(package-lock, uv.lock 등)과 툴체인 버전은 별개다. 둘 다 커밋하고 CI에서
--frozen계열 옵션으로 재현성을 검증한다.
결국 목표는 단순하다. 버전을 코드로 선언하고, 로컬·CI·프로덕션이 모두 그 하나의 파일을 바라보게 하는 것이다. 사람의 기억이 개입하는 지점을 없앨수록 "환경 탓" 이슈는 줄어든다.