← 염승준
11

실시간의 경계를 어디에 둘 것인가: 채팅

서버를 거치지 않는 대신, 지켜야 할 계약을 코드에 고정했습니다.

문제
  • 게시글·오픈플레이·파트너 찾기에서 각각 시작되는 1:1 대화가 필요했고, 메시지는 실시간이어야 했음
  • 전부 REST로 두면 폴링이 생기고, 전부 실시간으로 두면 권한·신고·나가기 같은 규칙이 클라이언트로 내려옴
해결
  • 규칙이 필요한 동작(방 생성·읽음·나가기·신고)은 REST, 흐름이 필요한 동작(메시지·방 목록)은 Firestore 실시간 구독으로 갈라 배치
  • 메시지 쓰기는 클라이언트가 Firestore에 직접. 대신 문서 필드를 정확히 5개로 고정하고 시각은 serverTimestamp()만 사용
  • 진입점을 StartChatButton 하나로 모으고, 출처 차이는 ChatOriginRef 유니온 타입이 컴파일 시점에 강제
  • 채팅은 TanStack Query 캐시를 쓰지 않음. 구독이 곧 단일 출처라 캐시를 겹치면 두 개의 진실이 생김
  • 방 목록 쿼리 축을 visibleFor array-contains + updatedAt desc로 고정, 과거 메시지에는 clearedAt 하한 유지
결과
  • 메시지 전송은 백엔드 API를 거치지 않고, 새 출처(파트너 찾기)를 붙일 때 ChatOriginRef에 한 갈래만 추가
  • 보안 규칙 위반이 "조용한 거부"로 나타나는 특성을 문서화해, 같은 함정을 두 번 밟지 않게 함
채팅이 시작되는 곳
게시글 · 오픈플레이 · 파트너 찾기
메시지 전송 경로
Firestore 직접 쓰기 (백엔드 API 미경유)
메시지 문서 필드
정확히 5개 (보안 규칙과의 계약)
채팅 진입점
StartChatButton 1개
새 채팅 출처 추가
ChatOriginRef 유니온 한 갈래

문제

채팅이 필요한 자리가 한 곳이 아니었습니다. 게시글(친구찾기·중고장터)에서는 글쓴이와, 오픈플레이에서는 개설자와 참가자 사이에, 나중에는 파트너 찾기에서 모집자와 신청자 사이에 1:1 대화가 필요했습니다. 출처마다 대화 상대를 정하는 방식도 달랐습니다. 게시글은 서버가 글쓴이를 상대로 정하고, 오픈플레이는 호출하는 쪽이 상대 채널 ID를 넘겨야 합니다.

메시지는 실시간이어야 했지만, 나가기·신고·차단·읽음 처리에는 권한 판단이 들어갑니다. 전부 REST로 두면 메시지를 폴링해야 하고, 전부 실시간 DB에 두면 권한 규칙이 클라이언트 코드로 내려옵니다.

전체 흐름

게시글 PostChatCtaBar · 오픈플레이 참가자 탭
  │ StartChatButton
  ↓
POST /chat/conversations
  │ firestoreId
  ↓
/chat/:firestoreId 대화방  ←  FCM 푸시 (딥링크)
  ├─ 첫 메시지 성공  →  알림 켜기 유도 모달
  └─ Firestore 직접 쓰기 · 실시간 구독
       ↓
     conversations
       │ unread 합계
       ↓
     알림 벨 DM 배지

입구는 여러 곳이지만 방을 만드는 길은 StartChatButton과 방 생성 API 하나로 모입니다. 방이 만들어진 뒤에는 메시지와 방 목록이 Firestore를 통해 오가고, 벨의 DM 배지도 같은 방 문서의 읽지 않은 수를 합산해 그립니다.

판단은 REST, 흐름은 구독

둘 중 하나를 고르지 않고, 서버의 판단이 필요한 동작과 상태가 흘러오기만 하면 되는 동작으로 나눴습니다.

서버 판단이 필요한 동작 (REST)흐름만 있으면 되는 것 (Firestore 실시간 구독)
방 생성 POST /chat/conversations방 목록 conversations/*
읽음 POST .../{id}/read메시지 conversations/*/messages
나가기 DELETE .../{id}, 신고 POST .../{id}/report접속 표시 presence

방을 만들고, 읽음을 내리고, 나가고, 신고하는 일은 되는지 안 되는지를 서버가 정해야 합니다. 반면 새 메시지가 도착하고 방 목록의 순서가 바뀌는 일에는 판단이 없습니다. 흐름 쪽만 구독으로 옮기면 폴링이 사라지고, 판단은 그대로 서버에 남습니다.

서버를 거치지 않는 대가: 조용히 거부되는 쓰기

메시지 전송은 클라이언트가 Firestore에 직접 addDoc 합니다. 서버 왕복이 없어 빠르지만, 그만큼 백엔드가 관리하는 보안 규칙이 유일한 방어선이 됩니다. 이 방어선은 실패할 때 에러 응답 대신 쓰기를 그냥 거부하기 때문에, 화면에서는 "메시지가 안 보내진다"로만 보입니다. 규칙이 허용하지 않는 필드를 하나만 더 넣어도 그렇게 됩니다.

