nextjs에서 PWA로 웹앱 지원하기

dobby·2026년 2월 16일

PWA

목록 보기
1/2
post-thumbnail

'잇다-'는 기록 서비스이다.
기록은 사실 데스크탑보단 모바일에서 빠르게 진행하는게 더 효율적이면서 편하다.

그렇기에 우리 팀은 웹 기반이면서 어플로도 변환할 수 있는 pwa를 사용해 웹앱을 지원하기로 했다.

pwa web-app을 지원하는 과정과 그 안에서 고민했던 점들을 정리해봤다.

Next.js를 사용해 PWA 구축하기

PWA: progressive web application

PWA는 웹 애플리케이션의 접근성과 활용성을 갖추면서, 네이티브 모바일 앱의 기능과 사용자 경험을 제공한다.

다음과 같은 이점이 있다.

  • 앱 스토어 승인을 기다릴 필요 없이 즉시 업데이트 배포 가능
  • 단일 코드베이스로 크로스 플랫폼 애플리케이션 개발
  • 홈 화면 설치 및 푸시 알림과 같은 네이티브 앱과 유사한 기능 제공

1. 웹 애플리케이션 manifest, viewport/metadata 생성

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:
    웹 애플리케이션의 기본 표시 모드 지정. 표시 모드는 운영체제 컨텍스트 내에서 앱이 실행될 때 사용자에게 표시되는 브라우저 UI의 범위를 결정
    • fullscreen:
      브라우저 UI 요소를 숨기고 사용 가능한 전체 화면 영역을 활용해 앱을 연다. 브라우저 컨트롤어 보이지 않고, 전체 화면을 차지해 완벽한 몰입형 게임 환경을 제공하는 게임 앱에 사용할 수 있다.
    • standalone:
      앱을 마치 독립형 네이티브 앱처럼 보이도록 연다. 앱이 별도의 창으로 표시되고 앱 실행기에 자체 아이콘이 포함된다. 브라우저는 URL 표시줄과 같은 UI 요소를 제외하지만 상태 표시줄과 같은 다른 UI 요소는 포함할 수 있다. (브라우저의 URL 표시줄은 없지만 기기의 배터리와 알림 상태 표시줄은 그대로 표시)
    • minimal-ui:
      앱을 독립 실행형 앱처럼 열되, 탐색을 위한 최소한의 UI 요소만 표시. 일반적으로 뒤로, 앞으로, 새로고침과 같은 탐색 컨트롤과 앱 URL을 표시하는 기능이 포함된다.
    • brower:
      앱을 플랫폼별 링크 열기 규칙에 따라 일반 브라우저 탭 또는 새 창에서 연다. display 모드가 지정되지 않은 경우 이 값이 기본값.
  • background_color: 앱의 창 배경색
    • 앱의 스타일시트가 로드되기 전에 애플리케이션 창에 표시되는 색상을 정의한다. 즉, 스타일시트가 로드되기 전에 나타나므로, 애플리케이션 스타일시트의 CSS 속성 색상 값과 동일하게 설정해야 시각적 전환이 원활해진다.

Next.js 13버전부턴 Metadata API가 나오면서 <head> 태그를 건드리지 않아도 됐다.
layout.tsxpage.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({
...

viewport/metadata vs manifest.ts

여기서, metadata/viewport랑 manifest.ts랑 무슨 차이가 있는걸까?

metadata/ viewport 는 브라우저(탭)을 위한 것이고, manifest.ts 는 설치될 앱을 위한 것이다.

  • viewport:
    ‘이 화면을 모바일 크기에 맞춰서 그려줘’, ‘주소창 색은 이걸로 해줘' 같은 실시간으로 브라우저 창에 명령을 내리기 위해 사용
  • metadata:
    ‘이 페이지의 제목은 XX야’, ‘검색 결과에 어떻게 나와야 헤’와 같은 SEO와 공유 시 미리보기(openGraph)를 담당

위 두 사항은 페이지를 이동할 때마다 바뀔 수 있다.

  • manifest.ts: 사용자가 ‘홈 화면에 추가’ 버튼을 눌러서 앱을 설치할 때만 주로 참조되는 파일

‘설치된 후 앱 아이콘 모양은?’, ‘앱을 켰을 때 가로모드로 고정할까?’, ‘스플래시 화면 배경색은 뭐야?’와 같은 앱의 설정 내용을 결정한다.

한 번 설치되면 잘 바뀌지 않는 앱의 정체성을 규정하고, 브라우저 탭 안에서 돌아갈 때는 manifest 의 설정보다 HTML 헤더의 metadata 설정이 우선순위를 갖는 경우가 많다.

metadata/viewportmanifest.ts
주요 대상구글 검색 로봇, 브라우저 탭, 공유 링크안드로이드/IOS 운영체제
적용 시점웹사이트 접속 중 매 순간앱 설치 시점 및 앱 실행(런타임)
핵심 설정제목, 설명, 브라우저 주소창 색앱 아이콘, 스플래시 화면, 앱 실행 방향
차이웹으로서의 최적화앱으로서의 최적화

둘 중 하나만 있으면 사용자 경험이 어딘가 깨지게 되기에, 웹앱을 지원하기 위해선 둘 모두 설정하는 것이 중요하다.

유저의 입장에서 봤을 때, 다음의 시나리오를 생각하면 된다.

  1. 카톡으로 링크 공유: 이때 드는 제목과 사진은 metadata 가 결정
  2. 브라우저로 접속 중: 주소창 색상이 바뀌는 건 viewportthemeColor 덕분
  3. 홈 화면에 추가: 이때 폰에 생기는 아이콘 모양과 이름은 manifest 에서 가져옴
  4. 설치된 앱 실행: 이때 뜨는 스플래시 화면과 앱 전송 UI는 manifest 설정 값

여기까지 해주면 웹앱 설치와 설정은 끝났다.

하지만 테마를 지원한다면, 테마에 맞춰 앱의 주소창 배경색을 동적으로 수정시켜줘야 한다.

테마에 맞춰 앱의 창 배경색 설정하기

나는 다크모드/라이트모드를 지원하고 있으며, 유저가 지정한 테마에 맞춰 창 배경색을 지정하고 싶었다.

manifest.ts 는 빌드 타임에 생성되기 때문에, 런타임에 유저가 버튼을 눌러 바꾸는 tailwind의 테마 상태를 실시간으로 반영하기는 어렵다.

브라우저와 OS 수준에서 지원하는 시스템 테마 설정을 활용하거나, HTML Meta Tag를 병용해서 다크모드/라이트모드에 대응하는 방법이 있다.

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

참고 자료

profile
성장통을 겪고 있습니다.

0개의 댓글