← 블로그 목록
SEOReactRouterCloudflarePagesJSON-LD사이트맵1인개발React

React Router SPA에 SEO 구현하기 — Next.js 없이 메타 태그, JSON-LD, 사이트맵 완성

2026년 4월 16일

React Router로 SPA를 만들면 SEO 처리가 Next.js보다 손이 많이 갑니다. 서버 사이드 렌더링 기본 지원이 없고, 메타 태그·구조화 데이터·사이트맵을 직접 연결해야 합니다.

투자킹(twojaking.com)을 React Router + Cloudflare Pages로 운영하면서 적용한 SEO 구현을 실제 코드 기반으로 정리합니다. 각 기술은 어렵지 않지만, 하나라도 빠뜨리면 효과가 줄고 어디서 누락됐는지 파악하기 어렵습니다.


전체 구조

메타 태그 (title, description, canonical, OG 7개, Twitter Card 5개)
  ↓
JSON-LD 구조화 데이터 (Organization, WebSite, Article, NewsArticle, BreadcrumbList)
  ↓
동적 사이트맵 (sitemap.xml — 정적 + DB 동적 혼합)
  ↓
Google News 사이트맵 (news-sitemap.xml — 48시간 이내 콘텐츠)
  ↓
RSS 피드 (rss/news.xml, rss/analysis.xml)
  ↓
Google Indexing API 자동 통보 (콘텐츠 저장 즉시 구글에 알림)
  ↓
robots.txt + Naver 검색 등록

1. 핵심 유틸리티: generateSeoMeta()

페이지마다 메타 태그를 직접 작성하면 항목이 빠집니다. SeoMetaConfig 인터페이스를 정의하고, generateSeoMeta() 함수 하나로 필요한 태그를 전부 생성합니다.

// app/utils/seo.ts
export interface SeoMetaConfig {
  title: string;
  description: string;
  keywords?: string;
  path: string;
  image?: string;
  type?: 'website' | 'article';
  noindex?: boolean;
}

const SITE_URL = 'https://twojaking.com';
const SITE_NAME = '투자킹';
const DEFAULT_IMAGE = `${SITE_URL}/android-chrome-512x512.png`;

export function generateSeoMeta(config: SeoMetaConfig) {
  const {
    title,
    description,
    keywords,
    path,
    image = DEFAULT_IMAGE,
    type = 'website',
    noindex = false,
  } = config;

  const url = `${SITE_URL}${path}`;
  // 홈(/) 이외 페이지는 "제목 | 사이트명" 형식으로 자동 처리
  const fullTitle = path === '/' ? title : `${title} | ${SITE_NAME}`;

  const meta = [
    { title: fullTitle },
    { name: 'description', content: description },
  ];

  // keywords는 값이 있을 때만 추가
  if (keywords) {
    meta.push({ name: 'keywords', content: keywords });
  }

  // Canonical URL
  meta.push({ tagName: 'link', rel: 'canonical', href: url } as any);

  // Open Graph (7개)
  meta.push(
    { property: 'og:type', content: type },
    { property: 'og:title', content: fullTitle },
    { property: 'og:description', content: description },
    { property: 'og:url', content: url },
    { property: 'og:image', content: image },
    { property: 'og:site_name', content: SITE_NAME },
    { property: 'og:locale', content: 'ko_KR' }
  );

  // Twitter Card (5개)
  meta.push(
    { name: 'twitter:card', content: 'summary_large_image' },
    { name: 'twitter:site', content: '@twojaking' },
    { name: 'twitter:title', content: fullTitle },
    { name: 'twitter:description', content: description },
    { name: 'twitter:image', content: image }
  );

  // noindex (로그인·프로필 등 개인 페이지)
  if (noindex) {
    meta.push({ name: 'robots', content: 'noindex' });
  }

  return meta;
}

함수 한 번 호출로 title, description, canonical, OG 7개, Twitter Card 5개 — 최대 15개 태그가 자동 생성됩니다.

