내 Next.js 16 버튼이 프로덕션에서는 보이지만 전혀 동작하지 않았다. 그 이유는.

발행: (2026년 6월 6일 AM 06:55 GMT+9)
12 분 소요
원문: Dev.to

출처: Dev.to

작은 테스트 페이지를 추가해서 오류 모니터링이 프론트엔드 충돌을 잡아내는지 확인했습니다. 제목, 문단 하나, 클릭 시 오류를 발생시키는 빨간 버튼 하나. 코드 리뷰에 보내고 “별거 아니어서 죄송합니다”라고 사과하고 싶을 정도로 사소한 코드였습니다.

로컬에서는 완벽히 동작했습니다. 클릭 → 오류 → 캡처 → 끝.

프로덕션에서는 버튼이 렌더링됐지만 클릭해도 아무 일도 일어나지 않았습니다. 콘솔에도 오류가 없고, 네트워크 요청도 없으며, 눈에 보이는 피드백도 없습니다. 마치 버튼처럼 보이는 죽은 HTML이었습니다.

저는 iOS 마켓 인텔리전스 툴을 만들고 있는 1인 개발자이며, 이 버그 때문에 사전 출시 스프린트에서 한 시간을 허비했습니다. 원인은 실제 Next.js 16의 함정이었고, 해결 방법은 문서에 잘 나와 있습니다. “이거 작동해야 하는데 왜 안 될까”와 “아, 이거 때문이구나” 사이의 과정을 공유하고 싶습니다—Next 16이 퍼지면 같은 형태의 버그가 많은 사람들을 물게 될 테니까요.


내가 가지고 있던 코드

"use client"
import { useSearchParams } from "next/navigation"

export default function SentryTestPage() {
  const key = useSearchParams().get("key")

  if (!key) return 

  return (
     { throw new Error("test crash") }}>
      Trigger crash
    
  )
}

Enter fullscreen mode

Exit fullscreen mode

?key= 쿼리 파라미터를 읽는 페이지입니다. 키가 없으면 잠긴 화면을 보여주고, 있으면 버튼을 보여줍니다. 아주 사소하죠.

프로덕션에서 ?key=... 를 URL에 넣고 실행하면 버튼은 보이지만 클릭해도 아무 일도 일어나지 않았습니다. 이벤트도, 오류도, 로그도 없었습니다. 버튼은 사실 div가 버튼인 척하고 있었던 겁니다.


처음 시도한 방법들 (그리고 왜 틀렸는지)

이론 1: 환경 변수가 설정되지 않음

NEXT_PUBLIC_SENTRY_DSN 이 빌드에 포함되지 않았을까 싶었습니다. 그래서 Sentry가 초기화되지 않아 오류를 잡지 못한다는 가정이었죠. Vercel에서 변수가 존재함을 확인했고, 빌드 캐시 없이 재배포도 해봤지만 여전히 버튼은 죽어 있었습니다.

이것은 확인해볼 바른 내용이었습니다—NEXT_PUBLIC_* 변수는 빌드 시점에 인라인되며, 나중에 추가해도 재빌드 없이는 반영되지 않으니까요. 하지만 버그는 아니었습니다.

이론 2: 떠다니는 위젯이 버튼을 가림

앱에 오른쪽 하단에 고정된 피드백 버튼이 있습니다. Z‑index 문제는 보이지 않는 클릭 방해 요소의 고전적인 원인입니다. 검사해보니 버튼은 pointer-events: auto 를 가지고 있었고, 위에 다른 요소는 없었습니다. 아니었습니다.

이론 3: 빌드가 오래됨

Cmd + Shift + R 로 강력 새로고침 후 시크릿 모드에서도 버튼은 여전히 반응하지 않았습니다. 캐시가 원인이 아니었습니다.

이론 4: React가 페이지를 하이드레이션하지 않음

이것이 가장 근접했습니다. 만약 React가 서브트리를 하이드레이션하지 못한다면, HTML은 존재하지만 JavaScript 이벤트 핸들러가 붙지 않아 현재 상황과 똑같이 보이게 됩니다.


실제 버그가 무엇인가

Next.js 16(그리고 13+에서도)에서는 useSearchParams() 를 읽는 모든 컴포넌트를 Suspense 경계 안에 넣어야 합니다. 경계 없이 사용하면 다음과 같은 일이 벌어집니다:

  • 서버 사이드 렌더링 시 useSearchParams() 는 빈 파라미터 객체를 반환합니다. 따라서 컴포넌트는 “키가 없음” 분기(null)를 렌더링합니다.
  • 클라이언트에서는 URL에 ?key=... 가 있기 때문에 useSearchParams() 가 실제 파라미터를 반환하고, 컴포넌트는 “버튼” 분기를 렌더링하려 합니다.
  • React는 서버가 만든 HTML을 하이드레이션하려고 시도하지만, 불일치가 발견됩니다: 서버는 null 을, 클라이언트는 버튼을 렌더링하려고 합니다.
  • React는 해당 서브트리의 하이드레이션을 중단합니다. 개발 환경에서는 콘솔에 경고가 찍히지만(보면 확인 가능), 프로덕션에서는 조용히 멈춥니다.
  • 서버가 보낸 HTML—어느 분기가 있었든—은 그대로 DOM에 남아 있지만, 이벤트 핸들러는 전혀 붙지 않습니다.

