Docker는 UTC, 사용자는 KST: Intl.DateTimeFormat만으로 만든 tz-aware ISO Week 유틸리티

주간 리포트의 주차 경계가 서버 컨테이너(UTC)와 사용자(KST) 사이에서 어긋났다. date-fns-tz나 luxon을 도입하는 대신, Node 22의 full-ICU Intl.DateTimeFormat만으로 IANA 타임존 ISO week 유틸리티를 구현한 기록.

1. 문제 상황

1.1 "일요일 밤에 주차가 어긋나요"

주간 리포트를 출시하고 며칠 뒤, 일요일 밤 11시경에 이런 버그 리포트가 들어왔습니다.

"지금 진행 리포트가 다음 주로 넘어가 있어요. 11시 30분인데?"

이 시점에 서버 로그를 보니:

[GET /api/reports/weekly]
  server time (UTC): 2026-04-05T14:30:00Z
  getISOWeek(now) → { year: 2026, week: 15 }
  user sees:        15주차 (다음 주)

사용자 시계(KST)로는 4월 5일 일요일 23:30, 즉 14주차의 마지막 순간이어야 했습니다. 하지만 UTC로는 이미 월요일 새벽 0시가 가까운 14:30, ISO week 계산이 UTC 기준 월요일을 기점으로 삼아 15주차를 반환했습니다.

1.2 왜 발생했나

기존 getISOWeek는 이렇게 생겼습니다:

// ❌ Before: 로컬 시간 vs UTC 혼용
export function getISOWeek(date: Date): { year: number; week: number } {
  const d = new Date(date);
  d.setHours(0, 0, 0, 0);  // ← 서버 로컬 타임존 기준으로 자정 이동!
  // ...
}

export function getWeekRange(year: number, week: number) {
  const jan4 = new Date(year, 0, 4);  // ← 로컬 시간 생성자
  // ...
}

로컬에서 돌리면 KST가 기준이어서 문제가 없었지만, Docker 컨테이너는 UTC. 두 환경에서 동작이 달라지는 전형적인 "로컬에선 되는데 배포하면 안 돼요" 버그였습니다.

1.3 진짜 요구사항은 더 컸다

이 버그를 고치면서 깨달은 사실: 단순히 UTC로 통일하면 되는 게 아니라, 조직별로 다른 타임존을 지원해야 한다는 요구사항이 따라옵니다.

  • 한국 본사 조직: Asia/Seoul
  • 도쿄 지사: Asia/Tokyo
  • 뉴욕 지사: America/New_York (DST 있음)

각 조직의 "월요일 00:00"은 서로 다른 UTC 순간입니다. 그리고 이 차이는 주차 경계를 계산할 때 직접적으로 영향을 미칩니다.


2. 원인 분석

2.1 "월요일 00:00"은 어느 순간인가

ISO 8601 주차는 월요일부터 일요일, 1월 4일이 포함된 주가 1주차입니다. 여기서 숨겨진 질문: "월요일 00:00"은 어느 타임존의 00:00인가?

기준 월요일 00:00의 UTC 순간
UTC 기준 월요일 00:00Z
KST 기준 일요일 15:00Z (KST = UTC+9)
NYC(EDT) 기준 월요일 04:00Z (EDT = UTC−4)

서로 다른 타임존 사용자에게 "이번 주 리포트"를 보여주려면, 그들의 현지 시간 기준으로 월요일 00:00을 계산해야 합니다. 단순히 UTC만 쓰면 일부 사용자는 한국 시간으로 월요일인데 "지난 주"를 보게 됩니다.

2.2 기존 라이브러리 도입 검토

가장 먼저 떠오르는 선택지:

  • date-fns-tz — date-fns의 tz 확장. 잘 알려진 라이브러리.
  • luxon — Moment.js 후속, tz 기본 지원.
  • js-joda — Java의 Joda-Time 포팅.

