Next.js에서 SSR/CSR timezone 불일치가 만든 Hydration 예외, Cookie로 timezone 동기화해서 해결하기

2026. 4. 5. 22:34·웹 프론트엔드
이 글은 React 19, Next.js 16, App Router를 기준으로 설명합니다.
글에서 설명하는 코드 예시는 설명을 위해서 간단하게 작성했습니다.

문제 상황

글로벌 서비스를 개발하면서 서버에서 날짜를 어떻게 내려줄지 고민했습니다. Java의 LocalDateTime은 타임존 정보를 포함하지 않아, 서버의 실행 환경(OS 타임존 설정 등)에 따라 같은 값이 서로 다른 시점을 의미하게 될 위험이 있습니다. 반면 Instant는 UTC 기준의 절대 시각을 나타내기 때문에 어느 환경에서든 동일한 시각을 보장합니다. 글로벌 사용자를 대상으로 하는 서비스라면 서버는 Instant로 내려주고, 브라우저에서 사용자의 timezone에 맞게 포맷하는 것이 자연스러운 선택이었습니다.

 

구현은 단순해 보였습니다. 서버에서 ISO 8601 형식의 문자열을 내려주고, 브라우저에서 Intl.DateTimeFormat으로 포맷하면 됐습니다. 그런데 Next.js에서 이 날짜를 화면에 렌더하자 다음과 같은 예외가 발생했습니다.

Hydration failed because the server rendered text didn't match the client.

 

아래는 이 글에서 코드 예시를 들기 위한 Next.js App Router 구조입니다. 먼저 problem 코드를 통해서 설명하겠습니다.

.
├── app
│   ├── layout.tsx
│   ├── page.tsx
│   │
│   ├── api
│   │   └── client
│   │       └── timezone
│   │           └── route.ts
│   ├── problem
│   │   ├── BrokenDate.tsx
│   │   └── page.tsx
│   └── solution
│       ├── LocalDate.tsx
│       ├── page.tsx
│       └── TimeZoneCookieSync.tsx
...
// app/problem/BrokenDate.tsx

'use client';

const ISO = '2026-04-03T15:01:00.000Z';

export function BrokenDate() {
  return (
    <p>{new Date(ISO).toLocaleDateString('en-US', { dateStyle: 'long' })}</p>
  );
}
// app/problem/page.tsx

import { BrokenDate } from './BrokenDate';

export default function ProblemPage() {
  return (
    <div>
      <h1>문제: Hydration 불일치</h1>
      <p>기준 날짜(UTC): 2026-04-03T15:01:00.000Z</p>
      <p>타임존 없이 toLocaleDateString() 호출 결과 (서버 TZ ≠ 브라우저 TZ면 다름):</p>
      <BrokenDate />
    </div>
  );
}

원인은 Next.js의 렌더링 방식에 있었습니다. 'use client'로 선언한 Client Component라도 첫 렌더는 서버에서 실행됩니다. 서버는 UTC 기준으로, 브라우저는 사용자의 로컬 타임존(예: Asia/Seoul)으로 동일한 ISO 문자열을 해석하고 포맷하는 과정에서 출력 결과물이 불일치하게 되었고, React는 이를 Hydration 예외로 처리했습니다.

  • 서버 (UTC): 2026-04-03T15:01:00.000Z → April 3, 2026
  • 브라우저 (Asia/Seoul, UTC+9): 2026-04-03T15:01:00.000Z → April 4, 2026
참고: local에서 Next.js를 실행할 경우 OS의 timezone을 사용합니다. 보통 배포 환경에서 UTC를 사용하게 됩니다. 로컬 환경에서 동일하게 실행하기 위해서는 "TZ=UTC pnpm run dev" 를 이용하시면 됩니다.

해결 방법

핵심은 SSR이 날짜를 포맷할 때 사용한 timezone과 CSR이 사용하는 timezone을 일치시키는 것입니다. 서버는 요청이 들어오기 전까지 브라우저의 timezone을 알 수 없으므로, 클라이언트가 마운트된 시점에 타임존을 파악해 서버에 쿠키로 저장하고, 이후 발생하는 SSR 요청에서 해당 쿠키를 참조하도록 개선했습니다.

1. 브라우저 timezone을 Cookie에 저장하기

클라이언트가 마운트되면 Intl.DateTimeFormat으로 브라우저 timezone을 읽어 API를 통해 서버에 전달합니다.

// app/solution/TimeZoneCookieSync.tsx

'use client';

import { useEffect } from 'react';

export function TimeZoneCookieSync() {
  useEffect(() => {
    const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
    if (!timeZone) return;
    void fetch('/api/client/timezone', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ timeZone }),
    });
  }, []);
  return null;
}

API Route는 전달받은 timezone 문자열이 유효한 IANA timezone인지 검증한 뒤 Cookie에 저장합니다.

// app/api/client/timezone/route.ts

import { type NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const { timeZone } = (await request.json()) as { timeZone: string };

  try {
    Intl.DateTimeFormat(undefined, { timeZone });
  } catch {
    return NextResponse.json({ error: 'invalid timezone' }, { status: 400 });
  }

  const response = NextResponse.json({ ok: true });
  response.cookies.set('tz', encodeURIComponent(timeZone), {
    path: '/',
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
  });
  return response;
}

2. SSR에서 Cookie를 읽어 timezone 결정하기

Server Component에서 tz Cookie를 읽습니다. Cookie가 없는 첫 방문에는 브라우저의 timezone을 알 수 없으므로 serverFormatted를 null로 두고, 클라이언트에서 마운트 후 브라우저 timezone으로 날짜를 채웁니다.

