7편: 복사 방지와 SEO 한방에 잡기
복사 방지, 한번 해볼까
다른 블로그들을 돌아다니다 보면 본문 드래그가 안 되는 곳이 있다. 그걸 보고 문득 "나도 한번 넣어볼까?" 싶었다. 사실 이 블로그는 GitHub 무료 플랜이라 저장소 자체가 공개되어 있어서, 복사를 막는다고 콘텐츠가 보호되는 건 아니다. 그래도 브라우저에서 드래그 한 번으로 전문이 복사되는 것과, 저장소에 가서 마크다운 소스를 찾아보는 것 사이에는 꽤 큰 허들 차이가 있다. 기술 블로그니까 코드 블록은 자유롭게 복사할 수 있어야 하고, 본문만 살짝 걸어두는 정도면 충분했다.
마침 SEO도 손볼 타이밍이었다. 6편: 정적 블로그에 다국어(i18n) 붙이기에서 hreflang과 sitemap 교차 참조를 다뤘지만, 포스트 단위의 구조화 데이터(JSON-LD)나 Open Graph 메타데이터는 아직 없었다. 복사 방지와 SEO, 두 작업을 같이 진행했다.
복사 방지와 SEO는 충돌하는가
처음에 든 의문이 있었다. 본문 복사를 막으면 검색 엔진 크롤링에도 영향이 있지 않을까?
결론부터 말하면, 충돌하지 않는다.
| 보호 수단 | 작동 방식 | 크롤러 영향 |
|---|---|---|
CSS user-select: none |
브라우저 렌더링 단계에서 텍스트 선택 차단 | 없음 (크롤러는 CSS를 렌더링하지 않음) |
JS copy 이벤트 차단 |
클립보드 이벤트를 preventDefault()로 막음 |
없음 (크롤러는 JS 이벤트를 실행하지 않음) |
robots.txt / meta robots |
크롤러 접근 자체를 제어 | 직접 영향 |
CSS와 JS 레벨의 복사 방지는 사람의 브라우저에서만 작동한다. Googlebot이든 Yeti(네이버)든, HTML 소스를 파싱할 뿐 CSS로 선택이 안 되는지, JS로 복사가 막혀 있는지는 신경 쓰지 않는다. 따라서 복사 방지를 걸면서 SEO 메타데이터를 강화하면, 콘텐츠 보호와 검색 노출을 동시에 챙길 수 있다. "한방에 잡기"가 가능한 이유다.
복사 방지 구현
복사 방지는 CSS 레벨과 JS 레벨, 두 겹으로 걸었다.
CSS: user-select로 선택 자체를 차단
/* src/app/globals.css */
.prose {
-webkit-user-select: none;
user-select: none;
}
.prose pre,
.prose code {
-webkit-user-select: text;
user-select: text;
}
.prose(본문 영역) 전체에 user-select: none을 걸고, pre와 code 태그만 text로 풀어줬다. -webkit- 접두사는 Safari 대응이다. 이렇게 하면 본문 텍스트는 드래그 자체가 안 되지만, 코드 블록은 자유롭게 선택할 수 있다.
JS: copy 이벤트 가로채기
CSS만으로는 부족하다. 개발자 도구에서 user-select를 풀면 그만이니까. JS에서 copy 이벤트도 차단했다.
// src/components/CopyProtection.tsx
function isInsideCodeBlock(node: Node | null): boolean {
while (node) {
if (node instanceof HTMLElement) {
const tag = node.tagName.toLowerCase();
if (tag === "pre" || tag === "code") return true;
}
node = node.parentNode;
}
return false;
}
function handleCopy(e: ClipboardEvent) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (
isInsideCodeBlock(range.startContainer) &&
isInsideCodeBlock(range.endContainer)
) {
return; // 코드 블록 안이면 복사 허용
}
e.preventDefault(); // 그 외 본문은 차단
}
핵심은 isInsideCodeBlock 함수다. 선택 영역의 시작점과 끝점이 모두 <pre> 또는 <code> 안에 있으면 복사를 허용하고, 그렇지 않으면 preventDefault()로 막는다. DOM 트리를 parentNode로 올라가며 확인하는 단순한 로직이다.
코드 블록 복사 버튼
코드 블록은 복사를 허용했으니, 편의를 위해 복사 버튼도 달았다.
// src/components/CodeCopyButton.tsx (핵심 발췌)
const preBlocks = document.querySelectorAll(".prose pre");
preBlocks.forEach((pre) => {
if (pre.querySelector(".code-copy-btn")) return; // 중복 방지
const btn = document.createElement("button");
btn.className = "code-copy-btn";
btn.addEventListener("click", async () => {
const code = pre.querySelector("code");
const text = code?.textContent || pre.textContent || "";
await navigator.clipboard.writeText(text);
// 체크 아이콘으로 2초간 피드백
});
(pre as HTMLElement).appendChild(btn);
});
.prose pre를 전부 순회하면서 복사 버튼을 동적으로 생성한다. navigator.clipboard.writeText()로 클립보드에 넣고, 복사 성공 시 아이콘을 체크 마크로 바꿔 2초간 시각적 피드백을 준다.
버튼 스타일은 opacity: 0으로 숨겨두고, pre:hover일 때만 보이게 했다:
/* src/app/globals.css */
.code-copy-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background-color 0.15s;
}
pre:hover .code-copy-btn {
opacity: 1;
}
SEO 구현
메타데이터 구조
SEO 메타데이터는 세 계층으로 나눠서 설정했다.
| 계층 | 파일 | 역할 |
|---|---|---|
| 루트 | src/app/layout.tsx |
metadataBase, title.template, 네이버 인증 |
| 로케일 | src/app/[locale]/layout.tsx |
Open Graph locale, RSS alternate, 언어 교차 참조 |
| 포스트 | src/app/[locale]/posts/[slug]/page.tsx |
canonical URL, OG article, JSON-LD |
루트 레이아웃에서 기본값을 깔고, 로케일 레이아웃에서 언어별 설정을 덮고, 포스트 페이지에서 개별 메타데이터를 최종 적용하는 구조다.
루트: metadataBase와 title 템플릿
// src/app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL("https://jinwonmin.github.io"),
title: {
default: "minsnote",
template: "%s | minsnote",
},
verification: {
other: {
"naver-site-verification": "691c49a5a029a53d09a4859edfade82b82b741f8",
},
},
};
metadataBase를 설정하면 하위 페이지에서 상대 경로로 Open Graph URL을 지정해도 자동으로 절대 URL이 된다. title.template의 %s에는 각 페이지의 title이 들어간다.
로케일: Open Graph와 alternate
// src/app/[locale]/layout.tsx (generateMetadata 발췌)
return {
description: dict.site.description,
openGraph: {
type: "website",
siteName: "minsnote",
locale: dict.site.locale, // ko_KR 또는 en_US
},
alternates: {
types: { "application/rss+xml": `/${locale}/rss.xml` },
languages: { ko: "/ko", en: "/en" },
},
};
alternates.languages로 ko/en 교차 참조를 설정했다. 6편에서 다뤘던 hreflang과 같은 맥락이다.
포스트: canonical과 JSON-LD
// src/app/[locale]/posts/[slug]/page.tsx (generateMetadata 발췌)
const url = `https://jinwonmin.github.io/${locale}/posts/${slug}`;
return {
title: post.title,
description: post.description,
alternates: {
canonical: url,
languages: { ko: `/ko/posts/${slug}`, en: `/en/posts/${slug}` },
},
openGraph: {
title: post.title,
description: post.description,
url,
type: "article",
publishedTime: new Date(post.date).toISOString(),
tags: post.tags,
},
};
Open Graph type을 로케일 레이아웃에서는 "website", 포스트에서는 "article"로 구분했다. publishedTime과 tags도 포스트 메타데이터에서 가져온다.
JSON-LD는 BlogPosting 스키마로 구조화했다:
// src/app/[locale]/posts/[slug]/page.tsx
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.description,
datePublished: new Date(post.date).toISOString(),
author: { "@type": "Person", name: "minsnote" },
url: `https://jinwonmin.github.io/${locale}/posts/${slug}`,
keywords: post.tags.join(", "),
};
이걸 <script type="application/ld+json">으로 페이지에 삽입한다. 구글 검색 결과에서 리치 스니펫으로 노출되는 기반 데이터다.
robots.txt
User-agent: *
Allow: /
User-agent: Yeti
Allow: /
Sitemap: https://jinwonmin.github.io/sitemap.xml
모든 크롤러에 전체 허용하고, 네이버 크롤러(Yeti)도 별도로 명시했다. Sitemap URL을 선언해서 크롤러가 사이트 구조를 파악할 수 있게 했다.
삽질 기록
삽질 1: user-select: none이 코드 블록까지 먹었다
처음에는 .prose 전체에 user-select: none만 걸었다. 코드 블록도 .prose 안에 있으니, 코드도 선택이 안 됐다. 기술 블로그에서 코드를 복사할 수 없으면 의미가 없다.
.prose pre, .prose code에 user-select: text를 명시적으로 풀어주는 것으로 해결했다. CSS 상속 특성상, 부모에서 none을 걸면 자식도 다 막히기 때문에, 예외 처리를 반드시 자식 선택자에서 해줘야 한다.
삽질 2: 복사 버튼이 다크모드에서 안 보였다
CodeCopyButton의 아이콘 색상을 color: #6b7280(gray-500)으로 고정해뒀다. 라이트 모드에서는 배경(흰색 계열)과 대비가 충분했지만, 다크모드에서는 코드 블록 배경(#1e1e2e)과 아이콘 색이 비슷해서 사실상 안 보였다.
hover 시 색상을 라이트/다크 분기로 나눠서 해결했다:
/* src/app/globals.css */
.code-copy-btn:hover {
color: #111827;
background-color: #e5e7eb;
}
.dark .code-copy-btn:hover {
color: #f9fafb;
background-color: #374151;
}
기본 상태에서는 opacity: 0이라 어차피 안 보이고, hover 시에만 나타나니까 hover 색상만 분기 처리하면 충분했다.
삽질 3: JSON-LD datePublished 포맷
post.date를 new Date(post.date).toISOString()으로 변환하는데, 6편에서 다뤘던 gray-matter의 Date 객체 문제가 여기서도 재현됐다. gray-matter가 frontmatter의 date 필드를 자동으로 Date 객체로 변환하면서, 타임존에 따라 날짜가 하루 밀리는 경우가 있었다.
6편에서 posts.ts에 ISO 문자열 강제 변환 로직을 넣어뒀기 때문에, JSON-LD 쪽에서는 추가 처리 없이 해결됐다. 이미 문자열로 정규화된 post.date를 다시 new Date()로 감싸는 것이라 문제가 없었다. 6편의 삽질이 7편을 살린 케이스다.
정리
| 항목 | 구현 방식 |
|---|---|
| 본문 복사 차단 | CSS user-select: none + JS copy 이벤트 차단 |
| 코드 블록 허용 | pre, code 태그에 user-select: text + DOM 트리 확인 |
| 코드 복사 버튼 | navigator.clipboard.writeText() + 시각적 피드백 |
| SEO 메타데이터 | 3계층 (루트 → 로케일 → 포스트) |
| JSON-LD | BlogPosting 스키마 |
| robots.txt | 전체 허용 + Yeti 별도 명시 + sitemap 선언 |
복사 방지와 SEO는 겉보기에 상충하는 요구사항이지만, 실제로는 작동 레이어가 다르다. CSS/JS는 브라우저에서만 동작하고, 크롤러는 HTML 소스만 본다. 이 차이를 이해하면 두 가지를 동시에 적용하는 데 아무 문제가 없다.