본문 바로가기
TYLER SONGBlog
블로그 목록
Frontend

실습: 입력 스트림에 디바운스 적용 (검색어 흉내내기): debounceTime으로 타이핑이 멈춘 후에만 반응하는 스트림 만들기

사용자가 검색창에 빠르게 타이핑할 때마다 API를 호출하면 서버에 불필요한 부하가 걸리고 화면도 깜빡거린다. debounceTime 연산자는 값이 연속으로 들어오는 동안은 무시하고, 입력이 멈춘 뒤 일정 시간이 지나야 마지막 값을 흘려보낸다. 이번 실습에서는 Node 환경에서 Subject로 키 입력을 흉내내고, debounceTime과 distinctUn

송민성7분 읽기

1. 개념

디바운스(debounce)는 짧은 시간 안에 연속으로 발생하는 이벤트 중 마지막 이벤트만 살리고 나머지는 버리는 처리 방식이다. RxJS에서는 debounceTime(ms) 연산자가 이 역할을 한다. 소스 옵저버블(observable)에서 새 값이 들어올 때마다 내부 타이머를 리셋하고, 지정한 시간(ms) 동안 추가 값이 들어오지 않으면 그때 마지막 값을 다음 단계로 내보낸다.

throttle과 자주 비교되는데, throttle은 "일정 주기마다 한 번씩" 값을 흘려보내는 방식이고 debounce는 "조용해진 다음에" 값을 흘려보내는 방식이다. 검색어 입력처럼 사용자가 타이핑을 멈춘 시점을 잡아내야 하는 상황에는 debounce가 더 적합하다.

2. 왜 사용하는가

검색창에 글자를 입력할 때마다 API를 호출하면 "리" → "리액" → "리액트" 세 글자만 입력해도 세 번의 네트워크 요청이 발생한다. 사용자가 빠르게 타이핑하고 있다면 중간 상태의 요청은 대부분 쓸모가 없고, 서버 자원과 네트워크 대역폭을 낭비하는 결과로 이어진다.

디바운스를 적용하면 사용자가 타이핑을 멈춘 순간(예: 300ms 동안 추가 입력이 없을 때)에만 요청을 보내므로, 실제로 필요한 시점에만 API를 호출하게 된다. 이는 서버 부하를 줄이는 것뿐 아니라 클라이언트 쪽에서 불필요한 렌더링과 상태 업데이트를 막아주는 효과도 있다.

3. 동작 원리

debounceTime(300)을 파이프(pipe)에 넣으면 내부적으로 다음과 같은 흐름이 만들어진다.

먼저 소스에서 값이 도착하면 이전에 걸어둔 타이머가 있으면 취소하고, 새로운 타이머를 300ms로 다시 설정한다. 그다음 300ms 동안 추가 값이 들어오지 않으면 타이머가 완료되어 마지막으로 받은 값을 구독자(subscriber)에게 전달한다. 만약 타이머가 끝나기 전에 새 값이 또 들어오면, 이전 값은 버려지고 타이머는 다시 리셋된다.

즉 소스가 계속 값을 쏟아내는 동안은 아무것도 방출되지 않고, 스트림이 잠잠해진 뒤에야 마지막 값 하나가 흘러나온다. 이 과정에서 스케줄러(scheduler)를 이용해 시간을 측정하는데, 별도로 지정하지 않으면 기본적으로 asyncScheduler가 사용된다.

4. 예제 (인라인 코드로만)

Node 환경이라 실제 input 이벤트가 없으므로, Subject로 키 입력을 흉내낸다.

먼저 rxjs에서 Subject, debounceTime, distinctUntilChanged, filter, map을 가져온다. 예를 들어 const { Subject } = require('rxjs');const { debounceTime, distinctUntilChanged, filter, map } = require('rxjs/operators'); 같은 형태로 임포트한다.

그다음 검색창 역할을 할 searchInput$ = new Subject()를 만든다. 이 Subject는 사용자가 한 글자씩 입력할 때마다 .next('리'), .next('리액'), .next('리액트') 식으로 값을 주입받는 대상이라고 생각하면 된다.

