'잇다-'는 기록 서비스이다.
기록은 사실 데스크탑보단 모바일에서 빠르게 진행하는게 더 효율적이면서 편하다.
그렇기에 우리 팀은 웹 기반이면서 어플로도 변환할 수 있는 pwa를 사용해 웹앱을 지원하기로 했다.
pwa web-app을 지원하는 과정과 그 안에서 고민했던 점들을 정리해봤다.
PWA: progressive web application
PWA는 웹 애플리케이션의 접근성과 활용성을 갖추면서, 네이티브 모바일 앱의 기능과 사용자 경험을 제공한다.
다음과 같은 이점이 있다.
Next.js는 App Router를 사용해 웹앱 매니페스트를 생성하는 기능을 기본적으로 제공한다.
app/manifest.ts 또는 app/manifest.json 파일을 만들어서 웹앱 설정을 할 수 있다.
// app/manifest.ts
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Next.js PWA',
short_name: 'NextPWA',
description: 'A Progressive Web App built with Next.js',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{
src: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
}
}
display:fullscreen:standalone:minimal-ui:brower:display 모드가 지정되지 않은 경우 이 값이 기본값.background_color: 앱의 창 배경색Next.js 13버전부턴 Metadata API가 나오면서
<head>태그를 건드리지 않아도 됐다.
layout.tsx나page.tsx에서metadata객체만 내보내면 Next.js가 빌드 및 런타임 시점에 알아서 최적화된<head>태그를 구성해준다.
Next.js 13버전 이전까지는 위처럼 meta 태그를 직접 넣어줘야 했는데, 13버전 이후부턴 meta 객체만 선언해주면 된다.
추가로 14버전부터는 metadata 객체 안에 있던 themeColor, viewport 같은 속성들이 별도의 viewport 객체로 분리되었다.
그래서 두 객체를 같이 작성해줘야 한다.
// src/app/layout.tsx
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
minimumScale: 1,
viewportFit: 'cover',
};
export const metadata: Metadata = {
title: {
default: '잇다-',
template: '%s - 잇다-',
},
description: '기억과 맥락을 이어주는 기록 서비스',
manifest: '/manifest.webmanifest',
icons: {
icon: '/web-app-icon-192x192.png',
apple: '/apple-icon.png',
},
};
export default function RootLayout({
...
여기서, metadata/viewport랑 manifest.ts랑 무슨 차이가 있는걸까?
metadata/ viewport 는 브라우저(탭)을 위한 것이고, manifest.ts 는 설치될 앱을 위한 것이다.
viewport:metadata:위 두 사항은 페이지를 이동할 때마다 바뀔 수 있다.
‘설치된 후 앱 아이콘 모양은?’, ‘앱을 켰을 때 가로모드로 고정할까?’, ‘스플래시 화면 배경색은 뭐야?’와 같은 앱의 설정 내용을 결정한다.
한 번 설치되면 잘 바뀌지 않는 앱의 정체성을 규정하고, 브라우저 탭 안에서 돌아갈 때는 manifest 의 설정보다 HTML 헤더의 metadata 설정이 우선순위를 갖는 경우가 많다.
| metadata/viewport | manifest.ts | |
|---|---|---|
| 주요 대상 | 구글 검색 로봇, 브라우저 탭, 공유 링크 | 안드로이드/IOS 운영체제 |
| 적용 시점 | 웹사이트 접속 중 매 순간 | 앱 설치 시점 및 앱 실행(런타임) |
| 핵심 설정 | 제목, 설명, 브라우저 주소창 색 | 앱 아이콘, 스플래시 화면, 앱 실행 방향 |
| 차이 | 웹으로서의 최적화 | 앱으로서의 최적화 |
둘 중 하나만 있으면 사용자 경험이 어딘가 깨지게 되기에, 웹앱을 지원하기 위해선 둘 모두 설정하는 것이 중요하다.
유저의 입장에서 봤을 때, 다음의 시나리오를 생각하면 된다.
metadata 가 결정viewport 의 themeColor 덕분manifest 에서 가져옴manifest 설정 값여기까지 해주면 웹앱 설치와 설정은 끝났다.
하지만 테마를 지원한다면, 테마에 맞춰 앱의 주소창 배경색을 동적으로 수정시켜줘야 한다.
나는 다크모드/라이트모드를 지원하고 있으며, 유저가 지정한 테마에 맞춰 창 배경색을 지정하고 싶었다.
manifest.ts 는 빌드 타임에 생성되기 때문에, 런타임에 유저가 버튼을 눌러 바꾸는 tailwind의 테마 상태를 실시간으로 반영하기는 어렵다.
브라우저와 OS 수준에서 지원하는 시스템 테마 설정을 활용하거나, HTML Meta Tag를 병용해서 다크모드/라이트모드에 대응하는 방법이 있다.
layout.tsx 나 index.html 에 다음의 코드를 추가해준다.
<head>
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
</head>
하지만 Next.js 13버전부턴 Metadata API가 나오면서 <head> 태그를 건드리지 않아도 됐다.
layout.tsx 나 page.tsx 에서 metadata 객체만 내보내면 Next.js가 빌드 및 런타임 시점에 알아서 최적화된 <head> 태그를 구성해준다.
즉, 위에서 설정한 부분은 그대로 가져가면 된다.

우리는 metadata 를 통해서 head에 추가된 저 meta 태그를 동적으로 수정해주면 된다.
테마를 지원한다면, 테마 토글 버튼이 있을테니 그 부분은 넘어가고 어떻게 주소창 배경색을 바꾸는지만 정리하겠다.
metadata 는 매 페이지마다 다르게 html에 들어가기 때문에, 전역적으로 테마를 알아내서 적용시켜줘야 한다.
그러니, layout.tsx 파일에서 테마를 설정하는 컴포넌트를 추가해주자.
// src/app/layout.tsx
import ThemeColorSetter from '@/components/ThemeColorSetter';
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
...
<body
className={`${notoSans.variable} antialiased relative`}
suppressHydrationWarning
>
<Script
src={`https://maps.googleapis.com/maps/api/js?key=${process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY}&libraries=places`}
strategy="beforeInteractive"
>
<Providers>
<MswLoader />
<ThemeProvider
attribute="class"
enableSystem={true}
defaultTheme="system"
>
<ThemeColorSetter /> <- 여기!
...
이제 ThemeColorSetter 컴포넌트를 만들어주자.
// src/components/ThemeColorSetter.tsx
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
export default function ThemeColorSetter() {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
// React 19의 cascading renders 에러 방지를 위한 지연 처리
const raId = requestAnimationFrame(() => {
setMounted(true);
});
return () => cancelAnimationFrame(raId);
}, []);
useEffect(() => {
if (!mounted) return;
const color = resolvedTheme === 'dark' ? '#121212' : '#ffffff';
const metaThemeColors = document.querySelectorAll(
'meta[name="theme-color"]',
);
if (metaThemeColors.length > 0) {
metaThemeColors.forEach((meta) => {
if (meta.getAttribute('content') !== color) {
meta.setAttribute('content', color);
}
});
} else {
const meta = document.createElement('meta');
meta.name = 'theme-color';
meta.content = color;
document.head.appendChild(meta);
}
}, [resolvedTheme, mounted]);
return null;
}
위처럼 동적으로 meta 태그의 속성을 바꿔줘야 한다.
마운트가 되기 전에는 meta 태그에 접근했을 때 undefined 가 뜰 수 있기 때문에, 안전하게 수정되도록 마운트 후에 수정되도록 해주었다.
이제 동적으로 주소창도 테마 색상을 바꾸는 것은 끝났다.
하지만 한 가지 더 고려해줘야 하는게 있다.
테마 색상은 localStorage 에서 테마 색상을 꺼내오는 로직이기 때문에, 앱 실행과 테마 값을 불러오는 그 사이 지연시간이 있다.
그 동안은 manifest 에서 설정해준 색상으로 주소창 색상이 결정된다.
유저는 설정한 테마 색상이 아닌 다른 테마 색상이 나왔다가 바뀌는 모습을 지켜보게 된다.

