조용진의 개발 블로그
← 글 목록

23:30~01:00 수업을 어떻게 저장할까: 자정 넘김 시간표 모델링

시간 모델링ReactNode.js데이터 모델링시간표

종료 요일 오프셋을 도입하고 주간 좌표로 겹침을 판정해 자정 넘김 일정을 API·관리자 화면·학부모 시간표·PDF까지 일관되게 처리한 과정입니다.

23:30에 시작해 다음 날 01:00에 끝나는 수업을 기존 시간표에 넣으려 하자, 단순해 보이던 시간 비교가 여러 계층에서 깨졌습니다. 종료 시각 01:00은 숫자로만 보면 시작 시각 23:30보다 작습니다. 같은 날의 잘못된 구간인지, 다음 날까지 이어지는 정상 일정인지 기존 데이터만으로는 구분할 수 없었습니다.

이번 작업에서는 자정 넘김 여부를 별도 값으로 저장하고, 서버의 겹침 판정부터 관리자 시간표, 학부모 화면, 엑셀 입력, PDF 출력까지 같은 의미를 사용하도록 정리했습니다.

종료 시각만으로는 날짜를 복원할 수 없다

기존 일정은 요일, 시작 시각, 종료 시각으로 표현됐습니다.

아래 코드 예시는 설명을 위해 식별자와 운영 설정값을 일반화했습니다.

요일: 월요일
시작: 23:30
종료: 01:00

이 데이터에는 종료가 월요일 01:00인지 화요일 01:00인지 정보가 없습니다. 화면에서 endTime < startTime이면 다음 날이라고 추측할 수 있지만, 이 규칙만 사용하면 잘못 입력된 당일 일정과 정상적인 자정 넘김 일정을 구분할 수 없습니다.

따라서 종료 시각의 날짜 의미를 명시하는 nextDayOffset을 추가했습니다.

0 = 시작한 날에 종료
1 = 다음 날에 종료

DB 컬럼은 기본값을 0으로 두어 기존 일정의 의미를 유지했습니다.

ALTER TABLE example_schedule
  ADD COLUMN nextDayOffset TINYINT NOT NULL DEFAULT 0
  COMMENT '종료 요일 오프셋(0=당일, 1=다음날)',
  ALGORITHM=INSTANT;

시간 문자열에 숨어 있던 날짜 의미를 데이터 모델로 끌어낸 것이 전체 개선의 출발점이었습니다.

시간 비교를 주간 좌표로 바꿨다

자정 넘김 일정은 하루 안에서 비교하면 시작값이 종료값보다 커집니다. 서버에서는 요일과 시각을 “한 주의 시작부터 흐른 분”으로 변환했습니다.

const MINUTES_PER_DAY = 24 * 60;
const MINUTES_PER_WEEK = 7 * MINUTES_PER_DAY;

const toWeeklyRange = ({ dayOfWeek, startTime, endTime, nextDayOffset }) => {
  const start = Number(dayOfWeek) * MINUTES_PER_DAY + timeToMinutes(startTime);
  const end =
    (Number(dayOfWeek) + normalizeEndDayOffset(nextDayOffset)) * MINUTES_PER_DAY +
    timeToMinutes(endTime);

  return { start, end };
};

월요일 23:30은 월요일 시작점 이후의 분으로, 화요일 01:00은 요일 오프셋이 더해진 다음 날의 분으로 표현됩니다. 이렇게 바꾸면 일반적인 반개방 구간 비교식을 그대로 사용할 수 있습니다.

left.start < right.end && right.start < left.end

일요일에서 월요일로 넘어가는 경계도 처리해야 했다

주간 시간표에는 한 가지 경계가 더 있습니다. 일요일 밤에 시작해 월요일 새벽에 끝나는 일정은 주의 끝을 넘어갑니다. 두 구간을 현재 주 좌표에서만 비교하면 월요일 새벽의 일정과 겹치는 것을 놓칠 수 있습니다.

실제 구현은 비교 대상 구간을 한 주 전, 현재 주, 한 주 뒤로 이동해 한 번씩 비교했습니다.

