Notion API 400 오류 해결: invalid_json·validation_error 원인 찾기

Notion API로 페이지를 만들거나 데이터 소스를 조회할 때 다음과 같은 응답이 반환될 수 있습니다.

HTTP 400
Bad Request

HTTP 400은 요청을 다시 보내면 자연스럽게 해결되는 일시적인 서버 오류가 아닙니다. 요청 URL, 헤더, JSON 또는 속성 값이 Notion API가 요구하는 구조와 맞지 않는다는 의미로 먼저 해석해야 합니다.

같은 400 응답이라도 잘못된 JSON과 데이터 소스 속성 불일치는 수정할 위치가 다릅니다. 상태 코드만 기록하지 말고 응답 본문의 codemessage를 확인해야 합니다.

이 글은 Notion의 API 상태 코드, 버전 관리, 데이터 소스, 페이지 생성, 요청 크기 제한과 Google Apps Script의 UrlFetchApp 공식 문서를 기준으로 작성했습니다. 예제의 토큰, 데이터 소스 ID와 속성 이름은 실제 연결 설정에 맞게 변경해야 합니다.

Notion API 요청에서 JSON 구조와 데이터 소스 속성 불일치로 발생하는 HTTP 400 오류를 설명하는 대표 개념 이미지
Notion API의 HTTP 400은 잘못된 JSON, 누락된 버전 헤더, 엔드포인트와 API 버전 불일치 또는 속성 구조 오류로 발생할 수 있습니다. 위 이미지는 오류 응답을 기준으로 원인을 구분하는 과정을 설명하기 위한 대표 개념 이미지입니다.


400 오류는 응답 본문의 code로 구분합니다

Notion API 오류 응답은 다음과 같은 구조를 사용할 수 있습니다.

{
  "object": "error",
  "status": 400,
  "code": "validation_error",
  "message": "요청 본문이 필요한 구조와 일치하지 않습니다."
}

message의 실제 문구는 요청 내용에 따라 달라집니다. 프로그램에서는 상태 코드뿐 아니라 code도 함께 분류하고, 사람이 원인을 확인할 때는 message를 읽습니다.

오류 코드 의미 확인할 위치
invalid_json 요청 본문을 JSON으로 해석할 수 없음 따옴표, 쉼표, 괄호와 JSON.stringify 사용 여부
invalid_request_url 요청 URL이 유효한 API 주소가 아님 엔드포인트 경로, ID와 URL 조합
invalid_request 현재 엔드포인트나 버전에서 지원되지 않는 요청 HTTP 메서드, API 버전과 엔드포인트 문서
validation_error 요청 본문이 예상 스키마와 일치하지 않음 필수 필드, 속성 이름·유형, 값 형식과 크기 제한
missing_version 필수 Notion-Version 헤더가 없음 요청 헤더의 Notion-Version
invalid_grant OAuth 승인 코드 또는 갱신 토큰이 유효하지 않음 OAuth 토큰 교환·갱신 요청과 redirect URI

일반적인 페이지 생성 요청에서 invalid_grant가 발생하는 것이 아니라 OAuth 승인 코드나 갱신 토큰을 처리하는 요청에서 관련될 수 있습니다.

Apps Script에서 오류 응답을 그대로 확인합니다

UrlFetchApp는 기본 설정에서 오류 상태 코드가 반환되면 예외를 발생시킬 수 있습니다. muteHttpExceptions: true를 사용하면 HTTP 400 응답도 HTTPResponse 객체로 받아 상태 코드와 응답 본문을 직접 확인할 수 있습니다.

다음 공통 함수는 토큰을 코드에 직접 작성하지 않고 스크립트 속성의 NOTION_TOKEN에서 읽습니다.

const NOTION_CONFIG = Object.freeze({
  apiBaseUrl: 'https://api.notion.com',
  apiVersion: '2026-03-11'
});

