모델이 멈출 때: GPT‑5.2가 Opus 4.5가 할 수 없었던 '간단한' 스피너를 완성한 방법

발행: (2026년 1월 7일 오전 01:54 GMT+9)
15 min read
원문: Dev.to

Source: Dev.to

기능 요청: “이것은 간단해야 합니다”

“때때로 LLM 응답이 문장 중간에 멈춥니다. 그런 경우 기본 스피너를 표시하고, 더 많은 텍스트가 도착하면 제거하세요.”

웹 UI에서는 멈춤이 짜증나고, 터미널에서는 마치 충돌인 것처럼 보입니다.
우리가 필요했던 것은 미묘한 ⋯ waiting for more 표시였습니다—화려하지 않고, 시스템이 살아있고 대기 중이라는 명확한 신호일 뿐입니다.

상황: 시뮬레이션 스트리밍과 실제 정체 현상

Aye Chat은 Rich(Live)를 사용해 터미널 UI에 응답을 스트리밍합니다.
우리는 스트리밍을 시뮬레이션하기도 합니다: 공급자가 큰 청크를 반환하더라도, 출력이 단어 단위로 애니메이션되어 마치 “타이핑”하는 것처럼 보이게 합니다.

실제 공급자는 다음과 같이 동작합니다

  1. 일부 토큰을 받습니다.
  2. 간격이 발생합니다(LLM이 생각 중이거나, 서버 측 일시 정지, 백프레셔 등).
  3. 스트리밍이 재개됩니다.

그 간격이 문장 중간에 발생하면 사용자는 멈춘 것처럼 보입니다.

네 가지 겉보기에 깔끔한 요구사항

  • 스트리밍 중 정체를 감지한다.
  • 정체가 있을 때만 ⋯ waiting for more를 표시한다.
  • 새로운 텍스트가 도착하면 즉시 제거한다.
  • Markdown이나 최종 포맷을 깨지 않는다.

아키텍처 (그리고 왜 그것이 당신에게 거짓말을 할 수 있는가)

높은 수준에서 스트리밍 UI는 세 가지 구성 요소로 이루어져 있습니다:

구성 요소역할
update(content: str)새로운 스트리밍 콘텐츠가 도착했을 때 호출됩니다 (전체 누적 콘텐츠이며, 델타가 아닙니다).
_animate_words(new_text: str)새로 받은 텍스트를 단어 단위로 약간의 지연을 두고 출력합니다.
Background monitor thread주기적으로 우리가 “정체” 상태인지 판단합니다.

렌더링은 다음과 같은 헬퍼를 통해 수행됩니다:

def _create_response_panel(
    content: str,
    use_markdown: bool = True,
    show_stall_indicator: bool = False,
) -> Panel:
    # …build a Rich Panel…
    if show_stall_indicator:
        content += "\n⋯ waiting for more"
    return Panel(content)

show_stall_indicator=True일 때, 스피너 라인이 추가됩니다.

“Stalled”가 실제 의미하는 바

스톨에는 두 가지 종류가 있습니다:

  1. 네트워크 스톨 – LLM으로부터 새로운 콘텐츠가 도착하지 않음.
  2. 사용자 가시 스톨 – 화면에 아무것도 변하지 않음(UI가 최신 데이터에 맞춰 잡힘).

이들은 의도적으로 렌더링을 지연시키는 시스템에서는 동일하지 않습니다.

Opus 4.5가 멈춘 곳: 기계가 아니라 증상을 고치기

Claude Opus 4.5는 첫 번째 부분을 빠르게 처리했습니다:

  • 타임스탬프 추가,
  • 경과 시간 모니터링,
  • 임계값 초과 시 표시기 표시.

작동했지만… 더 이상 작동하지 않았습니다. 스피너가 단어가 아직 출력되는 동안에도 잠깐 깜빡였습니다.

왜?
정지 감지기는 마지막 네트워크 업데이트 이후 경과 시간을 보고 있었고, UI는 여전히 버퍼된 단어를 애니메이션하고 있었습니다. Opus는 표시기를 억제하기 위해 _is_animating 플래그를 추가하려 했지만 문제가 지속되었습니다.

실제 문제는 동일한 UI에 대한 두 개의 동시 작성자였습니다:

  • 애니메이션 경로는 단어를 출력하면서 Live.update()를 호출합니다.
  • 모니터 스레드는 표시기를 토글하면서 Live.update()를 호출합니다.

직렬화가 없으면 일관되지 않은 중간 프레임이 렌더링되어 스피너가 깜빡이는 것처럼 보입니다.