const overlapsAcrossWeek = (left, right) => {
  const leftInterval = toWeeklyRange(left);
  const rightInterval = toWeeklyRange(right);

  return [-MINUTES_PER_WEEK, 0, MINUTES_PER_WEEK].some((shift) => {
    const shiftedStart = rightInterval.start + shift;
    const shiftedEnd = rightInterval.end + shift;
    return leftInterval.start < shiftedEnd && shiftedStart < leftInterval.end;
  });
};

이 방식은 일요일과 월요일을 별도 예외문으로 다루지 않고, 주기적인 시간축의 인접 복사본을 비교하게 합니다.

화면에서는 하나의 일정을 두 개의 시각 조각으로 나눴다

데이터는 하나의 일정이지만 주간 그리드에서는 시작 요일과 다음 요일에 걸쳐 보입니다. 학부모 시간표는 자정을 넘는 항목을 렌더링 직전에 두 조각으로 분리했습니다.

if (item.nextDayOffset !== 1) {
  return [{ ...item, displayKey: `${baseKey}-start` }];
}

return [
  {
    ...item,
    endTime: "24:00:00",
    displayKey: `${baseKey}-start`,
  },
  {
    ...item,
    dayOfWeek: (item.dayOfWeek + 1) % 7,
    startTime: "00:00:00",
    displayKey: `${baseKey}-end`,
  },
];

저장 모델을 두 건으로 쪼개지 않고 표시 모델만 분리했기 때문에 수정과 삭제는 계속 하나의 일정 단위로 처리할 수 있습니다. 화면의 각 조각에는 별도 displayKey를 부여해 React 목록 키도 충돌하지 않게 했습니다.

다음 그림은 하나의 저장 일정이 화면에서 어떻게 나뉘는지 보여줍니다.

flowchart LR
  A[월요일 23:30 시작] --> B[월요일 24:00까지 표시]
  A --> C[화요일 00:00부터 표시]
  C --> D[화요일 01:00 종료]

저장 단위와 표시 단위를 분리하면 데이터 정합성을 유지하면서도 주간 그리드의 구조에 맞게 표현할 수 있습니다.

24시간 전체를 그리되 실제 범위만 보여줬다

자정 일정 지원을 위해 시간표의 기본 좌표는 0시부터 24시까지 확장했습니다.

export const GRID_START_HOUR = 0;
export const GRID_END_HOUR = 24;

하지만 모든 사용자에게 24시간 높이를 그대로 보여주면 대부분 빈 영역입니다. 실제 일정의 가장 이른 시작과 가장 늦은 종료를 계산하고, 위아래 한 시간의 여백을 더한 범위만 화면에 표시했습니다. 경계는 0~24시를 넘지 않도록 제한했습니다.

이 선택으로 심야 일정도 표현하면서 주간 시간표가 불필요하게 길어지는 것을 피했습니다.

입력·엑셀·PDF도 같은 규칙을 사용해야 했다

시간표 화면만 고치면 데이터가 들어오는 다른 경로에서 다시 모순이 생깁니다. 이번 변경에는 다음 항목이 함께 포함됐습니다.

특히 PDF는 화면처럼 스크롤할 수 없습니다. 고정된 한 페이지에 전체 시간표를 축소하면 글자가 지나치게 작아지므로, 높이뿐 아니라 가로 폭을 기준으로도 페이지를 나누도록 수정했습니다.

확인된 결과와 한계

API, 관리자 웹, 학부모 웹에 같은 자정 넘김 규칙을 적용했습니다.

현재 모델은 종료 오프셋을 0 또는 1로 제한하므로 24시간을 넘는 일정은 표현하지 않습니다. 수업 시간표라는 도메인에서는 이 제약이 의도에 맞지만, 숙박이나 장기 이벤트라면 시작·종료 일시를 완전한 날짜로 저장하는 편이 적합합니다.

시간만 저장할 때도 날짜 의미는 사라지지 않는다

이번 작업에서 얻은 기준은 세 가지입니다.

  1. endTime < startTime 같은 추측을 데이터 모델의 의미로 사용하지 않습니다.
  2. 반복 일정은 하루가 아니라 반복 주기 전체의 좌표로 변환해 비교합니다.
  3. 저장 단위와 화면 표시 단위가 다르면 렌더링 단계에서 조각을 만듭니다.

자정을 넘는 일정의 핵심은 시간 포맷이 아니라 날짜 경계를 명시적으로 모델링하는 것이었습니다.