setTimeout() 반환값 정리 – 브라우저 vs Node에서 뭐가 다른지

2024. 11. 24. 22:12·FrontEnd/TypeScript

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
'FrontEnd/TypeScript' 카테고리의 다른 글
  • TypeScript 유틸리티 타입 Partial<T> – 부분만 채워도 되는 타입 만들기
  • TypeScript 유틸리티 타입 Omit<T, K> – 필요 없는 필드만 쏙 빼고 쓰기
  • 타입연산자 typeof, keyof, keyof typeof 패턴 정리
  • TypeScript 유틸리티 타입 Pick<T, K> – 객체 타입에서 필요한 것만 뽑아 쓰기
프론트엔드 개발자 jbeat
프론트엔드 개발자 jbeat
프론트엔드 개발자 블로그인데 일상도 쪼그으믐
  • 프론트엔드 개발자 jbeat
    jbeat 님의 블로그
    프론트엔드 개발자 jbeat
  • 전체
    오늘
    어제
    • 분류 전체보기 (44)
      • FrontEnd (43)
        • TypeScript (6)
        • JavaScript (18)
        • Next.js (3)
        • React (1)
        • Testing (2)
        • Third Party (1)
        • web (10)
        • Tooling (1)
        • coding test (0)
        • A.I (1)
      • 일상 (1)
        • wedding (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 인기 글

  • 태그

    CRDT
    playwright
    WebSocket
    preconnect
    고차함수
    Android
    pick
    Utility
    타입스크립트
    주니어
    javascript
    코테
    CrossOrigin
    omit
    이터러블
    컬렉션
    TypeScript
    Next.js
    yjs
    배열
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
프론트엔드 개발자 jbeat
setTimeout() 반환값 정리 – 브라우저 vs Node에서 뭐가 다른지
상단으로

티스토리툴바