Ver3.5 ๐ฒ PWA(Progressive Web App)๋ฅผ ํ์ฉํ์ค์นํ ์ฑ ๋์ฒด ๊ธฐ์ ์ ํ์ฌ์ ๋ฏธ๋

๐ฒ PWA(Progressive Web App)๋ฅผ ํ์ฉํ
์ค์นํ ์ฑ ๋์ฒด ๊ธฐ์ ์ ํ์ฌ์ ๋ฏธ๋
์ผ ๊ทผ๋ฐ ์์งํ ๋งํด๋ด. ์ฑ ํ๋ ์ฐ๋ ค๊ณ ์ฑ์คํ ์ด ๋ค์ด๊ฐ์ ๋ค์ด๋ฐ๊ณ , ์ค์น ๊ธฐ๋ค๋ฆฌ๊ณ , ๊ถํ ํ์ฉํ๊ณ ... ์ด ๊ณผ์ ์ด ์ผ๋ง๋ ๊ท์ฐฎ์? ๐ค
๊ทผ๋ฐ ์์ฆ ์น์ฌ์ดํธ ๋ค์ด๊ฐ๋๋ "ํ ํ๋ฉด์ ์ถ๊ฐํ์๊ฒ ์ด์?" ๋จ๊ณ , ์ถ๊ฐํ๋๊น ์ง์ง ์ฑ์ฒ๋ผ ์คํ๋๋ ๊ฑฐ ๊ฒฝํํด๋ณธ ์ ์์ง ์์?
๊ทธ๊ฒ ๋ฐ๋ก PWA(Progressive Web App)์. ์น ๊ธฐ์ ๋ก ๋ง๋ค์๋๋ฐ ์ฑ์ฒ๋ผ ๋์ํ๋ ๊ทธ ์ ๊ธฐํ ๊ฒ ใ
ใ
ใ
๐ค PWA๊ฐ ๋ญ๋ฐ? ์ง์ง๋ก
PWA๋ Progressive Web App์ ์ฝ์์ผ. ๊ตฌ๊ธ์ด 2015๋
์ ์ฒ์ ๊ฐ๋
์ ์ ์ํ๊ณ , ํฌ๋กฌ ๊ฐ๋ฐ์ Alex Russell์ด ๊ณต์์ ์ผ๋ก ๋ช
๋ช
ํ ๊ธฐ์ ์ด์ผ.
ํ๋ง๋๋ก ์ ์ํ๋ฉด โ ์น ๊ธฐ์ (HTML, CSS, JavaScript)๋ก ๋ง๋ค์์ง๋ง ๋ค์ดํฐ๋ธ ์ฑ์ฒ๋ผ ๋์ํ๋ ์น ์ ํ๋ฆฌ์ผ์ด์
์ด์ผ.
๐ PWA์ 3๊ฐ์ง ํต์ฌ ์กฐ๊ฑด
๊ตฌ๊ธ์ด ์ ์ํ PWA์ ๊ธฐ๋ณธ ์๊ฑด์ ํฌ๊ฒ 3๊ฐ์ง์ผ:
๋คํธ์ํฌ ์ํ๊ฐ ๋ถ์์ ํ๊ฑฐ๋ ์คํ๋ผ์ธ์ด์ด๋ ๋์ํด์ผ ํจ.
Service Worker๊ฐ ์ด๊ฑธ ๋ด๋นํจ.
์คํฌ๋กค, ์ ๋๋ฉ์ด์ , ํ๋ฉด ์ ํ์ด ๋ถ๋๋ฌ์์ผ ํจ.
60fps ๊ธฐ์ค์ผ๋ก ๋ฒ๋ฒ ์ ์์ด์ผ ํจ.
ํ ํ๋ฉด ์ค์น, ํธ์ ์๋ฆผ, ์ ์ฒด ํ๋ฉด ์คํ ๋ฑ
์ฑ๋ค์ด ๊ฒฝํ์ ์ ๊ณตํด์ผ ํจ.
๐๏ธ PWA๋ฅผ ๊ตฌ์ฑํ๋ ํต์ฌ ๊ธฐ์ ๋ค
PWA๋ ๋จ์ผ ๊ธฐ์ ์ด ์๋๋ผ ์ฌ๋ฌ ์น ํ์ค ๊ธฐ์ ์ ์กฐํฉ์ด์ผ. ๊ฐ๊ฐ ๋ญ์ง ์์๋ณด์:
๋ธ๋ผ์ฐ์ ๋ฐฑ๊ทธ๋ผ์ด๋์์ ์คํ๋๋ JavaScript ํ์ผ. ๋คํธ์ํฌ ์์ฒญ์ ๊ฐ๋ก์ฑ์ ์บ์๋ฅผ ๊ด๋ฆฌํ๊ณ , ์คํ๋ผ์ธ ๋์์ ๊ฐ๋ฅํ๊ฒ ํด์ค. PWA์ ์ฌ์ฅ์ด๋ผ๊ณ ๋ณด๋ฉด ๋จ.
JSON ํ์ผ๋ก, ์ฑ ์ด๋ฆ, ์์ด์ฝ, ์์ URL, ํ๋ฉด ๋ฐฉํฅ, ํ ๋ง ์์ ๋ฑ์ ์ ์ํจ. ํ ํ๋ฉด์ ์ถ๊ฐํ ๋ ์ด ํ์ผ์ ์ฐธ์กฐํด์ ์ฑ์ฒ๋ผ ๋ณด์ด๊ฒ ๋ง๋ค์ด์ค.
PWA๋ ๋ฐ๋์ HTTPS ํ๊ฒฝ์์๋ง ๋์ํจ. Service Worker๊ฐ ๋คํธ์ํฌ ์์ฒญ์ ๊ฐ๋ก์ฑ๋ ๊ฐ๋ ฅํ ๊ธฐ๋ฅ์ ๊ฐ๊ณ ์์ด์, ๋ณด์์ ์ํด ํ์ ์กฐ๊ฑด์.
Service Worker์ ํจ๊ป ์ฌ์ฉ๋๋ ์บ์ ์ ์ฅ์. ์ ์ ํ์ผ(HTML, CSS, JS, ์ด๋ฏธ์ง)์ ๋ก์ปฌ์ ์ ์ฅํด์ ์คํ๋ผ์ธ์์๋ ๋ถ๋ฌ์ฌ ์ ์๊ฒ ํจ.
์๋ฒ์์ ์ฌ์ฉ์์๊ฒ ํธ์ ์๋ฆผ์ ๋ณด๋ผ ์ ์๋ ๊ธฐ๋ฅ. ์ฑ์ ์ด์ง ์์๋ ์๋ฆผ์ด ์ค๋ ๊ทธ ๊ธฐ๋ฅ์.
โ๏ธ Service Worker: PWA์ ์ง์ง ํต์ฌ
Service Worker ์์ผ๋ฉด PWA ์๋. ์ง์ง๋ก. ์ด๊ฒ PWA๋ฅผ ์ผ๋ฐ ์น์ฌ์ดํธ์ ๊ตฌ๋ถ์ง๋ ๊ฐ์ฅ ์ค์ํ ๊ธฐ์ ์ด์ผ.
๊ทผ๋ฐ ์ด๊ฒ ์ข ๊ฐ๋
์ด ํน์ดํด์ ์ฒ์์ ํท๊ฐ๋ฆด ์ ์์. ์ฐจ๊ทผ์ฐจ๊ทผ ์ค๋ช
ํด์ค๊ฒ ๐
DOM์ ์ ๊ทผํ ์ ์๊ณ , ์น ํ์ด์ง์ ์ง์ ํต์ ํ๋ ค๋ฉด postMessage API๋ฅผ ์จ์ผ ํด.
๋์ ๋คํธ์ํฌ ์์ฒญ์ ํ๋ก์์ฒ๋ผ ๊ฐ๋ก์ฑ๊ณ , ์บ์๋ฅผ ๊ด๋ฆฌํ๊ณ , ํธ์ ์๋ฆผ์ ๋ฐ์ ์ ์์ด.
๐ Service Worker ์๋ช ์ฃผ๊ธฐ
Service Worker๋ ์ค์น โ ํ์ฑํ โ ์คํ์ ๋จ๊ณ๋ฅผ ๊ฑฐ์นจ:
navigator.serviceWorker.register()๋ฅผ ํธ์ถํด์ SW๋ฅผ ๋ฑ๋กํจ.2๋จ๊ณ: ์ค์น (Install)
install ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ๊ณ , ์ด๋ ํ์ํ ํ์ผ๋ค์ ์บ์์ ์ ์ฅํจ.3๋จ๊ณ: ํ์ฑํ (Activate)
์ด์ ๋ฒ์ ์ SW๊ฐ ์์ผ๋ฉด ๊ต์ฒดํ๊ณ , ์ค๋๋ ์บ์๋ฅผ ์ ๋ฆฌํจ.
4๋จ๊ณ: ์คํ (Fetch/Push/Sync)
๋คํธ์ํฌ ์์ฒญ์ ๊ฐ๋ก์ฑ๊ฑฐ๋, ํธ์ ์๋ฆผ์ ์ฒ๋ฆฌํ๊ฑฐ๋, ๋ฐฑ๊ทธ๋ผ์ด๋ ๋๊ธฐํ๋ฅผ ์ํํจ.
๐ป ์ค์ Service Worker ์ฝ๋ ์์
// sw.js (Service Worker ํ์ผ)
const CACHE_NAME = 'my-pwa-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/icons/icon-192.png'
];
// ์ค์น ์ด๋ฒคํธ: ํต์ฌ ํ์ผ๋ค์ ์บ์์ ์ ์ฅ
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('์บ์ ์ ์ฅ ์์!');
return cache.addAll(ASSETS_TO_CACHE);
})
);
});
// ํ์ฑํ ์ด๋ฒคํธ: ์ค๋๋ ์บ์ ์ญ์
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
});
// fetch ์ด๋ฒคํธ: ๋คํธ์ํฌ ์์ฒญ ๊ฐ๋ก์ฑ๊ธฐ
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
// ์บ์์ ์์ผ๋ฉด ์บ์์์ ๋ฐํ, ์์ผ๋ฉด ๋คํธ์ํฌ ์์ฒญ
return cachedResponse || fetch(event.request);
})
);
});
โข Network First: ๋คํธ์ํฌ ๋จผ์ , ์คํจํ๋ฉด ์บ์
โข Stale While Revalidate: ์บ์ ๋ฐํํ๋ฉด์ ๋ฐฑ๊ทธ๋ผ์ด๋์์ ์ ๋ฐ์ดํธ
โข Cache Only: ์บ์๋ง ์ฌ์ฉ
โข Network Only: ๋คํธ์ํฌ๋ง ์ฌ์ฉ
์ํฉ์ ๋ง๊ฒ ์ ๋ต์ ์ ํํด์ผ ํจ!
๐ฑ Service Worker ๋ฑ๋ก ์ฝ๋
// main.js (๋ฉ์ธ ์ฑ ํ์ผ)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('SW ๋ฑ๋ก ์ฑ๊ณต! ์ค์ฝํ:', registration.scope);
})
.catch((error) => {
console.log('SW ๋ฑ๋ก ์คํจ:', error);
});
});
}
๐ Web App Manifest: ์ฑ์ฒ๋ผ ๋ณด์ด๊ฒ ํ๋ ๋ง๋ฒ
Service Worker๊ฐ PWA์ ๊ธฐ๋ฅ์ ์ฌ์ฅ์ด๋ผ๋ฉด, Web App Manifest๋ PWA์ ์ธ๋ชจ๋ฅผ ๋ด๋นํด.
์ด JSON ํ์ผ ํ๋๋ก ์ฑ ์ด๋ฆ, ์์ด์ฝ, ์์, ํ๋ฉด ๋ชจ๋ ๋ฑ์ ์ค์ ํ ์ ์์.
๐ป manifest.json ์์
{
"name": "๋์ ๋ฉ์ง PWA ์ฑ",
"short_name": "๋ฉ์ง์ฑ",
"description": "PWA๋ก ๋ง๋ ์ต๊ณ ์ ์ฑ์
๋๋ค",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#0f3460",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"screenshots": [
{
"src": "/screenshots/screen1.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
],
"shortcuts": [
{
"name": "์ ๊ธ ์์ฑ",
"url": "/new-post",
"icons": [{"src": "/icons/new-post.png", "sizes": "96x96"}]
}
]
}
๐ฅ๏ธ display ์์ฑ ์ต์ ๋ค
display ์์ฑ์ด PWA ๊ฒฝํ์์ ์์ฒญ ์ค์ํด. 4๊ฐ์ง ์ต์ ์ด ์์:
| ์ต์ | ์ค๋ช | ์ฃผ์์ฐฝ |
|---|---|---|
| fullscreen | ์์ ์ ์ฒดํ๋ฉด, ์ํ๋ฐ๋ ์์ | ์์ |
| standalone | ๋ค์ดํฐ๋ธ ์ฑ์ฒ๋ผ ๋ณด์, ์ํ๋ฐ๋ ์์ | ์์ |
| minimal-ui | ์ต์ํ์ ๋ธ๋ผ์ฐ์ UI๋ง ํ์ | ์ต์ํ |
| browser | ์ผ๋ฐ ๋ธ๋ผ์ฐ์ ํญ์ผ๋ก ์ด๋ฆผ | ์์ |
standalone ๋ชจ๋๋ฅผ ์ฌ์ฉํจ. ์ฃผ์์ฐฝ์ด ์์ด์ ์ฑ์ฒ๋ผ ๋ณด์ด๋ฉด์๋, ์์ ์ ์ฒดํ๋ฉด๋ณด๋ค๋ ์ฌ์ฉ์๊ฐ ๋ ๋ต๋ตํดํจ. ๊ฒ์ ์ฑ์ด๋ฉด fullscreen ์ฐ๊ธฐ๋ ํจ.
๐ HTML์์ Manifest ์ฐ๊ฒฐํ๊ธฐ
<!-- index.html์ <head> ์์ ์ถ๊ฐ -->
<link rel="manifest" href="/manifest.json">
<!-- iOS Safari๋ฅผ ์ํ ์ถ๊ฐ ๋ฉํํ๊ทธ (iOS๋ manifest ์ผ๋ถ ๋ฏธ์ง์) -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="๋ฉ์ง์ฑ">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">
<!-- ํ
๋ง ์์ (๋ธ๋ผ์ฐ์ ์ฃผ์์ฐฝ ์์) -->
<meta name="theme-color" content="#0f3460">
๊ทธ๋์ iOS ์ง์์ ์ํด
apple-mobile-web-app-* ๋ฉํํ๊ทธ๋ฅผ ๋ณ๋๋ก ์ถ๊ฐํด์ผ ํด.
๐ฒ "ํ ํ๋ฉด์ ์ถ๊ฐ" โ ์ค์น ๊ฒฝํ ๊ตฌํํ๊ธฐ
PWA์ ๊ฝ์ ์ญ์ ํ ํ๋ฉด ์ค์น ๊ฒฝํ์ด์ง. ๊ทผ๋ฐ ์ด๊ฒ ๋ธ๋ผ์ฐ์ ๋ง๋ค ๋์์ด ๋ฌ๋ผ์ ์ข ๋ณต์กํจ ใ
ใ
์ ๋๋ก ๊ตฌํํ๋ฉด ์ฌ์ฉ์๊ฐ ์ฑ์คํ ์ด ์์ด๋ ์ฑ์ฒ๋ผ ์ธ ์ ์์ด.
๐ ์ค์น ํ๋กฌํํธ ์ ์ดํ๊ธฐ
Chrome/Edge์์๋ beforeinstallprompt ์ด๋ฒคํธ๋ก ์ค์น ํ๋กฌํํธ๋ฅผ ์ ์ดํ ์ ์์ด:
let deferredPrompt; // ์ค์น ํ๋กฌํํธ ์ ์ฅ
// ๋ธ๋ผ์ฐ์ ๊ฐ ์ค์น ํ๋กฌํํธ๋ฅผ ๋์ฐ๋ ค ํ ๋ ์ด๋ฒคํธ ๋ฐ์
window.addEventListener('beforeinstallprompt', (e) => {
// ์๋์ผ๋ก ๋จ๋ ๊ฑธ ๋ง๊ณ
e.preventDefault();
// ๋์ค์ ์ธ ์ ์๊ฒ ์ ์ฅ
deferredPrompt = e;
// ์ปค์คํ
์ค์น ๋ฒํผ ๋ณด์ฌ์ฃผ๊ธฐ
showInstallButton();
});
// ์ฌ์ฉ์๊ฐ ์ค์น ๋ฒํผ ํด๋ฆญํ์ ๋
async function handleInstallClick() {
if (!deferredPrompt) return;
// ์ค์น ํ๋กฌํํธ ํ์
deferredPrompt.prompt();
// ์ฌ์ฉ์ ์ ํ ๊ฒฐ๊ณผ ํ์ธ
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('์ฌ์ฉ์๊ฐ ์ค์น๋ฅผ ์๋ฝํ์ด์! ๐');
} else {
console.log('์ฌ์ฉ์๊ฐ ์ค์น๋ฅผ ๊ฑฐ์ ํ์ด์ ๐ข');
}
deferredPrompt = null;
hideInstallButton();
}
// ์ฑ์ด ์ค์น๋ ํ ์ด๋ฒคํธ
window.addEventListener('appinstalled', () => {
console.log('PWA ์ค์น ์๋ฃ! ๐');
deferredPrompt = null;
});
beforeinstallprompt ์ด๋ฒคํธ๋ฅผ ์ง์ํ์ง ์์.iOS์์๋ ์ฌ์ฉ์๊ฐ ์ง์ Safari ๊ณต์ ๋ฒํผ โ "ํ ํ๋ฉด์ ์ถ๊ฐ"๋ฅผ ๋๋ฌ์ผ ํด.
๊ทธ๋์ iOS ์ฌ์ฉ์์๊ฒ๋ ๋ณ๋๋ก "์ด๋ ๊ฒ ์ค์นํ์ธ์" ์๋ด UI๋ฅผ ๋ณด์ฌ์ฃผ๋ ๊ฒ ์ข์.
๐ ์ค์น ์ฌ๋ถ ๊ฐ์งํ๊ธฐ
// ํ์ฌ PWA๋ก ์คํ ์ค์ธ์ง ํ์ธ
function isPWA() {
return window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true; // iOS Safari
}
if (isPWA()) {
console.log('PWA ๋ชจ๋๋ก ์คํ ์ค!');
} else {
console.log('๋ธ๋ผ์ฐ์ ์์ ์คํ ์ค');
}
๐ ํธ์ ์๋ฆผ ๊ตฌํ โ ์ฑ์ด๋ ๋๊ฐ์ด
ํธ์ ์๋ฆผ์ ์ฌ์ฉ์ ์ฌ์ฐธ์ฌ(Re-engagement)์์ ์์ฒญ ์ค์ํ ๊ธฐ๋ฅ์ด์ผ.
PWA์์๋ ๋ค์ดํฐ๋ธ ์ฑ์ฒ๋ผ ํธ์ ์๋ฆผ์ ๋ณด๋ผ ์ ์๋๋ฐ, ์ด๊ฒ ์๊ฐ๋ณด๋ค ๊ตฌํ์ด ์ข ๋ณต์กํจ.
๐ Web Push ๋์ ์๋ฆฌ
2๏ธโฃ ๋ธ๋ผ์ฐ์ ๊ฐ Push Service(FCM ๋ฑ)์ ๊ตฌ๋ ์ ๋ณด ์์ฒญ
3๏ธโฃ ๊ตฌ๋ ์ ๋ณด(endpoint + ์ํธํ ํค)๋ฅผ ์๋ฒ์ ์ ์ฅ
4๏ธโฃ ์๋ฒ์์ Push Service๋ก ๋ฉ์์ง ์ ์ก
5๏ธโฃ Push Service๊ฐ ์ฌ์ฉ์ ๋ธ๋ผ์ฐ์ ๋ก ์ ๋ฌ
6๏ธโฃ Service Worker๊ฐ push ์ด๋ฒคํธ ์์ ํ ์๋ฆผ ํ์
// ํด๋ผ์ด์ธํธ: ํธ์ ๊ตฌ๋
์์ฒญ
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // ๋ฐ๋์ true (์ฌ์ฉ์์๊ฒ ๋ณด์ด๋ ์๋ฆผ๋ง)
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
});
// ์๋ฒ์ ๊ตฌ๋
์ ๋ณด ์ ์ก
await fetch('/api/push-subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
}
// Service Worker: ํธ์ ์ด๋ฒคํธ ์ฒ๋ฆฌ
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
data: { url: data.url },
actions: [
{ action: 'open', title: '์ด๊ธฐ' },
{ action: 'close', title: '๋ซ๊ธฐ' }
]
})
);
});
// ์๋ฆผ ํด๋ฆญ ์ฒ๋ฆฌ
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'open') {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
web-push ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ก ์ฝ๊ฒ ์์ฑ ๊ฐ๋ฅ: npx web-push generate-vapid-keys
๐ก ์คํ๋ผ์ธ ์ ๋ต โ ์ธํฐ๋ท ์์ด๋ ๋์ํ๊ฒ
PWA์ ํต์ฌ ๊ฐ์น ์ค ํ๋๊ฐ ์คํ๋ผ์ธ ๋์์ด์ผ. ์งํ์ฒ ์์ ์ธํฐ๋ท ๋๊ฒจ๋ ์ฑ์ด ๋์ํ๋ฉด ์ฌ์ฉ์ ๊ฒฝํ์ด ์์ฒญ ์ข์์ง๊ฑฐ๋ .
๊ทผ๋ฐ ์คํ๋ผ์ธ ์ ๋ต์ ์ด๋ป๊ฒ ์ง๋๋์ ๋ฐ๋ผ UX๊ฐ ์์ ๋ฌ๋ผ์ง.
๐๏ธ ์บ์ฑ ์ ๋ต ์์ธ ๋น๊ต
์ธ์ ์ฐ๋: ์์ฃผ ๋ณํ์ง ์๋ ์ ์ ํ์ผ (ํฐํธ, ์ด๋ฏธ์ง, CSS)
์บ์ โ ์์ผ๋ฉด ๋คํธ์ํฌ
์๋ ์ต์ฐ์ , ์ต์ ์ฑ ๋ฎ์
์ธ์ ์ฐ๋: ์์ฃผ ์ ๋ฐ์ดํธ๋๋ API ๋ฐ์ดํฐ
๋คํธ์ํฌ โ ์คํจํ๋ฉด ์บ์
์ต์ ์ฑ ์ฐ์ , ์คํ๋ผ์ธ ํด๋ฐฑ
์ธ์ ์ฐ๋: ๋ด์ค ํผ๋, ์์ ๋ฏธ๋์ด
์บ์ ์ฆ์ ๋ฐํ + ๋ฐฑ๊ทธ๋ผ์ด๋ ์ ๋ฐ์ดํธ
์๋์ ์ต์ ์ฑ ๊ท ํ
๐พ IndexedDB๋ก ์คํ๋ผ์ธ ๋ฐ์ดํฐ ์ ์ฅ
๋จ์ ํ์ผ ์บ์ ์ธ์๋, ์ฌ์ฉ์ ๋ฐ์ดํฐ๋ฅผ ๋ก์ปฌ์ ์ ์ฅํ๋ ค๋ฉด IndexedDB๋ฅผ ์จ์ผ ํด:
// IndexedDB ์ฌ์ฉ ์์ (idb ๋ผ์ด๋ธ๋ฌ๋ฆฌ ํ์ฉ)
import { openDB } from 'idb';
const db = await openDB('my-pwa-db', 1, {
upgrade(db) {
db.createObjectStore('posts', { keyPath: 'id' });
}
});
// ๋ฐ์ดํฐ ์ ์ฅ
async function savePost(post) {
await db.put('posts', post);
}
// ์คํ๋ผ์ธ ์ํ์์ ๋ฐ์ดํฐ ์ฝ๊ธฐ
async function getPosts() {
return await db.getAll('posts');
}
// ์จ๋ผ์ธ ๋ณต๊ท ์ ๋๊ธฐํ
window.addEventListener('online', async () => {
const offlinePosts = await db.getAll('offline-queue');
for (const post of offlinePosts) {
await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(post)
});
}
});
๐ Background Sync API
์คํ๋ผ์ธ ์ํ์์ ์์ ์ ํ์ ์ ์ฅํ๊ณ , ์จ๋ผ์ธ ๋ณต๊ท ์ ์๋์ผ๋ก ๋๊ธฐํํ๋ ๊ธฐ๋ฅ์ด์ผ:
// ์คํ๋ผ์ธ ์ํ์์ ํผ ์ ์ถ ์
async function submitForm(data) {
try {
await fetch('/api/submit', { method: 'POST', body: data });
} catch (error) {
// ์คํ๋ผ์ธ์ด๋ฉด ํ์ ์ ์ฅํ๊ณ ๋์ค์ ๋๊ธฐํ ์์ฒญ
await saveToQueue(data);
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-form-data');
}
}
// Service Worker์์ sync ์ด๋ฒคํธ ์ฒ๋ฆฌ
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-form-data') {
event.waitUntil(syncFormData());
}
});
๐ข ์ค์ ๊ธฐ์ ๋ค์ PWA ๋์ ์ฌ๋ก โ ์ซ์๋ก ์ฆ๋ช
์ด๋ก ์ ์ด๋ก ์ด๊ณ , ์ค์ ๋ก PWA๊ฐ ํจ๊ณผ๊ฐ ์๋๊ณ ? ์์. ๋ฐ์ดํฐ๋ก ์ฆ๋ช
๋จ ใ
ใ
๊ธ๋ก๋ฒ ๊ธฐ์
๋ค์ PWA ๋์
๊ฒฐ๊ณผ๋ฅผ ๋ณด๋ฉด ์ง์ง ๋๋ผ์.
๐ ์ฃผ์ ๊ธฐ์ PWA ๋์ ์ฑ๊ณผ
โข ์ธ์ ๋น ํ์ด์ง ์ 65% ์ฆ๊ฐ
โข ํธ์ ์ 75% ์ฆ๊ฐ
โข ์ฑ ํฌ๊ธฐ: ๊ธฐ์กด ์ฑ ๋๋น 97% ๊ฐ์ (600KB vs 23.5MB)
โข ์ฌ์ฉ์๋น ์๊ฐ ์๋น 3๋ฐฐ ์ฆ๊ฐ
โข ์ ํ์จ 70% ์ฆ๊ฐ
โข ๋ฐ์ดํฐ ์ฌ์ฉ๋ 3๋ฐฐ ๊ฐ์
โข iOS ์ฌ์ฉ์ ์ธ์ ๋น ํ์ด์ง ์ 2๋ฐฐ ์ฆ๊ฐ
โข ๋ชจ๋ ๋ธ๋ผ์ฐ์ ์์ ์ฒด๋ฅ ์๊ฐ 74% ์ฆ๊ฐ
โข ์๋ฆผ ์์ ๋์ ์ฌ์ฉ์์ ์ง์ ๋ฐฉ๋ฌธ 97% ์ฆ๊ฐ
โข ์คํ๋ผ์ธ ์ฌ์ฉ์ ์ ํ์จ 67% ์ฆ๊ฐ
โข ์คํฌ๋กค ๊น์ด 10% ์ฆ๊ฐ
โข ๊ด๊ณ ๊ฐ์์ฑ 20% ์ฆ๊ฐ
โข ์ดํ๋ฅ 20% ๊ฐ์
๐ ๏ธ PWA ๊ฐ๋ฐ ๋๊ตฌ & ํ๋ ์์ํฌ ์ด์ ๋ฆฌ
PWA๋ฅผ ์ฒ์๋ถํฐ ๋ค ๋ง๋ค ํ์ ์์ด. ์ด๋ฏธ ์ข์ ๋๊ตฌ๋ค์ด ๋ง์ด ์๊ฑฐ๋ .
์ด๋ค ๊ฑธ ์จ์ผ ํ ์ง ์ ๋ฆฌํด์ค๊ฒ ๐
โก Vite PWA Plugin
์์ฆ ๊ฐ์ฅ ํซํ PWA ์ค์ ๋ฐฉ๋ฒ. Vite ๊ธฐ๋ฐ ํ๋ก์ ํธ์์ ๋ช ์ค๋ก PWA ์ค์ ์๋ฃ:
// vite.config.js
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 // 24์๊ฐ
}
}
}
]
},
manifest: {
name: '๋์ PWA ์ฑ',
short_name: 'PWA์ฑ',
theme_color: '#0f3460',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png' }
]
}
})
]
});
โ๏ธ React์์ PWA ์ค์
# Create React App์ผ๋ก PWA ํ
ํ๋ฆฟ ์์ฑ
npx create-react-app my-pwa --template cra-template-pwa
# ๋๋ ๊ธฐ์กด ํ๋ก์ ํธ์ ์ถ๊ฐ
# src/index.js์์ serviceWorkerRegistration import ํ register() ํธ์ถ
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
serviceWorkerRegistration.register();
๐ Next.js์์ PWA ์ค์
# next-pwa ํจํค์ง ์ค์น
npm install next-pwa
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development'
});
module.exports = withPWA({
// ๊ธฐ์กด Next.js ์ค์
});
๐ ฐ๏ธ Angular์์ PWA ์ค์
# Angular PWA ์คํค๋งคํฑ ์ถ๊ฐ (ํ ์ค๋ก ๋!)
ng add @angular/pwa
# ์ด ๋ช
๋ น์ด ํ๋๋ก:
# - ngsw-config.json ์์ฑ
# - manifest.webmanifest ์์ฑ
# - ์์ด์ฝ ํ์ผ ์์ฑ
# - index.html ์
๋ฐ์ดํธ
# - app.module.ts ์
๋ฐ์ดํธ
# ๋ค ์๋์ผ๋ก ํด์ค ใทใท
๐ง Lighthouse๋ก PWA ํ์ง ์ธก์
๊ตฌ๊ธ์ Lighthouse ๋๊ตฌ๋ก PWA ํ์ง์ ์ ์๋ก ์ธก์ ํ ์ ์์ด:
- โ HTTPS ์ฌ์ฉ
- โ Service Worker ๋ฑ๋ก
- โ Web App Manifest ์ ํจ
- โ ์คํ๋ผ์ธ ํ์ด์ง ์ ๊ณต
- โ ์์ด์ฝ ํฌ๊ธฐ ์ ์ (192x192, 512x512)
- โ ์์ URL์ด ์คํ๋ผ์ธ์์๋ ๋์
- โ viewport ๋ฉํํ๊ทธ ์ค์
- โ ํ์ด์ง ๋ก๋ ์๋ (3G์์ 10์ด ์ด๋ด)
- โ ์ฝํ ์ธ ํฌ๊ธฐ๊ฐ ๋ทฐํฌํธ์ ๋ง์
- โ ํญ ์์๊ฐ ๋ ผ๋ฆฌ์
"Offline" ์ฒดํฌ๋ฐ์ค๋ก ์คํ๋ผ์ธ ๋์๋ ๋ฐ๋ก ํ ์คํธ ๊ฐ๋ฅํจ!
โ ๏ธ PWA์ ํ๊ณ โ ์์งํ๊ฒ ๋งํด์ค๊ฒ
PWA๊ฐ ๋ง๋ฅ์ ์๋. ์์งํ ๋งํ๋ฉด ์์ง ๋ค์ดํฐ๋ธ ์ฑ์ ์์ ํ ๋์ฒดํ๊ธฐ์ ํ๊ณ๊ฐ ์์ด.
๊ทผ๋ฐ ์ด ํ๊ณ๋ค์ด ์ ์ ์ค์ด๋ค๊ณ ์๋ค๋ ๊ฒ ํฌ์ธํธ์ ใ
ใ
๐ต iOS์์ ๊ธฐ๋ฅ ์ ํ
(ํธ์ ์๋ฆผ iOS 16.4+๋ถํฐ)
๐ ๋ฐฐํฐ๋ฆฌ/์ฑ๋ฅ ์ต์ ํ ํ๊ณ
(๋ค์ดํฐ๋ธ ๋๋น ์ฝ๊ฐ ๋จ์ด์ง)
๐ท ์ผ๋ถ ํ๋์จ์ด API ๋ฏธ์ง์
(NFC, ๋ธ๋ฃจํฌ์ค ๋ฑ)
๐ช ์ฑ์คํ ์ด ๋ ธ์ถ ๋ถ๊ฐ
(๊ฒ์ ์ ์ ์ฑ๋ ์ ํ)
๐ ๋ณด์ ๋ฏผ๊ฐ ์ฑ ํ๊ณ
(๋ฑ ํน, ๊ฒฐ์ ๋ฑ)
๐ฑ iOS ์ง์ ๊ณ์ ํ๋ ์ค
(Apple์ด ์ ์ง์ ์ผ๋ก ์ง์ ์ถ๊ฐ)
๐ WebAssembly๋ก ์ฑ๋ฅ ํฅ์
(๋ค์ดํฐ๋ธ ์์ค ์ฑ๋ฅ ๊ฐ๋ฅ)
๐ Web API ๊ณ์ ํ์ฅ ์ค
(WebBluetooth, WebNFC ๋ฑ)
๐ช Google Play์ TWA๋ก ๋ฑ๋ก ๊ฐ๋ฅ
(Trusted Web Activity)
๐ Web Crypto API๋ก ๋ณด์ ๊ฐํ
๐ iOS Safari PWA ์ง์ ํํฉ (2024 ๊ธฐ์ค)
| ๊ธฐ๋ฅ | ์ง์ ์ฌ๋ถ | ๋ฒ์ |
|---|---|---|
| ํ ํ๋ฉด ์ถ๊ฐ | โ ์ง์ | iOS 1.1.3+ |
| Service Worker | โ ์ง์ | iOS 11.3+ |
| Web App Manifest | โ ๏ธ ๋ถ๋ถ ์ง์ | iOS 11.3+ |
| ํธ์ ์๋ฆผ | โ ์ง์ (ํ ํ๋ฉด ์ค์น ํ) | iOS 16.4+ |
| Background Sync | โ ๋ฏธ์ง์ | - |
| Web Share API | โ ์ง์ | iOS 12.2+ |
| Payment Request API | โ ์ง์ | iOS 11.3+ |
| Badging API | โ ์ง์ | iOS 16.4+ |
๐ช TWA(Trusted Web Activity)๋ก ์ฑ์คํ ์ด ๋ฑ๋กํ๊ธฐ
PWA๋ฅผ Google Play Store์ ๋ฑ๋กํ๊ณ ์ถ๋ค๋ฉด TWA๋ฅผ ํ์ฉํ ์ ์์ด:
Chrome Custom Tabs๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๋์ํ๊ณ , ์ฑ์ฒ๋ผ ๋ณด์ด์ง๋ง ์ค์ ๋ก๋ ์น ์ฝํ ์ธ ๋ฅผ ํ์ํจ.
Bubblewrap CLI ๋๊ตฌ๋ก ์ฝ๊ฒ TWA ์ฑ์ ์์ฑํ ์ ์์ด.
# Bubblewrap์ผ๋ก TWA ์์ฑ
npm install -g @bubblewrap/cli
# ์ด๊ธฐํ (manifest.json URL ์
๋ ฅ)
bubblewrap init --manifest https://your-pwa.com/manifest.json
# APK ๋น๋
bubblewrap build
๐ ์ต์ Web API๋ค โ PWA๊ฐ ์ ์ ๊ฐํด์ง๊ณ ์์
Project Fugu๋ผ๊ณ ๋ค์ด๋ดค์ด? ๊ตฌ๊ธ์ด ์ฃผ๋ํ๋ ํ๋ก์ ํธ์ธ๋ฐ, ์น์์ ๋ค์ดํฐ๋ธ ์ฑ ์์ค์ ๊ธฐ๋ฅ์ ์ฌ์ฉํ ์ ์๋๋ก ์๋ก์ด Web API๋ฅผ ๊ณ์ ์ถ๊ฐํ๋ ํ๋ก์ ํธ์ผ.
๋๋ถ์ PWA๊ฐ ํ ์ ์๋ ๊ฒ๋ค์ด ์์ฒญ ๋์ด๋๊ณ ์์ ใทใท
๐ ์ฃผ๋ชฉํ ๋งํ ์ต์ Web API๋ค
๋ก์ปฌ ํ์ผ ์์คํ ์ ์ง์ ์ ๊ทผ ๊ฐ๋ฅ.
ํ์ผ ์ด๊ธฐ/์ ์ฅ/์์ ์ ๋ค์ดํฐ๋ธ ์ฑ์ฒ๋ผ ํ ์ ์์.
VS Code ์น ๋ฒ์ ์ด ์ด๊ฑธ ์!
OS ๊ธฐ๋ณธ ๊ณต์ ์ํธ๋ฅผ ์ด ์ ์์.
์นด์นด์คํก, ์ธ์คํ๊ทธ๋จ ๋ฑ์ผ๋ก ๊ณต์ ํ๋ ๊ทธ UI.
๋ชจ๋ฐ์ผ์์ ํนํ ์ ์ฉํจ.
๊ธฐ๊ธฐ์ ์ฐ๋ฝ์ฒ์ ์ ๊ทผ ๊ฐ๋ฅ.
์ฌ์ฉ์๊ฐ ์ ํํ ์ฐ๋ฝ์ฒ๋ง ์ฑ์ ์ ๋ฌ.
ํ๋ผ์ด๋ฒ์ ๋ณดํธ ์ค๊ณ.
์ฑ ์์ด์ฝ์ ๋ฐฐ์ง(์ซ์) ํ์ ๊ฐ๋ฅ.
์นด์นด์คํก ์๋ฆผ ์ซ์ ๊ฐ์ ๊ทธ๊ฑฐ.
iOS 16.4+, Android Chrome ์ง์.
ํ๋ฉด์ด ๊บผ์ง์ง ์๋๋ก ์ ์ง.
๋ ์ํผ ์ฑ, ๋ด๋น๊ฒ์ด์ ์ฑ์ ์ ์ฉ.
Chrome, Edge, Safari ์ง์.
๋ธ๋ฃจํฌ์ค ๊ธฐ๊ธฐ์ ํต์ ๊ฐ๋ฅ.
IoT ๊ธฐ๊ธฐ ์ ์ด, ํฌ์ค์ผ์ด ์ฑ ๋ฑ.
Chrome, Edge ์ง์ (iOS ๋ฏธ์ง์).
๐ป Badging API ์ฌ์ฉ ์์
// ์ฑ ์์ด์ฝ์ ๋ฐฐ์ง ํ์
async function updateBadge(count) {
if ('setAppBadge' in navigator) {
if (count > 0) {
await navigator.setAppBadge(count);
} else {
await navigator.clearAppBadge();
}
}
}
// ์ ๋ฉ์์ง 5๊ฐ ์์ ๋
updateBadge(5); // ์์ด์ฝ์ "5" ํ์
// ๋ชจ๋ ์ฝ์์ ๋
updateBadge(0); // ๋ฐฐ์ง ์ ๊ฑฐ
๐ File System Access API ์์
// ํ์ผ ์ด๊ธฐ (๋ค์ดํฐ๋ธ ํ์ผ ๋ค์ด์ผ๋ก๊ทธ)
async function openFile() {
const [fileHandle] = await window.showOpenFilePicker({
types: [
{
description: 'ํ
์คํธ ํ์ผ',
accept: { 'text/plain': ['.txt', '.md'] }
}
]
});
const file = await fileHandle.getFile();
const content = await file.text();
return content;
}
// ํ์ผ ์ ์ฅ
async function saveFile(content) {
const fileHandle = await window.showSaveFilePicker({
suggestedName: 'document.txt',
types: [{ accept: { 'text/plain': ['.txt'] } }]
});
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
}
โ ์ค์ PWA ๊ตฌํ ์ฒดํฌ๋ฆฌ์คํธ
PWA ๋ง๋ค ๋ ๋์น๊ธฐ ์ฌ์ด ๊ฒ๋ค ์ ๋ฆฌํด์ค๊ฒ. ์ด๊ฑฐ ๋ค ์ฒดํฌํ๋ฉด ์ง์ง ์ ๋๋ก ๋ PWA์ ใ ใ
๐ง ๊ธฐ๋ณธ ์ค์ ์ฒดํฌ๋ฆฌ์คํธ
- โ HTTPS ์ ์ฉ (Let's Encrypt๋ก ๋ฌด๋ฃ ๊ฐ๋ฅ)
- โ manifest.json ์์ฑ ๋ฐ HTML์ ์ฐ๊ฒฐ
- โ ์์ด์ฝ ์ค๋น (์ต์ 192x192, 512x512 PNG)
- โ Service Worker ๋ฑ๋ก
- โ ์คํ๋ผ์ธ ํด๋ฐฑ ํ์ด์ง ๊ตฌํ
- โ viewport ๋ฉํํ๊ทธ ์ค์
- โ theme-color ๋ฉํํ๊ทธ ์ค์
- โ iOS์ฉ apple-touch-icon ์ค์
- โ ํต์ฌ ํ์ผ Pre-caching ์ค์
- โ ์ด๋ฏธ์ง ์ต์ ํ (WebP ํฌ๋งท ์ฌ์ฉ)
- โ ์ฝ๋ ์คํ๋ฆฌํ ์ ์ฉ
- โ Critical CSS ์ธ๋ผ์ธ ์ฒ๋ฆฌ
- โ ํฐํธ preload ์ค์
- โ Lazy loading ์ ์ฉ
- โ Lighthouse ์ ์ 90์ ์ด์ ๋ชฉํ
- โ ์ค์น ํ๋กฌํํธ ์ปค์คํฐ๋ง์ด์ง
- โ iOS ์ค์น ์๋ด UI ๊ตฌํ
- โ ์คํ๋ผ์ธ ์ํ ํ์ UI
- โ ์ฑ ์ ๋ฐ์ดํธ ์๋ฆผ ๊ตฌํ
- โ ์คํ๋์ ์คํฌ๋ฆฐ ์ค์
- โ ์ ์ฒด ํ๋ฉด ๋ชจ๋ ํ ์คํธ
- โ ๋ค์ํ ๊ธฐ๊ธฐ์์ ํ ์คํธ
๐ ์ฑ ์ ๋ฐ์ดํธ ์ฒ๋ฆฌํ๊ธฐ
PWA์์ ์ ๋ฒ์ ๋ฐฐํฌ ์ ์ฌ์ฉ์์๊ฒ ์ ๋ฐ์ดํธ๋ฅผ ์๋ฆฌ๋ ํจํด:
// Service Worker ์
๋ฐ์ดํธ ๊ฐ์ง ๋ฐ ์๋ฆผ
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then((registration) => {
// ์
๋ฐ์ดํธ ๊ฐ์ง
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// ์ ๋ฒ์ ์ฌ์ฉ ๊ฐ๋ฅ! ์ฌ์ฉ์์๊ฒ ์๋ฆผ
showUpdateNotification();
}
});
});
});
}
function showUpdateNotification() {
// ํ ์คํธ ๋ฉ์์ง๋ ๋ฐฐ๋๋ก ์๋ฆผ
const banner = document.createElement('div');
banner.innerHTML = `
์ ๋ฒ์ ์ด ์์ด์!
<button onclick="location.reload()">์
๋ฐ์ดํธ</button>
`;
document.body.appendChild(banner);
}
// Service Worker์์ skipWaiting์ผ๋ก ์ฆ์ ํ์ฑํ
self.addEventListener('message', (event) => {
if (event.data === 'SKIP_WAITING') {
self.skipWaiting();
}
});
๐ค PWA vs ํ์ด๋ธ๋ฆฌ๋ ์ฑ โ ๋ญ ์ ํํด์ผ ํด?
์ฑ ๊ฐ๋ฐํ ๋ ์ ํ์ง๊ฐ ์ฌ๋ฌ ๊ฐ์์. PWA, ํ์ด๋ธ๋ฆฌ๋ ์ฑ(React Native, Flutter), ๋ค์ดํฐ๋ธ ์ฑ...
์ด๋ค ์ํฉ์์ ๋ญ ์ ํํด์ผ ํ๋์ง ์ ๋ฆฌํด์ค๊ฒ.
๐ ๊ธฐ์ ์ ํ ๊ฐ์ด๋
| ์ํฉ | ์ถ์ฒ ๊ธฐ์ | ์ด์ |
|---|---|---|
| ๋น ๋ฅธ MVP ์ถ์ | PWA | ์ฑ์คํ ์ด ์ฌ์ฌ ์์ด ์ฆ์ ๋ฐฐํฌ |
| ์ฝํ ์ธ ์ค์ฌ ์๋น์ค | PWA | SEO + ์ฑ ๊ฒฝํ ๋์์ |
| iOS/Android ๋์ ์ง์ | React Native / Flutter | ๋ค์ดํฐ๋ธ ๊ธฐ๋ฅ ์์ ์ง์ |
| ๊ณ ์ฑ๋ฅ ๊ฒ์/๊ทธ๋ํฝ | ๋ค์ดํฐ๋ธ ์ฑ | GPU ์ง์ ์ ๊ทผ ํ์ |
| ๊ธฐ์ ๋ด๋ถ ๋๊ตฌ | PWA | ๋ฐฐํฌ/๊ด๋ฆฌ ๊ฐํธ, ์ฑ์คํ ์ด ๋ถํ์ |
| ์ด์ปค๋จธ์ค / ๋ฏธ๋์ด | PWA | ์ ํ์จ ํฅ์, ๋น ๋ฅธ ๋ก๋ฉ |
| ์นด๋ฉ๋ผ/AR ์ฑ | ๋ค์ดํฐ๋ธ / ํ์ด๋ธ๋ฆฌ๋ | ํ๋์จ์ด ์์ ์ ์ด ํ์ |
| ๋ฑ ํน/ํํ ํฌ | ๋ค์ดํฐ๋ธ ์ฑ | ๋ณด์ ์๊ตฌ์ฌํญ, ์์ฒด์ธ์ฆ ๋ฑ |
๐ฐ ๊ฐ๋ฐ ๋น์ฉ ๋น๊ต
โข iOS + Android ๋์ ์ง์ โ ๋จ์ผ ์ฝ๋๋ฒ ์ด์ค
โข ์ฑ์คํ ์ด ์์๋ฃ ์์ โ Apple 30%, Google 15~30% ์ ์ฝ
โข ์ ๋ฐ์ดํธ ๋น์ฉ ์ต์ํ โ ์๋ฒ ๋ฐฐํฌ๋ง์ผ๋ก ์๋ฃ
์์ ์ ๊ฐ ํจ๊ณผ: ๋ค์ดํฐ๋ธ ์ฑ ๋๋น ๊ฐ๋ฐ ๋น์ฉ 40~60% ์ ๊ฐ ๊ฐ๋ฅ
๐๏ธ ์ฒ์๋ถํฐ ๋ง๋๋ ๋ฏธ๋ PWA ์์
์ด๋ก ๋ง ํ๋ฉด ์ฌ๋ฏธ์์์. ๊ฐ๋จํ PWA๋ฅผ ์ฒ์๋ถํฐ ๋ง๋ค์ด๋ณด์.
ํ ์ผ ๋ชฉ๋ก(Todo) ์ฑ์ PWA๋ก ๋ง๋๋ ์์ ์ผ. ์คํ๋ผ์ธ์์๋ ๋์ํ๊ณ , ํ ํ๋ฉด์ ์ค์น๋ ๊ฐ๋ฅํ ๋ฒ์ ์ผ๋ก!
๐ ํ๋ก์ ํธ ๊ตฌ์กฐ
my-todo-pwa/
โโโ index.html # ๋ฉ์ธ HTML
โโโ app.js # ์ฑ ๋ก์ง
โโโ styles.css # ์คํ์ผ
โโโ sw.js # Service Worker
โโโ manifest.json # Web App Manifest
โโโ icons/
โโโ icon-192.png
โโโ icon-512.png
๐ index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>๋์ ํ ์ผ PWA</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0f3460">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="ํ ์ผ์ฑ">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app">
<header>
<h1>๐ ๋์ ํ ์ผ</h1>
<div id="offline-indicator" hidden>์คํ๋ผ์ธ ๋ชจ๋</div>
</header>
<main>
<form id="todo-form">
<input type="text" id="todo-input" placeholder="ํ ์ผ ์
๋ ฅ..." required>
<button type="submit">์ถ๊ฐ</button>
</form>
<ul id="todo-list"></ul>
</main>
</div>
<script src="app.js"></script>
</body>
</html>
โ๏ธ app.js (ํต์ฌ ๋ก์ง)
// Service Worker ๋ฑ๋ก
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW ๋ฑ๋ก ์๋ฃ!', reg.scope))
.catch(err => console.log('SW ๋ฑ๋ก ์คํจ:', err));
});
}
// ์คํ๋ผ์ธ ๊ฐ์ง
window.addEventListener('online', () => {
document.getElementById('offline-indicator').hidden = true;
});
window.addEventListener('offline', () => {
document.getElementById('offline-indicator').hidden = false;
});
// Todo ์ฑ ๋ก์ง
const todoForm = document.getElementById('todo-form');
const todoInput = document.getElementById('todo-input');
const todoList = document.getElementById('todo-list');
// localStorage์์ ํ ์ผ ๋ถ๋ฌ์ค๊ธฐ (์คํ๋ผ์ธ ์ง์)
let todos = JSON.parse(localStorage.getItem('todos') || '[]');
function renderTodos() {
todoList.innerHTML = '';
todos.forEach((todo, index) => {
const li = document.createElement('li');
li.innerHTML = `
<span class="${todo.done ? 'done' : ''}">${todo.text}</span>
<button onclick="toggleTodo(${index})">
${todo.done ? 'โฉ๏ธ' : 'โ
'}
</button>
<button onclick="deleteTodo(${index})">๐๏ธ</button>
`;
todoList.appendChild(li);
});
}
function saveTodos() {
localStorage.setItem('todos', JSON.stringify(todos));
}
todoForm.addEventListener('submit', (e) => {
e.preventDefault();
todos.push({ text: todoInput.value, done: false });
saveTodos();
renderTodos();
todoInput.value = '';
});
function toggleTodo(index) {
todos[index].done = !todos[index].done;
saveTodos();
renderTodos();
}
function deleteTodo(index) {
todos.splice(index, 1);
saveTodos();
renderTodos();
}
renderTodos();
๐ง sw.js (Service Worker)
const CACHE_NAME = 'todo-pwa-v1';
const ASSETS = ['/', '/index.html', '/app.js', '/styles.css', '/icons/icon-192.png'];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
);
self.skipWaiting(); // ์ฆ์ ํ์ฑํ
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim(); // ๋ชจ๋ ํด๋ผ์ด์ธํธ ์ฆ์ ์ ์ด
});
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request).then(cached => cached || fetch(e.request))
);
});
โข ํ ํ๋ฉด์ ์ค์น ๊ฐ๋ฅ
โข ๋คํธ์ํฌ ์์ด๋ ํ ์ผ ์ถ๊ฐ/์ญ์ /์๋ฃ ๊ฐ๋ฅ
โข ์ฝ๋ 100์ค ๋ฏธ๋ง์ผ๋ก ์์ฑ๋๋ ์ค์ฉ์ ์ธ PWA!
๐ฎ PWA์ ๋ฏธ๋ โ ์์ผ๋ก ์ด๋ป๊ฒ ๋ ๊น?
PWA ๊ธฐ์ ์ ์ง๊ธ๋ ๋น ๋ฅด๊ฒ ๋ฐ์ ํ๊ณ ์์ด. ์์ผ๋ก ์ด๋ค ๋ฐฉํฅ์ผ๋ก ๊ฐ์ง ์ดํด๋ณด์.
๐ ์ฃผ๋ชฉํ ํธ๋ ๋๋ค
WebNN(Web Neural Network) API๋ก ๋ธ๋ผ์ฐ์ ์์ AI ๋ชจ๋ธ ์คํ ๊ฐ๋ฅ.
์คํ๋ผ์ธ AI ๊ธฐ๋ฅ์ PWA์ ๋ด์ฅํ๋ ์๋๊ฐ ์ด.
C/C++/Rust ์ฝ๋๋ฅผ ์น์์ ์คํ.
๋ค์ดํฐ๋ธ ์ฑ ์์ค์ ์ฑ๋ฅ์ PWA์์ ๊ตฌํ ๊ฐ๋ฅ.
AR/VR ๊ฒฝํ์ PWA๋ก ์ ๊ณต.
๋ฉํ๋ฒ์ค ์๋น์ค์ ์ ๊ทผ์ฑ ํฅ์.
EU ๊ท์ ๋ก iOS์์ ํ์ฌ ๋ธ๋ผ์ฐ์ ์์ง ํ์ฉ.
iOS PWA ๊ธฐ๋ฅ ์ ํ์ด ์ ์ฐจ ํด์๋ ์ ๋ง.
์ฑ์คํ ์ด ์์ด๋ ์ฑ ์์ค์ ๊ฒฝํ์ ์ ๊ณตํ ์ ์๋ PWA๋ ํนํ ๊ฐ๋ฐ ๋ฆฌ์์ค๊ฐ ์ ํ๋ ์คํํธ์ ์ด๋ ์ค์๊ธฐ์ ์๊ฒ ์์ฒญ๋ ๊ธฐํ์ผ.
์ฌ๋ฅ๋ท์ฒ๋ผ ์น ๊ธฐ๋ฐ ํ๋ซํผ๋ค์ด PWA๋ฅผ ๋์ ํ๋ฉด ์ฌ์ฉ์ ์ฌ๋ฐฉ๋ฌธ์จ๊ณผ ์ฐธ์ฌ๋๋ฅผ ํฌ๊ฒ ๋์ผ ์ ์์ด.
๐ PWA ์์ฅ ํํฉ (2024)
โข ์ฐํ๊ท ์ฑ์ฅ๋ฅ (CAGR): 31.9% (2024~2030 ์์)
โข Fortune 500 ๊ธฐ์ ์ค PWA ๋์ ๋น์จ: ์ฝ 60%
โข PWA ์ฌ์ฉ์์ ํ๊ท ์ธ์ ์๊ฐ: ์ผ๋ฐ ์น ๋๋น 2~3๋ฐฐ
โข PWA ์ ํ์จ: ์ผ๋ฐ ๋ชจ๋ฐ์ผ ์น ๋๋น ํ๊ท 36% ๋์
๐ฏ ๋ง๋ฌด๋ฆฌ โ PWA, ์ด์ ์์ํด๋ณผ ๋
์, ์ฌ๊ธฐ๊น์ง PWA์ ๋ํด ์ง์ง ๊น๊ฒ ํ๋ดค์ด. ์ ๋ฆฌํ๋ฉด:
2๏ธโฃ Service Worker๊ฐ ํต์ฌ โ ์คํ๋ผ์ธ, ์บ์ฑ, ํธ์ ์๋ฆผ ๋ด๋น
3๏ธโฃ Web App Manifest๋ก ์ฑ์ฒ๋ผ ๋ณด์ด๊ฒ โ ์์ด์ฝ, ์ด๋ฆ, ํ๋ฉด ๋ชจ๋ ์ค์
4๏ธโฃ ์ค์ ํจ๊ณผ ์์ โ Twitter, Flipkart, AliExpress ๋ฑ ์์ญ~์๋ฐฑ% ์งํ ํฅ์
5๏ธโฃ iOS ํ๊ณ ์์ง๋ง ๊ฐ์ ์ค โ iOS 16.4+๋ถํฐ ํธ์ ์๋ฆผ ์ง์
6๏ธโฃ TWA๋ก ์ฑ์คํ ์ด ๋ฑ๋ก๋ ๊ฐ๋ฅ โ Google Play Store์ PWA ๋ฑ๋ก ๊ฐ๋ฅ
7๏ธโฃ ๊ฐ๋ฐ ๋น์ฉ ์ ๊ฐ โ ๋จ์ผ ์ฝ๋๋ฒ ์ด์ค๋ก iOS/Android ๋์ ์ง์
1. HTTPS ์ ์ฉ (์ด๋ฏธ ๋์ด์์ผ๋ฉด ํจ์ค)
2. manifest.json ์์ฑ
3. ๊ธฐ๋ณธ Service Worker ์ถ๊ฐ
4. Lighthouse๋ก ์ ์ ํ์ธ
์ด 4๋จ๊ณ๋ง ํด๋ ๊ธฐ๋ณธ์ ์ธ PWA๊ฐ ์์ฑ๋ผ!
Vite๋ Next.js ์ฐ๊ณ ์๋ค๋ฉด ํ๋ฌ๊ทธ์ธ ํ๋๋ก ๋ ์ฝ๊ฒ ๊ฐ๋ฅํจ ใ ใ
PWA๋ ํนํ ์ฝํ
์ธ ์ค์ฌ ์๋น์ค, ์ด์ปค๋จธ์ค, ๋ด์ค/๋ฏธ๋์ด, ๊ธฐ์
๋ด๋ถ ๋๊ตฌ์์ ์์ฒญ๋ ํจ๊ณผ๋ฅผ ๋ฐํํด.
์ฑ์คํ ์ด ์ฌ์ฌ ์์ด ์ฆ์ ๋ฐฐํฌํ๊ณ , SEO๋ ๋๊ณ , ์คํ๋ผ์ธ๋ ๋๊ณ , ์ค์น๋ ๋๋ โ ์ด๊ฒ PWA์ ์ง์ง ๋งค๋ ฅ์ด์ผ.
์ด์ PWA ๋ชจ๋ฅด๋ฉด ๋ชจ๋ฐ์ผ ์น ๊ฐ๋ฐ์๋ผ๊ณ ํ๊ธฐ ์ข ๋ฏผ๋งํ ์๋๊ฐ ๋์ ใ
ใ
ใ
์ง๊ธ ๋ฐ๋ก ์์ํด๋ด! ๐ช
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.