← 목록으로

내 IT 유니버스를 하나로: 20년 블로그와 복잡한 웹 생태계를 모노레포로 통합한 기술 여정

2026. 7. 7.

내 IT 유니버스를 하나로: 20년 블로그와 복잡한 웹 생태계를 모노레포로 통합한 기술 여정


title: I Contain Multitudes (and Also Three Git Repos) published: true description: '20년 역사의 블로그 포스팅, 수십 개 강연 자료, 그리고 dev.to 콘텐츠까지. 이 모든 복잡한 IT 생태계를 하나의 모노레포로 통합하고, 콘텐츠 동기화 파이프라인을 구축한 과정을 실무자의 시선에서 풀어봅니다.' tags:


제 블로그는 운영에 사용되는 대부분의 도구들보다 훨씬 나이가 많습니다. 가장 오래된 포스팅들은 2000년대 초반 LiveJournal에서 처음 시작되었고, 2000년대 중반 어느 시점에 WordPress로 힘겹게 끌려왔죠. 이후 10년 가까이 Hugo 사이트로 지내다가, 약 일주일 전 마침내 Astro 5 사이트로 이전하면서 오래된 모든 URL을 바이트 단위까지 완벽하게 재현했습니다. 2001년부터 2020년까지 2,630개의 포스팅이 마치 시간 속에 얼어붙은 것처럼 그 자리에 그대로 남아있는 셈이죠.

같은 시기에, 제 강연 웹사이트 (2012년부터 지금까지 106개의 강연 기록)도 Notist에서 벗어나 제가 온전히 소유하는 형태로 전환했습니다. 그리고 dev.to 초안을 Git과 동기화하는 도구는 이제야 원래 의도했던 대로 제대로 작동하게 되었죠. 한동안 엉뚱하게 작동해서 애를 먹이던 부분이 드디어 제자리를 찾은 겁니다.

이 세 가지 시스템은 모두 원래 별개의 Git 레포지토리였습니다. 하지만 오늘부터 이들은 하나의 모노레포로 통합되었고, 각 레포의 실제 Git 히스토리는 "최초 커밋"으로 뭉뚱그려지지 않고 온전히 보존되었습니다. 지금부터 이 여정을 함께 살펴보시죠. 두 웹사이트가 어떻게 작동하는지, dev.to는 이들 중 어느 부분에 어떻게 통합되는지, 그리고 이 과정에서 어떤 흥미로운 버그들이 발생했는지 이야기해 드릴게요.

오늘날, 제 웹 생태계의 모습

세 가지 시스템, 한 명의 개발자, 그리고 이제는 하나의 레포지토리:

  • mattstratton.com은 제 개인 웹사이트입니다. 2,630개의 오래된 아카이브 포스팅과 더불어, 아카이브에 속하지 않는 모든 글을 위한 새로운 /writing/ 섹션을 포함하고 있습니다.
  • speaking.mattstratton.com은 제 강연 아카이브입니다. 모든 발표 자료, 영상, 이벤트 기록을 직접 호스팅하고 있죠.
  • mattstratton-dev-to는 웹사이트가 아닙니다. dev.to 초안과 이 Git 레포지토리를 동기화하는 작은 도구죠. dev.to에 발행된 글이 mattstratton.com에도 자체적으로 표시될 수 있도록 해주는 핵심 장치입니다. 지금 여러분이 읽고 계신 이 글도 이 파이프라인을 통해 양쪽에 게시될 예정입니다.

이 시스템들은 이제 하나의 모노레포로 합쳐졌지만, 각각 고유한 설정과 존재 이유를 가진 세 개의 독립적인 관심사로 여전히 작동하고 있습니다. 하나씩 자세히 들여다보겠습니다.

mattstratton.com: 20년 역사를 고스란히 보존하는 디지털 금고

