내 댓글-답글 큐(Queue) 초안: 스레드에 한 번 답글을 달았더니 그 이후의 모든 후속 메시지에 반응하지 않게 된 문제
요약
DEV.to 댓글 관리를 위한 자동화 스크립트 작성 중 발생한 트리 구조 탐색 버그와 그 해결 과정을 다룹니다. 재귀적 트리 탐색 시 최상위 노드 데이터만 참조하여 발생하는 논리적 오류를 분석합니다.
핵심 포인트
- 트리 구조 데이터 처리 시 최상위 노드와 자식 노드의 구분 필요
- 재귀적 탐색 로직에서 중복 제거 키(dedup key) 설정의 중요성
- 기존 버그 수정이 새로운 버그를 은폐할 수 있는 사례 제시
- 가장 최근 메시지(latest message)를 기준으로 한 데이터 참조 방식
저는 DEV.to의 모든 기사를 일일이 수동으로 스캔하며 새로운 댓글을 확인하지 않아도 되도록 도와주는 작은 스크립트인 reply_comments.py를 가지고 있습니다. 이 스크립트에는 두 가지 명령어가 있습니다: pending (아직 답글 초안을 작성하지 않은 미답변 댓글)과 audit (수동으로 붙여넣겠다고 말해두고 실제로는 한 번도 하지 않은 답글 초안들). 저는 이미 이 파일에서 두 가지 버그를 수정했습니다. 하나는 needs_reply()의 버그(상대방이 다시 후속 메시지를 남겼음에도 단 한 번의 답글 이후 스레드가 영원히 "처리됨(handled)" 상태로 유지되는 문제)였고, 다른 하나는 audit()의 버그(직계 자식 노드만 확인하여 두 단계 깊이로 중첩된 답글은 보이지 않는 문제)였습니다. 오늘 저는 pending() 자체에서 세 번째 버그를 발견했는데, 이는 앞선 두 번의 수정 덕분에 파일의 다른 모든 부분이 신뢰할 수 있는 것처럼 보여 오히려 교묘하게 숨어버린 종류의 버그입니다.
pending()이 실제로 하는 일
DEV.to의 댓글은 API로부터 트리(tree) 구조로 반환됩니다. 각 최상위 댓글은 children 리스트를 가지며, 답글은 임의의 깊이로 중첩될 수 있습니다. pending()은 각 기사의 최상위 댓글들을 순회하며, 각 댓글에 대해 답글이 필요한지 여부를 결정합니다:
def pending():
try:
drafted_text = open(DRAFTS, encoding="utf-8").read()
...
needs_reply(c)는 몇 주 전에 수정한 부분으로, 서브트리(subtree) 전체를 재귀적으로 탐색하여 단순히 내가 답글을 남긴 적이 있는지가 아니라, _가장 최근_에 메시지를 게시한 사람이 누구인지를 확인합니다. 그 부분은 정확합니다. 버그는 바로 그 뒤의 두 줄인 c["id_code"]와 c["body_html"]에 있습니다. 여기서 c는 실제 미답변 메시지가 아무리 깊은 곳에 있더라도 항상 **최상위 댓글(top-level comment)**입니다.
잘못되는 두 가지 상황
단판 댓글(누군가 게시물을 올렸고, 내가 아직 답글을 달지 않은 경우)의 경우, c가 곧 미답변 메시지이므로 이 코드는 잘 작동합니다. 이는 제가 테스트했던 유일한 사례였습니다.
하지만 이 파이프라인은 주고받는 스레드(back-and-forth threads)를 위해 특별히 존재합니다. 예를 들어 누군가 댓글을 달고(id_code: "aaa"), 제가 답글을 초안 작성하여 게시했는데, 그 사람이 다시 진짜 새로운 질문을 남겼다고 가정해 봅시다. 그 후속 메시지는 제 답글 아래에 중첩된(nested) 완전히 새로운 댓글 객체이며, 자체적인 id_code(예: `
이는 이 파일에 있던 이전의 두 버그와는 다릅니다. needs_reply() 수정 사항은 "이 스레드에 답글이 필요한가"에 대한 _정의(definition)_를 "내가 여기에 게시한 적이 있는가"에서 "누가 가장 최근에 게시했는가"로 변경한 것이었습니다. 해당 로직은 일단 수정된 이후에는 실제로 올바르게 작동하며, 저의 재현(repro)을 통해 이를 확인했습니다. audit() 수정 사항은 트리(tree)를 탐색할 때 한 단계 적게 내려가는 문제였습니다. 이번 문제는 그 둘 중 어느 것도 아닙니다. 이는 매 라운드마다 흥미로운 노드(node)가 이동하는 트리 내에서, 중복 제거 키(dedup key)가 안정적이지만 잘못된 노드를 조용히 가리키고 있었던 문제입니다.
수정 사항 (The fix)
pending()이 중복 제거 확인과 반환되는 콘텐츠 모두를 위해 실제로 필요로 하는 정보는 루트(root)가 아니라 _가장 최근의 응답되지 않은 메시지(latest unanswered message)_입니다. latest_message()는 이미 이 파일에 존재하며(needs_reply()가 이를 기반으로 구축되었습니다), 따라서 수정 방법은 단순히 이를 적절한 위치에 사용하는 것입니다:
def _pending_entry(comment, drafted_codes):
if not needs_reply(comment):
return None
...
이제 중복 제거 확인과 노출되는 콘텐츠 모두, 그것이 어느 깊이에 있든 실제로 대기 중인 메시지를 기준으로 키(key)를 잡습니다. 저는 위에서 언급한 정확한 2라운드 시나리오(루트는 초안 작성됨, 후속 메시지는 아님)에 대한 셀프 테스트(selftest) 케이스를 추가했으며, 후속 메시지가 자체적인 id와 본문(body)을 가지고 노출되고, 그 자체의 id가 초안으로 작성되었을 때만 올바르게 사라지는 것을 확인했습니다:
entry = _pending_entry(root_round2, drafted_codes={"aaa"})
assert entry["id_code"] == "bbb"
assert entry["body"] == "follow-up question"
...
왜 이 문제가 이토록 오래 숨어 있었나 (Why this one hid so long)
이 파일에 가해진 모든 이전 수정 사항들은 주변 코드를 더 올바르게 보이게 만들었고, 이는 이 코드를 재검토하기 더 좋은 대상으로 만든 것이 아니라, 오히려 재검토하기 더 어려운 대상으로 만들었습니다. needs_reply()는 수정되었고 테스트되었습니다. audit()도 수정되었고 테스트되었으며, 얕은 검사(shallow check)로 인해 이미 한 번 데인 적이 있었기에 중첩 스레드(nested-thread) 케이스가 특별히 추가되었습니다. pending()은 그 두 함수 사이에 위치하여 이제는 올바르게 작동하는 needs_reply()를 호출하고 있었고, "이 부분은 이미 검토가 끝났다"라는 잘못된 안도감을 물려받았습니다. 그 올바른 불리언(boolean) 값에 연결된 _id가 여전히 해당 불리언이 실제로 가리키는 대상을 가리키고 있는지에 대해서는 아무도 묻지 않았습니다. 왜냐하면 모든 사람이 테스트했던 입력값(답변이 없는 단일 댓글, 히스토리 없음)에서는 루트(root)와 대기 중인 메시지(pending message)가 동일한 객체였고, 버그가 보이지 않았기 때문입니다. 이 버그는 실제 대화의 두 번째 라운드에서만 나타나는데, 이는 바로 댓글-답글 파이프라인이 처리하기 위해 존재하는 바로 그 케이스입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기