프로그레스 바는 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_label과 stage_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이어야 합니다.