사용 예시

// 정적 페이지
export function meta() {
  return generateSeoMeta({
    title: '실시간 비트코인 뉴스',
    description: '최신 암호화폐 뉴스와 AI 분석을 실시간으로 확인하세요.',
    keywords: '비트코인, 코인 뉴스, 암호화폐',
    path: '/news',
  });
}

// 동적 페이지 (뉴스 기사 상세)
export function meta({ data, params }: Route.MetaArgs) {
  if (!data?.article) {
    return generateSeoMeta({
      title: '뉴스',
      description: '투자킹 뉴스',
      path: `/news/${params.articleId ?? ''}`,
      noindex: true,
    });
  }
  return [
    ...generateSeoMeta({
      title: data.article.title,
      description: data.article.summary.slice(0, 150),
      keywords: data.article.tags.join(', '),
      path: `/news/${data.article.uuid}`,
      type: 'article',
    }),
    { 'script:ld+json': generateNewsArticleSchema({ ... }) },
    { 'script:ld+json': generateBreadcrumbSchema([...]) },
  ];
}

canonical URL 설정이 중요합니다. 쿼리 파라미터가 붙은 변형 URL이 생겨도 구글이 대표 URL 하나로 인식합니다.


2. Open Graph + Twitter Card 자동 생성

generateSeoMeta()가 OG와 Twitter Card를 동시에 처리합니다. 별도 설정 없이 모든 페이지에서 SNS 공유 시 썸네일과 설명이 올바르게 표시됩니다.

핵심 포인트:

  • og:imagetwitter:imageDEFAULT_IMAGE로 통일. 페이지별 이미지가 있으면 image 파라미터로 전달
  • twitter:site에 계정 핸들(@twojaking)을 고정으로 포함
  • og:localeko_KR로 고정

콘텐츠 상세 페이지는 type: 'article'로 지정하면 og:typearticle로 설정됩니다.


3. JSON-LD 구조화 데이터

구글 검색 결과에 리치 스니펫(날짜, 출처, 경로 등)이 표시되려면 JSON-LD가 필요합니다.

React Router의 script:ld+json 처리 방식

React Router는 meta() 배열에서 'script:ld+json' 키를 자동으로 직렬화합니다.

// 올바른 방법
{ 'script:ld+json': generateArticleSchema({...}) }

// 절대 사용 금지 — React Router가 이중 직렬화하여 HTML이 깨짐
{ 'script:ld+json': JSON.stringify(generateArticleSchema({...})) }

JavaScript 객체를 그대로 넘기면 됩니다. JSON.stringify는 사용하지 않습니다.

generateArticleSchema() — 블로그·피드 콘텐츠

export function generateArticleSchema(config: {
  title: string;
  description: string;
  url: string;
  image?: string;
  datePublished: string;
  author: string;
}) {
  return {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: config.title,
    description: config.description,
    image: config.image || DEFAULT_IMAGE,
    datePublished: config.datePublished,
    author: {
      '@type': 'Person',
      name: config.author,
    },
    publisher: {
      '@type': 'Organization',
      name: SITE_NAME,
      logo: {
        '@type': 'ImageObject',
        url: `${SITE_URL}/android-chrome-512x512.png`,
      },
    },
    url: config.url,
  };
}

generateNewsArticleSchema() — 뉴스 기사

export function generateNewsArticleSchema(config: {
  title: string;
  description: string;
  url: string;
  image?: string;
  datePublished: string;
  dateModified?: string;
  author: string;
  keywords?: string[];
}) {
  return {
    '@context': 'https://schema.org',
    '@type': 'NewsArticle',
    headline: config.title.slice(0, 110), // 구글 권장 110자 제한
    description: config.description,
    image: config.image || DEFAULT_IMAGE,
    datePublished: config.datePublished,
    dateModified: config.dateModified || config.datePublished,
    keywords: config.keywords?.length ? config.keywords.join(', ') : undefined,
    author: {
      '@type': 'Organization',
      name: config.author,
    },
    publisher: {
      '@type': 'Organization',
      name: SITE_NAME,
      logo: {
        '@type': 'ImageObject',
        url: `${SITE_URL}/android-chrome-512x512.png`,
      },
    },
    url: config.url,
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': config.url,
    },
  };
}

ArticleNewsArticle의 차이: NewsArticledateModified, mainEntityOfPage, keywords를 추가로 포함하고 headline을 110자로 제한합니다. Google Search Console에서 "뉴스 기사" 리치 결과로 분류됩니다.

generateBreadcrumbSchema() — 경로 표시

export function generateBreadcrumbSchema(items: Array<{ name: string; url: string }>) {
  return {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: items.map((item, index) => ({
      '@type': 'ListItem',
      position: index + 1,
      name: item.name,
      item: item.url,
    })),
  };
}

뉴스 기사 페이지 적용 예시:

{ 'script:ld+json': generateBreadcrumbSchema([
  { name: '홈', url: 'https://twojaking.com' },
  { name: '뉴스', url: 'https://twojaking.com/news' },
  { name: data.article.title, url: `https://twojaking.com/news/${data.article.uuid}` },
]) }

검색 결과 URL 아래에 투자킹 > 뉴스 > 기사 제목 형식으로 경로가 표시됩니다.

전역 스키마 (root.tsx)

OrganizationWebSite 스키마는 모든 페이지에 공통 적용합니다.

// app/root.tsx
export function meta() {
  return [
    { 'script:ld+json': generateOrganizationSchema() },
    { 'script:ld+json': generateWebsiteSchema() },
  ];
}

WebSite 스키마에 SearchAction을 추가하면 구글 검색 결과에 사이트 내 검색 바가 표시될 수 있습니다.


4. 동적 사이트맵 구현

정적 파일로 만든 sitemap.xml은 새 콘텐츠가 추가되어도 갱신이 안 됩니다. React Router의 SSR 라우트로 만들면 요청할 때마다 DB를 조회해 최신 URL을 반환합니다.

// app/routes/sitemap[.xml].tsx
const STATIC_PAGES = [
  { url: '/', changefreq: 'daily', priority: '1.0' },
  { url: '/news', changefreq: 'hourly', priority: '0.9' },
  { url: '/btc', changefreq: 'hourly', priority: '0.8' },
  // ...
];

export async function loader() {
  let news: Array<{ uuid: string; published_at: string }> = [];
  let majorDates: string[] = [];
  let blogPosts: Array<{ slug: string; published_at: string; updated_at: string }> = [];

  // 뉴스 API + Supabase 블로그 병렬 조회
  const [newsResult, blogResult] = await Promise.allSettled([
    fetch(`${API_BASE}/api/v1/news/sitemap-urls`, {
      signal: AbortSignal.timeout(8000),
    }).then(async (res) => {
      if (!res.ok) return null;
      const json = await res.json();
      return json.success ? json.data : null;
    }),
    supabase
      .from('blog_posts')
      .select('slug, published_at, updated_at')
      .eq('is_published', true)
      .order('published_at', { ascending: false }),
  ]);

  if (newsResult.status === 'fulfilled' && newsResult.value) {
    news = newsResult.value.news ?? [];
    majorDates = newsResult.value.major_dates ?? [];
  }
  if (blogResult.status === 'fulfilled' && blogResult.value.data) {
    blogPosts = blogResult.value.data;
  }

  const xml = buildSitemapXml(news, majorDates, blogPosts);

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml; charset=utf-8',
      'Cache-Control': 'public, max-age=600, s-maxage=600', // 10분 캐시
    },
  });
}

Cache-Control: s-maxage=600으로 Cloudflare Edge에서 10분간 캐싱합니다. 매 요청마다 DB를 조회하지 않아 부하가 없습니다.

Promise.allSettled()를 사용해 뉴스 API나 Supabase 중 하나가 실패해도 나머지 URL은 정상 반환됩니다.


5. .xml 라우트 예외 처리 — Cloudflare Workers 설정

Cloudflare Pages는 .xml 확장자를 정적 파일로 오인합니다. workers/app.ts에 SSR 라우트로 명시해야 합니다.

// workers/app.ts
const SSR_ROUTES = [
  '/sitemap.xml',
  '/news-sitemap.xml',
  '/rss/news.xml',
  '/rss/analysis.xml',
];

export default {
  async fetch(request: Request, env: Env) {
    const url = new URL(request.url);
    if (SSR_ROUTES.includes(url.pathname)) {
      return handleSSR(request, env); // React Router SSR 처리
    }
    return env.ASSETS.fetch(request); // 정적 파일
  },
};

주의: scripts/create-pages-worker.js에도 동일한 SSR_ROUTES 배열이 있습니다. 새 XML 라우트를 추가할 때는 두 파일 모두 갱신해야 합니다.


6. Google Indexing API 자동 통보

사이트맵을 등록해도 구글이 크롤링하는 데 며칠이 걸릴 수 있습니다. Google Indexing API를 사용하면 콘텐츠 저장 즉시 구글에 알림을 보낼 수 있습니다.

원래 구인·구직 관련 사이트를 위한 API이지만, 실제로는 일반 콘텐츠에도 빠른 인덱싱 효과가 있습니다.

# twojaKing_be/app/application/services/hybrid/google_indexing.py
async def notify_url_updated(url: str) -> None:
    token = await _get_access_token(service_account_json)
    async with httpx.AsyncClient(timeout=10) as client:
        await client.post(
            'https://indexing.googleapis.com/v3/urlNotifications:publish',
            headers={'Authorization': f'Bearer {token}'},
            json={'url': url, 'type': 'URL_UPDATED'},
        )

4가지 이벤트에서 자동 통보합니다.

이벤트URL 포맷
뉴스 기사 저장/news/{uuid}
AI 분석 저장/{coin}/analysis/{id}
타임라인 이벤트 생성/news/events/{event_id}
주요뉴스 일일 페이지 갱신/news/major/{YYYY-MM-DD}
# news_service.py — 뉴스 기사 저장 후
if inserted_uuid:
    asyncio.create_task(
        notify_url_updated(f'https://twojaking.com/news/{inserted_uuid}')
    )

asyncio.create_task()로 백그라운드 실행이라 메인 처리 흐름에 영향이 없습니다. 실패해도 로깅만 하고 넘어갑니다.

일일 할당량이 200건입니다. 뉴스가 하루 수백 건 들어오는 서비스라면 중요도 높은 콘텐츠(AI 분석, 타임라인 이벤트)를 우선 통보하는 방향으로 조정이 필요합니다.


7. noindex 설정 — 개인 페이지 처리

로그인·프로필·설정 페이지는 검색 인덱싱이 불필요합니다. noindex: true를 설정하면 robots: noindex 메타 태그가 자동으로 추가됩니다.

// 로그인 페이지
export function meta() {
  return generateSeoMeta({
    title: '로그인',
    description: '투자킹 로그인',
    path: '/login',
    noindex: true,
  });
}

개인 페이지는 sitemap.xml에도 포함하지 않습니다. noindex 메타 태그만으로 충분합니다.


새 페이지 추가 시 체크리스트

작업대상
generateSeoMeta() meta 함수 추가모든 페이지
app/routes.ts 경로 등록모든 페이지
sitemap[.xml].tsxSTATIC_PAGES 추가정적 페이지
JSON-LD 구조화 데이터 추가콘텐츠 상세 페이지
noindex: true 설정로그인·프로필 등 개인 페이지
workers/app.tsscripts/create-pages-worker.js에 SSR_ROUTES 추가.xml 확장자 라우트

참고 자료

0

댓글 0

Ctrl+Enter