본문으로 건너뛰기
Engineering#Next.js#SEO#App Router#Open Graph#sitemap#RSS#검색엔진최적화

Next.js App Router SEO 기본기, 우리가 빠뜨린 다섯 가지

화면에 보이지 않아 놓치기 쉬운 canonical, 메타데이터, Open Graph, sitemap, RSS를 Next.js App Router에서 어떻게 점검하고 고쳤는지 정리했습니다.

Ian3 min read8

화면 밖의 정보

플린랩스 홈페이지는 Next.js 14 App Router로 만들었습니다. 페이지 디자인과 애니메이션을 다듬는 데 시간을 많이 썼는데, 정작 검색엔진과 메신저 공유 미리보기가 읽는 정보는 한참 뒤에야 점검했습니다. 사람에게는 보이지 않으니 빠져도 티가 나지 않는 부분입니다.

점검해 보니 빠지거나 어긋난 곳이 다섯 군데 있었습니다. 하나씩 어떻게 고쳤는지 정리합니다.

canonical, metadata, Open Graph, sitemap, RSS 다섯 가지 점검 항목

빠진 canonical

canonical은 "이 페이지의 대표 주소는 이것"이라고 검색엔진에 알려 주는 태그입니다. 쿼리 문자열이나 끝의 슬래시만 다른 URL이 따로 수집되면, 같은 페이지끼리 검색 순위를 나눠 갖게 됩니다.

블로그 글에는 있었는데, 회사소개·서비스·기술·솔루션 페이지 13개에는 없었습니다. App Router에서는 루트 layout에 metadataBase를 한 번 지정하면 각 페이지는 상대 경로만 쓰면 됩니다.

typescript
// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL(SITE),
  title: { default: 'Flean Labs — …', template: '%s | 플린랩스' },
};

// app/about/page.tsx
export const metadata: Metadata = {
  title: '회사소개',
  alternates: { canonical: '/about' },
};

한 줄짜리 수정이지만, 새 페이지를 만들 때마다 빠뜨리기 쉬운 줄입니다.

클라이언트 페이지 메타데이터

문의 페이지는 폼 상태를 다루느라 'use client' 컴포넌트입니다. Next.js에서는 클라이언트 컴포넌트에서 metadata를 export할 수 없기 때문에 제목과 설명을 붙일 곳이 없었습니다.

해결은 간단합니다. 같은 폴더에 서버 컴포넌트인 layout.tsx를 두고, 메타데이터를 그쪽으로 옮깁니다. layout은 children만 그대로 돌려줍니다.

클라이언트 페이지를 서버 layout이 감싸 메타데이터를 대신 내보내는 구조
typescript
// app/contact/layout.tsx
export const metadata: Metadata = {
  title: '문의하기',
  description: '플린랩스에 프로젝트를 문의하세요. …',
  alternates: { canonical: '/contact' },
  openGraph: shareCard('/contact'),
};

export default function ContactLayout({ children }: { children: React.ReactNode }) {
  return children;
}

홈 페이지도 손봤습니다. 제목과 설명을 따로 쓰지 않고 루트 layout의 기본값을 물려받고 있었습니다. 사이트 전체용 문구는 홈을 설명하기에 모호해서 검색엔진이 임의로 바꿔 보여줄 수 있습니다. 그래서 홈 전용 제목과 설명을 명시했습니다.

덮어쓰이는 openGraph

Next.js의 메타데이터는 layout에서 page로 내려가며 합쳐지지만, 합치는 깊이는 한 단계뿐입니다. 페이지가 openGraph를 선언하면 루트에 있던 openGraph 객체는 통째로 교체됩니다. 페이지에서 url만 바꾸려 했는데 사이트 이름, 타입, 공유 카드 이미지까지 함께 사라지는 식입니다.

페이지의 openGraph가 루트 값을 통째로 덮는 경우와 shareCard로 감싼 경우 비교

그래서 기본값을 다시 채워 주는 작은 헬퍼를 만들었습니다.

