터미널 뷰어는 겉으로 보기엔 로그 창입니다. 하지만 실제 구현은 단순한 텍스트 append와는 거리가 멉니다. Prompt Combo에서는 여러 LLM의 스트림이 동시에 들어오기 때문에, 정렬과 렌더링과 스크롤 을 모두 같이 다뤄야 했습니다.
가장 먼저 부딪힌 문제
처음 구현은 이벤트가 올 때마다 DOM에 한 줄씩 추가하는 방식이었습니다. 데모에서는 괜찮았지만, 실제로 Claude와 GPT와 Gemini를 동시에 붙이자 바로 느려졌습니다.
문제는 세 가지였습니다.
- 너무 잦은 렌더링
- 자동 스크롤 충돌
- 오래 열린 세션의 메모리 증가
스트림을 바로 그리지 않고 버퍼링했다
해결의 출발점은 "이벤트가 올 때마다 그리지 않는다"였습니다. 각 워커 스트림은 메모리 버퍼에 먼저 쌓고, 짧은 간격으로 병합해 화면에 반영했습니다.
const flushInterval = 48
setInterval(() => {
const chunk = drainBufferedEvents()
if (chunk.length) appendToTimeline(chunk)
}, flushInterval)
48ms는 아주 정교한 숫자는 아니지만, 체감상 실시간으로 보이면서도 렌더링 횟수를 크게 줄여줬습니다.
스크롤은 "유저가 따라가고 있을 때만" 자동으로
자동 스크롤도 단순하면 문제가 생깁니다. 사용자가 위쪽 로그를 읽고 있을 때 아래로 계속 끌어당기면 UI가 바로 짜증납니다.
그래서 마지막 근처에 머물러 있을 때만 자동 스크롤을 유지하도록 바꿨습니다.
- 하단 근처면 자동 추적
- 위로 올리면 추적 해제
- 다시 맨 아래로 내려오면 추적 재개
이 패턴 하나만으로 뷰어가 훨씬 안정적으로 느껴졌습니다.
병합 기준은 도착 순서가 아니라 타임라인
각 모델의 응답 속도는 다릅니다. 단순히 먼저 도착한 순서대로 섞으면 실제 실행 흐름이 이해되지 않는 경우가 생깁니다. 그래서 내부 이벤트는 공통 timestamp를 기준으로 다시 정렬했습니다.
물론 네트워크 지연 때문에 완전한 절대 순서를 보장할 수는 없습니다. 하지만 사용자 관점에서는 "무엇이 먼저 시작되고, 무엇이 그 뒤에 이어졌는가" 를 읽을 수 있어야 했고, 타임라인 정렬이 그 요구를 잘 만족했습니다.
긴 세션은 접는다
오래 실행하는 사용자를 위해서는 메모리 관리가 필요했습니다. 모든 로그를 끝까지 DOM에 들고 있으면 결국 브라우저든 Electron이든 버벅입니다.
그래서 오래된 구간은 접힌 블록으로 요약하고, 필요할 때만 펼치게 했습니다. 화면에는 필요한 밀도만 남기고, 원본 로그는 파일로 보존하는 방식입니다.
결국 터미널 뷰어의 핵심은 예쁜 UI보다 읽을 수 있는 속도와 남아있는 기록 이었습니다. 이 기준으로 설계를 단순화하니 구현 판단도 훨씬 쉬워졌습니다.