세 가지 모두 훌륭하지만, 이번 프로젝트에서는 도입을 보류했습니다. 이유:

  1. Node 22는 full-ICU 기본 탑재Intl.DateTimeFormat이 모든 IANA 타임존을 지원합니다.
  2. 번들 사이즈 부담date-fns-tz는 ~15KB gzip. 유틸 함수 몇 개 추가하는 데 라이브러리를 끌어오기 부담스러웠습니다.
  3. 의존성 = 미래 비용 — 메이저 업데이트 대응, 보안 패치, API 변경 추적.
  4. 범위가 작다 — 필요한 함수는 getISOWeek, getWeekRange, getWeekLabel, navigateWeek 4개뿐.

Intl.DateTimeFormat으로 얼마나 걸릴지 실험해보고, 너무 복잡하면 그때 라이브러리를 쓰기로 했습니다. 결과는 ~90줄에 테스트까지 포함하여 180줄. 수용 가능한 수준이었습니다.


3. 해결 방법

3.1 접근 전략: UTC 일원화 → tz-aware 확장

한 번에 tz-aware를 만들려 하지 말고, 2단계로 쪼갰습니다:

  1. Step 1 — UTC 일원화: 서버 로컬 타임존에 의존하지 않도록 모든 산술을 Date.UTC() / getUTC* 기반으로 통일. 이 단계만 해도 Docker(UTC)와 KST 개발자 환경의 불일치가 사라집니다.
  2. Step 2 — tz-aware 확장: 공개 함수에 timezone 파라미터를 추가하고, 내부적으로 UTC ↔ 지정 tz 현지 시간 변환을 거쳐 계산.

3.2 Step 1: UTC 일원화

// ✅ After Step 1: 모든 산술을 UTC 기준으로
export function getISOWeek(date: Date): { year: number; week: number } {
  // 로컬 Date도 UTC로 "해석"
  const localUtc = new Date(Date.UTC(
    date.getUTCFullYear(),
    date.getUTCMonth(),
    date.getUTCDate(),
  ));
  // ISO 8601: 그 주의 목요일 날짜로 연도/주차 결정
  localUtc.setUTCDate(localUtc.getUTCDate() + 3 - ((localUtc.getUTCDay() + 6) % 7));
  const isoYear = localUtc.getUTCFullYear();
  const jan4 = new Date(Date.UTC(isoYear, 0, 4));
  const week = 1 + Math.round(
    ((localUtc.getTime() - jan4.getTime()) / 86400000
      - 3 + ((jan4.getUTCDay() + 6) % 7)) / 7,
  );
  return { year: isoYear, week };
}

핵심 변화 한 줄로:

  • Before: d.setHours(0, 0, 0, 0) — 로컬 자정
  • After: Date.UTC(getUTCFullYear, getUTCMonth, getUTCDate) — UTC 자정

이것만으로 getWeekRange, getWeekLabel, navigateWeek 모두 서버 타임존 무관하게 결정론적으로 동작합니다.

트레이드오프: 모든 주차가 "UTC 기준"이 됩니다. 한국 사용자가 일요일 밤 11시에 조회하면 여전히 월요일로 넘어가 있는 것처럼 보입니다. 이를 해결하려면 Step 2가 필요합니다.

3.3 Step 2: tz-aware 변환 헬퍼

핵심은 두 가지 원자 연산입니다:

  • utcToZonedParts(date, tz) — UTC Date → tz의 현지 시간 파트(Y-M-D h:m weekday)
  • zonedWallClockToUtc(Y, M, D, h, m, tz) — tz의 현지 시간 → 해당 순간의 UTC Date

3.3.1 utcToZonedParts

const WEEKDAY_MAP: Record<string, number> = {
  Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7,
};