현재 웹사이트는 Astro 5와 Tailwind v4를 사용합니다. 이번 마이그레이션에서 모든 결정을 지배했던 단 하나의 규칙이 있습니다: URL 보존은 최우선 지상주의(prime directive)입니다. astro.config.mjstrailingSlash: 'always'를 설정하고, 이전 Hugo URL을 정확히 재현하기 위해 디렉토리 형식으로 빌드됩니다. 그리고 그 Hugo URL 또한 WordPress URL을 계승한 것이었죠. 2009년 블로그 롤이든, 2014년 강연 요약을 링크한 컨퍼런스 사이트든, 오래된 외부 링크들은 여전히 유효합니다. 제가 다른 프레임워크로 사이트를 재작성한다고 해서 누구도 깨진 링크를 고칠 필요가 없게 된 겁니다.

posts 컬렉션은 2001년부터 2020년까지의 글이 담긴, 더 이상 성장하지 않는 동결된 아카이브입니다. 그저 계속 존재해야 할 뿐이죠. 모든 새로운 글은 writing 섹션으로 들어갑니다. 그리고 이 writing 섹션은 제가 이 글을 쓰기 시작했을 때 예상했던 것보다 훨씬 더 많은 기능을 담고 있습니다.

필드 가이드: 제 글과 외부 기고의 시너지

/writing/ 섹션은 원래 훨씬 더 좁은 범위로 계획되었습니다. 이 프로젝트 이전에 세웠던 계획은 Postgres 중심의, 권위를 구축하는 콘텐츠만을 다루는 것이었죠. 이는 제 개인 블로그의 독자와는 완전히 다른 대상층을 위해 작성된 콘텐츠 전략 문서에서 계승된 아이디어였습니다. 이 좁은 범위에 대한 반발은 간단했습니다: 저는 다채로운 면모를 가지고 있는데, 하나의 주제만 다룰 수 있는 개인 웹사이트는 더 이상 '개인적'이지 않다는 것이었죠.

하지만 /writing/ 섹션이 지금 정말 흥미로운 점은 단순히 제 글만을 담고 있지 않다는 것입니다. 두 가지 전혀 다른 성격의 콘텐츠가 병합되어 있습니다:

  • src/content/writing/에 직접 작성된 글들은 content.config.ts의 스키마에 따라 title, description, pubDate, topics, draft를 가집니다. 특정 Postgres 아크를 위한 선택적 part 필드와 원본이 다른 곳에 있는 경우를 위한 canonicalUrl도 포함할 수 있죠.
  • 제가 고용주인 Tiger Data를 위해 작성한 외부 기고 링크들은 field-guide.ts에 수동으로 관리되는 목록으로 정의되어 있습니다:
export interface FieldGuideLink {
  title: string;
  description: string;
  part: "mechanics" | "limits" | "traps" | "decision";
  url: string;
}

현재 14개의 외부 링크가 있으며, 모두 tigerdata.com을 가리키고 있습니다. 이 글들은 자체 작성 글들이 선택할 수 있는 네 가지 part 카테고리 중 하나에 분류됩니다. writing.ts는 이 두 가지 형태를 title, description, href, external을 포함하는 하나의 공통 GuideItem으로 정규화합니다. 덕분에 템플릿은 이들을 동일하게 렌더링할 수 있으며, 각 파트 내에서는 자체 작성 글이 먼저, 외부 링크가 그 뒤에 나열되고 날짜에 따른 교차 배치는 없습니다. 유일한 시각적 표시는 외부 링크 제목 옆에 붙는 작은 "tigerdata.com ↗" 배지입니다. 새 탭으로 열리지는 않지만, 어디로 이동할지에 대한 정직한 안내를 제공하죠.

뉴스레터 아카이브: Astro가 정적 페이지로 Buttondown을 만나다

/newsletter/는 이 웹사이트의 대부분처럼 작동합니다: 빌드 시점에 데이터를 가져와서 정적 파일을 영원히 제공하는 방식이죠. buttondown.ts는 Astro 콘텐츠 레이어 로더로서, Buttondown API의 GET /emails?ordering=-publish_date 엔드포인트를 호출하고, 페이지네이션을 따라 모든 페이지를 가져온 다음 그 결과를 Astro의 newsletter 컬렉션으로 전달합니다. 각 뉴스레터 이슈 페이지, 아카이브 인덱스, RSS 피드는 모두 일반 getCollection() 호출의 정적 결과물일 뿐이며, 이는 로컬 Markdown 파일과 동일합니다.

