포트폴리오에 연락처를 실어야 했습니다. 이메일을 그대로 적으면 정적 HTML 에 완성된 주소가 남고, 크롤러 입장에서는 파일 하나를 내려받는 것으로 수집이 끝납니다. 요구 조건은 두 가지였습니다. 사람은 주소를 읽고 복사하고 눌러서 메일을 보낼 수 있어야 하고, 자동 수집에는 온전한 주소가 남지 않아야 합니다.
기존 방법들의 한계
| 방법 | 걸리는 곳 |
|---|---|
| 이미지로 넣기 | 복사할 수 없고 스크린 리더가 읽지 못함. 확대하면 흐려짐 |
| 문의 폼으로 대신하기 | 서버가 필요함. 정적 사이트라면 구조부터 바뀜 |
| CSS 로 글자 순서 뒤집기 | 화면에는 바로 보이지만 복사하면 뒤집힌 문자열이 붙음 |
(at)·[dot] 표기 | 사람도 손으로 고쳐 써야 하고, 수집기는 이미 이 표기를 되돌림 |
| 자바스크립트로만 그리기 | 스크립트를 끄면 연락처가 사라짐 |
공통점이 있습니다. 사람이 쓰는 편의를 줄여서 자동 수집을 막으려 한다는 것입니다. 그래서 반대 방향을 택했습니다. 사람에게는 평범한 텍스트 그대로 두고, 자동 수집에만 조각난 형태로 보이게 하는 방식입니다.
적용한 세 가지 방어
- 조각을 다른 요소로 나눕니다. 아이디·
@·도메인이 각각 다른<span>입니다. 원문을 정규식으로 검색하는 크롤러에는 이어진 주소가 없습니다. - 화면에 보이지 않는 미끼 문자열을 넣습니다. 아이디 뒤에
display:none인 조각을 하나 둡니다. 태그를 지우고 본문만 이어 붙이는 크롤러는 배달되지 않는 주소를 가져갑니다. - 메일 링크는 마운트 후에 추가합니다. 정적 HTML 에는
mailto:주소가 없습니다. 브라우저에서 조각을 합쳐 넣습니다.
export default function EmailAddress() {
const [href, setHref] = createSignalundefined>(undefined);
createEffect(
() => emailHref(),
(value) => {
setHref(value);
},
);
return (
class="email" href={href()} rel="nofollow">
{profile.email.user}
class="email__decoy">{profile.email.decoy}
@
{profile.email.domain}
);
} 조각은 콘텐츠 파일에 user·decoy·domain 으로 들어 있습니다. 어느 파일에도 완성된 주소를 적지 않습니다. 조각을 합치는 함수는 한 곳뿐이고 브라우저에서만 호출됩니다.
정적 HTML 에 남는 마크업
| 보는 쪽 | 얻는 것 |
|---|---|
| 사람이 화면에서 읽기 | 정상 주소 |
| 마우스로 끌어 복사 | 정상 주소(숨은 조각은 복사되지 않음) |
| 스크린 리더 | 정상 주소 |
| 원문을 정규식으로 검색 | 검출되지 않음(요소로 끊겨 있음) |
| 태그를 지우고 본문만 잇기 | soma0sd.no-spam@gmail.com - 배달되지 않음 |
mailto: 링크 수집 | 없음(문서에 링크 주소가 없음) |
미끼 문자열이 사람에게 노출되지 않는 이유
요소를 감추는 방법은 여러 가지인데, 여기서는 display:none 이어야 합니다. 이 값은 해당 요소를 렌더 트리와 접근성 트리 양쪽에서 제외합니다. 선택 대상이 아니므로 복사에 포함되지 않고, 스크린 리더도 읽지 않습니다.
visibility: hidden은 자리를 차지한 채 감춥니다. 화면에 빈칸이 생깁니다.opacity: 0·font-size: 0은 여전히 선택·복사 대상입니다. 사람이 주소를 복사하면 미끼 문자열까지 함께 복사됩니다.- 화면 밖으로 밀어내는 방식(
position: absolute; left: -9999px)은 스크린 리더가 그대로 읽습니다. 이 자리에 감춘 문자열을 두면 접근성을 해칩니다.
감춘 조각이 사람에게 한 번이라도 보이면, 그 순간부터 잘못된 주소를 알려 주는 셈이 됩니다. 감추는 방법을 고르는 일이 이 방식의 핵심입니다.
메일 링크는 마운트 후에 추가
서버 렌더 결과에 href 를 넣으면 조각으로 나눈 주소가 링크 속성 하나로 완성되어 남습니다. 그래서 링크는 브라우저에서 추가합니다. 프리렌더 결과와 첫 마크업이 같아야 한다는 제약과도 맞습니다.
자바스크립트가 꺼져 있으면 링크가 아니라 텍스트로 남습니다. 눌러서 메일 앱이 열리지는 않지만 주소는 그대로 읽히고 복사됩니다. 앞서 본 방법들과 달리, 스크립트가 실행되지 않아도 연락처가 사라지지 않습니다.
방어가 무효화되는 경우
- 링크 목록에 이메일을 다시 넣는 것. 그 목록은 정적 HTML 에
href가 그대로 나갑니다. 한 줄 추가로 전부 무효가 됩니다. - 구조화 데이터에 넣는 것. 검색 결과 표시를 늘리려고 JSON-LD 에
email을 적으면, 사람이 읽는 자리에서 가린 주소를 기계가 읽는 자리에 그대로 제공하게 됩니다. - 조각을 합치는 함수를 서버에서 호출하는 것. 프리렌더 중에 한 번이라도 호출되면 완성된 주소가 산출물에 포함됩니다.
규칙을 기억해서 지키는 대신 빌드 후에 검사합니다. 산출물에 mailto: 도, 이어진 주소도 없어야 합니다.
grep -r "mailto:" out/ | grep -v ".js:"
grep -rE "[a-z0-9_.]+@[a-z0-9.]+\.[a-z]{2,}" out/ --include="*.html"차단 범위와 한계
한계를 분명히 적어 둡니다. 이 방식이 막는 것은 HTML 원문을 받아 정규식으로 검색하는 크롤러입니다. 대량 수집은 대개 이 방식으로 동작합니다. 반면 브라우저를 실제로 실행해 렌더링된 텍스트를 읽는 크롤러에는 주소가 그대로 보입니다. 사람이 보는 것과 같은 화면을 읽기 때문입니다.
즉 완전한 차단이 아니라 수집 비용을 높이는 조치입니다. 주소를 감추지 않고 노출 경로만 줄이는 방식이라, 연락처를 텍스트로 공개한다는 전제에서 가장 비용이 적은 선택이었습니다. 스팸이 실제로 늘어나면 방식을 더 복잡하게 만들기보다 주소를 바꾸는 편이 빠릅니다.