노션 자동화가 컬럼 이름을 바꿀 때마다 깨지는 이유
노션 API 연동을 컬럼 이름으로 짜면 이름을 바꾸는 순간 실패합니다. 속성 ID로 매핑을 만들어 두는 방법과, 그래도 남는 한계를 정리합니다.
잘 돌던 자동화가 어느 날 멈춘다
노션을 기록 저장소로 쓰고, 거기에 자동으로 행을 쌓는 코드를 붙여 두는 구성은 1인 운영에서 흔합니다. 저도 수집 결과와 작업 로그를 노션 데이터베이스로 흘려보내고 있습니다.
이 구성은 한동안 아무 문제 없이 돌다가, 어느 날 노션 화면에서 컬럼 이름을 한 번 다듬는 순간 조용히 멈춥니다. 코드는 그대로인데 갑자기 실패하니 처음에는 API 쪽 문제로 의심하게 됩니다.
원인은 단순합니다. 코드가 컬럼을 이름으로 찾고 있어서입니다.
# 흔한 형태 — 이름 문자열이 코드에 박혀 있다
properties = {
"제목": {"title": [{"text": {"content": title}}]},
"상태": {"select": {"name": "대기"}},
"수집일": {"date": {"start": collected_at}},
}
노션 화면에서 수집일을 수집 날짜로 바꾸면, 이 코드는 존재하지 않는 속성에 값을 넣으려 하고 API는 오류를 돌려줍니다. 컬럼을 옮기거나 하나 끼워 넣는 것만으로도 순서를 가정한 코드는 같은 이유로 깨집니다.
노션은 컬럼마다 ID를 따로 가지고 있다
이 문제를 푸는 열쇠는 노션 API 응답 안에 이미 들어 있습니다. 데이터베이스를 조회하면 각 속성마다 id가 함께 내려옵니다.
curl -s -X GET "https://api.notion.com/v1/databases/$DATABASE_ID" \
-H "Authorization: Bearer $NOTION_TOKEN" \
-H "Notion-Version: 2022-06-28" | jq '.properties | to_entries[] | {name: .key, id: .value.id, type: .value.type}'
응답은 대략 이런 모양입니다.
{"name": "제목", "id": "title", "type": "title"}
{"name": "상태", "id": "%3AVuS", "type": "select"}
{"name": "수집일", "id": "N%3E%40l", "type": "date"}
%3AVuS 같은 값이 속성 ID입니다. 사람이 읽기 좋은 값은 아니지만, 이름을 바꿔도 이 값은 바뀌지 않습니다. 이름은 화면에 보여주려고 붙은 라벨이고, 실제 식별자는 따로 있는 구조입니다.
즉 이름으로 접근하는 코드는 라벨을 식별자처럼 쓰고 있었던 셈입니다.
실행 시점에 한 번 조회해서 매핑을 만든다
고치는 방법은 코드에 ID를 하드코딩하는 것이 아닙니다. ID를 코드에 박으면 이번에는 데이터베이스를 복제하거나 다른 워크스페이스에 옮길 때 깨집니다.
대신 쓰기 직전에 스키마를 한 번 조회해서, 그 자리에서 이름과 ID를 짝지어 둡니다.
import os, requests
from functools import lru_cache
NOTION_TOKEN = os.environ["NOTION_TOKEN"]
HEADERS = {
"Authorization": f"Bearer {NOTION_TOKEN}",
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
}
@lru_cache(maxsize=8)
def load_schema(database_id: str) -> dict:
"""이름 -> (속성 ID, 타입) 매핑을 만든다. 프로세스당 1회만 조회된다."""
r = requests.get(
f"https://api.notion.com/v1/databases/{database_id}",
headers=HEADERS,
timeout=10,
)
r.raise_for_status()
props = r.json()["properties"]
return {name: (p["id"], p["type"]) for name, p in props.items()}
쓸 때는 이름이 아니라 ID를 키로 씁니다. 노션 API는 속성 키 자리에 이름과 ID를 모두 받아 주는데, ID를 쓰면 개명에 영향을 받지 않습니다.
def build_properties(database_id: str, values: dict) -> dict:
"""values = {"제목": ..., "상태": ..., "수집일": ...}"""
schema = load_schema(database_id)
out = {}
for name, value in values.items():
if name not in schema:
raise KeyError(f"노션에 '{name}' 컬럼이 없습니다. 현재 컬럼: {list(schema)}")
prop_id, prop_type = schema[name]
out[prop_id] = render(prop_type, value)
return out
def render(prop_type: str, value):
if prop_type == "title":
return {"title": [{"text": {"content": str(value)}}]}
if prop_type == "rich_text":
return {"rich_text": [{"text": {"content": str(value)}}]}
if prop_type == "select":
return {"select": {"name": str(value)}}
if prop_type == "date":
return {"date": {"start": value}}
if prop_type == "number":
return {"number": value}
if prop_type == "checkbox":
return {"checkbox": bool(value)}
raise ValueError(f"아직 다루지 않는 타입: {prop_type}")
여기서 두 가지가 같이 해결됩니다.
첫째, 이름이 바뀌어도 ID로 쓰기 때문에 코드를 안 고쳐도 됩니다. 둘째, render가 타입을 스키마에서 읽어서 값의 모양을 정하기 때문에, 어느 컬럼이 셀렉트인지 날짜인지 코드가 따로 외우고 있지 않아도 됩니다.
컬럼이 없어졌을 때는 시끄럽게 실패시킨다
위 코드에서 일부러 KeyError를 던지고 현재 컬럼 목록을 함께 붙였습니다. 이 부분은 취향이 아니라 필요입니다.
자동화가 조용히 넘어가면 며칠 뒤에야 발견합니다. 저는 자동으로 맡겨둔 작업이 닷새 동안 멈춰 있던 걸 주간 회고에서야 안 적이 있습니다. 실패를 조용히 삼키지 않고 어떤 컬럼을 찾다 실패했는지, 지금 있는 컬럼은 무엇인지까지 남기면 원인을 찾는 시간이 몇 분으로 줄어듭니다.
조회 비용은 얼마나 되나
쓰기 앞에 조회 한 번이 늘어납니다. 단건 쓰기라면 왕복 한 번, 배치라면 lru_cache 덕분에 프로세스당 한 번입니다.
장시간 상주하는 워커라면 캐시를 영원히 들고 있는 게 오히려 문제가 됩니다. 하루 종일 도는 프로세스가 새벽에 읽은 스키마를 저녁까지 쥐고 있으면, 낮에 컬럼을 추가한 게 반영되지 않습니다. 이럴 때는 TTL을 두는 편이 안전합니다.
import time
_cache: dict[str, tuple[float, dict]] = {}
TTL_SECONDS = 600
def load_schema_ttl(database_id: str) -> dict:
now = time.time()
hit = _cache.get(database_id)
if hit and now - hit[0] < TTL_SECONDS:
return hit[1]
schema = _fetch_schema(database_id) # 위 load_schema 의 캐시 없는 버전
_cache[database_id] = (now, schema)
return schema
남는 한계
이렇게 해도 속성 타입을 바꾸면 고쳐야 합니다. 텍스트였던 컬럼을 셀렉트로 바꾸면 값의 형태 자체가 달라지고, render가 아는 타입이 아니면 예외가 납니다.
이건 막을 수 있는 종류의 변화가 아닙니다. 값의 의미가 바뀐 것이기 때문에, 코드가 알아서 맞춰주면 오히려 엉뚱한 데이터가 들어갑니다.
실무에서 이름은 자주 바뀝니다. 정리하다가, 용어를 통일하다가, 한글에서 영문으로 바꾸다가 손이 갑니다. 반면 타입은 한 번 정하면 잘 안 바뀝니다. 자주 바뀌는 쪽을 막고, 드물게 바뀌는 쪽은 그때 손보는 배치입니다.
정리
- 노션 속성은 이름과 별개로 고유 ID를 가진다. 이름은 라벨이다
- 코드에 ID를 박지 말고, 실행 시점에 스키마를 한 번 조회해 이름-ID 매핑을 만든다
- 매핑에 타입도 같이 담아 두면 값의 모양을 코드가 외우지 않아도 된다
- 컬럼을 못 찾으면 조용히 넘기지 말고 현재 컬럼 목록과 함께 실패시킨다
- 상주 워커는 캐시에 TTL을 둔다
- 타입 변경은 여전히 수동 대응 영역이다
자주 묻는 질문
노션 API에서 컬럼 이름을 바꾸면 왜 코드가 실패하나요?
코드가 컬럼을 이름 문자열로 찾고 있기 때문입니다. 노션은 속성마다 이름과 별개로 고유한 속성 ID를 가지고 있고, 이름은 사람이 보라고 붙은 라벨입니다. 이름을 바꿔도 ID는 그대로이므로, ID로 접근하면 개명·이동에 영향을 받지 않습니다.
속성 ID는 어디서 확인하나요?
Retrieve a database 엔드포인트를 호출하면 properties 객체가 내려오고, 각 속성마다 id 필드가 들어 있습니다. 별도로 관리자 화면에서 찾을 필요 없이 실행 시점에 한 번 조회해서 이름과 ID를 짝지어 두면 됩니다.
실행할 때마다 스키마를 조회하면 느려지지 않나요?
쓰기 작업 앞에 조회 한 번이 추가됩니다. 배치로 여러 건을 쓸 때는 프로세스당 한 번만 조회해 캐시하면 비용은 사실상 사라집니다. 장시간 도는 워커라면 TTL을 두고 주기적으로 새로 고치는 편이 안전합니다.
속성 ID로 짜면 이제 안 깨지나요?
이름 변경과 순서 변경에는 안 깨집니다. 다만 속성 타입을 바꾸면(예: 텍스트를 셀렉트로) 값의 형태 자체가 달라지므로 코드를 고쳐야 합니다. 실무에서는 이름이 자주 바뀌고 타입은 잘 안 바뀌기 때문에, 자주 바뀌는 쪽을 막는 셈입니다.