export function utcToZonedParts(date: Date, timezone: string) {
  const formatter = new Intl.DateTimeFormat("en-US", {
    timeZone: timezone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    hour12: false,
    weekday: "short",
  });
  const parts = formatter.formatToParts(date);
  const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
  const hourRaw = parseInt(get("hour"), 10);
  return {
    year: parseInt(get("year"), 10),
    month: parseInt(get("month"), 10),
    day: parseInt(get("day"), 10),
    hour: hourRaw % 24,  // ICU의 "24" edge case 처리
    minute: parseInt(get("minute"), 10),
    weekday: WEEKDAY_MAP[get("weekday")] ?? 1,
  };
}

숨겨진 함정 하나: 일부 ICU 버전은 자정을 "24"로 반환합니다 (24:00 표기). hour % 24로 방어했습니다.

3.3.2 zonedWallClockToUtc — 1회 수렴 알고리즘

반대 방향은 더 까다롭습니다. "KST로 2026-04-06 00:00은 UTC로 몇 시인가?" 라는 질문이지만, 타임존 오프셋은 그 순간의 UTC가 결정되어야 알 수 있습니다 (DST 때문에).

여기서 쓴 트릭:

  1. 목표 현지 시간을 "가짜 UTC"로 취급해 초기 추정 만들기
  2. 그 UTC 순간이 지정 tz에서 실제로 무슨 현지 시간인지 관찰
  3. 관찰값과 목표값의 차이 = tz offset
  4. 실제 UTC = 목표 UTC − offset
export function zonedWallClockToUtc(
  year: number, month: number, day: number,
  hour: number, minute: number,
  timezone: string,
): Date {
  const targetMs = Date.UTC(year, month - 1, day, hour, minute);
  const probe = new Date(targetMs);
  const parts = utcToZonedParts(probe, timezone);
  const observedMs = Date.UTC(
    parts.year, parts.month - 1, parts.day, parts.hour, parts.minute,
  );
  const offset = observedMs - targetMs;
  return new Date(targetMs - offset);
}

DST 걱정 없는 이유: 이 함수는 주간 리포트 경계 계산(월요일 00:00)에만 쓰입니다. 대부분 타임존에서 spring-forward gap은 일요일 02:00~03:00 사이이고, 월요일 00:00은 항상 그 바깥입니다. 그래서 1회 수렴으로 충분합니다. 임의 시각에 정확한 변환이 필요하다면 2차 반복을 추가하거나 (DST gap 내부는 명세가 모호함) 경계를 명시적으로 처리해야 합니다.

3.4 Step 2를 기존 함수에 꽂기

export function getISOWeek(
  date: Date,
  timezone: string = "Asia/Seoul",
): { year: number; week: number } {
  // 1) tz의 현지 시간 Y-M-D로 ISO week 계산
  const { year: y, month: m, day: d } = utcToZonedParts(date, timezone);
  // 2) UTC 도메인에서 ISO week 산술 (현지 시간 Y-M-D를 가짜 UTC로 올려 계산)
  const localUtc = new Date(Date.UTC(y, m - 1, d));
  localUtc.setUTCDate(localUtc.getUTCDate() + 3 - ((localUtc.getUTCDay() + 6) % 7));
  const isoYear = localUtc.getUTCFullYear();
  const jan4 = new Date(Date.UTC(isoYear, 0, 4));
  const week = 1 + Math.round(
    ((localUtc.getTime() - jan4.getTime()) / 86400000
      - 3 + ((jan4.getUTCDay() + 6) % 7)) / 7,
  );
  return { year: isoYear, week };
}

