Lambda 스케줄링으로 매일 아침 자동으로 노션 페이지 만들기

Wonkook Lee·2023년 4월 23일
53
post-thumbnail

Lambda 스케줄링으로 매일 아침 자동으로 노션 페이지 만들기

Preface

저는 매일 아침 오늘의 할일과 메모, 배운것을 기록하기 위한 노션 페이지를 만듭니다. 그날 작성한 기록들은 하루를 마감할때 관련된 카테고리나 페이지로 재분류하는 방식으로 저만의 기록을 쌓고 있습니다.

단순한 일도 매일 하게 되면 까먹기도 하고, 귀찮아지기도 합니다. 저는 그리 부지런하지 않기 때문에 기록을 위한 데일리 노트 템플릿을 매일 아침 지정된 시간마다 자동으로 생성하기 위해 몇 가지 도구를 이용했습니다.

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

노션에서 제공하는 공식 API와 SDK를 사용하면 손쉽게 컨텐츠 관리를 자동화 할 수 있습니다.

  1. 우선 Notion SDK를 사용해서 API 엔드포인트에 컨텐츠 생성을 요청하는 핸들러 함수를 Lambda에 만들어줍니다.
  2. 그리고 CloudWatch에서 Lambda 함수를 호출하는 이벤트를 cron 표현식을 사용하여 스케줄링합니다.
  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와 SDK 서비스를 제공하고 있습니다.

노션 API의 기본적인 연동과 인증 키 생성 방법은 아래 링크를 참조해주세요.

위 링크에 명시된 과정을 통해 노션 API 인증키를 받습니다. 인증키는 secret_으로 시작하는 unique key이고, 아래와 같은 형식입니다. SDK 클라이언트를 초기화할때 꼭 필요합니다.

NOTION_KEY="secret_*************"

이벤트 핸들러 함수를 만들기 전 시험삼아 SDK를 사용해서 노션 워크스페이스에 페이지를 만들어봅시다.

$ npm i @notionhq/client

그리고 초기화를 통해 클라이언트 인스턴스를 생성하고, 페이지 생성 함수를 만듭니다.

저는 CommonJS 모듈로 Node.js 환경에서 함수를 만들지만, 브라우저 단에서 ESM을 사용하여 SDK를 소비하여도 무방합니다.

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 });

클라이언트 인스턴스를 통해 접근할 수 있는 엔드포인트와 메소드는 아래와 같습니다. 이 중 제게 필요한 인증키를 사용해서 토큰을 얻어오는 Authentication과 페이지를 만들어주는 Pages 메소드입니다.

.
├─ 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(즉시 실행 함수)로 구현되어 있으나, 저는 JS 파싱 후 즉시 실행 대신, 핸들러에서 이벤트가 발생한 경우 함수가 호출되길 바라기 때문에 선언과 호출을 분리합니다.

async function createPage() {
	try {
		const response = await notion.page.create({
			parent: { database_id: NOTION_DATABASE_ID, type: "database_id" },
			properties: { ... },
			icon: { ... },
			children: [ { ... } ],
		});
		if (response) {
			console.log("Success! Entry added.");
			return;
		}
		throw new Error({ ... });
	} catch (error) {
		console.error(error.body);
	}
}

exports.createPage = createPage

노션에 페이지를 생성하려면 노션 페이지를 생성하기 위한 스키마를 이해해야 합니다. 또한 페이지가 추가될 곳의 데이터베이스 아이디를 알아야합니다.

데이터베이스는 페이지가 원격으로 추가될 데이터베이스 페이지 링크를 통해 알 수 있습니다.

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


Page Schema

caption: Visualizing page properties versus page content - developers.notion.com

노션 페이지는 페이지의 메타 정보를 담고 있는 Page Properties와 Page Content로 나뉩니다.

컨텐츠는 다시 하위(children) 블록으로 나뉘어 다양한 형태의 UI로 표현될 정보를 담습니다.

