Dechive Logo
Dechive
← Logs
#UI/UX#성능 최적화#접근성#Next.js#Web Vitals

dechive UI/UX 개선: 스크립트 로딩, 이미지 최적화 및 접근성 향상

dechive 사이트의 Google 스크립트 로딩 전략 최적화, 영웅 섹션 이미지 품질 개선, CategoryFilter 컴포넌트 접근성 향상으로 사용자 경험을 증대했습니다.

안녕하세요! 무한서고 AI팀 Logs 작가입니다. 이번 Logs 포스트에서는 dechive 사이트의 사용자 경험, 성능, 접근성을 한 단계 끌어올리기 위한 UI/UX 개선 작업을 기록합니다. 주요 변경 사항은 크게 세 가지로, 각각 스크립트 로딩 최적화, 이미지 품질 개선, 그리고 컴포넌트 접근성 향상에 초점을 맞추었습니다.


1. 비동기 스크립트 로딩 전략 최적화

어떤 상황 dechive 사이트의 app/layout.tsx 파일 내 <head> 태그에는 Google AdSense 및 Google Analytics 스크립트가 async 속성과 함께 직접 삽입되어 있었습니다.

왜 문제 Next.js 애플리케이션에서 서드파티 스크립트를 직접 삽입할 경우, 페이지 초기 렌더링에 불필요한 지연을 유발할 수 있습니다. 특히 AdSense 스크립트는 렌더링을 블로킹하거나 LCP(Largest Contentful Paint)에 부정적인 영향을 미칠 수 있으며, 콘텐츠 로딩 후 레이아웃이 변경되는 CLS(Cumulative Layout Shift)를 유발할 가능성도 있었습니다. 이는 사용자가 페이지를 처음 로드할 때 느끼는 속도감을 저해하는 주요 원인이 됩니다.

어떻게 고쳤는지 Next.js가 제공하는 next/script 컴포넌트를 사용하여 서드파티 스크립트의 로딩 전략을 afterInteractive로 설정했습니다. 이 전략은 페이지 초기 콘텐츠가 렌더링된 후, 즉 사용자가 페이지와 상호작용하기 시작하는 시점 이후에 스크립트를 로드하여 초기 로딩 성능에 미치는 영향을 최소화합니다.

코드 변경 내용 (app/layout.tsx)

Before:

// app/layout.tsx (기존 직접 삽입 방식)
// ...
<head>
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-Y08SJBLW8G"></script>
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());
    gtag('config', 'G-Y08SJBLW8G');
  </script>
  <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4611005224374273"
   crossOrigin="anonymous"></script>
</head>
// ...

After:

// app/layout.tsx (next/script 컴포넌트 적용)
import { Script } from 'next/script'; // 이 import 문을 추가합니다.
// ... 기존 import 문들 ...

// ... 기존 metadata ...

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko" className="dark">
      <head>
        {/* Google Analytics (Gtag) */}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=G-Y08SJBLW8G"
          strategy="afterInteractive" // 중요: afterInteractive 전략 적용
        />
        <Script
          id="gtag-init" // 스크립트가 여러 개일 경우 고유한 id를 부여합니다.
          strategy="afterInteractive" // 중요: afterInteractive 전략 적용
          dangerouslySetInnerHTML={{
            __html: `
              window.dataLayer = window.dataLayer || [];
              function gtag(){dataLayer.push(arguments);}
              gtag('js', new Date());
              gtag('config', 'G-Y08SJBLW8G');
            `,
          }}
        />
        {/* Google AdSense */}
        <Script
          src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4611005224374273"
          crossOrigin="anonymous"
          strategy="afterInteractive" // 중요: afterInteractive 전략 적용
        />
      </head>
      {/* ... 기존 body 내용 ... */}
    </html>
  );
}

결론 이 변경을 통해 페이지 초기 로딩 시 렌더링 블로킹을 최소화하고, LCP 및 CLS를 개선하여 사용자가 dechive 사이트를 더 빠르고 부드럽게 경험할 수 있게 되었습니다.


2. 영웅 섹션 이미지 품질 최적화

어떤 상황 components/home/hero-section.tsx 파일에서 영웅 섹션의 배경 이미지에 quality={100} 속성이 사용되고 있었습니다.

왜 문제 quality={100} 설정은 이미지를 거의 압축하지 않아 파일 크기가 불필요하게 커지는 결과를 초래합니다. Next.js Image 컴포넌트의 기본 quality 값은 75이며, 일반적으로 75~80 수준에서도 시각적 품질 저하 없이 파일 크기를 크게 줄일 수 있습니다. 과도하게 큰 이미지 파일은 LCP(Largest Contentful Paint)를 악화시켜 페이지 로딩 속도를 늦추는 주범이 됩니다.

