Don't fear Python subprocess or Go codegen

Ben Hoyt

Python subprocess와 Go 코드 생성을 두려워하지 마세요

원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기

Jubilant는 제가 Juju를 위해 만든 Python API로, Juju는 Canonical이 만든 배포 및 운영 도구입니다. Jubilant 자체는 매우 단순하지만, 이 글에서는 다른 개발자들에게 흥미로울 만한 몇 가지 설계 결정에 대해 설명합니다. Python의 subprocess.run 활용, Go 구조체로부터 Python dataclass를 만드는 코드 생성, 그리고 Make와 uv의 사용입니다.

평소에는 이 웹사이트에 업무 관련 이야기를 직접 쓰지 않지만, 왜 안 되겠어요? Canonical에서 만드는 거의 모든 것은 오픈 소스이며, Jubilant도 예외가 아닙니다.

게다가 이름이 정말 마음에 들어 지금 jubilant한 기분입니다 — 동료 Dave Wilding이 지어준 이름이거든요.

Subprocess.run

Jubilant는 subprocess.run을 사용해 juju 명령을 호출하는 Python API입니다. 아주 단순화한 예시는 다음과 같습니다.

def deploy(app: str):
    subprocess.run(['juju', 'deploy', app])

그렇게 하면 안 된다고 배우지 않았나요? 정말 형편없는 생각 아닌가요?

생각보다 그리 형편없지 않습니다. 저희 경우엔 기존 Python API인 python-libjuju보다 더 단순하고 안정적이었습니다. 기존 라이브러리는 커스텀 RPC, 웹소켓, 비동기적으로 갱신되는 자료구조, Python의 asyncawait, 그리고 방대한 API를 모두 포함한 복잡한 Juju API를 호출합니다. 사용하기도 유지보수하기도 즐겁지 않았죠.

게다가 대부분의 Juju CLI 작업은 본질적으로 비동기라서 asyncio의 복잡성이 필요하지 않았습니다. 예를 들어 juju deploy myapp은 빠르게 사용자에게 제어를 반환하고, Juju 컨트롤러가 백그라운드에서 앱을 배포합니다.

하지만 새 프로세스를 생성하면 오버헤드가 크지 않나요? 이 사용 사례에서는 비교적 적습니다(특히 Linux에서는 프로세스 생성이 빠르니까요). deploy 명령 자체가 1~2초 정도 걸리는데, 거기에 몇 밀리초가 더해지는 건 큰 문제가 되지 않습니다.

안정성은 어떨까요? 그건 정말 고민이었습니다. 하지만 Juju 팀은 메이저 버전 내에서는 안정적인 CLI를 유지하겠다고 약속합니다. 커맨드라인 인수를 바꾸지 않는다는 뜻이죠. 기본 텍스트 출력은 가끔 바꾸지만, Jubilant가 사용하는 JSON 출력 형식(--format json)은 깨뜨리지 않습니다.

물론 Jubilant가 python-libjuju의 모든 용도를 대체하는 건 아닙니다. 무언가를 스트리밍하거나 이벤트를 구독하고 싶다면 방법이 없죠. 하지만 python-libjuju는 주로 Juju 오퍼레이터(‘charm’이라 불리는)의 통합 테스트에 쓰였고, 그런 용도라면 Jubilant가 아주 잘 동작합니다.

그러니 복잡한 API와 단순한 CLI를 가진 도구라면, CLI를 감싸는 방식이 정답일 수 있습니다. 적어도 저희에게는 확실히 잘 통하고 있습니다.

이 방식을 활용한 단위 테스트

version 메서드(juju version을 실행하고 출력을 파싱하는 메서드)를 테스트하고 싶다고 해보겠습니다. 테스트 대상 코드는 다음과 같이 생겼습니다.

def version(self) -> Version:
    # self.cli() is a helper that calls subprocess.run
    stdout = self.cli('version', '--format', 'json', '--all',
                      include_model=False)
    version_dict = json.loads(stdout)
    return Version._from_dict(version_dict)

테스트에는 subprocess.run을 모킹한 버전을 사용합니다. 저희는 직접 만든 작은 mock을 사용했는데, 일반적인 MagicMock보다 쓰기 편합니다.

Pytest를 사용한 단위 테스트는 다음과 같이 생겼습니다. test_version.py에서 가져온 예시입니다.

