307 lines
7.8 KiB
Markdown
307 lines
7.8 KiB
Markdown
---
|
|
id: frontend-progressive-enhancement
|
|
title: Progressive Enhancement — Server-first / 점진 JS
|
|
category: Coding
|
|
status: draft
|
|
source_trust_level: B
|
|
verification_status: conceptual
|
|
created_at: 2026-05-09
|
|
updated_at: 2026-05-09
|
|
tags: [frontend, progressive-enhancement, html, vibe-coding]
|
|
tech_stack: { language: "HTML / TS", applicable_to: ["Frontend"] }
|
|
applied_in: []
|
|
aliases: [progressive enhancement, server-first, no-JS fallback, HTMX, web standards, MPA]
|
|
---
|
|
|
|
# Progressive Enhancement
|
|
|
|
> Server-rendered HTML 부터 → JS 로 향상. **JS 없어도 작동, 있으면 더 좋음**. Remix / Next App Router / HTMX / web standards. SPA 의 reaction.
|
|
|
|
## 📖 핵심 개념
|
|
- HTML 가 baseline.
|
|
- Form / link 가 native (JS 없어도).
|
|
- JS = enhancement.
|
|
- 학교 wifi / slow 3G / 옛 browser 도 작동.
|
|
|
|
## 💻 코드 패턴
|
|
|
|
### 기본 — Form 가 native
|
|
```html
|
|
<form method="post" action="/api/login">
|
|
<input name="email" type="email" required>
|
|
<input name="password" type="password" required minlength="8" required>
|
|
<button>Sign in</button>
|
|
</form>
|
|
```
|
|
|
|
→ JS 없이 작동. 검증도 native.
|
|
|
|
### Next.js Server Action (PE)
|
|
```tsx
|
|
// app/login/page.tsx
|
|
async function login(formData: FormData) {
|
|
'use server';
|
|
const email = formData.get('email') as string;
|
|
const password = formData.get('password') as string;
|
|
// ... auth
|
|
redirect('/dashboard');
|
|
}
|
|
|
|
export default function LoginPage() {
|
|
return (
|
|
<form action={login}>
|
|
<input name="email" type="email" required />
|
|
<input name="password" type="password" required />
|
|
<button>Sign in</button>
|
|
</form>
|
|
);
|
|
}
|
|
```
|
|
|
|
→ JS 없어도 form submit. JS 가 활성화되면 SPA-like UX.
|
|
|
|
### useFormStatus (loading state)
|
|
```tsx
|
|
'use client';
|
|
import { useFormStatus } from 'react-dom';
|
|
|
|
function SubmitButton() {
|
|
const { pending } = useFormStatus();
|
|
return <button disabled={pending}>{pending ? 'Signing in…' : 'Sign in'}</button>;
|
|
}
|
|
```
|
|
|
|
→ JS 활성 시 loading 표시. JS 없으면 기본 button.
|
|
|
|
### Remix loader / action
|
|
```tsx
|
|
// app/routes/login.tsx
|
|
export async function action({ request }: ActionArgs) {
|
|
const formData = await request.formData();
|
|
const email = formData.get('email');
|
|
// ...
|
|
return redirect('/dashboard');
|
|
}
|
|
|
|
export default function Login() {
|
|
return (
|
|
<Form method="post">
|
|
<input name="email" type="email" />
|
|
<input name="password" type="password" />
|
|
<button>Sign in</button>
|
|
</Form>
|
|
);
|
|
}
|
|
```
|
|
|
|
→ Remix `<Form>` = JS 없어도 native form, JS 있으면 SPA submit.
|
|
|
|
### HTMX (server-rendered + AJAX)
|
|
```html
|
|
<button hx-post="/api/like" hx-target="#likes">
|
|
Like
|
|
</button>
|
|
<span id="likes">42</span>
|
|
```
|
|
|
|
→ Server 가 새 HTML fragment 반환. JS 없으면 일반 button (또는 fallback).
|
|
|
|
```html
|
|
<!-- Fallback -->
|
|
<form action="/like" method="post">
|
|
<button hx-post="/api/like" hx-target="#likes" hx-swap="innerHTML">
|
|
Like
|
|
</button>
|
|
</form>
|
|
<span id="likes">42</span>
|
|
```
|
|
|
|
### Native dialog (JS 안)
|
|
```html
|
|
<dialog id="modal">
|
|
<form method="dialog">
|
|
<p>Are you sure?</p>
|
|
<button value="confirm">Yes</button>
|
|
<button value="cancel">No</button>
|
|
</form>
|
|
</dialog>
|
|
|
|
<button onclick="document.getElementById('modal').showModal()">Open</button>
|
|
```
|
|
|
|
→ JS 안 쓰고도 modal. (showModal 만 JS — fallback 가능).
|
|
|
|
### View Transitions (declarative animation)
|
|
```html
|
|
<a href="/about">About</a>
|
|
<!-- 새 page navigation 자동 transition (Chrome) -->
|
|
```
|
|
|
|
```css
|
|
@view-transition { navigation: auto; }
|
|
```
|
|
|
|
### Anchor link / form / radio = SPA-like 가능
|
|
```html
|
|
<!-- Tabs = radio + label -->
|
|
<input type="radio" id="tab1" name="tabs" checked>
|
|
<label for="tab1">Tab 1</label>
|
|
<input type="radio" id="tab2" name="tabs">
|
|
<label for="tab2">Tab 2</label>
|
|
|
|
<div class="content">
|
|
<section data-tab="tab1">Tab 1 content</section>
|
|
<section data-tab="tab2">Tab 2 content</section>
|
|
</div>
|
|
|
|
<style>
|
|
section[data-tab] { display: none; }
|
|
#tab1:checked ~ .content [data-tab="tab1"] { display: block; }
|
|
#tab2:checked ~ .content [data-tab="tab2"] { display: block; }
|
|
</style>
|
|
```
|
|
|
|
→ JS 0 — 작동.
|
|
|
|
### Search — native form + URL
|
|
```tsx
|
|
<form method="get" action="/search">
|
|
<input name="q" type="search" placeholder="Search…">
|
|
<button>Search</button>
|
|
</form>
|
|
```
|
|
|
|
→ JS 가 query param + result render. 없어도 server 가 SSR.
|
|
|
|
### Optimistic UI + rollback
|
|
```tsx
|
|
'use client';
|
|
import { useOptimistic } from 'react';
|
|
|
|
const [optimistic, addOptimistic] = useOptimistic(items);
|
|
|
|
async function add(item) {
|
|
addOptimistic([...items, item]); // 즉시 UI
|
|
await fetch('/api/add', { method: 'POST', body: JSON.stringify(item) });
|
|
}
|
|
```
|
|
|
|
→ JS 활성 시 즉시 반응. 없으면 server 의 form submit.
|
|
|
|
### loading="lazy", decoding="async"
|
|
```html
|
|
<img src="..." loading="lazy" decoding="async" alt="...">
|
|
<iframe src="..." loading="lazy"></iframe>
|
|
```
|
|
|
|
### Tailwind + native HTML
|
|
```html
|
|
<details class="border rounded p-4">
|
|
<summary class="cursor-pointer font-bold">FAQ</summary>
|
|
<p class="mt-2">Answer</p>
|
|
</details>
|
|
```
|
|
|
|
→ JS 없는 accordion.
|
|
|
|
### Form validation (browser native)
|
|
```html
|
|
<input type="email" required>
|
|
<input type="number" min="0" max="100" step="0.1">
|
|
<input pattern="[A-Z]{3}-\d{4}" title="ABC-1234">
|
|
<input type="text" required minlength="3" maxlength="20">
|
|
```
|
|
|
|
```css
|
|
input:invalid { border-color: red; }
|
|
input:invalid:not(:placeholder-shown) { ... }
|
|
```
|
|
|
|
### Web standards 활용
|
|
```html
|
|
<input type="date"> <!-- date picker native -->
|
|
<input type="color"> <!-- color picker -->
|
|
<input type="range"> <!-- slider -->
|
|
<input type="search"> <!-- clear button native -->
|
|
<input type="file" accept="image/*" multiple>
|
|
```
|
|
|
|
→ JS 라이브러리 없이.
|
|
|
|
### Fetch with form + JSON 전환
|
|
```tsx
|
|
async function handleSubmit(e: FormEvent) {
|
|
e.preventDefault();
|
|
const data = Object.fromEntries(new FormData(e.target as HTMLFormElement));
|
|
await fetch('/api/...', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
<form onSubmit={handleSubmit} action="/api/..." method="post">
|
|
...
|
|
</form>
|
|
```
|
|
|
|
→ JS 활성 = JSON. 없으면 form encode + server 가 처리.
|
|
|
|
### MPA vs SPA vs PE
|
|
```
|
|
MPA (Multi-Page App): 서버 매번 HTML 보냄. 옛.
|
|
SPA: JS 가 모든 거. PE 무시 자주.
|
|
Progressive Enhanced: 둘 다 — MPA 처럼 baseline, SPA 처럼 향상.
|
|
|
|
→ Next App Router / Remix / Phoenix LiveView / Rails Hotwire.
|
|
```
|
|
|
|
### a11y 자연
|
|
```html
|
|
<button>Click</button> <!-- focus, role 자동 -->
|
|
<a href="...">Link</a> <!-- 키보드 navigation 자동 -->
|
|
<form> <!-- enter submit, label association -->
|
|
```
|
|
|
|
→ Custom div 보다 우월.
|
|
|
|
### React Server Components (RSC)
|
|
```tsx
|
|
// 서버 컴포넌트 — JS 0 client
|
|
async function Page() {
|
|
const data = await db.query(...);
|
|
return <ul>{data.map(d => <li>{d}</li>)}</ul>;
|
|
}
|
|
```
|
|
|
|
→ HTML 만 보냄. 작은 page = JS 0.
|
|
|
|
## 🤔 의사결정 기준
|
|
| 상황 | 추천 |
|
|
|---|---|
|
|
| Public site (SEO, accessibility) | PE strict |
|
|
| 내부 dashboard (관리자만) | SPA OK |
|
|
| Form-heavy | Server actions / Remix |
|
|
| 인터랙션 없는 콘텐츠 | RSC + 0 client JS |
|
|
| 복잡 인터랙션 | SPA + PE 가능 한도 |
|
|
| 옛 browser / slow 3G | PE 강 |
|
|
|
|
## ❌ 안티패턴
|
|
- **모든 거 client-side JS**: SEO / a11y / slow.
|
|
- **`<div onClick>` button**: 키보드 안 됨.
|
|
- **Form 가 fetch + JSON 만 — server action 없음**: JS X = 작동 X.
|
|
- **Required JS — fallback X**: 옛 browser fail.
|
|
- **Client-only routing — server URL 안 됨**: refresh = 404.
|
|
- **Loading skeleton 만 + 데이터 없음**: PE 무시.
|
|
|
|
## 🤖 LLM 활용 힌트
|
|
- Form action + method = baseline.
|
|
- Native HTML 우선 (button, dialog, details).
|
|
- JS 는 향상 — 없어도 작동.
|
|
- Next App Router / Remix 가 PE 친화.
|
|
|
|
## 🔗 관련 문서
|
|
- [[React_RSC_Server_Actions_Deep]]
|
|
- [[Frontend_A11y_Testing]]
|
|
- [[Web_Performance_Core_Vitals]]
|