연재 『의심하고 확인하기』 제3화(총 8화). 성공처럼 보이는 실패를, 한 화에 하나씩 확인해 나갑니다.
이 글에서 알 수 있는 것
- 기존 Incoming Webhook (Office 365 커넥터)으로 보내는 알림이 본문 없이 403 에러로 조용히 실패하는 형태와, 이를 단 한 번의 전송으로 찾아내는 절차
- Power Automate의 워크플로우 Webhook과 Adaptive Card로 옮기기 위한 최소한의 코드 변경 사항
- 성공 판정 기준을
== 200에서 2xx로 수정해야 하는 이유와, requests 라이브러리의 ok 속성에만 의존해서는 안 되는 이유
결론
제가 직장에서 사용하는 자체 제작 도구의 Teams 알림이 본문 없이 403 에러로 실패하고 있었습니다. 문제를 발견했을 때, 이전 구현 방식과 변경한 새 구현 방식 모두 403 에러가 발생했습니다. 언제부터 실패했는지는 알 수 없습니다. 원인은 Microsoft가 폐지하는 과정에 있던 구식 방식(Office 365 커넥터) 때문이었습니다. 호출하는 쪽에서 반환 값을 확인하지 않는 구조였기 때문에, 실패하더라도 아무도 알아차릴 수 없었습니다.
수정한 내용은 세 가지입니다.
- 전송 대상을 Power Automate의 워크플로우 Webhook으로 변경했습니다.
- 카드를 MessageCard에서 Adaptive Card로 교체했습니다.
- 성공 판정 기준을 '200만'이 아니라 '2xx' 범위로 확장했습니다 (새 방식은 202 Accepted를 반환합니다).
게다가, 알림의 성공 여부는 단순히 '2xx가 반환되었다'는 것만으로는 판단할 수 없습니다. 첫 번째 시도는 채널에 카드가 도착하는 것을 눈으로 확인하며 결정해야 합니다.
무슨 일이 일어났나
자체 제작 도구에는 사용자의 조작을 계기로 Teams 채널로 카드(Card)를 보내는 알림 기능이 있습니다. 오래된 화면에서 새로운 화면으로 기능을 이전하는 작업 과정에서, 이 알림 기능도 함께 옮겨야 했습니다. Claude Code와 진행하던 작업이었습니다.
옮긴 알림을 시험 삼아 실제 URL로 단 한 번 전송했습니다. 반환된 것은 403 에러였고, 본문은 비어 있었습니다. 오류 설명도, 어디가 문제인지에 대한 단서도 없었습니다.
조사해 보니, 이전의 구식 알림 방식 역시 같은 403 에러를 반환하고 있었습니다. 언제부터 실패했는지는 알 수 없습니다. 다만, 실패하더라도 아무도 알아차릴 수 없는 구조였다는 것만 파악했습니다.
분리(Isolation) 순서: 구현을 의심하기 전에, 동일한 경로로 재현해 보기
처음에 403 에러를 본 것은 새롭게 옮긴 쪽이었습니다. 새로운 쪽은 HTTP 라이브러리가 다르거나 (httpx), 프록시 지정 방식까지 변경되어 있었습니다. 이렇게 되자, 우선 자신의 이식(Porting) 코드를 의심하게 됩니다.
여기서 차이점을 추적하기 전에, 구식 구현과 동일한 경로로 다시 전송해 보았습니다. 동일한 라이브러리 (requests), 동일한 MessageCard, 동일한 프록시 설정을 사용했습니다. 결과는 여전히 같은 403 에러였습니다.
이를 통해 원인은 이식이 아니라 전송 대상(Destination) 쪽에 있다는 것을 확정할 수 있었습니다. 신구 버전에서 동일한 결과가 나왔기 때문에, 신구 간의 차이점을 한 줄씩 추적할 필요가 없었습니다.
재현해 보기
구식 방식의 전송 코드는 다음과 같은 형태였습니다.
# old_notify.py — 구 Incoming Webhook (Office 365 커넥터)으로 MessageCard를 보내기
import os
import requests
...
당시 결과는 403 에러와 빈 본문이었습니다. 예외(Exception)가 발생하지 않습니다. requests 라이브러리는 4xx 에러를 반환받아도, raise_for_status()를 호출하지 않는 한 예외로 처리하지 않기 때문입니다.
구 URL을 가지고 있지 않아도, 판정 방식의 차이는 현장에서 재현할 수 있습니다. 403과 202만 반환하는 스텁(Stub)을 만들어 비교해 봅니다.
직접 테스트해 볼 스텁 (로컬에서 403과 202만 반환)
# stub_demo.py — 403 (본문 없음)과 202를 반환하는 스텁으로, 판정 방식을 비교
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
...
/old 403 '' | ==200: False | 2xx: False
/new 202 '' | ==200: False | 2xx: True
프록시를 환경 변수로 설정한 터미널에서는, NO_PROXY=127.0.0.1을 추가하고 실행해 주세요.
두 번째 줄이 이번 글의 핵심입니다. 새 방식은 도착했더라도 == 200은 False가 됩니다.
성공처럼 보인 이유
화면이 멈추거나 사용자에게 에러가 표시되지는 않았습니다. 알림이 실패하더라도 주 기능을 중단하지 않도록 설계했기 때문입니다. 이러한 격리 자체는 올바른 판단이었습니다.
문제는, 실패가 반환 값과 로그의 한 줄에 불과했다는 점입니다. 호출 측에서는 반환 값을 확인하지 않았고, 로그를 읽어보는 사람도 없었습니다. 403 에러는 예외(exception)가 아니기 때문에 try/except 구문으로도 잡을 수 없습니다.
당시 제가 성공이라고 부른 것은 '에러로 인해 멈추지 않았다'는 것이었습니다. '도착했는지'를 판단하는 곳은 어디에도 없었습니다.
해결 방법: 전송처 · 카드 · 판정 · 반환 값
1. Power Automate의 워크플로우 웹훅 URL 발급하기
Teams 채널에서 '워크플로우'를 열고, '웹훅 요청을 받았을 때(When a webhook request is received)'부터 URL을 발급받습니다. 이 발급 과정은 채널 사용자가 Teams 화면에서 수행하는 작업입니다.
이 URL 자체가 핵심입니다. 이 URL을 아는 사람은 해당 채널에 게시할 수 있습니다. 환경 변수나 .env 파일에만 저장하고, 문서 · 채팅 · 커밋에는 붙여넣지 않아야 합니다.
2. 카드를 Adaptive Card로 변경하고 2xx 코드로 판정하기
# new_notify.py — 워크플로우 웹훅으로 Adaptive Card를 보내고 2xx 코드로 판정하는 코드
import logging
import os
...
실제 전송 시에는 202 Accepted가 반환되었고, 채널에 카드가 도착했습니다.
== 200
을 남기지 않아야 합니다. ok 상태에도 의존하지 말아야 합니다.
== 200인 상태로 새로운 방식으로 전환하면, 실제로 도착했음에도 불구하고 실패로 판정됩니다. 202는 200이 아니기 때문입니다. 성공이 실패로 계속 기록되면, 그 로그는 읽히지 않게 됩니다.
requests 라이브러리의 Response.ok도 피했습니다. ok의 정의는 '400 미만이면 True'이며, 3xx 코드까지 성공에 포함합니다. allow_redirects=False로 전송했거나 추적할 수 없는 3xx가 반환된 경우, 이 3xx 코드가 성공으로 계산되는 형태입니다. httpx를 사용하는 쪽에서는 2xx만 보는 is_success 속성이 있습니다. 어느 쪽이든 판정은 200 <= code < 300과 같이 작성해야 오해의 소지가 없습니다.
4. 알림 결과를 호출원(Caller)이 볼 수 있는 형태로 반환하기
주 기능을 알림 실패로부터 분리하는 설계는 유지했습니다. 그 위에, 알림 결과를 반환 값으로 호출원에게 돌려주도록 했습니다. 주 기능의 결과와 알림의 결과를 하나의 반환 값에 나열합니다.
# 형태를 보여주기 위한 발췌본. notify와 build_card는 위의 new_notify.py 파일에서 가져옴
def save_record(record: dict) -> bool:
return True # 주 기능 임시 처리 (실제로는 저장 로직)
...
notified가 False인 줄이 나오면, 그것이 확인해야 할 신호입니다. 다만, 반환만 했다고 해서 사람의 눈에 보이는 것은 아닙니다. 화면에 표시할지, 기록으로 남길지는 호출 측의 숙제로 남아있습니다.
실측 결과
| 항목 | 결과 |
|---|
| 기존 URL로 MessageCard 전송 | 403 (본문 없음) |
| ... | |
수정하는 것은 짧은 시간 안에 끝났습니다. 길었던 것은 아무도 보지 않았던 시간이었습니다.
Office 365 커넥터(Incoming Webhook) 폐지 일정 및 기존 URL 찾는 방법
Microsoft는 Teams의 Office 365 커넥터 폐지를 공지한 후, 일정을 여러 번 연기했습니다. 최신 일정은 다음 두 곳에서 확인할 수 있습니다.
- Microsoft 개발자 블로그 기사 Retirement of Office 365 connectors within Microsoft Teams
- Microsoft 365 관리 센터의 메시지 센터
이 글을 작성하는 시점에서, 메시지 센터의 해당 공지는 MC1181996이었습니다. 공지 번호는 변경될 수 있으므로, 읽을 때는 최신 것을 찾아보시기 바랍니다.
이 글을 작성하는 시점에서는, 공지된 일정이 이미 지났습니다. 공지 내용에 따르면, 기한이 지나면 Office 365 커넥터는 작동하지 않게 됩니다.
제가 가진 403 에러가 어떤 단계의 중단으로 인한 것인지는 알 수 없습니다. 확실한 것은 다음 두 가지입니다.
- 이전 URL로 전송된 알림은 발견 시점에 도착하지 않았습니다.
- 새로운 URL로 변경하니 도착했습니다.
이전 URL이 남아있는지 여부는 수신자 도메인에서 검색할 수 있습니다. git grep
는 Git이 추적하는 파일만 보여줍니다. .env
은 Git 관리 영역 외에 있는 경우가 많으므로, 별도로 찾아야 합니다.
git grep -n "webhook.office.com"
Get-ChildItem -Recurse -Force -File -Filter ".env*" |
Where-Object { $_.FullName -notmatch '\(\.venv|node_modules|\.git)\' } |
...
Bash에서는 다음과 같은 형태입니다.
grep -rn --include='.env*' --exclude-dir=.venv --exclude-dir=node_modules \
"webhook.office.com" .
따라 해 본다면 (Best Practices)
다음 5가지 항목에 [OK] 표시가 가능한지 확인해 보세요.
- [OK]/[NG] 외부로 전송되는 알림을 Mock이 아닌 실제(본물)로 한 번 보내보고, 수신 여부를 눈으로 직접 확인했다.
- [OK]/[NG] 실패했을 때, 이전 구현과 동일한 경로로 재현하여, 자신의 변경 사항인지 상대방 측의 문제인지를 구분할 수 있었다.
- [OK]/[NG] 성공 판정이
200 <= code < 300이 되어 있고, == 200이나 ok에 의존하지 않는다. - [OK]/[NG] 알림 결과가 반환 값(return value)으로 호출한 곳에 전달되어, 어디서 사용자에게 보여줄지 결정할 수 있다.
- [OK]/[NG] Webhook URL을 비밀 키처럼 취급하여 문서, 채팅, 커밋 등에 붙여 사용하지 않는다.
이번 점검의 핵심 한 줄은 이것입니다.
알림의 성공 여부는 처음에는 도착한 것을 눈으로 확인하는 것으로 결정합니다. 그 이후부터는 2xx 코드로 판정하고, 결과를 호출한 곳에 반환해야 합니다.
다음 편
종료 코드(Exit Code), URL, HTTP 상태 코드가 있습니다. 이번 글에서 같은 형태가 세 번 나왔습니다. 다음 편에서는 상황을 아우르는 도감(圖鑑)이 될 예정입니다.
- 이전 편: 제2화 claude -p의 매일 아침 순찰이 약 7주 동안 한 번도 작동하지 않았던 일
- 다음 편: 제4화 종료 코드 0으로 실패하는 것들 — draw.io・robocopy・pytest・Bedrock에서 본 exit 0 도감 (출간 임박)
같은 시리즈
gh api -f body=@file로 PR에 임시 파일 경로가 올라갔던 일
- claude -p의 매일 아침 순찰이 약 7주 동안 한 번도 작동하지 않았던 일
- Teams Incoming Webhook이 403 에러로 조용히 실패하고 있었습니다 (본문)
- 종료 코드 0으로 실패하는 것들 — draw.io・robocopy・pytest・Bedrock에서 본 exit 0 도감 (출간 임박)
- Teams 문자 기록 가져오기 과정에서 '잠시 후 재시도'를 믿었다가, 필터의 오류였음이 밝혀진 일 — 에러 메시지로부터 판정 조건으로 내려가는 4단계 (출간 임박)
- Claude Code의 서브 에이전트 200여 개를 돌려 사고를 기계적으로 포착한 일 (출간 임박)
- AI의 답변은 '어디에, 어떤 맥락에서 쓰였는지'로 확인하는 법 — 행 번호와 원문을 인용하며 80번 비교했던 경험 (출간 임박)
- 자신의 AI 결과물을 감사해보니 — 빈 README, 모순점 0건, 글자 수 3배 차이 (출간 임박)
점검의 핵심 한 줄 (재게시): 알림의 성공 여부는 처음에는 도착한 것을 눈으로 확인하는 것으로 결정합니다. 그 이후부터는 2xx 코드로 판정하고, 결과를 호출한 곳에 반환해야 합니다.
문의사항이나 감상은 프로필 링크를 통해 부탁드립니다.
재현에 사용된 도구 버전: 기사 코드(Stub을 이용한 확인)는 Python 3.13.7・requests 2.32.5로 작동했습니다. 당시 실제 전송에 사용했던 버전은 보관하고 있지 않습니다.
Obsidian과 Claude Code를 활용하여 두 번째 뇌를 구축하는 책의 제1, 2화가 맛보기 무료 공개되었습니다:
Claude Code에게 교재를 만들게 했던 이야기는 note에 작성했습니다: https://note.com/hotto_ken/n/nd46a33a55a87
Discussion