function notionRequest_(
  method,
  path,
  body
) {
  const scriptProperties =
    PropertiesService.getScriptProperties();

  const token =
    scriptProperties.getProperty(
      'NOTION_TOKEN'
    );

  if (!token) {
    throw new Error(
      '스크립트 속성에 NOTION_TOKEN이 없습니다.'
    );
  }

  if (
    typeof path !== 'string' ||
    !path.startsWith('/v1/')
  ) {
    throw new Error(
      'Notion API 경로는 /v1/로 시작해야 합니다.'
    );
  }

  const requestOptions = {
    method: String(method).toLowerCase(),
    headers: {
      Authorization:
        'Bearer ' + token,
      'Notion-Version':
        NOTION_CONFIG.apiVersion
    },
    muteHttpExceptions: true
  };

  if (
    body !== undefined &&
    body !== null
  ) {
    requestOptions.contentType =
      'application/json';

    requestOptions.payload =
      JSON.stringify(body);
  }

  const response =
    UrlFetchApp.fetch(
      NOTION_CONFIG.apiBaseUrl + path,
      requestOptions
    );

  const statusCode =
    response.getResponseCode();

  const responseText =
    response.getContentText();

  const responseData =
    parseJsonOrNull_(responseText);

  if (
    statusCode >= 200 &&
    statusCode < 300
  ) {
    return responseData;
  }

  const errorCode =
    responseData &&
    responseData.code
      ? responseData.code
      : 'unknown_error';

  const errorMessage =
    responseData &&
    responseData.message
      ? responseData.message
      : summarizeText_(responseText);

  const requestId =
    responseData &&
    responseData.request_id
      ? responseData.request_id
      : '';

  const requestIdText =
    requestId
      ? ' | request_id: ' + requestId
      : '';

  throw new Error(
    'Notion API HTTP ' +
    String(statusCode) +
    ' | ' +
    errorCode +
    ' | ' +
    errorMessage +
    requestIdText
  );
}

function parseJsonOrNull_(text) {
  if (!text) {
    return null;
  }

  try {
    return JSON.parse(text);

  } catch (error) {
    return null;
  }
}

function summarizeText_(text) {
  return String(text || '')
    .replace(/\s+/g, ' ')
    .slice(0, 500);
}

이 함수는 다음 순서로 응답을 처리합니다.

  1. 스크립트 속성에서 Notion 토큰을 읽습니다.
  2. 필수 Authorization과 Notion-Version 헤더를 추가합니다.
  3. 본문이 있을 때만 JSON.stringify로 변환합니다.
  4. 오류 응답도 받을 수 있도록 muteHttpExceptions를 설정합니다.
  5. 200번대 응답만 정상 결과로 반환합니다.
  6. 오류 상태에서는 code, message와 존재하는 경우 request_id를 포함해 중단합니다.
토큰을 로그나 오류 메시지에 넣지 마세요

Authorization 헤더와 Notion 토큰은 콘솔, 시트, 이메일 또는 공개된 소스 코드에 기록하지 않습니다. 오류 확인에는 상태 코드, 오류 코드, 메시지와 request_id만 사용합니다.

missing_version은 Notion-Version 헤더를 확인합니다

Notion REST API 요청에는 Notion-Version 헤더가 필요합니다.

이 글의 예제는 다음 버전을 사용합니다.

Notion-Version: 2026-03-11

헤더 이름의 철자가 틀리거나 요청 옵션의 잘못된 위치에 넣으면 missing_version이 반환될 수 있습니다.

Apps Script에서는 headers 객체 안에 작성합니다.

headers: {
  Authorization: 'Bearer ' + token,
  'Notion-Version': '2026-03-11'
}

버전 헤더를 최신값으로 변경했다면 엔드포인트와 요청 본문도 해당 버전 문서에 맞는지 함께 확인해야 합니다.

API 버전만 바꾸고 기존 요청을 그대로 사용하지 않습니다

Notion API는 버전에 따라 엔드포인트와 요청 필드가 달라질 수 있습니다. 현재 예제에서 사용하는 2026-03-11은 이전 버전과 비교해 다음 구조가 변경됐습니다.