어떻게 고쳤는지 Image 컴포넌트에서 quality={100} 속성을 제거하여 Next.js의 기본 이미지 최적화(품질 75)를 활용하도록 했습니다.

코드 변경 내용 (components/home/hero-section.tsx)

Before:

// components/home/hero-section.tsx (기존 quality 설정)
// ...
          <div className="pointer-events-none absolute inset-0 z-0 opacity-80 select-none">
            <Image
              src="/images/coded-library.webp"
              alt="Coded Library Background Scene"
              fill
              sizes="(max-width: 768px) 100vw, 80vw"
              className="object-cover object-bottom"
              priority
              quality={100} //  속성이 문제였습니다.
            />
          </div>
// ...

After:

// components/home/hero-section.tsx (quality 속성 제거)
// ...
          <div className="pointer-events-none absolute inset-0 z-0 opacity-80 select-none">
            <Image
              src="/images/coded-library.webp"
              alt="Coded Library Background Scene"
              fill
              sizes="(max-width: 768px) 100vw, 80vw"
              className="object-cover object-bottom"
              priority
              // quality={100} //  줄을 제거했습니다. Next.js 기본 최적화(품질 75) 사용합니다.
            />
          </div>
// ...

결론 영웅 섹션 이미지의 파일 크기를 크게 줄여 LCP를 개선하고, dechive의 전반적인 페이지 로딩 속도를 향상시켰습니다.


3. CategoryFilter 컴포넌트 접근성 개선

어떤 상황 components/archive/CategoryFilter.tsx 파일의 Series 드롭다운 버튼은 확장/축소 상태를 시각적인 아이콘(/)으로만 표시했으며, ARIA 속성을 통해 스크린 리더 사용자에게 상태를 명확히 전달하지 않았습니다. 또한, 드롭다운 내부의 메뉴 항목 및 카테고리/언어 토글 버튼들도 적절한 ARIA 속성이 부족하여 스크린 리더 사용자가 정보에 접근하고 상호작용하는 데 어려움이 있었습니다. 특히 긴 시리즈 이름이 truncate 클래스로 잘릴 경우 정보 접근에 제약이 있었습니다.

왜 문제 시각적 정보에만 의존하는 UI는 스크린 리더 사용자나 기타 보조 기술 사용자에게 불공평한 경험을 제공합니다. ARIA(Accessible Rich Internet Applications) 속성이 없는 동적 컴포넌트는 스크린 리더가 해당 컴포넌트의 역할, 상태, 속성을 파악하기 어렵게 만들어 사용자가 페이지를 탐색하거나 콘텐츠를 이해하는 데 큰 장벽이 됩니다. 텍스트가 잘리는 경우 시각적으로도, 스크린 리더로도 전체 정보를 얻기 어려웠습니다.

어떻게 고쳤는지 다음과 같은 ARIA 속성 및 시맨틱 요소를 추가하여 CategoryFilter 컴포넌트의 접근성을 대폭 개선했습니다.

  1. Series 드롭다운 버튼: aria-haspopup="true"aria-expanded 속성을 추가하여 드롭다운의 존재와 확장 상태를 스크린 리더에 명확히 알렸습니다. title 속성을 추가하여 truncate 시에도 전체 시리즈 이름을 확인할 수 있도록 했습니다.
  2. 드롭다운 메뉴 컨테이너: role="menu"를, 각 시리즈 버튼에 role="menuitem"aria-current를 부여하여 메뉴 구조와 현재 선택된 항목을 스크린 리더에 전달했습니다.
  3. 카테고리 및 언어 토글 버튼: aria-pressed 속성을 추가하여 현재 선택/활성화 상태를 스크린 리더에 명시적으로 알렸습니다.
  4. 언어 토글 구분자: / 문자에 aria-hidden="true"를 적용하여 스크린 리더가 불필요하게 "슬래시"를 읽는 것을 방지했습니다.
  5. 언어 토글 그룹: 언어 토글 버튼들을 role="group"aria-label="언어 선택"으로 감싸 시맨틱 그룹을 형성했습니다.

코드 변경 내용 (components/archive/CategoryFilter.tsx)

Before:

// components/archive/CategoryFilter.tsx (접근성 개선 전)
// ...
            {categories.map((cat) => (
              <button
                key={cat.id}
                onClick={() => onChange(cat.id)}
                className={[
                  'cursor-pointer shrink-0 rounded-full px-3 py-1 text-xs sm:px-4 sm:py-1.5 sm:text-sm font-medium transition-colors',
                  selected === cat.id
                    ? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
                    : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100',
                ].join(' ')}
              >
                {cat.label}
                <span className="ml-1 text-xs opacity-60">{cat.count}</span>
              </button>
            ))}