이와 같은 방식을 고려하는 분들을 위해 두 가지 주목할 만한 디테일이 있습니다:

  • BUTTONDOWN_API_KEY가 설정되지 않은 경우, 로더는 경고를 기록하고 빈 컬렉션을 반환합니다. 빌드가 실패하지 않죠. 이는 누구나 제 API 키 없이 이 레포를 복제하고 로컬에서 빌드할 수 있다는 의미입니다.
  • Buttondown은 각 이슈의 본문을 Markdown으로 저장하므로, 로더는 템플릿에 도달하기 전에 marked.parse()를 통해 이를 처리합니다. 웹사이트는 API에서 가져온 순수 HTML을 전혀 사용하지 않습니다.
const key = import.meta.env.BUTTONDOWN_API_KEY ?? process.env.BUTTONDOWN_API_KEY;
if (!key) {
  logger.warn("BUTTONDOWN_API_KEY not set: newsletter archive will build empty.");
  return;
}

speaking.mattstratton.com: 내 강연 자료, 이제 온전히 내 소유로!

이전 강연 웹사이트는 Notist에서 운영되었는데, Notist는 두 가지 문제를 안고 있었고 결국 이 두 문제는 하나의 큰 문제로 귀결되었습니다. 비디오 임베드가 조용히 깨지기 시작했고, 모든 슬라이드 자료가 Notist의 CDN을 통해 제공되었다는 점이죠. 계정이 만료되는 날, 이미지와 PDF 파일도 함께 사라질 운명이었던 겁니다. 강연자의 전문적 정체성의 핵심이라고 할 수 있는, 제가 했던 모든 강연 자료가 타인의 데이터베이스에 얇은 내보내기 옵션만 가진 채 살아가고 있었다는 사실이 저를 괴롭혔습니다.

그래서 새 웹사이트의 전체 디자인 목표는 자산 소유권과 영구성입니다. 제 레포에, 제 도메인에, 제3자 의존성 없이 계속 작동하는 순수한 콘텐츠를 두는 것이었죠. 단순한 재디자인이 아니라, 포터빌리티(이식성) 프로젝트였습니다.

가장 흥미로운 부분은 슬라이드 파이프라인입니다. 이 파이프라인은 커밋이 발생하기 전, 전적으로 로컬에서 실행되며, 배포 호스트에서는 절대 실행되지 않습니다:

originals/{id}.pdf --최적화(gs, 300dpi)--> public/slides/{id}.pdf --래스터화--> public/slides/{id}/{n}.webp
   (gitignored, 고해상도)          (커밋됨, 제공됨, 다운로드 가능)     (커밋됨, 제공됨, 뷰어 이미지)

이것이 로컬에서 실행되어야 하는 이유는 간단합니다. 정적 호스트는 루트 권한을 제공하지 않고, pdftoppmgs를 실행하려면 루트 권한 (또는 최소한 실제 패키지 관리자)이 필요합니다. Netlify 빌드 안에서 Ghostscript를 쉘 명령으로 호출하려 한다면 실패할 수밖에 없을 겁니다. 래스터화 작업을 제 로컬 머신에서 수행하고 그 결과물을 커밋하면, 실제 배포 빌드는 지루하고 빠르며 호스트에 독립적인 astro build 작업으로만 남게 됩니다.

비디오 임베드도 동일한 "소유하라, 프록시하지 마라" 원칙을 따랐습니다. Notist의 접근 방식(이미 깨지기 시작한 프록시)을 재현하는 대신, 각 강연은 프런트매터에 간단한 프로바이더와 비디오 ID를 저장하고, 페이지는 클릭 시에만 실제 플레이어를 로드하는 경량 임베드 파사드(facade)를 렌더링합니다.