Opus는 지역 최적해에 갇혔습니다:

  • 타이밍 문제로 간주,
  • 단일 불리언으로 간주,
  • 가드를 계속 추가.

우리가 실제로 필요했던 것은 상태 + 동기화였습니다.

GPT‑5.2가 다르게 한 일: 단일 렌더러를 가진 상태 머신처럼 다루기

GPT‑5.2는 영리함으로 승리한 것이 아니라 엄격함으로 승리했습니다. 세 가지 결정적인 변화를 도입했습니다.

1️⃣ 공유 상태와 모든 UI 업데이트 직렬화

잠금 생성:

self._lock = threading.RLock()

규칙: 공유 상태에 접근하거나 Live.update()를 호출하는 모든 코드는 반드시 잠금을 보유해야 합니다.

렌더링을 단일 헬퍼로 중앙 집중화:

def _refresh_display(
    self,
    use_markdown: bool = False,
    show_stall: bool = False,
) -> None:
    with self._lock:
        if not self._live:
            return

        self._live.update(
            _create_response_panel(
                self._animated_content,
                use_markdown=use_markdown,
                show_stall_indicator=show_stall,
            )
        )
        self._showing_stall_indicator = show_stall

이제 한 번에 하나의 스레드만 UI를 수정할 수 있어 “두 스레드가 프레임 버퍼를 두고 싸우면서 깜박이는” 버그를 없앨 수 있습니다.

2️⃣ “정지(Stall)”를 “따라잡았고 새로운 입력이 없음”으로 재정의

정지는 다음 조건을 만족할 때만 발생해야 합니다:

  1. 현재 애니메이션 중이 아니며, 그리고
  2. 애니메이션된 출력이 받은 내용에 따라잡혔을 때.
caught_up = (not self._is_animating) and (
    self._animated_content == self._current_content
)

caught_upTrue이고 설정 가능한 타임아웃이 경과하면 시스템이 정지된 것으로 판단하고 표시기를 보여줍니다.

3️⃣ 표시기를 순수하게 UI 측에만 두기

모니터 스레드는 이제 원하는 정지 상태만 설정하고, 실제 렌더링 결정은 _refresh_display에 맡깁니다. 이 분리는 UI가 유휴 상태일 때 정확히 스피너가 나타나고, 새로운 텍스트가 도착하는 순간 바로 사라지도록 보장합니다.

def _monitor_stall(self):
    while self._running:
        time.sleep(self._check_interval)
        with self._lock:
            if self._should_show_stall():
                self._refresh_display(show_stall=True)
            else:
                self._refresh_display(show_stall=False)

Result: A Boring‑But‑Correct Spinner

  • No flickering.
  • No Markdown corruption.
  • Accurate “waiting for more” signal that only shows when the UI is truly idle.

The whole episode turned a “simple” feature request into a lesson about state machines, synchronization, and the importance of modeling the whole system—not just its symptoms.

“단어가 아직 출력 중인데도 표시기가 켜지는” 버그 수정

아래의 단일 정의는 버퍼에 남아 있는 단어가 아직 출력 중일 때도 정지 표시기가 켜지는 원래 문제를 해결합니다.

Note: UI가 아직 버퍼에 남은 단어를 소모하고 있다면, 정지된 것이 아니라 바쁜 상태입니다.

3) **“마지막 수신 시간”**을 “마지막 렌더링 시간” 대신 사용하기

첫 번째 문제를 해결한 뒤, 두 번째, 더 미묘한 버그가 나타났습니다:

스트리밍이 실제로 일시 정지되었을 때, 표시기가 깜박이는 대신 계속 켜져 있어야 합니다.

이는 실시간 UI 코드에서 흔히 발생하는 실수로, 진행 타임스탬프를 다시 그릴 때마다 업데이트하면 표시기가 스스로 취소됩니다.

해결책 (GPT‑5.2)

개념을 분리합니다:

# 새로운 스트림 콘텐츠가 도착할 때만 업데이트
self._last_receive_time: float = 0.0

콘텐츠가 실제로 변경될 때 오직 update() 안에서만 업데이트합니다:

with self._lock:
    if content == self._current_content:
        return
    self._last_receive_time = time.time()

모니터는 다음과 같이 확인합니다:

time_since_receive = time.time() - self._last_receive_time
should_show_stall = time_since_receive >= self._stall_threshold

결과: 표시기가 올바른 방식으로 “고정”됩니다:

  • 임계값을 초과하면 켜지고,
  • 계속 켜진 상태를 유지하며,
  • 새로운 텍스트가 도착하면 즉시 꺼집니다.