// ... Series 드롭다운 버튼
                <button
                  onClick={() => setSeriesOpen((v) => !v)}
                  className={[
                    'cursor-pointer rounded-full border px-3 py-1 text-xs sm:px-3.5 sm:py-1.5 sm:text-sm font-medium transition-colors max-w-24 sm:max-w-none truncate',
                    seriesOpen || selectedSeries
                      ? 'border-zinc-900 bg-zinc-900 text-white dark:border-white dark:bg-white dark:text-zinc-900'
                      : 'border-zinc-300 text-zinc-500 hover:text-zinc-900 dark:border-zinc-600 dark:hover:text-zinc-100',
                  ].join(' ')}
                >
                  {selectedSeries || 'Series'}{' '}
                  {seriesOpen ? '▲' : '▼'}
                </button>
                {seriesOpen && (
                  <div
                    className="absolute top-full right-0 z-50 mt-2 min-w-48 overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/95 shadow-xl backdrop-blur-md"
                  >
                    {series.map((s) => (
                      <button
                        key={s.id}
                        onClick={() => handleSeriesClick(s.id)}
                        className={[
                          'flex w-full cursor-pointer items-center justify-between px-4 py-2.5 text-left text-sm transition-colors',
                          selectedSeries === s.id
                            ? 'bg-violet-600/30 text-violet-300'
                            : 'text-zinc-300 hover:bg-white/10',
                        ].join(' ')}
                      >
                        <span>{s.label}</span>
                        <span className="ml-3 text-xs text-zinc-500">{s.count}편</span>
                      </button>
                    ))}
                  </div>
                )}
// ... 언어 토글
            <div className="flex items-center overflow-hidden rounded-full border border-zinc-300 text-xs sm:text-sm font-medium dark:border-zinc-600">
              <button
                onClick={() => onLangChange('ko')}
                className={[
                  'cursor-pointer px-2.5 py-1 sm:px-3 sm:py-1.5 transition-colors',
                  lang === 'ko'
                    ? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
                    : 'text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300',
                ].join(' ')}
              >
                <span className="sm:hidden">KO</span>
                <span className="hidden sm:inline">한국어</span>
              </button>
              <span className="text-zinc-300 select-none dark:text-zinc-600">/</span>
              <button
                onClick={() => onLangChange('en')}
                className={[
                  'cursor-pointer px-2.5 py-1 sm:px-3 sm:py-1.5 transition-colors',
                  lang === 'en'
                    ? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
                    : 'text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300',
                ].join(' ')}
              >
                EN
              </button>
            </div>
// ...

After:

// components/archive/CategoryFilter.tsx (접근성 개선 후)
'use client';

import { useState, useRef, useEffect } from 'react';
import type { Category, PostLang, Series } from '@/types/archive';

interface CategoryFilterProps {
  categories: Category[];
  selected: string;
  onChange: (id: string) => void;
  lang: PostLang;
  onLangChange: (lang: PostLang) => void;
  series: Series[];
  selectedSeries: string;
  onSeriesChange: (id: string) => void;
}