구분 이전 구조 2026-03-11 구조
블록 삽입 위치 after position 객체
휴지통 상태 archived in_trash
AI 회의록 블록 유형 transcription meeting_notes

블록을 특정 블록 뒤에 추가할 때 최신 버전에서는 다음처럼 position 객체를 사용합니다.

{
  "position": {
    "type": "after_block",
    "after_block": {
      "id": "기준_블록_ID"
    }
  },
  "children": [
    {
      "object": "block",
      "type": "paragraph",
      "paragraph": {
        "rich_text": [
          {
            "type": "text",
            "text": {
              "content": "추가할 내용"
            }
          }
        ]
      }
    }
  ]
}

이전 버전의 예제 코드를 참고했다면 버전 헤더와 요청 본문을 하나씩 섞지 말고, 선택한 버전의 공식 문서를 기준으로 전체 요청을 맞춥니다.

최신 버전에서는 database_id와 data_source_id를 구분합니다

현재 Notion API에서 데이터베이스는 하나 이상의 데이터 소스를 담을 수 있는 컨테이너이고, 데이터 소스는 실제 속성 구조와 행을 가진 표입니다.

화면에서는 하나의 데이터베이스처럼 보여도 API에서는 데이터베이스 ID와 데이터 소스 ID가 서로 다른 역할을 합니다.

작업 최신 API 경로 또는 값
데이터베이스 컨테이너 조회 GET /v1/databases/{database_id}
데이터 소스 속성 구조 조회 GET /v1/data_sources/{data_source_id}
데이터 소스의 행 조회 POST /v1/data_sources/{data_source_id}/query
데이터 소스에 새 행 생성 POST /v1/pagesparent.data_source_id

이전 예제의 데이터베이스 쿼리 경로를 최신 버전 헤더와 함께 그대로 사용하는 대신 데이터 소스 ID와 최신 쿼리 엔드포인트를 확인해야 합니다.

invalid_json은 JSON 문법부터 확인합니다

invalid_json은 요청 본문의 의미를 검사하기 전에 JSON 자체를 해석하지 못한 경우입니다.

다음은 원시 JSON에서 잘못된 예입니다.

{
  'parent': {
    'data_source_id': '데이터_소스_ID',
  }
}

위 원시 JSON에는 다음 문제가 있습니다.

  • JSON의 키와 문자열에 작은따옴표를 사용함
  • 마지막 속성 뒤에 불필요한 쉼표가 있음

유효한 원시 JSON은 다음과 같습니다.

{
  "parent": {
    "data_source_id": "데이터_소스_ID"
  }
}

Apps Script의 JavaScript 객체에서는 작은따옴표를 사용할 수 있습니다. JavaScript 객체와 서버에 전송되는 원시 JSON은 같은 문법이 아닙니다.

요청 본문을 문자열 연결로 직접 만들지 말고 JavaScript 객체를 JSON.stringify()로 변환하는 편이 따옴표와 쉼표 오류를 줄일 수 있습니다.

const requestBody = {
  parent: {
    data_source_id:
      '데이터_소스_ID'
  }
};

const payload =
  JSON.stringify(requestBody);

validation_error는 속성 이름과 유형을 먼저 확인합니다

JSON 문법이 맞더라도 데이터 소스에 없는 속성 이름을 보내거나 실제 속성 유형과 다른 값 구조를 보내면 validation_error가 발생할 수 있습니다.

예를 들어 데이터 소스의 “금액” 속성이 숫자 유형인데 rich_text 구조를 보내면 속성 유형과 값 형식이 맞지 않습니다.

현재 데이터 소스의 속성 이름·ID·유형을 먼저 출력할 수 있습니다.

