프로그레스 바는 API가 아니다

CLI가 유용해지면 언젠가 누군가는 그 CLI를 자동화하려고 합니다.

그때 progress bar가 조용히 문제가 될 수 있습니다.

사람에게는 이런 출력이 충분히 유용합니다.

Translating markdown files  12/40  30%  docs/intro.md

명령이 아직 살아 있는지, 어느 정도 진행됐는지, 지금 어떤 파일을 처리 중인지 바로 알 수 있습니다.

하지만 다른 시스템이 같은 출력을 읽기 시작하면 이야기가 달라집니다. Progress bar는 더 이상 단순한 사용자 인터페이스가 아닙니다. 의도하지 않은 contract가 됩니다.

Co-op Translator v0.20.0에서 다룬 문제도 여기에 가까웠습니다. 이번 버전에서는 Rich 기반 CLI progress UI를 추가했고, 동시에 structured translation event도 추가했습니다.

겉으로 보면 두 개의 기능처럼 보일 수 있습니다. 하지만 실제로는 같은 상태를 두 표면으로 나눈 작업이었습니다. Rich output은 사람이 읽기 좋은 화면을 만들고, structured event는 integration이 의존할 수 있는 안정적인 데이터를 제공합니다.

이 글에서는 세 가지를 다룹니다.

첫째, 왜 console output을 파싱하고 싶어지는지.

둘째, Co-op Translator가 Rich UI와 event stream을 어떻게 분리했는지.

셋째, 이 분리가 CLI, Python API, MCP, 그리고 Localizeflow 같은 product integration에서 왜 중요한지.

문제는 로그가 상태가 될 때 생긴다

콘솔 출력은 원래 사람을 위해 작성됩니다.

제가 translation command를 실행할 때 알고 싶은 것은 대체로 이런 것들입니다.

  • 명령이 아직 실행 중인가
  • 지금 어떤 stage인가
  • 어떤 파일을 처리 중인가
  • 얼마나 남았는가
  • 실패한 작업이 있는가

Progress bar는 이런 질문에 잘 맞습니다. 실행 상태를 사람이 빠르게 읽을 수 있는 형태로 압축해 줍니다.

하지만 product integration은 다른 종류의 정보를 필요로 합니다.

예를 들어 Localizeflow가 더 큰 workflow의 일부로 Co-op Translator를 실행한다고 생각해 보겠습니다. Localizeflow는 단순히 어떤 문장이 출력됐는지가 아니라, 더 안정적인 상태를 알고 싶어 합니다.

  • 어떤 translation job이 시작됐는가
  • 어떤 target language가 active인가
  • 어떤 stage가 실행 중인가
  • 어떤 파일이 완료됐는가
  • 어떤 파일이 실패했는가
  • 전체 중 몇 개가 끝났는가
  • 실행이 성공했는가

이 정보가 모두 console text 안에만 있으면 integration은 사람이 읽는 문장을 다시 파싱해야 합니다.

그 방식은 약합니다. 그 문장은 애초에 contract가 아니었기 때문입니다.

더 좋은 UI가 parser를 깨뜨릴 수 있다

이 문제에서 이상한 점은, 사람에게는 개선인 변화가 기계에게는 breaking change가 될 수 있다는 것입니다.

어떤 integration이 다음 문자열을 찾고 있다고 해보겠습니다.

Translating markdown files: 12/40

그런데 CLI를 더 읽기 좋게 바꾸면서 같은 정보가 Rich table, progress bar, status panel, 또는 더 짧은 label 안으로 들어갑니다.

사람에게는 더 나은 UI일 수 있습니다.

하지만 parser에게는 실패일 수 있습니다.

더 작은 문구 변경도 위험합니다.

Retranslating outdated markdowns

나중에 이 label을 조금 더 명확하게 바꿉니다.

Retranslating outdated markdown files

사람에게는 자연스러운 display text 변경입니다.

하지만 외부 시스템이 정확히 이 label 문자열에 의존하고 있었다면, 이 작은 개선이 integration breaking change가 됩니다.