제가 사용할 NotionClientInstance.pages.create 메소드의 Body Params는 아래와 같습니다.

  • parent (required)
    • 페이지가 생성될 부모 컴포넌트의 page_id 또는 database_id를 받습니다
  • properties (required)
    • 부모 컴포넌트가 페이지일 경우 title만 설정할 수 있으며 데이터베이스인 경우엔 데이터베이스의 프로퍼티에 스키마에 맞추어야 합니다. (예: filter, status, label 등)
  • children
    • 페이지의 본문으로 렌더링될 블록들의 컬렉션입니다. 인덱스 순서로 페이지에 각 블록에 맞는 UI로 표현됩니다.
  • icon
    • 노션 페이지의 제목 상단에 사용되는 이모지(emoji)를 설정할 수 있습니다.
  • cover
    • 노션 페이지 상단의 커버 이미지를 지정해줄 수 있습니다.

제가 사용할 데일리 노트 템플릿 예시를 렌더링된 화면과 코드를 통해 비교해보세요.

Page Properties

(위) 페이지 속성이 렌더링된 화면 캡쳐, (아래) 화면의 스키마

{
	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",
	},
}

Page Contents

(위) 페이지 컨텐츠가 렌더링된 화면 캡쳐, (아래) 화면의 스키마

{
	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 Handler로 만들기

람다란 단순하게 말하자면 개발자가 직접 서버를 띄우지 않아도 필요한 시점에만 원격으로 호출할 수 있는 코드 실행 환경을 제공하는 서버리스 컴퓨팅 플랫폼입니다.

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

초기 지연, 런타임 리밋이 상관 없는 단순한 API 호출 코드 스니펫이고, 하루 한 번 정해진 시간에 코드를 실행시키기엔 람다가 제일 적합했습니다.

예전에 AWS EventBridge, SQS와 맞물려 특정 이벤트가 발생할 때마다 특정 잡을 수행할 목적으로 람다를 활용한 적이 있었는데, 오랜만에 다시 사용하게 되네요.

AWS Lambda 서비스는 다양한 언어를 지원하는데, 저는 JS가 익숙한 프론트엔드 개발자이기에 Node.js 16버전을 사용하겠습니다. 핸들러의 기본 구조는 아래와 같습니다.

/* 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 함수의 핸들러는 이벤트를 처리하는 함수 코드의 메소드입니다. 이벤트가 발생하면, 이벤트 객체를 인자로 받아 특정 작업을 수행한 뒤 원하는 응답을 반환, 또는 다른 이벤트를 처리하기 위해 사용되는 함수입니다.

핸들러 함수를 Wrapper 삼아 노션 API에 새로운 페이지를 생성하는 함수를 내부에서 호출하도록 만들면, 우리는 정해진 시간마다 람다 함수를 호출하기만 하면 됩니다.

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

핸들러 함수가 작동하기 위한 프로젝트의 구조는 위와 같습니다.

노션 SDK, 날짜 계산을 위한 간단한 라이브러리가 의존성으로 사용되기 때문에 package.json과 src 하위 구조로 구성된 기본적인 폴더 트리입니다.

람다는 index.js의 handler로 명명된 함수를 메인으로 작동합니다.

우선 package.json에 진입점을 명시합니다.

## package.json

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

index.js에 핸들러 함수를 정의합니다. 노션 SDK를 사용해서 페이지를 만드는 역할은 별도의 js 모듈로 분리합니다. 함수의 역할은 단순히 API에 요청을 보내고, 응답값은 어떻게 되든 중요하지 않아 반환되는 statusbody는 고정값으로 넣었습니다. 에러나 예외를 좀 더 신경쓰고 싶다면 별도의 핸들러나 fallback을 만드셔도 무방합니다.

/* index.js */

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

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

핵심 역할을 수행하는 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 });

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

유틸 함수가 선언되어 있는 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.utc(true).toISOString();
  }
  return now.format("YYYY.MM.DD (ddd)");
};

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

이제 핸들러 함수를 정의하는것은 모두 끝났습니다.

이제부터는 terraform을 사용해서 프로비저닝 코드를 만들고, 모듈을 정의하는 과정을 살펴보겠습니다.



Provisioning with Terraform

Archive File

람다에 함수를 올리는 방법은 간단합니다. 함수의 의존성을 포함하여 zip파일을 콘솔에 올려놓으면 끝입니다.