function printNotionDataSourceSchema() {
  const scriptProperties =
    PropertiesService.getScriptProperties();

  const dataSourceId =
    scriptProperties.getProperty(
      'NOTION_DATA_SOURCE_ID'
    );

  if (!dataSourceId) {
    throw new Error(
      '스크립트 속성에 NOTION_DATA_SOURCE_ID가 없습니다.'
    );
  }

  const dataSource =
    notionRequest_(
      'get',
      '/v1/data_sources/' +
      encodeURIComponent(dataSourceId)
    );

  const properties =
    dataSource.properties || {};

  Object.keys(properties)
    .sort()
    .forEach(function (propertyName) {
      const property =
        properties[propertyName];

      console.log(
        propertyName +
        ' | id: ' +
        String(property.id) +
        ' | type: ' +
        String(property.type)
      );
    });
}

출력 결과에서 코드에 사용하는 속성 이름과 실제 유형이 일치하는지 확인합니다.

페이지 속성은 이름 대신 속성 ID를 키로 사용할 수도 있습니다. 속성 ID는 속성 이름이 바뀌어도 유지되므로 이름 변경이 잦은 자동화에서는 ID 사용을 검토할 수 있습니다.

속성 유형마다 요청 값의 구조가 다릅니다

데이터 소스 속성 페이지 요청 값의 기본 형태
title {"title":[{"text":{"content":"제목"}}]}
rich_text {"rich_text":[{"text":{"content":"내용"}}]}
number {"number":15000}
checkbox {"checkbox":false}
date {"date":{"start":"2026-07-01"}}
select {"select":{"name":"진행 중"}}
relation {"relation":[{"id":"연결할_페이지_ID"}]}

위 구조는 해당 이름의 속성이 실제 데이터 소스에 있고 유형도 일치할 때 사용할 수 있는 기본 예입니다.

숫자 속성에 "15,000원" 같은 문자열을 보내거나, 체크박스에 "false"라는 문자열을 보내면 실제 숫자·불리언 값과 유형이 다릅니다.

데이터 소스에 페이지를 만드는 전체 예제

다음 예제는 데이터 소스에 “이름”, “금액”, “완료”, “마감일” 속성이 각각 title, number, checkbox, date 유형으로 존재한다는 전제에서 페이지를 만듭니다.

function createNotionTaskPage() {
  const scriptProperties =
    PropertiesService.getScriptProperties();

  const dataSourceId =
    scriptProperties.getProperty(
      'NOTION_DATA_SOURCE_ID'
    );

  if (!dataSourceId) {
    throw new Error(
      '스크립트 속성에 NOTION_DATA_SOURCE_ID가 없습니다.'
    );
  }

  const requestBody = {
    parent: {
      data_source_id:
        dataSourceId
    },
    properties: {
      '이름': {
        title: [
          {
            type: 'text',
            text: {
              content:
                'API 요청 점검'
            }
          }
        ]
      },
      '금액': {
        number: 15000
      },
      '완료': {
        checkbox: false
      },
      '마감일': {
        date: {
          start: '2026-07-01'
        }
      }
    }
  };

  const createdPage =
    notionRequest_(
      'post',
      '/v1/pages',
      requestBody
    );

  console.log(
    '생성된 페이지 ID: ' +
    String(createdPage.id)
  );

  return createdPage;
}

실제 데이터 소스의 제목 속성 이름이 “이름”이 아니라 “업무명”이라면 코드의 키도 실제 속성 이름 또는 속성 ID에 맞게 변경해야 합니다.

데이터 소스에 없는 속성을 임의로 요청 본문에 추가한다고 새 열이 자동으로 만들어지는 것은 아닙니다.

읽기 전용 속성을 생성 요청에 넣지 않습니다

Notion이 자동으로 계산하거나 기록하는 일부 속성은 새 페이지를 만들 때 값을 직접 지정할 수 없습니다.

페이지 생성 요청에서 다음 속성 값을 임의로 보내지 않습니다.

  • rollup
  • created_by
  • created_time
  • last_edited_by
  • last_edited_time

이러한 값은 Notion이 페이지를 만들거나 수정할 때 자동으로 생성합니다. 요청 본문에 포함하면 지원되지 않는 속성 값으로 처리될 수 있습니다.

