본문으로 건너뛰기
2023. 4. 23·약 8분

AWS Lambda로 매일 아침 노션 페이지 만들기

시계가 달린 달력의 오늘 칸 옆에서 말풍선 속 람다 기호가 페이지를 만드는 그림

정보

2023년 4월 25일 한국신용데이터 기업 기술블로그에 게재한 내용입니다. 원본 확인하러 가기

시작하며​

노션에 날짜와 태그로 쌓아 온 데일리 노트 목록

저는 매일 아침 할 일과 메모, 배운 것을 기록할 노션 페이지를 만듭니다. 하루를 마칠 때 그 기록을 관련 카테고리나 페이지로 다시 분류합니다.

단순한 일도 매일 반복하면 잊거나 미루게 됩니다. 그래서 데일리 노트 템플릿이 아침마다 정해진 시각에 자동으로 생기게 만들었습니다.

  • AWS CloudWatch Event
  • AWS Lambda
  • Notion API / SDK
  • Terraform

노션 공식 API와 SDK로 페이지 생성을 자동화할 수 있습니다.

  1. Notion SDK로 페이지 생성을 요청하는 Lambda 핸들러를 만듭니다.
  2. CloudWatch Events에서 cron 표현식으로 Lambda 호출을 예약합니다.
  3. 예약 시각이 되면 Lambda가 노션 API를 호출해 워크스페이스에 새 페이지를 만듭니다.

토요일 반나절을 들여 매일 반복하던 일을 하나 줄였습니다.

예제 코드는 다음 저장소에 있습니다.

https://github.com/wonkooklee/notion-page-creation-scheduler

# 프로젝트 구조
.
├─ .gitignore
├─ package.json # -> ~/terraform/module/handlers/package.json (symlink)
├─ src # -> ~/terraform/module/handlers/src (symlink)
│ ├─ addItem.js
│ ├─ index.js
│ └─ utils.js
└─ terraform
├─ main.tf
├─ module
│ ├─ archive.tf
│ ├─ cloudwatchEvent.tf
│ ├─ handlers
│ │ ├─ package-lock.json
│ │ ├─ package.json
│ │ └─ src
│ │ ├─ addItem.js
│ │ ├─ index.js
│ │ └─ utils.js
│ └─ lambda.tf
├─ terraform.tfstate
└─ variables.tf



Notion API와 SDK​

노션은 2021년 5월부터 API와 JavaScript SDK를 제공합니다.

노션 API를 연동하고 인증 키를 만드는 과정은 공식 문서에 나와 있습니다.

문서의 절차에 따라 노션 API 인증 키를 받습니다. 이 키는 예전에는 secret_으로, 2024년 9월 25일 이후 발급분은 ntn_으로 시작합니다. SDK 클라이언트를 초기화할 때 사용합니다.

NOTION_API_KEY="secret_*************"

이벤트 핸들러를 만들기 전에 SDK로 노션 워크스페이스에 페이지를 하나 만들어 봅니다.

$ npm i @notionhq/client

인증 키로 클라이언트 인스턴스를 만들고 페이지 생성 함수를 작성합니다.

저는 CommonJS 모듈로 Node.js 환경에서 함수를 만듭니다. 참고로 노션 API는 CORS 헤더를 내려주지 않아 브라우저에서 직접 호출할 수 없기 때문에, SDK는 서버 사이드(Node.js) 환경에서 사용해야 합니다.

const { Client } = require("@notionhq/client");

// (1) 환경 변수로 API 키를 참조합니다.
const NOTION_API_KEY = process.env.NOTION_API_KEY;

// (2) SDK 클라이언트의 인스턴스를 초기화합니다.
const notion = new Client({ auth: NOTION_API_KEY });

