웹 프론트/React

React : 매초, 매분, 매시간 마다 정확히 호출되는 Emitter 커스텀훅 만들기

번데기 개발자 2025. 4. 22. 01:28
반응형

 

 

개요

 

프론트엔드 개발을 하다 보면, 매초마다 갱신되는 타이머, 매분마다 서버와 동기화되는 데이터, 매시간마다 갱신되는 통계처럼 시간의 흐름에 맞춰 무언가를 동작시켜야 하는 경우가 종종 생깁니다.


이처럼 "매초", "매분", "매시간" 단위로 특정 로직을 실행해야 할 때, 단순한 setInterval만으로는 부족하거나 비효율적인 경우가 많은데요.

이번 글에서는 이런 시간 단위 이벤트를 효율적으로 처리하는 구조에 대한 커스텀 훅을 제작해 보았습니다. 

 

 

매초마다 수행되는 시간을 반환하는 Custom Hook (맨처음 버전)

 

const useSecondUpdater = () => {
  const [time, setTime] = useState(new Date());
  const timeout = useRef<NodeJS.Timeout>(null!);

  useEffect(() => {
    const updateSecond = () => {
      setTime(new Date());
      // 다음 초의 시작 시점을 계산
      const now = new Date();
      // 현재 시간의 밀리초를 계산 후 오차 범위 제거
      const delay = 1000 - now.getMilliseconds();
      timeout.current = setTimeout(updateSecond, delay);
    };

    // 초기 호출
    updateSecond();

    // 컴포넌트 언마운트 시 타이머 정리
    return () => clearTimeout(timeout.current);
  }, []);

  return { time };
};

export default useSecondUpdater;

 

 

이 초기 useSecondUpdater 버전은 매초 정시를 맞추기 위해 setTimeout을 반복적으로 사용했는데요, 이때 여러 컴포넌트에서 해당 훅을 사용할 때마다 각각의 타이머가 생기기 때문에 메모리 사용에 효율적이지 않았습니다.

 

또 이에 따라, 정확한 시간 동기화를 중앙에서 제어할 수 없어 전체적인 일관성이 떨어지고 약간의 시간 오차가 발생할수도 있다고 생각했습니다.

 

 

매초마다 수행되는 시간을 반환하는 Custom Hook (개선 버전)

 

import { useState, useEffect } from 'react';

interface TimeState {
  time: Date;
}

class TimeEmitter {
  private static instance: TimeEmitter;
  private listeners: Set<(time: Date) => void>;
  private timeout: NodeJS.Timeout | null;
  private isRunning: boolean;

  private constructor() {
    this.listeners = new Set();
    this.timeout = null;
    this.isRunning = false;
    this.startTimer();
  }

  public static getInstance(): TimeEmitter {
    if (!TimeEmitter.instance) {
      TimeEmitter.instance = new TimeEmitter();
    }
    return TimeEmitter.instance;
  }

  private startTimer() {
    if (this.isRunning) return;

    const updateSecond = () => {
      const now = new Date();
      this.listeners.forEach((listener) => listener(now));
      const delay = 1000 - now.getMilliseconds();
      this.timeout = setTimeout(updateSecond, delay);
    };

    this.isRunning = true;
    updateSecond();
  }

  public subscribe(callback: (time: Date) => void) {
    this.listeners.add(callback);
    if (!this.isRunning) {
      this.startTimer();
    }
    return () => {
      this.listeners.delete(callback);
    };
  }

  public cleanup() {
    if (this.timeout) {
      clearTimeout(this.timeout);
      this.timeout = null;
    }
    this.isRunning = false;
  }
}

const useSecondUpdater = (): TimeState => {
  const [time, setTime] = useState<Date>(new Date());

  useEffect(() => {
    const unsubscribe = TimeEmitter.getInstance().subscribe(setTime);
    return unsubscribe;
  }, []);

  return { time };
};

export default useSecondUpdater;

 

 

따라서 위처럼 하여 여러 컴포넌트에서 useSecondUpdater를 사용해도 타이머를 하나만 유지하도록 선언했습니다. 이전처럼 각 훅마다 타이머를 만들면 비효율적이고 메모리 낭비가 발생하므로, 공통된 TimeEmitter 인스턴스를 통해 시간 이벤트를 공유하게 만든 것이죠.

문법적으로는 싱글톤 패턴을 사용하여 TimeEmitter.getInstance()를 통해 하나의 전역 인스턴스만 생성되도록 보장했고, subscribe 메서드에서 구독자 관리와 자동 타이머 시작 로직을 캡슐화했습니다.

 

import React from 'react';
import useSecondUpdater from './useSecondUpdater'; // 훅 경로에 맞게 수정

const SimpleClock = () => {
  const { time } = useSecondUpdater();

  return (
    <div>
      현재 시간: {time.getHours()}시 {time.getMinutes()}분 {time.getSeconds()}초
    </div>
  );
}

export default SimpleClock;

 

실제로 사용은 위와같이 할 수 있습니다.

 

 

매분마다 수행되는 시간을 반환하는 Custom Hook  (매분 시작 시점에 수행)

'use client';
import { useState, useEffect } from 'react';

interface TimeState {
  time: Date;
}

class MinuteTimeEmitter {
  private static instance: MinuteTimeEmitter;
  private listeners: Set<(time: Date) => void>;
  private timeout: NodeJS.Timeout | null;
  private isRunning: boolean;

