프로젝트/Next+TypeScript

[맛길] 접근성, SEO 개선

dev-hpk 2025. 2. 28. 20:13

사이트 배포 후 lighthouse를 이용해 접근성, SEO를 체크했습니다.

접근성, 검색엔진 최적화 측정 지표

분명 접근성과 SEO를 고려하면서 작업했다고 생각했는데 결과가 92점이네요. 낮은 점수는 아니지만 완성도 높은 서비스를 만들기 위해 개선 작업을 해보겠습니다.

접근성 개선

접근성 테스트 탈락 요소

위 사진은 접근성 테스트에서 통과하지 못한 요소들입니다. 차례대로 하나씩 살펴보면서 수정해 보겠습니다.

 

1️⃣ 텍스트 & 배경색 명암비

텍스트 색과 배경 색의 대비는 저시력자나 고령자도 인식할 수 있도록  4.5:1 이상이어야 한다고 합니다.

기존 버튼의 경우 배경색이 #059669, 텍스트가 #FFFFFF로 3.7:1입니다. 명암비 접근성 테스트 사이트를 이용해 접근성을 준수하도록 수정했습니다.

명도비 접근성 테스트 사이트

 

2️⃣ 버튼 터치 영역 수정

버튼의 크기를 W3C에서 권장하는 터치 영역 크기인 44 x 44 px로 수정했습니다. 

3️⃣ 제목 요소 내림차순으로 수정

페이지 구조가 h1 > h3 형태로 작업되어 있어, h3 태그를 h2 태그로 수정했습니다.

4️⃣ 시멘틱 마크업 적용 및 role 속성 삭제

헤더의 내비게이션 부분을 nav > div > a로 작업하고, 리스트라는 점을 명시하기 위해 div와 a 태그에 각각 'list', 'listitem' role을 적용했습니다. 시멘틱 태그(ul, li)를 이용해 구조를 개선하고 불필요한 role 속성을 삭제했습니다.

SEO 개선

검색엔진 최적화 탈락 요소

SEO(Search Engine Optimization)를 위해서는 사이트맵(sitemap)과 robots.txt 파일을 생성해야 한다고 합니다. 사이트맵과 robots.txt가 무슨 역할을 하는지 찾아봤습니다.

  • 사이트맵: 검색엔진의 크롤링을 돕기 위해 사이트에 어떤 페이지가 있는지 알려주는 역할
  • robots.txt: 검색엔진이 크롤링할 수 있는 페이지를 제한하는 역할

즉, 검색엔진이 불필요한 페이지 크롤링하지 않도록 해 빠르고 효율적으로 사이트를 인덱싱할 수 있게 해주는 역할이네요.  프로젝트에 적용해 보겠습니다.

 

1️⃣ robots.txt 추가

Next.js 공식 문서의 설명에 따라 /app 디렉토리 하위에 robots.txt를 추가했습니다.

User-Agent: *
Allow: /

Sitemap: https://mat-gil.vercel.app/sitemap.xml

모든 크롤러를 허용했고, 프로젝트에 보호가 필요한 민감한 데이터가 존재하지 않아 Disallow는 따로 설정하지 않았습니다.

 

2️⃣ 사이트맵(sitemap) 추가

// /app/sitemap.ts

import axios, { AxiosResponse } from 'axios';
import type { MetadataRoute } from 'next';
import { ChannelResponse, YoutubeData } from '@/app/types/youtube';
import path from 'path';
import { promises as fs } from 'fs';

// 채널 JSON 파일의 데이터 수 반환하는 함수
async function getChannelMaxCount(channel: string): Promise<number> {
  const filePath = path.join(process.cwd(), 'data', `${channel}.json`);
  const fileContents = await fs.readFile(filePath, 'utf-8');
  const data: YoutubeData[] = JSON.parse(fileContents);

  return data.length;
}

// 동적으로 각 채널의 경로를 생성하는 함수
async function getDynamicPathsFromAPI() {
  const channels = [
    'pungja',
    'seongsigyeong',
    'hongseokcheon',
    'haennim',
    'baekjongwon',
  ];
  const allPaths: MetadataRoute.Sitemap = [];

  const fetchChannelPaths = channels.map(async (channel) => {
    const maxCount = await getChannelMaxCount(channel);
    let nextCursor: number | null = null;

    const response: AxiosResponse<ChannelResponse> = await axios.get(
      `https://mat-gil.vercel.app/api/${channel}`,
      {
        params: { cursor: nextCursor, limit: maxCount },
      },
    );
    const { lists, nextCursor: newNextCursor } = response.data;

    // 채널의 세부 페이지 URL 경로를 생성하여 allPaths 배열에 추가
    lists.forEach((item: YoutubeData) => {
      allPaths.push({
        url: `https://mat-gil.vercel.app/${channel}/${item.position}`,
        lastModified: new Date(),
        changeFrequency: 'daily',
        priority: 0.7,
      });
    });

    nextCursor = newNextCursor;
  });

  // 빌드 타임에서 60초 초과 에러 발생
  // 모든 채널에 대해 병렬로 경로를 가져오도록 수정
  await Promise.all(fetchChannelPaths);

  return allPaths;
}

// sitemap 함수: 최종 Sitemap을 반환하는 함수
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const dynamicPaths = await getDynamicPathsFromAPI();

  return [
    {
      url: 'https://mat-gil.vercel.app',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    {
      url: 'https://mat-gil.vercel.app/pungja',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.7,
    },
    {
      url: 'https://mat-gil.vercel.app/seongsigyeong',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.7,
    },
    {
      url: 'https://mat-gil.vercel.app/hongseokcheon',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.7,
    },
    {
      url: 'https://mat-gil.vercel.app/haennim',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.7,
    },
    {
      url: 'https://mat-gil.vercel.app/baekjongwon',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.7,
    },
    ...dynamicPaths,
  ];
}
  • getChannelMaxCount: 각 채널에 포함된 데이터의 개수를 반환.
  • getDynamicPathsFromAPI: 각 채널에 대해 데이터를 가져와 동적 사이트맵 경로를 생성.
  • sitemap: 기본 URL과 동적 경로를 합쳐 최종 사이트맵을 반환.