현재 통계는 이렇습니다: 2012년부터 2026년까지 106개의 강연, 93개의 이벤트, 36개의 영상, 3,684개의 슬라이드 이미지. 이 모든 것이 커밋되어 있으며, 단 한 번의 CDN 장애로 사라질 위험이 없습니다. 제가 실무에서 중요한 자료를 클라우드 서비스에 맡겼다가 예상치 못한 정책 변경이나 서비스 종료로 인해 곤란을 겪었던 경험이 몇 번 있었기에, 이처럼 모든 자산을 온전히 제 손으로 가져오는 작업은 제게 매우 의미 있는 일입니다.

강연 콘텐츠의 무한 탐색: 검색 엔진도 내 손안에

영상은 강연 사이트가 온전히 소유하지 못하는 유일한 자산 유형입니다. 레퍼런스는 호스팅할 수 있지만, 파일 자체는 아니죠. 하지만 강연 사이트가 소유할 수 있는 것은 바로 스크립트(transcript)이며, 이는 모든 강연 내용을 검색할 수 있는 매우 유용한 기능으로 발전했습니다.

원본 스크립트는 YouTube의 자동 생성 자막에서 가져옵니다. scripts/transcripts.ts 스크립트가 yt-dlp를 통해 이를 가져와 정리한 후 public/transcripts/{id}.txt에 저장하죠. 자동 생성 자막은 거칠고, 구두점이 없으며, 고유 명사가 훼손되고, 모든 "음"과 "어"가 충실하게 기록됩니다. transcript-cleanup Claude Code 스킬이 정리 작업을 담당하는데, 엄격한 규칙을 따릅니다: 구두점, 대문자 처리, 고유 명사 수정, 비유창한 표현 제거. 하지만 실제 발언 내용을 재작성하거나, 의역하거나, 순서를 바꾸는 일은 절대 없습니다. 의심스러울 때는 추측하기보다 원문을 그대로 두라는 지시를 따르죠.

검색 자체는 배포 시점에 빌드되는 두 개의 별도 JSON 페이로드를 기반으로 클라이언트 측에서 실행됩니다:

  • search-index.json.ts는 작고, 강연별 제목, 요약, 이벤트, 태그, 연도를 포함합니다. 검색창을 처음 열 때 로드되죠.
  • deep-index.json.ts는 용량이 큰 파일로, 각 강연의 스크립트와 슬라이드 텍스트를 하나의 검색 가능한 블록으로 연결하며, 실제로 쿼리를 입력할 때만 지연 로드됩니다:
const parts = [readTranscript(talk.id), readSlideText(talk.id)].filter(Boolean);
if (!parts.length) continue;
records.push({ url: talkUrl(talk), text: parts.join(" ").replace(/\s+/g, " ") });

Search.astro는 각 페이로드 위에 Fuse.js 퍼지 인덱스를 구축하고 결과를 병합합니다. 덕분에 강연 중간에 제가 실제로 말했던 문구를 검색해도 제목의 단어뿐 아니라 정확한 강연을 찾아낼 수 있죠. 서버도, 데이터베이스도 없습니다. 단지 두 개의 정적 JSON 파일과 퍼지 매칭 라이브러리가 브라우저에서 모든 작업을 수행할 뿐입니다.

세 개의 레포가 하나의 모노레포로: 단순함 이상의 가치

세 개의 레포를 하나의 모노레포로 통합한 것은 단순히 깔끔함을 위한 것만은 아니었습니다. 세 개의 레포를 독립적으로 관리하는 것이 약간의 마찰을 일으킨 것은 맞지만, 실제 트리거는 동결된 아카이브와 새로 범위가 지정된 /writing/ 섹션 사이에 제 개인 웹사이트에 일반 글쓰기를 위한 공간이 남아있지 않다는 것을 깨달았을 때였습니다. 이것이 진짜 문제라는 것을 인지하자, "몇몇 레포를 병합하자"는 생각이 "아키텍처를 고치고, 김에 dev.to 크로스포스팅 기능도 제대로 작동하게 하자"는 방향으로 바뀌었습니다.