클라이언트 인스턴스에서 접근할 수 있는 API 리소스와 메서드는 다음과 같습니다. 여기서는 pages 리소스의 create 메서드인 notion.pages.create()를 사용합니다. Authentication의 Create a token은 public integration의 OAuth 토큰 교환용입니다. Internal integration은 발급받은 인증 키로 클라이언트를 바로 초기화합니다.

API 버전

이 글의 database_id 부모 예제는 작성 당시의 2022-06-28 API를 기준으로 합니다. Notion API 2025-09-03부터 데이터베이스는 하나 이상의 데이터 소스를 담는 컨테이너가 됐습니다. 새 연결에서는 데이터베이스에서 data_source_id를 조회하고 페이지의 부모를 { type: "data_source_id", data_source_id }로 지정해야 합니다. 자세한 이관 절차는 Notion 공식 업그레이드 가이드에 정리돼 있습니다.

.
├─ Authentication
│ └─ Create a token `POST`
├─ Blocks
│ ├─ Append block children `PATCH`
│ ├─ Retrieve a block `GET`
│ ├─ Retrieve block children `GET`
│ ├─ Update a block `PATCH`
│ └─ Delete a block `DELETE`
├─ Pages
│ ├─ Create a page `POST`
│ ├─ Retrieve a page `GET`
│ ├─ Retrieve a page property item `GET`
│ ├─ Update a page `PATCH`
│ └─ Archive a page
├─ Databases
│ ├─ Create a database `POST`
│ ├─ Filter database entries
│ ├─ Sort database entries
│ ├─ Query a database `POST`
│ ├─ Retrieve a database `GET`
│ ├─ Update a database `PATCH`
│ └─ Update database properties
├─ Users
│ ├─ List all users `GET`
│ ├─ Retrieve a user `GET`
│ └─ Retrieve your token's bot user `GET`
├─ Comments
│ ├─ Create comment `POST`
│ └─ Retrieve comments `GET`
└─ Search
├─ Search by title `POST`
└─ Search optimizations and limitations

# Reference - https://developers.notion.com/reference/intro

공식 문서의 예제는 IIFE(즉시 실행 함수)를 사용합니다. 여기서는 Lambda 핸들러가 이벤트를 받을 때 호출해야 하므로 함수 선언과 호출을 나눕니다. 다음 코드는 SDK 호출 형태만 보여 주며, 실제 addItem.js에서는 뒤에서 만든 요청 객체를 함수 내부에서 조립합니다.

async function createPage(pageRequest) {
try {
const response = await notion.pages.create(pageRequest);
console.log("Success! Entry added.");
return response;
} catch (error) {
console.error(error.body ?? error);
throw error;
}
}

exports.createPage = createPage;

노션 페이지를 만들려면 요청 스키마와 페이지가 들어갈 데이터베이스 ID가 필요합니다.

데이터베이스 ID는 해당 데이터베이스 페이지의 링크에서 확인할 수 있습니다.

https://www.notion.so/{workspace_name}/{database_id}?v={view_id}


페이지 스키마​

노션 페이지에서 page properties 영역과 page content 영역을 구분해 표시한 그림

노션 페이지의 속성과 본문 구분. 출처: developers.notion.com

노션 페이지는 메타 정보를 담는 Page Properties와 본문인 Page Content로 나뉩니다.

본문은 다시 하위(children) 블록으로 나뉩니다.

NotionClientInstance.pages.create에 넘길 주요 본문 매개변수는 다음과 같습니다.

  • parent (required)
    • 페이지가 생성될 부모 페이지나 데이터베이스의 page_id 또는 database_id를 받습니다.
  • properties (required)
    • 부모가 페이지이면 title만 설정할 수 있습니다. 데이터베이스이면 그 데이터베이스의 속성 스키마에 맞춰야 합니다.
  • children
    • 페이지 본문에 표시할 블록 배열입니다. 배열 순서대로 각 블록에 맞는 UI가 렌더링됩니다.
  • icon
    • 페이지 제목 위에 표시할 이모지나 아이콘을 설정합니다.
  • cover
    • 페이지 상단의 커버 이미지를 지정합니다.