그다음 파이프를 구성한다. 흐름은 다음과 같다: searchInput$.pipe(map(text => text.trim()), filter(text => text.length > 0), debounceTime(300), distinctUntilChanged()) 순서로 연산자를 연결한다. map으로 공백을 제거하고, filter로 빈 문자열을 걸러내고, debounceTime(300)으로 타이핑이 멈춘 뒤 300ms 뒤에만 값을 통과시키고, distinctUntilChanged()로 같은 검색어가 연달아 두 번 들어와도 중복 호출을 막는다.

이 파이프라인의 결과를 .subscribe(query => { console.log('검색 실행:', query); }) 형태로 구독하면, 콘솔에 실제로 검색이 실행되는 시점의 검색어만 출력된다.

마지막으로 타이핑을 흉내내기 위해 setTimeout을 여러 개 걸어 searchInput$.next('리')를 0ms 시점에, searchInput$.next('리액')를 100ms 시점에, searchInput$.next('리액트')를 200ms 시점에 호출하도록 구성한다. 이렇게 하면 세 번의 입력이 300ms 이내에 몰려 있으므로 debounceTime에 의해 중간 값들은 버려지고, 마지막 값인 '리액트'만 약 500ms(200ms + 300ms) 시점에 콘솔에 출력된다. 이어서 700ms 시점에 다시 searchInput$.next('리액트 강좌')를 호출하면 1000ms 근처에 두 번째 검색 실행 로그가 찍히는 것을 확인할 수 있다.

5. 실무 사용 사례

자동완성(autocomplete) 검색창, 실시간 유효성 검사(예: 아이디 중복 확인), 창 크기 조절(resize) 이벤트 처리, 무한 스크롤에서 스크롤 위치를 추적해 API를 호출하는 경우 등에서 debounceTime이 널리 쓰인다. 특히 검색 UI에서는 debounceTime과 distinctUntilChanged, 그리고 이전 요청을 취소하는 switchMap을 함께 조합하는 패턴이 표준적으로 사용된다.

폼 입력 자동 저장(auto-save) 기능에서도 사용자가 타이핑을 멈췄을 때만 서버에 저장 요청을 보내도록 디바운스를 적용하는 경우가 많다.

6. 주의할 점

debounceTime은 값을 지연시켜 방출하기 때문에, 너무 큰 시간을 주면 사용자가 "반응이 느리다"고 느낄 수 있다. 검색 UX에서는 보통 200ms~400ms 사이가 무난한 값으로 알려져 있지만, 실제 서비스 특성에 맞춰 조정이 필요하다.

또한 debounceTime은 스트림이 완료(complete)되기 전에 대기 중인 값을 즉시 흘려보내지 않는다는 점도 알아둬야 한다. 즉 소스가 값을 방출하다가 완료되면, 타이머가 끝나지 않은 상태에서 대기 중이던 마지막 값은 그대로 버려질 수 있다. 이 때문에 컴포넌트가 언마운트되거나 스트림이 예기치 않게 끝나는 시점을 고려해 테스트해야 한다.

debounce와 throttle을 혼동하지 않아야 한다. debounce는 "조용해진 뒤"에 반응하고, throttle은 "일정 주기마다" 반응한다는 차이를 명확히 구분해야 한다.

7. 핵심 정리

debounceTime은 연속된 이벤트 중 마지막 값만 골라 지연 방출하는 연산자로, 사용자가 입력을 멈춘 시점을 감지하는 데 적합하다. Subject로 이벤트를 흉내내고 map, filter, debounceTime, distinctUntilChanged를 파이프로 연결하면 실제 검색 UX와 유사한 흐름을 Node 환경에서도 재현할 수 있다. 실무에서는 switchMap과 결합해 이전 요청을 취소하는 패턴까지 확장하는 경우가 많으므로, 다음 단계로 switchMap과의 조합을 학습하는 것이 자연스럽다.

© 2026 Tyler Song