빈 문자열을 모든 필드의 초기값으로 보내지 않습니다

Notion API는 일반적으로 빈 문자열을 지원하지 않습니다. 값을 비우는 방법은 속성 유형에 따라 다릅니다.

URL 속성을 비울 때는 다음처럼 null을 사용할 수 있습니다.

{
  "웹사이트": {
    "url": null
  }
}

rich_text 값을 비울 때는 빈 배열을 사용할 수 있습니다.

{
  "메모": {
    "rich_text": []
  }
}

모든 필드에 일괄적으로 빈 문자열이나 null을 넣지 말고 해당 속성 유형의 공식 요청 구조를 확인해야 합니다.

undefined로 필수 값이 사라지지 않았는지 확인합니다

JavaScript의 JSON.stringify()는 객체 속성의 값이 undefined이면 그 속성을 결과 JSON에서 제외합니다.

다음과 같이 제목 변수가 정의되지 않았다고 가정합니다.

const pageTitle = undefined;

const requestBody = {
  properties: {
    '이름': pageTitle
  }
};

console.log(
  JSON.stringify(requestBody)
);

전송된 JSON에는 필요한 속성이 빠질 수 있습니다. 필수값은 요청을 보내기 전에 명시적으로 확인합니다.

if (
  typeof pageTitle !== 'string' ||
  pageTitle.trim() === ''
) {
  throw new Error(
    '페이지 제목이 비어 있습니다.'
  );
}

invalid_request_url은 API 경로와 ID를 분리해 확인합니다

Notion 앱에서 복사한 페이지 공유 주소 전체를 API 경로의 ID 위치에 그대로 붙여 넣으면 유효하지 않은 요청 주소가 만들어질 수 있습니다.

API 주소는 기본 주소, 엔드포인트와 ID를 구분해 구성합니다.

const apiBaseUrl =
  'https://api.notion.com';

const path =
  '/v1/data_sources/' +
  encodeURIComponent(dataSourceId);

const requestUrl =
  apiBaseUrl + path;

다음 항목을 확인합니다.

  • API 기본 주소가 https://api.notion.com인지
  • 경로가 /v1/로 시작하는지
  • database와 data_sources 엔드포인트를 혼동하지 않았는지
  • ID 앞뒤에 공백이나 추가 경로 문자가 섞이지 않았는지
  • 조회 요청과 쿼리 요청의 HTTP 메서드가 맞는지

데이터 소스 쿼리는 최신 엔드포인트를 사용합니다

최신 API 버전에서 데이터 소스의 행을 조건에 따라 조회하는 예제입니다.

function queryIncompleteNotionPages() {
  const scriptProperties =
    PropertiesService.getScriptProperties();

  const dataSourceId =
    scriptProperties.getProperty(
      'NOTION_DATA_SOURCE_ID'
    );

  if (!dataSourceId) {
    throw new Error(
      '스크립트 속성에 NOTION_DATA_SOURCE_ID가 없습니다.'
    );
  }

  const requestBody = {
    filter: {
      property: '완료',
      checkbox: {
        equals: false
      }
    },
    page_size: 100
  };

  return notionRequest_(
    'post',
    '/v1/data_sources/' +
    encodeURIComponent(dataSourceId) +
    '/query',
    requestBody
  );
}

“완료” 속성이 실제로 checkbox 유형이 아니라면 필터의 유형도 실제 속성에 맞게 바꿔야 합니다.

데이터 소스의 속성 이름을 변경했다면 필터의 property 이름도 수정하거나 속성 ID 사용을 검토합니다.

404를 400으로 오해하지 않습니다

요청 구조가 올바르더라도 Notion 연결에 대상 페이지나 데이터베이스가 공유되지 않았다면 404가 반환될 수 있습니다.

주요 상태 코드를 다음처럼 구분합니다.

