프론트엔드

시간대 버그가 로컬 머신에서 재현되지 않는 이유

서버는 UTC를, 랩톱은 현지 시간을 보내고, 문자열 슬라이싱은 그 차이를 숨깁니다. 왜 프로덕션에서만 문제가 발생하며, 로컬에서 강제로 재현하는 방법.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

내부 대시보드의 한 타임스탬프는 09:41로 표시되었습니다. 실제 이벤트는 18:41에 발생했습니다. 9시간 차이, 정확히 하나의 시간대 오프셋만큼 차이가 났고, 이 값을 생성한 코드는 몇 달 전에 아무도 모르게 배포되었습니다. 아무도 알아채지 못한 이유가 흥미로운 부분입니다. 모든 개발자 컴퓨터에서는 동일한 코드가 올바른 시간을 출력했습니다.

이것은 이름을 붙일 만한 특정한 실패 모드입니다. 버그는 형식화 로직에만 있는 것이 아닙니다. 버그는 서버가 시간을 직렬화하는 방식과 개발자 컴퓨터가 우연히 속해 있는 시간대 간의 상호작용에 있습니다. 이 두 가지가 일치할 때, 잘못된 구현이 올바르게 보입니다.

멀쩡해 보이지만 그렇지 않은 코드

서버는 타임스탬프를 RFC 3339 문자열로 반환합니다. 프런트엔드에서는 MM-DD HH:mm 형식이 필요합니다. 누군가 다음과 같이 당연해 보이는 코드를 작성했습니다.

// Renders "08-05 09:41" from "2026-08-05T09:41:42.240399Z"
function shortTime(iso) {
  return iso ? iso.slice(5, 16).replace('T', ' ') : '-';
}

여기에는 시간대 변환이 없습니다. Date 객체는 전혀 없습니다. 함수는 문자열의 5번째부터 16번째까지의 문자를 가져와 출력합니다. 서버가 어떤 시간대로 인코딩했든, 사용자는 그대로 보게 됩니다.

프로덕션 환경에서 컨테이너는 TZ가 설정되지 않은 채로 실행되며, 이는 UTC를 의미합니다. 페이로드는 "2026-08-05T09:41:42.240399Z"이고, 슬라이싱 결과는 08-05 09:41입니다. 사무실의 모든 시계를 현지 시간으로 읽는 운영자는 실제보다 9시간 늦은 숫자를 보게 됩니다.

노트북이 이 현상을 숨기는 이유

서울에 있는 개발자 머신에서 동일한 서버를 실행하면 Z가 직렬화되지 않습니다. Go의 time.Time은 위치 정보를 가지고 있으며, timestamptz 열을 읽는 드라이버는 그것을 세션 시간대에서 구체화합니다. TZ=Asia/Seoul로 설정된 머신에서는 JSON이 다음과 같이 나옵니다.

{ "startedAt": "2026-08-05T18:41:06.190270+09:00" }

이제 그것의 5번째부터 16번째 문자까지 잘라냅니다. 그러면 08-05 18:41을 얻게 되는데, 이는 올바릅니다. 함수가 무언가를 변환해서가 아니라, 서버가 이미 현지 시간을 내보냈고 단순한 잘라내기(slice)가 그 값을 보존했기 때문입니다.

그것이 바로 함정입니다. 함수에는 시간대 로직이 없으므로, 입력이 작성된 시간대를 그대로 반환합니다. 로컬 개발 환경에서는 우연히 대상 시간대를 제공하므로, 우연의 일치로 결과가 올바르게 나옵니다. 프로덕션 환경에서는 UTC를 제공하므로 결과가 틀리게 나옵니다. 두 경우 모두 코드는 동일합니다.

동일한 코드, 두 환경 1 로컬 2 UTC 개발 서버 운영 서버 slice() slice() 정확해 보임 9시간 차이 // 동일한 코드, 입력 시간대가 결과를 결정함

수정 사항을 신뢰하기 전에 프로덕션 조건을 강제로 설정하세요

표시 대상과 시간대가 일치하는 머신에서는 시간대 수정 사항을 확인할 수 없습니다. 문제가 있는 버전도 통과하므로 해당 환경에서의 확인은 무의미합니다.

프로덕션 환경에서 실제로 사용하는 시간대로 서버를 시작하세요:

