일요일

Next.js Hydration Error 해결 — 서버/클라이언트 불일치 원인과 디버깅 완전 가이드

🔍 검색 키워드: Next.js hydration error, hydration failed, Text content does not match server-rendered HTML, Hydration Mismatch, next.js 하이드레이션 에러 해결, useEffect hydration, next.js 서버 클라이언트 불일치

Next.js 프로젝트를 띄웠는데 콘솔에 이런 에러가 뜨는 순간 멘탈이 흔들린다.

Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Text content did not match. Server: "2026-06-21" Client: "2026-06-20"

에러 메시지는 있는데 어디서 터지는지 안 보인다. 서버에서는 잘 렌더링되는 것 같은데 클라이언트에서 뭔가 다르다는 것이다. 이 글에서 원인별로 정리해서 어디서 문제인지 빠르게 찾을 수 있도록 쓴다.


하이드레이션(Hydration)이 뭔지 먼저

Next.js는 서버에서 HTML을 미리 렌더링하고, 클라이언트에서 그 HTML 위에 React가 붙는다(hydrate). 이때 서버에서 만든 HTML과 클라이언트가 첫 렌더링하는 결과물이 완전히 일치해야 한다. 다르면 React가 신뢰를 잃고 에러를 던진다.


원인 1: 브라우저 전용 API를 렌더 중에 접근

가장 흔한 케이스다. window, document, localStorage 같은 객체는 서버(Node.js)에 없다.

// ❌ 잘못된 코드 — 서버에서 window가 undefined라 에러
function MyComponent() {
  const width = window.innerWidth; // 서버에서 undefined
  return <div>너비: {width}px</div>;
}
// ✅ 해결 — useEffect 안에서만 접근
import { useState, useEffect } from 'react';

function MyComponent() {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    setWidth(window.innerWidth);
  }, []);

  return <div>너비: {width}px</div>;
}

또는 컴포넌트 전체를 클라이언트 전용으로 만들어야 한다면:

// ✅ dynamic import + ssr: false
import dynamic from 'next/dynamic';

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

원인 2: 날짜/시간 값 불일치

두 번째로 흔하고, 찾기 까다롭다. 서버는 UTC로 렌더링하고 클라이언트는 로컬 타임존으로 렌더링한다.

// ❌ 문제 코드
function PostDate() {
  const today = new Date().toLocaleDateString('ko-KR');
  return <span>{today}</span>;
  // 서버(UTC): "2026. 6. 21."
  // 클라이언트(KST +9): "2026. 6. 22."
}
// ✅ 해결 1 — 서버에서 데이터로 날짜를 받아 props로 전달
function PostDate({ date }: { date: string }) {
  return <span>{date}</span>;
}

// 서버 컴포넌트에서:
export default function Page() {
  const date = new Date().toISOString().split('T')[0]; // "2026-06-21" (UTC 기준 고정)
  return <PostDate date={date} />;
}
// ✅ 해결 2 — 클라이언트 전용 렌더링
function PostDate() {
  const [date, setDate] = useState('');

  useEffect(() => {
    setDate(new Date().toLocaleDateString('ko-KR'));
  }, []);

  if (!date) return null;
  return <span>{date}</span>;
}

원인 3: Math.random() / Date.now() 같은 불확정 값

서버와 클라이언트에서 각각 한 번씩 실행되므로 결과가 달라진다.

// ❌ 문제 코드
function Avatar() {
  const id = Math.floor(Math.random() * 1000); // 서버 42, 클라이언트 731
  return <img src={`/avatar/${id}.jpg`} />;
}

// ✅ 해결 — userId 기반의 결정론적 값 사용
function Avatar({ userId }: { userId: number }) {
  return <img src={`/avatar/${userId % 100}.jpg`} />;
}

원인 4: 인증 상태 불일치 (Clerk, NextAuth 등)

인증 라이브러리 쓸 때 자주 발생한다. 서버는 쿠키 없이 렌더링하고, 클라이언트는 로그인 상태를 감지해서 다른 UI를 그린다.

// ✅ 해결 - 인증 상태에 따라 분기되는 UI는 클라이언트 전용으로
'use client';

import { useUser } from '@clerk/nextjs';

export default function Header() {
  const { isLoaded, isSignedIn } = useUser();

  if (!isLoaded) return null; // 로드 전엔 아무것도 렌더링 안 함

  return isSignedIn ? <UserButton /> : <SignInButton />;
}

원인 5: Partial Prerendering(PPR) + Suspense 경계 없음 (Next.js 15+)