상태 코드 주요 의미 먼저 확인할 항목
400 요청 형식·버전·본문 검증 오류 code, message, URL, 헤더와 JSON
401 토큰이 유효하지 않음 Authorization Bearer 토큰
403 연결 기능이나 작업 권한 부족 Read·Insert·Update Content 기능
404 리소스를 찾지 못했거나 연결에 공유되지 않음 ID와 Notion의 연결 추가 설정
429 요청 속도 제한 Retry-After와 요청 빈도

404가 발생했다면 JSON 속성 구조만 계속 수정하지 말고 대상 페이지 또는 데이터베이스에 해당 Notion 연결이 추가돼 있는지 확인해야 합니다.

403은 토큰이 완전히 틀린 문제와 다릅니다. 연결 설정에 필요한 읽기·삽입· 수정 기능이 허용돼 있는지 확인합니다.

요청 크기 제한도 validation_error를 발생시킬 수 있습니다

요청 구조가 올바르더라도 한 번에 너무 많은 블록이나 지나치게 긴 값을 보내면 HTTP 400 validation_error가 발생할 수 있습니다.

Notion 공식 문서의 주요 요청 제한은 다음과 같습니다.

항목 제한
전체 요청 크기 최대 500KB
요청 전체의 블록 요소 최대 1,000개
rich text의 text.content 최대 2,000자
하나의 블록 또는 rich text 배열 최대 100개 요소
URL 최대 2,000자
relation 최대 100개 관련 페이지

많은 블록을 추가해야 한다면 요청을 적절한 단위로 나눕니다. 긴 텍스트는 rich text 항목이나 여러 블록으로 분할하되 문장과 단어를 임의의 위치에서 자르지 않도록 별도 분할 규칙이 필요합니다.

400 오류에 무조건 재시도를 적용하지 않습니다

400 응답은 같은 요청을 잠시 뒤 다시 전송한다고 해결되는 경우가 일반적이지 않습니다.

다음 문제는 요청을 수정해야 합니다.

  • JSON 문법 오류
  • 필수 버전 헤더 누락
  • 잘못된 API 경로
  • database_id와 data_source_id 혼동
  • 데이터 소스에 없는 속성 이름
  • 속성 유형과 값 구조 불일치
  • 읽기 전용 속성을 생성 요청에 포함
  • 요청 크기 제한 초과

같은 잘못된 요청을 반복하면 원인은 그대로 남고 API 호출만 증가합니다. 자동 재시도는 429나 일부 일시적 서버 오류와 구분해야 합니다.

400 오류가 발생했을 때 확인하는 순서

1. 응답 본문의 code와 message 확인
상태 코드 400만 기록하지 말고 invalid_json, validation_error, missing_version 등 실제 오류 코드를 확인합니다.
2. URL과 HTTP 메서드 확인
API 경로, database·data_sources 구분과 GET·POST·PATCH 메서드가 현재 엔드포인트 문서와 맞는지 확인합니다.
3. 필수 헤더 확인
Authorization, Notion-Version과 본문이 있을 때 Content-Type이 올바르게 전달되는지 확인합니다.
4. 선택한 API 버전과 요청 구조 비교
최신 버전을 사용한다면 data_source_id, position, in_trash 등 변경된 구조를 반영합니다.
5. JSON.stringify 사용
요청 본문을 문자열로 직접 연결하지 말고 JavaScript 객체를 JSON으로 변환합니다.
6. 데이터 소스 속성 구조 조회
속성 이름·ID·유형을 출력하고 페이지 요청의 properties와 비교합니다.
7. undefined·빈 문자열·읽기 전용 속성 확인
필수값이 JSON에서 빠지거나 지원되지 않는 값이 전송되지 않았는지 확인합니다.
8. 요청 크기 확인
긴 rich text, 많은 relation과 블록을 한 요청에 과도하게 넣지 않았는지 확인합니다.
9. 다른 상태 코드와 구분
401은 토큰, 403은 기능 권한, 404는 ID·공유 설정, 429는 요청 속도를 확인합니다.

내용 확인에 사용한 공식 자료

공식 문서 확인일: 2026년 7월 29일

Post a Comment

다음 이전