TZ=UTC PORT=8099 go run . serve

이제 로컬 API는 프로덕션 환경에서 보내는 것과 바이트 단위로 동일한 "2026-08-05T09:41:06.190270Z"를 반환합니다. 페이지를 로드하십시오. 화면에 여전히 09:41이 표시된다면, 디버거가 연결되어 있고 2초의 편집 루프가 있는 상태에서 자신의 컴퓨터에서 결함을 재현한 것입니다.

이 단일 환경 변수는 재현 불가능한 프로덕션 보고를 평범한 로컬 버그로 바꿉니다. 버그가 존재하는 것과 같은 이유로 작동합니다. 즉, 서버의 직렬화는 프로세스 시간대를 따르고, 그 프로세스 시간대는 여러분이 설정할 수 있습니다.

두 가지 관련된 습관을 기르는 것이 좋습니다. 자신의 시간대와 멀리 떨어진 값으로 TZ를 설정하여 자동화된 테스트를 최소 하나 이상 실행하십시오. 자신의 시간대에서만 실행되는 테스트 스위트는 시간대 처리에 대해 아무것도 보증하지 않기 때문입니다. 그리고 버그 보고서에 "시간이 잘못되었습니다"라고 되어 있고 이를 재현할 수 없다면, 화면에 표시된 내용 대신 서버가 무엇을 보냈는지 물어보십시오. 페이로드가 몇 초 안에 문제를 명확히 해줍니다.

해결책은 먼저 파싱한 다음, 고정된 시간대에서 포맷하는 것입니다

입력이 실제 Date가 되면 문자열의 오프셋은 더 이상 중요하지 않습니다. new Date()Z+09:00을 동일하게 처리하여 둘 다 동일한 순간으로 해석합니다. 거기서부터 명시적인 시간대로 포맷합니다:

function kstParts(iso) {
  if (!iso) return null;
  const d = new Date(iso);
  if (isNaN(d.getTime())) return null;
  const parts = {};
  for (const { type, value } of new Intl.DateTimeFormat('en-US', {
    timeZone: 'Asia/Seoul', hourCycle: 'h23',
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit',
  }).formatToParts(d)) parts[type] = value;
  return parts;
}

해당 블록 내에서 내린 세 가지 결정은 설명할 가치가 있습니다.

브라우저의 시간대 대신 특정 시간대를 고정합니다. 순수한 toLocaleString()은 클라이언트 기기가 보고하는 모든 것을 따릅니다. 이는 각 사용자가 자신의 시계를 원하는 소비자 제품에는 올바른 방식입니다. 하지만 회사 내 모든 사람이 하나의 공유 타임라인을 읽고, 시간대가 잘못 구성된 노트북이 옆 사람과 다른 숫자를 조용히 생성하는 내부 도구에는 잘못된 방식입니다.

hourCycle: 'h23'을 설정합니다. en-US 로케일과 hour12: false의 조합은 자정을 24시로 렌더링하므로 00:0524:05로 나옵니다. 이는 하루에 한 번은 맞고 하루에 한 번은 틀린데, 이는 육안으로 발견하기 가장 어려운 종류의 버그입니다. 주기를 명명하면 모호함이 제거됩니다.

문자열 출력 대신 formatToParts를 사용합니다. 로케일 서식은 자체 구분 기호를 삽입하고 로케일 규칙에 따라 필드 순서를 정합니다. 이름이 지정된 부분을 가져와 직접 조립하면 로케일이 어떻게 처리했든 상관없이 출력이 안정적으로 유지됩니다.

날짜만 있는 필드가 하루만큼 밀리는 현상

명백한 대상은 시간을 표시하는 필드입니다. 놓치게 되는 것은 날짜만 표시하는 필드입니다:

expiresAt.slice(0, 10)   // "2026-08-05"

이는 눈에 보이는 시계가 없어 틀릴 일이 없어 보이므로 무해해 보입니다. 매일 9시간 동안 하루가 차이 납니다. 2026-08-06T07:00:00+09:00 만료는 2026-08-05T22:00:00Z이므로, 슬라이스는 8월 6일에 만료되는 항목에 대해 8월 5일을 출력합니다. 라이선스 페이지나 청구 화면의 경우, 이는 지원 티켓 발생의 원인이 되며, 눈에 띄게 잘못된 시계보다 진단하기가 더 어렵습니다.