병합 메커니즘은 git subtree add를 사용했으며, 스쿼시(squash)나 필터-레포 리라이트(filter-repo rewrite) 방식이 아니었습니다. 이는 "이제 파일이 여기 있습니다, 믿으세요" 하는 식의 주장 대신, 원본 레포들의 전체 커밋 히스토리를 실제적이고 검증 가능한 조상으로 보존합니다. 두 원본 레포가 아직 젊었기 때문에 (각각 약 50개, 22개 커밋) 수십 년간의 고고학적 작업을 보호할 필요는 없었지만, 원칙에 따라 올바르게 처리하는 것이 그 자체로 만족스러웠습니다.

각자 고유한 CI를 가지고 있던 레포들을 병합할 때 발생할 수 있는 문제점 중 몇 가지는 미리 알아두면 좋습니다:

  • GitHub Actions 워크플로우는 실제 레포 루트의 .github/workflows/에서만 발견되며, 서브디렉토리에서는 절대 발견되지 않습니다. 초기 병합 후 dev.to 워크플로우가 서브프로젝트 자체의 .github/workflows/ 아래에 중첩되어 남아 있었는데, GitHub에는 완전히 보이지 않았습니다. 오류도, 경고도 없었죠. gh workflow list에서도 표시되지 않았고, 조용히 다시는 실행되지 않았습니다. 제가 실무에서 여러 프로젝트를 모노레포로 전환할 때, 이런 CI/CD 설정 문제는 언제나 발목을 잡는 단골손님이었죠. 특히 GitHub Actions 워크플로우가 서브디렉토리에서 자동으로 감지되지 않는다는 건 꽤나 당황스러운 부분입니다.
  • Netlify는 자체적으로 서브디렉토리별 재빌드를 범위 지정하지 않습니다. 모든 푸시는 연결된 모든 사이트를 재빌드하므로, 각 사이트의 netlify.toml에 명시적인 ignore 조건을 추가해야 합니다. 그리고 Netlify가 관련 변경 사항이 없어서 빌드를 건너뛰면, "콘텐츠 변경 없음으로 인한 빌드 취소"라고 표시되는데, 이는 마치 실패처럼 보이지만 실제로는 아무런 문제가 없는 상황입니다.
  • "Base directory"와 "Package directory"는 다른 Netlify 설정입니다. npm 워크스페이스가 없는 레포의 재링크 흐름에서는 Base directory만 표시되고 Package directory는 전혀 나타나지 않습니다. 매번 겪는 일이지만, 가정하기보다는 실제 문서를 읽는 것이 중요하다는 것을 몇 년마다 다시금 깨닫곤 합니다.
  • astro check는 서브프로젝트 경계를 존중하지 않습니다. 모노레포 루트에서 실행하면, 관련 없는 서브프로젝트의 콘텐츠 컬렉션을 다른 서브프로젝트의 생성된 타입으로 스캔하여 200개 이상의 거짓 타입 오류를 생성했습니다. tsconfig.json에 명시적인 exclude를 추가하자 이 오류는 3개로 줄었고, 이마저도 기존에 존재하던 무관한 오류였습니다.

이 중 두 가지 이상이 여러분이 병합하려는 프로젝트에 해당한다면, 여분의 오후 시간을 예산에 추가해두세요.

dev.to 연동: '소유'의 원칙으로 혼란스러운 양방향 동기화 탈출!

dev.to 동기화 도구는 플랫폼과 Git 간의 콘텐츠 동기화를 시도하는 모든 분께 진심으로 추천하고 싶은 한 가지 아이디어를 중심으로 구축되었습니다: '소유권(ownership), 방향성(direction) 아님' 이라는 원칙입니다. 전체적인 정신 모델은 레포의 CLAUDE.md에 자세히 설명되어 있지만, 요약하자면 이렇습니다:

