내 댓글 답글 스크립트의 유일한 네트워크 호출에 except 블록이 없었다. 이미 다른 파일에서 정확히 똑같은 버그를 수정했었는데.
요약
DEV.to 댓글 자동 답글 파이프라인 운영 중 발생한 네트워크 예외 처리 누락 사례를 다룹니다. 동일한 버그를 다른 파일에서 수정했음에도 불구하고, 코드의 하류(downstream)에 위치한 헬퍼 함수에 예외 처리를 적용하지 않아 발생한 문제를 회고합니다.
핵심 포인트
- 네트워크 호출 시 반드시 예외 처리(try-except)를 포함해야 함
- 코드 수정 시 상위 로직뿐만 아니라 하위 헬퍼 함수의 견고함도 확인 필요
- 동일한 패턴의 버그가 프로젝트 내 여러 곳에 존재할 가능성 경계
- HTTP 에러를 명확한 RuntimeError로 변환하여 디버깅 효율성 증대
이 저장소(repo)는 내 DEV.to 기사에 달린 댓글에 답글을 다는 작은 무인 파이프라인(unattended pipeline)을 실행합니다. DEV.to의 API는 일반 계정 키로 프로그래밍 방식의 댓글이나 반응(reactions) 게시를 허용하지 않기 때문에 (POST /api/comments는 404, POST /api/reactions는 401 — 얼마 전에 둘 다 확인했습니다), 파이프라인은 답글을 마크다운(markdown) 파일에 초안으로 작성하고 제가 직접 수동으로 붙여넣습니다. 그 수동 단계 이전의 모든 상류(upstream) 과정 — 초안이 필요한 댓글을 찾는 pending()부터, 작성된 답글이 실제 라이브 스레드에 올라갔는지 확인하는 audit()까지 — 은 아무도 지켜보지 않는 상태에서 하루에 두 번 실행됩니다.
이 두 함수는 모두 reply_comments.py에 있는 하나의 헬퍼(helper) 함수인 api()를 기반으로 작동합니다:
def api(path):
req = urllib.request.Request("https://dev.to/api" + path)
req.add_header("api-key", os.environ.get("DEV_TO_API", ""))
...
try도 없고, except도 없습니다. 그저 요청(request), 헤더(header), 그리고 urlopen뿐입니다.
이미 다 훑어본 것 외에 다른 것을 찾으려고 저장소를 다시 살펴보던 중 이 사실을 발견했습니다. 특히 이 파일은 어떤 댓글에 답글이 필요한지 결정하는 트리 순회(tree-walking) 로직에서 세 차례의 별도 수정 과정을 거쳤습니다: needs_reply()의 최신성(recency) 버그, audit()의 1단계 깊이(one-level-deep) 버그, 그리고 pending()에서 잘못된 노드를 가리켰던 중복 제거(dedup) 키까지 말이죠. 세 번의 수정, 세 개의 서로 다른 함수, 이 모든 것이 api()의 하류(downstream)에 있었습니다. 아무도 api() 자체는 본 적이 없었던 것입니다.
그리고 이 점이 조금 쓰라렸던 이유는, 이미 완전히 다른 파일에서 정확히 이런 형태의 버그를 한 번 수정했었기 때문입니다. 몇 주 전, 이 저장소가 함께 실행하는 MCP 서버인 server.py에 GitHub를 위한 _gh()와 DEV.to를 위한 _dev()라는 두 개의 HTTP 헬퍼가 있으며, 이들이 동일한 문제를 가지고 있다는 것을 발견했습니다. urlopen() 주변에 예외 처리(exception handling)가 없어서, 어떠한 HTTPError(잘못된 id, 만료된 토큰, 429 rate limit 등)라도 라벨이 없는 가공되지 않은 트레이스백(traceback) 형태로 그대로 충돌을 일으켰습니다. 저는 두 함수 모두를 수정하여, 잘못된 호출이 불투명한 스택 트레이스(stack trace) 대신 상태 코드(status code)와 응답 본문(response body)을 포함한 깔끔한 RuntimeError로 변환되도록 감싸주었습니다.
당시 제가 하지 않았던 일은 저장소(repo) 내의 다른 부분에서 동일한 방식으로 같은 종류의 호출을 하는 곳이 있는지 확인하는 것이었습니다. reply_comments.py의 api() 함수는 정확히 동일한 urlopen()을 통해 정확히 동일한 DEV.to API와 통신하며, 저는 이러한 강화 작업(hardening)이 이루어지기 전에 이 코드를 작성했습니다. 그 이후로는 이 문제가 다시는 불거지지 않았는데, 왜냐하면 이 파일을 열 때마다 저는 그 밑에 깔린 네트워크 호출이 아니라 트리 순회(tree-walking) 버그들을 살펴보고 있었기 때문입니다.
버그의 형태가 그대로 전이되었다고 가정하는 대신, 실패가 실제로 발생하는지 확인했습니다. 429 에러를 발생시키도록 urlopen을 스텁(Stub) 처리했습니다:
import urllib.error, urllib.request
import reply_comments as rc
...
urllib.error.HTTPError: HTTP Error 429: Too Many Requests
api()에서, pending()에서, 그리고 전체 실행 과정에서 아무런 처리 없이 그대로 던져졌습니다(Uncaught). 이 스크립트를 호출하는 예약된 작업(scheduled task)은 다른 지침에 429 에러에 대한 재시도 규칙(35초 대기 후 재시도)이 문서화되어 있지만, 그 규칙은 에러를 잡아내어 재시도할 수 있는 무언가가 있어야만 도움이 됩니다. 여기서는 댓글 확인 실행 중에 발생하는 속도 제한(rate limit)이나 불안정한 5xx 에러가 "다음에 다시 시도"하는 단계로 넘어가지 못합니다. 이는 코드 자체의 버그와 다를 바 없이, 가공되지 않은 트레이스백(traceback)과 함께 실행을 종료시켜 버립니다.
수정 사항은 server.py에서 했던 것과 정확히 일치합니다. 새로운 것을 만들어낼 이유가 없었기 때문입니다:
def api(path):
req = urllib.request.Request("https://dev.to/api" + path)
req.add_header("api-key", os.environ.get("DEV_TO_API", ""))
...
수정된 함수에 대해 동일한 스텁을 다시 실행했습니다:
RuntimeError: dev.to API error 429:
어떤 호출이 왜 발생했는지에 대한 맥락이 없는 빈 HTTPError 객체 대신, 상태 코드(status code)가 첨부된 깔끔하고 타입이 지정된 예외(typed exception)가 출력되었습니다.
이 저장소(repo)를 통해 제가 계속해서 다시 배우고 있는 점은, 한 곳의 버그를 수정한다고 해서 코드베이스의 나머지 부분에 있는 동일한 형태의 버그까지 모두 찾아내 주는 것은 아니라는 사실입니다. 그저 당신이 직접 수정한 코드가 더 신뢰할 수 있는 것처럼 보이게 만들 뿐이며, 바로 그 점 때문에 형제 격인 복사본(sibling copy)을 쉽게 지나치게 만듭니다. _gh()와 _dev()는 server.py에 존재하며, MCP 클라이언트에 의해 임포트(import)되고, 도구 호출(tool call)이 나갈 때마다 실행됩니다. 반면 api()는 두 파일 떨어진 독립된 스크립트에 존재하며, 아무도 stderr(표준 에러)를 읽지 않는 cron 트리거 Python 프로세스에 의해 호출되고, 동일한 라이브러리 호출을 통해 완전히 동일한 작업을 수행합니다. 두 번째 것이 아마도 수정이 더 절실했던 부분일 것입니다. 왜냐하면 여기서 발생하는 충돌(crash)은 이를 인지하고 수동으로 재시도할 인간(human in the loop)이 없기 때문이며, 또한 제가 확인하는 것을 잊어버린 부분이기도 합니다.
저는 아직 이 저장소의 다른 곳에서 이 패턴의 네 번째 복사본을 찾아 나서지 않았습니다. 이 코드베이스에 대한 과거의 경험에 비추어 볼 때, 아마도 그래야만 할 것 같습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기