@RestController
@RequestMapping("/api/todos")
class TodoController {
private final TodoService service;
@GetMapping
List<Todo> list() { return service.findAll(); }
@PostMapping
ResponseEntity<Todo> create(@RequestBody TodoRequest req) {
Todo t = service.create(req.title());
return ResponseEntity.status(201).body(t);
}
@PatchMapping("/{id}")
Todo update(@PathVariable long id, @RequestBody TodoRequest req) {
return service.update(id, req);
}
@DeleteMapping("/{id}")
ResponseEntity<Void> delete(@PathVariable long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}정적 파일은 src/main/resources/static/ 에 그대로 복사하면 Spring Boot 가 자동으로 서빙합니다. SPA 폴백은 2.4 의 SpaFallbackController 하나로 대체됩니다.
TodoApi 의 if-else 분기가 @GetMapping/@PostMapping/@PatchMapping/@DeleteMapping 어노테이션으로 나뉘고, id 형식 오류는 Spring 이 알아서 400 으로 바꿔줍니다. MiniJson.read/write 가 하던 일은 Jackson 이 @RequestBody/반환값 직렬화로 대신합니다.
StaticHandler 의 캐시 정책(2.3)은 application.yml 의 spring.web.resources.cache.cachecontrol 로 옮길 수 있습니다.
spring:
web:
resources:
static-locations: classpath:/static/
cache:
cachecontrol:
max-age: 0 # index.html 등 기본은 짧게, 라이브러리 경로는 별도 설정// vue-router.global.prod.js 도 static/js/plugin 에 둔다
const { createApp } = Vue;
const { createRouter, createWebHistory } = VueRouter;
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: TodoList },
{ path: '/todos/:id', component: TodoDetail }, // 상세 화면
],
});
createApp(App).use(router).mount('#app');서버의 SPA 폴백(2.4) 덕분에 /todos/3 을 새로고침해도 index.html 이 내려오고, Vue Router 가 URL 을 읽어 같은 상세 화면을 다시 그립니다.
// TodoDetail 컴포넌트: 라우트 파라미터로 상세를 조회
const TodoDetail = {
props: ['id'],
template: `<div>{{ todo?.title }}</div>`,
setup(props) {
const todo = ref(null);
onMounted(async () => { todo.value = await api('GET', `/api/todos/${props.id}`); });
return { todo };
},
};props.id 는 라우트 정의의 :id 세그먼트에서 Vue Router 가 자동으로 채워 줍니다. 상세 컴포넌트가 마운트될 때마다 해당 id 로 서버를 다시 조회하므로, 목록과 상세가 서로 다른 시점의 데이터를 들고 있을 위험이 없습니다.
// 목록 조회에 검색·페이징 조건을 붙인다
async function loadTodos(q, page, size) {
const params = new URLSearchParams({ q, page, size });
return api('GET', `/api/todos?${params}`);
}// TodoApi: query 파싱은 07 레슨의 Page 와 같은 방식
Map<String, String> q = query(ex.getRequestURI().getQuery());
int page = Integer.parseInt(q.getOrDefault("page", "0"));URLSearchParams 는 q, page, size 를 자동으로 URL 인코딩해 주므로 한글 검색어가 있어도 별도 처리가 필요 없습니다. 서버는 07 레슨의 Page 레코드를 그대로 재사용해 {"total","items"} 형태로 응답하면 화면 쪽 코드도 그대로 재사용됩니다.
const error = ref('');
let errorTimer = null;
function showError(message) {
error.value = message;
clearTimeout(errorTimer);
errorTimer = setTimeout(() => { error.value = ''; }, 3000); // 3초 후 자동 소거
}
// run() 의 catch 블록에서 error.value = e.message 대신 showError(e.message) 호출
// busy.value 는 그대로 :disabled 에 묶어 중복 클릭을 막는다busy 는 run() 안에서만 켜고 끄므로, 버튼에 :disabled="busy" 를 걸어 두면 요청이 끝나기 전 두 번째 클릭이 서버에 아예 도달하지 않습니다. 에러 토스트를 자동으로 지우면 사용자가 매번 닫기 버튼을 누르지 않아도 됩니다.