1. 버그

크고 작은 버그들이 있었지만 특히 기억에 남는 3가지 버그가 있었다.
가. 리터럴 남발
AI agent가 빠르게 완성한 코드에는 치명적인 버그들이 있었다.
그중 하나가 바로 리터럴 값을 남발을 남발하는 문제다.
이곳저곳 빼곡하게 리터럴 값으로 채웠다.
거기에 더해서 리터럴 값에 대한 일관성을 잘 유지하지 못하는 모습을 보였다.
아마 LLM의 Context Window 크기에는 한계가 존재하기 때문에 프로젝트 전체에서 일관성을 유지하기는 어려운 것 같다.
리터럴을 Enum, Interface 그리고 Type을 활용해서 최대한 리팩터링했다.
덕분에 신뢰성과 유지보수성 향상시킬 수 있었다.
나. 잘못된 이미지 URL
이미지를 처리하는 기능이 구현하기 가장 까다로운 기능이었다.
Notion에서 제공하는 URL은 1시간 후에 만료된다.
때문에 직접 다운로드 후 WordPress로 업로드를 한 후 해당 URL로 교체해야 한다.
처음에는 Notion Block을 HTML로 변환한 다음 그 속에서 URL 자체를 찾아서 교체했다.
다만, URL이 퍼센트 인코딩되는 과정에서 변형되었고 정상적으로 URL을 추출할 수 없는 문제가 발생했다.
/**
* Extract image references recursively from MdBlock array.
* Replaces image URLs in the block content with placeholders.
* @param mdBlocks - The array of MdBlock objects.
* @returns An array of ImageReference objects extracted from the blocks.
*/
private extractImagesRecursively(mdBlocks: MdBlock[]): ImageReference[] {
let images: ImageReference[] = [];
if (!Array.isArray(mdBlocks) || mdBlocks.length === 0) return images;
for (const block of mdBlocks) {
if (block.type === 'image') {
const b = block as { parent: string; blockId: string };
const p = b.parent;
const placeholder = `image-${b.blockId}`;
// Extract altText and url from markdown syntax 
const imageRegex = /!\[.*?\]\((.*?)\)/g;
let match = imageRegex.exec(p);
if (!match)
throw new Error(
`Failed to extract image url from markdown block: ${JSON.stringify(block)}`
);
const altText = match[0].slice(2, match[0].indexOf('](')); // 2 is length of '!['
const url = match[1];
// Replace url with placeholder at here!
block.parent = block.parent.replace(url, placeholder);
images.push({ blockId: b.blockId, url, altText, placeholder });
logger.debug(
`extractImagesRecursively: extracted image - blockId: ${b.blockId}, placeholder: ${placeholder}`
);
}
images.push(...this.extractImagesRecursively(block.children));
}
return images;
}
Code language: JavaScript (javascript)
해당 프로세스를 보다 신뢰성 있게 처리하기 위해서 HTML로 변환하기 앞서 Notion Image URL을 추출하고 해당 자리에 placeholder를 삽입했다.
PlaceHolder가 삽입된 Notion Block을 HTML로 변환한 다음, WordPress Image URL로 다시 교체하도록 수정했다.
수정한 이후로는 문제없이 잘 동작하고 있다.
다. Callout Block 속 이미지
Notion의 Callout Block 내부에 위치한 이미지는 정상적으로 URL이 교체되지 않는 문제가 있었다.
해당 문제는 Notion의 Callout Block을 Markdown으로 변환하는 과정에서의 발생하는 문제였다.
Notion Block을 Markdown으로 변하기 위해 notion-to-md 라이브러리를 사용했다.
해당 라이브러리가 Notion Callout Block을 Markdown으로 변환하는 방식이 독특했다.
{
"type": "callout",
"blockId": "2a8a3a2b-1013-8097-b786-d1948b38cf92",
"parent": "> 💡 image \n> --- \n> \n> ",
"children": [
{
"type": "divider",
"blockId": "2a8a3a2b-1013-80c0-bd40-f5705701a07e",
"parent": "---",
"children": []
},
{
"type": "image",
"blockId": "2a8a3a2b-1013-803f-ba5f-c6daa26b1e58",
"parent": "",
"children": []
}
]
}
Code language: JSON / JSON with Comments (json)
Notion의 Callout은 자식 블록들을 parent와 children두 군대에 나눠 저장한다.
notion-to-md는 parent만 Markdown으로 변환한다.
children은 취급하지 않는다.
아무리 children 내부의 이미지 URL을 수정해도 Markdown에는 반영되지 않는다.
/**
* Handle callout blocks recursively in MdBlock array.
* notion-to-md library handles callback blocks in an incomprehensible way.
* Converts callout blocks to paragraph blocks with cleaned content.
* Moves children of callout blocks to same level.
* @param mdBlocks - The array of MdBlock objects.
* @returns The updated array of MdBlock objects with callouts handled.
*/
private handleCalloutRecursively(mdBlocks: MdBlock[]): MdBlock[] {
// 2025.11.12 notion-to-md version 3.1.9
// notion-to-md converts parent of callout block to markdown,
// but does not handle children blocks separately.
// So we convert callout blocks to paragraph blocks,
// and move children blocks to the same level.
// Deep copy to avoid mutating original blocks
let updatedBlocks: MdBlock[] = [];
if (!Array.isArray(mdBlocks) || mdBlocks.length === 0) return updatedBlocks;
for (const block of mdBlocks) {
if (block.type === 'callout') {
// Remove image markdown syntax from callout content
const callout = block as { parent: string; blockId: string; type: string };
const urlRegex = /!\[.*?\]\((.*?)\)/g;
const ctitle = callout.parent.split('\n')[0].replace(urlRegex, '');
const updatedBlock = {
parent: ctitle,
blockId: callout.blockId,
type: 'paragraph',
children: [],
};
updatedBlocks.push(updatedBlock);
// Move children to the same level
updatedBlocks.push(...this.handleCalloutRecursively(block.children));
} else {
const updatedBlock: MdBlock = { ...block, children: [] };
updatedBlock.children = this.handleCalloutRecursively(block.children);
updatedBlocks.push(updatedBlock);
}
}
return updatedBlocks;
}
Code language: PHP (php)
Callout Block을 Paragraph Block으로 변환하고 children은 바깥으로 꺼낸다.
이미지 URL을 교체하기 앞서 모든 Callout에 대하여 재귀적으로 처리한다.
2. 성능 개선
가. 이미지를 Batch 단위로 처리
처음에는 Image를 하나씩 다운로드하고 업로드했다.
시간을 줄이기 위해서 여러 이미지들을 batch로 묶어고 병렬로 처리했다.
case : 3개 post, 31개 이미지
before :
- 108402ms → 약 109초
after :
- 64706ms → 약 65초
- 80484ms → 약 80초
Code language: JavaScript (javascript)
덕분에 평균적으로 30% ~ 40% 정도 시간을 아낄 수 있었다.
// Process images in batches
for (let i = 0; i < images.length; i += maxConcurrent) {
logger.info(
`Syncing images ${i + 1} to ${Math.min(i + maxConcurrent, images.length)} of ${images.length}`
);
const batch = images.slice(i, i + maxConcurrent);
const promises = batch.map((image) => this.syncImage(syncJobItem, imageMap, image));
const batchResults = await Promise.allSettled(promises);
results.push(...batchResults);
}
// Collect errors
for (const result of results) {
if (result.status === 'rejected') {
errors.push(new Error(result.reason));
}
}
// If any errors, throw aggregate error
if (errors.length > 0) {
const message = errors.map((e) => e.message).join('; ');
throw new Error(`Failed to sync ${errors.length} images : ${message}`);
}
Code language: JavaScript (javascript)
이때 예외를 확실하게 처리하기 위해서 Promise.all()이 아닌 Promise.allSettled()를 사용했다.
단 하나의 이미지라도 실패한다면 전체가 실패한 것으로 간주한다.
나. Retry 전략 개선
외부 API 요청이 많은 구조라 일시적인 오류가 빈번하게 발생했다.
이를 안정적으로 처리하기 위해 지수 백오프 기반의 재시도 전략을 도입했다.
지수 백오프는 재시도 간격을 1초 → 2초 → 4초처럼 점진적으로 늘려 성공 가능성을 높이는 방식이다.
네트워크 지연이나 외부 서비스의 일시적 오류가 발생해도 효과적으로 대응할 수 있다.
Notion, WordPress, Telegram API와 같이 신뢰성이 중요한 구간에 적용해 전체 처리 안정성을 크게 개선했다.
# --- Retry Configuration ---
MAX_RETRY_ATTEMPTS=3
RETRY_INITIAL_DELAY_MS=1000
RETRY_MAX_DELAY_MS=30000
RETRY_BACKOFF_MULTIPLIER=2
Code language: PHP (php)
.env에서 재시도 횟수, 초기 지연 시간 등을 조정하며 환경별로 최적화할 수 있게 만들었다.
다. 증분 스캔
Notion Database에 데이터가 많이 쌓일수록 전체 페이지를 매번 스캔하는 방식은 불필요한 부하를 만들기 때문에 증분 스캔 방식을 도입했다.
마지막 동기화 시점을 저장해 두었다가, 이후 수정된 페이지만 조회하도록 구현했다.
3. 배포 과정
가. CI/CD
이번에는 처음으로 CI/CD의 모든 것을 Github에서 처리했다.

