Next.js는 운영에서 멀쩡한데 로컬 개발 서버에서만 UI가 먹통이 되는 이유

Next.js 애플리케이션은 운영 빌드가 안정적이면 개발 서버도 같은 방식으로 움직일 것 같지만, 둘은 클라이언트 코드의 수명이 다릅니다. 제가 확인한 환경은 Next.js 16.2.7과 Turbopack이었고, 운영 사이트에서는 잘 눌리던 상단 메뉴가 로컬 개발 서버에서만 간헐적으로 먹통이 됐습니다.

버튼 하나의 문제로 시작했는데 콘솔에는 Fast Refresh가 전체 새로고침을 수행했다는 경고가 반복됐습니다. 동적으로 불러오는 채팅 위젯에서는 ChunkLoadError도 나왔습니다. Auth 세션 요청의 Failed to fetch도 함께 보였지만, 이 오류는 하나의 원인을 끝까지 확정하지 못했습니다. 그래서 이 글에서는 원인으로 단정하지 않습니다.

처음에는 스크롤과 버튼을 의심했습니다

문제가 인트로 다시 보기 버튼을 누른 뒤 잘 보였기 때문에 스크롤 이동부터 의심했습니다. 링크의 기본 동작, 푸터 버튼 위치, 회전 아이콘, 맨 위로 이동시키는 코드까지 하나씩 바꿨습니다. 화면이 최상단으로 올라가는 반응은 줄었지만 메뉴가 늦게 열리거나 전혀 반응하지 않는 경우는 남았습니다.

여기서 버튼 자체를 더 고치는 방향이 틀렸다는 걸 알았습니다. 클릭 이후 필요한 동적 모듈이 로드되지 않았고, 어떤 때는 모달이 사라진 뒤에도 페이지가 잠긴 상태였습니다. 운영에서는 재현되지 않았습니다.

개발 서버의 청크는 계속 교체됩니다

운영 빌드는 한 번 만들어진 해시 청크를 그대로 제공합니다. 사용자가 받은 HTML과 JavaScript 파일의 조합이 배포가 바뀌기 전까지 유지됩니다.

개발 서버는 다릅니다. 파일을 저장할 때 Turbopack이 모듈 그래프와 청크를 다시 만들고 Fast Refresh가 가능한 React 상태를 살려 둡니다. 오래 열린 탭이 이전 이름의 동적 청크를 요청하면 서버에는 그 파일이 이미 없을 수 있습니다. 제가 본 채팅 위젯의 오류가 이 경우였습니다.

개발 서버를 켠 상태에서 프로덕션 빌드도 실행하고 있었습니다. 둘 다 기본 .next 폴더를 사용하면 빌드가 개발 청크를 교체할 수 있습니다. 개발과 빌드의 출력 폴더를 분리했습니다.

const nextConfig = {
output: "standalone",
distDir:
process.env.NODE_ENV === "development"
? ".next-dev"
: ".next",
};

분리한 뒤에는 프로덕션 빌드를 실행해도 개발 서버의 청크가 같은 폴더에서 덮이지 않았습니다.

HMR은 화면 상태를 살리지만 cleanup까지 보장하지는 않았습니다

메뉴 먹통의 다른 축은 스크롤 잠금이었습니다. 모달을 열면 iOS까지 배경 스크롤을 막기 위해 body를 position: fixed로 바꾸고 현재 위치를 top에 저장했습니다. 정상적으로 닫히면 effect cleanup이 원래 스타일과 스크롤 위치를 복구합니다.

런타임 오류나 HMR 경계에서 모달 DOM이 먼저 사라지면 cleanup이 끝까지 실행되지 않는 경우가 있었습니다. 화면에는 열린 모달이 없는데 body는 fixed이고 data-scroll-lock만 남았습니다. 모듈 전역 카운터도 Fast Refresh 때 다시 초기화될 수 있었습니다. 이 상태에서는 버튼이 문제없는 것처럼 보여도 페이지와 고정 내비게이션의 상호작용이 어긋납니다.

