Next.js로 검색 입력창에 디바운스를 붙이다가 setTimeout 타입에서 한 번 멈칫한 적이 있다.
클라이언트 컴포넌트에서 디바운스 훅을 만들고 있었고
그냥 setTimeout만 쓰다가 타입이 예상과 다르게 나왔고
결국 window.setTimeout을 명시적으로 쓰는 쪽으로 정리했다
결론부터
- 브라우저 환경
- window.setTimeout() 또는 전역 setTimeout() 모두 number를 반환한다
- 이 값은 "타이머 ID" 역할을 한다
- Node.js / 서버 환경
- setTimeout()이 NodeJS.Timeout 객체를 반환한다
- @types/node가 없으면 object 정도로 추론되는 경우도 있다
- 실무에서 주의할 점
- 클라이언트 전용 디바운스 로직이라면 window.setTimeout을 명시해서 number를 쓰는 쪽이 안정적이다
- 서버 컴포넌트나 Node 환경 코드는 NodeJS.Timeout 기준으로 타입을 잡는 편이 자연스럽다
실행 환경에 따른 반환값
1) 브라우저 환경 (클라이언트 컴포넌트)
브라우저에서 setTimeout을 호출하면 반환 타입은 number다.
const id = window.setTimeout(() => {}, 1000);
console.log(typeof id); // "number" (브라우저 기준)
- window.setTimeout을 쓰든
- 전역 setTimeout을 쓰든
브라우저에서는 둘 다 결국 number 타입의 타이머 ID를 반환한다.
2) Node.js 환경 (서버 컴포넌트)
Node 환경에서는 상황이 다르다.
const id = setTimeout(() => {}, 1000);
console.log(typeof id); // "object"
console.log(id instanceof NodeJS.Timeout); // true
- 반환 값은 NodeJS.Timeout 타입의 객체
- 타입 선언이 제대로 연결되어 있지 않으면 단순히 object로 보이기도 한다
Next.js 서버 컴포넌트, Node 스크립트 등은 이 쪽 세계관이라고 보면 된다.
내가 실제로 겪은 상황 정리
1) 디바운스 기능을 넣고 싶었다
검색창에 입력할 때마다 바로 API를 때리지 않고, 디바운스를 걸어 한 번에 묶어서 요청하고 싶었다.
그래서 클라이언트 컴포넌트에서 간단한 디바운스 로직을 직접 구현하려고 했다.
2) 그냥 setTimeout을 쓰고 있었다
처음에는 아무 생각 없이 이렇게 썼다.
const id = setTimeout(() => {
// ...
}, 500);
문제는 타입스크립트 입장에서 이 id가 상황에 따라 다르게 보인다는 점이었다.
- 브라우저 기준으로는 number라고 생각하고 코드를 짜고 있었고
- 타입 시스템에서는 NodeJS.Timeout 쪽으로 인식하는 흐름도 섞여 있었다
결국
- 내가 기대한 것처럼 "number"로만 깔끔하게 처리되지 않았고
- 디바운스 훅에서 상태 타입을 어떻게 잡느냐에서 애매한 부분이 생겼다
3) 검색하다가 window.setTimeout으로 정리
그래서 setTimeout 타입을 다시 찾아봤고
- 브라우저 환경에서 명시적으로 window.setTimeout을 쓰면
- 반환 타입을 "number"로 확실하게 가져갈 수 있다는 걸 확인했다
그래서 디바운스 훅은 "클라이언트 전용"이라는 전제를 두고, window.setTimeout을 기준으로 잡았다.
4) 디바운스 로직을 커스텀 훅으로 분리
최종적으로는
- 검색 입력 컴포넌트
- 디바운스 훅
두 부분으로 나눠서 정리했다.
검색 입력 컴포넌트
import { ChangeEvent, HTMLAttributes, useState } from "react";
import { Input } from "@/components/ui/input";
import useSearchParams from "@/hooks/useSearchParams";
import useDebounce from "@/hooks/useDebounce";
export function SearchInput({
className,
...props
}: HTMLAttributes<HTMLDivElement>) {
const { term, handleTermChange } = useSearchParams();
// 실제 인풋에 보이는 값
const [inputValue, setInputValue] = useState(term);
// 디바운스된 검색 함수
const handleDebounceTermChange = useDebounce(handleTermChange, 500);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInputValue(value); // 입력값은 바로 반영
handleDebounceTermChange(value); // 검색 쪽은 디바운스
};
return (
<main {...props}>
<Input
value={inputValue}
onChange={handleInputChange}
className="h-12 pl-12 text-base"
placeholder="Search"
/>
</main>
);
}
디바운스 훅
import { useState } from "react";
export default function useDebounce(
onDebouncedAction: (inputValue: string) => void,
delay: number
) {
// window.setTimeout은 number를 반환하므로 number | null로 타입을 맞춰 둔다
const [timeoutId, setTimeoutId] = useState<number | null>(null);
// 호출되는 쪽에서는 inputValue만 넘기면 된다
return (inputValue: string) => {
// 이전 타이머가 있으면 취소
if (timeoutId) {
window.clearTimeout(timeoutId);
}
// 새 타이머 설정
const newTimeoutId = window.setTimeout(() => {
onDebouncedAction(inputValue);
}, delay);
setTimeoutId(newTimeoutId);
};
}
여기서 중요한 포인트는 두 가지였다.
- window.setTimeout을 써서 반환 타입을 number로 고정했다
- 디바운스 훅 내부 상태도 number | null로 명확하게 타입을 잡았다
이렇게 맞춰 놓으니까 타입스크립트 입장에서도 헷갈릴 부분이 줄어들었다.
정리 메모
- setTimeout의 반환값은 실행 환경에 따라 다르다
- 브라우저: number (타이머 ID)
- Node.js: NodeJS.Timeout 객체
- 클라이언트 전용 디바운스 훅이라면 window.setTimeout을 기준으로 타입을 잡는 게 편하다
- 서버 코드나 Node 스크립트는 NodeJS.Timeout 기준으로 보는 것이 자연스럽다
- Next.js처럼 서버/클라이언트가 섞인 환경에서는
- "이 코드가 어디에서 실행될지"를 먼저 정리하고
- 그에 맞게 setTimeout 반환 타입을 의식하는 게 좋다
앞으로도 디바운스나 타이머 로직을 짤 때는
- 실행 환경
- 반환 타입
이 두 가지를 먼저 떠올려 보자
'FrontEnd > TypeScript' 카테고리의 다른 글
| TypeScript as const 이게 뭘까 (0) | 2025.12.18 |
|---|---|
| TypeScript 유틸리티 타입 Partial<T> – 부분만 채워도 되는 타입 만들기 (1) | 2025.12.14 |
| TypeScript 유틸리티 타입 Omit<T, K> – 필요 없는 필드만 쏙 빼고 쓰기 (0) | 2025.12.14 |
| 타입연산자 typeof, keyof, keyof typeof 패턴 정리 (0) | 2025.12.14 |
| TypeScript 유틸리티 타입 Pick<T, K> – 객체 타입에서 필요한 것만 뽑아 쓰기 (0) | 2025.12.13 |