이를 양방향 동기화로 생각하고 dev.to와 Git을 완벽하게 일치시키려 하거나, 양쪽으로 변경 사항을 푸시하려는 유혹에 빠지기 쉽습니다. 하지만 그렇게 하지 마세요. 끝없는 '환영 차이(phantom-diff)' 루프를 생성하여 양쪽 모두 자신이 실제 버전을 가지고 있다고 계속 착각하게 만듭니다. 대신, 글은 초안 작성 중일 때는 dev.to가 소유하며 Git은 이를 완전히 무시합니다. 사람이 import PR을 병합하면, 그 글은 프런트매터에 id 필드가 존재함으로써 영원히 Git이 소유하게 됩니다. 소유권은 단 한 번, 의도적인 순간에만 이전되며, 절대 자동으로 되돌아가지 않습니다. 초기에는 저도 양방향 동기화에 욕심을 냈지만, 결국 끝없는 충돌과 머리 아픈 수동 개입의 연속이었습니다. '소유권' 개념을 명확히 설정한 뒤로는 비로소 평화가 찾아왔죠.

두 개의 작은 워크플로우가 실제 작업을 수행합니다. devto-import.yml은 크론(cron)에 따라 실행되어 새로 게시된 dev.to 게시물을 가져오고 검토를 위한 PR을 엽니다. 이미 id를 가진 파일은 절대 건드리지 않습니다. devto-publish.ymlposts/ 아래 파일에 푸시가 발생할 때 실행되며, 로컬 편집 내용을 dev.to로 다시 푸시하고 게시물의 첫 발행 시 id를 다시 기록합니다. 이들의 도메인은 절대 겹치지 않으며, 이것이 핵심입니다.

크로스포스트 파이프라인 (이 글도 이 경로를 탑니다!)

글이 Git 소유가 되면, 프런트매터에 crosspost: true를 추가하여 크로스포스팅을 선택할 수 있습니다. 이렇게 하면 해당 글은 mattstratton.com의 /writing/ 컬렉션에 네이티브 항목으로 재게시되고, dev.to 게시물의 canonical_urlcrosspost-devto.ts를 통해 mattstratton.com을 가리키도록 재작성됩니다. 이 플래그는 글이 이미 id를 가지고 있다는 것, 즉 이미 dev.to에 게시되어 있다는 것을 의미합니다. 이는 의도적인, 나중에 선택하는 옵션이며, 초기 초안 템플릿의 일부가 아닙니다. 이 글은 "이전"의 좋은 예시입니다:

---
title: "I Contain Multitudes (and Also Three Git Repos)"
published: false
description: "..."
tags: [astro, webdev, showdev]
---

id도, crosspost도, canonical_url도 없습니다. 이 글이 게시되고 제가 mattstratton.com에도 속해야 한다고 결정하면, decouple-release-from-deploy.md가 그랬던 것처럼 세 가지 모두를 갖게 될 것입니다:

id: 4061454
date: "2026-07-03T15:43:16Z"
crosspost: true
canonical_url: "https://www.mattstratton.com/writing/decouple-release-from-deploy/"

개인적으로 마음에 드는 디자인 디테일은 이것이 하나의 원자적(atomic) 커밋으로 통합된다는 것입니다. 두 단계로 나뉜 '게시 후 다시 돌아와 수정하는' 방식이 아닙니다. 크로스포스팅된 글의 mattstratton.com URL은 빌드가 시작되기도 전에 파일명으로부터 완전히 결정 가능합니다. dev.to 자체의 아티클 ID는 서버 할당이며 미리 알 수 없습니다. 따라서 하나의 커밋이 새로운 /writing/ 항목을 생성하고 동시에 dev.to 소스의 canonical_url을 같은 diff 내에서 재작성합니다. 그리고 하나의 푸시가 Netlify 배포와 dev.to의 기존 게시 워크플로우를 정확히 트리거하여, 아무도 두 번째 단계를 지켜볼 필요가 없게 됩니다.

