접근성 검사기를 하나 돌린다고 해봅시다. 사이트 주소를 넣으면, 검사기는 어느 페이지를 봐야 할까요?
홈 하나만 보면 될까요? 그러면 “이 사이트는 접근성이 좋다/나쁘다"라고 말할 근거가 약합니다. 그렇다고 전부 보자니, 게시판·블로그·쇼핑몰은 글이 수백에서 수천 개예요. 다 검사하면 시간도 비용도 감당이 안 됩니다.
저는 접근성 자동 검사 서비스 A11y Check를 만들면서 이 질문에 계속 걸렸습니다. 이 글은 “본문을 하나도 내려받지 않고, URL과 사이트맵 신호만으로 사이트를 대표하는 페이지들을 결정적으로 뽑는” 알고리즘을 만든 기록입니다. 나중에 별도 패키지로 떼어낼 수도 있겠다 싶어, 처음부터 입출력 의존 없는 순수 함수로 설계했어요.

사진: qiwei yang / Unsplash
바퀴는 이미 있다 — WCAG-EM 2.0#
다행히 이건 맨땅에서 시작할 문제가 아닙니다. W3C의 WCAG-EM(Website Accessibility Conformance Evaluation Methodology, 웹 접근성 평가 방법론)이 “사이트 전체를 어떻게 표본 추출해 평가하나"를 이미 정의해두고 있거든요. 마침 얼마 전 WCAG-EM 2.0으로 개정되기도 했고요. 핵심은 두 종류의 표본입니다.
- 구조 표본(structured sample): 공통 페이지(홈·로그인·문의 등) + 각 페이지 유형(레이아웃)의 대표 + 핵심 기능 페이지.
- 무작위 표본(random sample): 구조 표본의 약 10%를 무작위로 더 뽑아, 구조 표본이 놓친 문제가 없는지 대표성을 검증.
여기서 방법론의 본질은 층화 표집(stratified sampling)입니다. 층화 표집은 모집단을 성질이 비슷한 ‘층’으로 나눈 뒤 각 층에서 표본을 뽑는 방식이에요. 여론조사에서 연령대·지역별로 나눠 뽑는 그것과 같습니다. 사이트를 “페이지 유형"이라는 층으로 나누고, 각 층에서 대표를 뽑는 거죠.
문제는 이 “유형 나누기"를 사람이 아니라 코드가 자동으로 해야 한다는 겁니다. 여기서부터가 제 몫이었어요.
순진한 첫 버전의 한계#
제 초기 구현은 페이지를 이렇게 분류했습니다.
function categorizePage(url) {
if (/login|signin|로그인/.test(path)) return "login";
if (/contact|문의/.test(path)) return "contact";
if (/privacy|terms|약관/.test(path)) return "legal";
// ...
return "content"; // 나머지 전부
}문제가 보이시나요? 게시글, 상품, 뉴스, 포스트가 전부 content 하나로 뭉개집니다. 게시판에 글이 100개 있어도 알고리즘은 그걸 “content 유형 100개"가 아니라 그냥 “잡다한 페이지 100개"로 봐요. 그러니 표본을 뽑을 때 게시글 하나가 대표로 확실히 들어간다는 보장이 없었습니다.
그런데 사용자 입장에서 게시판·블로그의 개별 글은 접근성 문제가 가장 많이 생기는 곳입니다. 에디터로 작성한 본문, 첨부 이미지의 대체 텍스트, 표 구조… 사람이 손으로 채우는 곳이라 그래요. 이걸 검사 표본에서 빠뜨리면 정작 봐야 할 걸 안 보는 셈이죠.
목표가 분명해졌습니다.
- 게시판·포스트·상품 같은 반복 콘텐츠를 하나의 “유형"으로 인식할 것.
- 그 안에서 대표 글을 최소 1개 이상, 검사할 페이지 수에 비례해 뽑을 것.
아이디어 ① — URL 템플릿 클러스터링#
반복 콘텐츠를 감지하는 정석은 near-duplicate 탐지입니다. 문서를 잘게 쪼개 지문을 비교하는 shingling·SimHash·MinHash 같은 기법이죠. 그런데 이건 전부 본문을 내려받아야 합니다. 페이지 수백 개를 미리 fetch하는 건 시간 예산상 불가능해요.
그런데 가만 보면, 같은 레이아웃에서 찍어낸 페이지는 URL 구조가 똑같습니다.
/blog/123
/blog/456
/blog/hello-world경로의 세그먼트를 토큰으로 쪼개서 “가변부"를 플레이스홀더로 바꾸면, 이것들은 전부 하나의 템플릿 키로 묶입니다.
/blog/{slug}이 키가 같은 URL 집합이 곧 하나의 “반복 콘텐츠 클러스터"예요. 본문을 한 번도 안 열고, URL만 보고 판단합니다. (학계에서는 이걸 URL pattern learning이라 부릅니다 — WSDM 2010의 “Learning URL Patterns for Webpage De-duplication” 같은 연구가 있어요.)

