Next.js Windows Unknown system error -4094 원인 libuv와 client-reference-manifest 디버깅 파악하기

궁금하면 500원·2026년 9월 5일

미생의 개발 이야기

목록 보기
95/95

Next.js에서 Unknown system error -4094가 발생했다 — libuv부터 Windows 파일 I/O까지 추적하기

Next.js App Router 기반 프로젝트를 Windows에서 개발하다가 꽤 골치 아픈 오류를 만났다.

문제의 핵심은 다음과 같은 형태의 에러였다.

Error: UNKNOWN: unknown error, open 'D:\...\frontend\.next\server\app\api\...\route_client-reference-manifest.js'
{
  errno: -4094,
  code: 'UNKNOWN',
  syscall: 'open',
  path: '...\route_client-reference-manifest.js'
}

처음에는 client-reference-manifest라는 파일 이름 때문에 Next.js의 Client Component import 문제를 의심했다.

그런데 파고들수록 이야기가 달라졌다.

-4094는 Next.js가 정의한 에러 코드가 아니다.

Node.js 아래에 있는 libuv의 UV_UNKNOWN이다.

이번 글에서는 다음 순서로 문제를 정리한다.

  1. -4094가 실제로 무엇인지
  2. Windows에서 왜 UNKNOWN이 발생하는지
  3. client-reference-manifest는 왜 등장하는지
  4. Next.js 버그와 Windows 파일 시스템 문제를 어떻게 구분할지
  5. 개발 서버 재시작 스크립트에서 발견한 문제
  6. ProcMon과 Sysinternals로 실제 범인을 찾는 방법
  7. 최종적으로 어떤 순서로 진단해야 하는지

1. -4094는 Next.js 에러가 아니다

Node.js의 파일 I/O 아래에는 libuv라는 크로스 플랫폼 라이브러리가 있다.

Node.js에서 fs.open(), fs.readFile() 같은 파일 작업을 수행하면 Windows에서는 결국 libuv가 Win32 API와 통신한다.

libuv의 errno.h를 보면 다음 정의가 실제로 존재한다.

#define UV__EOF     (-4095)
#define UV__UNKNOWN (-4094)

즉,

-4094 = UV_UNKNOWN

이다.

Node.js 공식 문서 역시 Windows에서 OS가 반환한 오류 번호를 libuv가 정규화한 뒤 Node의 error.errno로 노출한다고 설명한다.
syscall, path, code 역시 이 과정에서 만들어지는 시스템 에러 정보다.

따라서 다음 에러를 보더라도

errno: -4094
syscall: 'open'

이를

Next.js 내부에서 -4094라는 에러를 발생시켰다.

라고 해석하면 안 된다.

더 정확하게 표현하면 다음과 같다.

Node.js가 수행하던 open 파일 작업이 실패했고, 그 결과가 libuv에서 UV_UNKNOWN(-4094)으로 표현됐다.


2. 그러면 UV_UNKNOWN은 정확히 무슨 뜻인가?

Windows에서 libuv는 Win32 오류를 POSIX와 비슷한 libuv 오류 코드로 변환한다.

핵심 함수가 다음 함수다.

uv_translate_sys_error(int sys_errno)

구조를 단순화하면 이런 형태다.

switch (sys_errno) {
  case ERROR_FILE_NOT_FOUND:
    return UV_ENOENT;

  case ERROR_LOCK_VIOLATION:
    return UV_EBUSY;

  case ERROR_SHARING_VIOLATION:
    return UV_EBUSY;

  ...

  default:
    return UV_UNKNOWN;
}

실제 libuv 코드에서도 switch에서 처리되지 않는 Win32 오류는 마지막 default에서 UV_UNKNOWN으로 변환된다.

따라서 -4094를 대략 다음과 같이 이해하면 된다.

libuv가 현재 상황을 자신이 알고 있는 표준 오류 코드로 표현하지 못했다.

다만 한 가지 주의할 점이 있다.

이를 무조건

OS 에러 번역에 실패했다.

라고만 정의하는 것도 너무 강한 표현이다.

libuv 내부에는 다른 작업의 실패를 처리하다가 적절한 시스템 오류를 얻지 못했을 때 직접 UV_UNKNOWN을 사용하는 fallback 경로도 존재한다.

예를 들어 Windows의 파일 open 구현에서는 CreateFileW() 이후 _open_osfhandle() 처리 과정에서도 UV_UNKNOWN fallback이 존재한다.

