The Browser Already Has a Priority System. Your debounce(300) Does Not.
브라우저에는 이미 우선순위 시스템이 있습니다. debounce(300)에는 없습니다
디바운스는 검색을 잠시 미룰 뿐, 실행을 시작한 뒤 메인 스레드를 오래 점유하는 문제는 해결하지 않습니다. 글은 브라우저의 작업 우선순위와 yield를 활용해 긴 작업을 나누고, 취소와 성능 측정을 함께 적용하는 방법을 설명합니다.
- 주제
에디터 노트
디바운스와 yield는 해결하는 문제가 다릅니다. 디바운스는 아직 실행하지 않은 검색을 건너뛰고, yield는 시작한 작업이 메인 스레드를 독점하지 않도록 나눕니다. 서버 검색에서는 댓글이 짚었듯 요청량 제어가 디바운스의 역할이므로 두 기법을 대립시킬 이유가 없습니다. 50밀리초는 긴 작업을 찾는 기준이지 매끄러운 화면을 보장하는 프레임 예산이 아닙니다. 댓글의 60Hz 기준인 약 16.7밀리초를 고려하면 제시된 예시는 입력이 끼어들 틈을 만드는 데 초점을 둡니다. 실제 화면의 프레임 상태까지 보장하려면 작업 조각의 길이와 렌더링 비용을 별도로 측정해야 합니다. 서드파티 스크립트가 긴 작업의 원인일 수 있다는 경험담도 있어 코드 최적화 전에 측정 대상을 넓게 잡는 편이 타당합니다.
AI 요약
검색 입력이 버벅일 때 디바운스(debounce)만 추가하면 멈춤 시점이 타이핑 중에서 입력을 멈춘 직후로 옮겨갈 수 있습니다. 필터링 작업 전체가 메인 스레드에서 하나의 긴 작업으로 실행되기 때문입니다. 메인 스레드는 입력 처리와 JavaScript 실행, 스타일 계산, 레이아웃, 페인트를 차례로 처리합니다. 긴 루프가 실행 중이면 클릭이나 다음 키 입력이 느리게 처리되는 게 아니라 대기열에 서게 됩니다.
작업을 나눠 브라우저에 제어권 돌려주기
글은 메인 스레드 작업이 50밀리초를 넘으면 긴 작업(long task)으로 분류된다고 설명합니다. 이때 프레임을 놓치고 입력 반응도 늦어질 수 있습니다. 브라우저의 Prioritized Task Scheduling API는 작업에 user-blocking, user-visible, background 우선순위를 부여합니다. 분석 전송처럼 사용자의 현재 동작보다 덜 급한 일은 background로 예약할 수 있습니다.
이미 실행 중인 작업에는 scheduler.yield()를 호출해 브라우저에 제어권을 넘길 수 있습니다. 브라우저는 페인트나 새 키 입력처럼 더 급한 일을 먼저 처리한 뒤 중단한 함수 실행을 재개합니다. 글은 행마다 yield하는 대신 performance.now()로 마감 시간을 확인해 약 50밀리초마다 작업을 나누는 예시를 제시합니다. 필터 결과는 모든 조각이 끝난 뒤 한 번만 그려도 됩니다. 매 조각마다 화면을 다시 그리면 레이아웃과 페인트가 잦아져 다른 성능 문제가 생길 수 있습니다.
글의 호환성 설명에 따르면 scheduler.yield()는 Chrome과 Edge 129 이상, Firefox 142 이상에서 지원되며 Safari에는 없습니다. 지원하지 않는 환경에서는 setTimeout(0)을 대안으로 제시하지만 두 방식의 우선순위 동작은 다릅니다. yield로 재개하는 작업은 user-visible 우선순위로 이어지고, postTask() 안에서 호출했다면 기존 우선순위를 유지합니다. 반면 setTimeout(0)은 매크로태스크 대기열 뒤에 놓여 다른 타이머나 네트워크 콜백보다 늦게 실행될 수 있습니다.
취소와 측정도 함께 적용하기
작업을 나눠도 이전 검색을 취소하지 않으면 새 입력이 들어올 때마다 오래된 검색이 다시 실행됩니다. 예시는 새 입력마다 AbortController를 만들고 이전 검색을 중단합니다. yield 뒤에도 abort 신호를 확인해야 취소된 검색이 오래된 결과를 화면에 그리지 않습니다.
성능 측정에는 Long Animation Frames API를 소개합니다. PerformanceObserver로 프레임을 막은 스크립트와 함수, URL을 확인할 수 있습니다. 글은 Chromium 123 이상에서 지원하며 Firefox와 Safari는 지원하지 않는다고 설명합니다. 지원하지 않는 환경에서도 Long Task 항목으로 50밀리초를 넘긴 작업은 찾을 수 있지만, 실행 주체를 알려주는 정보는 더 제한적입니다. CSS 애니메이션은 컴포지터 스레드에서 계속 움직일 수 있어 화면이 멈췄는지 확인하는 지표로 부적절할 수 있습니다.
적용 범위와 보완 수단
DOM을 읽지 않는 순수 계산이나 큰 파일 파싱은 Web Worker로 옮기는 편이 낫습니다. 컴포넌트 상태를 참조하거나 DOM을 갱신해야 해 메인 스레드에 남겨야 하는 작업에 yield를 적용합니다. React의 startTransition은 React 렌더링을 중단 가능하게 하지만 이벤트 핸들러에서 setState 호출 전에 실행되는 동기 루프를 나누지는 않습니다. 작업이 10밀리초 정도라면 나누는 비용이 더 클 수 있으므로, 글은 50밀리초를 넘길 가능성이 있는 작업부터 예산을 고려하라고 제안합니다. 화면 밖 콘텐츠에는 content-visibility: auto와 contain-intrinsic-size를 함께 써 레이아웃과 페인트 비용을 줄일 수 있지만, 이 CSS가 긴 JavaScript 루프를 나누지는 않습니다.
dev.to 반응
- @phongdesigns — '느린 것'과 '무례한 것'을 구분한 설명이 잘 맞습니다. 다만 검색 인덱스가 서버나 호스팅 검색 서비스에 있으면 병목은 스크립트가 아니라 왕복 시간입니다. 이때 디바운스는 버그를 감추는 게 아니라 입력 한 글자마다 서너 번씩 요청을 보내지 않게 하는 부하 제어입니다. 같은 코드도 작업 위치에 따라 역할이 다릅니다. 진단 전에 해결책으로 붙이면서 디바운스 평판이 나빠진 경우가 많습니다. 운영 환경에서 가장 나쁜 작업은 몇 달 동안 우리 코드가 아니라 태그 관리자 컨테이너였습니다. 우리 코드에 yield를 넣어도 손댈 수 없었으니 자사 루프를 고치기 전에 서드파티 스크립트를 살펴볼 만합니다. 이번 주 배포본에서 가장 긴 작업이 무엇인지 솔직히 모르겠습니다. 마지막 문장이 이미 예상한 답이네요.
- @varshithvhegde — 맞는 지적입니다. 저는 메인 스레드에서 실행되는 필터를 말했습니다. 검색이 요청이라면 디바운스는 글자마다 네 번 요청하지 않게 하는 일이고, 다른 문제입니다. 태그 관리자 이야기는 아프네요. 한동안 우리 루프에 yield를 넣었는데 긴 작업은 우리가 소유하지도 않은 컨테이너에서 나왔습니다. 코드를 건드리기 전에 확인할 만합니다.
- @rushdeploy — 한 가지 덧붙이면 디바운스와 yield는 서로 대체하는 방법이 아닙니다. 디바운스는 아무도 보지 않을 검색을 건너뛰어 전체 작업량을 줄이고, yield는 각 작업 조각을 50밀리초 긴 작업 기준 아래로 유지합니다. 문제는 타이머가 아니라 필터 전체를 하나의 작업으로 실행한 데 있습니다.
- @varshithvhegde — 동의합니다.
- @rushdeploy — 잘 맞았네요. 제가 자주 쓰는 조합은 입력 이벤트에 150밀리초 디바운스를 적용한 뒤 필터 루프에서 약 50밀리초마다 yield하고, 실행마다 AbortController를 하나씩 둬 새 입력이 들어오면 오래된 검색을 즉시 취소하는 방식입니다.
- @kreout — 50밀리초는 진단 기준이지 프레임 예산이 아닙니다. 60Hz에서는 한 프레임에 전체 작업을 처리할 시간이 약 16.7밀리초이므로 49밀리초 조각을 반복해도 긴 작업 항목은 생기지 않으면서 화면이 끊길 수 있습니다. 데모에서 같은 requestAnimationFrame 표시기를 두고 50밀리초와 8밀리초 조각을 비교하면 차이가 드러납니다. 적절한 예산은 기기와 레이아웃, 페인트에 남은 작업량에 따라 달라집니다. 50밀리초 아래라고 UI가 프레임 예산을 지킨다는 뜻은 아닙니다.
- @rushdeploy — 좋은 지적입니다. 50밀리초는 긴 작업으로 분류하는 기준이지 프레임 예산이 아닙니다. 페이지에 애니메이션이 있으면 긴 작업 항목과 실제 프레임 시간을 함께 봅니다. yield는 입력 반응성을 돕고 16.7밀리초는 별도로 확인해야 합니다. 짚어줘서 고맙습니다.
- @varshithvhegde — 맞는 수정입니다. 50밀리초는 작업을 '긴 작업'이라고 부르는 기준일 뿐입니다. 60Hz 프레임에는 약 17밀리초만 있으므로 49밀리초 조각을 여러 번 실행해도 긴 작업 기록 없이 화면이 나빠질 수 있습니다. 데모는 500밀리초짜리 단일 작업과 비교하려고 그 기준에서 멈췄습니다. UI가 실제로 프레임을 맞춘다는 뜻은 아닙니다. 같은 표시기를 두고 8밀리초 조각을 비교하면 차이가 분명해질 겁니다.
원문: dev.to / 번역·요약: Trawling