/api/sitemap/route.ts

// /app/api/sitemap/route.ts

import { NextResponse } from 'next/server';
import sitemap from '@/app/sitemap';

const formatDate = (date: Date): string => {
  return date.toISOString().split('T')[0];
};

export async function GET() {
  const sitemapData = await sitemap();

  const xmlHeader = `<?xml version="1.0" encoding="UTF-8"?>\n`;
  const urlSetStart = `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`;
  const urlSetEnd = `</urlset>\n`;

  const urls = sitemapData
    .map((entry) => {
      return `<url>
      <loc>${entry.url}</loc>
      <lastmod>${formatDate(new Date())}</lastmod>
      <changefreq>${entry.changeFrequency}</changefreq>
      <priority>${entry.priority}</priority>
    </url>`;
    })
    .join('\n');

  const sitemapXML = xmlHeader + urlSetStart + urls + urlSetEnd;

  return NextResponse.json(sitemapXML, {
    headers: { 'Content-Type': 'application/xml' },
  });
}

sitemap.xml

위의 동적인 사이트 맵을 생성하는 코드를 작성하기까지 여러번의 시행착오를 겪었습니다. 에러 메시지는  it took more than 60 seconds로 빌드하는데 시간이 오래 걸려서 발생하는 문제였습니다.

동적 사이트맵 추가 후 빌드 에러

처음에는 빌드 시간이 길어지는 이유를 파악하는 데 어려움을 겪었고, 다양한 가능성을 검토해 봤습니다. 그 과정에서 API 요청, 데이터 처리 방식, 외부 리소스 불러오는 방식 등 여러 가지를 체크하며 최적화를 시도했습니다. 특히 API 호출에 대해 반복적으로 요청을 보내는 부분에서 속도가 느려지는 문제를 발견할 수 있었습니다.

결국, 이 문제를 해결하면서 빌드 과정에 대한 이해도가 높아졌고, 앞으로 유사한 문제가 발생했을 때 더 빠르게 대응할 수 있을 것 같습니다.

 

3️⃣ metaData 추가

검색 엔진이 페이지를 더 잘 인식하고 사용자에게 더 나은 검색 결과를 제공할 수 있도록, 페이지별로 고유한 제목(title)과 설명(description) 등의 정보를 동적으로 생성했습니다.

// /app/[channel]/page.tsx

export async function generateMetadata({
  params,
}: {
  params: Promise<{ channel: string }>;
}): Promise<Metadata> {
  const { channel } = await params;

  const title = `맛길 | 맛집 추천 & 길찾기 | ${channelMap[channel]}`;
  const description = `${channelMap[channel]}의 인기 맛집 리스트! 유튜브 영상으로 검증된 맛집 위치와 메뉴를 확인하고, 길찾기 기능으로 가까운 맛집에 방문해보세요!`;
  const imageUrl = 'https://mat-gil.vercel.app/images/logo.png';

  return {
    title,
    description,
    openGraph: {
      title,
      description,
      url: `https://mat-gil.vercel.app/${channel}`,
      images: {
        url: imageUrl,
        width: 1200,
        height: 630,
      },
      type: 'website',
      siteName: '맛길',
    },
    twitter: {
      card: 'summary_large_image',
      title,
      description,
      images: { url: imageUrl },
    },
  };
}
// /app/[channel]/[id]/page.tsx

export async function generateMetadata({
  params,
}: {
  params: Promise<{ channel: string; id: string }>;
}): Promise<Metadata> {
  const { channel, id } = await params;
  const {
    data: { list },
  } = await axios.get(`${channel}/${id}`);
  const { title: restaurant } = list;

  const title = `맛길 | 맛집 추천 & 길찾기 | ${channelMap[channel]}`;
  const description = `${restaurant}의 메뉴와 위치를 확인하고, 길찾기 기능을 통해 해당 매장까지 간편하게 찾아가세요!`;
  const imageUrl = 'https://mat-gil.vercel.app/images/logo.png';

  return {
    title,
    description,
    openGraph: {
      title,
      description,
      url: `https://mat-gil.vercel.app/${channel}/${id}`,
      images: [
        {
          url: imageUrl,
          width: 1200,
          height: 630,
        },
      ],
      type: 'website',
      siteName: '맛길',
    },
    twitter: {
      card: 'summary_large_image',
      title,
      description,
      images: { url: imageUrl },
    },
  };
}

mat-gil.vercel.app/pungja - 메타 데이터
mat-gil.vercel.app/seongsigyeong/0 - 메타 데이터

메타 데이터가 페이지에 따라서 동적으로 잘 생성 됩니다. 추가로 openGraph도 설정했으니, 공유 디버거와 실제 링크 공유를 통해 미리보기 화면도 확인해 보겠습니다.

kakao developers - 공유 디버거
meta developers - 공유 디버거
카카오톡 링크 공유 화면

 

접근성 & SEO 개선 결과

접근성, SEO 개선 후 측정 지표

 

 

이번 프로젝트는 접근성과 SEO 개선을 목표로 많은 노력을 기울였으며, 이 과정에서 많은 지식을 습득하고 문제 해결 능력을 키우는 값진 경험을 하였습니다. 문제를 해결하며 얻은 지식은 단순히 기술적인 부분에 그치지 않고, 사용자 중심의 사고방식을 배우는 기회가 되었습니다.