본문을 열지 않고 URL 구조만으로 반복 콘텐츠를 유형화합니다.
세그먼트 정규화 규칙#
각 경로 세그먼트를 이런 우선순위로 판정합니다.
| 세그먼트 예 | 판정 | 치환 |
|---|---|---|
f47ac10b-58cc-4372-... | UUID | {uuid} |
2024-01-31 | 날짜 | {date} |
2024 | 연도(19xx/20xx) | {year} |
123, 000123 | 숫자 | {n} |
9f8c2ab1e4d7... | 해시류(hex 12자 이상 등) | {hash} |
my-first-post | slug(하이픈·길이·영숫자 혼합) | {slug} |
about, login | 그 외 | 리터럴 유지 |
function segmentToken(seg: string, index: number): string {
const s = seg.toLowerCase();
if (UUID_RE.test(s)) return "{uuid}";
if (DATE_RE.test(s)) return "{date}";
if (YEAR_RE.test(s)) return "{year}"; // ^(19|20)\d{2}$
if (DIGITS_RE.test(s)) return "{n}";
if (isHash(s)) return "{hash}";
if (isSlug(s, index)) return "{slug}";
return s; // 가변부가 아니면 소문자 리터럴
}오탐을 막는 세 가지 규칙#
여기서 진짜 중요한 건 오탐 최소화입니다. 아무거나 플레이스홀더로 바꾸면 서로 다른 페이지가 한 클러스터로 뭉쳐서 층화가 무너지거든요. 몇 번 데여보고 넣은 규칙들입니다.
1) 첫 세그먼트는 절대 slug로 치환하지 않는다. /blog/*와 /products/*가 둘 다 /{slug}/*로 뭉개지면 서로 다른 섹션이 한 클러스터가 됩니다. 그래서 slug 판정은 index >= 1(두 번째 세그먼트부터)에서만 동작해요.
2) 짧고 사전적인 세그먼트는 리터럴로 남긴다. /contact-us를 slug로 보면 안 되겠죠. 그래서 slug 판정에 “길이 8자 이상 && (하이픈 2개 이상 || 영문+숫자 혼합)“이라는 임계를 뒀습니다.
3) 연도는 범위로 검증한다. 2024는 {year}지만 3000은 그냥 {n}입니다. ^(19|20)\d{2}$로 실제 연도만 잡아요.
페이지네이션은 접는다#
목록 페이지의 ?page=2, ?sort=desc 같은 파라미터는 같은 페이지의 다른 뷰입니다. 이걸 별도 URL로 세면 목록 페이지 하나가 클러스터를 오염시켜요. 그래서 페이지네이션·정렬 파라미터를 미리 접어둡니다.
export const PAGINATION_PARAMS = new Set([
"page", "p", "pg", "paged", "offset", "start", "sort", "order", // ...
]);
// /list?page=2 와 /list?page=3 이 같은 URL로 접힌다
normalizeForDedup("https://e.com/list?page=2"); // → "https://e.com/list"
아이디어 ② — 층화 + 한도 비례 배분#
클러스터를 감지했으면, 이제 한정된 예산(한번에 검사할 수 있는 페이지 수)을 어떻게 나눌지가 문제입니다. 두 라운드로 배분해요.

Round-1은 유형별 대표를 1개씩 확보하고, Round-2는 남은 자리를 크기 비례로 나눕니다.
Round-1: 커버리지 (각 유형 1개씩)#
우선순위 순서대로 한 슬롯씩 채웁니다.
홈 → 로그인 → 문의
→ 반복 클러스터 대표 1개씩 (크기 큰 순)
→ 나머지 공통 유형(폼·도움말·약관·검색)
→ 정적 콘텐츠이 순서 덕분에 한 번에 5개만 검사해도 큰 클러스터 두 개의 대표는 확보됩니다. 로그인·문의가 클러스터보다 앞이라 항상 살아남고요.
Round-2: 크기 비례 배분 (최대잔여법)#
Round-1을 채우고 예산이 남으면, 남은 예산을 클러스터 크기에 비례해 추가 배분합니다. 여기서 최대잔여법(Largest Remainder, 일명 Hamilton method)을 씁니다. 의석을 정당 득표율에 비례해 나눌 때 쓰는 그 방법이에요. 각자 비례 몫의 정수부를 먼저 받고, 남은 의석(여기선 표본 슬롯)을 소수부가 큰 순서로 하나씩 나눠주는 방식입니다.
한 클러스터가 표본을 독식하지 못하도록 클러스터별 상한 = ceil(max/2)을 두고, 상한에 걸려 남은 몫은 여유 있는 클러스터로 재분배해요.
예를 들어 게시판이 상품(글 120개)·블로그(30개)·공지(12개) 세 유형일 때, 한 번에 몇 개를 검사하느냐에 따라 이렇게 나뉩니다.
| 한 번에 검사할 페이지 수 | 배분 결과 |
|---|---|
| 5개 | 홈 + 로그인 + 문의 + 상품 1 + 블로그 1 |
| 10개 | 홈 + 로그인 + 문의 + 상품 1 + 블로그 1 + 공지 1 + 폼 + 도움말 + 약관 + 소개 |
| 20개 | Round-1 대표들 + 남은 자리를 크기 비례로 → 상품 8 · 블로그 3 · 공지 1 |
20개짜리를 따라가 볼게요. Round-1에서 각 유형이 대표 1개씩(상품·블로그·공지) 가져간 뒤 남은 9자리를 120/30/12 비례로 나누면 6.67 / 1.67 / 0.67, 정수부만 먼저 주면 6 / 1 / 0(합 7). 남은 2자리를 소수부 큰 순으로 상품·블로그에 하나씩 → 7 / 2 / 0. 여기에 Round-1 대표 1개씩을 더하면 상품 8 · 블로그 3 · 공지 1이 됩니다.
대표 글은 무엇으로 뽑나#
클러스터 안에서 어떤 글을 대표로 뽑을까요? 사이트맵의 신호를 씁니다. 사이트맵(sitemap.xml)은 사이트가 검색엔진에 자기 페이지 목록을 알려주는 파일인데, 각 URL마다 마지막 수정일(<lastmod>)과 상대적 중요도(<priority>)를 함께 적을 수 있어요.
정렬 우선순위: lastmod 최신 → priority 높은 순 → 결정적 해시그래서 사이트맵을 <loc> 하나만 훑는 게 아니라 <url> 블록 단위로 파싱하도록 고쳐, 형제 요소인 <lastmod>·<priority>까지 함께 읽습니다. 최신 글·중요한 글이 대표로 우선되는 거죠.
결정성 — 같은 사이트는 항상 같은 표본#
이게 은근히 중요합니다. 같은 사이트를 두 번 검사했는데 표본이 매번 달라지면, 점수 추이를 신뢰할 수 없거든요. “지난주보다 3점 올랐다"가 개선 때문인지 표본이 바뀌어서인지 알 수가 없죠.
그래서 모든 정렬의 마지막 tiebreak(동점 처리)를 URL 콘텐츠 해시로 고정했습니다. FNV-1a라는 가볍고 빠른 해시 함수를 사이트 주소로 시드해서 쓰는데, 결과는 무작위처럼 흩어지지만 같은 입력이면 항상 같은 값이라 완전히 재현 가능해요.
// lastmod·priority가 전혀 없어도 해시로 결정적 선택 → 재현성 보장
return hashString(rootUrl + "|" + a.url) - hashString(rootUrl + "|" + b.url);실전에서 만난 함정 — apex와 www#
여기까지 만들고 실제 사이트들을 돌려봤는데, 하필 제 블로그(codeslog.com)만 첫 페이지 하나밖에 안 잡혔습니다. 접근성 검사 알고리즘을 만들었더니 정작 접근성 블로그가 표본 0이라니, 웃어야 할지.
원인을 파보니 이랬어요.
codeslog.com은www.codeslog.com으로 리다이렉트하지 않습니다. apex(www 없는 최상위 도메인)도 200을 그냥 줘요.- 그런데 홈 HTML이 선언한 대표 주소(
og:url)는https://www.codeslog.com/을 가리키고, - 사이트맵의 실제 콘텐츠 URL이 전부 www 오리진에 있었습니다.

리다이렉트를 안 하는 사이트에서 후보가 0개가 되던 함정을, 선언된 canonical을 안전 범위에서만 채택해 해결했습니다.
같은 오리진(origin, 스킴+호스트+포트)만 크롤 대상으로 거르는데, codeslog.com과 www.codeslog.com은 호스트가 달라 남남으로 취급된 거죠. 원래 리다이렉트를 따라가 대표 도메인을 잡는 로직이 있었는데, 이 사이트는 리다이렉트를 안 하니까 그게 안 걸렸습니다.
해결은 이렇게 했어요. 리다이렉트가 없을 때는 루트 HTML이 스스로 선언한 대표 URL(<link rel="canonical"> 우선, 없으면 og:url)을 보고, 그게 www 변형이면 호스트를 교체해 대표 루트로 삼도록.
// 리다이렉트로 www가 안 잡혔으면, HTML이 선언한 canonical/og:url을 본다
if (canonicalRoot === rootUrl && rootHtml) {
const declared = extractDeclaredCanonical(rootHtml); // <link canonical> → og:url
const norm = declared ? normalizeUrl(declared, rootUrl) : null;
if (norm && isWwwVariant(norm, rootUrl)) {
const adopted = new URL(rootUrl);
adopted.hostname = new URL(norm).hostname; // 호스트만 교체, 경로는 유지
canonicalRoot = adopted.toString();
}
}여기서 보안을 한 겹 챙겨야 합니다. 페이지가 선언한 canonical을 무조건 믿으면 안 돼요. 악의적 페이지가 og:url에 evil.com을 박아두면 크롤러를 엉뚱한 곳으로 끌고 갈 수 있거든요. 그래서 “같은 등록 도메인에서 www 접두 차이"일 때만 채택하도록 제한했습니다(isWwwVariant). apex와 www는 정의상 같은 사이트이니 안전하죠.
고치고 나서 codeslog.com을 다시 돌린 결과입니다.
source = sitemap
clusters:
/posts/{slug} 61개 중 대표
/en/posts/{slug} 54개 중 대표
/tags/{hash} 14개 중 대표
/series/{slug} 10개 중 대표
...apex 주소만 넣어도 www에 있는 게시글·태그·시리즈를 유형별로 잡아 대표를 뽑습니다.
한계와 다음 단계#
정직하게 남는 것들도 적어둘게요.
- 템플릿 오탐: 퍼센트 인코딩된 한글 태그(
/tags/%EC...)는{hash}로,/tags/wcag-2.2는{slug}로 잡혀 같은 태그 페이지가 두 클러스터로 갈립니다. 위 codeslog 결과에서/tags/{hash}와/tags/{slug}가 따로 나온 게 바로 이 경우예요. 기능상 문제는 없지만 더 정교화할 여지가 있습니다. - 사이트맵이 없는 사이트: 목록 페이지를 얕게(2-hop) 열어 개별 글 링크를 얻는 옵션을 만들어 뒀지만, 시간 예산 보호를 위해 기본은 꺼둔 상태입니다.
- near-duplicate까지는 안 갑니다: URL 신호만으로도 충분히 실용적이지만, URL이 의미를 안 담는 사이트(예:
?id=쿼리로만 글을 구분하는 옛날 게시판)에는 약해요.
한 장 요약#
- 문제: 사이트를 검사할 때 전부 볼 수도, 홈만 볼 수도 없다. 대표 페이지를 골라야 하는데, 그 기준을 코드가 자동으로 정해야 한다.
- 기준: W3C WCAG-EM 2.0의 층화 표집 — 사이트를 유형으로 나누고 각 유형의 대표를 뽑는다.
- 아이디어 ①: URL 템플릿 클러스터링 — 경로의 가변부를 플레이스홀더로 치환해, 본문 없이 URL 구조만으로 반복 콘텐츠를 유형화한다.
- 아이디어 ②: 층화 + 최대잔여법(Hamilton) 배분 — Round-1로 유형별 대표를 1개씩 확보하고, Round-2로 남은 예산을 크기에 비례해 공정하게 나눈다.
- 마무리: 콘텐츠 해시 tiebreak로 결정성을 보장해 같은 사이트는 항상 같은 표본. apex↔www 함정은 선언된 canonical을 안전 범위에서만 채택해 해결.
crawlee·sitemapper처럼 좋은 크롤러·파서는 많지만, “접근성 평가를 위한 대표 표본 선정"을 다루는 도구는 의외로 없더라고요. 그래서 직접 만들었고, 핵심 로직은 입출력·프레임워크 의존이 없는 순수 함수로 짜서 나중에 독립 패키지로 떼어낼 수 있게 남겨뒀습니다.
A11y Check의 검사 엔진(@a11ychk/core)은 Apache-2.0으로 공개돼 있습니다. 이 글의 구현은 GitHub 저장소의 packages/core/src/crawler 아래 urlTemplate.ts·stratifiedSample.ts에 있어요. 접근성 도구를 만들면서 정작 도구 자신의 크롤러가 접근성 블로그를 못 읽던 이야기가, 누군가의 표본 추출 삽질을 한 번 줄여주면 좋겠습니다.
질문으로 다시 보기#
왜 사이트의 모든 페이지를 검사하지 않나요?
본문을 안 열고 어떻게 반복 콘텐츠를 알아채나요?
같은 사이트를 다시 검사하면 표본이 바뀌나요?
참고 자료#
- WCAG Evaluation Methodology (WCAG-EM) 2.0 — W3C Group Note, 층화 표집 방법론
- Learning URL Patterns for Webpage De-duplication — WSDM 2010, URL 패턴 학습
- A11y Check GitHub — 검사 엔진(
@a11ychk/core, Apache-2.0)
