Microsoft Graph 실무 입문 #7 운영: 스로틀링, 페이지네이션, delta 쿼리, 변경 알림
시리즈 마지막은 운영입니다. 4〜6편에서 만든 자동화는 “동작하는” 코드였습니다. 이번 편은 그것을 “몇 달을 돌려도 조용히 데이터가 잘리거나 멈추지 않는” 코드로 바꾸는 네 가지, 스로틀링, 페이지네이션, delta 쿼리, 변경 알림을 다룹니다. 인프라 검색형 글들에서 반복한 운영 감각이 Graph라는 API 위에서 재현되는 회차이기도 합니다.
스로틀링: 429는 에러가 아니라 계약입니다 #
Graph는 서비스별로 호출량 한도를 두고, 넘으면 429 Too Many Requests를 돌려줍니다. 여기서 중요한 규칙 세 가지입니다.
- 응답의
Retry-After헤더가 지시하는 초만큼 기다렸다가 재시도합니다. 이것이 가장 빨리 회복하는 길입니다. 429를 무시한 즉시 재시도는 사용량에 계속 집계되어 스로틀링을 연장시킵니다. - 한도는 “전역 X회"가 아니라 서비스별·테넌트별·앱별로 제각각입니다(Outlook 계열, SharePoint 계열, Teams 계열이 전부 다릅니다). 숫자를 외우기보다 “429를 만나면 물러난다"는 동작을 코드에 넣는 것이 정답입니다.
- 일부 리소스는 Retry-After를 안 주는 경우가 있으므로, 그때는 지수 백오프로 대체합니다.
좋은 소식은 SDK에 이 동작이 이미 들어 있다는 것입니다. msgraph-sdk의 기본 미들웨어가 429·5xx에 대해 Retry-After(없으면 지수 백오프) 재시도를 처리합니다. 그래서 실무의 초점은 재시도 구현보다 스로틀링을 애초에 덜 맞는 설계로 옮겨집니다.
$select로 응답을 줄입니다(1편부터 반복한 그 습관입니다).- 폴링을 delta 쿼리나 변경 알림으로 바꿉니다(아래에서 다룹니다).
- 대량 순회는 동시성을 제한합니다. asyncio로 수백 요청을 한 번에 쏘는 것은 429 제조기입니다.
asyncio.Semaphore로 동시 4〜8 수준으로 묶는 것이 무난한 출발점입니다.
페이지네이션: nextLink를 따라가지 않으면 데이터가 잘립니다 #
목록 API는 결과를 한 번에 다 주지 않습니다. 한 페이지(기본 수십〜수백 건)와 함께 다음 페이지 URL인 @odata.nextLink가 오고, 이것을 따라가지 않으면 나머지는 조용히 사라집니다. “사용자 수가 왜 100명밖에 안 나오지?“의 정체가 대부분 이것입니다.
async def all_pages(client, first_response):
"""어떤 목록 응답이든 전 페이지를 순회하는 제너레이터"""
page = first_response
while page:
for item in page.value or []:
yield item
if not page.odata_next_link:
break
page = await client.users.with_url(page.odata_next_link).get()
# 사용 예
result = await client.users.get()
async for user in all_pages(client, result):
print(user.display_name)with_url()은 nextLink처럼 이미 완성된 URL로 다음 요청을 보내는 SDK의 통로입니다. nextLink에는 원래 쿼리($select 등)가 보존돼 있으므로 그대로 따라가기만 하면 됩니다. 목록을 다루는 모든 자동화에 이 헬퍼 하나를 두고 강제로 쓰는 것이, 데이터 잘림 사고를 구조적으로 막는 방법입니다.
delta 쿼리: “바뀐 것만"을 상태로 관리하기 #
“매시간 전체 메일함을 다시 읽어 새 메일을 찾는” 폴링은 느리고, 스로틀링을 부르고, 대부분 낭비입니다(변화는 드물기 때문입니다). Graph의 답이 delta 쿼리입니다. 첫 호출로 전체 상태와 함께 @odata.deltaLink(책갈피)를 받아 저장해 두고, 다음부터는 deltaLink를 호출하면 그 사이에 바뀐 것만 옵니다.
async def sync_users(client, saved_delta_link: str | None):
if saved_delta_link:
page = await client.users.with_url(saved_delta_link).get()
else:
page = await client.users.delta.get() # 최초 전체 동기화
changes = []
while page:
changes.extend(page.value or [])
if page.odata_next_link: # 변경분도 페이지네이션됩니다
page = await client.users.with_url(page.odata_next_link).get()
else:
new_delta_link = page.odata_delta_link
break
# changes 처리: 삭제는 항목에 @removed 표시로 옵니다
return changes, new_delta_link # 새 책갈피를 저장소에 보관운영 규칙은 세 가지입니다. deltaLink는 상태이므로 파일이든 DB든 영속 저장하고, 삭제된 항목은 @removed 주석으로 오므로 반드시 처리하며, deltaLink가 너무 오래돼 만료됐다는 에러(410 Gone)를 받으면 전체 동기화부터 다시 시작합니다. 지원 리소스는 users, groups, 메일(messages), 일정(calendarView), driveItem, Teams 메시지(chatMessage) 등 동기화 수요가 큰 곳들입니다. “주기적으로 도는 배치 + delta"의 조합은 폴링 대비 호출량을 자릿수가 달라질 만큼 줄여 주고, SharePoint 쪽에서는 delta 요청의 스로틀링 비용 자체를 낮게 책정하기까지 합니다.
변경 알림: 밀어 주는 쪽으로 뒤집기 #
배치 간격조차 길다면(새 메일에 몇 초 안에 반응해야 한다면) 방향을 뒤집습니다. 변경 알림(구독·웹훅)은 Graph가 내 HTTPS 엔드포인트로 변경 사실을 밀어 주는 방식입니다.
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, Request, Response
from msgraph.generated.models.subscription import Subscription
app = FastAPI()
@app.post("/graph/notify")
async def notify(request: Request):
# ① 구독 생성 시의 검증: validationToken을 10초 안에 text/plain으로 반환
token = request.query_params.get("validationToken")
if token:
return Response(content=token, media_type="text/plain")
# ② 실제 알림: clientState 대조 후, 무겁게 처리하지 말고 큐에 넣고 즉시 202
payload = await request.json()
for note in payload.get("value", []):
if note.get("clientState") != EXPECTED_STATE:
continue
enqueue(note["resource"]) # 상세 조회는 워커가 별도로
return Response(status_code=202)
async def subscribe(client):
sub = Subscription(
change_type="created",
notification_url="https://automation.contoso.com/graph/notify",
resource="users/{id}/mailFolders('inbox')/messages",
expiration_date_time=datetime.now(timezone.utc) + timedelta(days=2),
client_state=EXPECTED_STATE, # 위조 알림 구분용 비밀값
)
return await client.subscriptions.post(sub)운영에서 중요한 성질들이 있습니다.
- 구독은 만료됩니다. 최대 수명이 리소스마다 달라서(메일·일정은 7일 미만, driveItem은 30일 미만, Teams 메시지는 조건에 따라 1시간 단위 등) 갱신(PATCH)을 스케줄에 넣는 것까지가 구현입니다. 갱신이 끊기면 알림도 조용히 끊깁니다.
- 알림에는 기본적으로 “무엇이 바뀌었다"는 사실과 리소스 경로만 옵니다. 내용은 받은 쪽이 다시 조회하는 것이 기본형이고, 그 조회에 delta를 결합하면(알림은 트리거, delta로 정합 동기화) 알림 유실에도 강한 구조가 됩니다.
- Teams 리소스처럼 lifecycleNotificationUrl(구독이 제거되거나 재인증이 필요할 때의 별도 알림 채널)이 요구되는 경우가 있고, 필수가 아니어도 붙여 두는 것이 회복력에 좋습니다.
- 엔드포인트는 공개 HTTPS여야 하므로, 사내망만 있는 환경이라면 Azure Functions 같은 얇은 수신부를 앞에 세우는 구성이 일반적입니다.
폴링 → 배치+delta → 알림+delta로 갈수록 실시간성이 올라가는 대신 운영 부품(엔드포인트, 갱신 스케줄, 상태 저장)이 늘어납니다. 요구되는 반응 속도에 맞는 가장 단순한 단계를 고르는 것이 맞고, 대부분의 사내 자동화는 배치+delta에서 끝납니다.
마지막 도구: JSON 배칭 #
여러 독립 요청을 하나의 HTTP 호출로 묶는 $batch(최대 20개)도 알아 둘 가치가 있습니다. “사용자 20명의 프로필을 각각 조회” 같은 팬아웃을 왕복 1회로 줄입니다. 다만 배칭은 왕복을 줄일 뿐 스로틀링 집계는 요청 수 기준으로 이뤄지므로, 네트워크 편의 왕복 곱셈 문제의 처방이지 429의 처방은 아니라는 구분만 정확히 해 둡니다.
정리: 시리즈를 닫으며 #
- 429는 Retry-After를 존중하면 됩니다(SDK가 기본 처리). 실무의 초점은 $select, 동시성 제한, 폴링 대체라는 “덜 맞는 설계"입니다.
- 목록은 nextLink를 끝까지 따라가야 전부입니다. 전 페이지 순회 헬퍼를 만들어 강제하는 것이 잘림 사고의 구조적 예방입니다.
- 반복 동기화는 delta 쿼리로 바꿉니다. deltaLink는 영속 상태, @removed 처리, 410이면 전체 재동기화라는 세 규칙이 운영의 전부입니다.
- 실시간이 필요하면 변경 알림을 얹되, 검증 핸드셰이크·clientState 대조·만료 갱신 스케줄까지가 한 세트입니다. 알림은 트리거로 쓰고 정합은 delta로 맞추면 유실에 강해집니다.
- $batch는 왕복을 줄이는 도구이지 스로틀링을 피하는 도구가 아닙니다.
시리즈 전체를 한 문장으로 요약하면 이렇습니다. 앱 등록과 권한 유형(2편)을 정확히 정하고, SDK 골격(3편) 위에 시나리오(4〜6편)를 얹고, 운영 네 가지(7편)를 갖추면, Microsoft 365 위의 어떤 사내 자동화든 같은 방법으로 만들 수 있습니다. 다음 확장 후보로는 Entra ID 관리 자동화(입퇴사 처리), Excel API를 이용한 리포트 생성, Power Automate와의 역할 분담 같은 주제가 자연스럽게 이어집니다.