Release에서 tag를 만들면 Actions가 동작하도록 구성했다.

자동으로 Docker Image를 빌드하고 ghcr에 업로드한다.

Github Actions는 처음 시도해보았는데 상당히 괜찮았다. 굉장히 쉽고 빨랐다.
모든 작업이 Github 안에서 물 흐르듯 자연스럽게 동작했다.
개발하면서 이런 자연스러움은 경험하기 쉽지 않은 일이다.
나. 문서화
최대한 문서화 하려고 노력했다.
혹시 다른 사람이 사용할 수도 있고 앞으로 계속햇 개발을 이어나가기 위해서다.
README.md, quickstart.md, quickstart-dev.md, LICENSE, sequence-sync-success.md, sequence-sync-failure.md 등 작성했다.
전체적으로 AI에게 작성하도록 지시하고 나는 검토했다.
Iteration을 반복할수록 완성도있는 문서를 얻을 수 있었다.
AI가 다른 것은 몰라도 문서화 만큼은 정말 잘한다.
덕분에 문서화가 빠르고 쉬워졌다.
퀵스타트 가이드와 LICENSE까지 작성하는 건 처음이다.

README.md도 나름 신경썼다.
다른 오픈소스 프로젝트들을 많이 참고했다.

특히 퀵스타트 가이드를 신경써서 작성했다.
배포하는 법, 설정하는 법, 사용법을 자세하게 설명했다.
다른 사람이 충분히 따라서 사용할 수 있도록 처음부터 끝까지 영어로 작성했다.
4. 실사용
홈서버에 VM을 생성하고 docker를 설치해서 서비스를 배포하고 있다.