데일리 노트 템플릿의 화면과 요청 코드를 나란히 봅니다.

페이지 속성​

제목과 태그, 상태 속성이 붙은 데일리 노트 페이지 상단

(위) 페이지 속성 예시, (아래) 자동 생성할 페이지에 사용할 속성 스키마

const pageMetadata = {
properties: {
title: {
title: [
{
text: {
content: getDate(), // 2023.04.23 (Sun)
},
},
],
},
Tags: {
multi_select: [
{
color: "yellow",
name: "new",
},
{
color: "blue",
name: "daily",
},
],
},
Status: {
status: {
name: "Not started",
},
},
},
icon: {
emoji: randomEmoji("food"),
type: "emoji",
},
};

페이지 본문​

Todo, Note, TIL 세 구역으로 짜인 데일리 노트 본문

(위) 당시 구현으로 생성한 페이지 본문, (아래) KST 오프셋 표기를 바로잡은 현재 예제 스키마

const pageContent = {
children: [
{
object: "block",
callout: {
rich_text: [
{
text: {
content: `이 페이지는 scheduled-lambda에 의해 \`${getDate(
true
)} (KST)\`에 생성되었습니다.`,
},
type: "text",
annotations: {
italic: true,
},
},
],
},
},
{
object: "block",
heading_1: {
rich_text: [
{
text: {
content: "✅ Todo",
},
},
],
color: "gray_background",
},
},
{
object: "block",
to_do: {
rich_text: [
{
text: {
content: "",
},
},
],
checked: false,
},
},
...blank(3),
{
object: "block",
heading_1: {
rich_text: [
{
text: {
content: "✏️ Note",
},
},
],
color: "gray_background",
},
},
...blank(4),
{
object: "block",
heading_1: {
rich_text: [
{
text: {
content: "✨ TIL",
},
},
],
color: "gray_background",
},
},
...blank(4),
],
};

blank()는 여백 블록을 만들려고 추가한 함수입니다. 여백의 길이를 받아 그 수만큼 빈 텍스트 블록을 반환합니다.

exports.blank = function blank(count) {
return Array(count).fill({
object: "block",
paragraph: {
rich_text: [{ text: { content: "" } }],
},
});
};


Lambda 핸들러 만들기​

AWS Lambda는 서버를 직접 운영하지 않고 필요한 시점에 코드를 실행할 수 있는 서버리스 컴퓨팅 서비스입니다.

https://docs.aws.amazon.com/ko_kr/lambda/latest/dg/welcome.html

이 작업은 짧은 API 호출이고 하루에 한 번만 실행하므로 초기 지연과 실행 시간 제한이 문제가 되지 않았습니다.

예전에 EventBridge와 SQS 이벤트로 작업을 실행할 때 Lambda를 쓴 적이 있어 같은 방식을 골랐습니다.

AWS Lambda는 여러 언어의 런타임을 지원합니다. 이 예제는 작성 당시의 Node.js 16을 사용합니다. nodejs16.x는 현재 지원이 끝나 새 함수를 만들 수 없으므로 새로 구성한다면 nodejs22.x 같은 현행 런타임을 선택합니다. 핸들러의 기본 구조는 다음과 같습니다.

/* CommonJS module handler */
exports.handler = async function (event, context) {
console.log("Event: \n" + JSON.stringify(event, null, 2));
return context.logStreamName;
};

/* ES module handler */
export const handler = async (event, context) => {
console.log("Event: \n" + JSON.stringify(event, null, 2));
return context.logStreamName;
};

// Reference - https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html

Lambda 핸들러는 이벤트 객체를 받아 작업을 수행하는 진입 함수입니다. 필요한 응답을 반환하거나 다른 서비스에 후속 작업을 요청할 수 있습니다.