// 메시지 문서 필드는 정확히 이 5개. 하나라도 더 넣으면 permission-denied
{
  senderUid,
  text,
  type,
  clientMsgId,
  createdAt: serverTimestamp(), // 기기 시각을 넣으면 거부된다
}

시각도 규칙이 serverTimestamp()를 요구하기 때문에 기기 시각을 쓸 수 없습니다. 덕분에 사용자 기기의 시계가 틀어져 있어도 메시지 순서는 흔들리지 않습니다.

이 구조의 가장 큰 제약은 규칙과 인덱스가 프론트엔드 저장소 밖에 있다는 점입니다. 프론트에서 타입을 아무리 좁혀도 계약이 맞는지는 실행해 봐야 드러납니다. 그래서 필드 계약을 firestoreChat.ts 한 곳에 모으고, 왜 이 다섯 개인지는 주석이 아니라 문서로 남겼습니다.

출처는 타입으로 강제하기

채팅을 시작하는 버튼은 StartChatButton 하나입니다. origin만 넘기면 로그인 유도, 방 생성 요청, 실패 시 스낵바, /chat/{firestoreId} 이동까지 버튼 안에서 처리합니다. 출처마다 다른 건 대화 상대를 정하는 방식뿐이라, 그 부분만 ChatOriginRef 유니온 타입으로 열었습니다.

// 게시글: 서버가 글쓴이를 상대로 정한다
{ type: 'post', postId }

// 오픈플레이: 상대 채널 ID가 필수다
{ type: 'openPlay', openPlayId, counterpartChannelId }

오픈플레이에서 counterpartChannelId를 빠뜨리면 컴파일 단계에서 막힙니다. 나중에 파트너 찾기를 새 출처로 붙일 때도 이 유니온에 한 갈래를 더하는 것으로 끝났습니다.

구독 위에 캐시를 얹지 않기

이 프로젝트의 다른 GET 요청은 모두 TanStack Query의 queryOptions 패턴을 따르지만, 채팅만 예외로 뒀습니다. Firestore 구독이 이미 현재 상태를 계속 밀어 주는데 그 위에 캐시를 한 겹 더 두면, 같은 데이터의 출처가 둘이 되어 어느 쪽이 맞는지 따져야 하는 상황이 생기기 때문입니다.

구독 결과는 useState로 받고, 서버 판단이 필요한 동작만 useMutation으로 둡니다. 읽지 않은 수는 방 문서의 unread[uid] 값으로, 서버가 올리고 POST .../read가 0으로 내립니다. 상단 벨의 DM 배지는 최근 20개 방의 합계입니다.

목록 조회와 나가기

방 목록은 visibleFor array-contains와 updatedAt desc 조합으로 조회합니다. 참가자 배열(participantUids)로 조회하는 쪽이 더 자연스러워 보이지만, 그 축에는 인덱스가 없어 쿼리가 실패합니다. 그래서 목록의 조회 축은 이 조합 하나로 고정했습니다.

방을 나가면 clearedAt이 기록되고, 과거 메시지를 불러올 때 이 시각을 하한으로 둡니다. 하한이 빠지면 스크롤을 올렸을 때 나가기 전의 대화가 다시 나타납니다. 나가기나 차단 뒤의 이동도 push가 아니라 replace로 처리했습니다. push로 이동하면 뒤로가기가 더 이상 들어갈 수 없는 방으로 되돌아가기 때문입니다.

알림 켜기 유도는 한 곳에서

벨은 알림·광고·채팅 세 탭이고, 확인하지 않은 알림 표시와 DM 개수 배지를 따로 답니다. 푸시는 FCM이며 딥링크로 해당 방까지 바로 엽니다.

알림 켜기 유도는 참가를 마쳤을 때나 첫 메시지를 보냈을 때처럼 여러 지점에서 떠야 했습니다. 트리거마다 기기 권한과 서버 설정을 판정하면 같은 참가인데 경로에 따라 유도가 뜨기도 하고 안 뜨기도 합니다. 그래서 트리거는 모두 notificationNudgeStore.open(domain)만 부르게 하고, 판정과 모달은 _app에 하나만 두었습니다.

남은 제약

관리자(manager) 모드에서는 채팅이 동작하지 않습니다. Firestore 규칙이 로그인한 본인의 uid를 기준으로 하기 때문에, 관리 대상 계정의 방을 읽으려 하면 permission-denied가 납니다. 규칙을 바꿔야 풀리는 문제라 지금은 벨 구독을 아예 하지 않고, 목록과 방은 에러 화면으로 안내합니다. 되는 것처럼 보이다가 실패하는 것보다는 처음부터 안 된다고 보여 주는 쪽을 택했습니다.

채팅 페이지는 비공개 경로라 noindex와 robots.txt의 Disallow를 함께 둡니다. 이때 <Head>는 인증 가드 바깥에 있어야 합니다. 서버 렌더링 시점에는 가드가 자식을 그리지 않으므로, 가드 안에 두면 noindex가 HTML에서 빠집니다.