<!-- ❌ 폐쇄망에서는 이 요청 자체가 실패한다 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>인터넷 연결이 없는 사내망에서는 DNS 조회부터 실패합니다. 화면이 완전히 빈 채로 뜨고 콘솔에만 오류가 남습니다. 파일을 미리 내려받아 static/js/plugin/ 에 두고 절대 경로로 로드해야 합니다.
# 폐쇄망 PC 에서 확인: CDN 요청은 타임아웃, 로컬 경로는 즉시 응답
curl -m 3 https://unpkg.com/vue@3 # 실패
curl http://127.0.0.1:8080/js/plugin/vue.global.prod.js # 성공정적 서버가 .js 파일에 Content-Type 을 안 붙이면 브라우저가 nosniff 정책으로 실행을 거부합니다. 화면은 뜨지만 Vue 가 전혀 동작하지 않고 콘솔에 MIME 오류가 남습니다. 확장자별 MIME 매핑을 빠짐없이 채워야 합니다.
/todos/3 을 새로고침하면 404 가 뜨고 화면이 사라집니다. Vue Router 는 브라우저 안에서만 경로를 알고, 서버는 그런 파일이 없기 때문입니다. 확장자 없는 경로는 index.html 로 돌려줘야 합니다.
// ✅ 확장자 없고 실제 파일도 없으면 index.html 로 폴백
boolean hasExt = file.getFileName().toString().contains(".");
if (!hasExt && !Files.isRegularFile(file)) file = root.resolve("index.html");// ❌ 확장자 유무를 안 가리고 무조건 폴백
if (!Files.isRegularFile(file)) file = root.resolve("index.html");오타로 /js/ap.js 를 요청하면 HTML 이 내려와 SyntaxError 로 보입니다. 원인 파악이 오래 걸립니다. 확장자가 있는 경로는 404 를 그대로 유지해야 진짜 원인이 드러납니다.
index.html 에 Cache-Control 을 안 지정하면 브라우저가 임의로 캐시해, 새 버전을 배포해도 사용자는 옛 화면을 계속 봅니다. index.html 은 항상 no-cache 로 매번 서버에 확인시켜야 합니다.
// ✅ vendor 여부로 캐시 정책을 나눈다
boolean vendor = uri.startsWith("/js/plugin/");
ex.getResponseHeaders().set("Cache-Control", vendor ? "public, max-age=31536000, immutable" : "no-cache");vue.global.prod.js 를 새 버전으로 덮어쓰면, 1년 캐시를 받은 브라우저가 옛 파일을 계속 씁니다. 파일명이나 폴더에 버전을 넣어 경로 자체를 바꿔야 캐시가 새로 받힙니다.
<!-- ✅ 버전이 경로에 드러나야 교체 시 캐시가 자동으로 무효화된다 -->
<script src="/js/plugin/vue-3.5.13/vue.global.prod.js"></script>// ❌ DELETE 는 본문이 없는데 무조건 json() 을 호출
const data = await res.json(); // 204 면 여기서 예외본문이 없는 204 에 res.json() 을 부르면 파싱 오류가 납니다. 상태 코드를 먼저 검사해 204 면 즉시 null 을 반환해야 합니다.
fetch 는 400·404 같은 응답도 예외를 던지지 않고 정상적으로 리턴합니다. res.ok 를 확인하지 않으면 실패 응답을 성공으로 오인해 화면이 조용히 잘못된 상태로 남습니다.
// ✅ res.ok(200~299)가 아니면 서버의 message 로 예외를 던진다
if (!res.ok) throw new Error(`${res.status} ${data.message}`);<!-- ❌ key 없이 반복 -->
<li v-for="t in todos">{{ t.title }}</li>목록 순서가 바뀌거나 항목이 지워지면 Vue 가 DOM 을 잘못 재사용해 체크박스 상태가 다른 항목으로 옮겨붙는 버그가 생깁니다. :key="t.id" 처럼 고유값을 반드시 지정해야 합니다.
JS 코드 안에서 todos.push(...) 처럼 .value 없이 접근하면 배열이 아니라 ref 객체 자체를 다루게 되어 오류가 나거나 반응성이 끊깁니다. 템플릿 밖에서는 항상 todos.value 로 접근해야 합니다.
// ✅ JS 코드에서는 .value, 템플릿에서는 자동 언래핑
const load = async () => { todos.value = (await api('GET', '/api/todos')).items; };