따라서 정확한 정의는 이렇게 잡는 편이 좋다.

UV_UNKNOWN(-4094)libuv가 더 구체적인 표준 오류 코드로 표현하지 못한 오류다.


3. syscall: 'open'은 꽤 중요한 단서다

Windows에서 libuv의 파일 open 구현을 보면 실제로 CreateFileW()를 호출한다.

file = CreateFileW(
  req->file.pathw,
  access,
  share,
  NULL,
  disposition,
  attributes,
  NULL
);

if (file == INVALID_HANDLE_VALUE) {
  SET_REQ_WIN32_ERROR(req, GetLastError());
  return;
}

즉 Node.js의

syscall: 'open'

은 적어도 다음 사실을 알려준다.

해당 파일 경로를 여는 작업을 수행하던 시점에 파일 시스템 레벨에서 작업이 실패했다.

중요한 건 이것만으로는 원인이 아직 안 나온다는 것이다.

다음은 전부 별개의 가능성이다.

Next.js artifact race
        ↓
파일이 순간적으로 존재하지 않음

Windows filesystem
        ↓
reparse point / junction / cloud file 문제

다른 프로세스
        ↓
파일 삭제·재생성과 충돌

백신 / 보안 프로그램
        ↓
파일 작업에 개입

libuv
        ↓
오류를 구체적으로 변환하지 못함

Node.js
        ↓
UNKNOWN / -4094

-4094는 원인 그 자체라기보다 조사가 시작되는 지점에 가깝다.


4. 처음 의심했던 SHARING_VIOLATION은 정정할 필요가 있었다

처음에는 다음과 같이 생각했다.

Defender나 다른 프로세스가 파일을 잡음
→ ERROR_SHARING_VIOLATION
→ libuv가 모름
→ -4094

그런데 현재 libuv 소스를 확인해보면 이 설명은 틀렸다.

다음 두 오류는 이미 매핑되어 있다.

case ERROR_LOCK_VIOLATION:
  return UV_EBUSY;

case ERROR_SHARING_VIOLATION:
  return UV_EBUSY;

따라서 일반적인 Sharing Violation이라면 보통

EBUSY

계열이 나오는 것이 자연스럽다.

마찬가지로

ERROR_ACCESS_DENIED

도 현재 libuv에서는

UV_EPERM

으로 처리된다.

그래서

Windows에서 -4094가 나오면 십중팔구 파일 잠금이다.

라고 단정하는 것도 정확하지 않다.

파일 잠금이나 백신 개입이 상위 원인일 가능성은 있지만, 단순한 ERROR_SHARING_VIOLATION 자체가 -4094라는 뜻은 아니다.


5. 반대로 libuv가 아직 직접 매핑하지 않는 Windows 오류도 있다

예를 들어 Windows에는 다음과 같은 오류가 존재한다.

ERROR_DELETE_PENDING

Windows 공식 정의는 다음 의미다.

파일이 삭제 중이므로 열 수 없다.

Microsoft 문서상 오류 번호는 303(0x12F)다.

그런데 현재 libuv의 uv_translate_sys_error()에는 ERROR_DELETE_PENDING에 대한 직접 매핑이 없다.

다만 여기에도 함정이 있다.

Windows의 NTSTATUS → Win32 변환 과정에서 STATUS_DELETE_PENDING이 항상 ERROR_DELETE_PENDING으로 노출되는 것도 아니다.

상황에 따라 ERROR_ACCESS_DENIED 같은 다른 Win32 오류로 바뀔 수도 있다.

즉 Windows 파일 오류는 생각보다 계층이 많다.

파일 시스템 드라이버
      ↓
NTSTATUS
      ↓
Win32 API
      ↓
GetLastError()
      ↓
libuv
      ↓
UV_EBUSY / UV_EPERM / UV_UNKNOWN ...
      ↓
Node.js Error

그래서 Node.js에서 -4094만 보고 원래 NTSTATUS까지 역산하는 것은 어렵다.


6. Cloud File / Reparse Point도 후보가 될 수 있다

OneDrive 같은 동기화 프로그램은 Windows의 Cloud Files API와 reparse point를 적극적으로 사용한다.

문제는 libuv의 Windows 오류 매핑표가 모든 Cloud File 계열 오류를 처리하는 것은 아니라는 점이다.

