메타 태그를 다 넣었는데도 검색 결과는 파란 링크 한 줄뿐이었습니다. 검색엔진이 페이지를 '읽는' 것과 '이해하는' 것은 다른 문제였고, 그 사이를 메우는 게 구조화된 데이터였습니다.
title, description, og:image를 빠짐없이 넣었습니다. 그런데 검색 결과는 여전히 제목 한 줄과 설명 두 줄이었습니다.
반면 어떤 페이지는 별점, 조리 시간, 재료 개수까지 검색 결과에 나옵니다. 같은 HTML인데 왜 다를까요.
이유는 이렇습니다. 검색엔진은 페이지를 읽을 수는 있지만, 그 안의 "4.8"이 별점인지 가격인지 버전 번호인지는 모릅니다. 사람은 문맥으로 알지만 기계는 아닙니다.
메타 태그는 페이지 전체를 한 덩어리로 설명합니다. "이 페이지의 제목은 X이고 요약은 Y다"까지입니다. 하지만 검색 결과에 별점을 띄우려면 **"이 숫자가 별점이고, 만점은 5이며, 13,000명이 매겼다"**를 알려줘야 합니다.
그 역할을 하는 게 구조화된 데이터입니다.
구글 공식 문서의 그림이 이걸 가장 명확하게 보여줍니다.
가운데 <script type="application/ld+json"> 안의 값들이 왼쪽 검색 결과의 각 요소로 그대로 연결됩니다. aggregateRating이 별점 4.8이 되고, recipeIngredient가 "3 ingredients"가 됩니다.
FAQ도 마찬가지입니다.
Question과 acceptedAnswer로 표시해두면 검색 결과에서 바로 답을 펼쳐볼 수 있습니다. 사용자가 클릭하지 않아도 답을 얻는다는 뜻이라 트래픽 관점에서는 손해 같지만, 검색 결과에서 차지하는 면적이 커집니다.
구조화된 데이터를 넣는 방법은 세 가지입니다.
| 방식 | 넣는 위치 | 특징 |
|---|---|---|
| JSON-LD | <script> 안에 따로 | 마크업과 분리됨. 구글 권장 |
| Microdata | HTML 태그 속성으로 | 마크업에 섞임 |
| RDFa | HTML 태그 속성으로 | 마크업에 섞임 |
JSON-LD를 씁니다. 이유는 관리 때문입니다.
Microdata나 RDFa는 <div itemprop="ratingValue">4.8</div>처럼 마크업 안에 섞여 들어갑니다. 그러면 디자인을 바꾸려고 태그 구조를 손볼 때마다 구조화된 데이터가 함께 깨집니다. 반대로 JSON-LD는 별도 스크립트라 화면 구조와 독립적입니다.
구글도 JSON-LD를 권장합니다.
Next.js 공식 문서가 알려주는 패턴은 간단합니다. 다만 한 줄이 중요합니다.
export function StructuredData({ jsonLd }: { jsonLd: JsonLdData }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
}}
/>
);
}
.replace(/</g, "\\u003c")가 그 줄입니다.
JSON.stringify는 악의적인 문자열을 걸러주지 않습니다. 만약 글 제목에 </script><script>...가 들어 있으면, 그대로 출력됐을 때 스크립트 태그가 닫히고 새 스크립트가 실행됩니다. <를 유니코드로 바꿔두면 JSON 파싱에는 영향이 없으면서 태그로 해석되지 않습니다.
사용자가 제목을 입력하는 서비스라면 반드시 필요한 처리입니다.
스키마 속성 이름은 외워서 쓰기 어렵습니다. datePublished인지 publishedDate인지 헷갈리는데, 틀려도 에러가 안 납니다. 그냥 무시될 뿐이라 한참 뒤에 발견하게 됩니다.
schema-dts를 쓰면 타입으로 잡힙니다.
import type { Graph } from "schema-dts";
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "BlogPosting",
headline: title,
datePublished: date,
// publishedDate로 쓰면 여기서 타입 에러
},
],
} satisfies Graph;
satisfies를 쓰는 이유는 타입 검사는 받으면서 실제 객체의 구체적인 타입은 유지하기 위해서입니다.
넣었으면 확인해야 합니다. 구글 리치 검색결과 테스트에 URL을 넣으면 됩니다.
위 예시를 보면 Recipe 타입은 인식됐지만 경고 6개가 있습니다. video, nutrition, description 같은 선택 속성이 비어 있다는 뜻입니다.
여기서 알아둘 게 있습니다. 속성은 두 종류입니다.
경고는 에러가 아니라 "더 채울 수 있다"는 안내입니다. 다만 경쟁 페이지가 다 채웠다면 그만큼 밀립니다.
구글 가이드라인 중 실수하기 쉬운 것들입니다.
1. 화면에 없는 정보를 넣지 않기. 페이지에 별점을 안 보여주면서 구조화된 데이터에만 별점을 넣으면 안 됩니다. 검색 결과와 실제 페이지가 다르면 사용자를 속이는 것이고, 패널티 대상입니다.
2. 구조화된 데이터만 있는 페이지를 만들지 않기. 마크업만 넣어둔 빈 페이지는 스팸으로 봅니다.
3. 한 페이지에 여러 방식을 섞지 않기. JSON-LD와 Microdata를 같이 쓰면 검색엔진이 어느 쪽을 믿어야 할지 모릅니다.
원칙은 하나입니다. 구조화된 데이터는 화면에 있는 내용을 기계가 읽을 수 있게 번역한 것이지, 화면에 없는 정보를 주장하는 수단이 아닙니다.
글마다 BlogPosting을, 사이트 전체에 Blog와 Person을 넣었습니다.
BreadcrumbList를 함께 넣은 이유는 검색 결과에서 URL 대신 경로가 보이게 하기 위해서입니다. codefug.github.io › posts › 2026-08-15보다 홈 › 글 › 구조화된 데이터는 왜 필요할까가 읽기 좋습니다.
Next.js를 쓴다면 알아둘 게 하나 있습니다. 저는 이걸 반대로 알고 있었습니다.
메타데이터는 얕게 병합됩니다.
// layout.tsx
export const metadata = {
openGraph: { siteName: "codefug", images: ["/og.png"] },
};
// page.tsx
export const metadata = {
openGraph: { title: "글 제목" },
// siteName과 images가 합쳐지는 게 아니라 사라진다
};
openGraph처럼 객체가 값인 속성은 하위에서 다시 선언하면 통째로 덮어씌워집니다. 병합이 아니라 교체입니다.
그래서 페이지마다 OG 이미지가 사라지는 일이 생깁니다. 공통 값을 상수로 빼두고 매번 펼쳐 넣는 편이 안전합니다.
export const metadata = {
openGraph: { ...defaultOpenGraph, title: "글 제목" },
};
구조화된 데이터를 넣으면서 남은 것은 이겁니다.
메타 태그는 페이지가 무엇인지 알려주고, 구조화된 데이터는 페이지 안의 값이 각각 무엇인지 알려줍니다. 검색엔진 입장에서는 후자가 있어야 별점을 별점으로, 날짜를 날짜로 다룰 수 있습니다.
그리고 이건 검색엔진만을 위한 게 아닙니다. 화면에 있는 것과 마크업이 일치하는지 점검하게 되는 부수 효과가 있었습니다. 구조화된 데이터를 채우다 보면 "이 페이지가 무엇을 주장하고 있는가"를 다시 보게 됩니다.
전역 Suspense로 감싸면 경고는 사라지지만, 페이지의 정적 마크업까지 placeholder로 대체됩니다. Next.js 소스코드를 따라가 BailoutToCSRError가 CLIENT_RENDERED 경계로 어떻게 흐르는지 살펴봅니다.
hooks 기반 이벤트 드리븐 위젯을 만들고, 그 과정에서 왜 Electron을 선택했는지, hook과 기록 파일이라는 두 신호를 어떻게 교차 검증했는지 정리했습니다.
문서에는 "queued"라고만 적혀 있습니다. 호출 단위인지 컴포넌트 단위인지 탭 전체인지 알 수 없어서, 30개 요청을 세 가지 방식으로 직접 재봤습니다.
참조형 데이터는 내용이 같아도 주소가 다르면 다른 값으로 취급됩니다. 이 특성이 useEffect 의존성 배열과 React.memo에서 왜 문제가 되는지, useCallback과 useMemo가 무엇을 해결하는지, 그리고 왜 모든 곳에 쓰면 안 되는지 정리합니다.
인앱 브라우저에서 기능이 깨지는 문제와 대용량 목록 렌더링 성능 문제, 두 가지를 실제로 부딪히고 풀어낸 기록입니다.
Promise 핸들러가 항상 비동기로 실행되는 이유인 마이크로태스크 큐를 짚고, 이를 편하게 다루는 async/await 문법과 실행 순서 종합 문제로 시리즈를 마무리합니다.