export function getWeekRange(
  year: number,
  week: number,
  timezone: string = "Asia/Seoul",
): { startDate: Date; endDate: Date } {
  const jan4Utc = new Date(Date.UTC(year, 0, 4));
  const jan4Parts = utcToZonedParts(jan4Utc, timezone);

  // 현지 시간 Y-M-D를 가짜 UTC로 올려 요일 재확인
  const jan4LocalAsUtc = new Date(Date.UTC(
    jan4Parts.year, jan4Parts.month - 1, jan4Parts.day,
  ));
  const jan4Weekday = utcToZonedParts(jan4LocalAsUtc, timezone).weekday;

  const week1MondayLocalDay = jan4Parts.day - (jan4Weekday - 1);
  const targetDate = new Date(Date.UTC(
    jan4Parts.year,
    jan4Parts.month - 1,
    week1MondayLocalDay + (week - 1) * 7,
  ));
  const targetParts = utcToZonedParts(targetDate, timezone);

  const startDate = zonedWallClockToUtc(
    targetParts.year, targetParts.month, targetParts.day, 0, 0, timezone,
  );
  const endDate = new Date(startDate.getTime() + 7 * 86400000 - 1);
  return { startDate, endDate };
}

중간에 "현지 시간 Y-M-D를 가짜 UTC로 올렸다가 다시 현지 시간으로 변환"하는 두 단계가 반복됩니다. 이유는 달력 산술은 UTC 도메인에서 하고, 경계 변환만 tz-aware로 하려는 분리입니다.


4. 조직별 타임존 설정 UI

4.1 Organization 스키마 확장

model Organization {
  // ... 기존 필드
  timezone String @default("Asia/Seoul")
}

기본값 Asia/Seoul로 기존 행이 자동 backfill됩니다. 엔트리포인트가 db push 기반이라 migration 파일 대신 schema.base.prisma 직접 수정으로 충분했습니다.

4.2 Curated allowlist

전 세계 400+ IANA 타임존을 드롭다운에 나열할 필요는 없습니다. 주 사용자 기반에 맞게 6개만 추렸습니다.

// src/lib/timezones.ts
export const SUPPORTED_TIMEZONES = [
  { value: "Asia/Seoul", label: "한국 표준시 (KST, UTC+9)" },
  { value: "Asia/Tokyo", label: "일본 표준시 (JST, UTC+9)" },
  { value: "America/Los_Angeles", label: "로스앤젤레스 (PST/PDT, UTC-8/-7)" },
  { value: "America/New_York", label: "뉴욕 (EST/EDT, UTC-5/-4)" },
  { value: "Europe/London", label: "런던 (GMT/BST, UTC+0/+1)" },
  { value: "Europe/Berlin", label: "베를린 (CET/CEST, UTC+1/+2)" },
] as const;

export const DEFAULT_TIMEZONE = "Asia/Seoul";

4.3 Two-tier validation

UI는 allowlist로 제한하지만, API는 Intl.DateTimeFormat이 받아주는 모든 IANA 문자열을 허용합니다. 이렇게 하면:

  • 일반 사용자는 드롭다운 안에서만 선택 → 실수 방지
  • 관리자가 직접 API로 Europe/Helsinki를 보내도 검증 통과 → 확장 여지
function isValidTimezone(tz: string): boolean {
  try {
    new Intl.DateTimeFormat("en-US", { timeZone: tz });
    return true;
  } catch {
    return false;
  }
}

5. 테스트 전략

5.1 여러 타임존 across

테스트는 **KST (비-DST) + New York (DST) + London (DST)**의 3개 tz를 모두 검증합니다.

import { describe, it, expect } from "vitest";
import { getWeekRange, getISOWeek, utcToZonedParts } from "@/lib/week-utils";

describe("getWeekRange with timezone", () => {
  it("Asia/Seoul — 2026-W15의 월요일 KST 00:00 = 일 15:00 UTC", () => {
    const { startDate, endDate } = getWeekRange(2026, 15, "Asia/Seoul");
    expect(startDate.toISOString()).toBe("2026-04-05T15:00:00.000Z");
    expect(endDate.toISOString()).toBe("2026-04-12T14:59:59.999Z");
  });

  it("America/New_York (EDT) — 월 00:00 EDT = 월 04:00 UTC", () => {
    const { startDate } = getWeekRange(2026, 15, "America/New_York");
    expect(startDate.toISOString()).toBe("2026-04-06T04:00:00.000Z");
  });

  it("Europe/London (BST) — 월 00:00 BST = 일 23:00 UTC", () => {
    const { startDate } = getWeekRange(2026, 15, "Europe/London");
    expect(startDate.toISOString()).toBe("2026-04-05T23:00:00.000Z");
  });
});

