Notion이 공식 API를 오픈하면서 개발자들이 Notion 워크스페이스에 프로그래매틱하게 접근할 수 있게 됐다. 다만 대부분의 개발자들은 Bearer 토큰 설정이나 페이로드 구조를 정확히 이해하지 못한 채 에러만 만난다. 이번에는 Notion API 인증부터 데이터베이스 조회, 페이지 생성까지 PHP에서 실제로 동작하는 완전한 구현을 정리해서 소개하겠다.

 

Notion API 준비 단계
Notion API를 사용하려면 먼저 Notion 개발자 포털에서 통합 앱(Integration)을 만들어야 한다. 이 과정은 한 번만 하면 되지만 몇 가지 중요한 설정이 있다.

1) Notion Integrations 페이지에 접속해 "Create new integration"을 클릭한다.
2) 앱 이름(예: "PHP Data Sync")을 입력하고 "Associated workspace"를 선택한다.
3) 생성 후 표시되는 "Internal Integration Token"을 복사해 안전한 곳에 저장한다. 이 토큰이 API 인증에 필요한 Bearer 토큰이다.
4) Notion에서 API와 연동할 데이터베이스를 열고, 오른쪽 상단의 "..." 메뉴에서 "Add connections"를 클릭한 후 방금 만든 통합 앱을 선택해 권한을 부여한다.
5) 데이터베이스 URL에서 ID를 추출한다(예: notion.so/1a2b3c4d5e6f7g8h9i?v=... 에서 1a2b3c4d5e6f7g8h9i가 데이터베이스 ID).

 

Notion API 기본 개념
Notion API는 세 가지 주요 엔티티로 구성된다. 페이지(Page)는 Notion의 가장 작은 단위이고, 데이터베이스(Database)는 페이지들의 모음이며, 블록(Block)은 페이지 내부의 콘텐츠 구성 요소다. API 버전은 현재 2024-06-04를 사용해야 안정적이다.

쿼리 방식도 중요하다. 데이터베이스에서 페이지를 조회할 때는 POST 요청으로 필터, 정렬, 페이지네이션 설정이 모두 바디에 JSON 형태로 들어간다. 간단한 조회도 GET이 아닌 POST를 써야 한다는 점에서 일반적인 REST API와 다르다.

 

Notion API와 일반 REST API의 차이
항목 Notion API 일반 REST API
인증 방식 Bearer 토큰 (Authorization 헤더) API Key 또는 OAuth
데이터베이스 조회 POST (필터·정렬 포함) GET + 쿼리 파라미터
버전 지정 필수 (Notion-Version 헤더) URL 경로에 포함 또는 생략
페이지네이션 cursor 기반 offset/limit 또는 cursor
응답 형식 결과 배열 + has_more 플래그 다양함

 

PHP에서 Notion API 연동 실전 구현
1단계. 기본 설정과 헬퍼 클래스

가장 먼저 Notion API와 통신할 헬퍼 클래스를 만든다.

<?php
class NotionAPI {
    private $apiKey;
    private $apiVersion = '2024-06-04';
    private $baseUrl = 'https://api.notion.com/v1';
    
    public function __construct($apiKey) {
        $this->apiKey = $apiKey;
    }
    
    private function request($method, $endpoint, $data = null) {
        $url = $this->baseUrl . $endpoint;
        
        $headers = [
            'Authorization: Bearer ' . $this->apiKey,
            'Notion-Version: ' . $this->apiVersion,
            'Content-Type: application/json'
        ];
        
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
            CURLOPT_CUSTOMREQUEST => $method
        ]);
        
        if ($data !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
        }
        
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        
        if ($httpCode >= 400) {
            throw new Exception('Notion API Error: ' . $response);
        }
        
        return json_decode($response, true);
    }
    
    public function queryDatabase($databaseId, $filter = null, $sorts = null, $pageSize = 100) {
        $payload = [
            'page_size' => $pageSize
        ];
        
        if ($filter) {
            $payload['filter'] = $filter;
        }
        
        if ($sorts) {
            $payload['sorts'] = $sorts;
        }
        
        return $this->request('POST', '/databases/' . $databaseId . '/query', $payload);
    }
    
    public function getPage($pageId) {
        return $this->request('GET', '/pages/' . $pageId);
    }
    
    public function createPage($databaseId, $properties, $content = null) {
        $payload = [
            'parent' => [
                'database_id' => $databaseId
            ],
            'properties' => $properties
        ];
        
        if ($content) {
            $payload['children'] = $content;
        }
        
        return $this->request('POST', '/pages', $payload);
    }
}
?>