핸들러 안에서 노션 페이지 생성 함수를 호출하면 예약 이벤트가 실행할 진입점이 완성됩니다. 이 함수를 정해진 시각에 호출하려면 뒤에서 EventBridge 규칙과 Lambda 호출 권한도 연결해야 합니다.

handlers
├─ package-lock.json
├─ package.json
└─ src
├─ addItem.js
├─ index.js
└─ utils.js

프로젝트는 package.json과 src 아래의 핸들러 코드로 구성됩니다. 노션 SDK와 날짜 계산 라이브러리는 패키지 의존성으로 설치합니다.

Lambda는 함수 설정의 handler 문자열을 기준으로 진입 함수를 찾습니다. 여기서는 src/index.handler이므로 src/index.js가 내보낸 handler 함수를 실행합니다. package.json의 main 필드는 이 탐색에 쓰이지 않습니다.

package.json의 기본 정보는 아래처럼 구성했습니다. 설치한 패키지의 dependencies 항목은 여기서 생략합니다.

{
"name": "notion-integration",
"version": "1.0.0",
"private": true,
"main": "src/index.js"
}

index.js에 핸들러 함수를 정의합니다. 노션 SDK를 사용해서 페이지를 만드는 역할은 별도의 js 모듈로 분리합니다. 예약 이벤트는 반환된 status를 HTTP 상태 코드로 해석하지 않습니다. 성공한 API 응답은 반환하고, 실패하면 예외를 다시 던져 Lambda가 실행 실패로 기록하도록 합니다. 재시도가 생길 수 있으므로 실제 운영에서는 같은 날짜의 페이지가 중복 생성되지 않도록 확인하는 처리도 필요합니다.

/* index.js */

const { createPage } = require("./addItem");

exports.handler = async function () {
const result = await createPage();
return {
status: 200,
body: JSON.stringify(result),
};
};

Notion 페이지를 실제로 추가하는 addItem.js 모듈의 구조는 다음과 같습니다. properties, icon, children에는 앞의 두 절에서 만든 객체와 블록 배열을 넣습니다.

addItem.js 구조를 보여 주는 의사 코드
/* addItem.js */

const { Client } = require("@notionhq/client");
const { blank, getDate } = require("./utils");
const randomEmoji = require("@0xadada/random-emoji");

const NOTION_API_KEY = process.env.NOTION_API_KEY;
const NOTION_DATABASE_ID = process.env.NOTION_DATABASE_ID;

const notion = new Client({ auth: NOTION_API_KEY });

const dailyProperties = { /* 앞 절의 페이지 속성 */ };
const dailyIcon = { /* 앞 절의 아이콘 */ };
const dailyBlocks = [ /* 앞 절의 본문 블록 */ ];

exports.createPage = async function createPage() {
try {
const response = await notion.pages.create({
parent: { database_id: NOTION_DATABASE_ID, type: "database_id" },
properties: dailyProperties,
icon: dailyIcon,
children: dailyBlocks,
});
console.log("Success! Entry added.");
return response;
} catch (error) {
console.error(error.body);
throw error;
}
};

유틸 함수는 utils.js에 둡니다.

/* utils.js */

const dayjs = require("dayjs");
const timezone = require("dayjs/plugin/timezone");
const utc = require("dayjs/plugin/utc");

dayjs.extend(utc);
dayjs.extend(timezone);

exports.getDate = function getDate(isISOString) {
const now = dayjs().tz("Asia/Seoul");
if (isISOString) {
return now.format("YYYY-MM-DDTHH:mm:ssZ");
}
return now.format("YYYY.MM.DD (ddd)");
};

exports.blank = function blank(count) {
return Array(count).fill({
object: "block",
paragraph: {
rich_text: [{ text: { content: "" } }],
},
});
};

핸들러를 작성했으니 Terraform으로 실행 환경과 예약 이벤트를 구성합니다.



Terraform으로 프로비저닝하기​

배포 파일 만들기​