하나의 함정은 언급할 가치가 있습니다. 이것은 언뜻 보기에는 전혀 문제가 없어 보이지만 그렇지 않은 종류의 문제입니다: writing 스키마는 Astro 마이그레이션 초기 커밋부터 선택적 canonicalUrl 필드를 가지고 있었습니다. 실제로는 전혀 발생하지 않았던 상황을 대비해서였죠. 즉, 기본적으로 /writing/에 있지만, 원본은 다른 곳에 있는 글(예: 미러링된 Tiger Data 포스트)을 위한 것이었습니다. 크로스포스트 스크립트가 존재하기 전에는, 이 필드는 플레이스홀더 템플릿 파일의 주석 처리된 # canonicalUrl: ... # set when cross-posted 줄에서만 나타났습니다. 누구도 실제 글에 이 필드를 설정한 적이 없었죠. 하지만 dev.to 크로스포스트의 경우, mattstratton.com이 '캐노니컬 소스'가 되는 것이므로, 이 필드가 원래 의도했던 것과는 반대 상황입니다. 따라서 크로스포스트 스크립트는 습관적으로 이미 이름 붙여진 필드를 사용하려 하지 않고, 자신이 생성하는 항목에는 이 필드를 설정하지 않도록 알아야 했습니다.

결코 평범하지 않았던 몇 가지 버그 이야기

이 글은 "이렇게 설정되었다"는 것을 보여주기 위함이지 "버그 보고서"는 아니지만, 과정에서 발생했던 몇 가지 실패 사례는 너무나 흥미로워서 간단히라도 언급하지 않을 수 없습니다.

가장 인상 깊었던 것은: publish 워크플로우가 첫 발행 후 canonical_url이 변경된 모든 글에서 422 에러와 함께 {"error":"Tried to load unspecified class: Time","status":422}라는 에러 바디를 반환하며 실패하기 시작했습니다. 근본 원인은 완전한 YAML 라운드트립이 JavaScript YAML 라이브러리의 규칙에 따라 엄밀히 따옴표가 필요 없는 타임스탬프에서 따옴표를 조용히 제거하는 것이었습니다:

# 수정 전 (dev.to의 Ruby 백엔드에서 문제 발생)
date: 2026-07-03T15:43:16Z
# 수정 후 (따옴표 처리, 양쪽 모두에서 정상적으로 파싱)
date: "2026-07-03T15:43:16Z"

JS 라이브러리 규칙으로는 어느 쪽이든 완벽하게 유효한 YAML입니다. 하지만 dev.to의 백엔드는 Ruby이고, Ruby의 YAML 파서는 이제 따옴표가 없는, ISO-8601 형태의 스칼라를 고유한 Time 객체로 자동 감지했으며, 안전 로더는 이를 인스턴스화하기를 거부한 겁니다. "동일한" 파일 형식의 두 구현이, 순수한 문자열이 무엇으로 변환될 수 있는지에 대해 조용히 서로 다른 의견을 가지고 있었던 거죠. 이 YAML 파싱 오류는 정말 예상치 못한 복병이었습니다. 자바스크립트 진영에서는 전혀 문제가 없던 YAML이 루비 백엔드에서 Time 객체로 오인되어 빌드 실패를 일으키는 걸 보면서, '표준'이라는 이름 아래 얼마나 다양한 해석이 존재할 수 있는지 다시 한번 깨달았습니다. 결국 canonical_url 라인만 대상으로 하는 단일 라인 교체 방식으로 문제를 해결했습니다.

몇 가지 사소한 버그들도 빠르게 살펴보겠습니다:

  • 다른 도구에서 편집된 두 개의 파일이 Windows 줄 바꿈(\r)을 가져왔고, crosspost: true\r가 불리언 true가 아닌 문자열 "true\r"로 파싱되었습니다. 이 두 게시물은 적격으로 조용히 표시되지 않았습니다.
  • 7부작 시리즈 중 dev.to의 실제 시리즈 기능이 7개 포스팅 중 정확히 2개에서만 켜져 있었는데, 이는 이 도구들이 만들어지기 전의 일이었습니다.
  • 동일한 시리즈 내에서 내부 링크를 재작성하는 과정에서 서브스트링 교체 버그가 발생했는데, 오타가 난 슬러그가 다른 올바른 슬러그의 문자열 접두사와 일치하는 경우가 있었습니다. 가장 긴 슬러그를 먼저 처리하도록 정렬하여 문제를 해결했습니다.

