
Notion 기반 블로그 자동화 프로젝트 (2): 실행
2025년 6월 22일
이 시리즈는 노션과 MCP를 활용해, 콘텐츠 작성부터 배포까지 자동화된 블로그를 만드는 과정을 기록한 글입니다
레포지토리 저장소: 링크
지난 1편에서는 노션과 MCP를 활용해 블로그의 뼈대를 세웠습니다.
이번 글에서는 그 뼈대를 실제 서비스로 구현해가기 위한 기술적 토대와 실제 화면 개발 과정, 그리고 자동화의 완성까지 이어지는 경험을 공유하고자 합니다.
모노레포 세팅
모노레포 세팅에는 의존성을 관리해주는 pnpm workspace와 빠른 빌드를 위한 turborepo를 사용했습니다.
pnpm workspace는 여러 패키지가 같은 라이브러리를 사용할 때, 하드링크를 통해 의존성을 루트에 한 번만 설치하고, 각 패키지에 필요한 버전별로 링크를 관리합니다. 같은 버전을 여러번 설치할 필요가 없고, 만약 패키지마다 서로 다른 버전을 사용하더라도 각 패키지가 자신에게 맞는 버전을 정확하게 바라보도록 내부적으로 관리해줍니다. 덕분에 의존성 충돌 없이, 중복 설치를 피할 수 있었습니다.
또한, workspace 프로토콜을 통해 publish하지 않은 메인 앱에 패키지를 연결하여 사용할 수 있어 로컬 변경사항을 실시간으로 반영해 사용할 수 있다는 것도 큰 장점으로 다가왔습니다.
turborepo는 변경된 패키지만 빌드하여 빌드 시간을 크게 줄여줍니다.
모노레포 세팅은 다음과 같은 순서로 진행됐습니다.
- 디렉토리 구조 설계
- pnpm-workspace.yaml 파일 생성 및 설정 추가
- 루트 packge.json 설정 추가
- turbo.json 설정 추가
- 각 패키지별 설정 추가
packages 폴더에는 공통으로 사용될 수 있는 여러 config 파일들과 ui 패키지를 추가했습니다.
프로젝트 구조는 아래와 같습니다.
1├── apps 2│ └── main 3└── packages 4 ├── ui 5 ├── eslint-config 6 ├── prettier-config 7 ├── tailwind-config 8 └── tsconfig
노션 DB 연동
홈 화면에서는 노션 DB에 저장된 포스트 리스트를 불러옵니다.

Notion DB 예시
처음엔 Notion Api를 직접 호출했는데, 에러처리 등이 번거로워 결국 notion-sdk-js로 돌아왔습니다. notion-sdk-js는 아래와 같이 사용할 수 있습니다.
1import { Client } from '@notionhq/client'; 2export const notion = new Client({ auth: process.env.NOTION_API_KEY });
쿼리 역시 notion-sdk-js의 databases.query를 활용합니다.
distributable 체크박스가 true인 포스트만, 최신순으로 보여주도록 했습니다.
1export const getDatabasesResult = async () => {
2 try {
3 const res = await notion.databases.query({
4 database_id: DATABASE_ID.POST,
5 filter: {
6 and: [
7 { property: 'distributable', checkbox: { equals: true } }
8 ]
9 },
10 sorts: [
11 { property: 'created_time', direction: 'descending' }
12 ]
13 });
14 return (res.results ?? []);
15 } catch (error) {
16 // 에러 핸들링
17 return [];
18 }
19};
notion-sdk-js 사용시 한글 속성 타입 이슈가 있었는데, 상속으로 타입을 커스텀하는 식으로 해결했습니다.
그리고 홈/포스트 화면 전환 시 스크롤바 유무로 인한 레이아웃 쉬프트 문제가 있어서, 최신 CSS 속성인 scrollbar-gutter로 해결했습니다.
노션 블록 → 리액트 컴포넌트
노션은 모든 콘텐츠를 정해진 타입의 블록 객체로 관리합니다. 이를 리액트 컴포넌트로 변환하기 위해 블록에 대응하는 컴포넌트를 하나씩 매핑해 주었습니다.
1{
2 "type": "heading_1",
3 "heading_1": {
4 "rich_text": [...]
5 }
6}
플로우는 다음과 같습니다.