이 마지막 점이 가장 잔인합니다. 제 경우 서버는 잠긴 화면을 렌더링했지만, 브라우저는 버튼 으로 교체되었습니다. 정확히 어떻게 버튼이 나타났는지는 확실히 알 수 없지만, 스트리밍 SSR의 특이점 때문에 클라이언트가 버튼을 그렸지만 React가 연결을 거부한 것으로 추정됩니다. 어쨌든 제가 본 버튼은 살아 있지 않은 HTML이었습니다.

로컬에서는 개발 모드가 불일치를 더 관대하게 처리하기 때문에 경고를 찍고 DOM을 패치해 주었지만, 프로덕션 모드에서는 그렇지 않았습니다.


해결 방법

Next.js 16 문서에 제시된 패턴은 useSearchParams() 호출을 자식 컴포넌트 안에 넣고, 그 자식을 Suspense 로 감싸는 것입니다:

"use client"
import { Suspense } from "react"
import { useSearchParams } from "next/navigation"

function PageContent() {
  const key = useSearchParams().get("key")
  if (!key) return 
  return (
     { throw new Error("test crash") }}>
      Trigger crash
    
  )
}

export default function SentryTestPage() {
  return (
    <Suspense fallback={null}>
      <PageContent />
    </Suspense>
  )
}

Enter fullscreen mode

Exit fullscreen mode

왜 동작하냐면, Suspense 가 React에게 초기 서버 렌더링 시점에 해당 내용이 준비되지 않을 수도 있음을 알려주기 때문입니다. 서버는 fallback(여기서는 null)만 내보내고, 클라이언트가 실제 콘텐츠를 렌더링합니다. 서버가 어떤 분기를 확정하지 않으니 불일치가 사라지고, 클라이언트가 깨끗하게 takeover 하면서 버튼이 정상적으로 하이드레이션되고 onClick 이 붙습니다.

이 수정 후 버튼은 첫 배포부터 정상 작동했습니다.


먼저 해야 했던 일

빌드 로그가 실제로 힌트를 주고 있었습니다. Next.js는 useSearchParams()Suspense 없이 사용한 페이지에 대해 프로덕션 빌드 시 다음과 같은 경고를 출력합니다:

Entire page deopted into client-side rendering. Read more: ...

Enter fullscreen mode

Exit fullscreen mode

런타임 로그만 보던 저는 이 경고를 놓쳤습니다. 빌드 출력에서 deopted 라는 단어를 grep 하면 30초 안에 버그를 찾을 수 있었을 텐데, 한 시간을 허비한 셈이죠.

따라서 제가 새긴 규칙은:

로컬에서는 동작하고 프로덕션에서는 깨질 때, 먼저 빌드 로그를 확인하라. 프레임워크가 이미 문제를 알려주고 있을 가능성이 높습니다. 단지 그 위치를 보지 못했을 뿐이죠.

두 번째로 기억할 점:

React 이벤트 핸들러가 작동하지 않을 때는, 코드보다 하이드레이션을 먼저 의심하라. 버튼 자체가 고장난 것이 아니라, 렌더된 HTML이 JavaScript와 연결되지 않은 것입니다. 디버깅 대상이 달라지고, 해결책도 달라집니다.


이 버그를 더 악화시킨 패턴

이번 스프린트 동안 대부분의 실제 코드는 AI 파트너(Claude Code)가 작성했습니다. AI는 빠르게 설득력 있는 코드를 만들어 내는 데 뛰어나지만, 제가 useSearchParams() 와 버튼을 넣어 달라고 요청했을 때 나온 코드는 개발 모드에서는 잘 동작합니다—왜냐하면 대부분의 튜토리얼과 데모 앱이 개발 모드에서 실행되기 때문이죠.

Suspense 경계 요구 사항은 프레임워크 업그레이드 가이드에만 적혀 있는 세세한 내용이며, 튜토리얼에는 거의 등장하지 않습니다. 따라서 학습 데이터가 이를 포착하지 못합니다. 저는 직접 확인해야 했습니다.

교훈은 “AI가 나쁘다”가 아니라 **“AI는 실행은 빠르지만 진단은 위험하다”**는 점입니다. AI는 다음 코드를 내가 읽는 속도만큼 빨리 써줄 수 있지만, 문제가 생겼을 때는 설득력 있는

0 조회
Back to Blog

관련 글

더 보기 »

모바일 한여름 열풍

!Cover image for Mobile Midsommer Madnesshttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploa...