사주고사 제작기 09. SEO 전방위 강화 — JSON-LD, 사이트맵, 메타데이터
'사주고사'로 검색해도 안 나왔다. 할 수 있는 SEO 조치를 전부 훑었다.
서비스는 커졌는데 SEO는 초기 상태였다
robots.txt와 sitemap.xml, 결과 페이지 메타데이터는 이전에 넣어뒀다. 그 뒤로 추리 모드에 캐릭터 20개가 붙고 사주 챗봇이 생기고 파비콘도 바뀌었는데, 이 변화가 검색엔진 쪽에는 하나도 반영돼 있지 않았다.
훑어보니 여섯 군데였다. 사이트맵에 루트 URL 하나만 있어서 추리 페이지 20개가 아예 안 잡히고, 구조화 데이터가 없어서 검색엔진이 이 사이트 성격을 모르고, 키워드가 여섯 개뿐이고, Twitter Cards 설정이 없고, 추리 페이지마다 고유 메타데이터가 없고, manifest 테마 컬러가 흰색이었다.
검색엔진에게 게임 앱이라고 알려줬다
구조화 데이터는 이 사이트가 뭔지를 기계가 읽을 수 있는 형태로 알려주는 것이다. 일반 메타 태그가 설명문이라면 JSON-LD는 명세서에 가깝다. Google이 별점이나 앱 정보 같은 걸 검색 결과에 붙일 때 이 데이터를 본다.
세 가지 스키마를 @graph로 묶었다.
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite", // 사이트 기본 정보
name: "사주고사",
url: siteUrl,
inLanguage: "ko",
},
{
"@type": "WebApplication", // 게임 앱으로 인식
applicationCategory: "GameApplication",
offers: { "@type": "Offer", price: "0", priceCurrency: "KRW" },
},
{
"@type": "Organization", // 브랜드 + 로고
logo: { "@type": "ImageObject", url: `${siteUrl}/icon` },
},
],
};WebApplication에 GameApplication 카테고리를 붙인 게 핵심이다. 사주 정보 사이트가 아니라 무료 게임 웹앱이라고 명시하면 게임이나 퀴즈 쪽 검색에 잡힐 여지가 생긴다. price: "0"으로 무료라는 것도 같이 알린다.
layout.tsx의 <body> 최상단에 스크립트 태그로 넣는다. Next.js Metadata API에는 JSON-LD 전용 필드가 없어서 직접 넣어야 한다.
<body className="antialiased">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</body>dangerouslySetInnerHTML을 쓰지만 여기 들어가는 값은 전부 서버에서 만든 상수라 사용자 입력이 섞이지 않는다.
캐릭터가 늘면 사이트맵도 늘어나게 했다
사이트맵에 루트 하나만 있었다. 시나리오 데이터에서 바로 뽑아내게 바꿨다.
import { detectiveScenarios } from "@/lib/data/detective-scenarios";
const detectiveRoutes = detectiveScenarios.map((scenario) => ({
url: `${baseUrl}/detective/${scenario.characterId}`,
changeFrequency: "monthly" as const,
priority: 0.7,
}));
return [
{ url: baseUrl, changeFrequency: "weekly", priority: 1 },
...detectiveRoutes,
];목록을 따로 관리하지 않고 데이터에서 매핑하니 캐릭터를 추가하면 사이트맵이 알아서 늘어난다. 손으로 관리하는 목록은 반드시 어긋난다.
우선순위는 루트를 1.0, 추리 페이지를 0.7로 뒀다. 예전에 robots.ts에서 quiz와 result만 막고 detective는 안 막아둔 게 다행이었다. 앞의 둘은 사용자마다 다른 동적 페이지라 인덱싱할 가치가 없지만, 추리 페이지는 캐릭터마다 고정된 시나리오라 검색에서 들어올 만한 콘텐츠다.
키워드를 6개에서 19개로 늘렸다
// Before
keywords: ["사주", "사주 퀴즈", "사주 테스트", "사주 실력", "사주팔자", "사주고사"]
// After
keywords: [
"사주고사", "사주", "사주 퀴즈", "사주 테스트", "사주팔자",
"사주 게임", "사주 추리", "사주풀이", "사주 무료", "오행",
"운세 퀴즈", "운명 추리", "사주 실력", "명리학", "사주 보기",
"무료 사주", "오늘의 운세", "사주 해석", "AI 사주",
]추가한 건 세 갈래다. 게임 쪽으로 사주 게임과 사주 추리, 운세 퀴즈, 운명 추리. 무료를 찾는 사람을 위해 사주 무료와 무료 사주. 기능 쪽으로 AI 사주와 오늘의 운세, 사주 해석, 사주 보기다. 기존 키워드가 전부 사주를 이미 아는 사람이 칠 법한 말이었다.
설명문도 바꿨다.
Before: "허구 인물의 사주를 풀어보고, 당신의 사주 실력을 인증받으세요."
After: "사주 힌트로 허구 인물의 운명을 추리하는 퀴즈 게임. 오행 분석, 사주팔자 해석,
AI 사주풀이까지. 20명의 캐릭터 시나리오와 전문가 에세이 모드를 무료로 즐겨보세요."실력 인증보다 추리 퀴즈 게임이 검색 의도에 가깝다. 캐릭터 20명이라는 숫자와 AI 사주풀이 같은 기능을 넣어 클릭할 이유를 만들었다.
타이틀은 파이프 뒤에 설명을 붙였다.
Before: "사주고사 - 사주 실력 인증 퀴즈"
After: "사주고사 - 사주 추리 퀴즈 | 사주팔자로 운명을 맞춰보세요"트위터에서 썸네일이 작게 떴다
OG 태그만 있고 Twitter Cards 설정이 없었다. Twitter는 OG를 폴백으로 읽긴 하는데, twitter:card가 없으면 작은 썸네일 형태로 기본 처리한다.
twitter: {
card: "summary_large_image",
title: "사주고사 - 사주 추리 퀴즈",
description: "사주 힌트로 한 사람의 운명을 추리하세요. 20명의 캐릭터, 오행 분석, AI 사주풀이.",
},summary_large_image를 명시해야 큰 이미지 미리보기가 뜬다. OG 쪽에는 siteName과 url을 추가했다. 앞의 것은 카카오톡이나 페이스북에서 출처 표시에 쓰이고, 뒤의 것은 대표 URL 역할을 한다.
크롤러 지시자도 넣었다.
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},max-image-preview: large는 검색 결과에 큰 이미지를 허용하고, max-snippet: -1은 발췌문 길이 제한을 없앤다. 기본값이 보수적이라 명시하지 않으면 짧게 잘린다.
캐릭터 페이지 20개가 전부 같은 제목이었다
추리 페이지에 메타데이터가 없어서 20개가 루트의 기본값을 공유하고 있었다. 검색엔진 입장에서는 제목과 설명이 같은 페이지가 20개 있는 셈이다.
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { characterId } = await params;
const scenario = getDetectiveScenarioByCharacterId(characterId);
const character = getCharacterById(scenario.characterId);
const title = `${character.name}의 운명 추리`;
const description = `${character.name}(${character.gender}, ${character.birthYear}년생)의 사주를 분석하고 ${scenario.rounds.length}개 라운드를 풀어보세요. ${scenario.sajuProfile.summary}`;
return { title, description, openGraph: { ... }, twitter: { ... } };
}렌더링되면 이렇게 나온다.
title: "김하늘의 운명 추리 | 사주고사"
description: "김하늘(남, 1990년생)의 사주를 분석하고 7개 라운드를 풀어보세요.
물이 넘치고 불이 부족한 사주 — 자유로운 영혼이지만 몸이 따라가지 못하는 운명"이름과 성별, 출생연도, 라운드 수, 사주 요약이 들어가니 20개가 서로 다른 페이지로 읽힌다. 데이터에서 조립하는 방식이라 캐릭터를 추가해도 따로 쓸 게 없다.
manifest만 흰색이었다
- background_color: "#ffffff",
- theme_color: "#ffffff",
+ background_color: "#0a0a0a",
+ theme_color: "#7c3aed",사이트는 다크 테마 고정인데 manifest는 초기 기본값 그대로였다. 모바일 브라우저 주소창 색과 홈 화면에 추가했을 때 뜨는 스플래시에 이 값이 반영된다.
막힌 지점과 원인
| 문제 | 원인 | 해결 |
|---|---|---|
| 추리 페이지 20개가 검색에 안 잡힘 | 사이트맵에 루트 URL만 등록 | 시나리오 데이터에서 매핑해 자동 생성 |
| 검색엔진이 사이트 성격을 모름 | 구조화 데이터 부재 | WebSite·WebApplication·Organization JSON-LD 추가 |
| 캐릭터 페이지가 전부 같은 제목 | 페이지별 메타데이터 없음 | generateMetadata로 캐릭터 정보 기반 생성 |
| SNS 공유 시 썸네일이 작음 | twitter:card 미설정 | summary_large_image 명시 |
| 검색 결과 발췌가 짧게 잘림 | 크롤러 지시자 기본값 | max-snippet: -1, max-image-preview: large |
| 주소창 색이 브랜드와 불일치 | manifest 테마 컬러가 흰색 | 다크 배경과 보라 테마로 교체 |
조치는 다 했는데 효과는 아직 모른다. 색인에 반영되기까지 시간이 걸리고, 국내 검색 트래픽 대부분이 오는 네이버 서치어드바이저에는 아직 등록도 안 했다. Search Console에 사이트맵 재제출도 남았다.