이 지점에서 기준이 더 분명해졌습니다. Progress bar는 실행 상태를 사람에게 설명할 수 있지만, 다른 프로그램이 의존하기에는 너무 쉽게 바뀌는 표면입니다.

그 기준이 분명해지자 해결 방향도 바뀌었습니다. Progress bar를 더 잘 파싱하게 만드는 것이 아니라, progress bar에게 두 가지 일을 시키지 않는 쪽이 맞았습니다.

해결은 출력을 두 갈래로 나누는 것

v0.20.0에서는 progress 정보를 한 번 만든 뒤 두 개의 표면으로 보냅니다.

첫 번째 표면은 사람을 위한 renderer입니다.

CLI에서는 Rich가 현재 translation state를 header, table, progress bar, status line으로 보여줍니다. 이 출력은 사람이 보기 좋아야 합니다. 그리고 시간이 지나면서 더 좋아질 수 있어야 합니다.

두 번째 표면은 시스템을 위한 event stream입니다.

외부 integration은 console output을 파싱하지 않아야 합니다. 대신 stable schema를 가진 event를 읽어야 합니다.

같은 진행 상황도 시스템이 믿을 수 있는 형태로 표현할 수 있습니다.

{
  "schema": "co-op.translation.event.v1",
  "type": "stage_progress",
  "stage_key": "translating_markdown_files",
  "stage_label": "Translating markdown files",
  "language": "ko",
  "current_path": "docs/intro.md",
  "completed": 12,
  "total": 40,
  "progress": 30
}

Progress bar와 event는 같은 내부 상태에서 만들어질 수 있습니다. 하지만 두 출력은 서로 다른 대상을 위해 다듬어집니다. UI는 읽기 쉬운 label과 풍부한 layout을 사용할 수 있고, event는 integration이 저장하고 비교할 수 있는 stable field를 유지합니다.

Label과 key를 나누기

Structured event에서 중요했던 결정 중 하나는 stage_labelstage_key를 나누는 것이었습니다.

stage_label은 사람이 보는 문구입니다.

{
  "stage_label": "Translating markdown files"
}

이 값은 나중에 더 자연스러운 표현으로 바뀔 수 있습니다. CLI를 더 이해하기 쉽게 만들고 싶을 수 있기 때문입니다.

반면 stage_key는 integration이 의존해야 하는 안정적인 값입니다.

{
  "stage_key": "translating_markdown_files"
}

외부 시스템은 label이 아니라 key를 사용해야 합니다. Label은 이해하기 쉽게 바뀔 수 있지만, key는 contract가 안정적으로 유지하겠다고 약속하는 값입니다.

작은 차이처럼 보이지만, 이 분리는 양쪽을 모두 지켜 줍니다. CLI 문구는 계속 더 명확해질 수 있고, integration은 모든 문구 변경을 두려워하지 않아도 됩니다.

이벤트는 workflow를 설명해야 한다

유용한 event stream은 percentage만 전달해서는 부족합니다.

Progress는 단순한 숫자가 아닙니다. Workflow가 여러 상태를 지나며 움직이는 과정입니다.

Co-op Translator에서는 이런 흐름을 event로 표현할 수 있습니다.

run_started
estimate_ready
stage_started
stage_progress
file_completed
run_completed

경고나 실패는 다음과 같은 event로 나타낼 수 있습니다.

warning
file_failed
run_failed

이렇게 나누면 integration code가 훨씬 단순해집니다.

Dashboard는 run_started를 받으면 job을 만들 수 있습니다. estimate_ready를 받으면 token estimate를 보여줄 수 있습니다. stage_progress를 받으면 화면의 progress를 갱신할 수 있습니다. run_completed를 받으면 job을 완료 상태로 바꿀 수 있습니다.

Dashboard는 Co-op Translator의 콘솔 문장을 이해할 필요가 없습니다.

Event contract만 이해하면 됩니다.

하나의 contract가 여러 interface를 지탱한다

Co-op Translator는 CLI만 제공하는 도구가 아닙니다.

Python API와 MCP server도 함께 제공합니다. 그래서 event contract는 특정 terminal UI에 묶이면 안 됩니다.

