<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>SEO · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/seo/</link>
    <description>소프트웨어 엔지니어링과 개발 조직 리더십에 대한 이야기. 성과 리뷰, 채용과 팀 확장, CI/CD, 데브옵스 모니터링, 에이전틱 AI. 서울에서 씁니다.</description>
    <generator>Hugo</generator>
    <language>ko</language>
    <copyright>© 2026 인재덕</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ko/tags/seo/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>ChatGPT에 인용되려면: 작은 블로그에서 한 일과 로그가 보여 준 것</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/</guid>
      <pubDate>Wed, 16 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>SEO</category>
      <category>Crawlers</category>
      <category>LLM</category>
      <category>Hugo</category>
      <category>Cloudflare</category>
      <category>content strategy</category>
      <category>Writing</category>
      <description>ChatGPT 같은 AI 어시스턴트에 인용되려고(GEO) 이 블로그에서 한 일. 들여보내야 할 봇, 브라우저 없이 읽히는 HTML, 저자가 들어간 구조화 데이터, 그리고 아무도 읽지 않은 llms.txt.</description>
      <content:encoded><![CDATA[<p>ChatGPT 같은 AI 어시스턴트에 인용되고 싶다면 먼저 답변할 때 페이지를 가져가는 검색용 봇을 들여보내야 합니다. 학습용 크롤러와는 별개라서, 한쪽을 막아도 다른 쪽에는 아무 영향이 없습니다. 그다음은 JavaScript 없이도 읽히는 HTML을, 저자 이름이 든 구조화 데이터와 함께 내보내는 것입니다. llms.txt는 제 로그로 보면 요청하는 곳이 없어서 아무 효과가 없고, 가장 중요한 건 각 섹션이 첫 문장에서 주장을 밝히고 문단 하나를 따로 떼어 인용할 수 있게 쓰는 일입니다.</p>
<p>이 블로그의 검색 유입은 2년쯤 제자리이고, 이제는 움직이길 기대하지도 않습니다. 그래서 대신 방문자가 어디서 오는지, 그 안에 AI 어시스턴트도 있는지를 들여다봤습니다. 있긴 합니다. 간신히요. 2026년 9월 16일까지 열두 달 동안 방문 분석 도구가 센 세션은 5,372개였습니다. 그중 AI 어시스턴트에서 온 건 27개. chatgpt.com에서 12개, gemini.google.com에서 9개, Perplexity에서 6개, claude.ai에서는 하나도 없었습니다. 0.5퍼센트죠. 다만 ChatGPT에서 온 방문자는 좋은 쪽이었습니다. 직접 유입이 평균 27초, 구글이 3분 머무는 동안 이쪽은 평균 5분을 머물렀습니다. 이미 답을 읽고 자세한 내용을 보러 온 사람들이니까요.</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="12개월간 유입 경로별 세션 수, AI 어시스턴트 강조"
    width="2400"
    height="1164"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_590f408ed875db1f.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_590f408ed875db1f.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_d8da7a3f1d1c9190.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_b3dda7d23abf271d.webp"></span><figcaption>12개월간 유입 경로별 세션 수입니다. 파란 막대가 AI 어시스턴트로, 5,372세션 중 27세션입니다.</figcaption></figure>
</p>
<p>그래서 작은 블로그가 그런 답변의 출처로 뽑히려면 뭐가 필요한지 시간을 좀 들여 봤습니다. 이 글은 제가 이 사이트에 한 일과, 로그가 그에 대해 뭐라고 하는지입니다. 숫자가 있는 곳에는 숫자를 적고, 추측인 곳은 추측이라고 밝히겠습니다. 초고에 있던 문장 세 개는 막상 들여다보니 틀린 것으로 드러났는데, 고친 흔적은 그대로 남겨 뒀습니다.</p>

<h2 class="relative group">봇은 두 종류이고, 인용하는 쪽은 하나뿐입니다
    <div id="봇은-두-종류이고-인용하는-쪽은-하나뿐입니다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b4%87%ec%9d%80-%eb%91%90-%ec%a2%85%eb%a5%98%ec%9d%b4%ea%b3%a0-%ec%9d%b8%ec%9a%a9%ed%95%98%eb%8a%94-%ec%aa%bd%ec%9d%80-%ed%95%98%eb%82%98%eb%bf%90%ec%9e%85%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>먼저 짚고 갈 것은 &ldquo;AI 봇&quot;이라는 게 사실 서로 다른 유저 에이전트를 쓰는 두 가지 다른 일이고, 한쪽을 막아도 다른 쪽에는 아무 영향이 없다는 점입니다.</p>
<p>학습용 크롤러는 다음 모델을 만들려고 페이지를 대량으로 긁어 갑니다. OpenAI는 GPTBot, Anthropic은 ClaudeBot이고, 구글은 Google-Extended 토큰으로, 애플은 Applebot-Extended로 제어하며, Common Crawl의 CCBot은 공개 데이터셋 대부분에 들어갑니다. 이들이 가져간 건 가중치가 되어 끝이고, 여러분에게 돌아오는 링크는 없습니다. 이쪽을 막아야 한다는 주장에는 일리가 있고, 저도 터무니없는 얘기라고는 생각하지 않습니다.</p>
<p>검색용 봇은 다른 종류입니다. 누군가 ChatGPT나 Claude, Perplexity에 질문하면 어시스턴트는 검색을 돌리고, 그 자리에서 페이지 몇 개를 가져와 읽은 다음, 인용을 달아 답을 씁니다. 그 요청은 OAI-SearchBot과 ChatGPT-User, Claude-SearchBot과 Claude-User, PerplexityBot과 Perplexity-User로 들어옵니다. 구글의 AI 오버뷰는 그냥 Googlebot을 쓰기 때문에 따로 끌 스위치 자체가 없습니다. 방문자를 돌려보내 주는 건 이쪽 봇들입니다. 목표가 인용되는 거라면 최적화 대상은 이쪽이고, GPTBot을 막아도 ChatGPT 검색 결과에는 어느 방향으로도 아무 영향이 없다는 건 알아 둘 만합니다.</p>
<p>이 사이트의 robots.txt는 Hugo 기본값 그대로, 전부 허용입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">User-agent: *
</span></span><span class="line"><span class="cl">Allow: /
</span></span><span class="line"><span class="cl">Sitemap: https://jared.lynskey.co.nz/sitemap.xml</span></span></code></pre></div></div>
<p>검색용 봇은 들이고 학습용은 막는 식으로 나눌까 생각해 봤지만, 읽히는 게 존재 이유의 전부인 블로그에서 그럴 만한 가치는 없다고 결론 내렸습니다. 굳이 하자면 아래 블록처럼 될 겁니다. 이걸 보여 드리는 이유는 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >Cloudflare가 Googlebot에게 빈 페이지를 내줬다</a>에서와 같은 교훈 때문입니다. 목록에는 회사가 아니라 봇 이름을 적어야 합니다. &ldquo;OpenAI&quot;라고 쓸 수 있는 줄은 없습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">User-agent: GPTBot
</span></span><span class="line"><span class="cl">User-agent: ClaudeBot
</span></span><span class="line"><span class="cl">User-agent: Google-Extended
</span></span><span class="line"><span class="cl">User-agent: Applebot-Extended
</span></span><span class="line"><span class="cl">User-agent: CCBot
</span></span><span class="line"><span class="cl">Disallow: /
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">User-agent: *
</span></span><span class="line"><span class="cl">Allow: /</span></span></code></pre></div></div>
<p>사이트가 Cloudflare 뒤에 있다면 확인할 게 하나 있습니다. 2025년 중반부터 거기서 새로 만드는 존은 기본으로 AI 크롤러를 막고, 봇 설정에는 기존 존에도 같은 걸 적용하는 원클릭 토글이 있습니다. 두 종류를 다 막습니다. 이 블로그는 지난주에 Cloudflare Pages로 옮겼기 때문에 이것도 제 확인 목록에 있었습니다. &ldquo;AI 봇&quot;이라는 말이 막아야 할 무언가처럼 들려서 켰다면, 인용도 같이 꺼 버린 겁니다. 그 뒤로 Cloudflare는 자기가 관리하는 robots.txt 파일에 <code>search</code>, <code>ai-input</code>, <code>ai-train</code> 플래그를 따로 둔 <code>Content-Signal</code> 줄을 쓰기 시작했습니다. 아무도 지킬 의무는 없지만 어휘는 제대로 골랐습니다. 위와 같은 구분을 벽이 아니라 선호로 표현한 거니까요.</p>

<h2 class="relative group">실제로 문을 두드리는 건 누구인가
    <div id="실제로-문을-두드리는-건-누구인가" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8b%a4%ec%a0%9c%eb%a1%9c-%eb%ac%b8%ec%9d%84-%eb%91%90%eb%93%9c%eb%a6%ac%eb%8a%94-%ea%b1%b4-%eb%88%84%ea%b5%ac%ec%9d%b8%ea%b0%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Cloudflare로 옮긴 데는 부수 효과가 하나 있었습니다. 처음으로 모든 요청을 유저 에이전트별로 볼 수 있게 된 겁니다. 2026년 9월 13일부터 16일까지 나흘 동안 들어온 건 아래와 같습니다. 가짜는 걸러낸 숫자인데, 그 얘기는 곧 하겠습니다.</p>
<table>
	<thead>
			<tr>
					<th>봇</th>
					<th>요청 수</th>
					<th>한 일</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>ClaudeBot (학습용)</td>
					<td>1,777</td>
					<td>이틀 동안 서로 다른 페이지 1,380개, 다섯 개 언어 전부, 거기에 robots.txt 37번</td>
			</tr>
			<tr>
					<td>meta-externalagent (학습용)</td>
					<td>1,748</td>
					<td>같은 일을 하루 늦게</td>
			</tr>
			<tr>
					<td>OAI-SearchBot (ChatGPT 검색 인덱스)</td>
					<td>59</td>
					<td>다섯 개 언어의 페이지들, 그리고 robots.txt 16번</td>
			</tr>
			<tr>
					<td>ChatGPT-User (답변 중 가져감)</td>
					<td>20</td>
					<td>콜드 스타트 글의 일본어판, 직접 운영하는 업데이트 서버 글, 홈페이지</td>
			</tr>
			<tr>
					<td>Claude-User (답변 중 가져감)</td>
					<td>8</td>
					<td>업데이트 서버 글, 연락처 페이지, 홈페이지</td>
			</tr>
			<tr>
					<td>PerplexityBot</td>
					<td>2</td>
					<td>iOS 디버깅 글, 영어판과 베트남어판</td>
			</tr>
			<tr>
					<td>Claude-SearchBot, Perplexity-User</td>
					<td>0</td>
					<td></td>
			</tr>
			<tr>
					<td>Googlebot / Bingbot / Applebot / CCBot</td>
					<td>168 / 71 / 45 / 28</td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>눈에 띄는 게 두 가지 있습니다. 검색용 봇이 페이지 수십 개를 읽는 사이 학습용 크롤러는 전부를 두 번씩 읽었습니다. 그리고 검색용 봇은 아무거나 가져가지 않습니다. ChatGPT-User와 Claude-User 둘 다 이 사이트에서 가장 구체적인 글인 업데이트 서버 글을 집어 갔고, 콜드 스타트 글은 일본어판으로 가져갔습니다.</p>
<p>이제 가짜 얘기입니다. GPTBot이라고 주장한 요청 20개 중 18개는 OpenAI가 공개한 IP 대역 밖에서 왔고, <code>/.env</code>, <code>/.git/HEAD</code>, <code>/firebase-adminsdk.json</code>을 달라고 했습니다. &ldquo;ChatGPT-User&rdquo; 요청 36개 중 15개도 같은 부류였습니다. &ldquo;PerplexityBot&rdquo; 트래픽은 대부분 <code>/id_ed25519</code>를 원했습니다. 유저 에이전트는 누구나 보낼 수 있는 문자열이고, 취약점 스캐너들은 AI 봇 이름을 달면 그냥 통과된다는 걸 눈치챘습니다. OpenAI, Anthropic, Perplexity 모두 IP 대역을 공개합니다. 봇을 어느 방향으로든 특별 취급할 거라면 이름이 아니라 주소를 확인해야 합니다. 위 표의 20, 59, 8은 제대로 된 주소에서 온 요청만 센 숫자입니다.</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="OpenAI 사용자 에이전트를 내세운 요청을 OpenAI 공개 IP 대역에서 왔는지로 나눈 그래프"
    width="2400"
    height="722"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_e7035266dc7ce0b1.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_e7035266dc7ce0b1.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_638e8c6286f5c91f.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_11d44262308d7c6c.webp"></span><figcaption>같은 사용자 에이전트 문자열을 출발지 주소로 나눈 것입니다. &lsquo;GPTBot&rsquo;이라 주장한 트래픽 대부분은 OpenAI에서 오지 않았습니다.</figcaption></figure>
</p>

<h2 class="relative group">페이지는 브라우저 없이도 읽혀야 합니다
    <div id="페이지는-브라우저-없이도-읽혀야-합니다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%8e%98%ec%9d%b4%ec%a7%80%eb%8a%94-%eb%b8%8c%eb%9d%bc%ec%9a%b0%ec%a0%80-%ec%97%86%ec%9d%b4%eb%8f%84-%ec%9d%bd%ed%98%80%ec%95%bc-%ed%95%a9%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>검색용 봇은 답변하는 그 시점에 타임아웃을 걸고 페이지를 가져오며, 대부분 JavaScript를 실행하지 않습니다. 번들이 로드되기 전까지 빈 div인 페이지는 그들이 읽지 않는 페이지입니다. Hugo는 완성된 HTML을 써 주니 이 블로그는 그걸 공짜로 얻고, 제가 더 영리한 무언가로 옮기고 싶었던 적이 한 번도 없는 주된 이유이기도 합니다. 제가 운영하는 마켓플레이스는 정반대 경우인 React Native 웹앱이라, 크롤러에게 제대로 된 문서를 주기까지 동적 렌더링 분기가 하나 필요했고, 그다음엔 그걸 조용히 되돌려 버린 캐싱 버그가 있었습니다. 위의 Cloudflare 글이 그 이야기입니다.</p>
<p>속도도 같은 식으로 셈에 들어가는데, 여기서 로그가 저를 바로잡았습니다. Cloudflare 뒤에 있는 정적 사이트는 가장 가까운 엣지에서 나간다고 넘겨짚고 있었거든요. 아닙니다. 이 블로그는 Cloudflare Pages에 있고, HTML은 캐시되지 않은 채 나가며, 서울에서 요청하면 응답이 로스앤젤레스에서 0.5초쯤 걸려 돌아옵니다. 타임아웃이 몇 초인 봇에게는 그래도 충분하지만, 제가 장담했을 법한 수십 밀리초는 아니고, 이걸 아는 것도 직접 재 봤기 때문입니다. 페이지 여섯 개로 답을 짜는 어시스턴트는 3초 걸리는 하나를 그냥 버립니다. 버렸다고 말해 주지도 않습니다.</p>

<h2 class="relative group">문단 하나를 그대로 떼어 갈 수 있게 쓰기
    <div id="문단-하나를-그대로-떼어-갈-수-있게-쓰기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%ac%b8%eb%8b%a8-%ed%95%98%eb%82%98%eb%a5%bc-%ea%b7%b8%eb%8c%80%eb%a1%9c-%eb%96%bc%ec%96%b4-%ea%b0%88-%ec%88%98-%ec%9e%88%ea%b2%8c-%ec%93%b0%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이 시스템들이 페이지를 쓰는 방식은 독자가 읽는 방식과 다릅니다. 페이지를 청크로 쪼개고, 청크마다 질문에 대해 점수를 매기고, 점수가 나오는 것을 인용합니다. 800단어에 걸쳐 요점을 향해 쌓아 올라가는 글은 모든 청크에서 점수가 낮습니다. 섹션마다 첫 문장에서 주장을 말하고 그다음에 뒷받침하는 글은 질문에 맞는 섹션에서 점수를 냅니다.</p>
<p>이게 어떤 기술적인 것보다 제가 여기 글을 쓰는 방식을 많이 바꿨습니다. 프런트매터의 description은 이제 읽어 보라고 낚는 문구가 아니라 글이 말하는 내용의 요약입니다. 어시스턴트가 보여 주는 게 description뿐인 경우가 많거든요. 섹션 제목은 그 섹션이 내리는 결론을 말합니다. 숫자는 차트가 아니라 단위와 함께 문장 안에 들어갑니다. 날짜는 풀어서 씁니다. 1년 뒤에 이 페이지를 읽는 모델에게 &ldquo;지난주&quot;는 아무 뜻도 없으니까요. 그리고 글 하나는 한 가지에 대해서만 씁니다.</p>
<p>나머지 절반은 인용할 만한 게 있느냐인데, 여기서는 데이터가 엇갈립니다. 로그를 보면 답변 시점에 봇이 가져가는 페이지는 구체적인 글들입니다. 콜드 스타트, 업데이트 서버, iOS 디버깅. 네이버 크롤러는 Yeti이고 다음 크롤러는 Daumoa다, 제가 쓰는 호스팅의 재시작은 504가 나는 30초짜리 구간이다, Cloudflare의 실시간 Brotli 압축은 제 번들에서 gzip보다 나쁘게 측정됐다. 이런 사실은 적혀 있는 곳이 많지 않아서, 그중 하나가 필요한 질문은 여기로 옵니다. 그런데 어시스턴트에서 클릭해 들어오는 사람들은 대부분 2023년과 2024년에 쓴 관리에 대한 글에 내려앉습니다. 글마다 한두 세션씩요. 구체적인 글은 기계가 읽고, 일반적인 글은 클릭을 받습니다. 이걸 깔끔하게 설명할 방법이 저한테는 없고, 지어내느니 그대로 보고하는 편을 택하겠습니다. 지렛대가 있다면 그건 여전히, 이번 주에 알아냈는데 검색했을 때는 찾을 수 없었던 그 구체적인 것을 적어 두는 겁니다. 기계가 실제로 읽는 건 그쪽이라는 게 로그에 보이니까요.</p>

<h2 class="relative group">누가 썼는지 밝히고, 계속 같은 말을 하기
    <div id="누가-썼는지-밝히고-계속-같은-말을-하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%88%84%ea%b0%80-%ec%8d%bc%eb%8a%94%ec%a7%80-%eb%b0%9d%ed%9e%88%ea%b3%a0-%ea%b3%84%ec%86%8d-%ea%b0%99%ec%9d%80-%eb%a7%90%ec%9d%84-%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>페이지를 인용할지 결정하는 어시스턴트는 꼼꼼한 독자가 하는 것과 비슷한 일을 합니다. 누가 썼고, 언제 썼고, 내가 찾은 다른 것들과 맞아떨어지는가? 이 사이트에서 그 부분의 기계적인 처리는 대부분 테마가 해 줍니다. 모든 글에 저자, 게시일, 수정일이 든 Article 구조화 데이터가 붙고, 저자는 같은 공개 프로필들로 링크된 Person입니다. 템플릿 몇 줄이지만 &ldquo;페이지 하나&quot;와 &ldquo;이력이 있는 특정 사람이 쓴 페이지&quot;의 차이가 거기서 납니다.</p>
<p>테마가 해 주지 않는 부분은 일관성입니다. 소개 페이지, llms.txt, 공개 프로필, 모든 글 맨 위의 한 줄이 전부 같은 사람을 같은 경력으로 설명합니다. 뭔가 바뀌면 전부 바꿔야 하고, 아니면 깜빡한 하나가 틀린 하나가 됩니다. 까다롭게 들리지만, 한 페이지는 제가 엔지니어링 매니저라고 하고 다른 페이지는 프리랜서라고 하는 건 모델이 얼버무리거나 다른 사람을 고르게 만드는 바로 그런 종류의 모순입니다.</p>
<p>수정일도 정직해야 합니다. 내용이 바뀔 때 바뀌고, 그 외에는 손대지 않습니다. 신선해 보이려고 날짜를 올리는 건 한 번은 통하고 그다음엔 도메인 전체가 평가절하되는 종류의 일입니다.</p>

<h2 class="relative group">llms.txt란 무엇인가, 그리고 아무것도 읽지 않는다는 사실
    <div id="llmstxt란-무엇인가-그리고-아무것도-읽지-않는다는-사실" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#llmstxt%eb%9e%80-%eb%ac%b4%ec%97%87%ec%9d%b8%ea%b0%80-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%ec%95%84%eb%ac%b4%ea%b2%83%eb%8f%84-%ec%9d%bd%ec%a7%80-%ec%95%8a%eb%8a%94%eb%8b%a4%eb%8a%94-%ec%82%ac%ec%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>llms.txt는 2024년 9월에 나온 제안으로, 사이트 루트에 언어 모델에게 사이트 요약과 골라 놓은 링크 목록을 주는 Markdown 파일을 두자는 겁니다. 저는 올해 2월에 하나 추가했습니다. 제가 누구인지 한 문단, 그다음 관리, 인프라, 모바일, 커뮤니티 섹션이 있고, 글마다 제목, URL, 그 글이 내리는 결론 한 문장으로 된 한 줄이 있습니다. 바쁜 사람을 위해 쓴 목차처럼 읽힙니다.</p>
<p>단서는, 아무것도 이 파일을 읽지 않는다는 겁니다. 제가 가진 나흘치 로그에서 <code>/llms.txt</code> 요청은 제가 보낸 것뿐이었습니다. Common Crawl은 2026년 8월 크롤에서 이 사이트의 페이지 76개를 담아 갔지만 그 파일은 한 번도 가져가지 않았습니다. 주요 어시스턴트 중 이 파일을 읽는다고 밝힌 곳도 없습니다. 그러니 여기 있는 뭔가가 인용되는 이유가 이 파일은 아니고, 그런 이유로 누군가에게 권하는 것도 삼가겠습니다. 그래도 두 가지 이유로 두고 있습니다. 한 시간 들었는데, 글마다 실제로 뭘 결론 내리는지 한 문장씩 쓰는 일이 파일 자체보다 값어치가 있었습니다. 어떤 글이 아무것도 결론 내리지 않는지 보여 줬거든요. 그리고 사이트가 뭘 위한 곳인지 기계에게 말해 주는 유일한 페이지라서, 필요 없더라도 있는 편이 낫습니다. 손으로 쓰다 보니 깜빡해서 그 뒤로 어긋나긴 했는데, 그게 Hugo 출력 포맷으로 프런트매터에서 생성하자는 쪽의 논거입니다. 할 일 목록에 있습니다.</p>

<h2 class="relative group">피드, 사이트맵, 그리고 다섯 개 언어
    <div id="피드-사이트맵-그리고-다섯-개-언어" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%94%bc%eb%93%9c-%ec%82%ac%ec%9d%b4%ed%8a%b8%eb%a7%b5-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%eb%8b%a4%ec%84%af-%ea%b0%9c-%ec%96%b8%ec%96%b4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>재미없는 기본 설비도 여전히 중요합니다. 크롤러가 올라간 당일에 새 글을 찾는 건 사이트맵을 통해서고, 글이 바뀐 걸 아는 건 RSS를 통해서입니다. 둘 다 Hugo 기본값이라 신경 써 본 적이 없습니다. ClaudeBot은 이틀 동안 사이트맵을 36번 읽었고, 페이지 1,380개를 그렇게 빨리 찾아낸 것도 그 덕입니다.</p>
<p>언어는 중요할 거라고 예상하지 못했던 부분입니다. 여기 있는 건 전부 영어, 일본어, 한국어, 중국어 간체, 베트남어로 있고, 크롤러가 같은 글이라는 걸 알 수 있도록 alternate 링크가 붙어 있습니다. 크롤러는 이걸 사이트 다섯 개로 취급합니다. ClaudeBot의 크롤은 다섯 언어에 거의 고르게 나뉘었고, Common Crawl이 8월에 가져간 글 페이지 73개 중 영어판은 네 개뿐이었습니다. 어시스턴트는 질문받은 언어로 답하고 그 언어로 된 출처를 선호하기 때문에, 콜드 스타트에 대한 한국어 질문은 영어 페이지를 즉석에서 번역한 것이 아니라 한국어 페이지로 답할 수 있고, ChatGPT가 실제로 가져간 것도 일본어 페이지였습니다. 조건은 번역이 그 언어로 쓴 것처럼 읽혀야 한다는 겁니다. 올해 초에 원어민 검토를 받아 가며 한 번 손본 건 사람 독자를 위해서였지만, 베트남어 페이지가 구경거리가 아니라 출처가 되는 것도 그 덕입니다.</p>

<h2 class="relative group">측정할 수 있는 것
    <div id="측정할-수-있는-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%b8%a1%ec%a0%95%ed%95%a0-%ec%88%98-%ec%9e%88%eb%8a%94-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>추측을 그만두고 나니 생각보다 많았습니다. Cloudflare 무료 플랜은 유저 에이전트와 경로별 요청 로그를 한 번에 하루치씩 조회하게 해 주는데, 위의 봇 표는 거기서 나왔습니다. 리퍼러는 유료 플랜이 필요합니다. 리퍼러는 방문 분석 도구에 있고, 대시보드를 눈 찌푸리고 들여다보는 대신 리포트 API로 뽑아낸 게 맨 위의 숫자들입니다. 둘을 합치면 윤곽이 보입니다. 나흘 동안 검색용 봇은 OpenAI와 Anthropic 주소에서 페이지 90개쯤을 가져갔습니다. 지난 한 달 동안 ChatGPT에서 건너온 사람은 두 명입니다. 많이 읽히고, 가끔 인용되고, 클릭은 드물게. 페이지를 썼는데 클릭은 안 된 답변을 볼 방법은 여전히 제가 아는 한 없습니다. 가장 유용한 확인은 여전히 수동입니다. 내 글이 답하는 질문을 어시스턴트에게 물어보고 누구 페이지를 인용하는지 보는 거죠.</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="13개월간 월별 AI 어시스턴트 유입 세션 수, 추세 없음"
    width="2400"
    height="968"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_cd5dc99be8b0cfc3.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_cd5dc99be8b0cfc3.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_7d401a84f177509c.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_5c308d89ad110ef7.webp"></span><figcaption>AI 어시스턴트에서 넘어온 방문을 월별로 본 것입니다. 추세라 부를 만한 것이 없습니다.</figcaption></figure>
</p>
<p>이 중에 하나만 남겨야 한다면 글쓰기 규칙입니다. robots.txt, 구조화 데이터, llms.txt는 오후 한나절 일이고 그러면 끝납니다. 섹션마다 주장을 먼저 말하도록 쓰는 것, 일반적인 것 대신 구체적인 것에 대해 쓰는 것은 습관이고, 페이지가 인용할 가치가 있는지를 결정하는 건 그 습관입니다. 두 번째는 주장하기 전에 재 보는 겁니다. 기계에게 신뢰받는 건 알고 보면 사람에게 신뢰받는 것과 많이 닮았습니다. 내가 누구인지 말하고, 내가 아는 걸 말하고, 매번 같은 방식으로 말하는 것.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>React Native Web SEO와 네이버 크롤러: Cloudflare가 Googlebot에게 빈 페이지를 내줬다</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-cloudflare-crawlers-cache/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-cloudflare-crawlers-cache/</guid>
      <pubDate>Wed, 09 Sep 2026 04:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>SEO</category>
      <category>Django</category>
      <category>Caching</category>
      <category>Crawlers</category>
      <category>Naver</category>
      <category>React Native</category>
      <description>React Native Web으로 만든 SPA를 구글과 네이버 크롤러에 보이게 만든 다이내믹 렌더링, 그리고 Googlebot에게 빈 SPA 셸을 내준 Cloudflare 캐시 규칙 이야기입니다.</description>
      <content:encoded><![CDATA[<p>React나 React Native Web으로 만든 싱글 페이지 앱은 그대로 두면 네이버 크롤러(Yeti)도 Bing도 자바스크립트를 실행하지 않고, 구글도 실행할 때가 있을 뿐입니다. 해결책은 다이내믹 렌더링입니다. User-Agent로 크롤러를 감지해서, 앱이 그렸을 것과 같은 내용의 서버 렌더링 HTML을 내주면 됩니다. Cloudflare에서 피해야 할 실수는 그 URL을 모든 방문자용으로 캐시하는 것입니다. Cloudflare는 HTML의 <code>Vary</code>를 무시하기 때문에 먼저 도착한 본문이 이후 모든 방문자에게 나가고, Googlebot이 빈 셸을 받을 수도 있습니다. 크롤러 요청만 캐시하고, 규칙을 좁힌 뒤에는 퍼지하세요.</p>
<p>제가 만드는 마켓플레이스 앱은 React Native Web으로 만든 싱글 페이지 앱입니다. 모든 리스팅 페이지는 자바스크립트가 실행되기 전까지 스크립트 태그 하나와 빈 div일 뿐입니다. 사람에게는 아무 문제가 없지만 검색에는 치명적입니다. 구글은 그 JS를 실행할 때도 있지만, Bing은 하지 않고, 리스팅이 거의 다 한국어인 사이트에 가장 중요한 네이버 크롤러는 확실히 하지 않습니다.</p>
<p>이 글은 그 페이지들을 보이게 만든 과정과, 그다음에 제가 예상하지 못한 방식으로 그걸 되돌려버린 캐싱 규칙에 대한 이야기입니다.</p>

<h2 class="relative group">클로킹이 아닌 다이내믹 렌더링
    <div id="클로킹이-아닌-다이내믹-렌더링" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%81%b4%eb%a1%9c%ed%82%b9%ec%9d%b4-%ec%95%84%eb%8b%8c-%eb%8b%a4%ec%9d%b4%eb%82%b4%eb%af%b9-%eb%a0%8c%eb%8d%94%eb%a7%81" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>해결책은 오래됐고 구글도 인정하는 방식입니다. 크롤러를 감지해서, SPA가 그렸을 바로 그 콘텐츠를 담은 서버 렌더링 HTML을 내주는 것. 다른 콘텐츠가 아니라, 같은 콘텐츠를 더 일찍.</p>
<p>그래서 <code>/listings/view?id=...</code> 같은 리스팅 URL은 분기합니다. 브라우저는 SPA 셸을 받습니다. 크롤러는 진짜 문서를 받습니다. 제목, 리스팅의 핵심 정보, alt 텍스트가 붙은 사진별 이미지, 본문 발췌, 그리고 이 페이지가 고아가 되지 않도록 관련 리스팅 몇 개로 가는 링크까지.</p>
<p>마지막 항목이 생각보다 훨씬 중요했습니다. 내부 링크가 생기기 전에는 리스팅 페이지 약 1,000개를 가리키는 것이 아무것도 없었고, 서치 콘솔에서 크롤이 하루 7~11회로 가라앉는 게 보였습니다. 페이지를 찾지 못하는 크롤러는 그 페이지가 얼마나 좋은지 신경 쓰지 않습니다.</p>

<h2 class="relative group">함정: 벤더 이름은 그 회사 봇의 이름이 아니다
    <div id="함정-벤더-이름은-그-회사-봇의-이름이-아니다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%95%a8%ec%a0%95-%eb%b2%a4%eb%8d%94-%ec%9d%b4%eb%a6%84%ec%9d%80-%ea%b7%b8-%ed%9a%8c%ec%82%ac-%eb%b4%87%ec%9d%98-%ec%9d%b4%eb%a6%84%ec%9d%b4-%ec%95%84%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>크롤러 감지는 user-agent 매칭이고, 바로 여기서 발목을 잡혔습니다.</p>
<p>한국 플랫폼들은 회사 이름과 전혀 닮지 않은 이름으로 크롤링합니다. 네이버의 크롤러는 <strong>Yeti</strong>. 다음은 <strong>Daumoa</strong>. 카카오의 링크 스크래퍼는 <strong>kakaotalk-scrap</strong>. 셋 중 어느 것도 벤더 이름이 아닙니다.</p>
<p>한편 같은 벤더들이 인앱 브라우저를 탑재한 앱을 내놓고, 그 브라우저는 진짜 브라우저 UA 뒤에 앱 이름을 붙여 보냅니다. 카카오톡 인앱 브라우저는 <code>KAKAOTALK/10.5.1</code>이라고 말합니다. 카카오의 스크래퍼는 <code>kakaotalk-scrap/1.0</code>이라고 말하고요.</p>
<p><code>kakao</code>로 매칭하면 둘 다 걸립니다. 제가 정확히 그렇게 했고, 그래서 링크를 연 카카오톡·네이버앱·다음앱 사용자 전원이 크롤러 페이지를 받았습니다. 자바스크립트도, 내비게이션도, 로그인도 없는 페이지를요. 문서로서는 훌륭하고, 사람에게는 완전히 쓸모없는.</p>
<p>해결책은 순서 규칙이고, 규칙으로 적어둘 만합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 인앱 브라우저는 평범한 브라우저 UA 뒤에 앱 이름을 붙인다.</span>
</span></span><span class="line"><span class="cl"><span class="n">INAPP_BROWSER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;kakaotalk/|naver\(inapp|daumapps&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 벤더가 아니라 봇 이름으로 매칭한다. 네이버는 Yeti, 다음은 Daumoa,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 카카오의 링크 미리보기 스크래퍼는 kakaotalk-scrap.</span>
</span></span><span class="line"><span class="cl"><span class="n">CRAWLER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;googlebot|bingbot|yeti/|daumoa|kakaotalk-scrap&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">is_crawler</span><span class="p">(</span><span class="n">user_agent</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># 인앱 브라우저는 절대 크롤러가 아니다. 그래서 이 검사가 먼저 온다.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">user_agent</span> <span class="ow">or</span> <span class="n">INAPP_BROWSER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">bool</span><span class="p">(</span><span class="n">CRAWLER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">))</span></span></span></code></pre></div></div>
<p>인앱 브라우저를 먼저 검사하고, 그쪽이 이깁니다. 그리고 모든 크롤러 토큰은 벤더가 아니라 <strong>봇</strong>을 지목해야 합니다.</p>

<h2 class="relative group">그다음 앞단에 Cloudflare를 놓았다
    <div id="그다음-앞단에-cloudflare를-놓았다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b7%b8%eb%8b%a4%ec%9d%8c-%ec%95%9e%eb%8b%a8%ec%97%90-cloudflare%eb%a5%bc-%eb%86%93%ec%95%98%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>오리진은 시드니에 있는 공유 CPU 머신 한 대이고, 초당 20~30 요청을 감당합니다. Googlebot이 걸어야 할 URL은 약 995개입니다. 그 전부가 시드니까지 도달하고 있었습니다. 모든 URL에서 <code>cf-cache-status</code>가 <code>DYNAMIC</code>이었기 때문입니다. <strong>Cloudflare는 규칙이 시키지 않으면 HTML을 캐싱하지 않습니다.</strong> 존의 약 16%만 캐싱되고 있었고, 정작 중요한 부분은 하나도 포함되지 않았습니다.</p>
<p>그래서 크롤러 페이지에 캐시 규칙을 걸었습니다. 간단한 일이죠. 리스팅 URL의 본문이 하나가 아니라는 점만 빼면. 본문은 셋입니다.</p>
<ul>
<li>HTML을 요청하는 크롤러는 서버 렌더링 페이지를 받습니다.</li>
<li>사람은 SPA 셸을 받습니다.</li>
<li><code>Accept: text/markdown</code>을 보내는 에이전트는 데이터베이스에서 만든 마크다운을 받습니다. 빈 HTML 셸을 변환해봐야 빈 문서가 나올 뿐이니까요.</li>
</ul>
<p>오리진은 <code>Vary: Accept</code>를 보내고, user-agent에 대한 <code>Vary</code>는 어차피 가망이 없습니다. 그리고 제가 몰랐던 게 여기 있습니다.</p>
<blockquote><p><strong>Cloudflare는 HTML에서 <code>Vary</code>를 무시합니다.</strong> 세 본문이 하나의 캐시 키를 공유합니다.</p>
</blockquote><p>먼저 도착한 본문이 그 URL을 차지하고, 해당 엣지 위치의 이후 모든 방문자에게 오리진의 <code>s-maxage</code>가 허용하는 한 시간 동안, 거기에 <code>stale-while-revalidate</code> 하루를 더해 그대로 나갑니다.</p>
<p>프로덕션에서 양방향으로 다 목격했습니다.</p>
<ul>
<li>네이버앱 사용자가 크롤러 페이지로 리스팅 URL을 워밍했고, 그다음 평범한 아이폰 사파리가 HIT으로 그 페이지를 받았습니다. 자바스크립트 없는 문서를 들여다보는 사람이 된 거죠.</li>
<li>사람이 SPA 셸로 URL을 워밍했고, Googlebot이 빈 셸을 받았습니다. SEO 작업 전체가 막으려던 바로 그 실패가, 이제 더 빠르게 엣지에서 제공된 겁니다.</li>
</ul>

<h2 class="relative group">좁혀서 고치는 규칙
    <div id="좁혀서-고치는-규칙" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a2%81%ed%98%80%ec%84%9c-%ea%b3%a0%ec%b9%98%eb%8a%94-%ea%b7%9c%ec%b9%99" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Cloudflare가 HTML에서 <code>Vary</code>를 존중하게 만들 수는 없습니다. 대신 할 수 있는 건, 세 본문 중 <strong>하나만</strong> 캐시에 들어갈 자격을 갖게 만드는 것입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">(starts_with(http.request.uri.path, &#34;/listings/&#34;)
</span></span><span class="line"><span class="cl"> and not any(http.request.headers[&#34;accept&#34;][*] contains &#34;text/markdown&#34;)
</span></span><span class="line"><span class="cl"> and (lower(http.user_agent) contains &#34;googlebot&#34; or ...)
</span></span><span class="line"><span class="cl"> and not (lower(http.user_agent) contains &#34;kakaotalk/&#34; or ...))</span></span></code></pre></div></div>
<p>크롤러 UA만 매칭하고, 마크다운 요청은 제외합니다. 이제 사람의 요청은 어떤 규칙에도 매칭되지 않으므로 예전처럼 <code>DYNAMIC</code>을 읽고 오리진으로 갑니다. 캐시를 읽고 쓰는 건 크롤러 요청뿐이고, 거기 들어갈 수 있는 본문은 크롤러용 하나뿐입니다.</p>
<p>엣지 TTL은 <code>respect_origin</code>입니다. 수명은 대시보드에 박히는 대신 페이지를 렌더링하는 코드가 계속 소유합니다.</p>
<p>놓치기 쉬운 운영 디테일 하나. <strong>규칙을 좁힌다고 이미 캐시에 있는 것이 사라지지는 않습니다.</strong> 퍼지해야 합니다. 안 그러면 방금 생성을 멈춘 그 오염된 항목을 계속 내주게 됩니다.</p>

<h2 class="relative group">두 목록, 그리고 그 오류가 대칭이 아닌 이유
    <div id="두-목록-그리고-그-오류가-대칭이-아닌-이유" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%91%90-%eb%aa%a9%eb%a1%9d-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%ea%b7%b8-%ec%98%a4%eb%a5%98%ea%b0%80-%eb%8c%80%ec%b9%ad%ec%9d%b4-%ec%95%84%eb%8b%8c-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이 규칙에는 크롤러 UA 목록이 필요하고, Django에는 이미 하나 있습니다. 같은 지식이 두 시스템에 두 벌 있는 건 대개 냄새나는 일이지만, 여기서 흥미로운 건 둘이 어긋났을 때 벌어지는 일입니다. 대칭이 아니거든요.</p>
<ul>
<li><strong>Cloudflare의 봇 목록은 Django 쪽의 부분집합으로 유지되어야 합니다.</strong> Cloudflare는 봇이라 여기지만 Django는 아니라고 보는 토큰이 있으면, 크롤러 전용 키 아래에 SPA 셸이 캐싱되고 그게 진짜 크롤러에게 나갑니다.</li>
<li><strong>인앱 브라우저 제외 목록은 Django 쪽의 상위집합이어야 합니다.</strong> 너무 많이 제외해봐야 캐시 히트를 잃을 뿐입니다.</li>
</ul>
<p>어느 쪽이든 안전한 방향으로 틀리면 캐싱을 조금 잃습니다. 첫 번째를 반대 방향으로 틀리면 다시 Googlebot에게 빈 페이지를 내주게 됩니다. 참고로 이 비대칭 때문에 목록이 정규식 대신 <code>contains</code>를 씁니다. <code>matches</code>는 Business 플랜이 필요하고, 알려진 안전한 방향으로 대충이어도 되는 목록에는 <code>contains</code>로 충분합니다.</p>

<h2 class="relative group">페이지 대신 빌드를 실패시키는 불변식
    <div id="페이지-대신-빌드를-실패시키는-불변식" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%8e%98%ec%9d%b4%ec%a7%80-%eb%8c%80%ec%8b%a0-%eb%b9%8c%eb%93%9c%eb%a5%bc-%ec%8b%a4%ed%8c%a8%ec%8b%9c%ed%82%a4%eb%8a%94-%eb%b6%88%eb%b3%80%ec%8b%9d" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>크롤러 페이지에는 7일간 유효한 presigned 이미지 URL이 들어갑니다. 페이지 자체는 엣지에서 한 시간, 거기에 stale-while-revalidate 하루가 붙습니다.</p>
<p>이 두 숫자는 서로 관련이 있고, 캐시 수명이 이미지 수명을 넘어서는 순간 엣지는 모든 사진이 403인 페이지를 내주기 시작합니다. 이걸 알려주는 알림은 없습니다. HTML 자체는 멀쩡하니까요.</p>
<p>그래서 그 숫자들을 정의하는 코드는 관계가 깨지면 임포트 자체를 거부합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">CRAWLER_IMAGE_TTL</span> <span class="o">=</span> <span class="mi">7</span> <span class="o">*</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>  <span class="c1"># presigned 이미지 URL</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_S_MAXAGE</span> <span class="o">=</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">=</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 엣지 캐시가 이미지 URL보다 오래 살 수 있으면 임포트 시점에 실패시킨다.</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">EDGE_S_MAXAGE</span> <span class="o">+</span> <span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">&gt;=</span> <span class="n">CRAWLER_IMAGE_TTL</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="ne">RuntimeError</span><span class="p">(</span><span class="s2">&#34;edge cache lifetime must stay below the image URL lifetime&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>서로 다른 파일에 있는 두 상수 사이의 제약은 코드 리뷰에서는 보이지 않고, 프로세스 시작 시점에는 명백합니다. 프로세스가 시작하는 곳에 두세요.</p>

<h2 class="relative group">Cloudflare가 한 일 중 되돌려야 했던 두 가지
    <div id="cloudflare가-한-일-중-되돌려야-했던-두-가지" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#cloudflare%ea%b0%80-%ed%95%9c-%ec%9d%bc-%ec%a4%91-%eb%90%98%eb%8f%8c%eb%a0%a4%ec%95%bc-%ed%96%88%eb%8d%98-%eb%91%90-%ea%b0%80%ec%a7%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><strong><code>Vary: Origin</code>이 번들을 캐시 밖으로 밀어냈습니다.</strong> <code>django-cors-headers</code>는 자기가 건드리는 모든 응답에 <code>Vary: Origin</code>을 찍고, Cloudflare는 <code>Accept-Encoding</code> 외의 헤더로 <code>Vary</code>가 걸린 응답은 캐싱하지 않습니다. 그래서 크리티컬 패스에서 가장 큰 1.6MB 자바스크립트 번들이 통째로 엣지에서 조용히 제외되고 있었습니다. 해결책은 미들웨어를 목록 맨 앞에 두어 나가는 길에 마지막으로 실행되게 하고, 정적 자산 경로의 <code>Vary</code>를 다시 쓰는 것입니다.</p>
<p><strong>엣지 압축이 오리진 압축보다 나빴습니다.</strong> Cloudflare가 즉석에서 Brotli를 하게 두면 2,041,742바이트가 나왔고, 오리진의 gzip은 1,658,115바이트였습니다. 생각하지 않아도 되는 대가로 23% 더 큰 셈입니다. 온더플라이 압축은 압축률이 아니라 CPU를 최적화합니다. 오리진에서 미리 압축한 자산이 이깁니다.</p>

<h2 class="relative group">크롤링을 기다리지 않고 검색엔진에 알리기
    <div id="크롤링을-기다리지-않고-검색엔진에-알리기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%81%ac%eb%a1%a4%eb%a7%81%ec%9d%84-%ea%b8%b0%eb%8b%a4%eb%a6%ac%ec%a7%80-%ec%95%8a%ea%b3%a0-%ea%b2%80%ec%83%89%ec%97%94%ec%a7%84%ec%97%90-%ec%95%8c%eb%a6%ac%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>프로토콜 두 개, 그리고 둘을 합쳐도 커버리지는 실망스럽습니다.</p>
<p><strong>IndexNow</strong>는 핑 하나를 받고 Bing, Yandex, Seznam을 커버합니다. 구글과 네이버는 참여하지 않습니다. 리스팅이 저장될 때마다 백그라운드에서 짧은 타임아웃으로 핑을 보내고, 판매완료·마감된 리스팅은 건너뜁니다. 네트워크 실패는 경고를 남길 뿐 그걸 촉발한 저장을 절대 실패시키지 않습니다. 부가적인 베스트 에포트 작업에 허용할 수 있는 동작은 이것뿐이죠.</p>
<p><strong>구글의 Indexing API</strong>는 정확히 두 가지 스키마 타입만 받고, 그중 하나가 제게 있는 <code>JobPosting</code>입니다. 살아 있는 공고는 <code>URL_UPDATED</code>, 마감된 공고는 <code>URL_DELETED</code>를 보냅니다. 그 삭제 신호가 정직한 이유는 마감된 리스팅이 <code>noindex</code>도 함께 내보내기 때문이고, 코드에 그렇게 적어두었습니다. 나중에 noindex를 걷어내는 사람이 무엇을 같이 망가뜨리는지 알 수 있도록.</p>
<p>거기서 시간을 두 번 썼습니다. 할당량은 하루 200 URL인데, 리스팅을 일괄 임포트하면 한 번에 수백 번이 호출됩니다. 그래서 임포트된 리스팅은 건너뜁니다. 안 그러면 임포트 한 번이 하루치 예산을 전부 씁니다. 그리고 엔드포인트는 콜론이 들어간 <code>urlNotifications:publish</code>입니다. 슬래시를 쓰면 구글은 평범한 HTML 404를 반환하는데, 그건 API가 비활성화된 것과 똑같아 보입니다.</p>

<h2 class="relative group">구조화 데이터가 가르쳐준 것
    <div id="구조화-데이터가-가르쳐준-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b5%ac%ec%a1%b0%ed%99%94-%eb%8d%b0%ec%9d%b4%ed%84%b0%ea%b0%80-%ea%b0%80%eb%a5%b4%ec%b3%90%ec%a4%80-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>규칙 셋, 각각 서치 콘솔에게 혼나면서 배웠습니다.</p>
<ol>
<li><strong>파싱할 수 없는 건 빼세요.</strong> 급여는 자유 텍스트로 들어옵니다. 예전에는 원문 문자열을 <code>baseSalary.value</code>로 내보냈는데, 그건 유효하지 않고 모든 공고에 단위 누락 플래그가 붙었습니다. 지금은 파싱 안 되는 급여는 그냥 없습니다.</li>
<li><strong>스키마를 만족시키려고 필드를 지어내지 마세요.</strong> 구인 공고에 상세 주소가 있는 경우는 드뭅니다. <code>PostalAddress</code>에 그럴듯한 걸 넣고 싶어지죠. 구인 공고에 지어낸 주소는 불완전한 주소보다 나쁩니다. 그래서 지역과 국가만 들어갑니다.</li>
<li><strong>만료일은 선택이 아닙니다.</strong> 구글은 <code>validThrough</code>가 없는 <code>JobPosting</code>을 버립니다. 그래서 하나를 유도하되, 오래된 공고가 어제 만료됐다고 주장하지 않도록 하한을 둡니다.</li>
</ol>
<p>그리고 사이트맵에 대한 네 번째. <strong>전부에 <code>changefreq: daily</code>를 주장하지 마세요.</strong> 리스팅 천 개를 매일 다시 크롤링해달라는 요청은 과부하 상태의 호스트로 읽히고, 구글은 사이트 전체의 크롤 속도를 낮추는 것으로 응답합니다. 실제로 얼마나 최근에 바뀌었는지에서 유도하세요.</p>
<p>사이트맵에는 품질 하한선도 있습니다. 제목과 본문을 합쳐 40자. 서치 콘솔이 약 1,000개 리스팅을 &ldquo;크롤링됨 – 현재 색인이 생성되지 않음&quot;으로 보고했었고, 어떤 중고 페이지의 고유 콘텐츠 전부가 한 단어짜리 제목과 전화번호 하나였습니다. 40자는 일부러 낮게 잡았습니다. 484개 리스팅 중 471개가 한국어이고 한국어는 글자당 담는 정보가 많으니까요. 영어에 맞춘 기준이었다면 마켓플레이스의 절반이 날아갔을 겁니다. 얇은 리스팅도 인덱스 페이지를 통해 크롤 가능한 상태로 남고, 누군가 수정하면 자동으로 사이트맵에 다시 들어옵니다.</p>

<h2 class="relative group">여기서 가져갈 것
    <div id="여기서-가져갈-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%97%ac%ea%b8%b0%ec%84%9c-%ea%b0%80%ec%a0%b8%ea%b0%88-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<ul>
<li><strong>한 URL에서 서로 다른 본문을 내주는 일은, 무엇보다 먼저 캐시 키 문제입니다.</strong> CDN이 무엇을 키로 삼는지 알아내고, 바로 그 콘텐츠 타입에서 증명하기 전까지는 <code>Vary</code>를 무시한다고 가정하세요.</li>
<li><strong>두 시스템이 하나의 목록에 합의해야 한다면, 어느 방향의 오류가 안전한지 알아내서</strong> 양쪽 사본 옆에 적어두세요. &ldquo;이건 어긋날 수 있고, 허용되는 어긋남은 이 방향뿐이다&quot;가 어긋나지 않는 척하는 것보다 낫습니다.</li>
<li><strong>봇은 벤더 이름이 아니라 봇 이름으로 매칭하세요.</strong> 특히 영어권 밖에서는, 그 벤더가 사용자 손에 들린 브라우저도 만들고 있습니다.</li>
<li><strong>멀리 떨어진 상수 사이의 불변식은 시끄럽게 실패하는 곳에 두세요.</strong> 임포트 시점은 공짜이고 아무도 건너뛸 수 없습니다.</li>
</ul>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-cloudflare-crawlers-cache/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>콘텐츠 랭킹 알고리즘이란: 조회수와 좋아요 너머의 신호들</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2023/2023-10-06-reinenforcement-learning/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2023/2023-10-06-reinenforcement-learning/</guid>
      <pubDate>Fri, 06 Oct 2023 00:30:03 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>content ranking</category>
      <category>metrics</category>
      <category>SEO</category>
      <category>machine learning</category>
      <category>user engagement</category>
      <category>content strategy</category>
      <description>콘텐츠 랭킹 알고리즘이 하는 일, 조회수와 좋아요만으로 순위를 매기면 인기 있는 것만 더 인기 있어지는 이유, 그리고 하나의 점수에 섞어 넣을 만한 신호들을 정리했습니다.</description>
      <content:encoded><![CDATA[<p>콘텐츠 랭킹 알고리즘은 피드나 검색 결과, &lsquo;인기 글&rsquo; 목록에 무엇을 어떤 순서로 보여줄지 정하는 장치입니다. 항목마다 여러 신호(사람들이 어떻게 반응하는지, 콘텐츠 자체가 어떤지, 얼마나 새롭고 관련성이 있는지)로 점수를 매기고, 그 점수대로 정렬하죠. 그런데 조회수와 좋아요만으로 순위를 매기면 &lsquo;부익부&rsquo; 루프가 생깁니다. 위에 있는 글이 눈에 띄니 조회수와 좋아요가 더 쌓이고, 그래서 계속 위에 머무는 식입니다.</p>
<p>이게 조회수와 좋아요의 뻔한 결함입니다. 이미 인기 있는 것에만 보상을 준다는 거죠. 새 콘텐츠는 피드 바닥에서 굶고, 플랫폼은 서서히 재방송 채널이 되어 갑니다. 피드 랭킹은 지금 다니는 회사에서도 은근히 어려운 문제라서, 어떤 다른 신호를 믿을 만한지 꽤 오래 고민해 왔습니다.</p>

<h3 class="relative group">클릭 말고 행동을 본다
    <div id="클릭-말고-행동을-본다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%81%b4%eb%a6%ad-%eb%a7%90%ea%b3%a0-%ed%96%89%eb%8f%99%ec%9d%84-%eb%b3%b8%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>명시적 피드백은 양도 적고 편향돼 있습니다. 반면 행동 데이터는 넘칩니다. 체류 시간과 스크롤 깊이를 보면 실제로 읽었는지, 첫 문단에서 이탈했는지 알 수 있죠. 재방문은 더 강력합니다. 같은 글에 다시 돌아온다는 건 어떤 좋아요보다 확실한 지지입니다. 반대로 빠른 이탈은 유용한 부정 신호입니다. 콘텐츠가 약속한 걸 독자가 받지 못했다는 뜻이니까요.</p>

<h3 class="relative group">콘텐츠 자체에 박혀 있는 신호
    <div id="콘텐츠-자체에-박혀-있는-신호" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%bd%98%ed%85%90%ec%b8%a0-%ec%9e%90%ec%b2%b4%ec%97%90-%eb%b0%95%ed%98%80-%ec%9e%88%eb%8a%94-%ec%8b%a0%ed%98%b8" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>좋은 콘텐츠가 처음부터 좋아요를 쓸어 담는 건 아니지만, 품질은 다른 방식으로 드러납니다. 하나는 새로움입니다. 플랫폼에서 아직 다뤄지지 않은 영역을 다루고 있는가. 또 하나는 구조인데, 깔끔한 포맷과 멀쩡한 문법, 명료함은 인정하고 싶지 않을 만큼 품질과 상관관계가 높습니다. 작성자의 이력도 괜찮은 사전 정보가 됩니다. 다만 좋아요 수가 아니라 과거 글이 독자를 얼마나 붙잡았는지로 판단해야 공정하겠죠. 초기 댓글도 도움이 됩니다. 진심 어린 긍정 댓글 세 개가 반사적인 추천 서른 개를 이깁니다. 여기에 의미 분석을 곁들이면 플랫폼에 비어 있던 구멍을 메워 주는 콘텐츠도 찾아낼 수 있습니다.</p>

<h3 class="relative group">바깥에서 신호를 빌려온다
    <div id="바깥에서-신호를-빌려온다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b0%94%ea%b9%a5%ec%97%90%ec%84%9c-%ec%8b%a0%ed%98%b8%eb%a5%bc-%eb%b9%8c%eb%a0%a4%ec%98%a8%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>지금 무엇이 통하는지는 바깥 인터넷이 알려줍니다. 검색 엔진의 트렌딩 키워드와 맞아떨어지는 콘텐츠는 태생적으로 시의성이 있고, 뜨는 주제를 빠르게 다룬 글에는 부스트를 줄 가치가 있습니다. 트위터나 레딧에서 화제인 것은 우리 사용자들이 다음에 원할 것의 예고편인 경우가 많아서, 예측을 조금만 얹으면 파도가 오기 전에 해당하는 새 콘텐츠를 밀어줄 수 있습니다. 자체 검색창도 저평가돼 있습니다. 사용자들이 어떤 주제를 계속 검색하고 있거나, 어떤 글이 검색 엔진에서 꾸준히 유입을 끌어오고 있다면, 그건 추측할 필요도 없는 시의성의 증거입니다.</p>

<h3 class="relative group">신호를 하나의 점수로 섞기
    <div id="신호를-하나의-점수로-섞기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8b%a0%ed%98%b8%eb%a5%bc-%ed%95%98%eb%82%98%ec%9d%98-%ec%a0%90%ec%88%98%eb%a1%9c-%ec%84%9e%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>아이디어를 파이썬 몇 줄로 옮기면 이렇습니다. 설명을 위한 예시일 뿐, 실제 운영 시스템은 아닙니다. 각 신호를 0~1 범위로 정규화해서 원래 숫자가 큰 신호가 나머지를 묻어 버리지 않게 하고, 가중합으로 점수 하나를 만들고, 시간 감쇠로 새 글도 지난달 인기 글과 겨뤄 볼 기회를 줍니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">WEIGHTS</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;read_depth&#34;</span><span class="p">:</span> <span class="mf">0.3</span><span class="p">,</span> <span class="s2">&#34;revisits&#34;</span><span class="p">:</span> <span class="mf">0.25</span><span class="p">,</span> <span class="s2">&#34;quality&#34;</span><span class="p">:</span> <span class="mf">0.25</span><span class="p">,</span> <span class="s2">&#34;early_comments&#34;</span><span class="p">:</span> <span class="mf">0.2</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="n">HALF_LIFE_HOURS</span> <span class="o">=</span> <span class="mi">48</span>  <span class="c1"># a post loses half its score every two days</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">normalise</span><span class="p">(</span><span class="n">values</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">lo</span><span class="p">,</span> <span class="n">hi</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="n">values</span><span class="p">),</span> <span class="nb">max</span><span class="p">(</span><span class="n">values</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[(</span><span class="n">v</span> <span class="o">-</span> <span class="n">lo</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">hi</span> <span class="o">-</span> <span class="n">lo</span><span class="p">)</span> <span class="k">if</span> <span class="n">hi</span> <span class="o">&gt;</span> <span class="n">lo</span> <span class="k">else</span> <span class="mf">0.0</span> <span class="k">for</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">values</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">rank</span><span class="p">(</span><span class="n">items</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;items: dicts holding raw signal values plus &#34;age_hours&#34;.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">norm</span> <span class="o">=</span> <span class="p">{</span><span class="n">name</span><span class="p">:</span> <span class="n">normalise</span><span class="p">([</span><span class="n">item</span><span class="p">[</span><span class="n">name</span><span class="p">]</span> <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">items</span><span class="p">])</span> <span class="k">for</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">WEIGHTS</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">scores</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">item</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">items</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">base</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">weight</span> <span class="o">*</span> <span class="n">norm</span><span class="p">[</span><span class="n">name</span><span class="p">][</span><span class="n">i</span><span class="p">]</span> <span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">weight</span> <span class="ow">in</span> <span class="n">WEIGHTS</span><span class="o">.</span><span class="n">items</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">        <span class="n">scores</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">base</span> <span class="o">*</span> <span class="mf">0.5</span> <span class="o">**</span> <span class="p">(</span><span class="n">item</span><span class="p">[</span><span class="s2">&#34;age_hours&#34;</span><span class="p">]</span> <span class="o">/</span> <span class="n">HALF_LIFE_HOURS</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">order</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="nb">range</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">items</span><span class="p">)),</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">i</span><span class="p">:</span> <span class="n">scores</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="n">reverse</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="n">items</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">order</span><span class="p">]</span></span></span></code></pre></div></div>
<p>가중치는 아래에서 말할 테스트로 확인하기 전까지는 그냥 짐작입니다. 조회수와 좋아요를 넣어도 괜찮습니다. 점수의 전부가 아니라 여러 입력 중 하나가 될 뿐이니까요. 반감기는 오래된 글이 얼마나 빨리 가라앉을지를 정하는데, 이건 그 자체로 큰 주제라서 <a href="https://jared.lynskey.co.nz/ko/posts/2023/2023-09-27-simulating-views/" >피드 랭킹 알고리즘은 오래된 글을 언제 묻어버릴까</a>에 따로 정리해 뒀습니다. 실제 시스템은 가중치를 손으로 정하기보다 모델로 학습시키는 경우가 많지만, 모양은 같습니다. 신호 여러 개를 넣고, 숫자 하나를 뽑고, 정렬하는 거죠.</p>

<h3 class="relative group">마지막엔 정직하게 테스트
    <div id="마지막엔-정직하게-테스트" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a7%88%ec%a7%80%eb%a7%89%ec%97%94-%ec%a0%95%ec%a7%81%ed%95%98%ea%b2%8c-%ed%85%8c%ec%8a%a4%ed%8a%b8" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>신호가 아무리 영리해도 A/B 테스트는 필수입니다. 새 콘텐츠를 일부 사용자에게 노출하고 기존 콘텐츠와 인게이지먼트를 비교하는 거죠. 그리고 퍼지는 모양을 관찰합니다. 플랫폼 안팎의 공유, 언급, 임베드는 조회수 카운터보다 훨씬 많은 걸 말해 줍니다.</p>
<p>현실을 견뎌내는 단일 지표는 없습니다. 실제로 작동하는 랭킹은 행동, 콘텐츠 자체의 품질, 시의성, 정직한 실험을 섞은 것이고, 어느 신호에게도 최종 결정권을 주지 않습니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2023/2023-10-06-reinenforcement-learning/featured.jpeg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>SEO 트렌드: 모범 사례를 따를까, 내 방식을 만들까</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2023/2023-10-04-following-seo-trends/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2023/2023-10-04-following-seo-trends/</guid>
      <pubDate>Wed, 04 Oct 2023 00:30:03 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Search Engine Optimization</category>
      <category>SEO</category>
      <category>algorithms</category>
      <category>digital marketing</category>
      <category>content strategy</category>
      <category>online visibility</category>
      <description>SEO 트렌드와 모범 사례를 그대로 따를지, 직접 실험해 볼지. 정석을 따르는 게 나은 경우와 실험해 볼 가치가 있는 경우, 그리고 어떤 알고리즘 업데이트에도 살아남은 단 하나의 규칙.</description>
      <content:encoded><![CDATA[<p>올해 읽은 SEO 가이드는 하나같이 비슷한 내용을 비슷한 순서로 다루고 있었습니다. 이걸 안심해도 된다는 신호로 볼지, 경고로 볼지는 관점에 따라 다르겠죠. 이 블로그 때문에도, 그리고 솔직히 검색 유입이 꽤 중요한 회사 일 때문에도 한동안 이 문제를 고민했습니다.</p>
<p><em>2026년 10월 업데이트: 이 글을 쓴 뒤로 검색하면 링크보다 AI가 쓴 답변이 먼저 나오는 일이 많아졌습니다. 구글의 AI 개요(AI Overviews)나 ChatGPT 검색 같은 것들이죠. 이런 답변은 참고한 페이지를 출처로 밝히고 링크를 거는데, 가장 인용하기 쉬운 페이지는 쓸모 있으면서 답을 분명하게 적어 둔 페이지입니다. 그러니 이 글 마지막에 적은 재미없는 규칙은 덜 중요해지기는커녕 더 중요해졌습니다. 그래서 이 블로그에서 바꾼 것들은 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/" >AI 어시스턴트가 인용하는 출처가 되도록 블로그 다듬기</a>에 정리해 두었습니다.</em></p>

<h3 class="relative group">남들 하는 대로 하면 대체로 통하는 이유
    <div id="남들-하는-대로-하면-대체로-통하는-이유" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%82%a8%eb%93%a4-%ed%95%98%eb%8a%94-%eb%8c%80%eb%a1%9c-%ed%95%98%eb%a9%b4-%eb%8c%80%ec%b2%b4%eb%a1%9c-%ed%86%b5%ed%95%98%eb%8a%94-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>모범 사례가 모범 사례라 불리는 데는 이유가 있습니다. 수천 개 사이트의 데이터에서 추려낸 것이라, SEO가 처음이라도 큰 사고 없이 상당한 수준까지 갈 수 있습니다. 다져진 길을 걸으면 불쾌한 서프라이즈도 적고요. 구글 공식 문서가 권장하는 대로 했다가 알고리즘 업데이트로 불이익을 받을 일은 거의 없습니다. 게다가 알고리즘은 계속 바뀌기 때문에, 최신 가이드를 따라가는 것만으로도 충분히 일이 됩니다.</p>
<p>문제는 모두가 같은 가이드를 읽고 있다는 점입니다. 한 분야의 사이트가 전부 같은 구조, 같은 키워드 범벅 제목, 같은 FAQ 블록을 달고 있으면 어느 것도 눈에 띄지 않죠. 게다가 항상 수동적인 입장이 됩니다. 다음 업데이트를 기다렸다가 허둥지둥 맞추는 일의 반복입니다.</p>

<h3 class="relative group">내 방식대로 실험해 볼 가치
    <div id="내-방식대로-실험해-볼-가치" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%82%b4-%eb%b0%a9%ec%8b%9d%eb%8c%80%eb%a1%9c-%ec%8b%a4%ed%97%98%ed%95%b4-%eb%b3%bc-%ea%b0%80%ec%b9%98" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>실험은 내 사이트에만 맞는 전술을 찾는 거의 유일한 방법입니다. 화장품 커뮤니티에서 통하는 방식이 레시피 블로그에서도 통하리란 법은 없고, 범용 체크리스트는 그 차이를 알려주지 않습니다. 실험이 성공하면 한동안 그걸 하는 사람이 나 혼자인 시기가 생깁니다. 그게 알려지면 이번엔 남들이 따라 하는 쪽이 되는데, 이것도 나름대로 쌓이는 가치가 있죠.</p>
<p>솔직한 단점은, 실험 대부분이 조용히 실패한다는 겁니다. 몇 주를 쏟아붓고도 남는 게 없을 수 있습니다. 검색 유입이 매출을 떠받치고 있다면, 그 시간을 승인받기가 쉽지 않을 겁니다.</p>

<h3 class="relative group">제 나름의 결론
    <div id="제-나름의-결론" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a0%9c-%eb%82%98%eb%a6%84%ec%9d%98-%ea%b2%b0%eb%a1%a0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>지루한 기본부터 제대로 합니다. 그다음 실험용으로 작게 시간을 떼어 두고, 감이 아니라 숫자로 판단합니다. 작은 사이트라면 정석에 크게 기대고 실험은 가장자리에서 조금만. 자원이 넉넉한 큰 조직이라면 제대로 베팅을 걸어볼 만합니다.</p>
<p>지금까지 모든 알고리즘 업데이트에서 살아남은 유일한 규칙은 가장 재미없는 규칙이기도 합니다. 검색하는 사람에게 진짜 쓸모 있는 걸 만들 것. 나머지는 전부 유행일 뿐입니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2023/2023-10-04-following-seo-trends/featured.jpeg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