def test_simple(run: mocks.Run):
    version_dict = {
        'version': '3.6.11-genericlinux-amd64',
        'git-commit': '17876b918429f0063380cdf07dc47f98a890778b',
    }
    run.handle(['juju', 'version', '--format', 'json', '--all'],
               stdout=json.dumps(version_dict))

    juju = jubilant.Juju()
    version = juju.version()

    assert version == jubilant.Version(
        3, 6, 11,
        release='genericlinux',
        arch='amd64',
        git_commit='17876b918429f0063380cdf07dc47f98a890778b',
    )
    assert version.tuple == (3, 6, 11)

run.handle 호출은 mock에게 “이 CLI 인수로 호출되면 주어진 출력을 반환하라”고 알려주는 역할을 합니다.

타입을 갖춘 Python스러운 래퍼

Juju 관리자는 이미 Juju CLI에 익숙하므로, Jubilant도 CLI처럼 느껴지되 Python스럽게 만들고 싶었습니다. 명령어 이름과 인수 이름을 포함해 CLI 명령을 일대일로 감싸는 것이 저희 설계 목표 중 하나였습니다.

예를 들어 관리자는 다음과 같은 명령을 실행하는 데 익숙합니다.

juju deploy webapp
juju deploy mysql --config cluster-name=testclust
juju integrate webapp mysql

이는 Python에서 다음과 같이 그대로 옮겨집니다.

juju = jubilant.Juju()

juju.deploy('webapp')
juju.deploy('mysql', config={'cluster-name': 'testclust'})
juju.integrate('webapp', 'mysql')

위치 기반 CLI 인수는 Python에서 위치 기반 메서드 인수가 되고, --config 같은 CLI 플래그는 키워드 인수가 됩니다. 그리고 cluster-name=testclust 같은 키-값 쌍의 리치 옵션은 딕셔너리 같은 제대로 된 Python 타입이 됩니다.

deploy 메서드는 다음과 같이 정의됩니다.

def deploy(
    self,
    charm: str | pathlib.Path,
    app: str | None = None,
    *,  # this makes the rest of the arguments keyword-only
    attach_storage: str | Iterable[str] | None = None,
    base: str | None = None,
    bind: Mapping[str, str] | str | None = None,
    channel: str | None = None,
    config: Mapping[str, ConfigValue] | None = None,
    # ...
) -> None:

타입 어노테이션은 훌륭한 문서가 되고(예: deploy), IDE에서 Jubilant를 사용할 때 큰 즐거움을 줍니다. 인수 이름에 대한 훌륭한 자동완성과 어떤 타입을 써야 하는지에 대한 힌트를 얻을 수 있죠.

저희는 엄격 모드에서 Pyright를 사용해 Jubilant를 타입 체크합니다. 단위 테스트와 통합 테스트도 포함해서 검사하므로, 라이브러리 사용자에게 타입이 제대로 동작한다는 것을 보장할 수 있습니다.

Juju의 일부 CLI 명령은 오버로드되어 있습니다. 예를 들어 juju config myapp처럼 인수 없이 실행하면 앱의 설정을 조회하지만, juju config myapp foo=bar baz=42처럼 인수를 주면 설정을 설정합니다. 이를 위해 Python의 @overload 데코레이터를 사용합니다.

ConfigValue = bool | int | float | str

# Get configuration values (return them)
@overload
def config(self, app: str) -> Mapping[str, ConfigValue]: ...

# Set configuration values
@overload
def config(
    self,
    app: str,
    values: Mapping[str, ConfigValue],
    *,
    reset: Iterable[str] = (),
) -> None: ...

# Only reset values
@overload
def config(self, app: str, *, reset: Iterable[str]) -> None: ...

# The definition itself (no @overload)
def config(
    self,
    app: str,
    values: Mapping[str, ConfigValue] | None = None,
    *,
    reset: Iterable[str] = (),
) -> Mapping[str, ConfigValue] | None:
    # actual implementation here

오버로드는 타입 체커에게 config()를 다음 중 하나의 방식으로만 호출할 수 있다고 알려줍니다.

# Get configuration values
config = juju.config('myapp')
assert config['foo'] == 'bar'

# Set configuration values
juju.config('myapp', {'foo': 'bar', 'baz': 42})