  private constructor() {
    this.listeners = new Set();
    this.timeout = null;
    this.isRunning = false;
    this.startTimer();
  }

  public static getInstance(): MinuteTimeEmitter {
    if (!MinuteTimeEmitter.instance) {
      MinuteTimeEmitter.instance = new MinuteTimeEmitter();
    }
    return MinuteTimeEmitter.instance;
  }

  private startTimer() {
    if (this.isRunning) return;

    const updateMinute = () => {
      const now = new Date();
      this.listeners.forEach((listener) => listener(now));

      // 다음 정시까지의 시간을 계산
      const nextMinute = new Date(now);
      nextMinute.setMinutes(now.getMinutes() + 1);
      nextMinute.setSeconds(0);
      nextMinute.setMilliseconds(0);

      const delay = nextMinute.getTime() - now.getTime();
      this.timeout = setTimeout(updateMinute, delay);
    };

    this.isRunning = true;
    updateMinute();
  }

  public subscribe(callback: (time: Date) => void) {
    this.listeners.add(callback);
    if (!this.isRunning) {
      this.startTimer();
    }
    return () => {
      this.listeners.delete(callback);
    };
  }

  public cleanup() {
    if (this.timeout) {
      clearTimeout(this.timeout);
      this.timeout = null;
    }
    this.isRunning = false;
  }
}

const useMinuteUpdater = (): TimeState => {
  const [time, setTime] = useState<Date>(new Date());

  useEffect(() => {
    const unsubscribe = MinuteTimeEmitter.getInstance().subscribe(setTime);
    return unsubscribe;
  }, []);

  return { time };
};

export default useMinuteUpdater;

 

// 다음 정시까지의 시간을 계산
const nextMinute = new Date(now);
nextMinute.setMinutes(now.getMinutes() + 1);
nextMinute.setSeconds(0);
nextMinute.setMilliseconds(0);

const delay = nextMinute.getTime() - now.getTime();
this.timeout = setTimeout(updateMinute, delay);

 

위와 똑같이 분단위로 수행되는 커스텀훅도 만들어보았는데요 정확히 매분 시작 시점에 수행되는데요, React Query의 같은 라이브러리를 사용할 때 매 분마다 바뀌는 서버 데이터를 가져오기 위해 사용하였습니다.


react-query에도 refetchInterval이 지정된 간격(예: 1분)마다 데이터를 자동으로 재조회하지만, 실제로 컴포넌트가 마운트 되는 시점에서 1분 간격으로 가져오기 때문에 1분 30초 , 2분 30초, 3분 30초 이렇게 데이터를 패치해 올 때가 있습니다.

반면, 위에서 만든 커스텀 훅은 정확히 매분 0초에 동작하도록 설계되어, 매분 시작 시점에 정확하게 동작합니다.


매시간 마다 수행되는 시간을 반환하는 Custom Hook  (+ 추가)

 

'use client';
import { useState, useEffect } from 'react';

interface TimeState {
  time: Date;
}

class HourTimeEmitter {
  private static instance: HourTimeEmitter;
  private listeners: Set<(time: Date) => void>;
  private timeout: NodeJS.Timeout | null;
  private isRunning: boolean;

  private constructor() {
    this.listeners = new Set();
    this.timeout = null;
    this.isRunning = false;
    this.startTimer();
  }

  public static getInstance(): HourTimeEmitter {
    if (!HourTimeEmitter.instance) {
      HourTimeEmitter.instance = new HourTimeEmitter();
    }
    return HourTimeEmitter.instance;
  }

  private startTimer() {
    if (this.isRunning) return;

    const updateHour = () => {
      const now = new Date();
      this.listeners.forEach((listener) => listener(now));

      // 다음 정시까지의 시간을 계산
      const nextHour = new Date(now);
      nextHour.setHours(now.getHours() + 1);
      nextHour.setMinutes(0);
      nextHour.setSeconds(0);
      nextHour.setMilliseconds(0);

      const delay = nextHour.getTime() - now.getTime();
      this.timeout = setTimeout(updateHour, delay);
    };

    this.isRunning = true;
    updateHour();
  }

  public subscribe(callback: (time: Date) => void) {
    this.listeners.add(callback);
    if (!this.isRunning) {
      this.startTimer();
    }
    return () => {
      this.listeners.delete(callback);
    };
  }

  public cleanup() {
    if (this.timeout) {
      clearTimeout(this.timeout);
      this.timeout = null;
    }
    this.isRunning = false;
  }
}

const useHourUpdater = (): TimeState => {
  const [time, setTime] = useState<Date>(new Date());

  useEffect(() => {
    const unsubscribe = HourTimeEmitter.getInstance().subscribe(setTime);
    return unsubscribe;
  }, []);

  return { time };
};

export default useHourUpdater;

 

 

마무리

 

오늘은 간단하게 매분, 매초, 매시간마다 동작되는 커스텀 훅을 메모리에 효율적으로 만들어서 사용하는 방법을 정리해 보았습니다.

 

두서없이 쓰다보니 글이 정신없어진 것 같아요.

 

요즘 바빠서 블로그할 시간이 많이 없는데, 앞으로는 다시 꾸준히 유용한 글을 포스팅하도록 하겠습니다.

 

감사합니다.!

반응형