현재 uv_translate_sys_error()에서도 ERROR_CLOUD_FILE_* 계열에 대한 직접적인 매핑은 확인되지 않는다.

따라서 프로젝트가 다음 위치에 있다면 한 번 의심할 필요가 있다.

OneDrive
Google Drive Desktop
Dropbox
네트워크 드라이브
가상 드라이브
특수 reparse point

특히 .next처럼 수천 개의 작은 파일을 아주 빠르게 생성하고 삭제하는 디렉터리는 동기화 프로그램과 궁합이 좋지 않다.


7. 여기서 발견한 내 프로젝트의 중요한 단서 — 과거 .next Junction

내 프로젝트의 개발 스크립트를 다시 살펴보다가 이런 코드가 눈에 들어왔다.

delete env.NEXT_DIST_DIR

그리고 restart-dev.mjs에는 다음 코드가 있었다.

const legacyExternal =
  join(process.env.LOCALAPPDATA, "FinSight", "next-dev")

즉 과거에 Next.js 개발 캐시를 프로젝트 밖의 LOCALAPPDATA로 이동시키는 실험을 했던 흔적이 남아 있었다.

이 경우 가장 먼저 확인할 것이 있다.

dir /a:l "D:\intel\2005\FinSight\frontend"

.next가 다음처럼 표시된다면

<JUNCTION> .next

일반 디렉터리가 아니라 Junction이다.

Windows에서 Junction 역시 reparse point를 기반으로 한다.
Sysinternals의 Junction 도구 역시 이를 확인하고 삭제할 수 있다.

다만 여기에서도 하나를 단정하면 안 된다.

.next가 Junction이다 = -4094의 원인이다.

이건 아니다.

현재 libuv는 일부 reparse 관련 오류를 이미 ENOENTEINVAL 등으로 처리한다.

그래서 Junction은 강하게 확인해야 할 환경 변수이지 -4094의 범인으로 자동 확정되는 증거는 아니다.


Junction을 없애고 일반 디렉터리로 되돌리기

디렉터리 링크는 다음처럼 제거할 수 있다.

rmdir "D:\intel\2005\FinSight\frontend\.next"

Microsoft의 mklink 문서에서도 디렉터리 심볼릭 링크 제거 예제로 rd를 사용한다.

Sysinternals Junction을 사용한다면 더 명시적으로 확인할 수 있다.

junction.exe "D:\intel\2005\FinSight\frontend\.next"

삭제:

junction.exe -d "D:\intel\2005\FinSight\frontend\.next"

여기서 중요한 건 대상 경로를 직접 삭제하지 않는 것이다.


8. route_client-reference-manifest.js가 존재하는 건 이상한가?

처음에는 이렇게 생각했다.

API Route Handler인 route.ts에서 route_client-reference-manifest.js가 생기는 것 자체가 이상하다.
어딘가에서 'use client' 모듈을 import하고 있는 것 아닐까?

이것도 너무 성급한 판단이었다.

Next.js에는 실제로 다음과 같은 파일이 존재할 수 있다.

.next/server/app/api/hello/route_client-reference-manifest.js

더 흥미로운 것은 2026년에 보고된 Next.js Webpack 이슈다.

순수한 백엔드 Route Handler가 Client Component를 하나도 import하지 않아도 Webpack 빌드에서 route_client-reference-manifest.js가 생성되는 문제가 실제로 보고됐다.

반면 같은 사례에서 Turbopack은 비어 있는 최소 manifest를 생성했다.

따라서

route_client-reference-manifest.js 존재

만으로

route.ts가 client module을 잘못 import했다

고 판단하면 안 된다.


9. Next.js에도 실제 client-reference-manifest 관련 버그가 있다

Next.js 15에서도 Route Handler와 관련해 실제 사례가 있다.

Next.js 15.2.1에서 App Router Route Handler를 사용했을 때

ENOENT:
no such file or directory,
open '.next/server/app/api/test/route_client-reference-manifest.js'

가 발생한 공식 GitHub 이슈가 존재한다.

client-reference-manifest 관련 문제가 모두 애플리케이션 코드 잘못이다.

라는 것도 사실이 아니다.

Next 자체의 artifact 생성·참조 문제도 실제로 발생한다.

하지만 여기서 아주 중요한 구분이 있다.

ENOENT
errno: -2