✓ 올바른 방식. 모든 요청에 필수 헤더(Authorization, Notion-Version, Content-Type)를 포함했고, cURL 에러를 HTTP 상태 코드로 확인한다.

 

2단계. 데이터베이스 조회하기

생성된 클래스를 사용해 Notion 데이터베이스에서 페이지를 조회한다.

<?php
$apiKey = 'secret_1a2b3c4d5e6f7g8h9i'; // 환경 변수로 관리 권장
$databaseId = '1a2b3c4d5e6f7g8h9i';

$notion = new NotionAPI($apiKey);

// ✗ 잘못된 코드
$response = $notion->queryDatabase($databaseId);
foreach ($response as $item) {
    echo $item['title']; // 에러: results 배열에 접근해야 함
}

// ✓ 올바른 코드
$response = $notion->queryDatabase($databaseId);
if (isset($response['results']) && is_array($response['results'])) {
    foreach ($response['results'] as $page) {
        $title = $page['properties']['Name']['title'][0]['plain_text'] ?? 'No title';
        echo $title . "\n";
    }
    
    // 다음 페이지 확인
    if ($response['has_more']) {
        $nextCursor = $response['next_cursor'];
        // cursor를 이용한 다음 페이지 조회 구현
    }
}
?>

✓ 올바른 방식. 응답의 results 배열에 실제 페이지 데이터가 들어 있다. 속성값도 중첩된 객체 구조를 따라가야 접근할 수 있다.

 

3단계. 필터와 정렬 적용하기

데이터베이스에서 특정 조건의 페이지만 가져오려면 필터를 사용한다.

<?php
// 예시: "Status" 속성이 "Done"인 페이지만 조회
$filter = [
    'property' => 'Status',
    'select' => [
        'equals' => 'Done'
    ]
];

// 정렬: Created time 역순
$sorts = [
    [
        'property' => 'Created',
        'direction' => 'descending'
    ]
];

$response = $notion->queryDatabase($databaseId, $filter, $sorts, 50);

foreach ($response['results'] as $page) {
    $title = $page['properties']['Name']['title'][0]['plain_text'] ?? 'No title';
    $status = $page['properties']['Status']['select']['name'] ?? 'No status';
    echo $title . " | " . $status . "\n";
}
?>

✓ 올바른 방식. 필터는 단일 조건 또는 and/or 연산자로 여러 조건을 조합할 수 있다. 속성 타입(select, text, checkbox 등)에 따라 필터 구조가 달라진다.

 

4단계. 새 페이지 생성하기

Notion 데이터베이스에 새로운 페이지를 추가한다.

<?php
// ✗ 잘못된 코드
$properties = [
    'Name' => 'New Task', // 문자열로 직접 입력
    'Status' => 'In Progress'
];

$notion->createPage($databaseId, $properties); // 에러 발생

// ✓ 올바른 코드
$properties = [
    'Name' => [
        'title' => [
            [
                'text' => [
                    'content' => 'New Task'
                ]
            ]
        ]
    ],
    'Status' => [
        'select' => [
            'name' => 'In Progress'
        ]
    ],
    'Priority' => [
        'select' => [
            'name' => 'High'
        ]
    ],
    'Tags' => [
        'multi_select' => [
            ['name' => 'urgent'],
            ['name' => 'backend']
        ]
    ]
];

try {
    $result = $notion->createPage($databaseId, $properties);
    echo 'Page created: ' . $result['id'];
} catch (Exception $e) {
    echo 'Error: ' . $e->getMessage();
}
?>

✓ 올바른 방식. 각 속성은 타입별 구조를 따라야 한다. Title 타입은 title 배열, Select는 select 객체, Multi-select는 배열 형태로 지정한다.

 

주의사항과 흔한 실수

✗ 토큰 없이 요청하거나 헤더 누락

