요약
DELETE /u/<username>.json 요청은 성공하고 users 테이블의 해당 행이 삭제되지만, 삭제된 사용자의 ID가 여전히 user_id 컬럼에 남아 있는 여러 테이블의 행들이 존재합니다. 이 테이블들에는 외래 키, dependent: 연관 관계, 정리 작업(cleanup job), 또는 문서화된 보존 이유가 없습니다.
그중 하나는 파괴자(destroyer)의 명시된 의도를 놓친 단순한 실수입니다: UserDestroyer#delete_posts는 topics.user_id를 null로 설정하도록 작성되어 있지만, 기본 스코프가 삭제된(trashed) 게시글을 제외하는 user.posts를 반복합니다. 따라서 첫 번째 게시글이 이미 삭제된 토픽은 여전히 삭제된 사용자를 가리키게 됩니다.
또 다른 두 개, incoming_links와 search_logs는 Discourse의 자체 익명화 경로가 사용자 연결 개인 데이터로 취급하는 테이블들입니다(Jobs::AnonymizeUser#anonymize_ips). UserMerger가 명시적으로 재지정(re-point)하는 대상이지만, UserDestroyer는 전혀 다루지 않습니다.
v2026.7.1에서 재현했으며, 기본 설정과 게시글이 없는 계정으로 테스트했습니다. 2026-09-25 기준 main 브랜치에서 UserDestroyer#delete_posts는 변경되지 않았습니다.
버전
Discourse v2026.7.1 (자체 호스팅, 로컬 인증 테스트 인스턴스). 대부분 코어 기능이며, 하나의 플러그인 행은 아래에 별도로 나열되어 있습니다.
재현 단계
최소 재현 케이스에는 사이트 설정 변경이나 게시글이 필요하지 않으므로, 기본 delete user self max post count 설정에서도 동작합니다:
-
일반 테스트 계정을 등록합니다.
-
해당 계정으로 로그인한 상태에서 검색을 수행합니다:
GET /search.json?q=<unique marker>. -
로그인한 상태로 외부 리퍼러에서 다른 사용자의 공유 파라미터와 함께 특정 토픽의 HTML 페이지를 방문합니다:
Referer: https://example.invalid/x헤더와 함께GET /t/<slug>/<topic_id>?u=<other-username>. -
로그아웃된 브라우저에서 테스트 계정의 공유 파라미터와 함께 외부
Referer를 사용하여 아무 토픽이나 방문합니다:GET /t/<slug>/<topic_id>?u=<test-account>. -
테스트 계정으로 계정을 삭제합니다:
context=/my/preferences/account와 함께DELETE /u/<test-account>.json.{"success":"OK"}가 반환되고users행이 사라집니다. -
쿼리 실행:
SELECT id, user_id, current_user_id, ip_address, post_id FROM incoming_links WHERE user_id = <id> OR current_user_id = <id>; SELECT id, user_id, term FROM search_logs WHERE user_id = <id>;
첨부된 poc.py는 HTTP를 통해 단계 1~5를 수행하고 단계 6의 정확한 SQL을 출력합니다.
기대 결과
사용자가 자신의 계정을 삭제한 후, 해당 사용자를 식별하는 행은 UserDestroyer가 이미 posts.user_id (null 처리), categories.user_id (system으로 재지정), topics.user_id (null 처리 예정)에 대해 수행하듯 삭제, null 처리, 또는 재할당되어야 합니다.
실제 결과
아래 내용은 DELETE /u/<username>.json이 200을 반환하고 SELECT count(*) FROM users WHERE id = <id>이 0을 반환한 후, 한 번의 실행에서 측정된 것입니다.
| 테이블 / 컬럼 | 남은 행 수 | 행에 포함된 내용 | 비고 |
|—|—|—|—|
| incoming_links.user_id | 1 | 삭제된 사용자의 ID + 방문자의 ip_address + post_id | 삭제된 사용자에게 귀속된 공유 링크 클릭 |
| incoming_links.current_user_id | 1 | 삭제된 사용자의 ID + post_id + 리퍼러 | 삭제된 사용자의 자체 클릭 기록 |
| search_logs.user_id | 1 | 삭제된 사용자의 ID + 입력한 검색어 | search query log max retention days (기본값 365일) 동안 유지 |
| topics.user_id | 1 | 삭제된 사용자의 ID | 첫 번째 게시글이 이미 삭제된(trashed) 토픽에 한함 — 아래 참조 |
| post_revisions.user_id | 2 | 삭제된 사용자의 ID + modifications['raw'] (즉, 이전 게시글 본문) | |
| custom_emojis.user_id | 1 | 삭제된 사용자의 ID | 사이트 전체 이모지의 업로더 귀속 |
| topic_localizations.localizer_user_id | 1 | 삭제된 사용자의 ID | |
| policy_users.user_id | 1 | 삭제된 사용자의 ID + accepted_at | discourse-policy 플러그인 |
이후에 포함된 정리 작업(cleanup jobs)을 실행해도 아무것도 변하지 않습니다: Jobs::UpdateScoresForToday, Jobs::CleanUpUnusedRegisteredUserApiKeyClients, PostDestroyer.destroy_stubs 실행 후 모든 행을 다시 읽어보았으나 여전히 존재했습니다.
대조적으로, 같은 실행에서 정상적으로 동작한 부분도 있습니다: users, user_profiles, email_tokens 행은 사라졌고, 해당 계정을 대상으로 한 chat_mentions 행은 삭제되었으며, posts.user_id와 대부분의 topics.user_id는 null 처리되었습니다. 따라서 이는 삭제가 아무것도 하지 않는다는 주장이 아니라, 구체적인 누락 사항 목록입니다.
해당 행들이 생성된 방식: 위의 단계 1~5는 일반 브라우징을 통해 incoming_links와 search_logs 행을 생성합니다. topics.user_id, post_revisions.user_id, policy_users 행은 일반 게시글 작성, 토픽 자체 삭제, 정책 수락에서 비롯됩니다. custom_emojis와 topic_localizations 행은 테스트 픽스처에 직접 시드되었습니다. 이는 커스텀 이모지 업로드와 토픽 로컬라이제이션 작성은 일반 계정이 수행하는 것이 아니라 관리자/플러그인 경로이기 때문입니다. 이들에 대해 측정된 삭제 동작은 그 외에는 동일합니다.
topics.user_id 케이스는 구체적인 스코프 오프바이원(off-by-scope) 버그입니다
UserDestroyer#delete_posts는 다음과 같이 작성되어 있습니다:
user.posts.find_each do |post|
...
if post.topic && post.is_first_post?
Topic.unscoped.where(id: post.topic_id).update_all(user_id: nil)
end
end
user.posts는 기본 스코프를 사용하며, 이는 삭제된(trashed) 게시글을 제외합니다. 사용자가 자신의 토픽 중 하나를 이미 삭제했다면 — 스텁은 나중에 PostDestroyer.destroy_stubs에 의해 삭제됨 — 그 게시글은 반복되지 않으므로 해당 토픽에 대한 null 처리가 실행되지 않습니다. 제 실행에서 대상 사용자의 세 토픽 중 두 개는 user_id IS NULL로 종료되었고, 계정 삭제 전에 첫 번째 게시글이 삭제된(trashed) 세 번째 토픽은 user_id = <deleted id>를 유지했습니다.
몇 줄 아래에 있는 Post.unscoped.where(user_id: result.id).update_all(user_id: nil)은 unscoped를 사용하므로 게시글 자체는 올바르게 분리됩니다 — 누락된 것은 토픽뿐입니다.
incoming_links와 search_logs가 두드러지는 이유
이것들은 단순히 고아된 ID가 아닙니다. Discourse는 이미 다른 곳에서 이들을 사용자 연결 개인 데이터로 분류하고 있습니다:
-
app/jobs/regular/anonymize_user.rb:43—IncomingLink.where(current_user_id: …).update_all(ip_address: new_ip),SearchLog,TopicLinkClick,TopicViewItem,UserProfileView와 함께. -
app/services/user_merger.rb:348—IncomingLink.where(user_id: …)와.where(current_user_id: …)모두 대상 사용자로 재지정됩니다.
사용자 익명화와 사용자 병합은 모두 이 테이블들을 처리합니다. 사용자 삭제는 그렇지 않습니다.
영향
무단 접근은 없습니다: 이 행들 중 어느 것도 익명 사용자나 일반 사용자에게 제공되지 않으며, 저는 그렇지 않다고 주장하지 않습니다. 영향은 데이터 보존 및 참조 무결성에 있습니다 — 사용자가 자체 서비스 삭제를 실행한 후에도 사이트는 여전히 그들에게 키된 행들을 보유하고 있으며, 여기에는 그들이 검색한 내용과 어떤 링크를 따랐는지가 포함됩니다. 삭제와 연관된 만료 기한도, 이를 정리할 운영자 도구도 없습니다.
이는 삭제 요청을 처리하는 모든 사이트에게 어색한 상황이며, 삭제 경로가 posts, categories, topics에 대해 자체적으로 취하는 조치와 일관성이 없습니다.
제안된 수정 사항
-
UserDestroyer#delete_posts에서user.posts.with_deleted를 반복하거나(또는 별도의Topic.unscoped.where(user_id: user.id).update_all(user_id: nil)패스로 토픽을 null 처리) 이미 삭제된(trashed) 첫 번째 게시글이 토픽의 귀속을 유지하지 않도록 합니다. -
Jobs::AnonymizeUser가 이미 나열하듯 사용자 연결 분석 테이블을 삭제 경로에 추가합니다:IncomingLink.where(user_id:),IncomingLink.where(current_user_id:),SearchLog.where(user_id:)를 삭제하거나 null 처리합니다. -
posts.user_id와 일관되게 나머지 귀속 컬럼 —post_revisions.user_id,custom_emojis.user_id,topic_localizations.localizer_user_id— 을 null 처리합니다. -
policy_users는discourse-policy에 속합니다.user_destroyer_on_content_deletion_callbacks를 훅하거나dependent: :destroy를 추가할 수 있습니다. 원하신다면 플러그인 저장소에서 별도로 이슈를 열겠습니다.
회귀 스펙(regression spec)은 UserDestroyer#destroy 후 이 집합의 어떤 행에도 삭제된 사용자의 ID가 남아 있지 않음을 단언(assert)할 수 있습니다.