시간표 관리 Handler 작성하기

김 주현·2024년 5월 28일

공시락 Gongsilock

목록 보기
6/6
post-thumbnail

서비스 내에서 시간을 다루는 상황이 생겼는데, 생각보다 애를 먹어서 기록하는 포스팅.


상황 및 흐름

시간표 관리 시퀀스 다이어그램

현재 서비스는 아래와 같은 상황으로 시간표라는 기능을 제공하고 있어요.

현재 서비스 내 상황

  • 학급 시간표처럼 요일마다 정해진 시간표가 존재
  • 현재 시간에 맞는 시간표를 출력
  • 교시와 교시 중간에 쉬는 시간이 존재
  • 시간표 시작 전 / 종료 후의 시간은 자습 시간
  • 시간표 초기화 시간은 다음 날 새벽 5시!

위의 상황을 바탕으로, 서버 시간과 시간표 정보를 받아와서 현재 교시를 표시하는 요구사항을 구현해야 하는 상황이었습니다. 큰 틀은 위와 같았으나 세세하게 열받는 포인트가 있었습니다.

제일 골머리를 앓았던 것은 시간표 정보를 어떻게 저장할 것인가였어요.

Date 형식으로 저장하기

new Date("2024-05-28 09:00")

A교시가 9시부터 시작한다고 했을 때, A교시의 시작 정보에 Date 형식으로 9시를 저장하는 방법이 있었어요. 그런데 생각해보면, 시간표라는 개념은 매일, 또는 매주 반복되는 형식인데 거기에 필요하지 않은 연/월/일이 굳이 존재해야 하냐는 의문점이 생겼습니다.

임의로 특정 날짜를 기입해놓고 로직상 변경하여 사용할 수 있겠지만, 불필요한 데이터를 저장한다는 느낌을 지울 순 없었어요.

Time 형식으로 저장하기

09:00

따라서, Date 형식이 아닌 "09:00"와 같은 형식으로 저장하는 방법을 생각했어요. 해당 형식은 시간표라는 정보에 적합한 형식으로 느껴져서 나름 흡족(?)하고 있었는데, 또 다른 문제가 발생했어요.

프론트에선 어쨌든 Date 객체인데?

받아온 현재 시간과 시간표 정보를 비교하여 현재 교시를 알아내기 위해선, 결국 Date 객체인 currentTime과 비교를 할 수밖에 없었어요. 그렇다면 currentTime에서 시/분을 파싱해서 비교를 하거나, 시간표 정보를 Date 형식으로 받아서 비교해야 했었는데, 첫 번째 방법은 추가적인 품이 들고 두 번째 방법은 다시 돌아가는 느낌이라 마음에 들지 않았어요.

또한 현재 교시가 얼마나 남았는지 표시해주는 기능도 있었는데, 이 기능은 Date 객체로 End - Start만 빼면 편하게 계산할 수 있는 것을 시간 형식으로 다루게 되면 복잡하게 돌아가는 느낌이 들었습니다.

이렇듯, 결국엔 프론트에서는 시간을 다룰 땐 Date 형식으로 다루는 것이 속편했어요.

저장은 Time, 다룰 땐 Date

그러므로 생각한 것이, 정보는 Time 형식으로 저장하고 프론트에서는 받아와서 Date 객체로 다루는 방식을 생각했어요. 이런 경우라면 서버에 저장된 형식과 프론트에서 쓰는 형식을 굳이 일치할 필요는 없다고 판단했기 때문이에요.

물론 그렇다보니 같은 모델인데도 서버에서 받아온 타입과 프론트에서 쓰는 타입을 분리해야 하긴 했어요.

같은 모델 다른 형식

/** From Server \*/
type PeriodFromServer = {
  ...
  startTime: string;
  ...
}

/** 프론트 \*/
type Period = {
  ...
  startTime: Date;
  ...
}

그래도~ 훨씬 다루기 편해진 것은 사실! 굳이 서버 타입과 맞추지 않아도 된다는 인사이트를 얻게된 경험이었어요.

Handler 작성