CLI에서는 사람이 Rich progress UI를 볼 수 있습니다. 다른 시스템이 machine-readable output을 필요로 한다면 NDJSON event를 쓸 수 있습니다.

translate -l "ko ja" -md --json-events progress.ndjson

Python API에서는 callback으로 같은 종류의 event를 받을 수 있습니다.

from co_op_translator.api import run_translation


def on_event(event):
    record_translation_event(job_id, event.to_dict())


run_translation(
    language_codes="ko ja",
    root_dir=".",
    markdown=True,
    progress_callback=on_event,
)

MCP에서는 run_translation 결과 payload에 event를 담을 수 있습니다. Agent와 host application은 terminal output을 긁어 오지 않고도 어떤 일이 일어났는지 이해할 수 있습니다.

Interface는 다르지만 contract는 같습니다.

  • CLI: Rich renderer와 NDJSON event file
  • Python API: progress_callback
  • MCP: tool result의 event payload

Display와 event를 분리하면 이런 장점이 생깁니다. UI는 계속 바뀌고 좋아질 수 있지만, API와 MCP integration은 흔들리지 않습니다.

Localizeflow 관점에서 달라지는 점

Localizeflow 같은 제품 관점에서 보면 차이가 더 분명해집니다.

약한 방식에서는 Localizeflow가 console text를 받아 의미를 추출해야 합니다.

Done: Translated README.md to Korean.

이 문장에서 파일명, 언어, 완료 상태를 역으로 추론해야 합니다.

Structured event가 있으면 더 안정적인 사실을 저장할 수 있습니다.

{
  "type": "file_completed",
  "language": "ko",
  "current_path": "README.md"
}

이 event는 database에 append하기 쉽습니다. 현재 job status는 event stream에서 materialize할 수 있습니다. Log는 여전히 존재할 수 있지만, product state의 source of truth가 될 필요는 없습니다. 이 모델에서는 log가 실행을 조사하는 사람에게 유용한 맥락으로 남고, event가 Localizeflow가 의존하는 사실을 전달합니다.

Rich는 여전히 중요하다

Event와 UI를 분리한다고 해서 UI가 덜 중요해지는 것은 아닙니다.

오히려 UI를 더 편하게 개선할 수 있습니다.

사람이 보는 CLI는 명확하고 보기 좋아야 합니다. 실행 중인 command, target language, estimate, current stage, current file, failure를 한눈에 볼 수 있어야 합니다.

Rich는 이런 CLI를 만들기에 좋았습니다.

  • Header로 실행 정보를 묶을 수 있음
  • Table로 estimate와 stage progress를 정리할 수 있음
  • Progress bar로 긴 작업을 따라가기 쉽게 만들 수 있음
  • GitHub Actions log에서도 비교적 읽기 좋은 출력을 만들 수 있음

하지만 Rich output은 CLI를 쓰는 사람이 더 이해하기 쉬워진다면 계속 바뀔 수 있어야 합니다.

이 설계에서는 Rich가 사람이 보는 화면을 렌더링하고, versioned event schema가 machine-readable state를 전달합니다.

이 분리 덕분에 두 표면이 모두 좋아질 수 있습니다.

원칙

이번 작업에서 얻은 교훈은 단순합니다.

Logs are for humans. Events are for systems.

사람은 문장을 읽고 맥락을 이해할 수 있습니다. 작은 문구 변경은 대개 문제가 되지 않습니다.

하지만 시스템에는 안정적인 key, schema version, event type, typed field가 필요합니다.

그래서 progress output을 설계할 때 첫 질문은 "이 텍스트를 어떻게 더 잘 파싱하게 만들까?"가 아니어야 합니다.

더 좋은 질문은 이것입니다.

누가 이 출력에 의존하게 될까?

사람이 읽는다면 UI를 명확하게 만들면 됩니다.

다른 시스템이 의존한다면 versioned structured event를 제공해야 합니다.

Progress bar는 여전히 중요합니다. 더 좋은 CLI 경험을 만듭니다.

하지만 다른 시스템이 progress에 의존해야 한다면, stable surface는 versioned event stream이어야 합니다.

댓글