UNKNOWN
errno: -4094

는 같은 에러가 아니다.

Next.js에 manifest 관련 ENOENT 버그가 존재한다고 해서

내가 겪은 -4094도 무조건 같은 Next 버그다.

라고 결론 내릴 수는 없다.

현재 단계에서 가장 합리적인 판단은 다음과 같다.

Next.js manifest lifecycle과 Windows 파일 시스템의 상호작용을 함께 의심해야 한다.


10. route.ts의 import graph는 원인이 아니라 '증폭 요인'으로 보자

API Route Handler가 다음처럼 되어 있다고 하자.

import { something } from "@/lib"

export async function GET() {
  ...
}

@/lib/index.ts 같은 barrel export가 다음을 전부 끌고 올 수도 있다.

export * from "./api"
export * from "./client"
export * from "./components"
export * from "./hooks"

이 경우 Route Handler의 dependency graph가 불필요하게 커질 수 있다.

그래서 가능하다면

import { something } from "@/lib/finsightApiProxy"

처럼 서버 코드의 직접 경로를 사용하는 것이 낫다.

다만 이것 역시 구분해야 한다.

import graph가 크다 = -4094의 직접 원인

은 아니다.

정확히는

dependency graph가 커지면서 recompilation 범위나 생성 artifact가 증가해 문제가 드러날 가능성을 높이는 요인이 될 수 있다.

정도로 보는 것이 적절하다.


11. restart-dev.mjs에서 발견한 첫 번째 문제 — 포트 탐색

기존 코드는 대략 이런 형태였다.

if (
  !line.includes(`:${port}`) ||
  !line.includes("LISTENING")
) {
  continue
}

문제는 문자열 검색이다.

예를 들어 port = 3000이면

127.0.0.1:3000

뿐만 아니라 경우에 따라 다른 숫자가 붙은 주소도 잘못 잡을 여지가 있다.

netstat -ano -p tcp의 컬럼을 파싱해서 Local Address의 마지막 port를 정확히 비교하는 편이 낫다.

const parts = line.trim().split(/\s+/)

if (parts.length < 5) {
  continue
}

const [
  protocol,
  localAddress,
  remoteAddress,
  state,
  pid,
] = parts

if (state !== "LISTENING") {
  continue
}

const portMatch = localAddress.match(/:(\d+)$/)

if (!portMatch) {
  continue
}

if (portMatch[1] !== String(port)) {
  continue
}

이제 3000이라는 문자열이 들어 있다는 이유만으로 프로세스를 선택하지 않는다.


12. 더 중요한 문제 — 포트 PID만 죽이면 전체 프로세스 트리가 안 죽을 수 있다

Next.js 개발 서버에는 메인 Node 프로세스 외에도 빌드 및 개발 과정에서 여러 작업이 연결될 수 있다.

그런데

3000 LISTENING

프로세스만 찾아 단일 PID를 종료하면 관련 자식 프로세스까지 확실하게 정리된다고 보장하기 어렵다.

Windows에서는 최소한 다음처럼 process tree를 함께 종료하는 것이 낫다.

taskkill /PID 12345 /F /T

핵심은

/T

다.

JavaScript에서는 예를 들어 다음처럼 처리할 수 있다.

execSync(
  `taskkill /PID ${pid} /F /T`,
  { stdio: "ignore" }
)

그리고 .next를 삭제하기 전에 남은 Node 프로세스를 먼저 조회해보는 편이 안전하다.

Get-CimInstance Win32_Process -Filter "Name='node.exe'" |
  Where-Object {
    $_.CommandLine -like '*D:\intel\2005\FinSight*'
  } |
  Select-Object ProcessId, ParentProcessId, CommandLine

여기서 프로젝트를 참조하는 Node 프로세스가 남아 있다면 그 PID를 확인한 뒤 종료할 수 있다.

처음부터 모든 node.exe를 일괄 종료하는 방식은 다른 프로젝트나 IDE 기능까지 죽일 수 있으므로 추천하지 않는다.


13. .next 삭제에도 함정이 하나 있다

기존 코드에서는 다음처럼 삭제하고 있었다.

rmSync(dir, {
  recursive: true,
  force: true,
  maxRetries: 8,
  retryDelay: 250,
})

언뜻 보면 충분히 안전해 보인다.

그런데 Node.js 문서를 보면 maxRetries가 적용되는 오류가 정해져 있다.

