<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://seokrae.github.io/blog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://seokrae.github.io/blog/" rel="alternate" type="text/html" hreflang="ko" /><updated>2026-07-24T20:14:59+09:00</updated><id>https://seokrae.github.io/blog/feed.xml</id><title type="html">SeokRae</title><entry><title type="html">장애의 원인은 다양해도, 대응이 늦어지는 이유는 하나다</title><link href="https://seokrae.github.io/blog/2026/07/24/incident-response-pipeline.html" rel="alternate" type="text/html" title="장애의 원인은 다양해도, 대응이 늦어지는 이유는 하나다" /><published>2026-07-24T00:00:00+09:00</published><updated>2026-07-24T00:00:00+09:00</updated><id>https://seokrae.github.io/blog/2026/07/24/incident-response-pipeline</id><content type="html" xml:base="https://seokrae.github.io/blog/2026/07/24/incident-response-pipeline.html"><![CDATA[<p>오늘 결제 서비스에서 실패율이 급증했다.</p>

<p>외부 연동 구간에서 커넥션 타임아웃이 터졌다. 요청이 커넥션을 기다리다 타임아웃되고, 클라이언트가 재시도하고, 재시도도 이미 고갈된 풀에서 대기하고, 타임아웃이 더 늘어나고, 재시도가 더 쌓인다. 이 재시도 폭풍(retry storm)<sup id="fnref:retry-storm" role="doc-noteref"><a href="#fn:retry-storm" class="footnote" rel="footnote">1</a></sup>은 적당한 부하를 몇 초 안에 전면 장애로 바꿔놓는다.</p>

<p>원인 자체는 기술적으로 단순한 편이었다. 커넥션 풀 조정, circuit breaker<sup id="fnref:circuit-breaker" role="doc-noteref"><a href="#fn:circuit-breaker" class="footnote" rel="footnote">2</a></sup>, 재시도 제한 같은 방어 수단은 알려져 있고, 원인을 알면 완화할 수 있다. 문제는 <strong>원인을 아는 데까지 가는 시간</strong>이 너무 오래 걸렸다는 거다.</p>

<p>되돌아보니 그 지연의 원인은 기술 부족이 아니었다.</p>

<h2 id="장애-대응에는-프로세스가-있다">장애 대응에는 프로세스가 있다</h2>

<p>장애 대응은 그때그때 임기응변으로 때우는 영역이 아니다. 탐지부터 사후조치까지 이미 정립된 프로세스가 있고, 이걸 따르지 않으면 기술이 아무리 좋아도 대응은 늘어진다.</p>

<p>장애가 터졌을 때 일어난 일을 복기하면 이렇다.</p>

<p>알림이 왔다. 대시보드를 열었다. 실패율이 튄 걸 확인했다. 그다음 — 누가 어떤 구간을 먼저 확인하는지가 불분명했다. 여러 사람이 각자 판단으로 제각기 구간을 들여다봤다. 누군가는 로그를 뒤졌고, 누군가는 네트워크 상태를 봤고, 누군가는 외부 연동 상태를 확인했다. 서로 뭘 하는지 몰랐고, 같은 구간을 중복으로 보는 사람도 있었고, 정작 봐야 할 구간을 아무도 안 보기도 했다.</p>

<p>Google SRE 책은 이런 상태를 정확히 짚는다 — 비관리 인시던트에서 엔지니어들이 조율 없이 각자 시스템을 변경하는 것을 “freelancing”<sup id="fnref:freelancing" role="doc-noteref"><a href="#fn:freelancing" class="footnote" rel="footnote">3</a></sup>이라 부른다. 기술에 몰두한 나머지 상황 인식(situational awareness)을 잃고, 리더십이 현황을 파악하지 못하고, 소통이 끊겨 위임조차 불가능해지는 상태.</p>

<p>오늘 우리가 정확히 그 상태였다.</p>

<p>기술적으로 유능한 엔지니어도 프로세스 구조 없이는 혼란을 만든다. <strong>위기에서 조율(coordination)이 개인의 기술력(individual brilliance)보다 중요하다</strong> — 이건 교과서에서 읽으면 당연한 소리인데, 직접 당하고 나니 다르게 읽힌다.</p>

<h2 id="원인부터-찾자가-장애를-키운다">“원인부터 찾자”가 장애를 키운다</h2>

<p>복기에서 가장 뼈아팠던 지점이 있다. 우리는 장애가 터지자마자 <strong>근본 원인</strong>을 찾으려 했다. 왜 타임아웃이 났는지, 어느 구간에서 문제가 생겼는지, 커넥션 풀 설정이 문제인지 외부 시스템이 문제인지. 당연한 순서처럼 보인다.</p>

<p>그런데 이게 틀렸다.</p>

<p>원인을 규명하는 동안에도 장애는 계속 확대되고 있었다. 실패한 요청은 계속 재시도되고, 재시도가 풀을 더 밀어내고, 실패율은 올라가고. 원인을 찾는 데 쏟는 시간이 곧 장애가 번지는 시간이었다.</p>

<p>인시던트 대응 라이프사이클에서 “완화(containment)”라는 단계가 따로 있는 이유가 이거다. <strong>완화는 문제를 완전히 고치는 게 아니다. 숨 쉴 공간을 만드는 거다.</strong> 트래픽을 일부 우회하든, 재시도를 일시 차단하든, 영향 범위를 격리하든 — 원인 파악 전에 먼저 확산을 멈춰야 한다.</p>

<p>“Containment rarely fixes the problem fully, but it restores breathing space.”</p>

<p>이 한 문장이 오늘 우리에게 필요했던 전부다.</p>

<h2 id="장애-대응은-7단계-파이프라인이다">장애 대응은 7단계 파이프라인이다</h2>

<p>돌이켜보면, 우리가 장애 대응이라고 생각한 건 “알림 받고 → 원인 찾고 → 고친다” 정도였다. 3단계. 실제로는 빠져 있는 단계들이 있었고, 빠진 단계마다 대응이 늦어졌다.</p>

<p>인시던트 대응 라이프사이클은 7단계로 정리된다.<sup id="fnref:lifecycle" role="doc-noteref"><a href="#fn:lifecycle" class="footnote" rel="footnote">4</a></sup></p>

<p><strong>1. 준비(Preparation).</strong> 런북, 역할 분담, 연락 체계를 사전에 정의해두는 단계다. 이게 없으면 장애가 터졌을 때 “분 단위로 중요한 순간에 눈먼 채로 허둥대게”(scramble blindly when minutes matter) 된다. 오늘 우리가 그랬다.</p>

<p><strong>2. 탐지(Detection).</strong> 모니터링과 알림이 장애를 잡아내는 단계다. 임계값이 너무 높으면 놓치고, 너무 낮으면 알림 피로에 빠진다. 오늘은 탐지 자체는 됐다 — 실패율 급등 알림이 왔다.</p>

<p><strong>3. 트리아지(Triage).</strong> 심각도를 판정하고 역할을 배정하는 단계다. 이게 불명확하면 책임 소재를 논쟁하느라 시간을 쓴다. 오늘은 트리아지가 사실상 없었다. 알림이 오자마자 각자가 각자 판단으로 움직였다.</p>

<p><strong>4. 완화(Containment).</strong> 위에서 말한 대로, 확산을 먼저 멈추는 단계다. 오늘 우리가 건너뛴 바로 그 단계다.</p>

<p><strong>5. 해결(Resolution).</strong> 근본 원인을 제거하는 단계다. 커넥션 풀 설정 조정, 외부 연동 구간 복구 등. 원인을 알아야 가능하므로, 완화로 시간을 번 뒤에 차분하게 진행해야 한다.</p>

<p><strong>6. 복구(Recovery).</strong> 시스템을 정상 상태로 되돌리는 단계다. 여기서 조급하면 같은 장애가 재발한다 — 이른바 “요요 장애(yo-yo outages).”<sup id="fnref:yoyo" role="doc-noteref"><a href="#fn:yoyo" class="footnote" rel="footnote">5</a></sup></p>

<p><strong>7. 사후조치(Postmortem).</strong> 무슨 일이 있었고, 왜 일어났고, 다음에 어떻게 막을지를 기록하는 단계다. 이건 별도로 얘기할 만큼 중요한 게 있어서 아래에서 따로 쓴다.</p>

<p>이 7단계를 나열하는 건 쉽다. 문제는 <strong>각 단계가 없을 때 실제로 무엇이 늦어지는지</strong>를 경험하지 않으면 그냥 목록에 머문다는 거다. 오늘 나는 최소한 준비, 트리아지, 완화 세 단계의 부재가 만드는 지연을 직접 겪었다.</p>

<h2 id="결제-시스템에서-타임아웃이-특히-까다로운-이유">결제 시스템에서 타임아웃이 특히 까다로운 이유</h2>

<p>잠깐 기술적인 얘기를 하자면, 결제 시스템에서 커넥션 타임아웃은 단순한 실패보다 나쁘다.</p>

<p>실패는 명확하다. 요청이 거부됐고, 결제가 안 됐다. 사용자에게 안내하면 된다. 그런데 타임아웃은 “결제가 된 건지 안 된 건지 모르는” 상태를 만든다. 요청을 보냈는데 응답을 못 받았으니, 상대 시스템에서 처리됐을 수도, 안 됐을 수도 있다.</p>

<p>이 불확실 상태(indeterminate state)에서 재시도 판단이 어려워진다. 재시도했는데 원래 요청이 사실 성공했었다면? 이중 결제가 된다. 그래서 결제 시스템에는 idempotency key<sup id="fnref:idempotency" role="doc-noteref"><a href="#fn:idempotency" class="footnote" rel="footnote">6</a></sup> 같은 안전장치가 필요하다 — 같은 키로 재요청이 오면 재처리하지 않고 저장된 결과를 돌려주는 방식이다.</p>

<p>여기서 장애 대응과 연결된다. <strong>안전장치 없이는 장애 대응 자체가 새로운 장애를 만든다.</strong> 타임아웃 장애가 터졌을 때 “재시도하면 되지”라는 판단이, idempotency가 없는 시스템에서는 이중 과금을 만든다. 장애 완화가 더 큰 장애를 만드는 역설.</p>

<h2 id="postmortem은-문서가-아니라-시스템-변경이다">Postmortem은 문서가 아니라 시스템 변경이다</h2>

<p>장애가 끝나고 나면 회고를 한다. 대부분 그렇다. 문제는 회고가 실제 변화로 이어지느냐다.</p>

<p>Google SRE Workbook에 있는 한 문장이 정곡을 찌른다.</p>

<blockquote>
  <p>“To our users, a postmortem without subsequent action is indistinguishable from no postmortem.”