// app/solution/page.tsx

import { cookies } from 'next/headers';
import { LocalDate } from './LocalDate';
import { TimeZoneCookieSync } from './TimeZoneCookieSync';

const ISO = '2026-04-03T15:01:00.000Z';

function normalizeTimeZone(tz: string | undefined): string {
  if (!tz) return 'UTC';
  try {
    Intl.DateTimeFormat(undefined, { timeZone: tz });
    return tz;
  } catch {
    return 'UTC';
  }
}

export default async function SolutionPage() {
  const cookieStore = await cookies();
  const raw = cookieStore.get('tz')?.value;
  const hasCookie = raw != null;
  const timeZone = normalizeTimeZone(hasCookie ? decodeURIComponent(raw) : undefined);

  const serverFormatted = hasCookie
    ? new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeZone }).format(new Date(ISO))
    : null;

  return (
    <div>
      <TimeZoneCookieSync />
      <h1>해결: tz 쿠키로 타임존 동기화</h1>
      <p>기준 날짜(UTC): {ISO}</p>
      <p>서버가 읽은 타임존 (tz 쿠키): {timeZone}</p>
      <p>변환된 날짜: <LocalDate iso={ISO} serverFormatted={serverFormatted} /></p>
    </div>
  );
}

3. 날짜 표시 Component

Cookie가 있을 때(serverFormatted가 전달된 경우)는 서버에서 이미 포맷한 문자열을 초기값으로 사용해 깜빡임 없이 바로 표시합니다. Cookie가 없는 첫 방문에는 스켈레톤을 보여주다가 useEffect에서 브라우저 timezone으로 포맷한 날짜로 교체합니다.

// app/solution/LocalDate.tsx

'use client';

import { useState, useEffect } from 'react';

interface Props {
  iso: string;
  serverFormatted: string | null;
}

export function LocalDate({ iso, serverFormatted }: Props) {
  const [display, setDisplay] = useState(serverFormatted);

  useEffect(() => {
    const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
    const d = new Date(iso);
    if (!Number.isNaN(d.getTime())) {
      setDisplay(
        new Intl.DateTimeFormat('en-US', {
          dateStyle: 'long',
          timeZone: tz,
        }).format(d),
      );
    }
  }, [iso]);

  if (display === null) return <span>—</span>;
  return <>{display}</>;
}

요청 흐름 정리

시점 tz Cookie SSR timezone 화면
첫 방문 없음 UTC (Fallback) 스켈레톤 -> 브라우저 TZ 날짜
TimeZoneCookieSync 실행 후 저장됨 - -
재방문 이후 있음 브라우저 Timezone 바로 정확한 날짜

마치며

이번 글에서는 글로벌 서비스에서 Instant로 내려준 날짜를 브라우저 timezone으로 포맷하는 과정에서 SSR/CSR 불일치로 Hydration 예외가 발생한 상황을 다뤘습니다. tz Cookie에 브라우저 timezone을 저장하고 서버가 이를 읽어 날짜를 미리 포맷하는 방식으로 해결했습니다. GitHub도 동일하게 tz Cookie를 사용하는 것을 보면 비슷한 방식으로 접근하는 것 같습니다.

 

글 읽어주셔서 감사합니다. 잘못된 부분이 있다면 댓글로 남겨주시길 바랍니다.

참고

  • https://react.dev/link/hydration-mismatch
  • https://react.dev/errors/418

'웹 프론트엔드' 카테고리의 다른 글

React에서 Zustand 기반 Event Bus로 SSE(Server-Sent Events) 이벤트 깔끔하게 관리하기  (0) 2025.05.21
마크다운 에디터에서 이미지 업로드 후 링크로 삽입하는 방법  (2) 2025.02.03
낙관적 업데이트와 디바운스로 [좋아요 기능] 사용자 경험 향상시키기 with Tanstack Query  (0) 2025.01.22
CSS로 최대 라인 수와 최소 라인 수를 고정하는 방법을 알아보자  (0) 2025.01.01
모바일에서 vh대신 svh를 사용해서 주소창을 제외한 높이를 구해보자  (0) 2024.12.22
'웹 프론트엔드' 카테고리의 다른 글
  • React에서 Zustand 기반 Event Bus로 SSE(Server-Sent Events) 이벤트 깔끔하게 관리하기
  • 마크다운 에디터에서 이미지 업로드 후 링크로 삽입하는 방법
  • 낙관적 업데이트와 디바운스로 [좋아요 기능] 사용자 경험 향상시키기 with Tanstack Query
  • CSS로 최대 라인 수와 최소 라인 수를 고정하는 방법을 알아보자
alstn113
alstn113
웹 프론트엔드, 서버 개발에 관한 이야기를 다룹니다 :D
  • alstn113
    alstn113's devlog
    alstn113
  • 전체
    오늘
    어제
    • 분류 전체보기 (64)
      • 서버 (31)
      • 웹 프론트엔드 (6)
      • 협업 (3)
      • 우아한테크코스 6기 백엔드 (12)
      • 책, 영상, 블로그 정리 (10)
      • 회고 (1)
  • 블로그 메뉴

    • 홈
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    회고
    플러피
    우아한테크코스
    글쓰기
    굿폰
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
alstn113
Next.js에서 SSR/CSR timezone 불일치가 만든 Hydration 예외, Cookie로 timezone 동기화해서 해결하기
상단으로

티스토리툴바