매년 1월 1일마다 내 API가 망가졌다

내 API는 1월 1일 정확히 00:00 UTC에 망가졌다.

사용자의 자정에 망가진 것이 아니었다. UTC 자정에 망가졌다. 도쿄의 사용자들은 현지 시간으로 오전 9시부터 깨진 데이터를 보게 되었다.

테스트는 통과했다. 스테이징 환경에서도 잘 작동했다. 오류는 오직 프로덕션 환경에서만 발생했는데, 프로덕션 서버가 스테이징과 다른 타임존을 사용했기 때문이다.

문제는 이 로직에 있었다:

function getDailyReport(date) {
  const start = new Date(date).toISOString().split('T')[0];
  const end = new Date(start + 'T23:59:59Z');

  return db.reports.findMany({
    where: {
      createdAt: { gte: new Date(start), lt: end }
    }
  });
}

"2026-01-01"처럼 시간 정보가 없는 날짜를 전달하면, 시스템은 로컬 타임존을 사용한다.

우리 스테이징 서버는 UTC를 사용했다. 하지만 프로덕션 서버는 US-East를 사용했다. 이로 인해 모든 날짜 쿼리에서 5시간의 오프셋이 발생했다.

모든 쿼리에서 5시간 분량의 데이터를 놓쳤다. 수치가 충분히 비슷해 보여서 1년 동안 아무도 눈치채지 못했다.

해결책은 간단했다. 문자열에 시간을 추가하여 UTC를 강제하는 것이다:

function getDailyReport(date: string) {
  const start = new Date(`${date}T00:00:00Z`);
  const end = new Date(`${date}T23:59:59.999Z`);

  return db.reports.findMany({
    where: {
      createdAt: { gte: start, lt: end }
    }
  });
}

코드 수정은 몇 초면 끝났다. 하지만 시스템을 바로잡는 데는 일주일이 걸렸다. 우리는 다음 네 가지 작업을 수행했다:

• 환경이 UTC로 유지되도록 CI에 타임존 assertion을 추가했다. • 모든 컨테이너를 동일하게 유지하기 위해 모든 Dockerfile에 TZ=UTC를 설정했다. • 배포 스크립트에 타임존 체크를 추가했다. • 타임존 정보가 없는 Date 생성자를 찾아낼 린터(linter) 규칙을 작성했다.

타임존 버그는 위험하다. 시스템을 다운시키지 않기 때문이다. 대신 올바르게 보이는 잘못된 데이터를 만들어낸다. 사용자는 에러 페이지를 보는 대신, 잘못된 숫자를 보게 되고 그 숫자를 믿게 된다.

다음 세 가지 규칙을 따르라:

  • 시스템 타임존을 절대 믿지 마라. 항상 TZ=UTC를 설정하라.
  • 타임존 없이 날짜를 파싱하지 마라.
  • CI 타임존이 프로덕션과 일치한다고 가정하지 마라.

Source: https://dev.to/kollittle/my-api-broke-every-january-1st-the-timezone-bug-i-should-have-caught-in-code-review-51hb