개인 포트폴리오 & 기술 블로그. Notion CMS 기반 콘텐츠 관리, YouTube BGM 플레이어 등 기능 구현.
포트폴리오랑 기술 블로그, 채용 레이더를 사이트 하나에 몰아넣었다. 블로그는 Notion을 CMS로 쓴다. 글 하나 쓰겠다고 배포하는 게 싫었다. 채용 레이더는 별도 크롤러가 격일로 공고를 긁어다 내 프로필 기준으로 점수를 매긴다. 공고를 손으로 찾아다니는 것도 그만두고 싶었다.
스택
| 영역 | 사용 기술 |
|---|---|
| 웹앱 | Next.js 15 (App Router), React 19, TypeScript, Tailwind CSS v4 |
| 콘텐츠 | Notion API, notion-to-md, react-markdown (remark/rehype) |
| 데이터 | Supabase (PostgreSQL, pg_cron) |
| 크롤러 | Node 22 ESM, tsx, cheerio, DART 공시 API |
| 인프라 | Vercel (region hnd1), GitHub Actions, Telegram Bot |
구조
저장소 하나에 npm 패키지가 두 개 들어 있다. 서로 import 하지 않는다. 접점은 Supabase 스키마뿐이라 크롤러를 갈아엎어도 웹앱은 모른다.
my-room/
├─ src/ # 웹앱 (읽기 전용)
│ ├─ app/
│ │ ├─ blog/[...slug]/ # 카테고리 목록 + 글 상세 겸용 catch-all
│ │ ├─ projects/[id]/ # 프로젝트 상세 (Notion 본문 렌더)
│ │ ├─ job-radar/ # 채용 대시보드
│ │ └─ api/ # 링크 프리뷰, Notion 이미지 프록시
│ ├─ components/
│ ├─ lib/ # notion.ts, job-radar/queries.ts
│ └─ data/ # Notion 실패 시 로컬 폴백
└─ crawler/ # 공고 수집·채점 (쓰기 전용)
├─ src/adapters/ # 소스별 크롤러 11종
├─ src/scoring/ # 결정론적 점수 엔진
├─ config/*.yaml # 점수 기준·프로필·정규화 사전
└─ supabase/migrations/주요 기능
1. Notion을 CMS로 쓰는 블로그
Notion DB에서 상태 = 발행으로 바꾸면 사이트에 뜬다. 배포는 필요 없다.
라우팅은 /blog/[...slug] catch-all 하나가 카테고리 목록과 글 상세를 겸한다. 첫 세그먼트가 카테고리 이름이면 목록, 아니면 Notion page id로 본다.
마크다운은 remark-gfm/math로 파싱한 뒤 rehype-raw를 거쳐 rehype-sanitize(확장 스키마)를 통과시키고 slug/autolink와 KaTeX를 붙인다. raw HTML을 허용하는 대신 sanitize는 절대 건너뛰지 않는다.
그 외에 코드블록 하이라이팅, sticky 목차, 이전/다음 글이 있다. [bookmark](url)만 있는 문단은 링크 프리뷰 카드로 바뀐다.
2. 채용 레이더 (job-radar)
관심 회사 채용 페이지를 직접 도는 크롤러와 점수 대시보드다.
소스마다 어댑터를 하나씩 만든다. 구현할 메서드는 fetchJobList, fetchJobDetail, normalize 3개뿐이다. 지금 11개고(직행·인디스워크·SK·LG·NHN·삼성·네이버 등) yaml에서 켜고 끈다. 파이프라인은 crawl → dedup → normalize → upsert → score → notify 순으로 돈다. dedup은 external_id와 회사명::제목 두 축으로 걸러서, 이미 본 공고는 상세 페이지를 아예 안 가져온다.
점수는 LLM을 안 쓰고 계산한다. 5개 카테고리(직무 적합 35, 기업 안정성 20, 보상 15, 워라밸 15, 커리어 핏 15)를 각각 매겨 가중합한다. 같은 입력이면 항상 같은 점수가 나오니까 기준을 바꿨을 때 뭐가 어떻게 움직였는지 그대로 비교할 수 있다. 기준값은 코드에 없고 scoring.yaml, candidate-profile.yaml에 있다. 고친 다음엔 npm run rescore -- --dry-run으로 점수 변화부터 본다.
회사 재무는 DART 공시 API로 붙이고 모자란 건 청년친화강소기업 데이터로 메운다. 85점 넘는 신규 공고는 텔레그램으로 바로 온다. 크롤링은 GitHub Actions에서 격일 KST 09:00.
대시보드에서는 통근 시간(양재역 기준), 매출 규모, 경력 조건으로 거른다. 마감 7일 지난 공고는 pg_cron이 지우는데 즐겨찾기한 건 남긴다.
3. 그 외
다크모드는 next-themes 안 쓰고 직접 만들었다. YouTube BGM 플레이어, 맨 위로 가기 버튼, 모바일 드로어 메뉴도 있다.
설계에서 신경 쓴 것
캐시는 페이지마다 따로 잡았다. Notion API가 느리고 쿼터도 있어서 전부 짧게 가져가면 손해다.
| 페이지 | revalidate | 이유 |
|---|---|---|
| 홈 · 블로그 목록 | 60초 | 새 글이 빨리 보여야 함 |
| 블로그 · 프로젝트 상세 | 1시간 | 발행 후 거의 안 바뀜 |
| job-radar | 12시간 | 크롤링이 격일이라 그보다 자주 볼 이유가 없음 |
| 링크 프리뷰 API | 캐시 안 함 | 요청마다 대상 페이지가 다름 |
목록 조회는 배치로 묶었다. 공고마다 회사 정보와 점수를 따로 조회하면 N+1이 된다. job id와 회사명을 50개씩 잘라 Promise.all로 병렬 조회하도록 고정해뒀다.
정렬과 필터는 클라이언트가 한다. 서버는 전체 목록을 한 번에 넘기고 그 뒤로는 관여하지 않는다. 공고가 수백 건 규모라 이쪽이 훨씬 빠르게 느껴진다.
트러블슈팅
다크모드가 첫 화면에서 한 번 깜빡였다
테마 클래스를 React useEffect에서 붙이고 있었다. 그러니 첫 페인트는 무조건 라이트로 나오고 하이드레이션이 끝나야 다크로 바뀐다. 게다가 localStorage만 읽고 prefers-color-scheme은 아예 안 봤다. 시스템이 다크인 첫 방문자는 계속 라이트 화면을 본 셈이다.
<head>에 동기 인라인 스크립트를 넣어 하이드레이션 전에 테마를 확정했다. 저장값이 있으면 그걸 쓰고 없으면 시스템 설정을 따른다. Provider는 그렇게 정해진 DOM 상태를 초기값으로 읽기만 한다. 토글 버튼도 mounted 전에는 null을 반환하고 있었는데 아이콘 두 개를 다 렌더해놓고 CSS로 전환하게 바꿨다. 하이드레이션 불일치 없이 첫 프레임부터 보인다. color-scheme도 같이 지정해서 스크롤바랑 폼 컨트롤까지 테마를 탄다.
Notion 업로드 이미지가 한 시간 뒤 깨졌다
Notion API가 주는 업로드 파일 URL은 presigned URL이고 유효기간이 1시간이다. 페이지를 1시간 캐시하면 캐시가 아직 살아 있는데 이미지 링크가 먼저 죽는다.
/api/notion-image?pageId=... 프록시 라우트를 만들었다. 요청이 올 때마다 Notion에서 최신 URL을 받아온다. 페이지에 박히는 주소는 만료가 없는 프록시 URL이다. 허용 호스트를 화이트리스트로 확인한 다음 이미지 바이트를 그대로 흘려보내고 24시간 캐시한다.
매출 필터를 켜면 오히려 좋은 공고가 사라졌다
"매출 N억 이상" 하드 필터가 재무 데이터 없는 공고까지 탈락시켰다. 스타트업이나 비상장사는 DART에 데이터가 없는 경우가 흔하다. 필터가 정확히 반대로 동작한 셈이다.
하드 필터 원칙을 unknown ≠ negative로 박아뒀다. 데이터가 없으면 거르지 않는다. 점수 엔진도 같은 식이다. 값 없는 카테고리를 0점 처리하지 않고 가중치를 나머지 카테고리에 재분배한다. 데이터가 없다는 이유로 점수가 깎이면 순위가 엉킨다.
GitHub Actions에서만 크롤러가 죽었다
로컬은 멀쩡한데 CI에서 Supabase 클라이언트가 fatal error로 떨어졌다. realtime-js가 쓰는 WebSocket 구현이 구버전 Node에서 깨지는 거였다. 워크플로 Node 버전을 22로 고정했다. 내리면 재발하는 종류라 주석으로 남겨뒀다.
Notion 속성 이름 하나 때문에 링크가 안 떴다
프로젝트 카드의 GitHub·데모 버튼이 계속 안 나왔다. 코드는 props["GitHub"], props["데모"]를 읽는데 실제 DB 속성명은 Github, Demo였다. Notion API는 없는 속성을 조회해도 에러 없이 undefined를 준다. 그래서 조용히 실패했다.
후보 이름을 순서대로 찾는 헬퍼로 바꿨다. any 캐스팅도 걷어내고 SDK의 isFullPage 타입 가드로 속성 타입을 좁혔다. 이제 속성이 없으면 타입 단계에서 걸린다.
즐겨찾기 페이지가 옛날 데이터를 보여줬다
job-radar 페이지들이 12시간 ISR을 공유하고 있었는데, 즐겨찾기는 방금 저장한 게 바로 보여야 하는 화면이다. 즐겨찾기만 캐시에서 빼고 매 요청마다 최신 DB를 보게 했다. 캐시 정책을 페이지 단위로 각자 정하게 된 게 이때부터다.
앞으로
- 프로젝트 상세에 스크린샷·아키텍처 다이어그램 추가
- 크롤러 어댑터 추가 (관심 회사 확대)
- 블로그 검색 / 태그 페이지