20년 블로그 역사를 품다: 흩어진 Git 레포 3개를 하나로 묶은 모노레포 구축 실전기
2026. 7. 9.
20년 블로그 역사를 품다: 흩어진 Git 레포 3개를 하나로 묶은 모노레포 구축 실전기
제 블로그는 제가 운영하는 대부분의 도구보다 나이가 많습니다. 가장 오래된 게시물들은 2000년대 초 라이브저널(LiveJournal)에서 시작해, 2000년대 중반 워드프레스(WordPress)로 한바탕 가져오기를 거쳤고, 거의 10년간 휴고(Hugo) 사이트로 지내다가, 약 일주일 전 아스트로 5(Astro 5) 사이트로 이사했습니다. 놀랍게도 그 모든 오래된 URL들을 바이트 단위까지 완벽하게 재현하면서 말이죠. 2001년부터 2020년까지 2,630개의 게시물이 그 자리에 얼어붙은 채 보존된 셈입니다.
동시에 제 발표 사이트(2012년부터의 106개 발표 기록)도 Notist에서 벗어나 제가 직접 소유하는 시스템으로 옮겨왔습니다. 그리고 제 dev.to 드래프트를 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 보존이 최우선 과제라는 것이었습니다. astro.config.mjs에서 trailingSlash: 'always'를 설정하고 디렉토리 형식으로 빌드한 것은 오래된 휴고(Hugo) URL을 정확하게 재현하기 위함입니다. 그리고 그 휴고 URL은 또 그 전의 워드프레스(WordPress) URL을 재현한 것이었죠. 2009년 블로그 롤이나 2014년 발표 요약에서 링크된 컨퍼런스 사이트 등, 오래된 인바운드 링크들은 여전히 유효하게 작동합니다. 제가 다른 프레임워크로 사이트를 재작성했다고 해서 누구도 깨진 링크를 고칠 필요가 없다는 뜻이죠.
posts 컬렉션은 2001년부터 2020년까지의 얼어붙은 아카이브로, 휴고에서 대량 변환된 것입니다. 이 컬렉션은 더 이상 늘어나지 않고, 그저 존재하기만 하면 됩니다. 모든 새로운 콘텐츠는 writing으로 들어가는데, 이 섹션은 이 글을 쓰기 시작했을 때 제가 예상했던 것보다 훨씬 더 많은 일들을 처리하고 있습니다. 저도 프로젝트를 진행하며 이 URL 보존의 중요성을 뼈저리게 느낀 적이 많아요. 한 번 깨진 링크는 복구하기 정말 어렵거든요. 그래서 초기에 이런 설계를 잡는 게 얼마나 중요한지 실감합니다.
필드 가이드: 내 글과 내가 다른 곳에 쓴 좋은 글들
/writing/의 범위는 한때 훨씬 더 좁았습니다. 이 프로젝트 이전의 계획은 Postgres 중심의 권위 구축 콘텐츠에만 초점을 맞추는 것이었는데, 이는 제 개인 블로그 독자층과는 전혀 다른 대상을 위해 작성된 콘텐츠 전략 문서에서 물려받은 아이디어였죠. 하지만 이런 범위 설정을 무너뜨린 반론은 간단했습니다. "나는 여러 모습을 담고 있는데(I contain multitudes), 한 가지 주제만 다룰 수 있는 개인 사이트는 더 이상 개인적인 의미가 없다."
그러나 지금 /writing/에서 정말 흥미로운 점은 단순히 제 글만 담고 있는 것이 아니라는 겁니다. 여기에는 두 가지 확연히 다른 요소가 병합되어 있습니다.
content.config.ts의 스키마에 따라 정의된src/content/writing/의 자체 게시물:title,description,pubDate,topics,draft, 엄선된 4부작 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 ↗" 배지입니다. 새 탭이 열리지 않고, 방문할 곳에 대한 솔직한 라벨일 뿐이죠.
뉴스레터: 발송은 Buttondown, 페이지는 Astro가 제공
/newsletter/는 이 사이트의 대부분과 동일한 방식으로 작동합니다. 빌드 시점에 데이터를 가져와 정적 파일을 영구히 제공하는 식이죠. buttondown.ts는 Buttondown API의 GET /emails?ordering=-publish_date 엔드포인트를 호출하는 Astro 콘텐츠 레이어 로더입니다. 페이지네이션을 따라 모든 페이지를 가져온 후 그 결과를 newsletter 컬렉션으로 Astro에 전달하죠. 모든 이슈 페이지, 아카이브 인덱스, RSS 피드는 일반 getCollection() 호출의 정적 출력이며, 로컬 마크다운 파일과 다를 바 없습니다.
이와 비슷한 작업을 고려하는 분들을 위해 두 가지 주목할 만한 세부 사항이 있습니다.
BUTTONDOWN_API_KEY가 설정되어 있지 않으면, 로더는 빌드 실패 대신 경고를 기록하고 빈 컬렉션을 반환합니다. 이는 의도적인 설계로, 제 API 키 없이도 누구나 이 레포를 복제하고 로컬에서 빌드할 수 있다는 의미입니다.- Buttondown은 각 이슈의 본문을 마크다운으로 저장하므로, 로더는 템플릿에 도달하기 전에
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;
}
사실 API 키를 필수로 강제하면 오픈소스 기여나 로컬 테스트가 매우 불편해집니다. 이렇게 개발 편의성을 고려한 설계는 실무에서 정말 박수쳐줄 만하죠.
speaking.mattstratton.com: CDN 종속성에서 슬라이드를 되찾다
이전 발표 사이트는 Notist에서 운영되었는데, Notist에는 결국 하나의 문제로 귀결된 두 가지 문제가 있었습니다. 비디오 임베드가 조용히 작동을 멈추기 시작했고, 모든 슬라이드 덱이 Notist의 CDN을 통해 제공되었다는 점이죠. 그 계정이 만료되는 날, 이미지와 PDF도 함께 사라집니다. 연사의 전문적인 정체성의 핵심인, 제가 했던 모든 발표가 다른 사람의 데이터베이스에 얇은 내보내기 옵션과 함께 살고 있었던 겁니다.
그래서 새 사이트의 전체 디자인 목표는 자산 소유권과 영구성입니다. 제 레포, 제 도메인에 있는 평범한 콘텐츠가, 언제든 중단되거나 조용히 사라질 수 있는 제3자 의존성 없이 계속 작동하는 것이죠. 단순한 재디자인이 아니라, 포터빌리티(portability) 프로젝트였습니다.
흥미로운 부분은 슬라이드 파이프라인인데, 이는 커밋이 발생하기 전, 전적으로 로컬에서 실행되며 배포 호스트에서는 절대 실행되지 않습니다.
originals/{id}.pdf --최적화(gs, 300dpi)--> public/slides/{id}.pdf --래스터화--> public/slides/{id}/{n}.webp
(gitignored, 풀 해상도) (커밋, 서비스, 다운로드 가능) (커밋, 서비스, 뷰어 이미지)
이것이 로컬에서 실행되어야 하는 이유는 정적 호스트는 루트 권한을 주지 않으며, pdftoppm과 gs를 실행하려면 루트 권한(또는 최소한 실제 패키지 관리자)이 필요하기 때문입니다. Netlify 빌드 안에서 Ghostscript를 셸 아웃하여 실행하려고 하면 실패할 겁니다. 제 머신에서 래스터화를 수행하고 그 결과물을 커밋함으로써 실제 배포 빌드는 지루하고 빠르며 호스트에 구애받지 않는 astro build가 됩니다.
비디오 임베드도 동일하게 "소유하고, 프록시하지 않는" 방식을 적용했습니다. Notist의 접근 방식(다시 말하지만, 이미 고장 나기 시작했던 프록시)을 재현하는 대신, 각 발표는 프론트매터에 단순한 제공자(provider)와 비디오 ID를 저장하고, 페이지는 클릭 시에만 실제 플레이어를 로드하는 경량의 임베드 파사드를 렌더링합니다.
현재 수치: 2012년부터 2026년까지 106개의 발표, 93개의 이벤트, 36개의 비디오, 3,684개의 슬라이드 이미지. 이 모든 것이 커밋되어 있고, CDN 장애 한 번으로 사라질 위험이 없습니다. 클라우드나 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는 더 큰 페이로드로, 각 발표의 트랜스크립트와 슬라이드 텍스트를 하나의 검색 가능한 덩어리로 연결하여, 실제 쿼리를 입력할 때만 지연 로드(lazy loaded)됩니다.
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) 재작성을 사용하지 않았습니다. 이는 두 소스 레포의 전체 커밋 히스토리를 "파일이 여기 있으니 믿어라"가 아닌 실제 검증 가능한 조상으로 보존합니다. 두 소스 레포 모두 생성된 지 얼마 되지 않아(각각 약 50개 및 22개의 커밋) 수십 년의 고고학적 작업을 보호해야 할 필요는 없었습니다. 그저 원칙에 따라 올바르게 처리한 것이었고, 이는 그 자체로 만족스러운 일이었습니다.
만약 각자의 CI를 가진 레포들을 병합하려 한다면, 다음 몇 가지 주의할 점이 있습니다.
- GitHub Actions 워크플로우는 실제 레포 루트의
.github/workflows/에서만 발견되며, 하위 디렉토리에서는 절대 발견되지 않습니다. dev.to 워크플로우는 초기 병합 후 서브프로젝트 자체의.github/workflows/아래에 중첩된 채로 남아있어 GitHub에 완전히 보이지 않았습니다. 오류도 경고도 없었죠.gh workflow list에 표시되지 않았고, 조용히 다시는 실행되지 않았습니다. - Netlify는 자체적으로 서브디렉토리별로 리빌드를 범위 지정하지 않습니다. 연결된 사이트의
netlify.toml에 명시적인ignore검사를 추가하지 않으면, 모든 푸시가 연결된 모든 사이트를 리빌드합니다. 그리고 Netlify가 관련 변경 사항이 없어 빌드를 건너뛸 때, "콘텐츠 변경 없음으로 빌드 취소"라고 표시하는데, 실제로는 아무 문제가 없는데도 이메일에서는 실패처럼 보입니다. - "기본 디렉토리(Base directory)"와 "패키지 디렉토리(Package directory)"는 다른 Netlify 설정이며, npm 워크스페이스가 없는 레포의 재연결 흐름에서는 기본 디렉토리만 표시됩니다. 패키지 디렉토리는 전혀 나타나지 않죠. 몇 년마다 다시 배워야 하는 문장이지만, 추측하는 대신 실제 문서를 읽는 것이 중요합니다.
astro check는 서브프로젝트 경계를 존중하지 않습니다. 모노레포 루트에서 실행하면, 관련 없는 서브프로젝트의 콘텐츠 컬렉션을 다른 서브프로젝트의 생성된 타입을 사용하여 스캔했고, 200개 이상의 거짓 타입 오류를 생성했습니다.tsconfig.json에 명시적인exclude한 줄을 추가하자 그 숫자는 3개로 줄었으며, 이 3개는 모두 이전에 존재하던 무관한 오류였습니다.
만약 병합하려는 두 개 이상의 레포에 이 중 두 가지 이상이 해당한다면, 오후 시간을 추가로 할애해야 할 겁니다. 이런 CI/CD 설정 문제는 정말 예고 없이 찾아오고, 디버깅에 시간이 많이 걸리죠. 통합 전에 이런 잠재적 문제점들을 미리 파악하는 것이 정신 건강에 이롭다는 걸 경험으로 알게 됩니다.
dev.to: 방향이 아닌 소유권
dev.to 동기화 도구는 콘텐츠를 플랫폼과 Git 사이에 동기화하는 사람이라면 누구에게나 진정으로 추천하고 싶은 하나의 아이디어를 중심으로 구축되었습니다. 바로 방향이 아닌 소유권입니다. 전체적인 사고 모델은 레포의 CLAUDE.md에 있지만, 간단히 요약하자면 다음과 같습니다.
이를 양방향 동기화, 즉 dev.to와 Git을 완벽하게 동기화하고 양방향으로 변경 사항을 푸시하는 것으로 생각하기 쉽습니다. 하지만 그렇게 하지 마세요. 각 측이 자신이 실제 버전을 가지고 있다고 계속 생각하는 끝없는 팬텀-차이(phantom-diff) 루프를 발생시킵니다. 대신, 게시물은 드래프트되는 동안 dev.to 소유이며, Git은 이를 완전히 무시합니다. 사람이 가져오기 PR을 병합하면, 게시물은 프론트매터에 id 필드가 존재함으로써 영원히 Git 소유가 됩니다. 소유권은 의도적인 순간에 정확히 한 번만 이전되며, 자동으로 되돌아가는 일은 없습니다.
두 개의 작은 워크플로우가 실제 작업을 수행합니다. devto-import.yml은 크론(cron)으로 실행되며, 새로 발행된 dev.to 게시물을 가져와 검토를 위한 PR을 엽니다. 이미 id를 가진 파일은 절대 건드리지 않습니다. devto-publish.yml은 posts/ 아래 파일에 푸시가 발생할 때 실행되며, 로컬 편집 내용을 dev.to로 다시 푸시하고, 게시물이 처음 발행될 때 id를 다시 기록합니다. 두 워크플로우의 도메인은 절대 겹치지 않으며, 이것이 핵심입니다. 제가 겪었던 양방향 동기화의 지옥을 생각하면 이 '소유권' 개념은 정말 혁신적입니다. 명확한 규칙이 없으면 수많은 충돌과 수동 개입으로 시간을 낭비하게 되더라고요.
크로스포스트 파이프라인 (그리고 이 글도 이 과정을 거칩니다)
게시물이 Git 소유가 되면, 프론트매터에 crosspost: true를 추가하여 크로스포스팅을 선택할 수 있습니다. 이렇게 하면 mattstratton.com의 /writing/ 컬렉션에 원본 게시물로 다시 발행되고, dev.to 게시물의 canonical_url은 crosspost-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/ 항목을 생성하고, 동일한 diff에서 dev.to 소스의 canonical_url을 재작성하며, 하나의 푸시로 Netlify 배포와 dev.to의 기존 발행 워크플로우를 모두 올바르게 트리거합니다. 아무도 두 번째 단계를 지켜볼 필요가 없죠.
당연히 괜찮아 보이지만 실제로는 그렇지 않은, 한 가지 함정을 짚고 넘어갈 가치가 있습니다. writing 스키마는 첫 Astro 마이그레이션 커밋부터 선택적 canonicalUrl 필드를 가지고 있었습니다. 이는 실제 원본이 다른 곳에 있는 콘텐츠(예: 미러링된 Tiger Data 게시물)의 사본인 경우를 위해 마련된 것이었습니다. 크로스포스트 스크립트가 존재하기 전에는, 이 필드가 플레이스홀더 템플릿 파일의 주석 처리된 줄 canonicalUrl: ... # set when cross-posted에만 나타났습니다. 아무도 실제 게시물에 이 필드를 설정한 적이 없었죠. 하지만 dev.to 크로스포스팅의 경우, mattstratton.com이 표준 소스가 되는 상황인데, 이는 이 필드가 예약된 용도와는 반대입니다. 따라서 크로스포스트 스크립트는 습관적으로 이미 이름이 있는 필드를 사용하려 하지 않고, 생성하는 항목에는 이 필드를 설정하지 않도록 알아야 합니다. 이런 사소해 보이는 필드 하나가 예상치 못한 문제를 일으킬 수 있다는 걸 보면, 스키마 설계 단계에서 미래의 사용 시나리오를 얼마나 깊이 고민해야 하는지 다시 한번 깨닫게 됩니다.
몇 가지 이상한 버그들 (양념 삼아)
이 글은 "이렇게 설정되었다"는 것을 보여주기 위한 것이지, "버그 보고서"가 아닙니다. 하지만 도중에 발생했던 몇 가지 실패 사례들은 너무 좋아서 간략하게 언급하지 않을 수 없네요.
가장 흥미로웠던 것은, 발행 워크플로우가 첫 발행 후 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 객체로 자동 감지하며, 안전 로더는 이를 인스턴스화하는 것을 거부하는 것이었습니다. "동일한" 파일 형식의 두 구현이, 맨 문자열이 무엇으로 변할 수 있는지에 대해 조용히 의견이 달랐던 것이죠. 해결책은 파일 전체를 재직렬화하는 대신 canonical_url 줄만 대상으로 하는 단일 줄 교체를 수행하는 것이었습니다. 이 YAML 파서 오류는 정말 고전적인 문제입니다. 서로 다른 언어 생태계에서 '표준'을 구현할 때 생기는 미묘한 차이가 얼마나 큰 나비효과를 가져올 수 있는지 보여주는 완벽한 예시죠.
몇 가지 사소한 것들도 빠르게 짚어보자면:
- 다른 도구에서 편집된 두 개의 파일이 Windows 줄 바꿈 문자(line ending)를 가져왔고,
crosspost: true\r는 불리언true가 아닌"true\r"문자열로 파싱되었습니다. 이 두 게시물은 그저 적격으로 조용히 표시되지 않았습니다. - 7부작 시리즈 중 dev.to의 실제 시리즈(Series) 기능이 7개 게시물 중 정확히 2개에만 켜져 있었는데, 이는 이 도구가 만들어지기 전의 일이었습니다.
- 동일한 시리즈 내에서 내부 링크를 재작성할 때, 오타가 난 슬러그가 다른 올바른 슬러그의 문자열 접두사와 우연히 일치하여 부분 문자열 교체 버그가 발생했습니다. 가장 긴 슬러그부터 먼저 교체하도록 정렬하여 해결했습니다.
재미있는 요소들: 기억과 얻은 것들
이 투어에서 다른 곳에 적합하지 않지만, 정말 흥미로워서 언급할 가치가 있는 두 가지 작은 부분이 있습니다.
발표 사이트의 Claude Code 스킬(새 발표 추가, 트랜스크립트 정리, 발표 기억 재동기화)은 memory.build 없이도 작동합니다. 하지만 이제는 AI 에이전트를 위한 영구 메모리 계층도 포함되어 있습니다. 덕분에 제가 에이전트와 콘텐츠 작업을 할 때마다, 매번 맥락을 다시 설명할 필요 없이 이미 알고 있는 발표나 이전 세션에 대한 정보를 불러올 수 있습니다. 있으면 좋지만, 핵심적인 기능은 아니죠.
그리고 /fitness는 빌드 시점에 Liftosaur API에서 직접 운동 기록을 가져옵니다. 이 기능이 존재하는 이유는 Liftosaur 자체의 공개 프로필 페이지가 제가 원하는 만큼 많은 정보를 보여주지 않았기 때문입니다. 실제 추세 보기나, 사람들에게 전체 이야기를 들려줄 만한 링크가 없었죠. Liftosaur는 운동 기록을 깔끔한 JSON으로 반환하지도 않습니다. 대신 압축된 Liftoscript-text 블롭을 반환하므로, 작은 파서가 그 텍스트에서 날짜, 프로그램, 운동, 세트 수를 추출합니다. 개인 최고 기록과 추세 스파크라인은 별도의 API 호출로 가져오는 것이 아니라, 이 파싱된 데이터로부터 렌더링 시점에 계산됩니다.
앞으로 할 일
현재 크로스포스트 트리거는 제가 기억해서 실행해야 하는 수동 CLI 명령어입니다. 제가 정말로 원하는 수정 사항은 다음과 같습니다. dev.to 워크플로우가 이미 완전히 PR 중심적(가져오기는 PR로 도착하고, id는 동일한 푸시에서 자동 커밋으로 다시 기록되며, 두 번째 PR이 생성되지 않음)이므로, 크로스포스트 단계도 정확히 동일한 형태를 따라야 합니다. pull_request에 대한 GitHub 액션이 crosspost: true를 감지하여 /writing/ 항목을 생성하고, 수동 스크립트 실행이나 불필요한 두 번째 PR 대신 동일한 PR에 추가 커밋으로 푸시하는 것이죠. 이 글을 쓰는 동안 이 문제를 이슈로 등록했습니다. 제가 오랫동안 이미 존재한다고 가정하고 있었는데, 사실은 없었거든요.
그 외에도 기대되는 몇 가지가 더 있습니다. 발표 사이트에 제가 발표했던 모든 장소의 지도를 추가하는 것(이벤트 데이터에 이미 위도/경도 정보가 있으니 즐거운 오후 작업이 될 것 같네요), 그리고 /writing/ 및 기존 아카이브를 위한 실제 히어로 및 썸네일 이미지 디자인입니다. 현재 크로스포스트 스크립트는 커버 이미지를 게시물 본문에 인라인으로 삽입하여 이 간극을 메우고 있기 때문이죠. 물론 master를 main으로 이름 변경, Node 24 업그레이드와 같은 일반적인 유지보수 작업들도 남아있지만, 읽기에 그렇게 재미있는 내용은 아니죠.
이 중 어떤 것도 이미 작동하는 부분의 출시를 지연시킬 만큼 시급하지 않았습니다. 좋은 할 일 목록은 보통 그렇게 만들어지는 법이니까요.
원문: https://dev.to/mattstratton/i-contain-multitudes-and-also-three-git-repos-33pf 수집일: 2026-07-09 01:29:58