노션 블록이 리액트 컴포넌트로 렌더링되는 과정
makeBlocksGroup 함수는 독립된 블록으로 내려오는 리스트 블록(bulleted_list_item, numbered_list_item)을 그룹핑 해주는 역할을 합니다.
1[ 2 { type: "bulleted_list_item", ... }, 3 { type: "bulleted_list_item", ... }, 4 { type: "paragraph", ... } 5]
bulleted_list_item을 ul > li, ol > li 같은 HTML 리스트 구조로 변환하기 위해 리스트 시작과 끝을 추적해서 group을 만듭니다.
1export const makeBlocksGroup = (blocks: GetBlockResponse[]) => {
2 const results: ConvertedBlockInterface[] = [];
3
4 for (let i = 0; i < blocks.length; i++) {
5 const block = blocks[i];
6 const lastPushedBlock = results.at(-1);
7
8 if ('type' in block && block.type === 'bulleted_list_item') {
9 if (lastPushedBlock?.type === 'bulleted_list_item_group') {
10 results.at(-1).bulleted_list_item_group.push(block);
11 continue;
12 } else {
13 results.push({
14 bulleted_list_item_group: [block],
15 id: block.id,
16 type: 'bulleted_list_item_group',
17 });
18 continue;
19 }
20 }
21
22 if ('type' in block && block.type === 'numbered_list_item') {
23 // 생략
24 }
25
26 results.push(block);
27 }
28
29 return results;
30};
이미지 관리
홈 화면과 포스트 화면에서, 간헐적으로 이미지가 보이지 않는 현상이 발생했습니다.
새로고침하면 다시 이미지가 보이지만, 사용자 경험에 있어 큰 문제라고 생각되어 원인을 추적하였습니다.
문제의 핵심은 Notion API를 통해 내려오는 이미지 URL의 유효기간이 약 1시간이라는 점이었습니다.
즉, 시간 경과 후 동일한 URL로 접근할 경우 이미지가 만료되어 보이지 않게 됩니다.
처음에는 Next.js의 revalidate를 통해 1시간마다 캐시를 갱신하도록 설정했기 때문에 문제가 없을 거라 생각했지만, 실제로는 그렇지 않았습니다.
1export const revalidate = 3600;
이 설정은 “3600초 동안 캐시를 유지한다”는 의미일 뿐, 정확히 1시간마다 백그라운드에서 데이터를 갱신하는 것이 아닙니다.
1시간이 지난 이후, 처음 접속한 클라이언트 요청에서 비로소 캐시 만료 여부가 판단되고, 이 요청은 여전히 오래된 데이터를 보고, 그 이후에 백그라운드에서 ISR이 수행됩니다.
즉, 1시간이 지난 후 최초 접근자는 깨진 이미지를 보게 됩니다. 새로고침 후에야 최신 데이터가 반영되면서 이미지가 정상적으로 보이게 되는 구조입니다.
이를 해결하기 위해 최적의 해결책은 이미지를 영구적으로 저장할 수 있는 스토리지를 활용하는 것이었습니다.
저는 Cloudinary를 선택했고, 주요 이유는 다음과 같습니다:
- 무료 플랜에서 제공되는 25GB 저장 용량
- 이미지 포맷 변환 및 최적화 기능 풍부
- 공식 SDK 제공
이미지를 Cloudinary에 올리고, 블록의 이미지 URL을 영구적인 주소로 바꾸는 작업은 빌드 시점에 한 번 수행됩니다.
처리 흐름은 다음과 같습니다:
- Notion API를 통해 모든 block을 가져온다.
- block의 type이 image인 경우만 필터링한다.
- fetch로 이미지 다운로드 → base64 인코딩
- base64 데이터를 Cloudinary에 업로드
- Cloudinary로부터 secure_url 반환
- 해당 block의 URL을 영구 주소로 교체한다.
아래처럼 블록 타입 자체가 변경되기 때문에, 한 번 변환된 이미지는 다시 처리되지 않습니다.
1// 기본 형식 (유효기간이 있는 URL)
2{
3 type: 'image',
4 image: {
5 type: 'file',
6 file: { url: string },
7 caption?: { plain_text: string }[]
8 }
9}
10
11// 외부 이미지 형식 (Cloudinary로 교체 후)
12{
13 type: 'image',
14 image: {
15 type: 'external',
16 external: { url: string },
17 caption?: { plain_text: string }[]
18 }
19}
Cloudinary에 이미지 업로드 로직을 반복해서 사용하기 위해 Cloudinary SDK를 감싼 래퍼 클래스를 만들었습니다. (코드: 링크)
컨텐츠 업데이트 자동화
처음엔 Notion에 distributable이라는 DB 컬럼을 추가해서 버튼 클릭시 캐시 무효화되도록 만들고 싶었습니다. 그러나, Notion Webhook 기능은 유료 플랜에서만 제공되기 때문에 이 방식은 사용할 수 없었습니다.
이를 우회하기 위해선 폴링 방식으로 노션 DB의 변경점을 주기적으로 확인해주어야 했습니다.
노션 DB의 변경점을 감지하기 위해 폴링의 주기는 최소 1분은 되야한다고 생각했습니다. 그러나, 이 경우 하루 1440번의 호출이 발생하기 때문에 비용, 성능 측면에서 비효율적이라 생각했습니다. 만약, 폴링 주기를 5-10분정도로 늘린다면 배포가 이미 끝났을 시간이기 때문에 메리트가 없었습니다.
그래서 구상한 방법은 Github Actions를 활용하는 것입니다.
전체 플로우는 다음과 같습니다:
- revalidate API를 만들고, 내부에서 revalidateTag로 캐시 무효화 처리
- GitHub Actions에서 이 API를 호출
revalidateTag를 사용하기 위해선 api마다 tag를 붙여주어야 하는데요. notion-sdk-js를 통해서 fetch의 옵션을 커스텀할 수 없었기 때문에 unstable_cache를 사용하여 tag를 붙여주었습니다.
unstable_cache 사용 코드: 링크
revalidate api는 아래와 같습니다:
1// 경로는 app/api/revlidate/route.ts
2export const POST = async (request: NextRequest) => {
3 try {
4 // 요청 본문에서 시크릿 키 가져오기
5 const { revalidationKey } = await request.json();
6
7 // 검증되지 않은 요청을 방지하기 위해 key 검증 필요
8 if (revalidationKey !== process.env.REVALIDATION_KEY) {
9 return NextResponse.json({ message: '유효하지 않은 토큰' }, { status: 401 });
10 }
11
12 // 홈과 모든 포스트 페이지의 캐시 무효화
13 revalidateTag('page');
14 revalidateTag('blocks');
15 revalidateTag('database');
16
17 //...
18};
revalidate.yml은 아래와 같습니다:
1name: Revalidate Cache
2
3on:
4 workflow_dispatch:
5 inputs:
6 site_url:
7 description: "Blog URL"
8 required: true
9 default: "https://devna.xyz"
10
11jobs:
12 revalidate:
13 runs-on: ubuntu-latest
14 steps:
15 - name: Revalidate Next.js Cache
16 id: revalidate
17 run: curl -X POST ${{ inputs.site_url }}/api/revalidate ..
CI/CD
CI/CD는 docker를 이용해 애플리케이션을 이미지로 빌드하고, 실행하는 방식으로 구성하였습니다.
docker를 구성하는 요소 중 하나인 dockerfile은 일종의 이미지 생성 레시피입니다. 해당 파일에 명시된 설정을 기반으로 이미지를 만들고, 이를 원격 레지스트리에 업로드합니다. 이후 서버에서는 이 이미지를 내려받아 실행하게 되고, 실행된 이미지는 컨테이너가 됩니다.
이렇게 만들어진 여러 개의 컨테이너를 동시에 관리할 수 있게 도와주는 도구가 docker-compose입니다.
컨테이너는 이미지를 실행시킨 결과입니다. 데스크톱 앱을 예로 들면 앱 설치 파일은 이미지이고, 앱을 실행시킨 것이 컨테이너라고 할 수 있습니다. 컨테이너는 가상머신과 독립적인 환경에서 실행되기 때문에 애플리케이션이 실행되는 환경을 보장할 수 있습니다.
기존 가상 머신 구조와 Docker 기반 구조를 비교한 다이어그램입니다:

가상머신 구조와 Docker 기반 구조 비교
Docker는 이 구조를 통해 애플리케이션을 작고, 빠르게, 그리고 확장 가능하게 배포할 수 있도록 지원합니다.
배포시에 Oracle Cloud의 인스턴스에 docker 컨테이너를 직접 띄우는 구조로 배포하였습니다. 작업을 진행한 순서는 아래와 같습니다.
- 인스턴스 생성 및 필요한 모듈 설치
- dockerfile 작성
- docker-compose.yml 작성
- nginx 설정
- Github Workflow 작성
작업을 진행하는 과정에서 많은 어려움들이 있었습니다. 예를 들어서, 도메인을 입력해도 페이지 응답이 없어 서버 로그를 확인해본 결과, 로그가 남아있지 않았습니다. 이를 통해 요청이 nginx에 닿고 있지 않다는 점을 파악하여 oracle cloud 콘솔에 443 port에 대한 ingress rule을 추가해 해결할 수 있었습니다.
이것 이외에도 배포 스크립트를 수정하고난 뒤 문제가 발생했습니다. 서버에 접속해서 docker compose logs 명령어를 통해 인증서가 잘 로드되고 있지 않다는 것을 파악할 수 있었습니다. 코드를 자세히 들여다보니 호스트에 저장되어 있는 인증서를 volumes를 통해 nginx 컨테이너 내부로 옮기는 과정에서 경로가 잘못되어 이를 수정해 주었습니다.
https 통신을 위한 인증서 설정시에는, certbot 인증서 유효기간이 30일이기 때문에 인증서를 주기적으로 업데이트 해주어야 합니다.
전체 CI/CD 흐름은 다음과 같습니다:
- dockerfile을 통해 이미지를 빌드하고 원격 registry에 올립니다.
- github actions가 Oracle 서버에 ssh 접속하여 가장 최신 이미지를 다운받습니다.
- docker-compose up -d 명령어를 통해 main-app, nginx 컨테이너를 실행합니다.
배포 스크립트는 다음 링크에서 볼 수 있습니다: 링크
docker와 nginx 코드는 다음 링크에서 볼 수 있습니다: 링크
애플리케이션 구조를 도식화하면 아래와 같습니다:

애플리케이션 구조
여기서, nginx와 main-app이 수평 계층에 존재하는데 어떻게 nginx에 요청이 먼저 닿는지 이해가 가지 않았는데요. 이는 포트 번호와 관련이 있습니다.
관례적으로 http 요청은 80, https 요청은 443을 가지는데, docker-compose 구성시에 80/443 포트는 nginx가 점유하도록 지정하였기 때문에 외부에서 요청이 들어오면 nginx를 먼저 들르게 됩니다. nginx에서는 3000 포트(main-app)로 요청을 전달합니다.
마무리
Next.js의 가장 큰 장점은, 애플리케이션 퀄리티의 저점을 높여준다는 점입니다.
예를 들어, 내장된 <Image> 컴포넌트는 LCP 최적화를 자연스럽게 유도했고, app router는 colocation 구조를 통해 코드의 응집도를 높여주었습니다. ISR(Incremental Static Regeneration)도 export const revalidate 한 줄만으로 설정이 가능했고, RSC 역시 안정적으로 지원되면서 전반적으로 쾌적하고 직관적인 개발 경험을 제공했습니다.
하지만 최근 15.2+ 버전에서 도입된 streaming metadata에서 메타데이터가 js 로딩 이후에 삽입되어 SEO에 악영향을 줄 수 있다는 점이 문제로 제기됐는데요. 이는 Vercel에서만 제공하는 htmlLimitedBots 옵션을 통해 완화할 수 있는데, 이는 곧 Next.js가 Vercel에 종속됨을 의미합니다.
Next.js 자체는 매우 강력한 도구이지만, 향후 방향성이 더 노골적으로 Vercel에 종속될 경우 섣불리 선택하긴 어려울 것 같습니다.
위와 같은 문제가 제시된 것이 처음도 아니기에 앞으로 Next.js의 방향성을 유심히 살펴봐야 할 것 같습니다.
관련해서는 이 글에서 더 자세히 다루고 있습니다.
끝으로
프론트엔드 개발자는 직접만든 UI, 기능, 제품을 시각적으로 확인할 수 있다는 점에서 굉장히 매력적인 직업이라고 생각합니다. 이번 작업도 프론트엔드 개발자로써 뿌듯함을 느낄 수 있는 작업이 되었습니다.
이 블로그는 앞으로 제가 사용해보고 싶은 기술을 실험해볼 수 있는 실험실이자, 더 넓은 개발 커뮤니티와 연결되는 계기가 되었으면 좋겠습니다.
얼마나 꾸준히 글을 작성할 수 있을지는 장담할 수 없지만, 적어도 제가 배운 개념을 정리하고, 저라는 사람을 알리고, 외부 개발자들과 연결되길 바랍니다.
그리고 무엇보다 이 모든 과정이 지속 가능하도록 과정 자체에서 기쁨을 느낄 수 있는 방향으로 꾸준히 고민하고 가꾸어 나갈 예정입니다.
저는 개발을 좋아합니다. 특히 문제를 해결하는 과정에서 더 큰 즐거움을 느낍니다. 앞으로는 그저 좋아하는 것을 넘어, 이 일을 사랑하는 사람이 되고 싶습니다.
개발자는 성장을 멈추는 순간 도태되는 직업이라고 생각합니다. 꾸준히 성장하면 평균을 유지할 수 있고, 남들보다 더 많이, 깊게 파고들면 그만큼 더 앞서 나갈 수 있습니다.
이 업계는 그런 속도감을 요구하고, 게으름은 쉽게 드러납니다.
그리고 이건 비단 개발자만의 이야기가 아닙니다.
소프트웨어 회사 자체가 게을러지면, 곧 방향을 잃고 무너집니다.
이 숙명이 가끔은 버겁지만, 동시에 저는 뒤에서 출발해도, 언제든 앞으로 나아갈 수 있는 기회가 주어진다고 생각합니다:
그렇기 때문에 지속적으로 성장하며 앞으로 나아가는 개발자가 되겠습니다.
마지막으로 앞으로의 목표를 작성하고 마무리하도록 하겠습니다 :)
- 나의 경험과 지식을 정리하고 전파하기
- 외부 개발자들과 지속적으로 소통하기
- 글을 쓰기 위한 글이 아닌, 퀄리티 있는 콘텐츠 작성하기
- 1년 이상 운영하며 경험을 축적하기