액션 아이템 없는 회고는 회고가 아니다. 더 흔한 문제는, 액션 아이템이 있지만 실행되지 않는 회고다. “모니터링 강화”, “프로세스 개선” 같은 모호한 항목을 적고 다음 스프린트에 묻히면, 다음 장애 때 똑같은 회고를 쓰게 된다.</p>
</blockquote>

<p>실효성 있는 postmortem은 몇 가지 조건이 있다.</p>

<ul>
  <li>인시던트 종료 <strong>수일 내</strong> 발행한다. 수개월 뒤에 쓰면 기억이 왜곡된다.</li>
  <li>해당 팀만이 아니라 <strong>조직 전체에 공유</strong>한다. 같은 패턴의 장애는 다른 팀에서도 일어날 수 있다.</li>
  <li>모든 액션 아이템에 <strong>명확한 담당자와 추적 번호</strong>를 부여한다. “모니터링 개선”이 아니라 “JIRA-1234: 커넥션 풀 고갈 알림 임계값을 N으로 설정, 담당: OOO, 기한: 다음 스프린트.”</li>
  <li>액션 아이템 <strong>완료율을 추적</strong>한다.</li>
  <li>postmortem 작업을 기능 개발과 <strong>동등한 우선순위</strong>로 다룬다.</li>
</ul>

<p>그리고 이 모든 것의 전제가 <strong>blameless</strong><sup id="fnref:blameless" role="doc-noteref"><a href="#fn:blameless" class="footnote" rel="footnote">7</a></sup> 문화다.</p>

<p>“사람을 고칠 수 없다. 시스템을 고쳐라 (You can’t ‘fix’ people, but you can fix systems).” 비난 문화에서는 정보가 숨겨진다. 장애의 원인을 사람의 실수에 귀착시키면, 다음부터는 실수를 보고하지 않게 된다. Dan Milstein의 말이 이걸 잘 요약한다 — “Let’s plan for a future where we’re all as stupid as we are today.” 사람의 주의력 향상에 기대는 대책은 대책이 아니다. 런북, 자동 알림, circuit breaker 같은 시스템적 방어가 진짜 대책이다.</p>

<h2 id="그래서-나는-이-파이프라인으로-가기로-했다">그래서 나는 이 파이프라인으로 가기로 했다</h2>

<p>오늘의 경험과 리서치를 거쳐, 앞으로 장애 대응에 쓸 파이프라인을 정리했다. 교과서를 베낀 게 아니라, 오늘 뭐가 없어서 늦어졌는지를 기준으로 골랐다.</p>

<h3 id="1-준비--장애가-터지기-전에-끝내야-할-것">1. 준비 — 장애가 터지기 전에 끝내야 할 것</h3>

<ul>
  <li><strong>역할 정의</strong>: 인시던트 커맨더(지휘), 오퍼레이션 리드(시스템 변경), 커뮤니케이션 리드(상황 공유). 최소한 이 세 역할이 누구인지 사전에 정한다. Google SRE가 강조하는 핵심은 인시던트 커맨더가 <strong>직접 트러블슈팅하지 않는다</strong>는 것이다 — 기술에 빠지면 전체를 놓친다.</li>
  <li><strong>런북</strong>: 서비스별로 “이 알림이 오면 → 이 순서로 확인 → 이 기준으로 판단”을 적어둔다. 기억에 의존하면 오늘처럼 흩어진다.</li>
  <li><strong>인시던트 선언 기준</strong>: 다수 팀 관여, 고객 가시적 영향, 1시간 이상 미해결 — 이 중 하나라도 해당하면 인시던트를 선언하고 역할을 활성화한다.</li>
</ul>

<h3 id="2-탐지--트리아지">2. 탐지 → 트리아지</h3>

<ul>
  <li>알림이 오면 <strong>트리아지부터 한다</strong>. 심각도 판정, 영향 범위 파악, 역할 배정. “각자 알아서”가 아니라 “누가 무엇을 본다”를 정한다.</li>
  <li>MTTA(Mean Time to Acknowledge)를 의식한다 — 문제를 인지하는 데 걸리는 시간이 전체 MTTR<sup id="fnref:mttr" role="doc-noteref"><a href="#fn:mttr" class="footnote" rel="footnote">8</a></sup>의 시작점이다.</li>
</ul>

<h3 id="3-완화-먼저-원인-규명은-그다음">3. 완화 먼저, 원인 규명은 그다음</h3>

<ul>
  <li><strong>근본 원인 찾기 전에 확산을 멈춘다.</strong> 트래픽 우회, 재시도 차단, 영향 구간 격리. 완벽한 수정이 아니라 숨 쉴 공간을 만드는 것.</li>
  <li>완화가 끝난 뒤 원인을 규명하고 해결한다. 이 순서를 뒤집지 않는다.</li>
</ul>

<h3 id="4-복구는-천천히">4. 복구는 천천히</h3>

<ul>
  <li>수정이 끝났다고 바로 전체 트래픽을 돌리지 않는다. 단계적으로 복원하면서 지표를 확인한다. 급하게 열면 재발한다.</li>
</ul>

<h3 id="5-postmortem--액션-없는-회고는-안-쓴다">5. Postmortem — 액션 없는 회고는 안 쓴다</h3>

<ul>
  <li>장애 종료 3일 이내 작성한다.</li>
  <li>모든 액션 아이템에 담당자, 추적 번호, 기한을 붙인다. “개선한다”로 끝나는 항목은 허용하지 않는다.</li>
  <li>팀 밖에 공유한다.</li>
  <li>비난하지 않는다. 사람을 고치려 하지 않고 시스템을 고친다.</li>
</ul>

<hr />

<p>이 파이프라인이 완성형이라고 생각하지 않는다. 다음 장애 때 이걸 써보고, 안 맞는 부분이 나오면 고칠 거다. 하지만 오늘 확실히 배운 건 하나다.</p>

<p><strong>파이프라인이 없는 상태에서 대응하는 것은, 기술이 부족한 것보다 느리다.</strong></p>

<p>커넥션 타임아웃은 알면 고친다. 하지만 누가 어떤 구간을 먼저 보고, 확산을 언제 멈추고, 원인 규명은 그다음에 하고, 끝나고 나서 뭘 바꿔야 하는지 — 이 흐름이 없으면 “아는 것”이 “고치는 것”으로 연결되지 않는다.</p>

<p>오늘 겪은 장애의 근본 원인은 커넥션 타임아웃이다. 하지만 대응이 늦어진 근본 원인은 파이프라인의 부재다. 기술적 원인은 고쳤다. 이제 프로세스적 원인을 고칠 차례다.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:retry-storm" role="doc-endnote">
      <p>실패한 요청이 재시도되면서 이미 고갈된 자원(커넥션 풀 등)에 더 많은 부하를 얹고, 그 부하가 더 많은 실패와 재시도를 낳는 악순환. Michael Nygard가 <em>Release It!</em>(2007)에서 다룬 문제를 Martin Fowler가 Circuit Breaker 패턴으로 정리했다. — <a href="https://martinfowler.com/bliki/CircuitBreaker.html">Martin Fowler, “CircuitBreaker”</a> <a href="#fnref:retry-storm" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:circuit-breaker" role="doc-endnote">
      <p>반복적으로 실패하는 호출 대상에 일정 시간 요청을 차단해 장애가 다른 시스템으로 번지는 걸 막는 패턴. — <a href="https://martinfowler.com/bliki/CircuitBreaker.html">Martin Fowler, “CircuitBreaker”</a> <a href="#fnref:circuit-breaker" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:freelancing" role="doc-endnote">
      <p>인시던트 대응 중 엔지니어들이 서로 조율 없이 각자 판단으로 시스템을 변경하는 상태. Google SRE Book이 비관리 인시던트의 전형적 실패 패턴으로 지목한다. — <a href="https://sre.google/sre-book/managing-incidents/">Google SRE Book, Ch.14 Managing Incidents</a> <a href="#fnref:freelancing" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:lifecycle" role="doc-endnote">
      <p>Preparation, Detection and Alerting, Triage and Prioritization, Containment, Resolution and Eradication, Recovery, Postmortem and Continuous Improvement. — <a href="https://rootly.com/incident-response/lifecycle-process">Rootly, “Incident Response Lifecycle Process”</a> <a href="#fnref:lifecycle" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:yoyo" role="doc-endnote">
      <p>복구를 서두르다 회귀 모니터링 없이 트래픽을 되돌려 같은 실패 모드가 다시 나타나는 현상. — <a href="https://rootly.com/incident-response/lifecycle-process">Rootly, “Incident Response Lifecycle Process”</a> <a href="#fnref:yoyo" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:idempotency" role="doc-endnote">
      <p>클라이언트가 요청에 고유 키를 실어 보내고, 서버는 같은 키로 재요청이 오면 재처리 없이 저장된 결과를 그대로 돌려준다. 이 장치가 없으면 타임아웃 뒤의 재시도가 이중 결제로 이어질 수 있다. — <a href="https://dzone.com/articles/art-of-idempotency-preventing-double-charges-and-duplicate">DZone, “Art of Idempotency”</a> <a href="#fnref:idempotency" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:blameless" role="doc-endnote">
      <p>인시던트에 관여한 모두가 그 순간 가진 정보로 최선의 판단을 했다고 전제하는 회고 문화. 비난 문화에서는 처벌이 두려워 문제가 드러나지 않는다는 게 핵심 근거다. — <a href="https://sre.google/sre-book/postmortem-culture/">Google SRE Book, Ch.15 Postmortem Culture</a> <a href="#fnref:blameless" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:mttr" role="doc-endnote">
      <p>MTTR(Mean Time to Resolve) — 탐지부터 완전 복구까지 걸리는 총 시간. 관측 가능성, 명확한 역할, 숙달된 런북에 투자한 팀이 개인의 영웅적 대응에 의존하는 팀보다 낮은 MTTR을 기록한다. — <a href="https://www.pagerduty.com/resources/incident-management-response/learn/best-practices-to-reduce-mttr/">PagerDuty, “Best Practices to Reduce MTTR”</a> <a href="#fnref:mttr" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><category term="장애대응" /><category term="회고" /><category term="아키텍처" /><summary type="html"><![CDATA[오늘 결제 서비스에서 실패율이 급증했다.]]></summary></entry><entry><title type="html">서비스는 움직이는데, 문서는 멈춰 있었다</title><link href="https://seokrae.github.io/blog/2026/07/15/flowcast-1-why-visual-docs.html" rel="alternate" type="text/html" title="서비스는 움직이는데, 문서는 멈춰 있었다" /><published>2026-07-15T00:00:00+09:00</published><updated>2026-07-15T00:00:00+09:00</updated><id>https://seokrae.github.io/blog/2026/07/15/flowcast-1-why-visual-docs</id><content type="html" xml:base="https://seokrae.github.io/blog/2026/07/15/flowcast-1-why-visual-docs.html"><![CDATA[<p><strong>문서는 쓸모없고, 낡았다.</strong></p>