대표적으로 다음이다.

EBUSY
EMFILE
ENFILE
ENOTEMPTY
EPERM

여기에 UV_UNKNOWN은 없다.

즉 정확히

errno: -4094
code: UNKNOWN

이 발생한다면

maxRetries: 8

을 주었다고 해서 Node가 같은 방식으로 알아서 8번 재시도해준다고 기대할 수 없다.

이건 꽤 중요한 포인트다.


14. 그래서 삭제 자체를 애플리케이션 레벨에서 재시도하는 게 낫다

예를 들어 다음처럼 만들 수 있다.

import {
  existsSync,
  rmSync,
} from "node:fs"

const sleep = (ms) =>
  new Promise(resolve => setTimeout(resolve, ms))

async function removeDirWithRetry(
  dir,
  tries = 10,
) {
  let lastError

  for (let i = 0; i < tries; i++) {
    try {
      rmSync(dir, {
        recursive: true,
        force: true,
      })

      if (!existsSync(dir)) {
        return
      }
    } catch (error) {
      lastError = error

      console.warn(
        `[cache delete ${i + 1}/${tries}]`,
        error.code,
        error.errno,
        error.message,
      )
    }

    await sleep(300 + i * 200)
  }

  throw lastError ??
    new Error(`Failed to remove ${dir}`)
}

이 방식의 장점은 UNKNOWN도 재시도 대상에 포함된다는 것이다.

그리고 더 중요한 원칙이 있다.

.next 완전 삭제 확인
        ↓
성공했을 때만
        ↓
next dev 시작

순서를 지켜야 한다.

rmSync({ recursive: true })는 트랜잭션이 아니다.

삭제 중간에 오류가 발생했다고 해서 이전 상태로 롤백되는 것이 아니다.

따라서 삭제가 실패한 상황에서 그대로 Next dev를 띄우는 것보다 프로세스를 중단시키는 편이 훨씬 안전하다.


15. 실제 범인을 찾는 가장 좋은 방법 — Process Monitor

여기까지는 전부 원인 후보를 좁히는 과정이다.

진짜 Windows 파일 시스템에서 무슨 일이 벌어졌는지 확인하려면 Sysinternals Process Monitor(ProcMon)가 가장 유용하다.

필터를 다음처럼 잡는다.

Process Name  is       node.exe
Path          contains \.next\
Result        is not   SUCCESS

더 좁히려면 문제 파일명을 넣는다.

route_client-reference-manifest.js

그 상태에서 오류를 재현한다.

그러면 다음과 같은 결과를 볼 수 있다.

NAME NOT FOUND
PATH NOT FOUND
ACCESS DENIED
SHARING VIOLATION
DELETE PENDING
REPARSE
...

여기서부터 비로소

Next.js race인지
다른 프로세스와의 충돌인지
파일 시스템 문제인지

를 구체적으로 구분할 수 있다.

Node.js에서 보이는

UNKNOWN -4094

보다 훨씬 아래 계층을 볼 수 있다는 것이 핵심이다.


16. 누가 파일을 잡고 있는지도 확인한다

Sysinternals의 handle.exe 또는 handle64.exe도 유용하다.

handle64.exe -a -u "route_client-reference-manifest"

예를 들어 다음 프로세스가 나타난다면 매우 중요한 단서다.

node.exe
MsMpEng.exe
OneDrive.exe
Code.exe
SearchIndexer.exe

다만 아무것도 나오지 않았다고 해서 파일 잠금 가능성이 0이 되는 것은 아니다.

문제가 수십 밀리초 단위로 발생했다 사라지는 경우 handle.exe를 실행할 때는 이미 핸들이 풀렸을 수도 있다.

그래서 지속적인 추적에는 ProcMon이 더 낫다.


17. Defender 제외는 '해결책'보다 A/B 테스트로 사용하자

처음에는 다음 명령을 생각했다.

Add-MpPreference `
  -ExclusionPath "D:\intel\2005\FinSight"

Add-MpPreference `
  -ExclusionProcess "node.exe"

하지만 두 번째 명령은 추천하지 않는다.

Microsoft 문서에서도 Defender exclusion은 보안 보호에 공백을 만들 수 있기 때문에 필요한 범위에서만 사용하라고 권고한다.
특히 node.exe 같은 이미지 이름을 프로세스 제외하면 그 이름으로 실행되는 프로세스가 여는 파일 전체가 제외 대상이 될 수 있다.

