Skip to Content
DM (1:1 다이렉트 메시지)

DM (1:1 다이렉트 메시지)

얼마집 앱의 1:1 다이렉트 메시지 기능입니다. 채팅방에서 상대 프로필을 통해 1:1 대화를 시작하고, 실시간 송수신·안읽음·읽음 표시·푸시 알림을 제공합니다.

관련 작업: HMH-7252 (PR #4890). 백엔드 푸시 API: koreanproptech/howmuchhome#4121 (HMH-7251).

핵심 결정: 왜 Sendbird가 아니라 Supabase인가

카페/커뮤니티 채팅은 Sendbird를 쓰지만, DM은 Supabase Realtime으로 구현합니다.

  • Sendbird 과금은 MAU(월 연결 유저 수) + PCC(최대 동시접속) 기반입니다. 채널 수·채널 타입(1:1 DM = distinct=true 그룹채널)으로는 추가 과금이 없습니다.
  • 그러나 DM은 안읽음 뱃지·실시간 수신을 위해 상시 연결을 요구하는 경향이 있어, 현재 의도적으로 MAU를 억제 중인 구조(ChatConnectProviderautoConnect=false + shouldInitialConnectSendbirdChat 화이트리스트)를 깨고 비용을 키울 위험이 있습니다.
  • 이미 풀스택으로 구현돼 있던 Supabase DM을 재사용하면 MAU 과금 모델에서 벗어나 비용 리스크가 낮습니다.

푸시도 Sendbird를 경유하지 않습니다. DM 메시지를 Sendbird 채널로 복제하면 Sendbird 결합·MAU가 부활하므로, 푸시는 메인 백엔드의 유저 푸시 API를 직접 호출합니다(아래 “푸시 알림”).

데이터 모델 (Supabase)

community-v2 전용 Supabase 프로젝트(tax-chat과 별도)에 저장됩니다. 인증은 기존 시스템 세션 기반으로 백엔드가 발급한 커스텀 JWT의 app_user_id claim으로 RLS를 통제합니다.

마이그레이션: supabase/migrations/000{2,3,4,6,7}_community_v2_dm*.sql

테이블역할
cv2_dm_thread1:1 쓰레드. user_low_id < user_high_id 정렬쌍 unique로 (a,b)/(b,a) 중복 방지. 닉네임/이미지 스냅샷 컬럼 보유(0004)
cv2_dm_message메시지(텍스트 only, 1~4000자). insert 시 트리거가 thread.last_message_at 갱신
cv2_dm_read유저별 마지막 읽은 시각(last_read_at). 안읽음 카운트 + 읽음 표시 기준

RPC: cv2_dm_my_threads()(내 쓰레드 + 닉네임/이미지 스냅샷 + unread_count 일괄), cv2_dm_thread_unread(thread_uuid).

0003 의 cv2_dm_my_threads() 는 0004 에서 추가된 닉네임/이미지 스냅샷 컬럼을 RETURNS TABLE 에 포함하지 않아 목록에서 상대 이름이 ‘사용자’로만 보였다(컬럼 누락 → 클라이언트로 미전달). 0007 에서 RPC 를 재생성해 해결.

RLS 요점

  • thread/message: 참여자만 select/insert. message insert는 sender_id = app_user_id 강제. thread는 update/delete 정책 없음(트리거만 수정).
  • read: 기본은 본인 행만(cv2_dm_read_all). 0006에서 “스레드 참여자는 상대 행도 SELECT” permissive 정책(cv2_dm_read_select_participants)을 추가 → 읽음 표시용. 쓰기는 여전히 본인만.
  • Realtime publication: cv2_dm_message, cv2_dm_thread(0002), cv2_dm_read(0006).

클라이언트 구조

데이터 액세스·Supabase 클라이언트는 아직 features/community-v2/에 있습니다(community-v2 게시판 폐기 작업 시 features/dm/로 추출 예정).

역할경로
데이터 액세스apps/app/src/features/community-v2/lib/dm.ts (openOrCreateThread, listMessages, sendMessage, markThreadAsRead, getPeerLastReadAt, getPeerId 등)
Supabase 클라이언트 + 토큰 갱신/모바일 라이프사이클apps/app/src/features/community-v2/lib/supabase.ts (useCommunityV2Supabase)
커스텀 JWT 서명apps/app/src/features/community-v2/lib/signSupabaseJWT.ts
JWT 발급 APIapps/app/src/app/api/community-v2/supabase-token/route.ts
DM 페이지apps/app/src/app/(certified-user-only)/dm/ (page.tsx, [threadId]/page.tsx, _sections/*)
안읽음 뱃지apps/app/src/features/community-v2/dm/_components/DmEntryButton.tsx + _hooks/useDmTotalUnread.ts
프로필 진입(메시지 보내기)apps/app/src/features/community-v2/profile/{ChatroomProfileSheet,ProfileDetailBody}.tsx

라우트(apps/app/src/routes/index.ts): dmList(/dm), dmThread(/dm/[threadId]).

useCommunityV2Supabase는 토큰 TTL 만료 전 Realtime auth 갱신(50분), 모바일 webview 라이프사이클(visibilitychange/online 시 재구독), CHANNEL_ERROR 시 exponential backoff 재구독을 처리합니다.

진입점과 노출 게이팅

DM 진입점은 현재 라이브 채팅 안에 있습니다.

  1. 프로필 “메시지 보내기” — 채팅 메시지의 아바타 탭 → ChatroomProfileSheetProfileDetailBody의 버튼 → openOrCreateThread/dm/[threadId].
  2. 채팅 헤더 DM함 진입CommunityHeader의 우측 메뉴에 DmEntryButton(안읽음 뱃지 포함). 채팅 탭에서만 노출(카페 탭 제외).

게이팅: NEXT_PUBLIC_CHAT_MODERATION_ENABLED_CHAT_URLS

DM은 모더레이션이 활성화된 채팅방(chatUrl)에서만 노출됩니다(별도 플래그/flagsmith 미사용).

  • 게이트 함수: packages/chat-react/hooks/useChatModerationEnabled.ts
    • useChatModerationEnabled()ChatUrlProvider 컨텍스트 기반(헤더 등)
    • isChatUrlModerationEnabled(chatUrl) — chatUrl을 prop으로 가진 컴포넌트용 순수 함수(ProfileDetailBody)
  • 이 env(쉼표 구분 chatUrl 목록)에 든 방에서만 DM 진입 버튼이 보입니다. 이 목록 확대가 곧 롤아웃 스위치입니다.

DM 페이지 자체는 (certified-user-only) 그룹에 있어 인증 유저면 접근 가능하고, 데이터는 RLS로 보호됩니다(자기 쓰레드만 조회). 즉 노출 제어는 “진입점”에서 하고, 데이터 보안은 RLS가 담당합니다.

과거에는 DM 페이지가 (community-v2-internal) 그룹의 ADMIN 전용 게이트 안에 있어 실사용되지 못했습니다. HMH-7252에서 (certified-user-only)/dm으로 이동하며 게이트를 제거했습니다.

읽음 표시 (상대방 읽음)

  • 상대가 읽은 마지막 시각(cv2_dm_read.last_read_at)을 조회·구독해, 내가 보낸 메시지 중 그 시각 이전 것에 “읽음”을 표시합니다(iMessage 스타일 — 가장 최근 읽은 메시지에만 표기). 렌더: MessageList.tsx.
  • ThreadView는 진입 시 getPeerLastReadAt로 초기 조회 + cv2_dm_read Realtime(event:'*', 상대 행만) 구독으로 갱신합니다.
  • 단조(monotonic) 갱신: 초기 fetch와 realtime이 경쟁해 더 오래된 값으로 되돌아가지 않도록, 두 경로 모두 “더 최신일 때만” peerLastReadAt을 갱신합니다(읽음 표시 깜빡임 방지).
  • RLS상 상대 read 행을 읽으려면 마이그레이션 0006이 적용돼 있어야 합니다. 미적용 시 읽음 표시는 조용히 비활성(크래시 없음).

푸시 알림

DM 새 메시지 도착을 수신자에게 푸시합니다. 클라이언트가 메시지 전송 성공 후 백엔드 푸시 API를 직접 호출하는 방식입니다(DB 트리거/Vault 미사용).

  • 프론트 엔드포인트: packages/api/lib/messaging/MessagingAPI.sendPush (@POST('v1/messaging/push/send')).
  • 호출 위치: ThreadView.handleSendsendMessage 성공 직후 fire-and-forget(실패해도 메시지 전송 UX에 영향 없음).
  • 요청 body(프론트 camelCase → fetcher가 snake_case 자동 변환):
// 프론트가 만드는 값 { "userId": <peer id>, "title": "<내 닉네임>", "body": "<메시지 미리보기(50자)>", "data": { "type": "DM", "threadId": "<uuid>", "senderId": <내 id> } } // 실제 전송(snake) { "user_id": ..., "title": ..., "body": ..., "data": { "type": "DM", "thread_id": ..., "sender_id": ... } }
  • 인증: 기존 fetcher가 처리(표준 유저 API 호출과 동일).
  • 백엔드: POST /v1/messaging/push/send (koreanproptech/howmuchhome#4121, HMH-7251). AppSendPushReq { user_id, title, body, data? }, Depends(authenticated_user_id). 내부적으로 SendPushMessageto_all_user=False로 호출해 단일 유저에게만 발송(전체 발송 악용 차단).
  • 딥링크: 푸시 탭 시 data.type:"DM" + data.thread_id로 네이티브가 /dm/[threadId]로 라우팅 — 네이티브 키 규격은 협의 후 확정.

환경변수

레포에 .env.example은 없으며 로컬 .env.local + 배포 환경에서 관리합니다.

변수용도
NEXT_PUBLIC_COMMUNITY_V2_SUPABASE_URLDM Supabase URL
NEXT_PUBLIC_COMMUNITY_V2_SUPABASE_ANON_KEYDM Supabase anon key
COMMUNITY_V2_SUPABASE_JWT_SECRET커스텀 JWT 서명(서버)
NEXT_PUBLIC_CHAT_MODERATION_ENABLED_CHAT_URLSDM/모더레이션 노출 chatUrl 화이트리스트(쉼표 구분) = 롤아웃 스위치

배포 체크리스트

  1. 마이그레이션 0006·0007을 프로덕션 Supabase(community-v2)에 적용. (CI 자동화 없음 → 수동) — 0007 미적용 시 DM 목록의 상대 이름이 ‘사용자’로만 표시됨.
  2. 백엔드 #4121 머지·배포.
  3. 네이티브 딥링크(data 키 규격) 연동.
  4. NEXT_PUBLIC_CHAT_MODERATION_ENABLED_CHAT_URLS에 DM 오픈할 chatUrl 추가 → 점진 롤아웃.
  5. QA: 송수신·안읽음·읽음 표시·푸시(백그라운드)·게이팅.

미적용 단계에서도 송수신은 정상 동작하며, 읽음 표시(0006 미적용)·푸시(#4121 미배포)는 graceful하게 비활성됩니다.

미구현 / 후속

  • 첨부파일, 메시지 삭제·수정, 차단·신고, 타이핑 표시
  • DM 데이터/UI를 features/community-v2/features/dm/로 추출 (community-v2 게시판 폐기와 함께)
  • 게이팅을 env 화이트리스트 → API 기반 플래그(capturePreventionEnabled 방식)로 전환
Last updated on