함수 코드와 의존성을 ZIP 파일로 묶어 Lambda에 올릴 수 있습니다.

수정할 때마다 콘솔에서 소스 코드를 직접 올리지 않도록 Terraform에서 패키징 과정도 함께 구성했습니다.

archive_file 데이터 소스로 배포 파일을 만듭니다.

# terraform/module/archive.tf

data "archive_file" "main" {
type = "zip"
source_dir = "${path.module}/handlers"
output_path = "${path.module}/.terraform/archive_files/handlers.zip"

depends_on = [
null_resource.main
]
}

resource "null_resource" "main" {
triggers = {
updated_at = timestamp()
}

provisioner "local-exec" {
command = <<EOF
npm i
EOF

working_dir = "${path.module}/handlers"
}
}

${path.module}로 현재 Terraform 모듈의 경로를 참조합니다. local-exec 프로비저너는 ZIP 파일을 만들기 전에 핸들러 디렉터리에서 의존성을 설치합니다.

핸들러가 실행되려면 node_modules도 ZIP 파일에 들어가야 합니다. 이 설치 과정을 자동화하려고 빈 리소스(null_resource)에서 스크립트를 실행했습니다.


Lambda 함수​

람다 함수를 프로비저닝하기 위한 코드입니다.

Data 블록에서 ZIP 파일을 참조하고 핸들러 위치를 지정합니다. CommonJS 모듈인 index.js가 exports.handler에 함수를 할당했으므로 값은 src/index.handler입니다.

런타임은 nodejs16.x로 지정하고, 노션 API 키와 데이터베이스 ID는 environment 블록에서 환경 변수로 주입합니다.

그 외 권한과 역할은 여기서 자세히 설명하지 않습니다. 다만 예시의 inline_policy 블록은 현행 AWS 프로바이더에서 deprecated 되었으므로, 지금은 aws_iam_role_policy 리소스로 분리하는 방식이 권장됩니다.

# terraform/module/lambda.tf

resource "aws_lambda_function" "processing_lambda" {
filename = data.archive_file.main.output_path
function_name = "notion-page-creation-scheduler"
handler = "src/index.handler"
source_code_hash = data.archive_file.main.output_base64sha256
role = aws_iam_role.processing_lambda_role.arn

runtime = "nodejs16.x"
timeout = 60
memory_size = 1024

environment {
variables = {
"NOTION_API_KEY" = var.notion_api_key
"NOTION_DATABASE_ID" = var.notion_database_id
}
}
}

resource "aws_iam_role" "processing_lambda_role" {
name = "my-role"
path = "/service-role/"
assume_role_policy = data.aws_iam_policy_document.assume-role-policy_document.json

inline_policy {
name = "test_policy"
policy = data.aws_iam_policy_document.test_policy_document.json
}
}

data "aws_iam_policy_document" "assume-role-policy_document" {
statement {
actions = ["sts:AssumeRole"]

principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}

data "aws_iam_policy_document" "test_policy_document" {
statement {
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:AssociateKmsKey"
]
resources = ["*"]

}
}

CloudWatch Events 예약 규칙​

CloudWatch Events의 cron 규칙으로 정해진 주기에 이벤트를 발생시킵니다. CloudWatch Events는 2019년에 Amazon EventBridge로 통합·개명됐고 같은 API를 사용합니다. 현재 예약 실행에는 별도 서비스인 EventBridge Scheduler가 권장됩니다. 차이는 다음 글에서 확인할 수 있습니다.

var.schedule의 cron 표현식을 규칙에 넘기고 event target을 앞에서 만든 Lambda 함수로 지정합니다.

# terraform/module/cloudwatchEvent.tf

resource "aws_cloudwatch_event_rule" "schedule" {
name = "schedule"
description = "Schedule for Lambda Function"
schedule_expression = var.schedule
}