서비스를 구축하는데 중심이 되는 역할들이 있었습니다. 크게 나눠보면 아래와 같은 녀석들이었어요.

  1. 현재 시간을 관리하는 녀석
  2. 현재 시간표를 관리하는 녀석
  3. 현재 교시를 관리하는 녀석

이 녀석들을 저는 빈 JSX를 반환하는 컴포넌트로 만들고 활용하고 있었어요. 이런 컴포넌트를 유틸리티 컴포넌트, 로직 컴포넌트라고 부르는 것 같긴 한데,, 널리 불리는 이름은 아닌 듯.

하여튼 로직 컴포넌트

export const TimeHandler = () => {
  // TODO: 시간 관리
  return <></>
}

1번, 2번, 3번 모두 각각의 한 상태를 다루는 녀석들인데요, 그렇다면 상태 저장소에서 충분히 제어할 수 있는 것 아니냐! 라는 생각도 들었습니다. 그렇지만 그렇게 하지 않은 이유는,, 제 생각엔 저장소는 해당 상태를 직접적으로 다루는 로직만 있어야 하기 때문이에요. 사실 한 상태를 다룬다곤 하지만 종속성이 있는 녀석들이라 ^~^,,

그러므로, 이 저장소를 가져다가 쓰는 핸들러가 필요하다고 생각했어요. 이 핸들러를 singleton class로 만들어서 서비스 내에 하나의 인스턴스로 존재하게 하려고 했는데, 이러면 React의 생명주기에서 벗어나게 되고 상태 추적이 어렵게 될 것 같았어요.

또한 위에서 언급했듯, 2번과 3번은 1번에 종속성이 있는 애들이라 1번 상태를 추적해야 했습니다. 이를 위해 이러저러 로직을 짜는 것보다 빈 JSX를 반환하고 useEffect()만 사용하는 Handler를 만들고자 했어요.

이건 결국 Hook이 아닌가? 하는 생각도 해봤는데, Hook은 그저 관련 있는 것들의 '모음집'이지, 실제 존재하기 위해선 '호출'을 해야 하므로, Rendering을 통해 계속 추적하게 할 순 없다고 생각했어요.

다만,, 이런 형태의 컴포넌트를 작성해도 되는가? 하는 의문은 남아있습니다. 제 나름의 판단이 있으니 안 될 거야 없겠지만, 이런 형태로 작성하는 건 처음이라서(...) 다른 방법이 있지 않을까 싶은 생각이에요.

TimeHandler

서버에서 받아온 현재 시간를 가지고 1초마다 CurrentTimeStore에 상태를 업데이트해주는 핸들러입니다.

TimeHandler.tsx 중 일부

export const TimeHandler = ({ serverTime }: { serverTime: Date }) => {
  const { status, initializeTime, tick: currentTimeTick } = useCurrentTime();

  const registerTimerWorker = () => {
    const timerWorker = new Worker(new URL('../\_workers/timeWorker.ts', import.meta.url), { type: 'module' });

    const handleMessage = () => {
      currentTimeTick();
    };

    timerWorker.addEventListener('message', handleMessage);

    return timerWorker;
  };

  useEffect(() => {
    const timerWorker = registerTimerWorker();
    startTimer(timerWorker);

    return () => {
      terminateTimer(timerWorker);
    };
  }, [status]);

  return <></>;
};

필요한 코드만 가져와봤어요. 위에서 serverTime을 넘겨주면 타이머를 시작해요. Worker에서 setInterval()을 통해 message를 emit해주면 현재 시간을 Tick해준답니다.

TimetableHandler

받아온 현재 시간과 시간표 정보를 토대로 오늘에 해당하는 시간표 정보를 업데이트하는 핸들러입니다.

TimetableHandler.tsx 중 일부

