이 글은 누구를 위한 것인가
- JavaScript 없이도 기본 기능이 동작하는 앱을 만들려는 팀
- 느린 네트워크 환경의 사용자를 배려하는 프론트엔드를 만들고 싶은 개발자
- CSS 신기능을 폴백과 함께 안전하게 사용하려는 개발자
들어가며
점진적 향상은 "HTML이 기본, CSS가 향상, JS가 추가 향상"이라는 웹의 본질적 철학이다. React의 Server Actions, Astro의 Islands, SvelteKit의 폼 향상은 모두 이 철학을 현대적으로 구현한다.
이 글은 bluefoxdev.kr의 점진적 향상 현대 웹 가이드 를 참고하여 작성했습니다.
1. 점진적 향상의 세 레이어
[레이어 구조]
1. 기반 레이어 (HTML):
- JS 없이 동작해야 함
- 시맨틱 HTML: <nav>, <main>, <article>, <button>
- 폼: action 속성으로 서버 제출
- 링크: href로 탐색
- 이미지: alt 텍스트
2. 표현 레이어 (CSS):
- 기본 스타일: 모든 브라우저 지원
- @supports로 고급 기능 분기
- 컨테이너 쿼리, 앵커 포지셔닝 등 신기능 점진적 적용
- prefers-reduced-motion으로 사용자 선호 존중
3. 동작 레이어 (JavaScript):
- HTML 기능 향상 (AJAX 폼, 인터랙션 추가)
- JS 실패해도 HTML 레이어는 동작
- 기능 탐지: 'fetch' in window, CSS.supports()
- 무겁고 필수적이지 않은 것은 지연 로딩
[점진적 향상의 실제 이점]
성능: 초기 HTML은 가볍고 빠름
접근성: 스크린 리더, 보조 기기와 호환
안정성: JS 에러가 전체 앱을 망가뜨리지 않음
검색 엔진: 크롤러는 HTML만 읽음
오프라인: Service Worker 없어도 기본 기능 동작
2. 점진적 향상 구현 패턴
<!-- 기반: 순수 HTML 폼 (JS 없이 동작) -->
<form action="/api/newsletter" method="post">
<label for="email">이메일</label>
<input
type="email"
id="email"
name="email"
required
autocomplete="email"
/>
<button type="submit">구독하기</button>
</form>
<!-- JS 향상: 클라이언트 유효성 검사, AJAX 제출 -->
<script>
const form = document.querySelector('form[action="/api/newsletter"]');
if (form && 'fetch' in window) {
form.addEventListener('submit', async (e) => {
e.preventDefault(); // 기본 제출 차단
const formData = new FormData(form);
const button = form.querySelector('button[type="submit"]');
button.disabled = true;
button.textContent = '처리 중...';
try {
const res = await fetch(form.action, {
method: 'POST',
body: formData,
});
if (res.ok) {
form.innerHTML = '<p>✅ 구독이 완료됐습니다!</p>';
} else {
throw new Error('서버 오류');
}
} catch {
// 실패 시 네이티브 폼 제출로 폴백
form.submit();
} finally {
button.disabled = false;
button.textContent = '구독하기';
}
});
}
</script>
// Next.js Server Actions - 점진적 향상
'use server';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
export async function subscribeNewsletter(formData: FormData) {
const email = formData.get('email') as string;
if (!email || !email.includes('@')) {
return { error: '유효한 이메일을 입력해주세요' };
}
await db.newsletter.create({ data: { email } });
// Server Action은 JS 없이도 동작
// JS 있으면 클라이언트에서 결과를 받아 UI 업데이트
revalidatePath('/');
redirect('/thank-you'); // JS 없으면 리다이렉트
}
// 클라이언트 컴포넌트에서 향상된 UX
'use client';
import { useActionState } from 'react';
import { subscribeNewsletter } from './actions';
function NewsletterForm() {
const [state, action, isPending] = useActionState(subscribeNewsletter, null);
return (
// action 속성 = JS 없이 폼 제출 가능
<form action={action}>
<input type="email" name="email" required />
{state?.error && (
<p role="alert" className="text-red-500">{state.error}</p>
)}
<button type="submit" disabled={isPending} aria-busy={isPending}>
{isPending ? '처리 중...' : '구독하기'}
</button>
</form>
);
}
/* CSS 점진적 향상 */
/* 기본: 모든 브라우저 지원 */
.card {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
}
/* 향상: 현대 브라우저 */
@supports (backdrop-filter: blur(10px)) {
.card.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
}
/* 컨테이너 쿼리 점진적 적용 */
.product-grid {
display: grid;
grid-template-columns: 1fr; /* 폴백: 단일 열 */
}
@media (min-width: 768px) {
.product-grid {
grid-template-columns: repeat(2, 1fr); /* 폴백: 미디어 쿼리 */
}
}
/* 컨테이너 쿼리 지원 시 더 정확한 반응형 */
@supports (container-type: inline-size) {
.product-card-container {
container-type: inline-size;
}
@container (min-width: 300px) {
.product-card {
display: flex;
flex-direction: row;
}
}
}
/* 사용자 선호 존중 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* 고대비 모드 지원 */
@media (forced-colors: active) {
.button {
border: 2px solid ButtonText;
}
}
// JavaScript 기능 탐지
// 방어적 프로그래밍
// 나쁜 패턴: userAgent 파싱
// const isModern = navigator.userAgent.includes('Chrome');
// 좋은 패턴: 기능 직접 탐지
const canUseWebShare = 'share' in navigator;
const canUseWebAnimation = 'animate' in document.createElement('div');
const canUsePaint = 'paint' in CSS;
const canUseVibration = 'vibrate' in navigator;
function shareContent(data) {
if (canUseWebShare) {
navigator.share(data);
} else {
// 폴백: 복사 버튼 표시
showCopyLink(data.url);
}
}
// 서비스 워커 점진적 등록
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// 서비스 워커 실패해도 앱은 정상 동작
});
});
}
// IntersectionObserver 폴백
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) entry.target.classList.add('visible');
});
});
document.querySelectorAll('.animate').forEach(el => observer.observe(el));
} else {
// 폴백: 모든 요소 즉시 표시
document.querySelectorAll('.animate').forEach(el => el.classList.add('visible'));
}
마무리
점진적 향상은 "JS가 없을 때의 경험"이 아니라 "모든 사용자에게 최적의 경험"을 주는 철학이다. 서버 액션으로 작성된 폼은 JavaScript 없이도 제출되고, JavaScript가 있으면 더 나은 UX를 제공한다. @supports로 CSS 신기능을 폴백과 함께 사용하면 구형 브라우저를 지원하면서도 현대적 경험을 제공할 수 있다. 점진적 향상을 따르면 성능, 접근성, SEO가 동시에 개선되는 시너지가 발생한다.