잠금 상태를 모듈 변수가 아니라 window에 보관했습니다. 숫자 카운터 대신 effect마다 고유한 토큰을 만들었습니다.

type ScrollLockWindow = Window & {
__duolabsScrollLock?: {
owners: Set<symbol>;
saved: SavedScrollState | null;
};
};

const host = window as ScrollLockWindow;
host.__duolabsScrollLock ??= {
owners: new Set(),
saved: null,
};

화면에 aria-modal="true"인 요소가 하나도 없는데 잠금 속성만 남아 있으면 고아 잠금으로 판단했습니다. DOM 변경, 탭 복귀, 포커스 시점에 검사하고 body 스타일과 스크롤을 복원했습니다. 내비게이션 전체에 pointer-events: none을 주던 CSS도 제거했습니다. 오버레이가 실제로 열려 있을 때는 오버레이의 z-index가 클릭을 막으면 충분했습니다.

청크 오류는 개발 환경에서 한 번만 복구했습니다

출력 폴더를 나눠도 오래 열린 탭이나 컴파일 중인 순간에는 청크 오류가 생길 수 있습니다. 개발 환경에서만 전역 error와 unhandledrejection을 듣고 ChunkLoadError 패턴을 찾았습니다. 발견하면 최신 HTML과 청크를 받도록 한 번 새로고침합니다.

const isChunkFailure = (text: string) =>
/ChunkLoadError|Failed to load chunk|Loading chunk .* failed/i.test(text);

if (isChunkFailure(message)) {
sessionStorage.setItem("dev-chunk-recovery", String(Date.now()));
window.location.reload();
}

실제 컴파일 오류에서 무한 새로고침이 생기지 않도록 30초 쿨다운을 넣었습니다. 메뉴를 눌러 동적 모듈을 열려던 중 오류가 났다면 의도한 메뉴도 세션 스토리지에 잠깐 저장하고, 새 문서가 올라온 뒤 그 동작을 한 번 이어서 실행했습니다.

동적 모듈을 기다리는 동안 아무 표시가 없으면 정상적인 지연도 먹통처럼 보였습니다. 클릭 직후 로딩 상태를 먼저 보여 주고, 개발 환경의 import에는 제한 시간을 뒀습니다. 이 변경은 오류 복구와 별개지만 진단할 때 도움이 됐습니다. 사용자가 누른 사실과 모듈이 열린 사실을 나눠서 볼 수 있었기 때문입니다.

로컬에서 필요 없는 원격 요청도 줄였습니다

로컬 개발 서버가 원격 데이터베이스에 닿지 못하는 날에는 분석 이벤트가 연결 대기 상태로 쌓였습니다. 이 요청들이 HMR과 동적 청크 응답까지 붙잡지 않도록 개발 환경의 분석 수집을 기본 비활성화했습니다. 분석 기능을 확인할 때만 환경 변수로 명시적으로 켭니다.

Prisma의 개발 연결에는 3초 제한을 추가했습니다. 운영의 연결 정책은 건드리지 않았습니다. Auth 세션의 Failed to fetch가 모두 데이터베이스 때문이었다고 말할 수는 없지만, 원격 연결이 닫힌 상태에서 요청 하나가 오래 대기하는 문제는 분리할 수 있었습니다.

다음에는 운영과 개발을 같은 앱으로 보지 않으려고 합니다

이번에는 버튼, 스크롤, 인트로를 차례로 고치며 한참 돌아갔습니다. 화면에 보이는 증상은 메뉴 먹통이었지만 개발 환경에서만 재현된다는 사실이 더 중요한 단서였습니다.

다음에 같은 증상을 만나면 세 가지부터 확인할 생각입니다. 개발 서버와 빌드가 출력 폴더를 공유하는지, HMR 이후 DOM 잠금과 body 스타일이 남았는지, 오래 열린 탭이 사라진 동적 청크를 요청하는지입니다. 운영에서 멀쩡하다는 사실은 버그가 없다는 뜻이 아니라, 개발 서버만 가진 생명주기를 먼저 보라는 신호였습니다.