export const TimetableHandler = ({ initialTimetable }: { initialTimetable: TimetableResponse }) => {
  const { status: currentTimeStatus, currentTime } = useCurrentTime();
  const { status: timetableStatus, updateTimetable } = useTimetable();

  useEffect(() => {
    const doneSetCurrentTime = currentTimeStatus === CurrentTimeStatus.SET_WITH_SERVER_TIME;
    const isNotInitializedTimetalbe = timetableStatus === TimetableStoreStatus.NOT_INITIALIZED;
    const shouldInitialize = doneSetCurrentTime && isNotInitializedTimetalbe;

    if (shouldInitialize) {
      const calcuatedPeriods = calcuatePeriods();

      return updateTimetable({
        ...initialTimetable,
        periods: calcuatedPeriods,
      });
    }
  }, [currentTimeStatus]);

  useEffect(() => {
    const resetTime = getResetTime(currentTime);

    const isResetTime =
      isSameDay(currentTime, resetTime) &&
      isSameHour(currentTime, resetTime) &&
      isSameMinute(currentTime, resetTime) &&
      isSameSecond(currentTime, resetTime);

    if (isResetTime) {
      const calcuatedPeriods = calcuatePeriods();

      return updateTimetable({
        ...initialTimetable,
        periods: calcuatedPeriods,
      });
    }
  }, [currentTime]);

  return <></>;
};

역시 필요한 코드만 가져왔어요. currentTime이 최초로 설정됐을 때 timetable을 지정해주는 부분과 currentTime이 바뀔 때마다 초기화를 해야하는지 판단하는 코드입니다.

여기에서 사실 주의깊게 봐야 하는 부분은 calcuatePeriods() 인데, 코드가 조금 길어져서 생략했어요. 그 부분 중에서도 시간 형식을 Date 형식으로 바꾸는 로직을 가져와서 살펴볼게요.

calcuatePeriods() 중 일부

const startBaseTime = isOvernight ? subDays(currentTime, 1) : currentTime;

(period) => {
  const hours = Number(period.startTime.split(':')[0]);
  const minutes = Number(period.startTime.split(':')[1]);

  const newStartTime = setSeconds(setMinutes(setHours(startBaseTime, hours), minutes), 0);

  const isPeriodOvernight = hours < 5;

  return {
    ...period,
    startTime: isPeriodOvernight ? addDays(newStartTime, 1) : newStartTime,
  };
}

period의 startTime은 09:00와 같은 형식으로 값이 존재합니다. 이를 시와 분으로 나누고, startBaseTime을 기준으로 period가 가진 정보로 Date 객체를 반환해요.

이 과정에서 초기화 시간인 다음 날 새벽 5시를 고려해야 하기 때문에, isOvernight이라는 Flag로 구분해주었어요.

calcuatePeriods()이 끝나고 나면 Frontend에서 이제 자유롭게 오늘 기준, 정확하게는 서버에서 내려준 현재 시간을 기준으로 생성된 교시 정보를 Date 객체로 다룰 수 있게 돼요. 짱!

PeriodHandler

시간표 정보와 현재 시간으로 현재 교시를 계산하는 핸들러입니다.

PeriodHandler.tsx 중 일부

export const PeriodHandler = () => {
  const { status: currentTimeStatus, currentTime } = useCurrentTime();
  const { status: timetableStatus, timetable } = useTimetable();
  const { status: periodsStatus, periods, updatePeriod, initializePeriodsStore } = usePeriods();

  useEffect(() => {
    const result = getCurrentPeriod(periods, currentTime);

    const isBeforeFirstPeriod = result === PeriodStatus.BEFORE_FIRST_PERIOD;
    const isAfterLastPeriod = result === PeriodStatus.AFTER_LAST_PERIOD;
    const isInPeriod = !(isBeforeFirstPeriod || isAfterLastPeriod);

    if (isBeforeFirstPeriod) {
      updatePeriod(null, PeriodStatus.BEFORE_FIRST_PERIOD);
    }

    if (isAfterLastPeriod) {
      updatePeriod(null, PeriodStatus.AFTER_LAST_PERIOD);
    }

    if (isInPeriod) {
      updatePeriod(result, PeriodStatus.IN_PERIOD);
    }
  }, [currentTime]);

  return <></>;
};

그러면 이제 현재 교시를 계산할 수 있게 되는데요, 이 역시 currentTime이 바뀔 때마다 계산을 해주게 됩니다.

이때 시간표 내의 시간이냐 외의 시간이냐에 따라 설정해주는 값이 다른데요, 만약 시간표 외의 시간이라면 period의 값은 null로 지정하고, 각각의 상태에 따라 현재 교시의 정보를 업데이트하게 됩니다.

BEFORE_FIRST_PERIODAFTER_LAST_PERIOD, IN_PERIOD는 실제 로직 상으로는 없어도 무방하지만, 보다 명시적이고 편리한 계산을 위해 status와 enum을 추가하여 관리해주었어요.

그리고 현재 교시를 계산하는 로직은 getCurrentPeriod()인데, 다음과 같아요.

getCurrentPeriod()

const getCurrentPeriod = (periods: Period[], currentTime: Date) => {
  // (1) 시간표 시작 전인지 확인
  const firstPeriod = periods[0];
  const isBeforeFirstPeriod = currentTime.getTime() < firstPeriod.startTime.getTime();

  if (isBeforeFirstPeriod) {
    return PeriodStatus.BEFORE_FIRST_PERIOD;
  }

  // (2) 시간표 종료 후인지 확인
  const lastPeriod = periods.at(-1)!;
  const isAfterLastPeriod = currentTime.getTime() > addMinutes(lastPeriod.startTime, lastPeriod.duration - 1).getTime();

  if (isAfterLastPeriod) {
    return PeriodStatus.AFTER_LAST_PERIOD;
  }

  // (3) 시간표 시간 확인
  const matchPeriod = periods
    .filter((period) => {
      const isOverStartTime = period.startTime.getTime() <= currentTime.getTime();
      const isUnderEndTime = currentTime.getTime() < addMinutes(period.startTime, period.duration).getTime();
      const isInPeriod = isOverStartTime && isUnderEndTime;

      return isInPeriod;
    })
    .at(0)!;

  // matchPeriod가 []인 경우는 never

  return matchPeriod;
};

해당 함수에서는 3가지의 체크를 진행하고 있어요. (1) 시간표 시작 전인지, (2) 시간표 종료 후인지, (3) 어떤 교시인지.

비교를 진행할 때 Date객체의 getTime()을 십분 활용하고 있어서 덕분에 .. 골치아픈 시간 Helper 함수를 작성하지 않아도 됐어요.

Handler Status

현재는 서버에서 시간을 받아오고 시간표 정보를 받아오는 것이 병렬로 진행되는데, Handler는 일단 컴포넌트이기 때문에 랜더링이 되고 있는 상태에요. 그렇다보니 현재 시간과 시간표 정보에 종속성이 있는 Handler는 그 사이 Pending을 대처해야 하므로, Status를 넣어 해결해주었어요.

CurrentTime Status

type CurrentTimeStoreReturn =
  | {
      status: CurrentTimeStatus.NOT_SET;
      currentTime: null;
      initializeTime: (serverTime: Date) => void;
      tick: () => void;
    }
  | {
      status: CurrentTimeStatus.SET_WITH_SERVER_TIME;
      currentTime: Date;
      initializeTime: (serverTime: Date) => void;
      tick: () => void;
    };

Timetable Status

type TimetableStoreReturn =
  | ({
      status: TimetableStoreStatus.NOT_INITIALIZED;
      timetable: null;
    } & TimetableStoreReturnBase)
  | ({
      status: TimetableStoreStatus.INITIALIZED;
      timetable: Timetable;
    } & TimetableStoreReturnBase);

Periods Status

type PeriodsStoreReturn =
  | ({
      status: PeriodStatus.NOT_PERIODS_SET;
      currentPeriod: null;
      periods: null;
    } & PeriodsStoreReturnBase)
  | ({
      status: PeriodStatus.AFTER_LAST_PERIOD | PeriodStatus.BEFORE_FIRST_PERIOD;
      currentPeriod: null;
      periods: Period[];
    } & PeriodsStoreReturnBase)
  | ({
      status: PeriodStatus.IN_PERIOD;
      currentPeriod: Period;
      periods: Period[];
    } & PeriodsStoreReturnBase);

각각 Handler의 상태를 구분지어주고 이에 따라 처리를 해주었는데 ,,,

지금 생각해보면 그냥 서버 시간과 시간표 정보를 다 받을 때까지 Pending해주고 넘겨주면 더 깔끔하잖아?! 라는 생각이 들었습니다.

리팩토링 하러 가야 해서 그럼 20000,,,

profile
FE개발자 가보자고🥳

0개의 댓글