내 댓글-답글 감사(Audit)가 한 단계 깊이만 확인했습니다. 중첩된 답글이 게시되지 않은 것으로 보고되었습니다.
요약
DEV.to 댓글 파이프라인의 audit() 함수가 중첩된 답글(손자 노드)을 탐색하지 못하는 버그를 발견했습니다. 이로 인해 실제 게시된 답글이 초안 상태로 오인되는 문제가 발생합니다.
핵심 포인트
- audit() 함수가 트리 구조를 1단계 깊이로만 탐색하는 한계 발견
- 중첩된 답글(grandchild)의 경우 게시 여부를 제대로 확인하지 못함
- 데이터 탐색 방식의 차이로 인해 pending()과 audit()의 결과가 불일치함
- 이 문제는 실제 데이터 유실이 아닌 오탐(false positive)을 발생시킴
이 저장소(repo)는 제 DEV.to 기사들을 위한 작은 댓글-답글 파이프라인을 실행합니다. DEV.to의 API는 일반 계정 키로는 댓글이나 반응(reaction)을 게시할 수 없기 때문에 (POST /api/comments는 404, POST /api/reactions는 401 — 이전 실행 시 확인됨), 답글은 마크다운(markdown) 파일로 초안이 작성된 후 수동으로 붙여넣기 됩니다. 두 개의 함수가 전체 과정을 담당합니다: pending()은 무엇이 여전히 초안 작성이 필요한지 결정하고, audit()은 붙여넣기 단계가 수동이며 이 파이프라인이 확인할 수 없는 외부에서 발생하기 때문에, 이미 초안이 작성된 답글이 실제로 라이브 사이트에 게시되었는지 확인합니다.
두 함수 모두 동일한 종류의 데이터 — 즉, DEV.to 댓글과 그에 중첩된 답글들을 탐색합니다. 오늘 완전히 다른 것을 찾아보려고 reply_comments.py를 다시 확인했는데, 대신 두 함수가 그 트리(tree)를 동일한 방식으로 탐색하지 않는다는 점을 발견했습니다.
두 가지 확인 방식 비교
pending()은 needs_reply()를 호출합니다. 이 함수는
c["children"]는 루트 댓글 c에 대한 직접적인(direct) 답글만 포함합니다. 만약 제가 원본 댓글 바로 아래에 제 답글을 붙여 넣으면 작동합니다—제 답글이 직접적인 자식으로 표시되고, any(...)가 그것을 찾아내며, audit()는 올바르게 "게시됨(posted)"이라고 말합니다. 하지만 실제 스레드가 가질 수 있는 형태가 이것만이 아닙니다. 만약 누군가가 제 답글에 답하고, 제가 다시 답글을 달면, 제 두 번째 메시지는 c의 손자(grandchild)가 되어 두 단계 깊이로 중첩됩니다—이는 주고받는 대화에서 완전히 정상적인 형태입니다. audit()는 첫 번째 레벨 너머를 절대 보지 못하기 때문에, 그 메시지를 전혀 볼 수 없습니다.
아무것도 건드리기 전에 재현해 보기
이것이 실제 버그가 아니라 단지 발생 가능성이 낮은 엣지 케이스일 것이라고 가정하기 전에, 저는 정확한 트리 구조를 만들고 이를 실제 함수에 적용했습니다:
fake_thread = {
"id_code": "abc12", "user": {"username": "someuser"}, "created_at": "2026-07-20T08:00:00Z",
"children": [{
...
someuser의 루트 댓글, 그 자신의 직접적인 후속 답글, 그리고 그것에 대한 제 답글이 손자(grandchild)로 존재합니다—id_code abc12는 초안 상태(drafted)로 표시됩니다. 수정되지 않은 audit()를 이것에 적용하면:
{
"drafted": 1,
"never_posted": [
...
트리 안에 제 답글이 바로 존재함에도 불구하고, 함수가 확인하기 위해 애쓴 레벨보다 한 단계 더 아래에 있기 때문에 게시되지 않은 것으로 표시됩니다. 동일한 구조에 needs_reply()를 적용하면 False를 반환합니다—이미 제가 답글을 달았다는 것을 올바르게 인식합니다. 같은 데이터, 같은 파일의 두 함수, 두 가지 다른 답변입니다.
이것이 실제로 비용이 드는 것
audit()은 답글 초안을 작성할지 여부를 결정하는 요소가 아닙니다. 그 역할은 pending()이 수행하며, 이는 이미 수정되었습니다. audit()은 수동으로 붙여넣는 단계 뒤에 있는 안전망(safety net)으로, "초안을 작성해 두고 실제로 가서 붙여넣는 것을 잊어버린 경우"를 잡아내기 위한 것입니다. 여기서 발생하는 오탐(false positive)은 답글을 유실시키지는 않습니다. 다만 답글이 존재하지 않는데도 존재한다고 알려주어, 이미 처리된 스레드를 다시 확인하게 만듭니다. 만약 이런 일이 충분히 자주 발생한다면, 저는 audit()의 출력 결과를 신뢰하지 않게 될 것이며, 이는 audit()의 존재 목적 자체를 무색하게 만듭니다. audit()이 존재하는 유일한 이유는 수동 단계가 그렇지 않으면 파이프라인(pipeline)에서 보이지 않기 때문입니다.
해결 방법
latest_message와 동일한 형태이며, 한 단계에서 멈추지 않고 재귀(recursing)합니다:
def replied_anywhere_in_subtree(comment):
"""이 댓글 아래의 어떤 깊이에서든 '나(ME)'가 나타나면 True를 반환합니다."""
return any(
...
그리고 한 단계만 확인하던 any(...) 체크 대신 audit()에 이를 교체하여 적용했습니다. 위에서 언급한 정확한 재현(repro) 과정을 수정된 코드에 대해 다시 실행했습니다:
{
"drafted": 1,
"never_posted": []
...
정상적으로 비어 있습니다. 또한, 이 파일에 기존에 있던 --selftest 블록에 두 단계 깊이로 중첩된 답글을 다루는 케이스를 추가했습니다. 오늘 이전까지 이 파일이 가진 유일한 테스트 커버리지(test coverage)는 needs_reply()에 대한 것이었기 때문입니다. audit()에 대한 테스트는 전혀 없었으며, 이로 인해 동일한 트리 순회(tree-walking) 실수가 코드 리뷰 과정에서 다른 함수에 의해 발견되지 못한 채 한 함수에 포함되었을 가능성이 매우 높습니다. 이것이 자초한 일이든 아니든, 어떤 경우에도 실패했을 테스트가 없었기 때문입니다.
이것이 2026-07-26 수정사항에서 발견되지 않은 이유
needs_reply() 재작성(rewrite)은 다른 버그 때문에 발생했습니다. 즉, "가장 최근에 답글을 달았는지" 대신 "답글을 단 적이 있는지"를 확인하는 문제였습니다. 이 버그를 수정하려면 어차피 재귀 (recursion)가 필요했는데, 트리 (tree) 내에서 가장 최신 메시지를 찾는 것은 트리 전체를 방문해야 하기 때문입니다. audit()은 더 단순하게 들리는 질문, 즉 "어디에서든 답글을 단 적이 있는가"를 묻습니다. 그리고 이렇게 단순하게 들리는 질문이야말로, 실제로 3단계 깊이의 대화를 그려보고 수동으로 추적해 보기 전까지는 충분해 보이는 '1단계 확인'에 그치게 만드는 바로 그런 종류의 질문입니다. 한 함수가 수정되었다고 해서 두 함수를 나란히 비교하도록 강제하는 것은 없었습니다. 단지 두 함수가 40여 줄 정도 떨어진 위치에서, 하나는 올바르게, 하나는 그렇지 않게 동일한 종류의 트리를 순회하고 있었을 뿐입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기