WordPress 블로그 4개, MySQL 1개, phpadmin 1개, Notion2Wordpress 4개를 운영하고 있다.


아직까지 잘 동작하고 있고 너무 편하다.
개발 블로그도 빨리 WordPress로 옮겨야 겠다.
아직 투자된 시간만큼 본전을 찾진 못했지만 그리 오래걸리진 않을 듯 하다.
여기에 Notion AI까지 도입하면 정말 빠르게 컨텐츠를 생성할 수 있을 것 같다.
사용하면서 불편한 것들은 지속적인 개선할 예정이다.
가장 먼저 북마크, 썸네일, 카테고리, 태그를 설정하는 기능을 고려하고 있다.
5. 느낀점
가. Agentic Coding
Agentic Coding은 필수다.
하지만 실무에선 개발자의 개입이 필요하다.
생산성과 신뢰성 사이의 적절한 타협점을 찾을 필요가 있다.
그런 면에서 Agentic Coding의 많은 문제를 TDD가 예방할 수 있을 것 같다.
Test 가능한 최소한의 단위로 끊어서 구현하도록 프롬프트를 작성해야겠다.
나. Agentic Coding의 3요소
Claude Code, Github Copilot, OpenAI Codex, Cursor2, Google Antigravity…. AI 도구들의 춘추전국시대다.
자고 일어나면 새로운 서비스가 출시된다.
개인적으로 AI Agentic Coding은 Editor, Model, Context 3가지 요소로 이루어진다고 생각한다.
매일 새로운 Tool과 Model이 출시된다.
휩쓸리지 않으려면 Context에 집중해야 한다.
자신에게 맞는 프롬프트, 템플릿, 자료를 쌓아가야 한다.
(feat. 조만간 구조화된 프롬프트를 쉽고 빠르게 생성할 수 있는 툴을 만들어보자.)
(feat. TUI도 좋지만 개인적으로 결국은 IDE에서 하게 되더라…)
다. 느리지만 가장 빠른 길
꼬박 한달, 생각보다 시간이 오래걸려서 불안했다.
직접 만드는 것은 처음에는 효율이 나오지 않는다.
하지만 MVP를 완성하고 어느정도 자리를 잡는 순간부터 급격히 효율이 증가하는 것 같다.
비효율을 개선하지 않고 버티면 점점 시간은 낭비된다.
반면에 직접 문제를 해결하기 위해 노력한 시간은 다르다.
처음에는 더 많은 시간을 투자해야하고 부담되지만 문제를 해결하기 위해서 노력한 시간은 흩어지지 않고 쌓인다.
그런 면에서 소프트웨어를 직접 만드는 것은 시간을 쌓는 것 같다.
지름길은 없다. 가장 느리지만 확실한 길을 가자.
라. 처음으로 쓸모있는 것을 만들었다.
지금까지는 기술을 습득하기 위해서 프로그램을 만들었다.
SSAFY에서 몇달동안 노력해서 만든 프로그램도 이런저런 이유로 서비스할 수 없었다.
어느샌가 “스스로를 쓸모없는 것만 만드는 사람이다”라는 부정적인 생각이 자리잡았다.
Notion2Wordpress는 거창한 이유가 있어서 시작한게 아니다.
단지 내가 필요해서 개발했다.
아무리 찾아도 적당한 것이 없어 직접 개발하게 되었다.
사실 사용한 기술도 아키텍처도 별 볼일 없는 그저 그런 프로그램이다.
하지만 처음으로 쓸모있는 것을 만들어냈다는 사실에 큰 기쁨을 느낀다.
앞으로도 쓸모있는 것을 만들고 싶다.