마지막으로 이를 보완해보자.
현재 문제인건 ThemeColorSetter 는 클라이언트 컴포넌트이기 때문에, 브라우저가 HTML 파일을 내려받고 자바스크립트를 해석해서 실행하기 전까지는 주소창이 어떤 색이어야 할지 모르는 상태이다.
그래서 Next.js가 기본으로 렌더링한 초기값이 잠깐 보였다가 자바스크립트가 실행된 후에야 색이 바뀌는 것이다.
이를 해결하기 위해 자바스크립트가 로드되길 기다리지 않고, HTML이 읽히자마자 즉시 실행되는 인라인 스크립트를 삽입해야 한다.
layout.tsx 의 <head> 안에 테마를 확인하고 즉시 메타 태그를 수정하는 스크립트를 넣으면 해결된다.
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" suppressHydrationWarning>
<head>
{/* 테마 깜빡임을 방지하기 위한 인라인 스크립트 */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const theme = localStorage.getItem('theme');
const supportDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = theme === 'dark' || (theme === 'system' && supportDarkMode) || (!theme && supportDarkMode);
const color = isDark ? '#121212' : '#ffffff';
// 메타 태그 생성 또는 수정
let meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'theme-color';
document.head.appendChild(meta);
}
meta.setAttribute('content', color);
// 시스템 배경색과 일치시키기 위해 <html> 클래스도 미리 제어
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {}
})();
`,
}}
/>
</head>
<body className="...">
{/* ... 기존 내용 */}
</body>
</html>
);
}
body 가 렌더링되기 전, 즉 HTML 헤더를 읽는 즈시 스크립트가 실행되기 때문에 컴포넌트가 마운트될 때가지 기다릴 필요가 없다.
성능적으론 짧은 순수 자바스크립트이기 때문에, 성능 저하 거의 없이 문제를 해결할 수 있다.

pwa를 지원하면서 유저가 어떻게 이 웹을 다운받아 어플로 사용할 수 있는지 모를 수도 있다.
이를 위해서 브라우저별로 어떻게 다운받는지를 알려주기 위해 추가 작업을 진행했다.
이에 대해서는 다음 포스트에서 다룬다.