스트리밍 콘텐츠를 위한 안정적인 인터페이스 설계하기
요약
스트리밍 데이터가 유입되는 실시간 인터페이스 설계 시 발생하는 스크롤 제어, 레이아웃 시프트, 렌더링 빈도 문제를 다룹니다. 콘텐츠가 지속적으로 확장됨에 따라 사용자의 읽기 경험을 방해하지 않고 안정적인 UI를 유지하는 방법론을 제시합니다.
핵심 포인트
- 스크롤 관리: 사용자가 위로 스크롤할 때 인터페이스가 강제로 하단으로 이동시키는 문제를 해결해야 함
- 레이아웃 시프트 방지: 콘텐츠 성장에 따라 하단 요소들의 위치가 급격히 변하는 현상을 제어해야 함
- 렌더링 최적화: 스트림 데이터의 빠른 유입 속도와 브라우저의 렌더링 빈도 사이의 불일치로 인한 성능 저하를 고려해야 함
- 사용자 경험(UX) 중심 설계: 인터페이스가 사용자의 주의력을 임의로 결정하지 않고 상호작용을 방해하지 않도록 설계해야 함
이제 더 많은 인터페이스가 응답이 생성되는 동안 렌더링(render)됩니다. UI는 하나의 상태로 시작하여, 더 많은 데이터가 들어옴에 따라 업데이트됩니다. 여러분은 채팅 앱, 로그(logs), 전사(transcription) 도구 및 기타 실시간 시스템에서 이를 볼 수 있습니다.
까다로운 점은 인터페이스가 고정된 상태가 아니라는 것입니다. 새로운 콘텐츠가 들어옴에 따라 계속해서 변화합니다. 줄이 길어지고 새로운 블록이 나타나면서 인터페이스가 확장됩니다. 방금 화면 바로 아래에 있던 것이 갑자기 이동할 수 있으며, 사용자의 스크롤(scroll) 위치를 관리하기가 더 어려워집니다. 사용자가 이미 상호작용하고 있는 동안 UI의 일부가 불완전할 수도 있습니다.
이 글에서는 간단한 인터페이스를 가져와 이를 적절하게 처리하는 방법을 다룰 것입니다. 어떻게 하면 안정성을 유지하고, 스크롤을 관리하며, 읽기 경험을 해치지 않고 부분적인 콘텐츠를 렌더링할 수 있는지 살펴보겠습니다.
스트리밍 UI는 실제로 어떤 모습인가?
저는 서로 다른 방식으로 콘텐츠를 스트리밍하는 세 가지 데모를 제작했습니다: 채팅 버블(chat bubble), 로그 피드(log feed), 그리고 전사 뷰(transcription view)입니다. 겉보기에는 달라 보이지만, 모두 동일한 세 가지 문제에 직면합니다.
첫 번째는 **스크롤(scroll)**입니다. 콘텐츠가 스트리밍되는 동안 대부분의 인터페이스는 뷰포트(viewport)를 하단에 고정합니다. 단순히 지켜보고만 있다면 괜찮지만, 무언가를 읽기 위해 스크롤을 위로 올리는 순간 페이지가 다시 아래로 튕겨 내려갑니다. 여러분은 그것을 요청하지 않았습니다. 인터페이스가 여러분 대신 결정해 버렸고, 이제 여러분은 읽는 대신 인터페이스와 싸워야 합니다.
두 번째는 **레이아웃 시프트(layout shift)**입니다. 콘텐츠가 스트리밍된다는 것은 컨테이너(container)가 끊임없이 성장한다는 것을 의미하며, 성장에 따라 그 아래의 모든 것이 아래로 밀려납니다. 클릭하려던 버튼이 더 이상 원래 위치에 있지 않습니다. 읽고 있던 줄이 이동합니다. 페이지가 고장 난 것은 아닙니다. 단지 편안하게 상호작용할 수 있을 만큼 아무것도 가만히 머물러 있지 않을 뿐입니다.
세 번째는 **렌더링 빈도 (render frequency)**입니다. 브라우저는 초당 약 60회 화면을 그립니다(paint). 하지만 스트림은 그보다 훨씬 빠르게 도착할 수 있습니다. 이는 페이지의 모든 것을 나타내는 브라우저의 내부 표현 방식인 DOM (Document Object Model)이 사용자가 실제로 보지 못할 프레임에 대해서까지 업데이트를 수행하게 된다는 것을 의미합니다. 각 업데이트는 여전히 비용을 발생시키며, 이 비용은 성능이 저하되기 시작할 때까지 조용히 쌓여갑니다.
각 데모를 진행하면서 무언가 어색하게 느껴지기 시작하는 지점에 주목해 보세요. 인터페이스가 사용자의 방해가 되기 시작하는 그 작은 마찰의 순간 말입니다. 이것이 바로 우리가 해결하고자 하는 문제입니다.
예시 1: 스트리밍 AI 채팅 응답
가장 친숙한 사례입니다. Stream 버튼을 클릭하면, 일반적인 AI 채팅 인터페이스처럼 메시지가 토큰(token) 단위로 하나씩 늘어나기 시작합니다.
다음 사항들을 시도해 보시기 바랍니다:
- Stream 버튼을 클릭합니다.
- 메시지가 스트리밍되는 동안 위로 스크롤해 봅니다.
- 속도를 높여 봅니다 (예: 10ms).
미묘하지만 중요한 점을 발견하게 될 것입니다. UI가 계속해서 당신을 아래로 끌어내리려 한다는 점입니다. 기본적으로, UI가 당신의 주의가 어디에 머물러야 하는지에 대해 대신 결정을 내리고 있는 것입니다.
이것은 하나의 예시일 뿐입니다. 다른 사례를 살펴보겠습니다.
예시 2: 로그 뷰어에서의 실시간 처리
이 예시는 겉보기에는 달라 보이지만, 문제는 첫 번째 예시와 매우 유사합니다. 시간이 지남에 따라 메시지가 길어지는 대신, 터미널이나 로그 스트림처럼 새로운 줄이 지속적으로 추가됩니다.
여기서 흥미로운 부분은 'tail' 토글(toggle)입니다. 이는 상호작용과 안정적인 인터페이스 사이의 트레이드오프 (trade-off)를 매우 명확하게 보여줍니다.
다시 한번, 다음 사항들을 시도해 보시기 바랍니다:
- Start 버튼을 클릭합니다.
- 로그가 컨테이너의 높이를 넘어 스트리밍되도록 둡니다.
- 맨 처음 부분으로 스크롤을 올립니다.
- 스트리밍을 중단하고 "tail" 옵션을 비활성화합니다.
'tail'이 활성화되어 있을 때는 UI가 새로운 콘텐츠를 따라간다는 점을 확인하세요. 하지만 사용자는 위로 스크롤하여 그 자리에 머물러 있을 수 없습니다. 대신, 콘텐츠를 탐색하려면 스트리밍을 중단하거나 "tail" 옵션을 활성화해야만 합니다.
예시 3: 실시간 지표를 표시하는 대시보드
이 경우, UI는 제자리에서 업데이트됩니다:
- 숫자가 변경되고,
- 차트가 움직이며,
- 값이 지속적으로 새로고침됩니다.
이번에는 스크롤 텐션 (scroll tension) 문제가 발생하지 않지만, 다른 문제가 나타납니다. 그 내용은 다음 섹션에서 다루겠습니다.
UI가 불안정하게 느껴지는 이유와 해결 방법
만약 채팅 데모를 시도하면서 응답이 들어오는 동안 위로 스크롤했다면, 첫 번째 문제를 즉시 발견했을 수도 있습니다. UI가 업데이트될 때마다 최신 스트리밍 콘텐츠로 사용자를 계속 끌어내리는 현상입니다. 이는 사용자를 문맥 (context)에서 벗어나게 만들며, 지나간 콘텐츠를 완전히 소화할 시간을 전혀 주지 않습니다.
두 번째 예시인 로그 뷰어 (log viewer)에서도 정확히 동일한 문제를 볼 수 있습니다. tail 토글이 없다면, 스트리밍되는 콘텐츠가 사용자의 스크롤 위치를 덮어써 버립니다.
이것들은 코드 에러를 발생시키는 전통적인 의미의 버그는 아닙니다. 오히려 모든 사용자에게 영향을 미치는 접근성 (accessibility) 문제입니다. 하지만 작업을 계획하고 테스트할 때 세심한 UX 고려 사항을 적용한다면, 이러한 문제들은 수정하고 예방할 수 있습니다.
예측 가능한 스크롤 동작 보장하기
우리의 목표는 다음과 같습니다:
- 사용자가 스트림의 맨 아래에 있다고 감지되면 자동 스크롤 (auto-scrolling)을 활성화합니다.
- 사용자가 위로 스크롤하면 자동 스크롤을 중단합니다.
- 사용자가 다시 스트림의 맨 아래로 스크롤하면 자동 스크롤을 재개합니다.
이를 위해서는 사용자가 의도적으로 맨 아래에서 벗어났는지 알아야 하며, 스크롤 위치가 수동으로 변경되었을 때 사용자가 의도적으로 움직였다고 가정할 수 있습니다. 우리는 플래그 (flag)를 사용하여 해당 동작을 추적할 수 있습니다.
let userScrolled = false;
chatEl.addEventListener('scroll', () => {
const gap = chatEl.scrollHeight
...
그 60px 임계값 (threshold)이 중요합니다. 이 값이 없다면, 사용자가 실제로 스크롤하지 않았더라도 아주 작은 레이아웃 변경(예: 새로운 줄 추가)만으로도 일시적인 간격이 생겨 자동 스크롤이 깨질 수 있습니다.
이제 사용자의 스크롤 위치가 스트림의 스크롤 높이(scroll height)와 일치할 때, 즉 사용자가 스트림의 맨 아래에 있을 때만 자동 스크롤이 활성화되도록 만들어 보겠습니다:
function autoScroll() {
if (!userScrolled) {
chatEl.scrollTop = chatEl.scrollHeight;
...
간과하기 쉬운 작은 점 하나가 있습니다. 새로운 스트림 (stream)이 시작되면 userScrolled를 반드시 초기화해야 합니다. 그렇지 않으면 이전 메시지에서의 스크롤 한 번이 다음 메시지의 자동 스크롤 (auto-scroll) 기능을 조용히 비활성화할 수 있습니다.
레이아웃 안정성 강화하기
첫 번째 예시에서도 확인했듯이, 새로운 콘텐츠가 스트리밍 (streaming)됨에 따라 레이아웃이 튀거나 (jumps) 이동하여 (shifts) 현재의 문맥 (context)에서 벗어나게 만듭니다. 무엇이 이동하는지 구체적으로 말하자면, 광범위한 의미의 페이지 레이아웃이 아니라 채팅 버블 (chat bubble) 바로 아래에 있는 콘텐츠입니다.
코드를 살펴보기 전에 언급할 만한 더 미묘한 현상이 하나 더 있습니다. 바로 커서 깜빡임 (cursor flicker)입니다. 매 틱 (tick)마다 innerHTML을 지우고 모든 요소를 다시 생성하기 때문에, 빠른 속도에서는 초당 최대 80회까지 커서가 계속해서 삭제되고 다시 추가됩니다.
정상적인 속도에서는 놓치기 쉽지만, 슬라이더를 약 30ms 정도로 늦추면 텍스트 끝에서 희미하지만 지속적인 깜빡임을 볼 수 있습니다. 재빌드 (rebuild) 패턴을 수정하면 이 깜빡임은 완전히 사라집니다.
그 재빌드 패턴이 바로 여기에 있습니다. 이것이 들어오는 모든 문자마다 실행되는 코드입니다:
bubble.innerHTML = '';
fullText.split('\n').forEach(line => {
const p = document.createElement('p');
...
이 방식은 작동하지만 비용이 많이 듭니다 (expensive). 매 업데이트마다 DOM을 지우고 다시 빌드하므로, 매번 레이아웃 재계산 (layout recalculation)을 강제합니다.
이제 라이브 노드 (live node)에 직접 작성해 보겠습니다:
let currentP = null;
function initBubble(bubble, cursor) {
currentP = document.createElement('p');
...
다음으로 할 수 있는 일은 빈 텍스트 노드 (text node)를 가진 단락 (paragraph) 하나를 생성하여 커서 앞에 삽입하는 것입니다. 이렇게 하면 직접 써넣을 수 있는 라이브 노드를 확보할 수 있습니다.
그다음, 도착하는 각 문자에 대해 다음과 같이 처리합니다:
function appendChar(char, bubble, cursor) {
if (char === '\n') {
currentP = document.createElement('p');
...
일반적인 문자의 경우, 텍스트 노드(text node)를 한 글자만큼 확장합니다. 브라우저는 이를 위해 레이아웃(layout)을 다시 계산할 필요가 없습니다. 텍스트가 늘어났을 뿐, 다른 요소가 움직이지는 않기 때문입니다. 줄바꿈(newline)의 경우, 새로운 단락(paragraph)을 생성하고 currentP를 앞으로 이동시킵니다. 해당 새 단락에 대해 레이아웃이 한 번 재계산되면 모든 작업이 완료됩니다.
렌더링 빈도 (Render Frequency)
이 문제는 첫 번째 예시인 채팅 UI에서 가장 눈에 띄게 나타납니다. 스크롤을 하고 레이아웃이 고정되어 있더라도, 우리는 여전히 들어오는 모든 문자마다 DOM에 쓰고 있습니다.
스트림(stream)이 빠르게 움직일 때, 실제로는 중요하지 않은 업데이트로 DOM을 계속해서 두드리는(hammering) 상황이 발생합니다. 해결책은 간단합니다. 들어오는 텍스트를 즉시 쓰는 대신 버퍼(buffer)에 보관하는 것입니다. 충분한 양이 모이면 한 번에 모두 DOM에 작성합니다. 이것이 바로 **플러시 (flush)**입니다.
이를 구현하기 위해, 우리는 간단한 버퍼를 유지하고 한 번에 단 하나의 업데이트만 예약되도록 보장합니다. 업데이트가 실행되면, requestAnimationFrame이 쌓여 있는 모든 내용을 가져와 한 번에 DOM에 작성합니다.
let pending = '';
let rafQueued = false;
새로운 문자가 스트림으로 들어오면 버퍼에 추가합니다. 아직 예약된 플러시가 없다면, 하나를 예약합니다.
function onChar(char) {
pending += char;
if (!rafQueued) {
...
rafQueued 플래그가 중요합니다. 이 플래그가 없다면 모든 문자가 각자의 프레임을 예약하게 되어, 수십 번의 불필요한 플러시가 발생하게 됩니다.
플러시가 실행되면, 한 번의 통과(pass)로 전체 버퍼를 비웁니다.
function flush() {
for (const char of pending) {
appendChar(char);
...
마지막 프레임 이후에 도착한 모든 문자는 브라우저가 화면을 그리기(paint) 직전에 함께 렌더링됩니다. 그 후 버퍼를 비우고, 플래그를 초기화하며, 자동 스크롤을 한 번 실행합니다.
let userScrolled = false;
chatEl.addEventListener('scroll', () => {
const gap = chatEl.scrollHeight
...
간격(gap)이 작으면 자동 스크롤(auto-scrolling)을 유지합니다. 간격이 커지면 사용자가 위로 스크롤했다고 가정하고 중단합니다. 이 작은 임계값(threshold)은 새로운 줄이 추가되어 높이가 미세하게 변할 때 발생하는 지터(jitter, 떨림) 현상을 방지하는 데 도움이 됩니다. 또한, 새로운 스트림(stream)이 시작될 때 userScrolled를 초기화하는 것을 잊지 마세요.
스크롤이 제어되면 또 다른 문제가 명확해집니다. 메시지가 길어짐에 따라 계속해서 위치가 밀려납니다:
- 처음에는 한 줄로 시작하지만,
- 확장되면서,
- 그 아래에 있는 모든 요소를 밀어냅니다.
기술적으로 무엇인가가 고장 난 것은 아니지만, 안정적인 느낌이 들지 않습니다. 흔히 사용하는 방식은 매 업데이트마다 전체 메시지를 다시 구축(rebuild)하는 것입니다:
bubble.innerHTML = '';
fullText.split('\n').forEach(line => {
const p = document.createElement('p');
...
이 방식은 작동하지만, 너무 많은 작업을 수행합니다. 매 업데이트마다 DOM을 파괴하고 다시 구축하므로, 매번 레이아웃 재계산(layout recalculation)을 강제합니다. 이것이 모든 것이 계속 밀려나는 이유입니다. 핵심 아이디어는 현재의 단락(paragraph)에 글자를 써 내려가다가, 실제로 줄 바꿈(line break)이 발생할 때만 새로운 단락을 생성하는 것입니다.
let currentP = null;
function initBubble(bubble, cursor) {
currentP = document.createElement('p');
...
그런 다음 글자 단위로 업데이트합니다:
function appendChar(char, bubble, cursor) {
if (char === '\n') {
currentP = document.createElement('p');
...
이제 더 이상 모든 것을 다시 구축하지 않습니다. 대부분의 업데이트는 단순히 텍스트 노드(text node)를 확장할 뿐이며, 이는 비용이 저렴하고 큰 레이아웃 변화(layout shifts)를 유발하지 않습니다. 또한, 커서를 제거했다가 다시 추가하지 않기 때문에 이전에 관찰되었을 수 있는 미세한 커서 깜빡임(flicker) 현상도 해결됩니다.
이 시점에서 UI는 이미 훨씬 나아진 느낌을 주지만, 여전히 미묘한 문제가 남아 있습니다. 우리는 여전히 매 글자마다 DOM을 업데이트하고 있습니다. 속도가 빨라지면 이는 수많은 작은 업데이트가 되어, 실제로 눈에 보이지도 않는 업데이트가 너무 많아지게 됩니다.
즉시 렌더링하는 대신, 들어오는 글자들을 버퍼(buffer)에 담아두었다가 프레임(frame)당 한 번씩 적용할 수 있습니다.
let pending = '';
let rafQueued = false;
function onChar(char) {
...
이 시점에서 우리는 아직 DOM (Document Object Model)을 건드리지 않고, 문자들이 도착하는 대로 수집만 하고 있습니다. 그런 다음, 다음 프레임이 그려지기 직전에 모든 것을 한꺼번에 쏟아냅니다 (flush):
function flush() {
for (const char of pending) {
appendChar(char);
...
이것은 이전에 서로 묶여 있던 두 가지 요소를 분리합니다:
- 데이터가 도착하는 속도,
- UI가 업데이트되는 시점.
결과는 동일해 보이지만, 브라우저가 수행하는 작업량이 줄어들어 특히 스트림 속도가 빠를 때 UI가 더 부드럽게 느껴집니다.
이러한 변화 중 어느 하나도 그 자체로 큰 노력은 아닙니다. 하지만 일단 이것들이 적용되면, 인터페이스는 모든 업데이트에 맹목적으로 반응하는 것을 멈춥니다. 콘텐츠가 여전히 지속적으로 들어오고 있음에도 불구하고, 읽기 쉬워지고, 제어하기 쉬워지며, 주의를 분산시키는 요소가 훨씬 줄어듭니다.
안정적이고 예측 가능하며 좋은 사용자 경험 (UX)을 보장하기 위해 고려해야 할 사항은 더 많이 있습니다. 예를 들어, 스트림이 흐름 중간에 취소되면 어떻게 될까요? 그리고 동작 줄이기 (reduced motion), 키보드 탐색 (keyboard navigation), 스크린 리더 접근성 (screen reader accessibility)과 같은 사용자 설정이 존중되도록 하려면 무엇을 할 수 있을까요? 다음 단계에서 이 내용들을 살펴보겠습니다.
중단된 스트림 처리하기
AI 자동 생성 콘텐츠
본 콘텐츠는 Smashing Magazine의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기