Authorization 헤더가 없으면 401 Unauthorized 에러가 난다. Notion-Version 헤더도 필수다.

✓ 모든 헤더를 명시적으로 설정

cURL 옵션에서 CURLOPT_HTTPHEADER 배열에 세 가지 헤더를 모두 포함한다.

✗ 데이터베이스 ID에 하이픈이나 특수문자 포함

URL에서 복사한 ID에 하이픈이 있으면 제거해야 한다(1a2b-3c4d-5e6f → 1a2b3c4d5e6f).

✓ 데이터베이스 ID는 정확히 추출

Notion 데이터베이스 URL의 맨 처음 긴 알파벳 조합만 ID로 사용한다.

✗ 속성값을 문자열로 직접 전달

Notion API는 모든 속성을 정해진 객체 구조로 전달해야 한다. 타입마다 형식이 다르다.

✓ 속성 타입에 맞는 구조 사용

text는 plain_text로, select는 name으로, checkbox는 boolean으로, date는 ISO 8601 형식으로 지정한다.

✗ 페이지네이션 무시

데이터가 100개 이상이면 has_more 플래그가 true가 되고 next_cursor가 반환된다. 이를 무시하면 나머지 데이터를 조회할 수 없다.

✓ cursor 기반 페이지네이션 구현

has_more가 true면 start_cursor 파라미터에 next_cursor 값을 넣어 다음 페이지를 요청한다.

 

실전 예제: 할일 관리 앱과 연동
<?php
require 'NotionAPI.php';

class TaskManager {
    private $notion;
    private $databaseId;
    
    public function __construct($apiKey, $databaseId) {
        $this->notion = new NotionAPI($apiKey);
        $this->databaseId = $databaseId;
    }
    
    // 완료되지 않은 작업 조회
    public function getPendingTasks() {
        $filter = [
            'property' => 'Status',
            'select' => [
                'does_not_equal' => 'Done'
            ]
        ];
        
        $response = $this->notion->queryDatabase(
            $this->databaseId,
            $filter,
            [['property' => 'Priority', 'direction' => 'descending']],
            100
        );
        
        $tasks = [];
        foreach ($response['results'] as $page) {
            $tasks[] = [
                'id' => $page['id'],
                'title' => $page['properties']['Name']['title'][0]['plain_text'] ?? '',
                'status' => $page['properties']['Status']['select']['name'] ?? '',
                'priority' => $page['properties']['Priority']['select']['name'] ?? '',
                'dueDate' => $page['properties']['Due Date']['date']['start'] ?? null
            ];
        }
        return $tasks;
    }
    
    // 새 작업 추가
    public function addTask($title, $priority = 'Medium', $dueDate = null) {
        $properties = [
            'Name' => [
                'title' => [['text' => ['content' => $title]]]
            ],
            'Status' => [
                'select' => ['name' => 'To Do']
            ],
            'Priority' => [
                'select' => ['name' => $priority]
            ]
        ];
        
        if ($dueDate) {
            $properties['Due Date'] = [
                'date' => ['start' => $dueDate]
            ];
        }
        
        return $this->notion->createPage($this->databaseId, $properties);
    }
}

// 사용 예시
$manager = new TaskManager('secret_xxx', 'database_id_xxx');

// 대기 중인 작업 조회
$tasks = $manager->getPendingTasks();
foreach ($tasks as $task) {
    echo $task['title'] . " (" . $task['priority'] . ")\n";
}

// 새 작업 추가
$manager->addTask('API 문서 작성', 'High', '2024-12-31');
?>

✓ 올바른 예제. getPendingTasks()는 does_not_equal 필터로 완료 상태를 제외하고, addTask()는 속성을 정확한 구조로 전달한다.

 

마무리

Notion API는 강력하지만 구조가 정해진 만큼 정확한 형식을 따라야 한다. Bearer 토큰 설정부터 속성 객체 구조까지 모두 실무에서 자주 하는 실수들을 정리했다. 이 글의 NotionAPI 클래스를 참고해 헬퍼를 만들고, 속성 타입별 구조를 문서와 대조하면서 구현하면, Notion 데이터베이스를 PHP에서 완전히 제어할 수 있을 것이다.