describe("utcToZonedParts", () => {
  it("UTC 자정 → KST 오전 9시", () => {
    const parts = utcToZonedParts(new Date("2026-04-06T00:00:00Z"), "Asia/Seoul");
    expect(parts).toMatchObject({ year: 2026, month: 4, day: 6, hour: 9 });
  });

  it("'24:00' edge case를 0시로 정규화", () => {
    // 일부 ICU가 자정을 24:00으로 반환하는 케이스
    // hourRaw % 24 == 0이어야 함
    const parts = utcToZonedParts(new Date("2026-04-06T15:00:00Z"), "Asia/Seoul");
    expect(parts.hour % 24).toBeLessThan(24);
  });
});

5.2 CI 환경 차이 방어

CI는 UTC, 개발자 로컬은 KST일 때 new Date(2026, 3, 6) 같은 로컬 Date 리터럴은 값이 9시간 틀어집니다. 실제로 개발 중 CI에선 통과했지만 로컬에선 실패하는 케이스가 있었습니다.

해법은 간단합니다: 테스트 픽스처의 모든 Date를 Date.UTC()로 통일.

// ❌ Before: 로컬 타임존에 의존
const startedAt = new Date(2026, 3, 6, 10, 0);

// ✅ After: 모든 환경에서 같은 UTC 순간
const startedAt = new Date(Date.UTC(2026, 3, 6, 10, 0));

6. 핵심 개념 정리

6.1 "UTC 현지 시간"과 "존 현지 시간"의 분리

이 라이브러리의 코드를 읽을 때 가장 혼란스러운 부분은, 같은 new Date(Date.UTC(Y, M, D)) 객체를 두 가지 의미로 사용한다는 점입니다:

  1. 실제 UTC 순간jan4Utc처럼 "2026년 1월 4일 UTC 자정"
  2. "현지 시간 Y-M-D"를 담는 컨테이너localUtc처럼 "이 Date 객체의 UTC 필드가 tz의 현지 시간 값이다" (실제 UTC 순간은 무시)

달력 산술(주차 계산)은 2번 의미로 하고, DB 저장/비교는 1번 의미로 합니다. 경계에서 utcToZonedParts / zonedWallClockToUtc로 변환합니다.

6.2 왜 Intl.DateTimeFormat.formatToParts를 쓰나?

toLocaleString()도 tz 변환을 해주지만 반환이 문자열이라 파싱이 필요합니다. formatToParts는 구조화된 배열([{type: 'year', value: '2026'}, ...])을 반환해서 언어/지역 무관하게 안정적인 파싱이 가능합니다.

6.3 full-ICU 의존성

이 구현의 암묵적 전제: 런타임이 full-ICU를 탑재하고 있다는 것입니다.

  • Node 18+: 기본 탑재
  • Node 13 이하: 별도 설치 필요 (full-icu 패키지)
  • 브라우저: 모던 브라우저 모두 지원

Node 버전이 낮은 레거시 환경이라면 이 전제가 무너질 수 있어서 Intl.DateTimeFormat("en-US", { timeZone: "Asia/Seoul" })이 먼저 동작하는지 확인이 필요합니다.


7. 베스트 프랙티스

