stack-analysis
CHAPTER 83 / 90
읽기 약 2분
FUNCTION
실시간 채팅: WebSocket + 메시지 저장
핵심 개념
Socket.io·메시지 큐·읽음 처리·typing — 카카오톡·디스코드 패턴.
본문
데이터 모델
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 서버
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)
'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);
}} />
</>
);
}메시지 영속성 — 큐 패턴 (대규모)
// 직접 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,
});
});읽지 않은 메시지 카운트
-- 사용자별·대화방별 읽지 않은 수
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