Microsoft Graph API — 놓치기 쉬운 OneNote API 제한
한동안 Note Bridge를 개발해왔습니다. OneNote 노트북을 Notion으로 이전하는 도구예요. 처음엔 콘텐츠 변환이 가장 어려울 거라고 생각했습니다. OneNote의 복잡한 HTML을 Notion으로 정확하게 변환하는 작업이요. 결과적으로 Microsoft의 rate limit 처리가 더 많은 시간을 잡아먹었습니다. 다음은 그 과정에서 배운 것들입니다.
1. 3 × 2 차원의 rate limit
OneNote의 rate limit은 단순하지 않습니다. 세 가지 차원——분당, 시간당, 동시 요청 수——이 각각 두 가지 범위에서 적용됩니다: 사용자별, 앱별 [1]. 곱하면 여섯 가지 제한을 전부 지켜야 하고, 그렇지 않으면 429가 즉시 날아옵니다.
이 말은 흔히 쓰이는 다항식 백오프 전략만으로는 부족하다는 뜻입니다. 분당 제한에는 안 걸렸는데 시간당 상한에 걸릴 수 있어요. 한 사용자는 괜찮은데, 전체 사용자 합산이 앱 전체 상한에 닿을 수도 있습니다. 차원마다 별도의 계량 로직이 필요합니다.
다만 실전 경험상 실제 제한은 문서 스펙보다 관대해 보입니다. 그래도 스펙대로 코딩합니다——언젠가 Microsoft가 엄격하게 적용하기 시작하면 문제가 되니까요.
2. Retry-After 헤더가 없음
주류 API 설계에서는 429를 반환할 때 Retry-After 헤더를 같이 보내서 다음 안전한 재시도 시간을 알려줍니다. 메커니즘이 너무 복잡해서인지, OneNote Graph API는 이를 지원하지 않습니다.
Retry-After가 없으면 단순한 백오프 전략으로는 대응할 수 없습니다. 429가 올 때마다 대기 시간을 늘릴 수는 있지만, 다음번에 또 걸리지 않는다는 보장이 없습니다. 프로덕션 환경의 앱에게 이건 심각한 문제입니다. 유일하게 신뢰할 수 있는 방법은 Microsoft 스펙에 맞춰 rate limiter를 직접 구현하는 것입니다——저희는 Cloudflare Durable Objects를 활용해 각 차원의 사용량을 추적하고, 프론트엔드와 백엔드 양쪽이 참조할 수 있는 자체 Retry-After도 함께 구현했습니다.
3. 두 개의 x-ms- 헤더
rate limiter를 만들고 나서야 발견했는데, Graph는 사실 유용한 응답 헤더를 두 개 보내줍니다——단지 429 경로에 있지 않아서 놓치기 쉬울 뿐입니다.
x-ms-throttle-limit-percentage——성공 응답에 포함됩니다. 현재 제한에 얼마나 가까운지(0.0–1.0)를 알려줍니다. 0.8 정도를 넘으면 노란불, 1.0에 도달하면 다음 Request는 거의 확실히 429를 받습니다.x-ms-throttle-scope——429 응답에 포함됩니다. 값은User,Application또는 둘 다이고, 어느 레벨의 제한에 걸렸는지 알려줍니다.
이 두 헤더가 설계를 바꿨습니다. 첫 번째 덕분에 429를 받기 전에 소프트 서킷 브레이커를 발동할 수 있게 됐습니다——시스템이 알려줄 때까지 기다리지 않아도 됩니다. 두 번째 덕분에 429를 알맞은 브레이커로 보낼 수 있습니다——앱 전체가 걸려 있다면 한 사용자만 늦춰봐야 의미가 없고 모두를 늦춰야 합니다. 사용자 레벨이라면 다른 사용자는 그대로 달려도 됩니다.
429 상태 코드에만 반응한다면 rate limiter는 사실상 반쯤 눈감고 돌아가고 있는 셈입니다.
4. Batch API는 별 의미 없음
Microsoft Graph에는 여러 요청을 하나의 HTTP 요청으로 묶는 batch 기능이 있습니다. 직감적으로는 1건의 Request로 카운트되어야 할 것 같지만, 그렇지 않습니다. batch 안의 각 내부 Request가 개별적으로 rate limit에 카운트됩니다 [2].
결과적으로 $batch를 사용할 실질적 의미는 거의 없고, 기껏해야 네트워크 왕복 시간을 약간 줄이는 정도입니다.
더 미묘한 함정도 있습니다. $batch POST가 바깥쪽에서는 HTTP 200을 반환하지만, 내부 개별 sub-response는 429, 502, 503을 반환할 수 있습니다. 한 번은 프로덕션 이전에서 내부 결과 두 개가 조용히 누락된 적이 있었습니다——retry 루프가 바깥쪽만 retry하고, sub-response의 5xx 오류가 그대로 묻혔던 것입니다. $batch를 쓴다면 재시도 전략은 response 전체가 아니라 각 sub-response 단위로 동작해야 합니다.
5. 죽음의 나선
가장 아팠던 일은 429 자체가 아니라 429 이후에 일어난 일이었습니다. 한 사용자가 1,300페이지짜리 이전을 시작했습니다. 처음 200페이지는 순조로웠어요. 그러다 429가 하나 왔고, 저희 consumer가 잘 받아내서 Cloudflare Queues에 30초 후 재시도를 요청했습니다. 여기까지는 합리적이었습니다.
하지만 남은 1,100개 message가 이미 모두 큐에 들어가 있었기 때문에, 30초 후 거의 동시에 깨어나서 한꺼번에 rate limit에 부딪혔고, 다 같이 429를 받았습니다. 매 429 파도마다 throttle 윈도우가 더 길어졌습니다. 그래프에는 아름다운 자기 강화 루프가 그려졌습니다: rate limit → retry → rate limit → 더 많은 retry. 30분 동안 단 한 페이지도 진전이 없었습니다.
결국 효과가 있었던 두 가지 조치:
- 진짜 서킷 브레이커는 호출 지점이 아니라 rate limiter 계층에 둬야 합니다. 상류에서 지속적으로 429를 반환하면, rate limiter 자체가 받아내고 모든 caller가 로컬에서 일정 쿨다운 동안 429를 받습니다. 이렇게 하면 N개의 독립 retry 루프가 하나의 공유 대기로 수렴됩니다——N명의 caller가 각각 30회 × 5분씩 retry하는 대신.
- 포그라운드와 백그라운드 분리. Note Bridge에는 두 종류의 작업이 있습니다: 포그라운드의 노트북 스캔(사용자가 기다리는 중)과 백그라운드 이전 작업(아무도 화면을 보고 있지 않음). 1,300페이지 이전이 rate limit 예산을 다 먹어버리면 노트북을 스캔하려는 사용자는 포그라운드에서 발이 묶입니다. 저희 해법은 rate limiter를 두 개로 나누고, 백그라운드 쪽 상한을 예산의 약 50%로 두는 것입니다. 포그라운드는 항상 우선됩니다.
6. 미묘한 retryAfterMs 문제
이건 발견하는 데 시간이 조금 걸려서 따로 언급할 가치가 있습니다. rate limiter가 두 계층(per-user와 per-app)을 확인하는데 두 계층 모두 가득 찬 상태일 때, 호출자에게 어떤 retryAfterMs를 반환해야 할까요?
저희는 원래 Math.min(userRetry, appRetry)를 썼습니다——더 짧은 대기 시간을 반환, 친절해 보이지만 틀렸습니다. user-scope가 "2초 기다려", app-scope가 "30초 기다려"라고 한 상황에서 2초 후에 정말 retry하면 즉시 또 429를 받습니다. 정답은 Math.max(userRetry, appRetry)——두 계층 모두 여유가 있을 때만 그 Request는 진짜로 보낼 수 있는 것입니다.
결론
대부분의 rate limit 튜토리얼은 "지수 백오프 + 재시도"에서 끝납니다. Microsoft Graph에는 그것만으로는 한참 부족합니다. 다음이 필요합니다:
- 3 × 2 제한 매트릭스를 만들어 두세요.
x-ms-throttle-limit-percentage를 읽어서 429를 받기 전에 먼저 backoff하세요.x-ms-throttle-scope로 429를 알맞은 레벨로 한정해서, 사용자 레벨과 앱 레벨의 제한이 서로 영향을 주지 않도록 하세요.- retry는
$batch의 sub-response 단위로 하세요. payload 전체가 아니라. - 공유 서킷 브레이커를 쓰세요. 그렇지 않으면 큐가 무효한 retry로 계속 차게 됩니다.
- 포그라운드와 백그라운드를 분리해서 인터랙티브 UX가 백그라운드 작업에 굶지 않게 하세요.
Microsoft Graph에 Retry-After가 없는 건 표면적인 문제고, 더 깊은 교훈은 "rate limit은 헤더 하나가 아니라 시스템이다, 시스템답게 설계해야 제대로 풀린다"입니다.
Note Bridge 는 OneNote 노트북을 Notion으로 이전합니다.