<p>정확히는, 지금의 서비스를 설명하지 못하는 문서가 그렇다.</p>

<p>처음부터 틀린 문서는 아니었을 것이다. 만들어질 당시에는 서비스의 구조와 흐름을 제대로 담고 있었을지도 모른다. 그러다 새로운 기능이 추가되고, 책임이 분리되고, 다른 시스템과의 연결이 늘어나는 동안 문서는 작성된 순간에 그대로 머물렀다.</p>

<p>서비스는 가만히 있지 않는다. 운영 과정에서 발견한 문제를 고치고 새로운 요구사항을 받아들이며 계속 개선되고 확장된다.</p>

<p>문서는 그렇지 않다. 코드와 설정은 서비스가 동작하려면 함께 바뀌어야 하지만, 문서는 고치지 않아도 빌드가 깨지지 않고 배포도 막히지 않는다. 그렇게 서비스가 앞으로 나아가는 동안 문서는 현실에서 조금씩 멀어진다.</p>

<p>살아 움직이는 서비스를 설명하려면 문서도 함께 움직여야 한다. 서비스가 바뀌면 문서도 바뀌고, 구조가 달라지면 다이어그램도 다시 그려야 한다. 필요할 때 현재의 모습을 확인할 수 있어야 문서는 쓸모를 유지한다.</p>

<p>문제는 <strong>서비스와 문서를 계속 동기화하는 비용</strong>이다.</p>

<p>나는 이 비용을 AI로 낮출 수 있는지 확인하기 위해 <a href="https://github.com/SeokRae/flowcast">flowcast</a>를 만들고 있다.</p>

<h2 id="새-서비스를-맡을-때마다-같은-자리에서-시작했다">새 서비스를 맡을 때마다 같은 자리에서 시작했다</h2>

<p>새 서비스를 맡을 때마다 이 문제를 반복해서 만났다.
담당이 정해지고 저장소를 열면 실행 방법은 적혀 있지만, 정작 서비스를 이해할 수 있는 문서는 없었다. 위키에 그림이 남아 있어도 지금 구조와 맞는지 아무도 장담하지 못했다.</p>

<p>문서가 없어도 코드 레벨은 어떻게든 따라갈 수 있다.
클래스와 호출 관계를 좇다 보면, 시간이 걸릴 뿐 서비스가 무슨 일을 하는지는 어느 정도 드러난다. “문서가 없으면 코드가 문서”라는 말이 아주 틀린 건 아니다. 적어도 코드 레벨에서는 그렇다.</p>

<p>문제는 서비스 경계 바깥의 흐름이다.
요청이 어느 존에서 시작해 어떤 게이트웨이와 큐를 거쳐 다른 서비스로 넘어가는지는 코드 한 곳을 읽는다고 나오지 않는다. 설정 파일과 방화벽 규칙, 배포 스크립트, 그리고 “그건 옆 팀에 물어봐야 한다”는 사람의 기억에 흩어져 있다.</p>

<p>게다가 이 흐름에는 여러 사람이 얽혀 있다.
개발자뿐 아니라 인프라 담당자, 다른 서비스 담당자, 그리고 “이 결제가 어디를 거쳐 나가느냐”를 묻는 업무 담당자도 같은 구조를 이해해야 한다. 그래서 모두가 함께 볼 수 있는 그림 한 장이 절실한데, 정작 그 그림을 믿기 어렵다.</p>

<h2 id="문서를-만드는-순간보다-그다음이-어렵다">문서를 만드는 순간보다 그다음이 어렵다</h2>

<p>그림이 없으면 새로 만들면 된다고 생각했다.
다이어그램을 하나 그려 위키에 올리고 나면 거기서 끝이 아니다. 유지보수가 시작된다.</p>

<p>서비스 경계를 리팩터링하거나 큐를 추가하거나 내부 API 이름을 바꾸면 코드는 달라지지만 위키 속 그림은 그대로 남는다. 누가 일부러 방치해서가 아니다. 그림을 고치는 일은 늘 더 급한 일에 밀린다.</p>

<p>수작업 다이어그램의 진짜 비용은 처음 그릴 때보다 <strong>두 번째 수정</strong>에서 드러난다.
한 장을 그리는 일은 할 만하다. 구조나 스타일이 바뀌어 여러 장을 다시 손봐야 하면 그때부터 관련된 그림을 하나씩 찾아 같은 작업을 반복하게 된다. 낡은 그림은 이 비용 구조가 만든 결과다.</p>

<p>여기서 생각이 하나 바뀌었다.</p>

<p><strong>다이어그램은 독립된 산출물이 아니라 추적 가능한 근거의 파생물이어야 한다.</strong></p>

<p>근거가 되는 코드나 설정, 원문 없이 떠 있는 그림은 나중에 고치려고 해도 무엇을 기준으로 확인해야 할지 알 수 없다. 확인할 수 없으니 고치기 어렵고, 고치기 어려우니 낡는다. 예쁜 그림 한 장보다 출처를 되짚을 수 있는 그림이 더 중요한 이유다.</p>

<p>그런데 출처를 연결한다고 문제가 끝나는 것은 아니다.
서비스가 바뀔 때마다 사람이 다시 자료를 읽고 그림을 고쳐야 한다면 동기화 비용은 여전히 사람의 몫이다.</p>

<h2 id="사람만으로는-변화의-속도를-따라가기-어렵다">사람만으로는 변화의 속도를 따라가기 어렵다</h2>

<p>문서화가 중요하다는 데 반대하는 사람은 없다. 회고 때마다 나오는 말이고, 다음 일정이 시작되면 먼저 밀리는 항목일 뿐이다.</p>

<p>나도 한동안 개인 시간을 내서 이 간격을 메우려고 했다.
담당 서비스의 흐름을 그려보고, 다른 팀에 물어 빈칸을 채우고, 여러 그림의 스타일을 맞췄다. 그렇게 다 그리고 나면 서비스는 다시 달라져 있었다. 살아 움직이는 서비스를 정지된 그림으로 계속 따라가는 방식에는 한계가 있었다.</p>

<p>그래서 질문을 바꿔보기로 했다.</p>

<blockquote>
  <p>사람이 문서를 더 열심히 관리하는 대신, <strong>AI를 잘 활용해 동기화 비용을 낮출 수는 없을까?</strong></p>
</blockquote>

<h2 id="그림이-항상-답인-것은-아니다">그림이 항상 답인 것은 아니다</h2>

<p>여기서 왜 하필 그림인지도 짚어볼 필요가 있다.</p>

<p>그림이 글보다 기억에 잘 남는다는 것은 인지심리학에서 오래 다뤄진 주제다. <a href="https://doi.org/10.1007/BF01320076">dual coding theory</a>는 시각 정보와 언어 정보가 분리되면서도 연결된 체계로 처리된다고 본다. <a href="https://doi.org/10.1016/S0022-5371(67)80067-7">Shepard의 1967년 실험</a>에서는 수백 장의 그림을 다시 봤을 때 알아보는 재인 정확도가 90%대 후반에 이르렀다.</p>

<p>그렇다고 이 결과를 “다이어그램이 복잡한 구조를 더 잘 이해하게 한다”는 주장으로 곧장 옮길 수는 없다.
소프트웨어 아키텍처를 직접 대상으로 한 <a href="https://doi.org/10.1109/ESEM.2011.25">Heijstek 등의 통제 실험</a>에서는 그래픽과 텍스트 중 어느 쪽도 유의하게 더 효율적이지 않았다. 오히려 텍스트 위주로 본 참가자들이 토폴로지 관련 문항에서 더 높은 점수를 받았다.</p>

<p>그림이 언제나 글보다 나은 것은 아니다.
중요한 것은 표현 수단의 서열이 아니라 정보의 성격에 맞는 표현을 선택하는 일이다. 서비스 사이의 경계와 경로처럼 공간적이고 구조적인 정보는 글만으로 전달하면 독자가 머릿속에서 다시 조립해야 한다. 반대로 잘못 그린 그림은 그럴듯한 오해를 더 빠르게 퍼뜨린다.</p>

<p>그래서 목표는 그림을 많이 만드는 것이 아니다.
근거를 되짚을 수 있고, 변경되면 적은 비용으로 다시 만들 수 있는 그림을 만드는 것이다.</p>

<h2 id="기존-접근에서-한-걸음-더-가보기">기존 접근에서 한 걸음 더 가보기</h2>

<p>다이어그램을 서비스 변화와 함께 관리하려는 접근은 이미 있다.
그중 하나가 diagrams-as-code다. 그림을 텍스트로 코드 옆에 두고 버전 관리하며 필요할 때 다시 렌더하는 방식이다. <a href="https://c4model.com/">C4 model</a>, <a href="https://plantuml.com/">PlantUML</a>, <a href="https://mermaid.js.org/">Mermaid</a> 같은 모델과 도구가 이 계보에 있다.</p>

<p>그림을 PR에서 diff로 확인하고 코드 변경과 함께 갱신한다는 점에서 유지보수 문제에 잘 맞는다.
하지만 내 문제의 절반은 남는다. 그 텍스트를 누군가는 여전히 손으로 써야 하고, 그러려면 그전에 시스템을 이미 이해하고 있어야 한다.</p>

<p>diagrams-as-code가 그림을 관리하는 비용을 낮춘다면, 그보다 앞선 <strong>저작 과정</strong>에도 AI를 활용해볼 수 있지 않을까.
사람이 코드와 설정, 기존 문서를 매번 처음부터 읽고 흐름을 옮기는 대신 AI가 먼저 자료를 읽어 초안을 만들게 하는 것이다. 사람의 역할은 빈 화면에서 작성하는 쪽에서, AI가 만든 결과의 근거를 확인하고 잘못된 부분을 바로잡는 쪽으로 옮겨간다.</p>

<p>이 접근이 동기화를 자동으로 해결해주지는 않는다.
다만 문서를 다시 만드는 비용을 낮춘다면, 서비스가 바뀔 때 문서를 갱신하는 일을 지금보다 더 자주 시도할 수 있다.</p>

<h2 id="이-접근을-확인하기-위해-flowcast를-만들고-있다">이 접근을 확인하기 위해 flowcast를 만들고 있다</h2>

<p>이 가능성을 확인해보기 위해 만들고 있는 것이 <strong>flowcast</strong>다.</p>

