JSP·Thymeleaf 는 요청마다 서버가 완성된 HTML 문자열을 만들어 내려줍니다. SPA 는 처음에 빈 HTML 한 장만 받고, 그 안의 JS 가 API 를 호출해 받은 JSON 으로 화면을 그립니다. 서버가 하던 "HTML 생산" 역할이 브라우저 쪽으로 옮겨간 셈입니다.
| 항목 | JSP·Thymeleaf | Vue SPA |
|---|---|---|
| 화면 생성 주체 | 서버(템플릿 엔진) | 브라우저(JS) |
| 서버 응답 | 완성된 HTML | 빈 HTML + JSON |
| 새로고침 시 동작 | 서버가 다시 그려서 반환 | JS 가 다시 API 호출 |
| 서버 코드 위치 | 컨트롤러 + 뷰 템플릿 | 컨트롤러(API) + 정적 파일 |
서버 입장에서는 결국 "정적 파일 몇 개"와 "JSON 을 내려주는 API" 두 가지 책임만 남습니다. 이 레슨의 Main 이 두 책임을 경로별로 나눈 이유입니다.
SPA 는 검색 엔진 노출(SEO)이나 첫 화면 표시 속도에서 서버 렌더링보다 불리할 수 있습니다. 사내 업무 시스템은 검색 노출이 필요 없고 로그인 뒤에만 쓰이므로 이 단점이 거의 문제가 안 됩니다. 반대로 화면 전환이 잦은 목록·상세·모달 같은 UI 는 SPA 가 압도적으로 편합니다.
배포 방식은 크게 두 가지입니다.
개발 중에는 Vite dev server 의 proxy 설정으로 (B) 처럼 동작시키다가, 배포할 때 빌드 산출물을 Java 서버의 정적 폴더로 옮겨 (A) 로 합치는 방식이 실무에서 흔합니다. Spring Boot 는 src/main/resources/static 폴더에 파일을 두면 별도 설정 없이 그대로 서빙합니다.
// vite.config.js: 개발 중에만 8080 으로 API 요청을 대신 전달
export default {
server: { proxy: { '/api': 'http://localhost:8080' } },
};이 설정은 개발용 dev server(보통 5173 포트)에서 실행할 때만 적용됩니다. npm run build 로 만든 산출물에는 포함되지 않으므로, 배포 시점에는 반드시 (A) 나 (B) 중 하나로 실제 서빙 경로를 정해야 합니다.
배포 전에 아래 표로 구조를 다시 확인하면 CORS 설정을 빼먹는 실수를 줄일 수 있습니다.
| 확인 항목 | (A) 같은 출처 | (B) 분리 배포 |
|---|---|---|
| CORS 설정 | 불필요 | API 서버에 허용 출처 지정 |
| 정적 파일 위치 | Java 서버의 static 폴더 | Nginx 문서 루트 |
| 배포 단위 | 서버 1개 | 서버 2개(또는 Nginx+Java) |
| SPA 폴백 담당 | Java 서버 | Nginx try_files |
정적 파일 서버는 단순해 보이지만 세 가지를 반드시 챙겨야 합니다.
Content-Type 없이 JS 를 내려주면 브라우저가 nosniff 정책 아래에서 실행을 거부할 수 있습니다. 확장자별로 타입을 정확히 지정해야 합니다.max-age=31536000, immutable 로 1년 캐시해도 됩니다. index.html 은 배포마다 바뀌므로 no-cache 로 매번 서버에 확인시킵니다... 가 섞인 경로로 폴더 밖 파일을 읽으려는 시도를 막아야 합니다.StaticHandler 는 이 세 가지를 한 메서드에서 처리합니다.
// StaticHandler.handle 핵심부
Path file = root.resolve(uri.substring(1)).normalize();
if (!file.startsWith(root)) { send(ex, 404, "text/plain", "not found"); return; }
// ... 확장자별 MIME, /js/plugin/ 이면 1년 캐시, 아니면 no-cacheresolve 로 합친 경로를 normalize 로 정리한 뒤 root 로 시작하는지 검사합니다. ../../Main.java 같은 경로는 정규화 후에도 root 밖으로 나가므로 여기서 걸러집니다.
Cache-Control 값도 파일의 성격에 따라 다르게 정해야 합니다. 값을 잘못 고르면 "라이브러리를 매번 새로 받아 느리다"거나 "배포했는데 화면이 안 바뀐다" 둘 중 하나가 됩니다. 이 판단 기준은 2.5 에서 다시 다룹니다.
StaticHandler.MIME 이 아는 확장자 목록을 표로 보면 어떤 파일이 어떤 타입으로 나가는지 한눈에 보입니다.
| 확장자 | Content-Type |
|---|---|
| html | text/html; charset=utf-8 |
| js | application/javascript; charset=utf-8 |
| css | text/css; charset=utf-8 |
| json | application/json |
| svg | image/svg+xml |
| woff2 | font/woff2 |
목록에 없는 확장자는 application/octet-stream 으로 내려가 브라우저가 다운로드로 처리합니다. 새 파일 형식을 쓰게 되면 이 맵에 항목을 추가해야 합니다.
Vue Router 의 history 모드를 쓰면 주소창에 /todos/3 같은 경로가 그대로 보입니다. 이 상태에서 새로고침하면 브라우저는 서버에 /todos/3 을 요청합니다. 서버에는 그런 파일이 없으니, 확장자가 없는 경로는 index.html 을 대신 돌려주고 Vue Router 가 브라우저에서 그 경로를 다시 해석하게 합니다.
반대로 확장자가 있는 경로, 예를 들어 /css/none.css 는 진짜로 없는 파일이므로 404 를 그대로 유지합니다. 이 구분이 없으면 오타 난 .js 경로가 HTML 로 내려와 콘솔에 알아보기 힘든 문법 오류가 뜹니다.
Spring Boot 에서는 컨트롤러로 같은 일을 합니다.
@Controller
class SpaFallbackController {
// 확장자 없는 모든 경로를 index.html 로 전달
@GetMapping("/{path:[^\\.]*}")
String fallback() { return "forward:/index.html"; }
}<script src="https://unpkg.com/vue@3"> 같은 CDN 스크립트는 폐쇄망에서 인터넷이 없으므로 무조건 실패합니다. 해결은 간단합니다. 인터넷이 되는 PC 에서 Vue 의 전역 빌드 파일 하나를 내려받아 static/js/plugin/ 에 두고, <script src="/js/plugin/vue.global.prod.js"> 로 로드합니다.
전역 빌드는 window.Vue 객체 하나를 만들고, 템플릿 컴파일러까지 포함해서 별도 빌드 도구 없이 동작합니다. .vue 단일 파일 컴포넌트를 쓰려면 Vite 같은 빌드 도구가 필요하지만, 이 레슨처럼 HTML 안에 템플릿을 직접 쓰는 방식은 필요 없습니다.
| 빌드 종류 | 특징 | 언제 쓰는가 |
|---|---|---|
vue.global.prod.js |
window.Vue, 컴파일러 포함 |
빌드 도구 없이 HTML 에 직접 |
vue.esm-browser.js |
ES 모듈, import 문 사용 |
빌드 도구 없이 모듈만 나누고 싶을 때 |
| SFC + Vite | .vue 파일, 빌드 필요 |
컴포넌트가 많고 빌드 환경이 있을 때 |
라이브러리 파일명에 버전을 넣거나 plugin/vue-3.5.13/ 처럼 버전 폴더로 관리하면, 버전을 올릴 때 경로 자체가 바뀌어 브라우저 캐시가 자동으로 무효화됩니다. 파일명을 그대로 덮어쓰면 오래된 캐시가 남아 있는 브라우저가 새 버전을 못 받을 수 있습니다.
폐쇄망에 파일을 들여오는 절차도 정해 두면 편합니다. 인터넷이 되는 PC 에서 공식 배포처의 dist/vue.global.prod.js 를 내려받고, 해시나 버전 번호를 기록해 둔 뒤, 반입 절차(보안팀 승인, 매체 검사 등)를 거쳐 폐쇄망으로 옮깁니다. 사내에 이미 반입 이력이 있는 버전을 재사용하면 이 절차를 반복하지 않아도 됩니다.
이 레슨의 데모에서 vue.global.prod.js 는 154807자, 약 150KB 입니다. Vue Router·Pinia 같은 부가 라이브러리를 더할수록 파일 수와 반입 절차가 늘어나므로, 팀에서 실제로 쓸 라이브러리 목록을 먼저 정하고 한 번에 반입하는 편이 낫습니다.
app.js 의 api() 함수는 모든 호출이 지나가는 통로입니다. 07 레슨의 에러 규격 {"status","message"} 을 여기서 한 번에 처리합니다.
async function api(method, url, body) {
const res = await fetch(url, { method, /* ... */ });
if (res.status === 204) return null; // DELETE 본문 없음
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.message}`);
return data;
}axios 를 쓴다면 이 역할은 응답 인터셉터가 대신합니다. res.ok 로 성공·실패를 가리고, 실패면 서버가 보낸 message 를 그대로 Error 에 담아 호출한 쪽에서 catch 로 받게 합니다.
이 레슨의 화면은 변경 요청 후 항상 목록을 다시 조회합니다. 서버가 데이터의 단일 진실 원천이라는 원칙을 지키는 가장 단순한 방식입니다. 응답 지연을 줄이려는 낙관적 UI(먼저 화면을 바꾸고 실패하면 되돌리기)는 더 복잡하므로 뒤에서 따로 다룹니다.
같은 출처 배포에서는 브라우저가 CORS 검사를 생략합니다. 요청을 보내는 페이지의 출처(스킴·호스트·포트)와 API 의 출처가 같기 때문입니다. 분리 배포(2.2 의 B)에서는 API 서버가 Access-Control-Allow-Origin 헤더로 어느 출처의 요청을 허용할지 명시해야 브라우저가 응답을 JS 에 넘겨줍니다.
PATCH·DELETE 처럼 단순 요청 범위를 벗어나는 메서드는 브라우저가 실제 요청 전에 OPTIONS 로 preflight 요청을 먼저 보냅니다. 서버가 OPTIONS 에 정상 응답을 안 하면 본 요청 자체가 나가지 않아, 07 레슨의 CORS 핸들러처럼 OPTIONS 전용 처리가 따로 필요합니다.
이 레슨에서 쓰는 Vue API 는 다섯 개뿐입니다. createApp 으로 앱을 만들고, setup() 안에서 상태와 함수를 정의해 반환합니다. ref 는 반응형 값 하나를 감싸고, computed 는 다른 값에서 파생되는 값을 정의하며, onMounted 는 화면이 그려진 뒤 한 번 실행할 코드를 등록합니다.
템플릿에서 쓰는 디렉티브도 다섯 개로 충분합니다. v-model 은 입력과 상태를 양방향으로 묶고, v-for 는 목록을 반복하며 :key 가 필수입니다. v-if/v-else-if 는 조건 분기, @submit.prevent 는 폼 기본 동작(새로고침)을 막고 핸들러만 실행하며, :disabled 는 속성값을 동적으로 바인딩합니다.
반응성의 핵심은 두 문장으로 요약됩니다. ref 로 만든 값은 JS 코드에서 항상 .value 로 접근해야 합니다. 다만 템플릿 안에서는 Vue 가 자동으로 언래핑하므로 .value 없이 todos.length 처럼 바로 씁니다.
이 다섯 API 와 다섯 디렉티브만 표로 정리하면 이 레슨의 화면 코드 전체를 설명할 수 있습니다.
| 이름 | 종류 | 역할 |
|---|---|---|
createApp |
API | 앱 인스턴스를 만들고 .mount() 로 DOM 에 붙인다 |
ref |
API | 값 하나를 반응형으로 감싼다, JS 에서는 .value |
computed |
API | 다른 반응형 값에서 파생되는 값을 정의한다 |
onMounted |
API | 화면이 처음 그려진 뒤 한 번 실행한다 |
v-model |
디렉티브 | 입력과 상태를 양방향으로 묶는다 |
v-for |
디렉티브 | 목록을 반복한다, :key 필수 |
v-if/v-else-if |
디렉티브 | 조건에 따라 다른 요소를 그린다 |
@submit.prevent |
디렉티브 | 폼 기본 제출(새로고침)을 막는다 |
:disabled |
디렉티브 | 속성값을 데이터에 맞춰 동적으로 바꾼다 |