# Only reset values
juju.config('myapp', reset=['foo', 'baz'])

Go로 Python dataclass 생성하기

일부 Juju CLI 명령은 데이터를 반환합니다. 예를 들어 juju status가 그렇죠. 기본적으로 이 명령은 다음과 같이 사람이 읽기 쉬운 텍스트 출력을 반환합니다.

$ juju status
Model  Controller           Cloud/Region         Version  SLA          Timestamp
tt     localhost-localhost  localhost/localhost  3.6.11   unsupported  15:13:39+13:00

Model "admin/tt" is empty.

하지만 출력을 반환하는 거의 모든 Juju 명령은 JSON이나 YAML 형식으로 출력을 요청할 수 있습니다. 예를 들어(jq로 JSON을 예쁘게 출력한 예시):

$ juju status --format json | jq
{
  "model": {
    "name": "test",
    "type": "iaas",
    "controller": "localhost-localhost",
    "cloud": "localhost",
    "region": "localhost",
    "version": "3.6.11",
    "model-status": {
      "current": "available",
      "since": "18 Nov 2025 11:06:43+13:00"
    },
    "sla": "unsupported"
  },
  "machines": {},
  "applications": {},
  "storage": {},
  "controller": {
    "timestamp": "15:14:15+13:00"
  }
}

Jubilant는 --format json을 사용하고 이를 일련의 status dataclass로 파싱합니다. status 메서드가 반환하는 최상위 dataclass는 단순히 Status라고 불립니다. 각 클래스는 dict(JSON에서 온)에서 인스턴스를 생성하는 _from_dict 메서드를 가지고 있습니다. 예를 들면 다음과 같습니다.

@dataclasses.dataclass(frozen=True)
class Status:
    model: ModelStatus
    machines: dict[str, MachineStatus]
    apps: dict[str, AppStatus]
    # ...

    @classmethod
    def _from_dict(cls, d: dict[str, Any]) -> Status:
        return cls(
            model=ModelStatus._from_dict(d['model']),
            machines={k: MachineStatus._from_dict(v)
                      for k, v in d['machines'].items()},
            apps={k: AppStatus._from_dict(v)
                  for k, v in d['applications'].items()},
            # ...
        )

하지만 Status 객체는 큽니다. 28개의 서로 다른 클래스로 구성되어 있으며, 각 클래스는 여러 필드를 가지고 타입도 다양합니다. 보통은 str, int이거나 값이 다른 dataclass의 인스턴스인 dict죠.

이들의 원천(source of truth)은 Juju 코드베이스에 있는 여러 Go 구조체입니다. 예를 들어 위의 Status 클래스는 Juju의 formattedStatus 구조체에 해당합니다.

type formattedStatus struct {
    Model        modelStatus                  `json:"model"`
    Machines     map[string]machineStatus     `json:"machines"`
    Applications map[string]applicationStatus `json:"applications"`
    // ...
}

실수를 피하기 위해 Python dataclass를 손으로 직접 쓰고 싶지 않았습니다. 그래서 런타임 리플렉션을 사용해 Python 코드를 뽑아내는 아주 단순한 코드 생성기를 Go로 작성했습니다.

핵심은 주어진 구조체로부터 필드 정보 맵을 채우는 재귀 함수입니다. 맛보기로 코드 일부를 보여드리겠습니다.

func getFields(t reflect.Type, m map[string][]FieldInfo, typeName string, level int) {
    if _, ok := m[typeName]; ok {
        return
    }
    // ...
    if t.Kind() != reflect.Struct {
        return
    }
    m[typeName] = nil
    var result []FieldInfo
    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)
        jsonTag := field.Tag.Get("json")
        if jsonTag == "" {
            jsonTag = field.Name
        }
        tagFields := strings.Split(jsonTag, ",")
        jsonField := tagFields[0]
        if jsonField == "-" {
            jsonField = ""
        }
        fieldType := field.Type.String()
        niceName := getNiceName(fieldType)
        result = append(result, FieldInfo{
            Name:      field.Name,
            Type:      niceName,
            JSONField: jsonField,
            Pointer:   fieldType[0] == '*',
            OmitEmpty: slices.Contains(tagFields[1:], "omitempty"),
        })
        if jsonField == "" {
            continue
        }
        switch field.Type.Kind() {
        case reflect.Struct:
            getFields(field.Type, m, niceName, level+1)
        case reflect.Map:
            elemType := field.Type.Elem()
            niceElemName := getNiceName(elemType.String())
            getFields(elemType, m, niceElemName, level+1)
        case reflect.Slice:
            elemType := field.Type.Elem()
            niceElemName := getNiceName(elemType.String())
            getFields(elemType, m, niceElemName, level+1)
        case reflect.Pointer:
            elemType := field.Type.Elem()
            niceElemName := getNiceName(elemType.String())
            getFields(elemType, m, niceElemName, level+1)
        }
    }
    m[typeName] = result
}

