금요일

Next.js Hydration Mismatch 에러 해결

🔍 검색 키워드: Next.js hydration 불일치, hydration mismatch 에러, Next.js 서버사이드 렌더링, suppressHydrationWarning

Next.js Hydration Mismatch 에러 해결

증상

Warning: Expected server HTML to contain a matching <div> in <div>.
This means the server HTML, the browser's DOM, and the virtual DOM are out of sync.

또는:

Text content did not match. Server: "2026-07-30 14:32" Client: "2026-07-30 14:33"

개발 환경에서 콘솔에 경고가 나타나고, 프로덕션에서 UI가 깜빡거리거나 스타일이 제대로 적용되지 않습니다.

원인

Next.js는 서버에서 HTML을 생성한 후 클라이언트에서 해당 DOM에 이벤트 리스너를 연결(hydration)합니다. 서버와 클라이언트의 렌더링 결과가 다르면 hydration mismatch 발생:

// ❌ 서버와 클라이언트 결과가 다른 경우

export default function Clock() { const now = new Date(); // 서버와 클라이언트 시간 다름 return <div>{now.toLocaleString()}</div>; }

// 서버 렌더링: "2026-07-30 08:00:00 UTC" // 클라이언트 렌더링: "2026-07-30 14:00:00 KST" // → Mismatch!

흔한 원인들: - 시간/날짜 함수 사용 (Date, new Date()) - 난수 생성 (Math.random()) - localStorage/sessionStorage 접근 - 조건부 렌더링이 서버/클라이언트마다 다름 - CSS-in-JS의 서버 스타일과 클라이언트 스타일 불일치

해결방법

1. useEffect 활용 (권장)

클라이언트에서만 렌더링하는 콘텐츠는 마운트 후 렌더링:
import { useState, useEffect } from 'react';

export default function Clock() { const [time, setTime] = useState(null);

useEffect(() => { setTime(new Date().toLocaleString()); }, []); // 클라이언트에서만 실행

// 서버 렌더링: null 또는 placeholder 반환 if (!time) return <div>Loading...</div>; return <div>{time}</div>; }

2. suppressHydrationWarning (간단한 경우)

export default function Counter() {
  return (
    <div suppressHydrationWarning>
      {Math.random()}
    </div>
  );
}

// 또는 특정 속성만 억제 export default function Div() { return ( <div suppressHydrationWarning={true}> Content </div> ); }

3. Dynamic Import with ssr: false

// components/ClientOnlyComponent.tsx
export default function ClientOnly() {
  return <div>{Math.random()}</div>;
}

// pages/index.tsx import dynamic from 'next/dynamic';

const ClientOnlyComponent = dynamic( () => import('@/components/ClientOnlyComponent'), { ssr: false } );

export default function Home() { return ( <div> <h1>Server Content</h1> <ClientOnlyComponent /> {/ 클라이언트에서만 렌더링 /} </div> ); }

4. useLayoutEffect 또는 useId (안정적 ID)

import { useId } from 'react';

export default function Modal() { const id = useId(); // 서버와 클라이언트에서 동일한 ID 생성

return ( <div id={id} data-modal={id}> Modal with stable ID </div> ); }

5. 실전 예제: Dark Mode Toggle

'use client'; // App Router의 경우

import { useState, useEffect } from 'react';

export default function ThemeProvider() { const [isDark, setIsDark] = useState(false); const [isMounted, setIsMounted] = useState(false);

useEffect(() => { // 로컬스토리지에서 테마 읽기 (클라이언트에서만) const saved = localStorage.getItem('theme') === 'dark'; setIsDark(saved); setIsMounted(true); }, []);

// 마운트 전: 기본값 표시 (서버 렌더링과 일치) if (!isMounted) { return <div className="light">Loading...</div>; }

// 마운트 후: 실제 테마 적용 return ( <div className={isDark ? 'dark' : 'light'}> <button onClick={() => setIsDark(!isDark)}> Toggle Theme </button> </div> ); }

6. 조건부 렌더링 안정화

// ❌ hydration mismatch 위험
const isClient = typeof window !== 'undefined';

export default function Component() { return isClient ? <ClientComponent /> : <ServerComponent />; }

// ✅ useEffect 사용 import { useEffect, useState } from 'react';

export default function Component() { const [isClient, setIsClient] = useState(false);

useEffect(() => { setIsClient(true); }, []);

if (!isClient) return <ServerComponent />; return <ClientComponent />; }

정리표

원인 증상 해결책
시간/날짜 함수 "Expected server HTML..." 경고 useEffect에서만 실행
localStorage 접근 서버/클라이언트 값 다름 마운트 후 접근
조건부 렌더링 UI 깜빡임 또는 깨짐 useLayoutEffect 또는 dynamic import
난수 생성 텍스트 불일치 경고 suppressHydrationWarning 또는 useId
CSS-in-JS 불일치 스타일 깜빡임 서버 스타일과 클라이언트 스타일 동기화
핵심: 서버에서 실행되는 코드와 클라이언트에서 실행되는 코드가 동일한 결과를 생성하도록 하거나, 클라이언트에서만 실행되는 로직을 useEffect로 분리하세요.

Nginx 502 Bad Gateway 에러 완벽 해결법

Kubernetes OOMKilled 에러 완벽 해결 가이드

🔍 검색 키워드: k8s OOMKilled, 쿠버네티스 메모리 부족, 포드 강제 종료, 메모리 리소스 요청, 리미트 설정

Kubernetes OOMKilled 에러 완벽 해결 가이드

1. 증상: OOMKilled 에러 메시지

Pod이 갑자기 재시작되고 로그에 다음과 같이 나타난다:

$ kubectl describe pod my-app-5f8d4c9d7b-xyz12
...
Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137
  Killed:       True
Events:
  Type    Reason     Age   Message
  ----    ------     ---   -------
  Normal  Created    2m    Created container app
  Normal  Started    2m    Started container app
  Normal  Killing    1m    Killing container app (killed due to memory limit exceeded)

Pod의 상태가 계속 Pending이거나 CrashLoopBackOff 상태에 빠진다. 다른 Pod들은 정상인데 특정 Pod만 반복적으로 재시작된다.

2. 원인 분석

메모리 리미트 설정 부족

애플리케이션이 사용하는 실제 메모리가 Pod에 할당된 메모리 한계를 초과했다. kubelet이 OOM(Out of Memory) 상태를 감지하면 강제로 Pod을 kill한다.

메모리 누수

애플리케이션 코드에 메모리 누수가 있어서 시간이 지날수록 메모리 사용량이 증가한다. Node의 물리적 메모리도 한계가 있어 OOMKilled 발생.

Node 자원 부족

Node 레벨의 메모리가 부족하면 kubelet은 eviction policy에 따라 Pod들을 순차적으로 종료한다.

사이드카 컨테이너

logging agent, service mesh proxy(Istio sidecar) 등 추가 컨테이너들의 메모리 누적.

3. 해결방법

방법 1: Memory Request/Limit 올바르게 설정

apiVersion: v1
kind: Pod
metadata:
  name: my-app
spec:
  containers:
  - name: app
    image: my-app:latest
    resources:
      requests:
        memory: "256Mi"    # Pod 스케줄링 시 보장받는 메모리
      limits:
        memory: "512Mi"    # 절대 초과 불가능한 한계
    livenessProbe:
      httpGet:
        path: /health
        port: 8080
      initialDelaySeconds: 30
      periodSeconds: 10

requests: Scheduler가 Node 할당 결정 시 사용
limits: cgroup을 통해 강제로 제한

방법 2: 메모리 사용량 모니터링

# Pod의 실제 메모리 사용량 확인
$ kubectl top pod my-app-5f8d4c9d7b-xyz12 -n default

NAME                     CPU(cores)   MEMORY(bytes)
my-app-5f8d4c9d7b-xyz12  150m         387Mi

# Node 전체 메모리 상태
$ kubectl top nodes
NAME          CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
worker-node1  1200m        60%    5432Mi          68%

Prometheus + Grafana로 시간대별 메모리 추이를 분석. 대부분 정상이지만 특정 시간에 메모리 스파이크 발생하는지 확인.

방법 3: 메모리 누수 디버깅 (Node.js 예시)

// Node.js 힙 덤프 수집
const heapdump = require('heapdump');

app.get('/heapdump', (req, res) => {
  const fileName = `/tmp/heapdump-${Date.now()}.heapsnapshot`;
  heapdump.writeSnapshot(fileName, (err, filename) => {
    if (err) return res.status(500).send(err);
    res.send(`Heap dump written to ${filename}`);
  });
});

// Kubernetes manifest에 보안 컨텍스트 추가
securityContext:
  readOnlyRootFilesystem: true
  runAsNonRoot: true
  allowPrivilegeEscalation: false
volumeMounts:
- name: tmp
  mountPath: /tmp
volumes:
- name: tmp
  emptyDir: {}

방법 4: HPA(Horizontal Pod Autoscaler) 설정

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: my-app-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 70

메모리 사용율이 70% 도달하면 자동으로 Pod 개수 증가.

4. 정리표

구분 내용 해결도
증상 Pod이 OOMKilled로 반복 재시작됨 -
원인 1 메모리 limit 설정 부족 ✓ 높음
원인 2 애플리케이션 메모리 누수 ✓ 중간
원인 3 Node 자원 전체 부족 ✓ 중간
원인 4 사이드카 컨테이너 누적 ✓ 낮음
해결 1 Request/Limit 재설정 즉시 효과
해결 2 메모리 모니터링 (top, Prometheus) 원인 파악 필수
해결 3 메모리 누수 디버깅 근본 해결
해결 4 HPA 자동 스케일링 장기 대책

핵심: k8s OOMKilled는 "메모리가 부족하다"는 신호. requests는 넉넉하게, limits는 필요한 만큼만 설정하고, 정기적으로 메모리 프로파일링을 해야 한다.