저희 사이트는 전체 채팅 히스토리를 저장하고 있으며, 이는 오랜 기간에 걸쳐 축적되어 왔습니다. 최근에는 채팅 버튼이 로딩되는 데 걸리는 시간이 길거나 아예 표시되지 않는다는 사용자 불만이 이어졌습니다. 또한 사용자 간에 동작이 일관되지 않은 것처럼 보였습니다.
Claude Code의 도움으로 문제를 찾았으며, 이는 모든 사이트에 영향을 미칠 수 있습니다. 아래는 해당 문제에 대해 정리한 보고서입니다. 문제가 되는 쿼리가 매우 자주 트리거되므로 사이트 전체의 부하에 영향을 미칠 수 있습니다.
요약하자면, 채팅에서 답글을 작성하면 추적 이벤트가 생성되며 시간이 지남에 따라(스레딩이 꺼져 있더라도) 부풀어 오르는 것 같습니다. 추적된 메시지의 업데이트를 확인하는 쿼리가 많은 사용자에게 느리거나 타임아웃되고 있습니다.
아래는 AI가 생성한 보고서로, 제가 약간 수정한 것입니다.
문제
활발한 사용자들이 채팅이 계속 로딩 중 상태(spinner)로 남아 영원히 로드되지 않는다고 보고했습니다. 관리자 로그에서 Pitchfork 워커 타임아웃이 백트레이스와 함께 덤프되는 것을 확인했습니다:
Pitchfork worker is about to timeout, dumping backtrace for main thread
...
lib/mini_sql/postgres/connection.rb:... MiniSql::Postgres::Connection#query
plugins/chat/app/queries/chat/thread_unreads_query.rb:132 Chat::ThreadUnreadsQuery.call
plugins/chat/app/queries/chat/tracking_state_report_query.rb:71 Chat::TrackingStateReportQuery.call
plugins/chat/app/services/chat/list_user_channels...
웹 워커는 단일 SQL 쿼리(ThreadUnreadsQuery)가 워커 타임아웃 내에 반환되지 않아 종료되었습니다. 동일한 백트레이스가 하루 동안 수백 번 반복되었습니다.
클라이언트 측에서는 /chat/api/me/channels의 500 오류로 나타납니다:
/chat/api/me/channels Failed to load resource: the server responded with a status of 500
이는 동일한 Pitchfork 타임아웃의 브라우저 측 증상입니다. ThreadUnreadsQuery를 트리거하는 엔드포인트가 완료되지 않고 워커가 재사용되며, UI는 채팅을 렌더링하는 데 필요한 채널 목록을 받지 못합니다.
평이한 언어로 설명한 원인
사용자가 특정 채팅 메시지에 "답글"을 클릭할 때마다 Discourse는 해당 답글을 담기 위해 스레드(thread)를 생성(또는 재사용)합니다. 채널에서 스레딩이 비활성화되어 있더라도 마찬가지입니다. 그룹 DM의 경우, 모든 DM 참여자가 새로운 스레드의 멤버십에 자동 추가됩니다. 시간이 지남에 따라 모든 활성 사용자는 의도적으로 추적을 선택하지 않았음에도 user_chat_thread_memberships에 수천 개의 멤버십 행이 축적됩니다.
해당 사용자 중 누구든 채팅을 열면, 핵심 추적 쿼리가 해당 사용자의 모든 추적 대상 스레드를 순회하며 스레드당 세 개의 상관 서브쿼리를 실행합니다. 수천 개의 멤버십을 가진 사용자는 쿼리가 Postgres와 Pitchfork가 대기할 수 있는 한계를 초과하게 만들어, 채팅 로딩이 완료되기 전에 워커가 종료됩니다.
청소 메커니즘이 없습니다: 멤버십은 절대 정리되지 않으며, 현재 플러그인 코드에서는 자동 생성된 행에 notification_level = muted/normal을 설정하는 로직이 없습니다.
데이터
조사 시점의 프로덕션 DB 기준:
- 180일 이상 활동이 없는 스레드에 대한 오래된 추적 레벨 멤버십(
notification_level = 2): 29,309개 - 15일 기준 동일한 규칙: 41,139개
- 1,500개 이상의 스레드 멤버십을 가진 사용자: 11명
- 최상위 개별 사용자: 3,738개 멤버십 —
ThreadUnreadsQuery의MAX_THREADS = 3000상한선을 초과 - 이 11명의 헤비 유저 중 실제로 100%의 멤버십이
notification_level = 2(자동 추적)였으며, 사용자가 선택한 “관심”(레벨 3)은 하나도 없었습니다.
최상위 사용자의 추적 멤버십
영향을 받은 채널
두 가지 명확한 경로가 모두 동일한 결과를 초래했습니다:
-
그룹 DM (
chatable_type = DirectMessage,threading_enabled = false)
그룹 DM에서 답글을 작성할 때마다 스레드가 생성되고 모든 DM 참여자가 자동 추가됩니다. 이는create_message.rb에 명시되어 있습니다(아래 코드 참조). -
threading_enabled = false인 카테고리/공개 채널
threading_enabled플래그가 현재false이고 1년 이상 그렇게 되어 있는 카테고리 채널에서 수천 개의 스레드를 발견했습니다. 그럼에도 불구하고 스레드가 여전히 생성되고 있었습니다(쿼리 실행 후 몇 분 내의 새로운 스레드 ID).create_message.rb의fetch_thread경로는 채널의 스레딩 설정을 확인하지 않고 답글에 대해 조용히 스레드를 생성합니다.
따라서 "스레딩 끄기"는 효과적이지 않습니다: 이 설정은 전용 스레드 UI를 숨기지만, 사용자가 "메시지에 답글"을 사용할 때 백엔드에서 스레드가 생성되는 것을 막지는 못합니다.
관련 코드
스레드를 조용히 생성하는 답글 경로 — plugins/chat/app/services/chat/create_message.rb:131
def fetch_thread(params:, reply:, channel:, options:)
return Chat::Thread.find_by(id: params.thread_id) if params.thread_id.present?
reply.thread ||
reply.build_thread(
...
force: options.force_thread,
...
)
end
channel.threading_enabled?에 대한 확인이 없습니다. 비-DM 카테고리 채널에서는 조건에 관계없이 스레드가 생성됩니다.
모든 참여자의 DM 자동 등록 — 같은 파일, 약 203행
if channel.direct_message_channel? && !channel.threading_enabled
# Add all DM participants to threads so they have memberships
# for unread tracking and mark-as-read functionality
channel.chatable.users.each { |user| thread.add(user) }
thread.membership_for(guardian.user).update!(last_read_message: message_instance)
그룹 DM에서의 모든 답글은 DM 크기에 따라 멤버십을 확장하며, 이러한 행은 절대 정리되지 않습니다.
병목 구간 쿼리 — plugins/chat/app/queries/chat/thread_unreads_query.rb:14-54
class ThreadUnreadsQuery
MAX_THREADS = 3000
...
# user_chat_thread_memberships의 각 행에 대해 세 개의 상관 서브쿼리:
# SELECT (SELECT COUNT(*) ... unread) AS unread_count,
# (SELECT COUNT(*) ... mentions) AS mention_count,
# (SELECT COUNT(*) ... watched) AS watched_threads_unread_count,
# chat_threads.channel_id, memberships.thread_id
# FROM user_chat_thread_memberships AS memberships
# ...
# WHERE memberships.user_id = :user_id
# ...
# LIMIT :limit
N개의 멤버십이 있으면 3 × N개의 상관 서브쿼리 실행이 발생하며, 각 실행은 5개 이상의 테이블을 조인합니다. 사용자가 수천 개의 멤버십을 초과하면 쿼리는 Pitchfork 타임아웃을 안정적으로 초과합니다. MAX_THREADS = 3000은 이미 확장성 문제에 대한 인정을 나타내지만, 사용자는 이를 초과할 수 있고 실제로도 초과하고 있습니다.
user_chat_thread_memberships (user_id, thread_id) UNIQUE 및 (thread_id, user_id)에 대한 인덱스가 존재하므로, 이는 인덱스 누락 문제가 아닙니다.
임시 해결책
즉각적인 완화 조치로, 지난 15일 동안 활동이 없었던 스레드에 대한 추적 레벨 멤버십을 삭제했습니다:
DELETE FROM user_chat_thread_memberships uctm
USING chat_threads t
WHERE t.id = uctm.thread_id
AND uctm.notification_level = 2
AND NOT EXISTS (
SELECT 1 FROM chat_messages m
WHERE m.thread_id = t.id
AND m.created_at > NOW() - INTERVAL '15 days'
)
정리 후, 해당 최상위 사용자의 멤버십이 3,738개에서 77개로 감소했으며 채팅이 즉시 로드되었습니다.
이는 안전합니다: 해당 행은 사용자의 명시적인 선택(모두 자동 추적, 레벨 2)에 해당하지 않으며, 사용자가 정리된 스레드와 다시 상호작용하면 다음 답글/읽음 시 새로운 멤버십이 자동 생성됩니다.
헤비 유저의 멤버십은 1,500~3,700개에서 150개 미만으로 감소했으며, 채팅이 다시 정상적으로 로드됩니다. 우리는 근본적인 동작이 업스트림에서 변경될 때까지 이를 주기적 작업으로 예약할 계획입니다.
수정 방향 제안
- 자동 추적 멤버십을 적절한 시간 범위(예: 예약 작업을 통해 N일 이상 활동이 없는 스레드의
notification_level = 2행 삭제)로 제한하거나 정리합니다. - 그룹 DM에서 "모든 DM 참여자를 모든 스레드에 자동 추가"하는 동작이 참여자당 스레드당 완전한
user_chat_thread_memberships행보다 더 저렴한 기록 메커니즘을 사용할 수 있는지 고려합니다.