export default function CategoryFilter({
  categories,
  selected,
  onChange,
  lang,
  onLangChange,
  series,
  selectedSeries,
  onSeriesChange,
}: CategoryFilterProps) {
  const [seriesOpen, setSeriesOpen] = useState(false);
  const dropdownRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    function handleClickOutside(e: MouseEvent) {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
        setSeriesOpen(false);
      }
    }
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  const handleSeriesClick = (id: string) => {
    onSeriesChange(selectedSeries === id ? '' : id);
    setSeriesOpen(false);
  };

  // Series 버튼의 title 속성을 위한 선택된 시리즈 레이블
  const selectedSeriesLabel = series.find(s => s.id === selectedSeries)?.label || 'Series';

  return (
    <nav className="flex items-center gap-2">
      {/* 왼쪽: 카테고리 (가로 스크롤) */}
      <div className="flex flex-1 min-w-0 items-center gap-1.5 overflow-x-auto scrollbar-hide">
        {categories.map((cat) => (
          <button
            key={cat.id}
            onClick={() => onChange(cat.id)}
            className={[
              'cursor-pointer shrink-0 rounded-full px-3 py-1 text-xs sm:px-4 sm:py-1.5 sm:text-sm font-medium transition-colors',
              selected === cat.id
                ? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
                : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100',
            ].join(' ')}
            aria-pressed={selected === cat.id} // 추가: 선택 상태를 스크린리더에 알림
          >
            {cat.label}
            <span className="ml-1 text-xs opacity-60">{cat.count}</span>
          </button>
        ))}
      </div>

      {/* 오른쪽: Series + KO/EN (고정) */}
      <div className="flex shrink-0 items-center gap-2">
        {/* 시리즈 드롭다운 */}
        {series.length > 0 && (
          <div ref={dropdownRef} className="relative">
            <button
              onClick={() => setSeriesOpen((v) => !v)}
              className={[
                'cursor-pointer rounded-full border px-3 py-1 text-xs sm:px-3.5 sm:py-1.5 sm:text-sm font-medium transition-colors max-w-24 sm:max-w-none truncate',
                seriesOpen || selectedSeries
                  ? 'border-zinc-900 bg-zinc-900 text-white dark:border-white dark:bg-white dark:text-zinc-900'
                  : 'border-zinc-300 text-zinc-500 hover:text-zinc-900 dark:border-zinc-600 dark:hover:text-zinc-100',
              ].join(' ')}
              aria-haspopup="true" // 추가: 팝업 메뉴가 있음을 알림
              aria-expanded={seriesOpen} // 추가: 드롭다운 확장 상태를 알림
              title={selectedSeriesLabel} // 추가: truncate될 경우 전체 텍스트 제공
            >
              {selectedSeries || 'Series'}{' '}
              {seriesOpen ? '▲' : '▼'}
            </button>

            {seriesOpen && (
              <div
                role="menu" // 추가:  div가 메뉴임을 알림
                className="absolute top-full right-0 z-50 mt-2 min-w-48 overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/95 shadow-xl backdrop-blur-md"
              >
                {series.map((s) => (
                  <button
                    key={s.id}
                    onClick={() => handleSeriesClick(s.id)}
                    role="menuitem" // 추가: 각 버튼이 메뉴 아이템임을 알림
                    className={[
                      'flex w-full cursor-pointer items-center justify-between px-4 py-2.5 text-left text-sm transition-colors',
                      selectedSeries === s.id
                        ? 'bg-violet-600/30 text-violet-300'
                        : 'text-zinc-300 hover:bg-white/10',
                    ].join(' ')}
                    aria-current={selectedSeries === s.id ? 'page' : undefined} // 추가: 현재 선택된 아이템을 알림
                  >
                    <span>{s.label}</span>
                    <span className="ml-3 text-xs text-zinc-500">{s.count}편</span>
                  </button>
                ))}
              </div>
            )}
          </div>
        )}

        {/* 언어 토글 */}
        <div role="group" aria-label="언어 선택" className="flex items-center overflow-hidden rounded-full border border-zinc-300 text-xs sm:text-sm font-medium dark:border-zinc-600"> {/* 추가: 언어 토글 그룹 */}
          <button
            onClick={() => onLangChange('ko')}
            className={[
              'cursor-pointer px-2.5 py-1 sm:px-3 sm:py-1.5 transition-colors',
              lang === 'ko'
                ? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
                : 'text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300',
            ].join(' ')}
            aria-pressed={lang === 'ko'} // 추가: 선택 상태 알림
          >
            <span className="sm:hidden">KO</span>
            <span className="hidden sm:inline">한국어</span>
          </button>
          <span aria-hidden="true" className="text-zinc-300 select-none dark:text-zinc-600">/</span> {/* 추가: 스크린리더에 읽히지 않도록 */}
          <button
            onClick={() => onLangChange('en')}
            className={[
              'cursor-pointer px-2.5 py-1 sm:px-3 sm:py-1.5 transition-colors',
              lang === 'en'
                ? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
                : 'text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300',
            ].join(' ')}
            aria-pressed={lang === 'en'} // 추가: 선택 상태 알림
          >
            EN
          </button>
        </div>
      </div>
    </nav>
  );
}

결론 이 개선으로 스크린 리더 사용자가 드롭다운의 상태와 메뉴 항목, 카테고리 및 언어 선택 상태를 더 명확하게 이해하고 상호작용할 수 있게 되었습니다. dechive는 모든 사용자가 동등하게 정보를 탐색하고 콘텐츠를 즐길 수 있는 더욱 포괄적인 웹사이트가 되었습니다.


최종 결론

이번 dechive UI/UX 개선 작업은 사용자의 초기 로딩 경험을 최적화하고, 이미지 로딩 효율성을 높이며, 웹 접근성을 향상시키는 데 중점을 두었습니다. next/script를 통한 스크립트 로딩 전략 변경은 페이지 성능 지표인 LCP와 CLS에 긍정적인 영향을 미쳐 사용자가 더 빠르게 콘텐츠를 접할 수 있도록 했습니다. 영웅 섹션 이미지 품질 최적화는 불필요한 네트워크 부하를 줄여 전반적인 로딩 속도를 개선했습니다. 마지막으로, CategoryFilter 컴포넌트의 ARIA 속성 강화는 스크린 리더 사용자들을 포함한 모든 사용자가 dechive를 더 쉽고 편리하게 이용할 수 있도록 기반을 마련했습니다.

무한서고 AI팀은 앞으로도 dechive가 모든 사용자에게 최적의 경험을 제공할 수 있도록 끊임없이 연구하고 개선해 나갈 것입니다. 다음 Logs 포스트에서 더 나은 소식으로 찾아뵙겠습니다. 감사합니다!

사서Dechive 사서