실행 방법입니다. 외부 jar 는 필요 없습니다.
cd java-src\extension\08_vue_spa
C:\project\jdk-21.0.8\bin\javac -encoding UTF-8 *.java
C:\project\jdk-21.0.8\bin\java -Dstdout.encoding=UTF-8 Main # 8080, 브라우저 http://127.0.0.1:8080/
C:\project\jdk-21.0.8\bin\java -Dstdout.encoding=UTF-8 Main --demo # 11개 시나리오 자동 실행// Main.main
server.createContext("/api/todos", new TodoApi()); // 이 접두사가 더 길다
server.createContext("/", new StaticHandler(Path.of("static")));HttpServer 는 요청 경로와 가장 길게 일치하는 컨텍스트를 고릅니다. /api/todos 가 / 보다 구체적이므로, /api/todos 로 시작하는 요청은 TodoApi 로, 나머지는 모두 StaticHandler 로 갑니다.
// StaticHandler.handle
boolean hasExt = file.getFileName().toString().contains(".");
if (!hasExt && !Files.isRegularFile(file)) file = root.resolve("index.html");
if (!Files.isRegularFile(file)) { send(ex, 404, "text/plain", "not found"); return; }실행 결과입니다.
[1] GET / → index.html
200 text/html; charset=utf-8 app 루트 포함=true
[2] GET /js/plugin/vue.global.prod.js → 장기 캐시
200 application/javascript; charset=utf-8 Cache-Control=public, max-age=31536000, immutable 154807자
[3] GET /todos/3 (SPA 라우트, 파일 없음) → index.html 로 폴백
200 text/html; charset=utf-8 Cache-Control=no-cache
[4] GET /css/none.css (확장자 있음, 파일 없음) → 404
404
[5] GET /%2e%2e/Main.java (경로 탐색) → 404
404// TodoApi.handle
String rest = ex.getRequestURI().getPath().substring("/api/todos".length());
Long id = rest.length() > 1 ? Long.parseLong(rest.substring(1)) : null;
if (id == null && method.equals("GET")) { /* 목록 */ }
else if (id == null && method.equals("POST")) { /* 생성, title 검증 */ }
else if (id != null && method.equals("PATCH")) { /* 부분 수정 */ }
else if (id != null && method.equals("DELETE")) { /* 삭제, 204 */ }Long.parseLong 이 던지는 NumberFormatException 은 IllegalArgumentException 의 하위 타입이라 같은 catch 블록에서 400 으로 처리됩니다.
실행 결과입니다.
[6] POST /api/todos 2건 → 201
201 {"done":false,"id":1,"title":"우유 사기"}
201 {"done":false,"id":2,"title":"보고서 쓰기"}
[7] POST /api/todos 제목 없음 → 400
400 {"message":"title 은 필수입니다","status":400}
[8] PATCH /api/todos/1 완료 처리 → 200
200 {"done":true,"id":1,"title":"우유 사기"}
[9] GET /api/todos → 목록
200 {"total":2,"items":[{"done":true,"id":1,"title":"우유 사기"},{"done":false,"id":2,"title":"보고서 쓰기"}]}
[10] DELETE /api/todos/1 → 204, 다시 조회 → 404
204
404 {"message":"할 일이 없습니다","status":404}
[11] GET /api/todos/abc (id 형식 오류) → 400
400 {"message":"For input string: \"abc\"","status":400}<!-- index.html: 반복·조건·양방향 바인딩 -->
<form @submit.prevent="add">
<input v-model.trim="title" placeholder="할 일 입력" required>
<button :disabled="busy">추가</button>
</form>
<li v-for="t in todos" :key="t.id">
<input type="checkbox" :checked="t.done" @change="toggle(t)">
</li>// app.js: 변경 후 재조회 패턴
const run = async (fn) => {
busy.value = true; error.value = '';
try { await fn(); await load(); }
catch (e) { error.value = e.message; }
finally { busy.value = false; }
};브라우저에서 http://127.0.0.1:8080/ 을 열면 할 일을 추가·완료·삭제할 수 있고, 새로고침해도 서버가 다시 조회해 같은 화면이 나옵니다.
개발자 도구의 Network 탭을 열어 두면 이 레슨의 구조가 그대로 보입니다. 첫 진입 시 index.html·app.css·vue.global.prod.js·app.js 요청이 순서대로 뜨고, 이후 추가·완료·삭제마다 /api/todos 로 시작하는 요청만 새로 나갑니다.
vue.global.prod.js 요청의 응답 헤더에서 Cache-Control: public, max-age=31536000, immutable 을 확인할 수 있습니다. 새로고침을 반복해도 이 파일은 캐시에서 즉시 로드됩니다.