Next.js 15 이상에서 PPR(Partial Prerendering)을 쓸 때, 동적 데이터 영역을 <Suspense>로 감싸지 않으면 미스매치가 난다.

// ✅ 해결 — 동적 컴포넌트를 Suspense로 래핑
import { Suspense } from 'react';

export default function Page() {
  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <DynamicContent />
    </Suspense>
  );
}

async function DynamicContent() {
  const data = await fetchDynamicData();
  return <div>{data.value}</div>;
}

원인 6: 빌드 캐시 오염

코드는 맞는데 .next 캐시가 오래된 경우다. 주로 use client 경계 변경 후 발생한다.

# 캐시 완전 삭제 후 재시작
rm -rf .next
npm run dev

# 프로덕션 빌드라면
rm -rf .next
npm run build
npm start

빠른 진단 체크리스트

증상 의심 원인 해결
window is not defined 브라우저 API 직접 접근 useEffect 안에서 접근
날짜/숫자 값 불일치 new Date(), Math.random() 렌더 중 사용 useEffect 또는 서버 props 전달
인증 UI 불일치 인증 상태 SSR isLoaded 체크 후 렌더
코드 변경 후 갑자기 발생 빌드 캐시 오염 rm -rf .next 후 재시작
PPR 활성화 후 발생 Suspense 경계 없음 동적 컴포넌트 Suspense 래핑

정리

Hydration Error는 "서버에서 만든 HTML과 클라이언트 첫 렌더가 다르다"는 한 가지 원인에서 파생된다. 원인 찾는 순서:

  1. 브라우저 전용 API(window, document, localStorage) 렌더 중 직접 접근 여부 확인
  2. new Date(), Math.random() 같은 비결정론적 값 렌더 중 사용 여부 확인
  3. .next 캐시 삭제 후 재시작
  4. 인증 라이브러리 상태 로드 완료 전 렌더 여부 확인
  5. PPR 설정과 Suspense 경계 검토

대부분은 1~3번에서 해결된다.

토요일

GitHub Actions Node.js 빌드 실패 완전 정리 — npm ci, 캐시, 의존성 에러 해결

Kubernetes OOMKilled 에러 해결 (Exit Code 137) — Spring Boot/Java Pod 메모리 초과 완전 정리

🔍 검색 키워드: kubernetes oomkilled, exit code 137, pod oomkilled 해결, spring boot kubernetes memory, k8s OOMKilled java, 쿠버네티스 메모리 에러, kubectl oomkilled 원인

증상

Pod를 describe 했더니 이런 게 나온다.

State:          Running
  Last State:   Terminated
    Reason:     OOMKilled
    Exit Code:  137
    Started:    ...
    Finished:   ...

kubectl get pods에서 STATUS가 OOMKilled 또는 Error로 뜨고, Restarts 카운터가 계속 올라간다.

원인

컨테이너가 설정된 resources.limits.memory를 초과해서 커널이 강제로 프로세스를 죽인 것이다. Exit Code 137은 SIGKILL(128 + 9)을 의미한다.

Java 기반 Spring Boot 앱에서 특히 자주 발생하는 이유가 있다:

  • JVM íž™ 크기는 컨테이너 limit와 별개로 동작
  • JVM은 기본적으로 호스트 ì „ì²´ 메모리를 기준으로 힙을 잡으려 함
  • Off-heap 메모리(Metaspace, Stack, Direct Buffer 등)ê°€ íž™ 외에 추가로 소비됨

결과적으로 -Xmx512m 설정에 limit를 512Mi로 주면 반드시 OOMKilled 된다.

빠른 진단

1. 현재 메모리 사용량 확인

# Pod 메모리 현황
kubectl top pod <pod-name> -n <namespace>

# 상세 상태 확인
kubectl describe pod <pod-name> -n <namespace> | grep -A 10 "Last State"

# 최근 종료된 컨테이너 로그
kubectl logs <pod-name> --previous -n <namespace>

2. 리소스 설정 확인

kubectl get pod <pod-name> -o jsonpath='{.spec.containers[*].resources}' -n <namespace>
{
  "limits": { "memory": "512Mi" },
  "requests": { "memory": "256Mi" }
}

3. 노드 전체 메모리 현황

kubectl top nodes
kubectl describe node <node-name> | grep -A 5 "Allocated resources"

체크리스트