재미와 효율을 동시에: AI 메모리부터 운동 기록까지

이 여정에서 다른 곳에 넣기에는 애매하지만, 진정으로 멋진 두 가지 작은 부분을 짚어보겠습니다.

강연 사이트의 Claude Code 스킬(새 강연 추가, 스크립트 정리, 강연 메모리 재동기화)은 memory.build가 없어도 작동합니다. 없어도 잘 실행되었죠. 하지만 이제 이를 포함하여 AI 에이전트를 위한 영구적인 메모리 레이어를 갖추게 되었습니다. 덕분에 제가 에이전트와 콘텐츠 작업을 할 때마다, 매번 맥락을 다시 설명할 필요 없이 이미 알고 있는 강연이나 이전 세션에 대한 정보를 불러올 수 있게 되었습니다. 필수는 아니지만, 있으면 좋은 기능이죠.

그리고 /fitness 섹션은 제 운동 기록을 빌드 시점에 Liftosaur API에서 직접 가져옵니다. 이 섹션이 존재하는 이유는 간단합니다. Liftosaur 자체의 공개 프로필 페이지는 제가 원하는 만큼의 정보, 즉 실제 트렌드 뷰나 전체 스토리를 보여줄 수 있는 링크를 제공하지 않았기 때문입니다. Liftosaur는 운동 기록을 깔끔한 JSON으로 반환하지 않고, 압축된 Liftoscript 텍스트 블록으로 돌려줍니다. 그래서 작은 파서가 그 텍스트에서 날짜, 프로그램, 운동, 세트 등을 추출합니다. 개인 최고 기록과 트렌드 스파크라인은 별도의 API 호출로 가져오는 대신, 파싱된 데이터에서 렌더링 시점에 계산됩니다.

아직 남아있는 작은 숙제들

현재 크로스포스트 트리거는 제가 수동으로 실행해야 하는 CLI 명령입니다. 제가 실제로 원하는 수정 사항은 이렇습니다: dev.to 워크플로우가 이미 PR 중심이라는 점을 감안할 때 (import는 PR로 도착하고, id는 동일한 푸시에 대한 자동 커밋으로 다시 기록되며, 두 번째 PR이 스폰되지 않음), 크로스포스트 단계도 정확히 동일한 형태를 따라야 합니다. pull_request에 대한 GitHub Action이 crosspost: true를 감지하고 /writing/ 항목을 생성하여, 수동 스크립트 실행이나 중복된 두 번째 PR 대신 동일한 PR에 추가 커밋으로 푸시하는 방식 말이죠. 이 글을 쓰면서 오랫동안 이미 존재한다고 착각했던 이 기능에 대해 이슈를 올렸습니다.

그 외에도 기대하고 있는 몇 가지 사항이 있습니다: 강연 사이트에 제가 강연했던 모든 장소의 지도를 추가하는 것 (이벤트 데이터에 이미 위도/경도가 있으니 즐거운 오후 작업이 될 겁니다), 그리고 /writing/과 레거시 아카이브를 위한 실제 히어로 및 썸네일 이미지 디자인입니다. 현재 크로스포스트 스크립트는 커버 이미지를 포스팅 본문에 인라인으로 삽입하여 이 간극을 메우고 있지만, 제대로 렌더링해야겠죠. 또한 mastermain으로 변경하고 Node 24로 업그레이드하는 등의 일상적인 유지보수 작업들도 남아있습니다.

이 모든 것들이 이미 작동하는 부분의 배포를 지연시킬 만큼 시급하지는 않았습니다. 결국 좋은 아이디어 목록은 대체로 이런 식으로 만들어지는 법이니까요.


원문: https://dev.to/mattstratton/i-contain-multitudes-and-also-three-git-repos-33pf 수집일: 2026-07-07 01:49:58