본문 바로가기

이 포트폴리오의 원본은 https://cv.iruyo.com (심재빈) 입니다 · 출처 식별자 jbx-7f3a2e9b

← 기술 블로그

운영·인프라

노션 6개 DB가 정적 114페이지로 — 빌드·배포·PDF를 무인화하기

이력서·포트폴리오를 여러 곳에 두면 동기화가 깨진다. 콘텐츠 원본을 노션 한 곳에 두고 매시간 sync→정적 생성→배포→인쇄 PDF가 자동으로 따라오게 만든 단일 파이프라인의 설계와 검증.

문제: 같은 사실이 세 곳에 흩어진다

이력서를 쓰다 보면 같은 경력이 여러 곳에 복제된다. 채용 플랫폼의 이력서 양식, PDF 포트폴리오, 깃허브 프로필. 한 곳에서 직책을 고치면 나머지를 손으로 따라 고쳐야 한다.

하나라도 빠뜨리면 그 순간 세 버전의 내가 서로 다른 말을 한다. 면접관이 PDF와 사이트에서 다른 숫자를 보면, 그 자리에서 신뢰가 깨진다.

기존 이력서 플랫폼과 양식 중에 맞는 걸 끝내 못 찾은 것도 같은 이유였다. 표현은 내 손에 없고, 데이터는 플랫폼 안에 갇혀 있다.

그래서 전제를 뒤집었다. 콘텐츠 원본(source of truth)을 한 곳에 두고, 보여지는 표면은 전부 그 한 곳에서 생성되게 한다. 원본은 노션으로 골랐다. 표 편집과 관계형 DB가 익숙하고, API가 열려 있으니 빌드 파이프라인이 읽어갈 수 있다.

노션 DB에서 생성된 이력서 홈 — 손으로 만든 페이지가 하나도 없다

제약과 설계: 노션 6개 DB → 정적 114페이지

원본은 노션의 6개 DB다 — 경력기술서·개인 프로젝트·활동·경력·학력·신상. 사이트에 보이는 모든 문장은 여기서 온다.

같은 노션 DB가 만든 경력기술서 카드 (/projects)

핵심 제약은 두 가지였다.

  • 손으로 만드는 산출물이 하나도 없어야 한다. 사람이 끼는 단계는 곧 동기화가 깨지는 지점이다.
  • 같은 데이터로 사이트·PDF·챗봇 코퍼스를 동시에 뽑되, 표면마다 노출 범위가 달라야 한다. 사이트에 띄울 문장과 챗봇에 먹일 문장이 같을 수 없다.

흐름은 단방향이다. 동기화 스크립트가 노션을 읽어 MDX로 떨어뜨리고, Astro가 그 MDX를 정적 114페이지로 빌드한다.

노션 6 DB source of truth sync → MDX Astro 빌드 사이트 114p 인쇄 PDF 챗봇 코퍼스

이 스크립트를 GitHub Actions에 매시간 크론으로 걸었다. 변경분이 있으면 자동 커밋되고, 커밋이 곧 Cloudflare Workers 배포 트리거다.

노션에서 한 줄 고치면 늦어도 한 시간 안에 배포된 사이트에 반영된다. 인쇄용 PDF도 같은 MDX에서 Puppeteer가 뽑는다. 사람이 끼는 단계가 없다.

노출 경계를 데이터에 박는다

표면마다 노출 범위가 다르다는 두 번째 제약이 까다로웠다. 사이트에는 띄워도 되지만 챗봇에는 먹이면 안 되는 문장이 있고, 면접 자리에서만 꺼낼 문장이 있다.

경계를 후처리 코드가 아니라 데이터 자체에 박았다. 카드마다 정책 필드를 달았다.

  • 자소서 OK — 자기소개 맥락까지 노출
  • 이력서 OK — 공개 이력서 표면
  • 면접 only — admin 모드에서만
  • 비공개 — 어떤 공개 빌드에도 안 들어감

사이트·PDF·챗봇이 각자 허용된 범위만 읽는다.

카드 + 정책 필드 policy = ? 이력서 OK → 사이트·PDF 자소서 OK → + 챗봇 면접 only → admin 비공개 → 빌드 제외

전화번호 등 민감 정보는 애초에 공개 빌드 대상에서 빠진다

전화번호 같은 민감 정보는 코드가 런타임에 거르는 게 아니다. 애초에 공개 빌드 대상에서 빠진다. 마스킹을 후처리가 아니라 데이터 모델에 둔 셈이다. 거르는 코드를 빠뜨려서 새는 사고가 구조적으로 불가능하다.

막혔던 지점: 직렬화

쉽게 깨진 곳은 직렬화였다. 노션의 멀티값 속성을 배열로 풀어 MDX frontmatter에 넣는 과정이다.

캡션에 공백이나 괄호가 들어가면 마크다운 이미지 문법 파싱이 깨졌다. 이미지가 렌더링되지 않고 본문에 리터럴 텍스트로 새어 나왔다. 이런 식이다.

<!-- 캡션에 괄호가 들어가면 -->
![도면 (v2) 캡처](https://.../shot.png)

<!-- )에서 URL이 끊겨 파싱이 깨지고, 뒤가 본문 텍스트로 샌다 -->

URL을 안전하게 인코딩하는 단계를 동기화 쪽에 넣어 막았다. 노션 원본은 그대로 두고, MDX로 떨어뜨리는 순간에만 URL을 감쌌다.

// sync 단계: 괄호·공백을 인코딩해 ![]() 파싱을 보호한다
const safe = encodeURI(url).replace(/[()]/g, (c) =>
  c === "(" ? "%28" : "%29"
);
const md = `![${caption}](${safe})`;

막혔던 지점: 세 표면, 한 토큰

PDF는 표면이 또 달랐다. 라이트·다크·인쇄 세 표면이 각자 다른 색을 쓰면, 노션 한 곳에서 고쳐도 표면 사이에 색이 어긋난다.

규칙을 강제했다. 색을 하드코딩하지 않고 세 표면이 같은 시맨틱 토큰을 공유한다.

/* 표면이 무엇이든 토큰만 참조한다 — hex 직접 사용 금지 */
.card { color: var(--color-ink); background: var(--color-paper); }

그 덕에 다크 모드는 따로 만들지 않아도 공짜로 따라온다. 토큰만 반전시키면 끝이다.

검증

검증 기준은 단순하다. 노션에서 한 글자 고친 뒤 손대지 않고 기다린다.

한 시간 안에 다음 세 가지가 동시에 일어나면 통과다.

  • 배포된 사이트가 바뀐다
  • PDF가 같은 내용으로 다시 뽑힌다
  • 챗봇이 새 문장을 인용한다

운영비는 0원이다. Workers 정적 호스팅과 Actions 무료 한도 안에서 돈다.

자동화는 판단을 대신하지 않는다

디자인 리서치, 직렬화 버그 진단, 노션 콘텐츠 정리까지 한 세션에서 오갔다.

다만 무엇을 원본에 두고 어디까지 공개할지, 표면을 어떻게 가를지는 내가 정했다. 자동화는 판단을 대신하지 않는다. 판단을 한 번 잘 박아 두면 그다음을 대신해 줄 뿐이다.

만들어 운영해 보고 나서야 그 경계가 손에 잡혔다.