resource "aws_cloudwatch_event_target" "schedule_lambda" {
rule = aws_cloudwatch_event_rule.schedule.name
target_id = "processing_lambda"
arn = aws_lambda_function.processing_lambda.arn
}

resource "aws_lambda_permission" "allow_events_bridge_to_run_lambda" {
statement_id = "AllowExecutionFromCloudWatch"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.processing_lambda.function_name
principal = "events.amazonaws.com"
}

variable "schedule" {
type = string
}

루트 모듈​

프로바이더를 설정하고 앞에서 만든 모듈에 필요한 값을 전달합니다.

# terraform/main.tf

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
}
}
}

provider "aws" {
region = var.aws_region
}

module "schedule_test_module" {
source = "./module"
schedule = var.schedule
notion_api_key = var.notion_api_key
notion_database_id = var.notion_database_id
}

저는 .tfvars 파일로 apply할 때 이 값들을 전달했습니다. 자동 배포를 붙이면 러너가 같은 역할을 맡을 수 있습니다.

# terraform/variables.tfvars

notion_api_key = "secret_*********************"
notion_database_id = "************************"
schedule = "cron(0 23 ? * * *)"

cron 표현식은 UTC 기준 매일 23시, 즉 KST 오전 8시에 이벤트가 발생하도록 설정했습니다.

이제 매일 오전 8시에 Lambda가 실행돼 노션에 새 템플릿을 만듭니다.

cron 표현식에 대해 더 알고 싶으시다면 다음 링크를 참조하세요.


심볼릭 링크​

package.json과 핸들러 파일은 Terraform 디렉터리 아래에 있지만 루트에서도 바로 편집하고 싶었습니다. 그래서 루트에 심볼릭 링크를 만들었습니다.

# terraform/module/handlers 디렉터리에서 실행
$ ln -s $(pwd)/src $(pwd)/../../../src
$ ln -s $(pwd)/package.json $(pwd)/../../../package.json

심볼릭 링크에는 상대 경로도 사용할 수 있습니다. 대상의 상대 경로는 명령을 실행한 위치가 아니라 링크 파일이 놓인 디렉터리를 기준으로 해석됩니다.



실행 결과​

apply 명령으로 프로비저닝된 리소스를 확인하면 아래와 같습니다.

data.archive_file.main
data.aws_iam_policy_document.assume-role-policy_document
data.aws_iam_policy_document.test_policy_document
aws_cloudwatch_event_rule.schedule
aws_cloudwatch_event_target.schedule_lambda
aws_iam_role.processing_lambda_role
aws_lambda_function.processing_lambda
aws_lambda_permission.allow_events_bridge_to_run_lambda
null_resource.main

AWS 콘솔에서 생성된 리소스를 확인하고 직접 실행해 봅니다.

AWS 콘솔의 notion-page-creation-scheduler 람다 함수 코드 편집 화면

Lambda 함수와 node_modules 의존성이 모두 올라왔습니다.

EventBridge 규칙 상세. cron 식과 앞으로 열 번의 트리거 시각이 매일 오전 8시로 잡혀 있다

콘솔의 표시 시간대를 현지 시간으로 바꾸면 매일 오전 8시에 실행되는 일정으로 보입니다. 예약 규칙의 cron 표현식 자체는 UTC 기준입니다.

Lambda를 직접 실행해 봅니다.

람다를 직접 실행해 노션에 데일리 노트가 만들어지는 과정

Lambda를 호출할 때마다 정해진 템플릿의 노션 페이지가 만들어집니다. 예약 규칙은 다음 날 오전 8시에 다시 확인했습니다.


다음 날 오전 8시 38분 확인 결과​

다음 날 아침 자동으로 만들어진 노션 페이지를 휴대폰에서 확인한 화면

CloudWatch 로그 스트림 목록. 08:00:37 에 실행된 기록이 남아 있다

예약 시각에도 페이지가 정상적으로 생성됐습니다.



좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자