SEO 스플릿 테스트와 실험
추측이나 전후 비교 차트가 아니라 통제된 실험으로 무엇이 실제로 오가닉 트래픽을 움직이는지 증명한다.
블로그의 title 태그를 바꿨다. 2주 뒤 클릭이 8% 늘었다. 축하하는 Slack 메시지를 보낸다. 그런데 동료가 불편한 질문을 던진다. 그게 title 태그 때문인지 어떻게 아느냐?
알 수 없다. 그 2주 동안 Google은 공지 없이 랭킹을 손봤고, 경쟁사 하나는 도메인을 만료시켰으며, 당신의 카테고리는 평소대로 계절적 상승을 겪었고, 어떤 뉴스레터가 당신의 글 세 편으로 링크를 걸었다. 순진한 전후 비교 차트는 이 모든 것을 조용히 하나의 숫자로 뭉뚱그린 뒤 그 공을 당신의 변경에 돌린다. 이것이 SEO 팀이 스스로를 속이는 가장 흔한 방식이다.
해법은 신약 임상시험과 전환율 최적화에서 쓰는 것과 똑같다. 바로 **대조군(control group)**이다. 테스트 페이지와 동일한 알고리즘 업데이트, 동일한 계절성, 동일한 외부 잡음을 겪는 페이지 집합을 찾을 수 있고 — 그러면서 테스트 페이지에는 오직 변수 하나만 바꾼다면 — 두 그룹의 차이는 당신의 변경에 귀속시킬 수 있다. 이것이 SEO 스플릿 테스트이며, 진짜 실험을 몇 번 돌려보고 나면 전후 비교 차트를 영영 믿지 않게 된다.
💡 사고 모델: 당신은 트래픽이 올랐는지를 측정하는 것이 아니다. 트래픽이 어차피 올랐을 것보다 더 올랐는지를 측정하는 것이다. 그 “어차피”를 대조군이 대신 추정해 준다.
SEO A/B 테스트란
먼저 제품 분석에서 넘어온 개발자들이 빠지기 쉬운 혼동을 정리하자. 고전적인 A/B 테스트는 **사용자 단위(user-level)**다. 각 방문자가 무작위로 한 변형(variant)에 배정되고, 사용자 간 전환을 비교한다. SEO 랭킹에는 이것을 쓸 수 없다. 당신이 신경 쓰는 “사용자”는 Googlebot이고, 그것은 정확히 하나뿐이기 때문이다. 같은 URL에 대해 Googlebot에게 변형 A와 변형 B를 보여줄 수는 없다 — 그건 클로킹(cloaking)이고, 페널티를 받는다.
SEO 스플릿 테스트는 대신 **페이지 단위(page-level)**다. 무작위화의 단위는 방문자가 아니라 URL이다. 비슷한 페이지 집단을 가져와 대조군과 변형군으로 나누고, 변형군에 변경을 적용한 뒤, 시간이 지나며 두 그룹 사이에서 오가닉 성과가 어떻게 변하는지 비교한다.
| 사용자 단위 A/B (CRO) | 페이지 단위 스플릿 테스트 (SEO) | |
|---|---|---|
| 무작위화 단위 | 방문자 / 세션 | URL / 페이지 |
| 무엇이 달라지나 | 사용자마다 보이는 UI | 그룹 전체에 걸친 온페이지 요소 하나 |
| 누가 “보나” | 실제 사람 | 크롤러 + SERP를 통한 검색자 |
| 지표 | 전환율 | 클릭, 노출, CTR, 순위 |
| 잘못했을 때의 위험 | 나쁜 UX | 클로킹 페널티 (사용자별로 달리하면) |
이것을 작동하게 만드는 요건은 **비교 가능성(comparability)**이다. 대조군과 변형 페이지는 당신의 변경 외에 모든 면에서 통계적 쌍둥이처럼 행동해야 한다. 쌍둥이가 닮을수록 더 작은 효과까지 검출할 수 있다.
테스트 설계하기
좋은 설계는 페이지 하나를 건드리기 전에 거의 다 결정된다. 이 부분을 서두르면 아무리 영리한 분석을 해도 구해낼 수 없다.
1. 페이지 집단을 고른다. 템플릿을 공유하고 같은 의도(intent)를 충족하는 페이지 그룹이 필요하다 — 상품 상세 페이지, 레시피 페이지, 도시 랜딩 페이지, 한 카테고리의 블로그 글 등. 이미 꾸준한 오가닉 노출을 얻고 있어야 한다. 트래픽이 거의 0에 가까운 페이지는 신호를 주지 못한다. 수백 페이지면 편안한 하한선이고, 1만 페이지면 호사스럽다. 손으로 고른 열두 페이지는 테스트가 아니라 일화다.
2. 그룹으로 무작위 배정한다. 집단을 대략 50/50으로, 무작위로 나눈다. “개선하고 싶은 페이지”를 변형군에, “지루한 페이지”를 대조군에 넣지 마라 — 그런 편향은 오해를 부르는 결과를 보장한다. URL을 해시해서 배정을 결정적이고 재현 가능하게 만든다.
import hashlib
def assign_group(url: str, salt: str = "title-test-2026q2") -> str:
h = hashlib.sha256((salt + url).encode()).hexdigest()
# use one hex digit; even -> control, odd -> variant
return "variant" if int(h[-1], 16) % 2 else "control"
salt가 있으면 다음 실험을 위해 깔끔하게 재무작위화할 수 있어 같은 페이지가 항상 같은 버킷에 떨어지지 않는다.
3. 정확히 변수 하나만 바꾼다. title을 다시 쓰고 동시에 meta description도 바꾸고 동시에 FAQ 스키마까지 추가하면, 이겼더라도 어느 레버가 움직였는지 전혀 알 수 없다. 테스트당 변수 하나. 여기서의 규율이 측정과 분위기(vibes)를 가른다.
4. 기간과 유의성을 미리 정한다. 시작 전에 결정하라. 얼마나 오래 돌릴지, 무엇을 승리로 칠지. 검색 효과는 느리다 — Google이 재크롤하고 재색인해야 하며, 순위는 며칠에 걸쳐 안정된다. 최소 4~6주는 돌리고, 이상적으로는 주간 사이클 두어 번에 걸치게 하라. 무엇보다 종료일을 미리 못 박아라. 매일 들여다보다가 선이 좋아 보이는 순간 멈추는 것은 “p-해킹(p-hacking)“이다 — 순전히 우연만으로도 대략 두 번에 한 번꼴로 가짜 승자를 발견하게 된다.
5. 외부 요인을 통제한다. 알려진 코어 업데이트가 있는 주에 테스트를 시작하지 마라. 가능하면 계절적 변동의 정점은 피하라. 실험 기간 동안 게시 주기, 내부 링크 변경, 백링크 캠페인을 두 그룹에 걸쳐 일정하게 유지하라. 대조군은 공유되는 잡음을 흡수하지만, 한 그룹에만 닥치는 충격은 흡수하지 못한다.
⚠️ 주의: 서면 로그를 남겨라 — 시작일, 종료일, 가설, 정확한 변경 내용, 그룹 배정, 그리고 당신이 알아챈 외부 사건들. 6주는 당신이 무엇을 했는지 반드시 잊어버릴 만큼 긴 시간이고, 문서화되지 않은 테스트는 재현 불가능하다.
무엇을 테스트할까
SERP가 당신의 페이지를 어떻게 렌더링하는지, 혹은 크롤러가 페이지를 어떻게 이해하는지에 영향을 주는 것은 모두 후보다. 대체로 레버리지가 큰 것부터 미묘한 것 순으로 정리했다.
| 요소 | 검증하는 가설 | 지켜볼 주요 지표 |
|---|---|---|
| title 태그 | 키워드를 앞으로 배치 / 숫자 추가 / 대괄호 수식어가 CTR을 끌어올린다 | CTR, 이어서 클릭 |
| meta description | 가치 제안을 더 명확히 하면 같은 순위에서 더 많은 클릭을 얻는다 | CTR |
| H1 | 온페이지 헤드라인을 쿼리 의도에 맞추면 관련성에 도움이 된다 | 노출, 순위 |
| 구조화 데이터 | FAQPage / Product / HowTo 추가가 리치 결과를 얻는다 | 노출, CTR |
| 콘텐츠 포맷 | TL;DR 블록, 표, 단계 목록이 참여와 관련성을 높인다 | 순위, 클릭 |
| 내부 링크 | 권위 있는 페이지에서 맥락 링크를 추가하면 순위가 오른다 | 순위, 노출 |
| 브레드크럼 | BreadcrumbList 마크업이 SERP에서 URL 줄의 렌더링을 바꾼다 | CTR |
유용한 구분: title과 meta description 테스트는 CTR을 움직인다 — 검색자가 보고 클릭하는 것을 바꾸며, 순위 위치에는 영향이 거의 없다. H1, 내부 링크, 콘텐츠 테스트는 순위와 노출을 움직인다 — 페이지가 어떻게 랭크되고 노출되는지를 바꾼다. 어느 레버를 당기는지 알면 어느 지표가 반응해야 하는지가 보이고, 엉뚱한 지표가 움직이는 결과는 다른 무언가가 작용하고 있다는 적신호다.
🧑💻 개발자 관점: 구조화 데이터 테스트는 특히 깔끔하게 돌릴 수 있다. 변경이 순전히 마크업에만 있어 그룹 전체에 템플릿으로 적용하기 쉽고, 그 효과(리치 결과 여부)가 노출과 CTR 곡선에서 선명하게 드러나는 경우가 많기 때문이다.
측정하기
Google Search Console이 당신의 계측기다. 성과(Performance) 보고서 — 그리고 특히 그 API와 대량 Search Console BigQuery 익스포트 — 는 페이지별·일별로 클릭, 노출, CTR, 평균 순위를 준다. 그 일별, URL별 세밀도가 바로 스플릿 테스트에 필요한 것이다.
핵심 기법은 **대조군 대비 정규화(normalization)**다. 변형군의 원시 수치를 단독으로 보는 일은 절대 없다. 원시 수치는 우리가 논한 모든 것에 오염되어 있기 때문이다. 대신 시간에 따른 변형군 대 대조군의 *비율(ratio)*을 추적한다.
For each day d:
ratio[d] = clicks_variant[d] / clicks_control[d]
변경이 적용되기 전 ratio는 대체로 평평해야 한다 — 그게 당신의 기준선이며, 두 그룹이 애초에 비교 가능했음을 증명한다. 변경 이후 ratio의 지속적인 이동이 곧 효과다. 두 그룹 모두 같은 코어 업데이트, 같은 계절성, 같은 뉴스 사이클을 타기 때문에 그 공유된 힘들은 비율에서 대체로 상쇄된다. 남는 것이 당신의 변수다.
GSC BigQuery 익스포트에서 가져온 최소 스케치:
SELECT
data_date,
SUM(IF(g.is_variant, clicks, 0)) AS variant_clicks,
SUM(IF(NOT g.is_variant, clicks, 0)) AS control_clicks,
SAFE_DIVIDE(
SUM(IF(g.is_variant, clicks, 0)),
SUM(IF(NOT g.is_variant, clicks, 0))
) AS variant_to_control
FROM `searchconsole.searchdata_url_impression` AS s
JOIN `my_dataset.group_assignment` AS g USING (url)
GROUP BY data_date
ORDER BY data_date;
variant_to_control을 시간에 대해 그리고 출시일을 표시하라. 유지되는 명확한 계단 상승은 승리다. 출시선에서 끊김 없이 떠도는 곡선은 무효(null) 결과다.
신뢰도에 관하여: 차트를 눈대중하는 것은 시작일 뿐 결론이 아니다. 잡음으로 가득한 두 페이지도 실눈을 뜨면 계단처럼 보일 수 있다. 일별 비율을 표본으로 취급하고, 사전 기간 분포를 사후 기간 분포와 비교한 뒤, 그 이동이 당신이 아무것도 바꾸기 전에 봤던 일별 흔들림에 비해 큰지 물어라. 사전 기간 비율이 일별로 ±15%씩 출렁였다면, 사후 기간의 5% 상승은 잡음이다.
이것의 엄밀한 버전이 CausalImpact — Google의 오픈소스 라이브러리로 대중화된 베이지안 구조적 시계열(Bayesian structural time-series) 접근법이다. 발상은 우아하다. 대조군을 예측 변수로 넣으면, 사전 기간 동안 대조군과 변형군 사이의 관계를 학습한다. 그런 다음 아무것도 바뀌지 않았다면 출시 후 변형군이 어떻게 했을지 — 즉 반사실(counterfactual) — 를 투영하고, 그 예측과 현실 사이의 간극을 신용 구간(credible interval)과 함께 보고한다. 구간이 0을 포함하지 않으면 통계적으로 방어 가능한 효과가 있는 것이다.
library(CausalImpact)
# response = variant clicks; covariate = control clicks (the predictor)
data <- zoo(cbind(variant_clicks, control_clicks), dates)
pre <- c(start_date, launch_date - 1)
post <- c(launch_date, end_date)
impact <- CausalImpact(data, pre, post)
summary(impact) # relative effect + 95% credible interval
plot(impact) # observed vs. counterfactual
이것이 “오른 것처럼 보인다”와 “클릭 +6.3%, 95% 구간 [+2.1%, +10.4%], p = 0.004”의 차이다. 둘 중 하나는 회의적인 이해관계자 앞에서 살아남고, 다른 하나는 그렇지 못하다.
함정
실패한 테스트는 대부분 손에 꼽을 만한 구조적 이유 하나 때문에 실패한다. 이것들을 경계하라.
- 표본이 너무 작다. 한 줌의 페이지, 혹은 노출이 졸졸 흐르는 페이지로는 일별 잡음을 이길 수 없다. 사전 기간 비율이 심하게 튄다면, 거대한 효과가 아닌 한 무엇도 검출할 만한 물량이 부족한 것이다. 페이지를 더 모으거나 트래픽이 더 높은 집단을 골라라.
- 테스트 기간이 너무 짧다. 검색은 재크롤·재색인 지연을 두고 반응하고, 이어서 순위가 안정된다. 2주에서 멈추면 정상 상태가 아니라 과도기를 측정하게 될 수 있다. 최소 4~6주는 줘라.
- 테스트 중간에 코어 업데이트가 떨어진다. 업데이트가 한 그룹의 쿼리 구성을 더 세게 때리게 되면 그룹을 불균등하게 재편할 수 있다. 확인된 업데이트가 당신의 기간에 떨어지면, 그것을 표시하고, 사전 기간의 평행성이 깨졌는지 면밀히 살피고, 폐기 후 재실행할 준비를 하라.
- 그룹이 비교 가능하지 않다. 조용한 살인자다. 당신의 “무작위” 분할이 실은 페이지 나이, 트래픽 등급, 하위 주제와 상관되어 있었다면, 비율은 당신의 변경과 무관한 이유로 표류한다. 사후 기간을 믿기 전에 사전 기간 비율이 평평한지 항상 검증하라. 평평하지 않은 기준선은 테스트 전체를 무효로 만든다.
- 그룹 간 누수. 내부 링크, 사이트 전역 템플릿 변경, 사이트맵 재배치가 변형군의 변경을 대조군 페이지로 번지게 할 수 있다. 변경은 변형 집합으로 엄격히 한정하라.
- 들여다보고 일찍 멈추기. 이미 언급했지만 반복할 가치가 있다. 종료일을 미리 정하고 그것을 지켜라.
🧑💻 구현
템플릿을 포크하거나 잠긴 CMS를 건드리지 않고 어떻게 실제로 페이지 절반에 변형을 서빙할까? 엣지에서다. 이 사이트는 이미 Cloudflare 위에서 돌아가므로, Worker가 각 URL을 결정적으로 그룹에 배정하고 변형 페이지의 <head>를 즉석에서 다시 쓸 수 있다 — 엣지 SEO 가이드에서 다룬 것과 같은 HTMLRewriter 기법이다.
Worker는 URL을 해시하고(분석 코드와 동일한 결정적 배정), 변형 페이지에 대해 title과 meta description을 새 패턴으로 다시 쓴다.
async function assignGroup(url, salt = "title-test-2026q2") {
const data = new TextEncoder().encode(salt + url);
const digest = await crypto.subtle.digest("SHA-256", data);
const lastByte = new Uint8Array(digest).at(-1);
return lastByte % 2 ? "variant" : "control";
}
class TitleRewriter {
element(el) { el.setInnerContent(this.newTitle); }
constructor(newTitle) { this.newTitle = newTitle; }
}
export default {
async fetch(request) {
const res = await fetch(request);
const url = new URL(request.url);
// only experiment on the chosen template; pass everything else through
if (!url.pathname.startsWith("/products/")) return res;
if ((await assignGroup(url.pathname)) !== "variant") return res;
const newTitle = await buildVariantTitle(url); // your variant pattern
return new HTMLRewriter()
.on("title", new TitleRewriter(newTitle))
.on('meta[name="description"]', {
element(el) { el.setAttribute("content", buildVariantDescription(url)); },
})
.transform(res);
},
};
이것을 안전하게 만드는 두 가지가 있다. 첫째, 배정은 방문자가 아니라 URL 기준이다 — 특정 페이지로 가는 모든 요청(사람이든 Googlebot이든)이 같은 변형을 받으므로 클로킹이 아니다. 둘째, 배정은 결정적이며 당신의 SQL/R 분석이 쓰는 해시와 일치하므로, 서빙 시점에 페이지가 속한 그룹은 정확히 당신이 그것을 측정하는 그룹이다. 동일한 salt와 배정 함수를 BigQuery group_assignment 테이블로 익스포트하면 양쪽이 완벽하게 동기화된다.
⚠️ 주의: 테스트가 시작될 때 배정을 영원히 재계산하는 대신 내구성 있는 어딘가에 로그로 남겨라. salt나 URL 집합을 한 번이라도 바꾸면, 재계산이 페이지를 조용히 재배치해 분석을 오염시킨다. 첫날에 그룹의 스냅샷을 떠라.
테스트가 끝나면 롤백은 사소하다. 한 줄짜리 변경을 배포해 Worker가 승리한 변형을 모든 페이지에 서빙하게 하거나, Worker 규칙을 통째로 제거하면 된다. 오리진 배포도, 템플릿 포크도 없이 완전히 되돌릴 수 있다.
핵심 요약
- ✅ 전후 비교 차트를 절대 믿지 마라 — 무작위 대조군을 써서 공유된 잡음(업데이트, 계절성)이 상쇄되게 하라.
- ✅ 방문자가 아니라 URL로 분할하라. 사용자별로 콘텐츠를 달리하는 것은 클로킹이다.
- ✅ 수백 페이지 이상의 비교 가능한 집단에서 변수 하나만 바꾸고, 무엇이든 믿기 전에 사전 기간 비율이 평평한지 검증하라.
- ✅ 종료일과 유의성 기준을 미리 정하라 — 4~6주를 돌리고 들여다보다 멈추지 마라.
- ✅ 대조군 대비 정규화한 GSC 데이터로 측정하고, 신뢰 구간이 있는 방어 가능한 반사실을 위해 CausalImpact를 써라.
- ✅ Cloudflare Worker로 엣지에서 변형을 서빙해 테스트를 템플릿 없이, 결정적으로, 즉시 되돌릴 수 있게 하라.