
자막 생성 서비스(asr.museck.com)가 실제 사용자 트래픽을 받기 시작했다. 그러자 하루 만에 8가지 문제가 연달아 터졌다. 포트 충돌, 누락된 파일 형식, 로그 노이즈, 진행 상황 전달... 전형적인 "프로덕션에 띄우니까 보이는" 문제들이다.
systemd로 uvicorn을 재시작하면 이전 프로세스가 graceful shutdown하는 동안 소켓을 놓지 않는다. 새 프로세스가 포트를 잡지 못해서 시작이 실패한다.
서버 시작 전에 lsof로 포트를 점유한 프로세스를 찾아서 SIGTERM을 보내는 _kill_port_holder()를 추가했다.
def _kill_port_holder(port: int) -> None:
try:
out = subprocess.check_output(
["lsof", "-ti", f":{port}"], text=True
).strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return
my_pid = os.getpid()
for pid_str in out.splitlines():
pid = int(pid_str)
if pid == my_pid:
continue
logger.info("Killing existing process %d on port %d", pid, port)
os.kill(pid, signal.SIGTERM)
time.sleep(1)yt-dlp로 YouTube 오디오를 받으면 .opus 형식이 나올 수 있다. SUPPORTED_EXTENSIONS에 없어서 거부되고 있었다. 한 줄 수정.
교훈: yt-dlp 출력 형식은 예측 불가다. opus, m4a, webm 등 다양한 형식이 나오니까 지원 목록은 넉넉하게 잡아야 한다.
파일명, 모듈명, 테스트 파일, pyproject.toml entry point, CLAUDE.md 문서까지 일괄 변경했다. 프로젝트 초기에 붙인 이름이 규모가 커지면서 맞지 않게 된 전형적인 케이스.
파이프라인 전체를 한 번에 실행하는 것 외에, GPU가 필요 없는 단계(오디오 추출, 교정, SRT 생성)를 별도 HTTP 엔드포인트로 분리했다.
@app.post("/run/extract-audio")
async def run_extract_audio(input_data: dict) -> dict:
"""Non-GPU step: extract audio from video file."""
...
@app.post("/run/correct")
async def run_correct(input_data: dict) -> dict:
"""Non-GPU step: text correction via Claude Code."""
...orjson도 이때 도입했다. 대용량 JSON 직렬화 성능이 표준 라이브러리보다 훨씬 빠르다.
이전에는 화자가 2명 이상일 때만 nospeaker 버전을 만들었다. 그런데 사용처에 따라 화자 표기 없는 버전이 항상 필요하다. speaker/nospeaker 두 버전을 항상 생성하도록 변경했다.
ASR, align, correction 각 단계에 time.monotonic() 기반 elapsed time 기록을 추가했다. 어디가 병목인지 숫자로 보이니까 최적화 방향이 명확해진다.
웹 UI가 매초 /transcribe/status/{taskId}를 폴링한다. access log가 켜져 있으면 journalctl이 폴링 로그로 가득 차서 실제 중요한 로그가 묻힌다. access_log=False로 해결.
폴링 기반 아키텍처에서는 access log 비활성화가 사실상 필수다.
가장 큰 변경이다. TranscriptionTask에 logs: list[str] 필드를 추가하고, 각 단계에서 모델 로딩 상태, 교정 진행률 등을 append한다.
# 모델 로딩 시
if logs is not None:
logs.append(f"모델 로딩: {name}...")
t_load = time.monotonic()
worker.start()
if worker.wait_ready():
elapsed = time.monotonic() - t_load
logs.append(f"모델 로딩: {name} 완료 ({elapsed:.1f}s)")
# 교정 진행률 - 마지막 항목 교체 패턴
seg_count = response_text.count('"index"')
if seg_count > _last_logged_seg:
if logs and logs[-1].startswith("교정 중..."):
logs[-1] = f"교정 중... {seg_count}/{len(segments)} segments"
else:
logs.append(f"교정 중... {seg_count}/{len(segments)} segments")/transcribe/status/{taskId} 응답에 logs를 포함시켜서, 프론트엔드가 폴링으로 진행 상황을 실시간 표시한다.
교정 단계처럼 반복 업데이트가 필요한 경우 logs[-1]을 교체하는 패턴이 깔끔하다. 배열의 마지막 항목만 갱신하면 프론트엔드에서 최신 상태를 자연스럽게 보여줄 수 있다.
덤으로 --skip-preload 플래그도 추가했다. systemd lazy-load 모드로, 서버 시작 시 GPU 모델 preload를 건너뛰고 첫 요청이 올 때 로딩한다. 개발할 때 서버 재시작이 잦은 경우에 유용하다.
하나하나는 작은 수정이지만, 이런 것들이 모여서 "쓸 수 있는 서비스"가 된다. 특히 상태 폴링 + 실시간 로그 패턴은 비동기 작업을 처리하는 다른 서비스에서도 그대로 재사용할 수 있다.
uvicorn이 graceful shutdown하는 동안 소켓을 유지하기 때문이다. 새 프로세스가 시작되려면 이전 프로세스가 완전히 종료돼야 하는데, 타이밍이 겹치면 포트를 못 잡는다. lsof로 기존 프로세스를 찾아 kill하는 게 확실한 해결책이다.
폴링 요청이 매초 들어오는 환경에서는 access log가 오히려 디버깅을 방해한다. 중요한 정보는 애플리케이션 로거로 별도 기록하고, HTTP 레벨 로그는 필요할 때만 켜는 게 낫다.
이미 상태 확인용 폴링 엔드포인트가 있고, 로그를 배열로 누적하면 구현이 단순하다. WebSocket은 연결 관리 복잡도가 올라가는데, 1초 간격 폴링이면 체감 지연이 거의 없다.