항목확인 방법정상 기준
Pod OOMKilled 여부kubectl describe pod → Reason: OOMKilledCompleted 또는 Running
현재 메모리 사용량kubectl top podlimit의 70% 이하
JVM 힙 설정JAVA_OPTS 환경변수limit의 50~75%
Metaspace 제한-XX:MaxMetaspaceSize256m 이하 권장
requests ≤ limitsDeployment YAML항상 requests ≤ limits
OOM Heap Dump-XX:+HeapDumpOnOutOfMemoryError파일 생성 확인

해결 방법

방법 1: JVM 힙을 컨테이너 limit에 맞게 명시 설정

가장 흔한 실수. limit 대비 JVM 힙 비율을 반드시 맞춰야 한다.

# deployment.yaml
containers:
  - name: my-spring-app
    image: my-app:latest
    resources:
      requests:
        memory: "512Mi"
        cpu: "250m"
      limits:
        memory: "1Gi"
        cpu: "1000m"
    env:
      - name: JAVA_OPTS
        value: "-Xms256m -Xmx768m -XX:MaxMetaspaceSize=256m -XX:+UseContainerSupport"

메모리 배분 계산 예시 (limit: 1Gi = 1024Mi)

영역크기비고
JVM Heap (-Xmx)768Milimit의 약 75%
Metaspace256Mi-XX:MaxMetaspaceSize
Stack, Direct Buffer 등~100MiOS/JVM 관리
총계~1124Milimit 초과 → OOMKilled 위험!
위 예시도 빡빡하다. limit를 1.5Gi 이상으로 올리거나 Metaspace를 줄여야 한다.

방법 2: -XX:+UseContainerSupport 활용 (Java 11+)

Java 11 이상에서는 JVM이 컨테이너 limit를 자동 인식한다.

env:
  - name: JAVA_OPTS
    value: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:MaxMetaspaceSize=256m"

-XX:MaxRAMPercentage=75.0은 컨테이너 limit의 75%를 자동으로 힙에 할당한다. -Xmx 하드코딩 없이 limit만 조정해도 알아서 따라온다.

Java 8 사용자: update 191 이상이면 UseContainerSupport 지원됨. 미만이면 -Xmx를 직접 계산해서 설정해야 한다.

방법 3: 실제 메모리 누수인 경우 — 힙 덤프 분석

limit를 올려도 계속 OOMKilled 된다면 실제 누수를 의심해야 한다.

env:
  - name: JAVA_OPTS
    value: >-
      -Xmx768m
      -XX:+HeapDumpOnOutOfMemoryError
      -XX:HeapDumpPath=/tmp/heapdump.hprof
      -XX:+ExitOnOutOfMemoryError

덤프 파일 로컬로 복사:

kubectl cp <namespace>/<pod-name>:/tmp/heapdump.hprof ./heapdump.hprof

Eclipse MAT 또는 VisualVM으로 열어서 Leak Suspects Report 돌린다.

방법 4: Spring Boot Actuator로 메모리 실시간 모니터링

<!-- pom.xml -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# application.yaml
management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus
# JVM 메모리 사용량 확인
curl http://localhost:8080/actuator/metrics/jvm.memory.used
curl http://localhost:8080/actuator/metrics/jvm.memory.max

Prometheus + Grafana로 시계열 수집하면 OOMKilled 발생 패턴을 잡기 쉽다.

방법 5: Node.js / Python Pod라면

Node.js:

env:
  - name: NODE_OPTIONS
    value: "--max-old-space-size=768"
resources:
  limits:
    memory: "1Gi"

Python (Gunicorn):

command: ["gunicorn", "--workers=2", "--threads=2", "app:app"]
resources:
  limits:
    memory: "512Mi"

HPA와 VPA 연계

단기 해결은 limit 증가지만, 장기적으로는 자동 스케일링이 필요하다.

# HPA - 메모리 기준 스케일 아웃
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: my-spring-app-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-spring-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 70

정리

OOMKilled는 대부분 limit 설정 부족 아니면 JVM 힙과 limit 불일치에서 온다. Spring Boot 앱이라면 순서대로 확인:

  1. kubectl describe pod로 OOMKilled 확인
  2. kubectl top pod로 실사용량 파악
  3. JAVA_OPTS에서 -Xmx vs limit 비율 점검
  4. Java 11+이면 UseContainerSupport + MaxRAMPercentage로 교체
  5. 그래도 반복되면 힙 덤프 분석

limit를 무한정 올리는 게 해결책이 아니다. 적정 비율로 잡고, 모니터링으로 추세를 보면서 튜닝하는 게 맞다.