OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 83 / 90
stack-analysis
CHAPTER 83 / 90
읽기 약 2
FUNCTION

실시간 채팅: WebSocket + 메시지 저장


핵심 개념

Socket.io·메시지 큐·읽음 처리·typing — 카카오톡·디스코드 패턴.

본문

데이터 모델

SQL📋 코드 (32줄)
CREATE TABLE conversations (
  id UUID PRIMARY KEY,
  type VARCHAR NOT NULL,  -- 'direct', 'group'
  name VARCHAR,
  last_message_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE conversation_members (
  conversation_id UUID,
  user_id UUID,
  role VARCHAR DEFAULT 'member',  -- owner, admin, member
  joined_at TIMESTAMP DEFAULT NOW(),
  last_read_at TIMESTAMP,        -- 읽음 처리
  notification_enabled BOOLEAN DEFAULT true,
  PRIMARY KEY (conversation_id, user_id)
);

CREATE TABLE messages (
  id UUID PRIMARY KEY,
  conversation_id UUID NOT NULL,
  sender_id UUID NOT NULL,
  content TEXT,
  type VARCHAR DEFAULT 'text',  -- text, image, video, file, system
  reply_to_id UUID REFERENCES messages(id),
  attachments JSONB,
  edited_at TIMESTAMP,
  deleted_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX ON messages(conversation_id, created_at DESC);

Socket.io 서버

TYPESCRIPT📋 코드 (91줄)
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';

const io = new Server(httpServer, {
  cors: { origin: FRONTEND_URL },
});

io.adapter(createAdapter(pubClient, subClient));

io.use(authMiddleware);

io.on('connection', (socket) => {
  const userId = socket.data.userId;

  // 사용자가 속한 모든 대화방 자동 join
  socket.on('init', async () => {
    const memberships = await db.conversationMember.findMany({
      where: { userId },
      select: { conversationId: true },
    });
    for (const m of memberships) {
      socket.join(`conv:${m.conversationId}`);
    }
    socket.join(`user:${userId}`);

    // 온라인 상태
    await redis.sadd('online_users', userId);
    socket.broadcast.emit('user:online', userId);
  });


  // 메시지 전송
  socket.on('message:send', async (data, ack) => {
    const { conversationId, content, replyToId } = data;

    // 권한 검증
    const member = await db.conversationMember.findFirst({
      where: { conversationId, userId },
    });
    if (!member) return ack({ error: 'not_a_member' });

    const message = await db.message.create({
      data: {
        conversationId,
        senderId: userId,
        content,
        replyToId,
      },
      include: { sender: { select: { id: true, name: true, avatarUrl: true } } },
    });

    // 대화방의 last_message_at 갱신
    await db.conversation.update({
      where: { id: conversationId },
      data: { lastMessageAt: message.createdAt },
    });

    // 같은 대화방 모두에게 브로드캐스트
    io.to(`conv:${conversationId}`).emit('message:new', message);

    // 오프라인 멤버에게 푸시
    await sendOfflinePush(conversationId, message, userId);

    ack({ ok: true, message });
  });


  // 타이핑 표시
  socket.on('typing:start', ({ conversationId }) => {
    socket.to(`conv:${conversationId}`).emit('typing:start', { userId });
  });
  socket.on('typing:stop', ({ conversationId }) => {
    socket.to(`conv:${conversationId}`).emit('typing:stop', { userId });
  });


  // 읽음 처리
  socket.on('message:read', async ({ conversationId }) => {
    await db.conversationMember.update({
      where: { conversationId_userId: { conversationId, userId } },
      data: { lastReadAt: new Date() },
    });
    socket.to(`conv:${conversationId}`).emit('message:read', { userId, conversationId });
  });


  socket.on('disconnect', async () => {
    await redis.srem('online_users', userId);
    socket.broadcast.emit('user:offline', userId);
  });
});

클라이언트 (React)

TSX📋 코드 (61줄)
'use client';
import { useEffect, useState } from 'react';
import { io as ioClient } from 'socket.io-client';

const socket = ioClient(API_URL, {
  auth: { token: getAccessToken() },
});


export function ChatRoom({ conversationId }: { conversationId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [typing, setTyping] = useState<string[]>([]);

  useEffect(() => {
    // 초기 메시지 로드
    fetch(`/api/conversations/${conversationId}/messages?limit=50`)
      .then(r => r.json())
      .then(setMessages);

    // 새 메시지 수신
    socket.on('message:new', (msg) => {
      if (msg.conversationId === conversationId) {
        setMessages(prev => [...prev, msg]);
      }
    });

    // 타이핑
    socket.on('typing:start', ({ userId }) => {
      setTyping(prev => [...new Set([...prev, userId])]);
    });
    socket.on('typing:stop', ({ userId }) => {
      setTyping(prev => prev.filter(id => id !== userId));
    });

    // 읽음 처리
    socket.emit('message:read', { conversationId });

    return () => {
      socket.off('message:new');
      socket.off('typing:start');
      socket.off('typing:stop');
    };
  }, [conversationId]);

  const sendMessage = (content: string) => {
    socket.emit('message:send', { conversationId, content }, (res: any) => {
      if (res.error) toast.error(res.error);
    });
  };

  return (
    <>
      <MessageList messages={messages} />
      {typing.length > 0 && <TypingIndicator users={typing} />}
      <MessageInput onSend={sendMessage} onTyping={() => {
        socket.emit('typing:start', { conversationId });
        setTimeout(() => socket.emit('typing:stop', { conversationId }), 3000);
      }} />
    </>
  );
}

메시지 영속성 — 큐 패턴 (대규모)

TYPESCRIPT📋 코드 (25줄)
// 직접 INSERT 대신 큐로
socket.on('message:send', async (data, ack) => {
  const tempId = crypto.randomUUID();

  // 즉시 응답 (낙관적)
  ack({ ok: true, tempId });

  // 큐에 추가 (비동기 영속화)
  await messageQueue.add('persist', {
    tempId,
    conversationId: data.conversationId,
    senderId: userId,
    content: data.content,
  }, { jobId: tempId });
});


// Worker
const worker = new Worker('messages', async (job) => {
  const message = await db.message.create({ data: job.data });
  io.to(`conv:${job.data.conversationId}`).emit('message:persisted', {
    tempId: job.data.tempId,
    message,
  });
});

읽지 않은 메시지 카운트

SQL📋 코드 (11줄)
-- 사용자별·대화방별 읽지 않은 수
SELECT
  m.conversation_id,
  COUNT(*) as unread_count
FROM messages m
JOIN conversation_members cm
  ON cm.conversation_id = m.conversation_id
  AND cm.user_id = ?
WHERE m.created_at > COALESCE(cm.last_read_at, '1970-01-01')
  AND m.sender_id != ?
GROUP BY m.conversation_id;

다음 챕터

CH.84 "미디어: 이미지/비디오 업로드 + 트랜스코딩".


AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude

무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6

내 프로젝트의 실시간 채팅 부분을 분석해서
실전 분석 + 개선 우선순위를 알려줘.
ChatGPT

무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro

실시간 채팅 관련 실제 서비스 5개를
비교 분석해서 패턴 추출를 알려줘.
Gemini

무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro

내 코드베이스에서 실시간 채팅
최적화 가능 위치를 보고해줘.
Grok

무료: Grok 4.1 / SuperGrok $30/mo

2026년 한국 풀스택 시장의
실시간 채팅 트렌드를 솔직히 알려줘.

⭐ 이것만 기억하세요
실시간 채팅: WebSocket + 메시지 저장 이 3가지만 확실히 잡으세요
1.Socket.io = WebSocket + 룸 + 재연결 표준 — 카카오톡 패턴
2.읽음 처리는 last_read_at + 비교 — 메시지마다 read 테이블 X
3.타이핑은 Redis pub/sub — DB 부담 없이 실시간


공유하기
진행도 83 / 90