typescript
// lib/og.ts
export function shareCard(path: string, card: OpenGraph = {}): OpenGraph {
  return {
    type: 'website',
    siteName: 'Flean Labs',
    url: path,
    images: [{ url: '/opengraph-image', width: 1200, height: 630 }],
    ...card,
  };
}

title과 description은 일부러 넣지 않았습니다. 빠져 있으면 Next.js가 페이지의 값으로 채워 주기 때문입니다. 블로그 글은 shareCard(path, { type: 'article', images: [post.coverImage] })처럼 커버 이미지를 넘기고, 커버가 없는 글은 사이트 카드를 씁니다.

사이트 카드 자체도 바꿨습니다. 로고 하나만 있던 이미지를 next/og의 ImageResponse로 그린 1200×630 카드로 교체했습니다. 강조 바, 한 줄 소개, 로고 심볼이 들어갑니다. 코드로 그리기 때문에 문구를 바꿔도 디자인 툴을 열 필요가 없습니다. 글 페이지에는 BlogPosting JSON-LD도 함께 넣어 제목, 요약, 게시일을 구조화 데이터로 전달합니다.

빠짐없는 sitemap

sitemap은 검색엔진에 "수집할 페이지는 이것들"이라고 건네는 목록입니다. 기존 sitemap.ts에는 홈, 솔루션, 블로그, 문의 페이지만 있었고, 회사소개·서비스·기술 페이지 11개가 빠져 있었습니다. 블로그 글도 목록 API의 첫 페이지만 가져오고 있어서 글이 늘어나면 뒤쪽 글은 sitemap에서 빠질 구조였습니다.

sitemap에 들어간 블로그 외 페이지 수가 10개에서 21개로 늘어난 비교

정적 페이지는 changeFrequency와 priority를 붙여 모두 나열했고, 글은 전체 페이지 수를 확인한 뒤 나머지 페이지를 병렬로 가져옵니다.

typescript
async function allPostSlugs() {
  try {
    const first = await api.posts({ page: 1 });
    if (first.totalPages <= 1) return first.items;
    const rest = await Promise.all(
      Array.from({ length: first.totalPages - 1 }, (_, i) => api.posts({ page: i + 2 })),
    );
    return [...first.items, ...rest.flatMap((p) => p.items)];
  } catch {
    return [];
  }
}

API가 응답하지 않으면 빈 배열을 돌려줍니다. 글 목록을 못 가져와도 정적 페이지가 담긴 sitemap은 계속 나가야 하기 때문입니다.

RSS 피드

마지막은 /feed.xml입니다. Route Handler 하나로 RSS 2.0 문서를 만들고, revalidate = 3600으로 한 시간마다 다시 생성합니다. 제목과 요약은 XML 특수문자를 이스케이프하고, 피드 리더가 원본 주소를 알 수 있게 atom:link로 자기 자신을 가리킵니다. sitemap과 마찬가지로 API가 응답하지 않으면 빈 피드를 내보냅니다.

RSS는 예전보다 덜 쓰이지만, 피드 리더로 기술 블로그를 구독하는 개발자는 아직 있습니다. 만드는 데 드는 비용이 파일 하나라면, 기술 블로그에는 RSS를 기본으로 두는 게 맞다고 생각합니다.

마치며

다섯 가지 모두 수정 자체는 짧았습니다. 문제는 화면에 보이지 않으니 빠져도 아무도 모른다는 점입니다. 배포한 뒤에는 운영 주소에 직접 요청해서 확인했습니다.

bash
curl -s https://fleanlabs.io/about | grep -o '<link rel="canonical"[^>]*>'
curl -s https://fleanlabs.io/sitemap.xml | grep -c '<url>'
curl -s -o /dev/null -w '%{content_type}\n' https://fleanlabs.io/feed.xml

우리는 SEO를 마케팅 작업이 아니라 빌드 결과물의 일부로 봅니다. 새 페이지를 추가할 때 canonical, 메타데이터, sitemap 항목을 같이 넣는 것을 체크리스트로 삼으려 합니다.

플린랩스와 함께

함께 미래를 만들 준비가 되셨나요?

기획·설계부터 구축, 운영까지 — 플린랩스가 전 과정을 함께합니다.

인천, 북위 37.53도 동경 126.73도