5번의 복붙 지옥에서 1번의 명령어로: NotionPresso CLI 개선기
처음 NotionPresso를 써봤을 때의 당황
npresso --page https://notion.so/myblog/post-1... --auth secret_key...
...
npresso --page https://notion.so/myblog/post-5... --auth secret_key...
노션에 정리해둔 블로그 포스트 하나를 옮기려고 NotionPresso CLI를 처음 사용했을 때의 경험이었다. 긴 URL을 복사하고, 시크릿 키를 붙여넣고, 엔터를 누르는 과정을 거치면서 "이게 맞나?" 싶었다.
만약 더 많은 글을 옮기려는 상황이라면 어떨까? 노션에 수십 개의 글을 정리해둔 사람이 이걸 다 옮긴다고 생각해보니, 이 방식으로는 정말 지옥 같을 것 같았다. 위의 5번 반복 상황은 충분히 현실적인 시나리오였다. 한두 개 글이야 그러려니 하겠지만, 블로그 이전이나 대량 동기화를 해야 하는 상황에서는 치명적인 불편함이었다.
"이걸 자동화할 수 없을까?"
그 생각이 이번 개선 작업의 시작이었다. 5번의 복붙이 1번의 명령어로 바뀔 수 있다면 얼마나 좋을까? 그리고 실제로 그 꿈은 현실이 되었다.
# 이제는 이것만으로 끝
npresso --all
하지만 이 간단해 보이는 변화 뒤에는 생각보다 복잡한 구현과 많은 고민들이 숨어있었다. 취준생인 내가 오픈소스 기여를 통해 배운 것들을 공유해보려고 한다.
간단해 보였는데 복잡했던 것들
1. 타입 안전한 페이지 정보 추출: "Notion API가 이렇게 복잡하다고?"
가장 먼저 부딪힌 건 Notion API 응답에서 페이지 제목을 추출하는 일이었다.
"그냥 page.title
하면 되는 거 아닌가?" 싶었는데, 현실은 달랐다.
// 처음에 이렇게 하려 했는데...
const title = page.properties.title.title[0].plain_text; // ❌ 타입 에러
빨간 줄이 그어지면서 TypeScript가 화를 냈다. 문제는 Notion 페이지마다 속성 동적이다는 점이었다.
어떤 페이지는 properties.title
에 제목이 있고, 어떤 페이지는 다른 속성에 title 타입이 숨어있었다.
그래서 안전하게 접근하는 함수가 필요했다.
function getPageTitle(page: any): string {
// 첫 번째 시도: 일반적인 title 속성
if (page.properties?.title?.title?.[0]?.plain_text) {
return page.properties.title.title[0].plain_text;
}
// 두 번째 시도: 모든 속성을 순회하며 title 타입 찾기
for (const prop of Object.values(page.properties || {})) {
if ((prop as any)?.type === 'title' && (prop as any)?.title?.[0]?.plain_text) {
return (prop as any).title[0].plain_text;
}
}
// 최종 fallback
return 'Untitled';
}
이 과정을 통해 데이터베이스 페이지의 동적 속성명 처리가 생각보다 복잡하다는 걸 배웠다. 일반 페이지와 달리 속성명이 사용자마다 다르기 때문에type: "title"
로 찾아야 했다. Optional chaining과 fallback 전략은 선택이 아니라 필수였다.
2. 선택적 업데이트
문제: 모든 페이지를 매번 처음부터 처리하는 비효율
--all
옵션을 구현하면서 가장 고민이 됐던 부분은 "어떤 페이지가 업데이트되었는지" 판단하는 로직이었다. 사용자 입장에서 매번 모든 페이지를 다시 처리하면 불필요한 대기 시간이 발생한다. 실제로는 한두 개만 수정했을 텐데 수십 개를 모두 처리하는 건 비효율적이니까. 그래서 변경된 페이지만 선별하는 방법을 처음부터 고려했다.
첫 번째 시도: 단순 시간 비교
가장 직관적인 방법은 last_edited_time
을 비교하는 것이었다. 새로운 시간이 기존 시간보다 최신이면 업데이트하는 식으로.
한계: 파일 시스템 불일치
하지만 시간만 비교하면 문제가 생긴다. 사용자가 JSON 파일을 삭제했거나 이전 실행이 중단된 경우, 메타데이터에는 "최신"이라고 기록되어 있지만 실제 파일이 없을 수 있다. 이 경우 시간 비교로만은 해당 페이지가 영영 복구되지 않는다.
개선: 두 단계 검증
결국 두 가지를 순서대로 확인하는 구조로 만들었다:
- 1
파일이 실제로 존재하는가?
- 2
시간상 업데이트가 필요한가?
이 로직을 다듬으면서 성능과 안정성도 함께 챙겼다. 페이지가 많아질수록 배열 전체 순회는 비효율적이니까 기존 데이터를 Map으로 변환해서 페이지 ID로 즉시 찾도록 최적화했다. 또한 Notion의 복잡한 페이지 의존성(하위 페이지 수정 시 상위 페이지 시간도 변경)을 완벽히 추적하는 대신, last_edited_time
이 변경되면 일단 업데이트하는 단순하고 안전한 방식을 택했다.
const jsonFile = path.join(outputDir, `${page.fileName}.json`);
// 1단계: 파일 존재 여부 확인
if (!fs.existsSync(jsonFile)) {
return true; // 파일이 없으면 무조건 재생성
}
// 2단계: 시간 비교
return !existingTime || new Date(page.last_edited_time) > new Date(existingTime);
3. 파일 시스템 일관성 보장: "파일과 폴더의 싱크 맞추기"
마지막으로 가장 복잡했던 건 JSON 파일과 이미지 폴더를 일관되게 관리하는 일이었다.
각 페이지마다 page-name.json
파일과 page-name/
이미지 폴더가 생기는데, 이들의 이름이 항상 일치해야 했다. 그런데 페이지 제목에는 파일명으로 쓸 수 없는 문자들이 들어있었다.
function sanitizeFileName(title: string, fallbackId: string): string {
const sanitized = title
.replace(FILE_CONSTANTS.INVALID_FILENAME_CHARS, '-') // /[<>:"/\\|?*]/g
.replace(FILE_CONSTANTS.WHITESPACE, '-') // /\s+/g
.replace(FILE_CONSTANTS.MULTIPLE_DASHES, '-') // /-+/g
.replace(FILE_CONSTANTS.LEADING_TRAILING_DASHES, '') // /^-|-$/g
.toLowerCase();
if (!sanitized || sanitized.length > FILE_CONSTANTS.MAX_FILENAME_LENGTH) {
return fallbackId;
}
return sanitized;
}
파일명에 쓸 수 없는 문자들과 길이 제한 등을 상수로 관리해서 나중에 수정하기 쉽게 했다. OS별 제약사항이 바뀌어도 FILE_CONSTANTS
만 수정하면 된다.
파일 생성 순서도 신경 써야 했다:
// 1. 페이지 데이터 가져오기
const fullPage = await client.fetchFullPage(pageId);
const title = getPageTitle(fullPage);
const finalFileName = fileName || sanitizeFileName(title, pageId);
// 2. 경로 설정
const outputFile = path.join(outputDir, `${finalFileName}.json`);
const finalImageOutDir = path.join(imageOutDir, finalFileName);
// 3. 중복 체크 (성능상 빠른 종료)
if (checkExisting && fs.existsSync(outputFile)) {
return { title, skipped: true };
}
// 4. 이미지 폴더 먼저 생성 (이미지 다운로드 위해)
fs.mkdirSync(finalImageOutDir, { recursive: true });
// 5. 이미지 처리
await updateImageOnBlocks({ blocks: fullPage.blocks, imageDir: finalImageOutDir, pageId });
// 6. JSON 파일 생성
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputFile, JSON.stringify(fullPage, null, 2), 'utf-8');
JSON 파일과 이미지 폴더가 같은 이름(finalFileName
)을 사용해서 일관성을 유지했다. 이미지 폴더를 먼저 만들어야 이미지 다운로드가 가능하고, recursive: true
옵션으로 상위 디렉토리까지 한 번에 생성했다.
이렇게 하나하나 처리하고 나니, 사용자가 어떤 환경에서, 어떤 제목으로 페이지를 만들어도 안전하게 동작해야 한다는 것이 단순해 보이는 파일 저장에도 이렇게 많은 고려사항을 만들어냈다는 걸 깨달았다. 예상치 못한 상황에 대비하는 것이 생각보다 중요했다.
실제 문제 해결 과정에서 배운 것들
권한 문제: "왜 갑자기 파일이 안 지워지지?"
빌드를 돌렸는데, 콘솔이 갑자기 빨갛게 물들었다.
EACCES: permission denied, rmdir '/Users/.../cli/dist/notion-data/bookmark'
처음엔 권한이 꼬인 줄 알았다.
근데 원인을 추적해보니, CLI 실행 위치가 문제였다. 현재 디렉토리가 dist
폴더였고, 상대 경로로 출력한 런타임 데이터가 빌드 결과물 안에 생성돼 있었다.
# 문제 상황
├── dist/
│ ├── notionpresso.es.js # 빌드 결과물
│ └── notion-data/ # 런타임 데이터 (잘못된 위치!)
│ └── bookmark/
결국 sudo rm -rf dist/notion-data
로 수동 정리하고, .gitignore
에 notion-data/
와 public/notion-data/
를 추가했다. 그리고 출력 경로를 절대 경로로 고정해 빌드 산출물과 런타임 데이터를 분리했다.
이 일을 겪고 나니, CLI는 실행 위치와 출력 위치를 설계 단계에서 확실히 분리해야 한다는 걸 배웠다. 권한 문제의 절반은 경로 설계에서 시작된다.
크로스 플랫폼 호환성: "맥에서만 테스트하면 안 되는구나"
개발 도중 문득 드는 생각이었다. "내 환경에서만 생각하고 있는 건 아닌가?"
오픈소스는 다양한 환경의 사용자들이 쓰는데, 내 개발 환경에서만 동작하면 의미가 없었다. 가장 먼저 파일명 처리에서 OS별 제약사항을 챙겨야 했다.
// 파일명 처리에서 OS별 제약사항을 고려했다
export const FILE_CONSTANTS = {
INVALID_FILENAME_CHARS: /[<>:"/\\|?*]/g, // Windows 금지 문자
MAX_FILENAME_LENGTH: 50, // 안전한 길이 제한
WHITESPACE: /\s+/g,
MULTIPLE_DASHES: /-+/g,
LEADING_TRAILING_DASHES: /^-|-$/g,
} as const;
README 작성할 때도 마찬가지였다. 같은 기능이라도 OS마다 명령어가 달랐다.
# Windows
echo NOTION_API_KEY=your_token > .env
# macOS/Linux
echo "NOTION_API_KEY=your_token" > .env
단순히 따옴표 하나 차이지만, Windows 사용자에겐 중요한 디테일이었다.
결국 내 맥에서 잘 동작한다고 모든 곳에서 잘 동작하는 건 아니다라는 걸 배웠다. 앞으로는 다른 환경에서도 제대로 동작하는지 꼭 확인해야겠다.
결국 개발 환경과 사용자 환경은 다를 수 있다는 걸 깨달았다. 앞으로는 다양한 환경에서의 테스트를 더 신경 써야겠다고 생각했다.
사용자 관점에서 생각해보기: "문서가 곧 UX다"
기능을 만드는 것과 사용자가 쉽게 쓸 수 있게 하는 것은 완전히 별개였다.
npm에서 라이브러리를 사용할 때마다 불편한 경우가 왕왕 있었다. 특히 사용자 화면에 표시되는 것과 개발자가 임의로 적은 내용이 다를 때가 제일 짜증났다.
그럴 때 사용자는 두 가지 중 하나다. 1) 끙끙대며 예측해보거나, 2) 포기하거나.
나도 그런 경험이 싫어서 환경변수 설정 가이드부터 정확하게 작성했다.
# ❌ 부정확한 안내
NOTION_API_KEY=your_notion_api_token_here
# ✅ 정확한 안내
NOTION_API_KEY=secret_your_internal_integration_secret_here
처음에는 대충 써놨다가, 실제 Notion 화면과 다르다는 걸 깨달았다. API 키를 얻는 방법도 단계별로 상세히 써야 했다.
- 1
Notion Developers 사이트 접속
- 2
"New integration" 생성
- 3
"Internal Integration Secret" 복사 (여기가 핵심!)
- 4
원하는 페이지에 integration 권한 부여
개발자가 당연하게 여기는 것도 사용자에겐 높은 벽일 수 있었다. 하나하나 설명하고 나니, 문서화는 기능 구현만큼 중요하다는 걸 절실히 느꼈다.
오픈소스 기여를 통해 배운 것들
간단해 보이는 기능의 복잡성
학습할 때는 모든 게 단순해 보였다.
5번의 복붙을 1번의 명령어로 바꾸는 게 목표였지만, 그 과정에서 더 중요한 걸 배웠다. 내가 편하게 만든 기능이 사용자에게는 불편할 수 있다는 점이었다. 크로스 플랫폼 호환성을 고려해야 하고, 문서는 실제 화면과 정확히 일치해야 하고, 사용자가 실수할 수 있는 상황을 미리 대비해야 했다.
방어적 코딩, 예외 상황 처리, 안전한 데이터 변환... 이런 것들이 단순히 "좋은 코딩 관습"이 아니라 실제로 필요한 방어막이라는 걸 느꼈다. 코드를 짜는 시간보다 문서를 쓰는 시간이 더 오래 걸렸지만, 문서는 기능의 일부라는 걸 깨달았다.
간단한 기능에도 생각보다 많은 걸 고려해야 한다는 걸 다시금 깨달았다.
작은 기여의 가치
이번 PR은 거창한 기술적 혁신은 아니었다. 그냥 불편했던 걸 조금 편하게 만든 것뿐이었다.
하지만 실제 사용자의 불편함을 해결하는 것이야말로 진짜 의미 있는 개발이라는 걸 느꼈다. 복잡한 알고리즘이나 최신 기술을 쓰지 않아도, 누군가의 일상을 조금 더 편하게 만들 수 있다면 충분히 가치 있는 일이다.
이 경험을 통해 오픈소스 기여에 대한 두려움이 많이 줄었다. 완벽한 코드를 짜야 한다는 부담보다는, 실제 문제를 해결하는 것에 집중하면 된다는 걸 배웠다.
결국 아주 사소한 불편함이 나를 성장시키는 기회이자, 누군가에게 의미 있는 변화를 만들어줄 수 있는 시작점이었다. 앞으로도 그런 순간들을 그냥 지나치지 않으려고 한다.