메타 태그를 다 넣었는데도 검색 결과는 파란 링크 한 줄뿐이었습니다. 검색엔진이 페이지를 '읽는' 것과 '이해하는' 것은 다른 문제였고, 그 사이를 메우는 게 구조화된 데이터였습니다.
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")는 제목 같은 값에</script>가 섞여 들어와 스크립트가 닫히는 XSS를 막는 이스케이프입니다. 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;
구글 리치 검색결과 테스트에 URL을 넣으면 검증할 수 있습니다.
위 예시를 보면 Recipe 타입은 인식됐지만 경고 6개가 있습니다. video, nutrition, description 같은 선택 속성이 비어 있다는 뜻입니다.
여기서 알아둘 게 있습니다. 속성은 두 종류입니다.
경고는 에러가 아니라 "더 채울 수 있다"는 안내입니다. 다만 경쟁 페이지가 다 채웠다면 그만큼 밀립니다.
URL만 알면 아무 페이지나 넣어볼 수 있습니다. 실제로 올라 서비스의 ISMS 인증 블로그 상세 페이지를 넣어보면 이렇게 나옵니다.
구글 가이드라인 중 실수하기 쉬운 것들입니다.
1. 화면에 없는 정보를 넣지 않기. 페이지에 별점을 안 보여주면서 구조화된 데이터에만 별점을 넣으면 안 됩니다. 검색 결과와 실제 페이지가 다르면 사용자를 속이는 것이고, 패널티 대상입니다.
2. 구조화된 데이터만 있는 페이지를 만들지 않기. 마크업만 넣어둔 빈 페이지는 스팸으로 봅니다.
3. 한 페이지에 여러 방식을 섞지 않기. JSON-LD와 Microdata를 같이 쓰면 검색엔진이 어느 쪽을 믿어야 할지 모릅니다.
원칙은 하나입니다. 구조화된 데이터는 화면에 있는 내용을 기계가 읽을 수 있게 번역한 것이지, 화면에 없는 정보를 주장하는 수단이 아닙니다.
이 글은 올라 서비스에 구조화된 데이터를 도입하고 개선하면서 알게 된 내용을 정리한 것에 가깝습니다. 마케팅팀과 협업하고 관련 구글 문서를 찾아보면서, 그동안 개발 영역이 아니라며 지나쳤던 곳에서 비즈니스 관점을 여럿 배웠고 스스로 반성하기도 했습니다.
인터넷에는 SEO에 대한 이야기가 굉장히 많습니다. 어떻게 하면 우리 서비스가 검색결과 상단에 나올지를 고민하며 메타데이터를 상세화하거나, 이벤트나 유료 광고로 우선순위를 높이려 합니다. 하지만 그렇게 올라간 검색결과가 사용자에게 매력적으로 보이는지에 대한 고민은 상대적으로 적다고 느꼈습니다. 검색결과 자체의 질을 끌어올리는 구조화된 데이터를 도입하면, 사용자가 우리 서비스를 더 의미 있게 인식하고 방문하지 않을까 생각합니다.
전역 Suspense로 감싸면 경고는 사라지지만, 페이지의 정적 마크업까지 placeholder로 대체됩니다. Next.js 소스코드를 따라가 BailoutToCSRError가 CLIENT_RENDERED 경계로 어떻게 흐르는지 살펴봅니다.
문서에는 "queued"라고만 적혀 있습니다. 호출 단위인지 컴포넌트 단위인지 탭 전체인지 알 수 없어서, 30개 요청을 세 가지 방식으로 직접 재봤습니다.
참조형 데이터는 내용이 같아도 주소가 다르면 다른 값으로 취급됩니다. 이 특성이 useEffect 의존성 배열과 React.memo에서 왜 문제가 되는지, useCallback과 useMemo가 무엇을 해결하는지, 그리고 왜 모든 곳에 쓰면 안 되는지 정리합니다.
인앱 브라우저에서 기능이 깨지는 문제와 대용량 목록 렌더링 성능 문제, 두 가지를 실제로 부딪히고 풀어낸 기록입니다.
Promise 핸들러가 항상 비동기로 실행되는 이유인 마이크로태스크 큐를 짚고, 이를 편하게 다루는 async/await 문법과 실행 순서 종합 문제로 시리즈를 마무리합니다.
Promise.all/allSettled/race 같은 정적 메서드, AbortController로 요청을 취소하는 방법, 그리고 콜백을 Promise로 바꾸는 Promisification을 정리합니다.