7.1 체크리스트

  • [ ] 서버 코드에서 setHours, new Date(y, m, d) 같은 로컬 타임존 API 금지
  • [ ] Date 산술은 항상 Date.UTC() / getUTC* 기반으로
  • [ ] 사용자에게 보여주는 경계(일/주/월)는 사용자 tz 기준으로 별도 변환
  • [ ] 테스트 픽스처는 Date.UTC()로 생성 (CI/local 차이 방어)
  • [ ] 타임존 목록은 curated allowlist + API 검증 ("UI 좁게, API 넓게")
  • [ ] hour % 24로 ICU "24" edge case 방어

7.2 안티패턴

// ❌ 1. 로컬 자정으로 정규화
d.setHours(0, 0, 0, 0);

// ❌ 2. 로컬 Date 생성자
new Date(2026, 3, 6);

// ❌ 3. "YYYY-MM-DD" 파싱 후 setHours
const d = new Date("2026-04-06");  // UTC 자정
d.setHours(0);                     // 로컬 자정 — 타임존마다 다른 순간

// ❌ 4. toLocaleString 파싱
const str = d.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });
const [year, month, day] = str.split(/[^0-9]/).filter(Boolean);  // 언어 설정에 취약

8. FAQ

Q. 왜 UTC로만 통일하면 안 되나요?

A. 서버 저장용 데이터(inProgressAt, completedAt)는 UTC로 저장해서 맞습니다. 문제는 사용자에게 보여주는 주차 경계가 UTC 기준이면 한국 사용자가 일요일 밤 11시에 조회할 때 "다음 주"로 넘어가 보인다는 점입니다. 저장은 UTC, 표시는 tz-aware — 이 구분이 핵심입니다.

Q. date-fns-tz와 비교하면 성능은?

A. 내부적으로 Intl.DateTimeFormat을 쓰는 건 동일하므로 유사한 수준입니다. Intl.DateTimeFormat 인스턴스 생성 비용이 약간 있어서 호출당 ~0.05ms 정도. 반복 호출이 많다면 formatter를 모듈 레벨에서 캐싱할 수 있습니다.

Q. DST gap에 걸리는 현지 시간은 어떻게 처리하나요?

A. 이 구현은 주간 리포트의 월요일 00:00에만 쓰이므로 gap 외부라 안전합니다. 임의 시각 변환이 필요해지면:

  • zonedWallClockToUtc 2차 반복 — 첫 추정이 gap 내부일 때 관찰값으로 재조정
  • 명시적 정책 — gap 내부 입력은 에러 throw 또는 "skip forward" 선택

Q. navigateWeek(year, week, delta)도 timezone 파라미터를 받나요?

A. 현재 구현에서는 getWeekRange(year, week) 기본값 Asia/Seoul로 계산한 뒤 delta * 7일을 더합니다. 타임존 무관하게 "7일 뒤"가 다음 주의 시작이므로 tz 파라미터가 없어도 정확합니다. 단, 극단적 DST edge case(주 경계가 23시간 또는 25시간인 주)를 완벽하게 처리하려면 결과를 getISOWeek(shifted, tz)로 다시 정규화해야 합니다 — 코드에 이미 그렇게 되어 있습니다.

Q. 조직별 타임존이 바뀌면 과거 리포트는 어떻게 되나요?

A. startDate/endDate는 DB에 이미 저장되어 있으므로 과거 리포트의 주차 경계는 유지됩니다. 미래 리포트만 새 tz로 계산됩니다. 가장 안전한 설계는 "한 번 정하면 잘 바꾸지 말 것"이고, UI에서도 이를 경고하는 것이 좋습니다.


9. 참고 자료


10. 다음 단계

이전 글 JavaScript 타임존 함정 피하기: UTC vs Local 날짜 처리에서 다룬 "정오(12:00) 패턴"은 단일 타임존 가정 하에서 DATE 타입 저장 시 하루 밀림을 막는 기법입니다. 이번 글의 Intl.DateTimeFormat 변환은 멀티 타임존이 필요할 때의 다음 단계입니다. 두 기법은 배타적이 아니라 상황에 따라 선택하는 도구입니다.