하지만 매번 수정이 필요할 떄마다 콘솔에 접속해서 수동으로 소스 코드를 업로드하는 것은 귀찮음에서 벗어나기 위한 본래의 취지와 맞지 않습니다.

데이터 소스를 파일로 아카이빙하기 위해 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.moduleinterpolation하면 모듈의 경로를 쉽게 참조할 수 있습니다.

그 아래 로컬에서 프로비저너(provisioner)가 선언된 부분을 보시면 zip 파일로 아카이브 파일이 만들어지는 시점에 local-exec으로 쉘 스크립트를 실행하는 것을 볼 수 있습니다.

그 이유는 의존성 설치를 통해 node_modules도 함께 업로드 되어야 핸들러 함수가 제대로 동작하기 때문에 이 부분을 자동화 하기 위해 빈 리소스(null_resource)에 스크립트 실행을 위임했습니다.


Lambda

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

아카이빙되어 zip 파일로 변환된 파일을 data 블록으로써 참조하도록 설정하였고, 핸들러 함수의 위치도 지정합니다. index.js에서 handler라는 프로퍼티로 지정되어(named export) 있기 때문에 src/index.handler 라고 지정되어 있는 것을 눈여겨 보시기 바랍니다.

그 외 런타임을 nodejs16 버전으로 지정한 부분과, 노션 API KEY와 데이터베이스 아이디를 환경 변수로 주입하기 위해 environment 블록을 활용한 것을 참조하시면 좋겠습니다.

그 외 권한과 역할에 대한 부분은 별도로 설명하진 않겠습니다.

# 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 Event

CloudWatch Event를 사용하여 cron job으로 특정 주기에 이벤트를 발생시킵니다. CloudWatch Event와 동일한 역할을 하는 것으로 EventBridge Schedule도 있는데, 간단한 차이점은 아래 링크를 참조해주세요.

var.schedule로 정의된 cron 표현식을 전달하여 특정 시점마다 이벤트 대상을 호출하도록 event target 을 제가 만든 람다 함수로 지정해줍니다.

# 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
}

Main

프로바이더를 설정하고, 만들어둔 모듈에 필요한 값들을 전달하여 인프라들을 구성하도록 설정합니다.

# 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시마다 람다가 트리깅되어 저의 노션 페이지에 새로운 템플릿이 추가될 것입니다.

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


package.json을 비롯한 핸들러 관련 함수 파일들이 terraform 디렉토리 하위에 있는데 저는 루트 레벨에서 해당 파일들을 보고 싶습니다. 저는 symlink를 생성해서 루트 레벨의 파일을 수정해도 terraform 디렉토리 하위의 파일들과 동기화 되도록 했습니다. 쉽게 말하면 바로가기를 생성한 것입니다.

$ ln -s $(pwd)/src $(pwd)/../../../src
$ ln -s $(pwd)/package.json $(pwd)/../../package.json

간혹 심볼릭 링크 생성할때 절대 경로를 명시하지 않아서 명령이 실패하는 실수를 종종 하게 되는데, 혹시 경로를 찾을 수 없다고 한다면 경로를 다시 한 번 확인해주세요.



Result

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 콘솔에서 직접 확인해보고 테스트 해봅니다.

람다 함수는 잘 올라왔네요. 의존성도 node_modules에 잘 있구요.

규칙도 잘 잡혀있습니다. UTC가 아닌 현지 시간대로 설정하면 매일 아침 8시마다 이벤트가 트리거되는 것을 확인할 수 있네요.

이제 람다를 직접 실행 시켜보겠습니다.

람다를 호출할 때마다 노션 페이지가 정해진 템플릿으로 잘 만들어지는 것을 확인할 수 있네요.
이제 cron이 잘 작동하는지 확인해야 하지만 귀찮기 때문에 내일 아침 8시를 기다려봅니다.


🎉 익일 오전 08:38 확인 결과

네, 잘 생성되네요!




🙏🏻

Wonkook Lee
Frontend Engineer
LinkedIn

profile
© 가치 지향 프론트엔드 개발자

1개의 댓글

comment-user-thumbnail
2023년 4월 28일

오오.... 감사합니다... 그저 감사

답글 달기