어차피 한 번만 실행하고(그 다음부터는 Python dataclass를 직접 유지보수할 예정이었으니까) 작성한 코드라, 그리 고품질의 코드는 아닙니다. 하지만 필요한 역할은 해냈습니다. dataclass와 필드, _from_dict 메서드가 담긴 거대한 Python 파일을 생성했고, 오타 없이 원천과 정확히 일치한다는 것을 알 수 있었죠.

교훈은 무엇일까요? 한 언어의 자료구조를 다른 언어로 변환하는 데 도움이 되는 작은 일회용 프로그램을 작성하는 것을 두려워하지 마세요. 원천이 꼭 과도하게 설계된 스키마 언어일 필요는 없습니다. Go 구조체면 충분합니다.

Make와 uv

Jubilant에서 강조하고 싶은 또 다른 측면은 개발자 도구입니다. Astral의 uv를 프로젝트에 처음 사용해 봤는데, 정말 훌륭했습니다. Python 의존성 관리의 고통을 정말로 해결했더군요.

저희는 라이브러리 의존성(Jubilant의 유일한 의존성은 PyYAML입니다)과 개발 의존성(Pyright, Pytest, Ruff 등)을 포함한 모든 프로젝트 설정이 담긴 pyproject.toml을 가지고 있습니다.

또한 uv에 자체 기능이 생길 때까지 명령어 러너로 사용하는 아주 단순한 Makefile도 있습니다. Just 같은 대안이 있다는 건 알지만, 별난 구석이 있음에도 어디에나 설치된 50년 된 프로그램을 쓰는 걸 좋아합니다.

다음은 저희 Makefile의 일부로, 제가 가장 자주 사용하는 명령들을 보여줍니다.

# We're using Make as a command runner, so always make
# (avoids need for .PHONY)
MAKEFLAGS += --always-make

help:  # Display help
	@echo "Usage: make [target] [ARGS='additional args']\n\nTargets:"
	@awk -F'#' '/^[a-z-]+:/ { sub(":.*", "", $$1); print " ", $$1, " #", $$2 }' Makefile | column -t -s '#'

all: format lint unit  # Run all quick, local commands

docs:  # Build documentation
	MAKEFLAGS='' $(MAKE) -C docs run

format:  # Format the Python code
	uv run ruff format

lint:  # Perform linting and static type checks
	uv run ruff check
	uv run ruff format --diff
	uv run pyright

unit:  # Run unit tests, eg: make unit ARGS='tests/unit/test_deploy.py'
	uv run pytest tests/unit -vv --cov=jubilant $(ARGS)

“help” 타깃에 있는 독특한 awk 명령 덕분에 make help를 입력하면 다음과 같이 각 명령과 설명이 담긴 목록을 볼 수 있습니다.

$ make help
Usage: make [target] [ARGS='additional args']

Targets:
  help      Display help
  all       Run all quick, local commands
  docs      Build documentation
  format    Format the Python code
  lint      Perform linting and static type checks
  unit      Run unit tests, eg: make unit ARGS='tests/unit/test_deploy.py'

이 Makefile 덕분에 제 개발 사이클은 코드를 작성하고, make all을 입력해 린트와 테스트를 통과했는지 확인한 뒤, PR을 올리는 것으로 이루어집니다.

결론

Python에서 다루고 싶은 큰 도구가 있다면, 다음 중 하나 이상을 고려해 볼 만합니다.

  • 과감하게 subprocess.run으로 감싸기
  • 복사 실수를 피하기 위해 코드 생성기 작성하기
  • Make와 uv 사용하기!

가능한 한 단순하게 유지하고, 2025년 크리스마스를 즐기세요!

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글