코드베이스 전체에서 이를 찾을 때는 개념보다는 문자열 연산을 검색하십시오. ISO 문자열을 슬라이싱하는 것이 패턴이며, 대상이 날짜이든 전체 타임스탬프이든 동일하게 보입니다.

한 가지 범주는 안전하며 건드릴 필요가 없습니다. 바로 상대 시간입니다. Date.now() - new Date(iso)로부터 "3시간 전"을 계산하는 함수는 순간(instant)을 비교하며, 순간에는 시간대가 없습니다. 이것들은 그대로 두십시오.

변환이 이루어져야 할 위치

저장은 UTC로 유지됩니다. 데이터베이스는 timestamptz를 유지하고, API는 RFC 3339를 유지하며, 로그는 플랫폼이 내보내는 것을 그대로 유지합니다. 변환은 표시되는 순간에 단 한 번만 일어나며, 다른 곳에서는 일어나지 않습니다.

이는 스타일 선호의 문제가 아닙니다. 일찍 변환한다는 것은 모든 다운스트림 소비자가 자신이 내리지 않았고 볼 수도 없는 시간대 결정을 상속받는다는 것을 의미하며, 일단 포맷된 문자열에 포함되고 나면 그 값에서 시간대는 보이지 않게 됩니다. 가장자리에서 변환하면 전송 중에는 하나의 표현을, 경계에서는 하나의 프레젠테이션 규칙을 유지하게 됩니다.

그 규칙의 실용적인 형태는 작은 규모의 공유 포맷터 집합입니다. 흩어져 있는 slice() 호출은 스타일 문제가 아니라, 다음에 누군가가 만들 화면에 동일한 결함이 발생하는 원인입니다. 모든 호출 사이트가 명명된 헬퍼를 통하게 되면, 시간대를 한 번 수정하는 것으로 모든 것이 수정되며, 다음 개발자는 바로 가져다 쓸 수 있는 명확한 것이 생깁니다.

자주 묻는 질문

JavaScript 프런트엔드에만 영향을 미치나요? 아니요. 직렬화된 타임스탬프를 텍스트로 취급하는 모든 레이어는 동일한 문제에 노출됩니다. 원시 필드를 출력하는 템플릿, 문자열을 연결하는 로그 포맷터, 문자열 결합으로 만들어진 CSV 내보내기 등이 해당됩니다. JavaScript는 문자열을 자르는 것이 포맷터를 구성하는 것보다 짧기 때문에 특히 이런 실수를 하기 쉽습니다.

컨테이너 시간대를 사용자와 일치하도록 설정하면 되지 않나요? 그 방법은 작동하다가도 언젠가는 문제가 생깁니다. 그러면 시스템의 정확성이 배포 매니페스트의 환경 변수에 의존하게 되며, 이를 잊어버린 서비스는 잘못된 출력을 생성하게 됩니다. 또한 다른 시간대의 사용자가 생기는 순간 시스템이 깨집니다. 모든 곳에서 UTC를 유지하고 표시 시점에 변환하면 테스트 가능한 코드 내에서 불변성이 유지됩니다.

Intl.DateTimeFormat은 수백 개의 행이 있는 테이블에 사용하기에 충분히 빠른가요? 비용이 많이 드는 부분은 포맷터를 호출하는 것이 아니라 생성하는 것입니다. 큰 테이블을 렌더링하는 경우, 각 셀마다 포맷터를 생성하는 대신 모듈 스코프에서 한 번만 생성하여 재사용하세요. 수십 개 정도의 행에서는 그 차이를 측정할 수 없습니다.

영향을 받는 모든 위치를 찾았는지 어떻게 알 수 있나요? 의미가 아닌 메커니즘을 grep으로 찾으세요. 이름이 At 또는 Date로 끝나는 필드에 적용된 .slice(를 검색하고, 타임스탬프가 포맷터를 거치지 않고 DOM에 도달하는 모든 위치를 찾아보세요. 그런 다음 서버를 TZ=UTC로 전환하고 화면을 살펴보세요. 잘못된 값은 누락된 호출보다 눈에 띄기 쉽기 때문입니다.

관련 글