최종 모니터 루프 (작동하는 지루한 버전)

def _monitor_stall(self) -> None:
    while not self._stop_monitoring.is_set():
        if self._stop_monitoring.wait(0.5):
            break

        with self._lock:
            if not self._started or not self._animated_content:
                continue

            caught_up = (not self._is_animating) and (
                self._animated_content == self._current_content
            )
            if not caught_up:
                continue

            time_since_receive = time.time() - self._last_receive_time
            should_show_stall = time_since_receive >= self._stall_threshold

            if should_show_stall != self._showing_stall_indicator:
                self._live.update(
                    _create_response_panel(
                        self._animated_content,
                        use_markdown=False,
                        show_stall_indicator=should_show_stall,
                    )
                )
                self._showing_stall_indicator = should_show_stall

주요 속성

  • 버퍼링된 단어가 아직 애니메이션 중일 때는 표시기가 없습니다.
  • 표시기는 stall_threshold 동안 새로운 콘텐츠가 도착하지 않을 때 나타납니다.
  • 표시기가 한 번 나타나면 계속 켜진 상태를 유지합니다.
  • 새로운 텍스트가 도착하면 표시기가 즉시 사라집니다.

스피너는 더 이상 “기능”이 아니라 인프라가 됩니다 – 바로 터미널 UX가 그래야 하는 모습입니다.

실제 주제: 모델 교체가 디버깅 도구인 이유

저는 “모델 전쟁”에 관심이 있는 것이 아니라, 실제로 모델을 사용해 구축할 때의 현실적인 부분에 관심이 있습니다:

모델강점
Opus 4.5요청 시 설득력 있는 구현을 빠르게 생성하고 구조를 정리하지만, 점진적인 수정에만 머무르는 경향이 있습니다.
GPT‑5.2한 발 물러서서 “두 명의 작가 + 모호한 대기 정의” 문제를 파악하고, 이를 직렬화된 렌더링을 갖는 작은 상태 머신으로 해결하도록 강제합니다.

이는 어느 한 모델이 추상적으로 “더 좋다”는 의미가 아니라, 특정 디버깅 상황에서 더 유용하다는 뜻입니다. 모델이 반복에 빠지면 대화 형태를 바꾸거나 모델 자체를 바꾸세요. Aye Chat에서는 모델 전환이 저비용이며, “저비용”은 10번 중 1번만 재현되는 UI 레이스 컨디션에 갇혔을 때 큰 차이를 만듭니다.

Takeaways (and how they match Aye Chat’s philosophy)

  • 스피너는 그에 비해 과도하게 큰 정확성 표면 영역을 가지고 있다.
    애니메이션 + 모니터링 + 동시 렌더링은 실제 시스템이다.

  • “Stall”은 상태이지, 타임아웃이 아니다.
    ‘잡힌 상태이며 새로운 입력이 없다’는 의미여야 하고, ‘시간이 흐른 것’이 의미가 아니다.

  • 렌더링이 렌더링 여부를 결정하는 시계를 업데이트하도록 하지 마라.
    그게 깜빡임을 만들게 된다.

  • 여러 스레드가 렌더링할 수 있을 때 락은 선택 사항이 아니다.
    아무것도 충돌하지 않더라도 사용자 경험이 악화될 것이다.

  • 모델 선택은 툴체인의 일부이다.
    한 모델이 지역적인 수정에 갇히면, 다른 모델은 전체적인 형태를 볼 수 있다.

이상하게도, 이 작은 ⋯ waiting for more 표시기는 낙관적인 워크플로와 같은 교훈을 가르친다:

  1. 시스템을 빠르게 움직이게 하라,
  2. 즉시 복구할 수 있도록 구축하라,
  3. 당신을 막힌 상황에서 벗어나게 하는 도구(모델 포함)에 대해 실용적으로 접근하라.

Aye Chat 소개

Aye Chat은 오픈‑소스이며 AI‑기반 터미널 워크스페이스로, AI를 명령‑라인 워크플로에 직접 통합합니다. 파일을 편집하고, 명령을 실행하며, 코드베이스와 터미널을 떠나지 않고 채팅할 수 있습니다.

지원하기

  • ⭐ 우리 GitHub 저장소에 별표를 달아 주세요
  • 소문을 퍼뜨리세요. 터미널에서 일하는 팀원과 친구들에게 Aye Chat을 공유하세요
Back to Blog

관련 글

더 보기 »