<p>현재 flowcast에서는 확정된 흐름 문서와 명세, 설명, PPT 같은 입력 자료를 다이어그램 단위로 나눈다. 그리고 검토와 수정, 재생성까지 잇는 구조를 시험하고 있다. 에이전트가 자료에서 관계를 추출해 중간 표현을 만들고 다이어그램으로 렌더하는 방식이다. 각 다이어그램에는 근거가 된 원문의 경로를 남겨 검토할 때 출처를 되짚을 수 있게 하려 한다.</p>

<figure class="flowcast-embed">
<style>
.flowcast-embed{margin:2em 0;padding:1.25em;border:1px solid rgba(31,111,208,0.18);border-radius:10px;background:#fbfcfe;--muted:#54667e;--accent:#1f6fd0;--line:rgba(31,111,208,0.42);--line-soft:rgba(31,111,208,0.30);--act-bg:rgba(31,111,208,0.12);--act-bd:rgba(31,111,208,0.32);--zone-bg:rgba(31,111,208,0.07);--zone-bd:rgba(31,111,208,0.30);--font:-apple-system,"Apple SD Gothic Neo","Noto Sans KR","Segoe UI",sans-serif;--mono:ui-monospace,"JetBrains Mono",Menlo,monospace;}
[data-theme="dark"] .flowcast-embed{background:#0c1524;border-color:rgba(153,186,255,0.14);--muted:#98abc9;--accent:#68b6ff;--line:rgba(104,182,255,0.75);--line-soft:rgba(140,166,205,0.55);--act-bg:rgba(104,182,255,0.15);--act-bd:rgba(104,182,255,0.32);--zone-bg:rgba(104,182,255,0.08);--zone-bd:rgba(104,182,255,0.32);}
.flowcast-embed svg{max-width:100%;height:auto;}
.flowcast-embed .zone{fill:var(--zone-bg);stroke:var(--zone-bd);stroke-width:1.5;}
.flowcast-embed .zone-tx{fill:var(--accent);font-family:var(--font);font-size:12px;font-weight:700;}
.flowcast-embed .actor{fill:var(--zone-bg);stroke:var(--zone-bd);stroke-width:1.5;}
.flowcast-embed .actor-tx{fill:var(--accent);font-family:var(--font);font-size:12.5px;font-weight:700;}
.flowcast-embed .actor-sub{fill:var(--muted);font-family:var(--mono);font-size:10px;}
.flowcast-embed .lifeline{stroke:var(--line-soft);stroke-opacity:0.4;stroke-width:1;stroke-dasharray:5,5;}
.flowcast-embed .act-bar{fill:var(--act-bg);stroke:var(--act-bd);stroke-width:1;}
.flowcast-embed .ar{fill:none;}
.flowcast-embed .ar-req{stroke:var(--line);stroke-width:1.8;}
.flowcast-embed .ar-res{stroke:var(--line-soft);stroke-width:1.5;stroke-dasharray:6,4;}
.flowcast-embed .mk-req, .flowcast-embed .mk-self{fill:var(--line);}
.flowcast-embed .mk-res, .flowcast-embed .mk-relay{fill:var(--line-soft);}
.flowcast-embed text{font-family:var(--font);}
.flowcast-embed .lb-req, .flowcast-embed .lb-self{fill:var(--accent);font-size:12px;font-weight:600;}
.flowcast-embed .lb-res{fill:var(--muted);font-size:12px;}
.flowcast-embed .lb-proto{fill:var(--muted);font-size:10.5px;font-family:var(--mono);}
</style>
<svg viewBox="0 0 906 553" style="width:100%;display:block;" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="주문 결제 FLOW"><defs><marker id="mk-req" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="mk mk-req" d="M0,0 L0,8 L8,4z" /></marker><marker id="mk-res" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="mk mk-res" d="M0,0 L0,8 L8,4z" /></marker><marker id="mk-relay" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="mk mk-relay" d="M0,0 L0,8 L8,4z" /></marker><marker id="mk-self" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="mk mk-self" d="M0,0 L0,8 L8,4z" /></marker></defs><rect class="zone" x="206" y="14" width="154" height="30" rx="8" /><text class="zone-tx" x="283.0" y="33.0" text-anchor="middle">Frontend</text><rect class="zone" x="376" y="14" width="324" height="30" rx="8" /><text class="zone-tx" x="538.0" y="33.0" text-anchor="middle">Backend</text><line class="lifeline" x1="113.0" y1="104" x2="113.0" y2="537" /><line class="lifeline" x1="283.0" y1="104" x2="283.0" y2="537" /><line class="lifeline" x1="453.0" y1="104" x2="453.0" y2="537" /><line class="lifeline" x1="623.0" y1="104" x2="623.0" y2="537" /><line class="lifeline" x1="793.0" y1="104" x2="793.0" y2="537" /><rect class="actor" x="38.0" y="52" width="150" height="52" rx="9" /><text class="actor-tx" x="113.0" y="82.0" text-anchor="middle">Buyer</text><rect class="actor" x="208.0" y="52" width="150" height="52" rx="9" /><text class="actor-tx" x="283.0" y="82.0" text-anchor="middle">Web Store</text><rect class="actor" x="378.0" y="52" width="150" height="52" rx="9" /><text class="actor-tx" x="453.0" y="75.0" text-anchor="middle">Order API</text><text class="actor-sub" x="453.0" y="93.0" text-anchor="middle">8080</text><rect class="actor" x="548.0" y="52" width="150" height="52" rx="9" /><text class="actor-tx" x="623.0" y="82.0" text-anchor="middle">Payment Gateway</text><rect class="actor" x="718.0" y="52" width="150" height="52" rx="9" /><text class="actor-tx" x="793.0" y="82.0" text-anchor="middle">Bank</text><rect class="act-bar" x="107.0" y="149" width="12" height="364" rx="2" /><rect class="act-bar" x="277.0" y="149" width="12" height="364" rx="2" /><rect class="act-bar" x="447.0" y="196" width="12" height="270" rx="2" /><rect class="act-bar" x="617.0" y="258" width="12" height="161" rx="2" /><rect class="act-bar" x="787.0" y="305" width="12" height="67" rx="2" /><line class="ar-req ar" x1="120.0" y1="159" x2="275.0" y2="159" marker-end="url(#mk-req)" /><text class="lb-req" x="198.0" y="153" text-anchor="middle">1. 상품 주문</text><line class="ar-req ar" x1="290.0" y1="206" x2="445.0" y2="206" marker-end="url(#mk-req)" /><text class="lb-req" x="368.0" y="200" text-anchor="middle">2. 결제 요청</text><text class="lb-proto" x="368.0" y="221" text-anchor="middle">( HTTPS )</text><line class="ar-req ar" x1="460.0" y1="268" x2="615.0" y2="268" marker-end="url(#mk-req)" /><text class="lb-req" x="538.0" y="262" text-anchor="middle">3. 승인 요청</text><line class="ar-req ar" x1="630.0" y1="315" x2="785.0" y2="315" marker-end="url(#mk-req)" /><text class="lb-req" x="708.0" y="309" text-anchor="middle">4. 카드 승인</text><line class="ar-res ar" x1="786.0" y1="362" x2="631.0" y2="362" marker-end="url(#mk-res)" /><text class="lb-res" x="708.0" y="356" text-anchor="middle">5. 승인 응답</text><line class="ar-res ar" x1="616.0" y1="409" x2="461.0" y2="409" marker-end="url(#mk-res)" /><text class="lb-res" x="538.0" y="403" text-anchor="middle">6. 승인 결과</text><line class="ar-res ar" x1="446.0" y1="456" x2="291.0" y2="456" marker-end="url(#mk-res)" /><text class="lb-res" x="368.0" y="450" text-anchor="middle">7. 결제 완료</text><line class="ar-res ar" x1="276.0" y1="503" x2="121.0" y2="503" marker-end="url(#mk-res)" /><text class="lb-res" x="198.0" y="497" text-anchor="middle">8. 주문 완료</text></svg>
<figcaption style="margin-top:0.75em;color:var(--muted);font-size:0.85em;text-align:center;">flowcast가 생성한 시퀀스 다이어그램 예시 (합성 데이터 · <a href="https://github.com/SeokRae/flowcast">SeokRae/flowcast</a>)</figcaption>
</figure>

<h2 id="필요한-관점부터-스킬로-만들고-있다">필요한 관점부터 스킬로 만들고 있다</h2>

<p>flowcast를 처음부터 프로젝트 시각화 도구로 설계한 것은 아니다.
출발점은 내가 실제 프로젝트를 이해하고 설명하는 데 필요했던 그림이었다.</p>

<p>필요한 그림이 하나 생길 때마다 그 관점을 다루는 방법을 별도의 스킬로 만들었다. 지금의 <code class="language-plaintext highlighter-rouge">flowcast</code>는 입력을 다이어그램 단위로 나누고, 무엇을 보여줘야 하는지에 따라 이 스킬들을 선택해 연결한다. 하나의 자료에서 여러 그림이 필요하면 각각의 스킬이 맡을 단위로 나눠 동시에 처리한다.</p>

<p>만들면서 프로젝트를 시각화한다는 의미도 조금씩 넓어졌다.
여기서 프로젝트는 일정이나 Issue, WBS 같은 관리 현황을 뜻하지 않는다. 내가 보고 싶은 것은 코드와 설정, 컴포넌트와 인프라, 요청과 데이터가 함께 움직이는 <strong>소프트웨어 프로젝트 자체</strong>다.</p>

<p>하나의 프로젝트도 질문에 따라 필요한 그림이 달라진다.</p>

<table>
  <thead>
    <tr>
      <th>관점</th>
      <th>알고 싶은 것</th>
      <th>스킬</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>동작</td>
      <td>요청과 응답이 어떤 순서로 오가는가</td>
      <td><code class="language-plaintext highlighter-rouge">sequence</code></td>
    </tr>
    <tr>
      <td>실행 환경</td>
      <td>서비스가 어느 존과 인프라를 거치는가</td>
      <td><code class="language-plaintext highlighter-rouge">topology</code></td>
    </tr>
    <tr>
      <td>구성</td>
      <td>컴포넌트와 포트가 어떻게 연결되는가</td>
      <td><code class="language-plaintext highlighter-rouge">component</code></td>
    </tr>
  </tbody>
</table>

<p>지금 있는 것은 이 셋뿐이다. 소스 구조나 의존성, 데이터 흐름처럼 더 다뤄야 할 관점이 남아 있지만 아직 이름만 붙여둔 단계라 약속처럼 늘어놓지는 않겠다.</p>

<p>위에서 본 시퀀스가 서비스의 <strong>동작</strong>을 시간순으로 본 것이라면, <strong>실행 환경</strong>(topology)과 <strong>구성</strong>(component)은 다른 질문에 답하는 관점이다. 세 예시는 같은 시스템이 아니라 관점마다 다른 합성 예제이고, flowcast로 그리면 각각 아래처럼 보인다.</p>

<figure class="flowcast-embed">
<style>
.flowcast-embed{margin:2em 0;padding:1.25em;border:1px solid rgba(31,111,208,0.18);border-radius:10px;background:#fbfcfe;--muted:#54667e;--accent:#1f6fd0;--surface:#ffffff;--border:rgba(28,60,110,0.16);--text:#1b2635;--line:rgba(31,111,208,0.42);--line-soft:rgba(31,111,208,0.30);--zone-bg:rgba(31,111,208,0.07);--zone-bd:rgba(31,111,208,0.30);--note-bg:rgba(181,126,10,0.07);--note-bd:rgba(181,126,10,0.42);--font:-apple-system,"Apple SD Gothic Neo","Noto Sans KR","Segoe UI",sans-serif;--mono:ui-monospace,"JetBrains Mono",Menlo,monospace;}
[data-theme="dark"] .flowcast-embed{background:#0c1524;border-color:rgba(153,186,255,0.14);--muted:#98abc9;--accent:#68b6ff;--surface:#16233b;--border:rgba(153,186,255,0.14);--text:#edf3ff;--line:rgba(104,182,255,0.75);--line-soft:rgba(140,166,205,0.55);--zone-bg:rgba(104,182,255,0.08);--zone-bd:rgba(104,182,255,0.32);--note-bg:rgba(255,208,114,0.06);--note-bd:rgba(255,208,114,0.38);}
.flowcast-embed svg{max-width:100%;height:auto;}
.flowcast-embed .topo-zone{fill:var(--zone-bg);stroke:var(--zone-bd);stroke-width:1.4;stroke-dasharray:5,4;}
.flowcast-embed .topo-zone-tx{fill:var(--accent);font-family:var(--font);font-size:11.5px;font-weight:700;}
.flowcast-embed .topo-node{fill:var(--surface);stroke:var(--border);stroke-width:1.4;}
.flowcast-embed .topo-node.dim{opacity:0.5;}
.flowcast-embed .topo-node.on{fill:var(--zone-bg);stroke:var(--accent);stroke-width:1.9;}
.flowcast-embed .topo-ext{fill:var(--note-bg);stroke:var(--note-bd);}
.flowcast-embed .topo-gear{fill:var(--surface);stroke:var(--line-soft);stroke-dasharray:4,3;}
.flowcast-embed .topo-tx{fill:var(--muted);font-family:var(--font);font-size:11px;font-weight:600;}
.flowcast-embed .topo-tx.on{fill:var(--accent);}
.flowcast-embed .topo-link{stroke:var(--line-soft);stroke-width:1.3;fill:none;opacity:0.55;}
.flowcast-embed .topo-seg{fill:none;stroke:var(--line);stroke-width:2;}
.flowcast-embed .mk-topo{fill:var(--line);}
.flowcast-embed .topo-badge{fill:var(--accent);stroke:var(--surface);stroke-width:1.6;}
[data-theme="dark"] .flowcast-embed .topo-badge{fill:#2a72c8;}
.flowcast-embed .topo-badge-tx{fill:#fff;font-family:var(--font);font-size:11px;font-weight:700;}
.flowcast-embed .topo-legend-h{fill:var(--muted);font-family:var(--font);font-size:11px;font-weight:700;}
.flowcast-embed .topo-legend-tx{fill:var(--text);font-family:var(--font);font-size:11.5px;}
.flowcast-embed text{font-family:var(--font);}
</style>
<svg viewBox="12 -20 980 458" style="width:100%;display:block;" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="요청 처리 FLOW"><defs><marker id="mk-topo" markerWidth="9" markerHeight="9" refX="7.5" refY="3" orient="auto"><path class="mk-topo" d="M0,0 L8,3 L0,6z" /></marker></defs><g class="iff-zone" data-zone="dmz" data-members="lb" data-pad="14" data-lbl="18" data-lblpos="tl"><rect class="topo-zone" x="208" y="82" width="182" height="96" rx="10" /><text class="topo-zone-tx" x="218" y="95">DMZ</text></g><g class="iff-zone" data-zone="app" data-members="web1,web2,app1" data-pad="14" data-lbl="18" data-lblpos="tl"><rect class="topo-zone" x="404" y="-6" width="378" height="272" rx="10" /><text class="topo-zone-tx" x="414" y="7">App Tier</text></g><g class="iff-zone" data-zone="data" data-members="db,cache" data-pad="14" data-lbl="18" data-lblpos="tl"><rect class="topo-zone" x="796" y="-6" width="182" height="272" rx="10" /><text class="topo-zone-tx" x="806" y="7">Data Tier</text></g><line class="topo-link" data-from="client" data-to="lb" x1="180.0" y1="139.0" x2="222.0" y2="139.0" /><line class="topo-link" data-from="lb" data-to="web1" x1="354.6818181818182" y1="114.0" x2="439.3181818181818" y2="76.0" /><line class="topo-link" data-from="lb" data-to="web2" x1="354.6818181818182" y1="164.0" x2="439.3181818181818" y2="202.0" /><line class="topo-link" data-from="web1" data-to="app1" x1="550.6818181818182" y1="76.0" x2="635.3181818181818" y2="114.0" /><line class="topo-link" data-from="web2" data-to="app1" x1="550.6818181818182" y1="202.0" x2="635.3181818181818" y2="164.0" /><line class="topo-link" data-from="app1" data-to="db" x1="746.6818181818182" y1="114.0" x2="831.3181818181818" y2="76.0" /><line class="topo-link" data-from="app1" data-to="cache" x1="746.6818181818182" y1="164.0" x2="831.3181818181818" y2="202.0" /><path class="topo-seg" data-from="client" data-to="lb" d="M180.0,139.0 L222.0,139.0" marker-end="url(#mk-topo)" /><path class="topo-seg" data-from="lb" data-to="web1" d="M354.6818181818182,114.0 L439.3181818181818,76.0" marker-end="url(#mk-topo)" /><path class="topo-seg" data-from="web1" data-to="app1" d="M550.6818181818182,76.0 L635.3181818181818,114.0" marker-end="url(#mk-topo)" /><path class="topo-seg" data-from="app1" data-to="db" d="M746.6818181818182,114.0 L831.3181818181818,76.0" marker-end="url(#mk-topo)" /><path class="topo-seg" data-from="app1" data-to="cache" d="M746.6818181818182,164.0 L831.3181818181818,202.0" marker-end="url(#mk-topo)" /><g class="iff-node" data-id="client" data-cx="103.0" data-cy="139.0" data-w="154" data-h="50"><rect class="topo-node topo-ext on" x="26" y="114" width="154" height="50" rx="9" /><text class="topo-tx on" x="103.0" y="143.0" text-anchor="middle">Client</text></g><g class="iff-node" data-id="lb" data-cx="299.0" data-cy="139.0" data-w="154" data-h="50"><rect class="topo-node topo-gear on" x="222" y="114" width="154" height="50" rx="9" /><text class="topo-tx on" x="299.0" y="143.0" text-anchor="middle">Load Balancer</text></g><g class="iff-node" data-id="web1" data-cx="495.0" data-cy="51.0" data-w="154" data-h="50"><rect class="topo-node on" x="418" y="26" width="154" height="50" rx="9" /><text class="topo-tx on" x="495.0" y="55.0" text-anchor="middle">Web 1</text></g><g class="iff-node" data-id="web2" data-cx="495.0" data-cy="227.0" data-w="154" data-h="50"><rect class="topo-node dim" x="418" y="202" width="154" height="50" rx="9" /><text class="topo-tx" x="495.0" y="231.0" text-anchor="middle">Web 2</text></g><g class="iff-node" data-id="app1" data-cx="691.0" data-cy="139.0" data-w="154" data-h="50"><rect class="topo-node on" x="614" y="114" width="154" height="50" rx="9" /><text class="topo-tx on" x="691.0" y="143.0" text-anchor="middle">App Server</text></g><g class="iff-node" data-id="db" data-cx="887.0" data-cy="51.0" data-w="154" data-h="50"><rect class="topo-node on" x="810" y="26" width="154" height="50" rx="9" /><text class="topo-tx on" x="887.0" y="55.0" text-anchor="middle">Database</text></g><g class="iff-node" data-id="cache" data-cx="887.0" data-cy="227.0" data-w="154" data-h="50"><rect class="topo-node on" x="810" y="202" width="154" height="50" rx="9" /><text class="topo-tx on" x="887.0" y="231.0" text-anchor="middle">Cache</text></g><g class="iff-badge" data-from="client" data-to="lb"><circle class="topo-badge" cx="203.10000000000002" cy="139.0" r="11" /><text class="topo-badge-tx" x="203.10000000000002" y="143.0" text-anchor="middle">1</text></g><g class="iff-badge" data-from="lb" data-to="web1"><circle class="topo-badge" cx="401.2318181818182" cy="93.10000000000001" r="11" /><text class="topo-badge-tx" x="401.2318181818182" y="97.10000000000001" text-anchor="middle">2</text></g><g class="iff-badge" data-from="web1" data-to="app1"><circle class="topo-badge" cx="597.2318181818182" cy="96.9" r="11" /><text class="topo-badge-tx" x="597.2318181818182" y="100.9" text-anchor="middle">3</text></g><g class="iff-badge" data-from="app1" data-to="db"><circle class="topo-badge" cx="793.2318181818182" cy="93.10000000000001" r="11" /><text class="topo-badge-tx" x="793.2318181818182" y="97.10000000000001" text-anchor="middle">4</text></g><g class="iff-badge" data-from="app1" data-to="cache"><circle class="topo-badge" cx="793.2318181818182" cy="184.9" r="11" /><text class="topo-badge-tx" x="793.2318181818182" y="188.9" text-anchor="middle">5</text></g><text class="topo-legend-h" x="26" y="296">흐름 설명</text><text class="topo-legend-tx" x="26" y="319">1. HTTPS 요청</text><text class="topo-legend-tx" x="26" y="340">2. 부하 분산</text><text class="topo-legend-tx" x="26" y="361">3. API 호출</text><text class="topo-legend-tx" x="26" y="382">4. 데이터 조회</text><text class="topo-legend-tx" x="26" y="403">5. 캐시 갱신</text></svg>
<figcaption style="margin-top:0.75em;color:var(--muted);font-size:0.85em;text-align:center;">flowcast가 생성한 topology(구성도) 다이어그램 예시 — 존 배치 위에 요청 흐름을 번호로 오버레이 (합성 데이터 · <a href="https://github.com/SeokRae/flowcast">SeokRae/flowcast</a>)</figcaption>
</figure>

<figure class="flowcast-embed">
<style>
.flowcast-embed{margin:2em 0;padding:1.25em;border:1px solid rgba(31,111,208,0.18);border-radius:10px;background:#fbfcfe;--muted:#54667e;--accent:#1f6fd0;--text:#1b2635;--line:rgba(31,111,208,0.42);--zone-bg:rgba(31,111,208,0.07);--zone-bd:rgba(31,111,208,0.30);--note-bg:rgba(181,126,10,0.07);--note-bd:rgba(181,126,10,0.42);--comp-bg:rgba(35,170,155,0.13);--comp-bd:rgba(20,130,120,0.55);--font:-apple-system,"Apple SD Gothic Neo","Noto Sans KR","Segoe UI",sans-serif;--mono:ui-monospace,"JetBrains Mono",Menlo,monospace;}
[data-theme="dark"] .flowcast-embed{background:#0c1524;border-color:rgba(153,186,255,0.14);--muted:#98abc9;--accent:#68b6ff;--text:#edf3ff;--line:rgba(104,182,255,0.75);--zone-bg:rgba(104,182,255,0.08);--zone-bd:rgba(104,182,255,0.32);--note-bg:rgba(255,208,114,0.06);--note-bd:rgba(255,208,114,0.38);--comp-bg:rgba(78,205,190,0.14);--comp-bd:rgba(78,205,190,0.44);}
.flowcast-embed svg{max-width:100%;height:auto;}
.flowcast-embed .comp-zone{fill:var(--zone-bg);stroke:var(--zone-bd);stroke-width:1.4;stroke-dasharray:6,4;}
.flowcast-embed .comp-zone-tx{fill:var(--accent);font-family:var(--font);font-size:12px;font-weight:700;}
.flowcast-embed .comp-node{fill:var(--comp-bg);stroke:var(--comp-bd);stroke-width:1.6;}
.flowcast-embed .comp-ext{fill:var(--note-bg);stroke:var(--note-bd);}
.flowcast-embed .comp-name{fill:var(--text);font-family:var(--font);font-size:12px;font-weight:700;}
.flowcast-embed .comp-port{fill:var(--muted);font-family:var(--mono);font-size:10px;font-weight:700;}
.flowcast-embed .comp-edge{fill:none;stroke:var(--line);stroke-width:1.7;}
.flowcast-embed .comp-lb{fill:var(--text);font-family:var(--font);font-size:11px;font-weight:600;}
.flowcast-embed .comp-proto{fill:var(--muted);font-family:var(--mono);font-size:10px;}
.flowcast-embed .mk-comp{fill:var(--line);}
.flowcast-embed text{font-family:var(--font);}
</style>
<svg viewBox="-4 -24 648 386" style="width:100%;max-width:648px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="결제 처리 Process"><defs><marker id="mk-comp" markerWidth="9" markerHeight="9" refX="7.5" refY="3" orient="auto"><path class="mk-comp" d="M0,0 L8,3 L0,6z" /></marker><marker id="mk-comp-s" markerWidth="9" markerHeight="9" refX="0.5" refY="3" orient="auto"><path class="mk-comp" d="M8,0 L0,3 L8,6z" /></marker></defs><g class="iff-zone" data-zone="internal" data-members="gw,order,pay,db" data-pad="18" data-lbl="20" data-lblpos="tr"><rect class="comp-zone" x="12" y="-8" width="616" height="354" rx="12" /><text class="comp-zone-tx" x="616" y="7" text-anchor="end">&lt; Internal &gt;</text></g><path class="comp-edge" data-from="gw" data-to="order" d="M157.71666666666667,150.0 L268.2833333333333,88.0" marker-end="url(#mk-comp)" /><path class="comp-edge" data-from="order" data-to="db" d="M396.0,59.0 L458.0,59.0" marker-end="url(#mk-comp)" /><path class="comp-edge" data-from="gw" data-to="pay" d="M157.71666666666667,208.0 L268.2833333333333,270.0" marker-end="url(#mk-comp)" /><path class="comp-edge" data-from="pay" data-to="psp" d="M396.0,299.0 L458.0,299.0" marker-end="url(#mk-comp)" /><g class="iff-node" data-id="gw" data-cx="106.0" data-cy="179.0" data-w="152" data-h="58"><rect class="comp-node" x="30" y="150" width="152" height="58" rx="9" /><text class="comp-name" x="106.0" y="176.0" text-anchor="middle">API Gateway</text><text class="comp-port" x="106.0" y="192.0" text-anchor="middle">Port: 8080</text></g><g class="iff-node" data-id="order" data-cx="320.0" data-cy="59.0" data-w="152" data-h="58"><rect class="comp-node" x="244" y="30" width="152" height="58" rx="9" /><text class="comp-name" x="320.0" y="56.0" text-anchor="middle">Order Service</text><text class="comp-port" x="320.0" y="72.0" text-anchor="middle">Port: 8081</text></g><g class="iff-node" data-id="pay" data-cx="320.0" data-cy="299.0" data-w="152" data-h="58"><rect class="comp-node" x="244" y="270" width="152" height="58" rx="9" /><text class="comp-name" x="320.0" y="296.0" text-anchor="middle">Payment Service</text><text class="comp-port" x="320.0" y="312.0" text-anchor="middle">Port: 8082</text></g><g class="iff-node" data-id="db" data-cx="534.0" data-cy="59.0" data-w="152" data-h="58"><rect class="comp-node" x="458" y="30" width="152" height="58" rx="9" /><text class="comp-name" x="534.0" y="63.0" text-anchor="middle">Order DB</text></g><g class="iff-node" data-id="psp" data-cx="534.0" data-cy="299.0" data-w="152" data-h="58"><rect class="comp-node comp-ext" x="458" y="270" width="152" height="58" rx="9" /><text class="comp-name" x="534.0" y="303.0" text-anchor="middle">Payment Gateway</text></g><text class="comp-lb" data-from="gw" data-to="order" x="213.0" y="99.0" text-anchor="middle">(1) 주문 생성</text><text class="comp-proto" data-from="gw" data-to="order" x="213.0" y="113.0" text-anchor="middle">( http )</text><text class="comp-lb" data-from="order" data-to="db" x="427.0" y="39.0" text-anchor="middle">(2) 주문 저장</text><text class="comp-proto" data-from="order" data-to="db" x="427.0" y="53.0" text-anchor="middle">( jdbc )</text><text class="comp-lb" data-from="gw" data-to="pay" x="213.0" y="219.0" text-anchor="middle">(3) 결제 요청</text><text class="comp-proto" data-from="gw" data-to="pay" x="213.0" y="233.0" text-anchor="middle">( http )</text><text class="comp-lb" data-from="pay" data-to="psp" x="427.0" y="279.0" text-anchor="middle">(4) 카드 승인</text><text class="comp-proto" data-from="pay" data-to="psp" x="427.0" y="293.0" text-anchor="middle">( https )</text></svg>
<figcaption style="margin-top:0.75em;color:var(--muted);font-size:0.85em;text-align:center;">flowcast가 생성한 component 다이어그램 예시 — 포트를 가진 컴포넌트와 프로토콜 연결 (합성 데이터 · <a href="https://github.com/SeokRae/flowcast">SeokRae/flowcast</a>)</figcaption>
</figure>

<p>세 관점을 직접 움직여 가며 보고 싶다면 <a href="https://seokrae.github.io/flowcast/">flowcast 예제 갤러리</a>에 인터랙티브 버전이 있다.</p>

<p>앞으로도 모든 정보를 한 장에 욱여넣는 거대한 스킬을 만들 생각은 없다. 프로젝트를 이해하면서 생기는 질문마다 적합한 관점을 작은 스킬로 만들고, flowcast가 필요한 관점을 선택하고 조합하게 하는 방향을 생각하고 있다.</p>

<p>아직 스킬로 만들지 않았지만 가장 확인해보고 싶은 관점은 <strong>변화</strong>다.
코드나 설정이 바뀌었을 때 영향을 받는 흐름과 다이어그램을 찾아내 다시 생성하고, 사람이 변경 내용과 근거를 검토한다. 이것이 된다면 서비스와 문서 사이의 간격을 지금보다 적은 비용으로 줄일 수 있다. 낡은 문서라는 처음의 문제로 그대로 돌아오는 셈이다.</p>

<p>결국 만들고 싶은 것은 그림을 그리는 스킬 하나가 아니다.
<strong>소프트웨어 프로젝트를 여러 관점에서 읽고, 프로젝트가 변하면 그 관점도 다시 갱신할 수 있는 시각화 스킬의 집합</strong>이다.</p>

<p>물론 AI가 그린 그림을 그대로 믿을 수는 없다.
입력 문서가 이미 낡았을 수도 있고, 코드만으로 드러나지 않는 운영 정보도 있으며, AI가 관계를 잘못 추론할 수도 있다. 사람의 검토와 판단은 여전히 필요하다.</p>

<p>그래서 flowcast는 낡은 문서 문제의 해결책이라고 말할 수 없다.
AI를 잘 활용하면 더 적은 비용으로 서비스와 문서 사이의 간격을 줄일 수 있는지, 그 접근이 실제로 쓸 만한지 확인해보려고 만들고 있는 도구다.</p>

<p>각 스킬이 어떤 자료를 읽고, 어떻게 관점을 나누고, 어떤 방식으로 근거를 확인하는지는 다음 편들의 몫으로 남겨둔다.
이번 글에서 세워두고 싶은 출발점은 하나다.</p>

<blockquote>
  <p>서비스가 살아 움직인다면 문서도 함께 움직여야 한다. 나는 AI가 그 동기화 비용을 얼마나 낮출 수 있는지 확인해보려 한다.</p>
</blockquote>]]></content><author><name></name></author><category term="문서화" /><category term="아키텍처" /><category term="시각화" /><category term="AI" /><category term="flowcast" /><category term="회고" /><summary type="html"><![CDATA[문서는 쓸모없고, 낡았다.]]></summary></entry><entry><title type="html">옮긴 이유가 틀렸다는 걸 뒤늦게 알았다 — Chirpy에서 Type Theme로</title><link href="https://seokrae.github.io/blog/2026/07/13/chirpy-to-type-theme.html" rel="alternate" type="text/html" title="옮긴 이유가 틀렸다는 걸 뒤늦게 알았다 — Chirpy에서 Type Theme로" /><published>2026-07-13T00:00:00+09:00</published><updated>2026-07-13T00:00:00+09:00</updated><id>https://seokrae.github.io/blog/2026/07/13/chirpy-to-type-theme</id><content type="html" xml:base="https://seokrae.github.io/blog/2026/07/13/chirpy-to-type-theme.html"><![CDATA[<p>이 블로그는 원래 <a href="https://github.com/cotes2020/jekyll-theme-chirpy">Chirpy</a> 테마로 시작했다.
사이드바, 다크모드, PWA, 검색, 카테고리·태그 아카이브까지 웬만한 건 다 들어 있는 잘 만든 테마다.
그런데 얼마 못 가 <a href="https://github.com/rohanchandra/type-theme">Type Theme</a>라는, 사이드바도 없는 단일 컬럼 미니멀 테마로 갈아탔다.</p>

<p>교체 커밋과 <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> 변경 이력에 나는 이유를 이렇게 적어뒀다.</p>

<blockquote>
  <p>Chirpy 저장소는 archived 상태라 유지보수 대신 교체 선택.</p>
</blockquote>

<p>이 글은 그 한 줄이 <strong>틀렸다는 걸 나중에 알게 된</strong> 이야기다.
그 착오 덕분에 “왜 옮겼는가”를 정직하게 다시 쓰면 무엇이 남는지를 짚어본 이야기이기도 하다.</p>

<h2 id="적어둔-이유가-사실이-아니었다">적어둔 이유가 사실이 아니었다</h2>

<p>테마를 옮기고 한참 뒤, 이 결정을 글로 정리하려고 저장소 상태를 다시 확인했다.
그런데 방향이 뒤집혀 있었다.</p>

<ul>
  <li><strong>Chirpy는 archived가 아니었다.</strong> 이 글을 쓰는 2026년 7월 시점에도 릴리스가 계속 나오고 이슈가 활발하게 오가는, 여전히 살아 있는 프로젝트였다.</li>
  <li><strong>정작 archived인 쪽은 도착지 Type Theme였다.</strong> 저장소 상단에 “This repository was archived by the owner on Jul 26, 2025. It is now read-only.”라고 붙어 있다.</li>
</ul>

<p>즉 나는 “소스가 죽었으니 옮긴다”고 적어놓고, 실제로는 <strong>살아 있는 테마를 떠나 멈춘 테마로 이사</strong>했다.
교체의 근거였던 “archived”라는 사실은 떠나온 곳이 아니라 도착한 곳에 붙는 라벨이었다.</p>

<p>이게 이 글에서 제일 하고 싶은 이야기다.
결정 자체가 틀렸다는 게 아니라 — 뒤에서 보겠지만 옮긴 건 여러모로 잘한 일이었다 — <strong>결정을 정당화하려고 갖다 붙인 “검증 가능한 사실”을 검증하지 않고 적었다</strong>는 것.
changelog에 “저장소가 archived다” 같은 문장을 쓸 때, 그건 취향이나 판단이 아니라 30초면 확인되는 팩트다.
그 30초를 건너뛰면, 미래의 내가 그 기록을 근거로 삼았다가 이렇게 뒤집힌다.</p>

<p>역설적이게도 이 교훈을 가장 확실하게 가르쳐준 건 이 글을 쓰기 위한 리서치 그 자체였다.</p>

<h2 id="그럼-진짜-왜-옮겼나">그럼 진짜 왜 옮겼나</h2>

<p>가짜 이유를 걷어내고 나면 진짜 동인이 남는다.
그건 “archived”보다 훨씬 정직하고, 훨씬 설득력 있다.</p>

<p>이 블로그의 컨셉은 <strong>인사이트</strong>다. 절차를 나열하는 튜토리얼이 아니라 “왜 그렇게 판단했는지, 무엇을 배웠는지”를 쓰는 공간.
이 컨셉은 테마를 옮기기 전부터 이미 정해져 있었다.
그런 글에 Chirpy가 제공하는 기능 대부분은 그냥 <strong>안 쓰는 무게</strong>였다.</p>

<p>교체 커밋 하나의 순증감이 이걸 그대로 보여준다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+136 / -706 lines
</code></pre></div></div>

<p>무엇이 사라졌는지가 더 흥미롭다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">_config.yml</code>이 <strong>237줄 → 72줄</strong>로 줄었다. PWA, comments, analytics, webmaster verification, collections/defaults 같은 Chirpy 전용 블록이 통째로 빠졌다.</li>
  <li><code class="language-plaintext highlighter-rouge">.devcontainer/</code>, <code class="language-plaintext highlighter-rouge">.vscode/</code>, <code class="language-plaintext highlighter-rouge">tools/run.sh</code>, <code class="language-plaintext highlighter-rouge">tools/test.sh</code> 같은 스캐폴딩이 사라졌다.</li>
  <li>정적 자산을 끌어오던 git submodule(<code class="language-plaintext highlighter-rouge">chirpy-static-assets</code>)도 제거했다.</li>
</ul>

<p>여기서 배운 건 조금 반직관적이다. 기능이 많은 걸 버리는 것 자체가 이득이었다.
Chirpy가 나빠서가 아니다. 인사이트 블로그라는 목적에는 그 기능들이 대부분 불필요했고, 안 쓰는 기능은 유지보수 면적으로만 남는다.
“더 많은 걸 주는 도구”가 항상 더 좋은 선택은 아니라는, 알면서도 자주 잊는 사실을 700줄 삭제로 다시 확인했다.</p>

<h2 id="테마-교체인-줄-알았는데-배포-모델-교체였다">“테마 교체”인 줄 알았는데 “배포 모델 교체”였다</h2>

<p>가장 아팠던 지점은 여기다.
나는 이 작업을 “파일 몇 개 바꾸는 테마 교체”로 생각했는데, 실제로는 <strong>배포 파이프라인을 바꾸는 일</strong>이었다.</p>

<p>Chirpy는 GitHub Actions로 사이트를 직접 빌드해서 배포했다.
워크플로가 <code class="language-plaintext highlighter-rouge">jekyll build</code>로 <code class="language-plaintext highlighter-rouge">_site</code>를 만들고, <code class="language-plaintext highlighter-rouge">htmlproofer</code>로 검사한 뒤, 그 결과물을 Pages에 올리는 구조다.
Type Theme로 옮기면서 나는 이 방식을 버리고 <strong>GitHub Pages 네이티브 빌드</strong>(Pages가 알아서 Jekyll을 돌리는 방식)로 전환했다. Actions로 직접 빌드할 이유가 없어졌으니까.</p>

<p>문제는 저장소에 <code class="language-plaintext highlighter-rouge">.nojekyll</code> 파일이 그대로 남아 있었다는 것이다.</p>

<p>같은 파일인데 두 배포 모델에서 의미가 정반대다.</p>

<ul>
  <li><strong>Actions로 직접 빌드할 때</strong>: 워크플로가 이미 <code class="language-plaintext highlighter-rouge">_site</code>를 완성해서 넘긴다. 그러니 Pages한테 “여기서 Jekyll 또 돌리지 말고 준 대로 서빙해”라고 말하는 <code class="language-plaintext highlighter-rouge">.nojekyll</code>이 <strong>맞는</strong> 신호다.</li>
  <li><strong>네이티브 빌드로 바뀐 뒤</strong>: 이제 Jekyll을 돌려줄 주체가 Pages 자신이다. 그런데 <code class="language-plaintext highlighter-rouge">.nojekyll</code>이 “Jekyll 돌리지 마”라고 막아버리니, 소스가 처리되지 않은 채 그대로 노출됐다. 사이트가 깨졌다.</li>
</ul>

<p>결국 별도 hotfix 커밋으로 정리했다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fix: remove .nojekyll so GitHub Pages legacy build runs Jekyll
</code></pre></div></div>

<p>교훈은 파일 한 줄보다 크다.
<strong>배포 모델을 바꾸면, 이전 모델에서는 옳았던 설정이 새 모델에서는 지뢰가 된다.</strong>
<code class="language-plaintext highlighter-rouge">.nojekyll</code>은 버그가 아니라 “예전 문맥에서는 정답이었던 것”이다.
파이프라인을 갈아탈 때 위험한 건 새로 추가하는 것보다 이전 파이프라인이 남기고 간 전제들이다.
그것들은 조용히 남아 있다가, 문맥이 바뀐 순간 반대로 작동한다.</p>

<h2 id="멈춘-테마를-신뢰하는-법">멈춘 테마를 신뢰하는 법</h2>

<p>앞에서 “archived 테마로 이사했다”는 게 실수처럼 들렸을 수 있다.
하지만 archived라는 사실을 <strong>알고 나면</strong>, 오히려 그걸 안전하게 다루는 방법이 명확해진다.</p>

<p>원격 테마(<code class="language-plaintext highlighter-rouge">remote_theme</code>)를 브랜치 이름으로 추적하면, 그쪽이 언제 바뀌든 내 사이트가 따라 흔들린다.
그래서 재현 가능한 빌드를 위해 세 겹으로 고정했다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># _config.yml — 테마를 커밋 SHA로 못 박음 (브랜치 추적 X)</span>
<span class="na">remote_theme</span><span class="pi">:</span> <span class="s">rohanchandra/type-theme@c6ec5a69ff7dfe2df193be08515193c72bd4a55d</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile — github-pages gem 버전 고정</span>
<span class="n">gem</span> <span class="s2">"github-pages"</span><span class="p">,</span> <span class="s2">"232"</span><span class="p">,</span> <span class="ss">group: :jekyll_plugins</span>
</code></pre></div></div>

<p>여기에 더해 <code class="language-plaintext highlighter-rouge">Gemfile.lock</code>을 <code class="language-plaintext highlighter-rouge">.gitignore</code>에서 빼고 커밋에 포함시켰다.
테마 SHA, gem 버전, lockfile — 세 개를 동시에 잠근 것이다.</p>

<p>흥미로운 건, <strong>archived라는 사실이 이 결정을 오히려 정당화한다</strong>는 점이다.
움직이지 않는 테마이기 때문에 특정 커밋에 묶어도 뒤에서 갱신돼 깨질 일이 없다.
“죽은 프로젝트에 의존한다”는 리스크를, “죽었으니 절대 안 움직인다”는 안정성으로 뒤집은 셈이다.
(다만 이건 보안 패치도 안 온다는 뜻이기도 하다. 원격 테마에 스크립트가 많았다면 다시 고민했을 것이다.)</p>

<h3 id="포크가-아니라-최소-오버라이드">포크가 아니라 최소 오버라이드</h3>

<p>원격 테마의 몇몇 기본값은 내 상황에 안 맞아서 손을 봐야 했다.
선택지는 둘이었다.</p>

<ol>
  <li><strong>테마 전체를 포크</strong>해서 내 저장소로 들여온다 → 이제 테마 전체가 내 유지보수 대상이 된다.</li>
  <li><strong>영향받는 템플릿만 오버라이드</strong>한다 → 유지보수 면적은 작지만, 나중에 핀을 올릴 때 오버라이드를 수동으로 동기화해야 한다.</li>
</ol>

<p>2번을 택했다. 실제로 오버라이드한 건 <code class="language-plaintext highlighter-rouge">head.html</code>, <code class="language-plaintext highlighter-rouge">header.html</code>, <code class="language-plaintext highlighter-rouge">default.html</code>, <code class="language-plaintext highlighter-rouge">post.html</code>, <code class="language-plaintext highlighter-rouge">page.html</code>, <code class="language-plaintext highlighter-rouge">main.scss</code> 정도다.
그리고 이 트레이드오프의 대가(동기화 부담)를 잊지 않으려고 커밋 메시지에 지시문으로 박아뒀다.</p>

<blockquote>
  <p>Keep theme overrides synchronized if the pinned Type Theme commit is intentionally upgraded.</p>
</blockquote>

<p>정답이 있는 선택은 아니었다. 다만 <strong>“작은 유지보수 면적 + 명시된 동기화 리스크”가, “포크로 얻는 자유 + 무한 유지보수 면적”보다 이 블로그 규모엔 맞다</strong>고 판단했다.
중요한 건 트레이드오프를 고른 것보다, 고른 뒤에 그 대가를 기록으로 남긴 것이다.</p>

<h2 id="blog라는-접두어가-만든-버그들"><code class="language-plaintext highlighter-rouge">/blog</code>라는 접두어가 만든 버그들</h2>

<p>이 블로그는 user page(<code class="language-plaintext highlighter-rouge">user.github.io</code>)가 아니라 project page(<code class="language-plaintext highlighter-rouge">user.github.io/blog</code>)다.
즉 <code class="language-plaintext highlighter-rouge">baseurl</code>이 <code class="language-plaintext highlighter-rouge">/blog</code>다.
그런데 원본 테마의 URL 처리 기본값들은 대체로 user page(baseurl 없음)를 가정하고 있었다.
그 간극에서 같은 종류의 버그가 세 번 터졌다. 전부 “<code class="language-plaintext highlighter-rouge">/blog</code>가 한 번 더 붙는” 문제였다.</p>

<p><strong>1. 페이지네이션 경로 중복</strong></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># baseurl이 이미 /blog인데 경로에 blog/를 또 넣어 /blog/blog/page2/가 됨</span>
<span class="na">paginate_path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">blog/page:num"</span>   <span class="c1"># 문제</span>
<span class="na">paginate_path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/page:num/"</span>      <span class="c1"># 수정</span>
</code></pre></div></div>

<p><strong>2. 검색 결과 URL의 이중 슬래시</strong></p>

<div class="language-liquid highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;!-- baseurl + 앞에 /가 붙은 post.url = /blog//... --&gt;
"<span class="p">{{</span><span class="w"> </span><span class="nv">site</span><span class="p">.</span><span class="nv">baseurl</span><span class="w"> </span><span class="p">}}</span>/<span class="p">{{</span><span class="w"> </span><span class="nv">post</span><span class="p">.</span><span class="nv">url</span><span class="w"> </span><span class="p">}}</span>"   &lt;!-- 문제 --&gt;
"<span class="p">{{</span><span class="w"> </span><span class="nv">post</span><span class="p">.</span><span class="nv">url</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">relative_url</span><span class="w"> </span><span class="p">}}</span>"       &lt;!-- 수정 --&gt;
</code></pre></div></div>

<p><strong>3. 빈 feature image가 홈페이지를 배경으로 요청</strong></p>

<p>feature 이미지가 없는 글에서, 원본 테마는 <code class="language-plaintext highlighter-rouge">background-image: url('/blog/')</code>를 깔았다.
빈 값에 baseurl만 남아 <strong>사이트 홈페이지 자체를 배경 이미지로 불러오는</strong> 요청이 나간 것이다.
<code class="language-plaintext highlighter-rouge">page.feature-img</code>가 있을 때만 배경을 지정하도록 오버라이드해서 막았다.</p>

<p>세 버그의 해법이 전부 같은 방향이라는 게 핵심이다.
baseurl을 문자열로 손수 이어 붙이지 말고, <code class="language-plaintext highlighter-rouge">relative_url</code> 같은 Jekyll 필터나 명시적 조건에 맡기는 것.
문자열 연결로 URL을 만들면 “붙는다/안 붙는다”의 경계에서 반드시 사고가 난다.
이 버그 클래스는 user page에서는 절대 안 보인다. baseurl이 빈 문자열이라 이중으로 붙어도 티가 안 나니까.
테마를 가져다 쓸 때 그 테마가 어떤 배포 형태를 가정하는지는, 문서가 아니라 이렇게 URL이 깨지고 나서야 드러난다.</p>

<h2 id="발행-전에-신뢰성부터">발행 전에, 신뢰성부터</h2>

<p>여기서 한 판단이 스스로도 의외였다.
<strong>글을 한 편도 쓰기 전에</strong> 계약 테스트와 CI부터 깔았다.</p>

<p>포스트 6개짜리 픽스처를 만들어 실제로 <code class="language-plaintext highlighter-rouge">jekyll build</code>를 돌리고, 생성된 결과물을 단언으로 검증하는 테스트다.
위에서 고친 버그들이 회귀하지 않는지를 실제 빌드 산출물에 대고 확인한다.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># test/site_output_test.rb — 생성된 HTML을 직접 단언</span>
<span class="n">refute_match</span><span class="p">(</span><span class="sr">%r{"url": "/blog//}</span><span class="p">,</span> <span class="n">search</span><span class="p">)</span>          <span class="c1"># 이중 슬래시 재발 방지</span>
<span class="n">refute_includes</span> <span class="n">about</span><span class="p">,</span> <span class="s2">"background-image: url('/blog/')"</span>  <span class="c1"># 홈 배경 요청 방지</span>
<span class="n">assert_includes</span> <span class="n">search</span><span class="p">,</span> <span class="s1">'integrity="sha384-'</span>       <span class="c1"># 검색 CDN 스크립트 SRI</span>
<span class="n">assert_includes</span> <span class="n">search</span><span class="p">,</span> <span class="s1">'aria-label="검색"'</span>         <span class="c1"># 접근성</span>
</code></pre></div></div>

<p>그리고 이걸 CI로 강제했다. push/PR마다 Ruby 3.4 환경에서 이 테스트가 돈다.
여기서 GitHub Actions의 역할이 바뀌었다는 게 재밌다. 예전엔 Actions가 “배포”를 담당했는데, 이제 배포는 Pages 네이티브가 맡고 <strong>Actions는 “빌드 계약을 먼저 검증”하는 게이트</strong>가 됐다. 같은 도구, 다른 임무.</p>

<p>콘텐츠가 0개인데 테스트부터 까는 게 과해 보일 수 있다.
하지만 인사이트 블로그의 신뢰는 글 내용에서만 오는 게 아니다.
독자가 링크를 눌렀을 때 사이트가 안 깨지는 것, 페이지네이션이 정상인 것, 검색이 되는 것 — 이 기반이 무너지면 글이 아무리 좋아도 신뢰가 안 쌓인다.
<code class="language-plaintext highlighter-rouge">.nojekyll</code> 하나로 사이트가 통째로 깨졌던 경험이 이 판단을 밀어줬다.</p>

<p>곁다리로 하나 더 드러났다.
테마를 교체하며 빌드 <code class="language-plaintext highlighter-rouge">exclude</code> 목록을 정리하다가, <strong><code class="language-plaintext highlighter-rouge">CLAUDE.md</code>가 정적 파일로 그대로 서빙되고 있었다</strong>는 걸 발견했다.
프론트매터 없는 최상위 <code class="language-plaintext highlighter-rouge">.md</code>는 Jekyll이 그냥 복사해서 공개 페이지로 노출한다.
<code class="language-plaintext highlighter-rouge">exclude</code>에 추가해서 막았지만, 교훈은 그대로다. 빌드 방식이 바뀌면 “무엇이 공개되는가”의 규칙도 같이 바뀐다. 테마 교체는 렌더링만 바꾸는 게 아니라, 노출 경계도 다시 그린다.</p>

<h2 id="그래서-무엇을-배웠나">그래서 무엇을 배웠나</h2>

<p>테마 하나 옮긴 작업치고 배운 게 많았는데, 정리하면 결국 하나로 모인다.</p>

<p><strong>“왜”를 기록할 때, 그 안에 섞인 “검증 가능한 사실”은 그 자리에서 검증하라.</strong>
“단일 컬럼이 컨셉에 맞아서”는 판단이라 틀릴 수 없지만, “저장소가 archived라서”는 팩트라서 틀릴 수 있다.
나는 후자를 확인 없이 적었고, 그 한 줄이 이 글 전체를 다시 쓰게 만들었다.</p>

<p>나머지 교훈들도 결이 같다.</p>

<ul>
  <li><strong>테마 교체는 대개 배포 모델 교체다.</strong> 이전 모델이 남긴 전제(<code class="language-plaintext highlighter-rouge">.nojekyll</code>)가 새 문맥에서 반대로 작동한다.</li>
  <li><strong>멈춘 의존성은 핀으로 고정하면 오히려 안정적이다.</strong> archived가 리스크이자 동시에 안정성의 근거가 된다.</li>
  <li><strong>URL은 문자열로 잇지 말고 필터에 맡겨라.</strong> project page의 <code class="language-plaintext highlighter-rouge">/blog</code> 접두어가 그걸 세 번 가르쳐줬다.</li>
</ul>

<p>다시 한다면 딱 하나를 바꾸겠다.
결정을 changelog에 적는 순간, “이 문장은 판단인가 사실인가”를 한 번 자문하는 것.
사실이라면, 적기 전에 확인하는 30초.
그 30초가 없어서 이 글이 태어났다는 게, 조금 웃기면서도 이 블로그의 컨셉에 가장 잘 맞는 결말인 것 같다.</p>]]></content><author><name></name></author><category term="Jekyll" /><category term="GitHub Pages" /><category term="블로그" /><category term="회고" /><summary type="html"><![CDATA[이 블로그는 원래 Chirpy 테마로 시작했다. 사이드바, 다크모드, PWA, 검색, 카테고리·태그 아카이브까지 웬만한 건 다 들어 있는 잘 만든 테마다. 그런데 얼마 못 가 Type Theme라는, 사이드바도 없는 단일 컬럼 미니멀 테마로 갈아탔다.]]></summary></entry></feed>