그래서 진단 목적으로 테스트한다면 차라리 프로젝트 경로만 제한적으로 사용한다.

Add-MpPreference `
  -ExclusionPath "D:\intel\2005\FinSight"

그리고 에러가 재현되는지 확인한다.

문제가 동일하다면 다시 제거한다.

Remove-MpPreference `
  -ExclusionPath "D:\intel\2005\FinSight"

Defender 제외를 영구 해결책처럼 사용하는 것보다는

제외 전 → 오류 발생
제외 후 → 오류 사라짐

이라는 A/B 실험에 사용하는 것이 낫다.


18. D: 드라이브도 확인한다

다음 명령으로 볼륨 상태를 확인할 수 있다.

fsutil fsinfo volumeinfo D:

또는 PowerShell에서는

Get-Volume D

를 사용할 수 있다.

확인할 항목은 다음과 같다.

File System : NTFS인가?
로컬 디스크인가?
네트워크 드라이브인가?
가상 디스크인가?
특수 동기화 영역인가?

개발 환경을 최대한 단순화하려면 테스트용으로 아예

C:\dev\FinSight

같은 일반 NTFS 로컬 디렉터리에 소스를 복제해 보는 방법도 좋다.

단,

.next
node_modules

는 복사하지 않고 새로 설치한다.

pnpm install
pnpm dev

여기에서 문제가 사라진다면 애플리케이션 코드보다 기존 D: 환경 또는 .next 경로 구성 쪽에 무게가 실린다.

반대로 깨끗한 로컬 경로에서도 동일하게 재현된다면 Next.js 버전과 build artifact lifecycle을 더 강하게 의심할 수 있다.


19. Turbopack / Webpack A/B 테스트도 가치가 있다

내 개발 스크립트에는 이미 Webpack과 Turbopack을 바꾸는 옵션이 있다.

예를 들어 프로젝트 스크립트가

$env:NEXT_DEV_WEBPACK=1
pnpm dev

를 지원한다면 동일한 조건에서 비교한다.

Case A

Turbopack → -4094 발생
Webpack   → 정상

이 경우 bundler별 artifact 생성 방식 차이를 강하게 의심할 수 있다.

Case B

Turbopack → 발생
Webpack   → 발생

그러면 공통으로 사용하는 Windows 파일 시스템, .next 경로, 외부 프로세스 등을 우선 조사한다.

Case C

특정 Next.js 버전에서만 발생

이 경우 GitHub issue와 changelog를 비교하면서 Next.js regression을 의심할 수 있다.

실제로 Next.js에서는 Route Handler의 client-reference manifest 처리 방식이 Webpack과 Turbopack 사이에 달랐던 이슈가 존재했다.


20. output, 'standalone'은 무조건 제거할 필요 없다

처음에는

const nextConfig = {
  output: "standalone",
}

도 의심했다.

하지만 이 역시 무조건적인 원인으로 보는 것은 맞지 않는다.

실제로 Next.js 15.2.1의 route_client-reference-manifest.js ENOENT 이슈는 output: standalone 없이도 발생했다.

따라서 개발 중 open ... -4094가 발생한다면 standalone을 가장 먼저 제거하기보다 다음을 먼저 확인하는 것이 낫다.

.next 경로
Junction 여부
ProcMon 결과
남은 Node process
Next.js 버전
Webpack/Turbopack 차이

standalone 관련 테스트는 next build 또는 traced file copy 단계에서 문제가 발생할 때 우선순위가 올라간다.


21. 내가 처음 잘못 짚었던 부분 정리

이번 문제를 조사하면서 몇 가지 가설을 수정했다.

잘못된 판단 1

route_client-reference-manifest.js가 API Route에 존재한다
→ use client import가 잘못됐다.

항상 그렇지 않다.

Route Handler에서도 해당 manifest가 생성될 수 있다.


잘못된 판단 2

ERROR_SHARING_VIOLATION
→ libuv가 모른다
→ -4094

아니다.

현재 libuv에서는 UV_EBUSY로 매핑된다.


잘못된 판단 3

Next.js에 client-reference-manifest 버그가 있다
→ 내 -4094도 Next 버그다.

이것도 아직 확정할 수 없다.

Next.js에는 실제 manifest 관련 버그가 존재하지만 대표 사례는

