이메일 주소를 그대로 보여 주면서 크롤링 방어하기

연락처는 텍스트로 보여야 하고, 크롤러에는 온전한 주소가 남지 않아야 합니다. 주소를 조각으로 나누고 화면에 보이지 않는 미끼 문자열을 넣고 링크는 마운트 후에 추가하는 방식, 그리고 이 방법의 차단 범위와 한계를 정리했습니다.

포트폴리오에 연락처를 실어야 했습니다. 이메일을 그대로 적으면 정적 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 를 넣으면 조각으로 나눈 주소가 링크 속성 하나로 완성되어 남습니다. 그래서 링크는 브라우저에서 추가합니다. 프리렌더 결과와 첫 마크업이 같아야 한다는 제약과도 맞습니다.

자바스크립트가 꺼져 있으면 링크가 아니라 텍스트로 남습니다. 눌러서 메일 앱이 열리지는 않지만 주소는 그대로 읽히고 복사됩니다. 앞서 본 방법들과 달리, 스크립트가 실행되지 않아도 연락처가 사라지지 않습니다.

방어가 무효화되는 경우

  1. 링크 목록에 이메일을 다시 넣는 것. 그 목록은 정적 HTML 에 href 가 그대로 나갑니다. 한 줄 추가로 전부 무효가 됩니다.
  2. 구조화 데이터에 넣는 것. 검색 결과 표시를 늘리려고 JSON-LD 에 email 을 적으면, 사람이 읽는 자리에서 가린 주소를 기계가 읽는 자리에 그대로 제공하게 됩니다.
  3. 조각을 합치는 함수를 서버에서 호출하는 것. 프리렌더 중에 한 번이라도 호출되면 완성된 주소가 산출물에 포함됩니다.

규칙을 기억해서 지키는 대신 빌드 후에 검사합니다. 산출물에 mailto: 도, 이어진 주소도 없어야 합니다.

grep -r "mailto:" out/ | grep -v ".js:"
grep -rE "[a-z0-9_.]+@[a-z0-9.]+\.[a-z]{2,}" out/ --include="*.html"

차단 범위와 한계

한계를 분명히 적어 둡니다. 이 방식이 막는 것은 HTML 원문을 받아 정규식으로 검색하는 크롤러입니다. 대량 수집은 대개 이 방식으로 동작합니다. 반면 브라우저를 실제로 실행해 렌더링된 텍스트를 읽는 크롤러에는 주소가 그대로 보입니다. 사람이 보는 것과 같은 화면을 읽기 때문입니다.

즉 완전한 차단이 아니라 수집 비용을 높이는 조치입니다. 주소를 감추지 않고 노출 경로만 줄이는 방식이라, 연락처를 텍스트로 공개한다는 전제에서 가장 비용이 적은 선택이었습니다. 스팸이 실제로 늘어나면 방식을 더 복잡하게 만들기보다 주소를 바꾸는 편이 빠릅니다.