ENOENT / -2

이고, 내가 보고 있는

UNKNOWN / -4094

와는 다른 libuv 오류다.


잘못된 판단 4

.next가 Junction이면 범인 확정

Junction은 충분히 의심할 만한 환경 변수지만 존재 자체가 UV_UNKNOWN의 증거는 아니다.


22. 내가 다시 조사한다면 이 순서로 한다

지금이라면 무작정 .next를 지우고 Next를 재설치하는 대신 다음 순서로 접근한다.

1단계 — 환경 확인

dir /a:l "D:\intel\2005\FinSight\frontend"

.next Junction 여부 확인.

그리고

fsutil fsinfo volumeinfo D:

로 파일 시스템 확인.


2단계 — 프로세스 트리 제대로 종료

taskkill /PID <PID> /F /T

이후 프로젝트 관련 node.exe가 남아 있는지 확인한다.


3단계 — .next 완전 삭제

삭제에 실패하면 dev 서버를 시작하지 않는다.

UNKNOWNfs.rmSync()의 기본 retry 대상이 아니므로 별도의 retry wrapper를 적용한다.


4단계 — ProcMon 실행

Process Name = node.exe
Path contains = .next
Result != SUCCESS

로 필터링하고 에러를 재현한다.


5단계 — 깨끗한 로컬 NTFS 환경에서 비교

C:\dev\FinSight

같은 새 위치에서

pnpm install
pnpm dev

를 실행한다.


6단계 — Webpack / Turbopack 비교

같은 Next.js 버전, 같은 소스에서 bundler만 바꾼다.


7단계 — Next.js 버전 비교

현재 버전에서 발생한다면 인접 patch 버전 또는 검증된 버전과 비교한다.

이때 한 번에 여러 조건을 바꾸면 안 된다.

Next 버전
Node 버전
bundler
프로젝트 경로
Defender

를 동시에 바꿔버리면 무엇이 원인이었는지 알 수 없기 때문이다.


23. 최종 정리

이번 문제에서 가장 중요한 것은 -4094 자체를 Next.js 에러로 보지 않는 것이다.

구조를 다시 보면 다음과 같다.

Next.js
   ↓
manifest 파일을 생성하거나 읽음
   ↓
Node.js fs
   ↓
libuv
   ↓
Windows filesystem / Win32 API

그리고 최종적으로

UNKNOWN
errno: -4094
syscall: open

이 올라왔다.

현재 확실하게 말할 수 있는 사실은 다음과 같다.

  • -4094는 libuv의 UV_UNKNOWN이다.
  • Windows의 일부 오류가 libuv의 매핑표에 없으면 UV_UNKNOWN으로 떨어질 수 있다.
  • 일반적인 ERROR_SHARING_VIOLATION은 이미 UV_EBUSY로 매핑된다.
  • route_client-reference-manifest.js가 Route Handler에 존재하는 것 자체는 비정상이라고 볼 수 없다.
  • Next.js에는 실제로 Route Handler + client-reference-manifest 관련 ENOENT 버그 사례가 있다.
  • 하지만 ENOENT(-2)UNKNOWN(-4094)는 구분해야 한다.
  • .next Junction, Cloud Files, 개발 서버 프로세스, 파일 시스템 상태는 별도로 확인할 필요가 있다.
  • fs.rmSync()maxRetriesUNKNOWN 오류까지 자동 재시도해주는 옵션이 아니다.
  • 진짜 원인을 잡으려면 Windows에서는 ProcMon으로 실패한 CreateFile 작업을 보는 것이 가장 확실하다.

결국 이번 디버깅에서 얻은 가장 큰 교훈은 이것이었다.

프레임워크에서 이상한 파일 오류가 발생했다고 해서 프레임워크 코드부터 뒤질 필요는 없다.
Node.js → libuv → Win32 API까지 한 계층씩 내려가면, 적어도 “어디까지가 사실이고 어디부터가 추측인지”를 구분할 수 있다.

그리고 UNKNOWN -4094 같은 오류는 그 구분이 특히 중요하다.

에러 메시지는 “모르겠다”고 하는데 개발자까지 같이 모르겠다고 끝낼 수는 없으니까.

참고

profile
레거시를 이해하면서도 새로운 기술을 현실적으로 적용할 수 있는 백엔드 개발자가 되는 것이 목표입니다.

0개의 댓글