
채팅 기능 설계
기능 요구사항 분석
- 채팅방 생성: 글 작성자와 현재 사용자 간 1:1 채팅방
- 채팅방 목록: 내가 참여한 채팅방들 보기
- 실시간 채팅: Socket.IO를 활용한 양방향 통신
- 메시지 저장: DB에 채팅 내역 영구 보관
데이터 구조 설계
{
_id: ObjectId,
member: [user1_id, user2_id],
date: new Date()
}
{
_id: ObjectId,
parentRoom: ObjectId,
content: "메시지 내용",
who: ObjectId,
date: new Date()
}
1단계: 채팅방 생성 기능
채팅 버튼 추가
<a href="/chat/request?writerId=<%= result.user %>">채팅하기</a>
채팅방 생성 API
app.get('/chat/request', async (요청, 응답) => {
let existing = await db.collection('chatroom').findOne({
member: { $all: [요청.user._id, new ObjectId(요청.query.writerId)] }
})
if (existing) {
return 응답.redirect(`/chat/detail/${existing._id}`)
}
let result = await db.collection('chatroom').insertOne({
member: [요청.user._id, new ObjectId(요청.query.writerId)],
date: new Date()
})
응답.redirect(`/chat/detail/${result.insertedId}`)
})
2단계: 채팅방 목록 페이지
채팅방 목록 API
app.get('/chat/list', async (요청, 응답) => {
let result = await db.collection('chatroom')
.find({ member: 요청.user._id })
.toArray()
응답.render('chatList.ejs', { 글목록: result })
})
MongoDB 배열 검색 참고
{ member: 내_id }
{ member: { $in: [내_id] } }
{ member: { $all: [내_id] } }
채팅방 목록 페이지 레이아웃
<h4>나의 채팅방 목록</h4>
<div class="white-bg">
<% for (let i = 0; i < 글목록.length; i++) { %>
<div class="list-box">
<h4>
<a href="/chat/detail/<%= 글목록[i]._id %>">
채팅방 <%= i + 1 %>
</a>
</h4>
<p>생성일: <%= 글목록[i].date.toLocaleDateString() %></p>
</div>
<% } %>
</div>
3단계: 채팅방 상세 페이지
상세 페이지 API
app.get('/chat/detail/:id', async (요청, 응답) => {
let result = await db.collection('chatroom')
.findOne({ _id: new ObjectId(요청.params.id) })
if (!result || !result.member.includes(요청.user._id)) {
return 응답.status(403).send('접근 권한이 없습니다')
}
let messages = await db.collection('chatMessage')
.find({ parentRoom: new ObjectId(요청.params.id) })
.sort({ date: 1 })
.toArray()
응답.render('chatDetail.ejs', {
result: result,
messages: messages
})
})
Socket.IO 설치 및 설정
1. 서버 설정
npm install socket.io@4
const { createServer } = require('http')
const { Server } = require('socket.io')
const server = createServer(app)
const io = new Server(server)
server.listen(8080, function () {
console.log('listening on 8080')
})
2. 클라이언트 설정
<script src="https://cdn.jsdelivr.net/npm/socket.io@4.7.2/client-dist/socket.io.min.js"></script>
<script>
const socket = io()
</script>
3. 기본 연결 테스트
io.on('connection', (socket) => {
console.log('웹소켓 연결됨')
})
Socket.IO 통신 기본 문법
클라이언트 → 서버
socket.emit('메시지명', '데이터')
socket.emit('메시지명', { key: 'value' })
서버에서 메시지 수신
io.on('connection', (socket) => {
socket.on('메시지명', (data) => {
console.log('클라이언트가 보낸 데이터:', data)
})
})
서버 → 클라이언트
io.emit('메시지명', '데이터')
io.to('룸이름').emit('메시지명', '데이터')
클라이언트에서 메시지 수신
socket.on('메시지명', (data) => {
console.log('서버가 보낸 데이터:', data)
})
Socket.IO Room 기능
Room 개념
- Room: 사용자들을 그룹화하는 가상 공간
- 용도: 특정 사용자들에게만 메시지 전송
- 보안: 서버에서만 Room 입장/퇴장 가능
Room 입장
socket.join('룸이름')
클라이언트가 Room 입장 요청
socket.emit('ask-join', '룸이름')
socket.on('ask-join', (roomName) => {
socket.join(roomName)
console.log(`사용자가 ${roomName} 룸에 입장`)
})
특정 Room에 메시지 전송
io.to('룸이름').emit('메시지명', '데이터')
실시간 채팅 구현
1. 채팅방 입장 처리
<script>
const socket = io()
socket.emit('ask-join', '<%= result._id %>')
</script>
socket.on('ask-join', async (roomId) => {
let chatroom = await db.collection('chatroom')
.findOne({ _id: new ObjectId(roomId) })
if (chatroom && chatroom.member.includes(socket.request.session?.passport?.user?.id)) {
socket.join(roomId)
console.log(`사용자가 채팅방 ${roomId}에 입장`)
}
})
2. 메시지 전송 처리
<div class="chat-container">
<div class="chat-screen">
<% for (let message of messages) { %>
<div class="chat-box <%= message.who.toString() === user._id.toString() ? 'mine' : '' %>">
<span><%= message.content %></span>
</div>
<% } %>
</div>
<div class="chat-input-container">
<input class="chat-input" placeholder="메시지를 입력하세요">
<button class="chat-button">전송</button>
</div>
</div>
<script>
document.querySelector('.chat-button').addEventListener('click', function() {
let message = document.querySelector('.chat-input').value
if (message.trim()) {
socket.emit('message-send', {
room: '<%= result._id %>',
msg: message
})
document.querySelector('.chat-input').value = ''
}
})
document.querySelector('.chat-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
document.querySelector('.chat-button').click()
}
})
</script>
3. 서버에서 메시지 처리 및 저장
socket.on('message-send', async (data) => {
try {
await db.collection('chatMessage').insertOne({
parentRoom: new ObjectId(data.room),
content: data.msg,
who: new ObjectId(socket.request.session.passport.user.id),
date: new Date()
})
io.to(data.room).emit('message-broadcast', {
content: data.msg,
who: socket.request.session.passport.user.id,
username: socket.request.session.passport.user.username
})
} catch (error) {
console.error('메시지 저장 오류:', error)
}
})
4. 메시지 수신 및 화면 표시
socket.on('message-broadcast', (data) => {
const isMine = data.who === '<%= user._id %>'
const chatBox = `
<div class="chat-box ${isMine ? 'mine' : ''}">
<span>${data.content}</span>
</div>
`
document.querySelector('.chat-screen')
.insertAdjacentHTML('beforeend', chatBox)
document.querySelector('.chat-screen').scrollTop =
document.querySelector('.chat-screen').scrollHeight
})
CSS 스타일링
채팅 화면 기본 스타일
.chat-container {
max-width: 600px;
margin: 0 auto;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.chat-screen {
height: 400px;
overflow-y: auto;
padding: 20px;
background-color: #f8f9fa;
}
.chat-box {
margin: 10px 0;
padding: 8px 12px;
background-color: #e9ecef;
border-radius: 12px;
max-width: 70%;
word-wrap: break-word;
}
.chat-box.mine {
background-color: #007bff;
color: white;
margin-left: auto;
text-align: right;
}
.chat-input-container {
display: flex;
padding: 15px;
background-color: white;
border-top: 1px solid #ddd;
}
.chat-input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 20px;
margin-right: 10px;
}
.chat-button {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
}
고급 기능 구현
1. 채팅 메시지 페이지네이션
app.get('/chat/detail/:id', async (요청, 응답) => {
let page = parseInt(요청.query.page) || 1
let limit = 50
let messages = await db.collection('chatMessage')
.find({ parentRoom: new ObjectId(요청.params.id) })
.sort({ date: -1 })
.skip((page - 1) * limit)
.limit(limit)
.toArray()
messages.reverse()
응답.render('chatDetail.ejs', {
result: result,
messages: messages
})
})
2. 로그인 정보 연동
const session = require('express-session')
io.use((socket, next) => {
session(sessionMiddleware)(socket.request, {}, next)
})
socket.on('message-send', async (data) => {
const user = socket.request.session.passport?.user
if (!user) {
return socket.emit('error', '로그인이 필요합니다')
}
})
3. 온라인 사용자 표시
socket.on('ask-join', (roomId) => {
socket.join(roomId)
socket.to(roomId).emit('user-joined', {
username: socket.request.session.passport.user.username
})
})
socket.on('disconnect', () => {
socket.broadcast.emit('user-left', {
username: socket.request.session.passport.user.username
})
})
성능 최적화
1. DB Adapter 사용
npm install @socket.io/mongo-adapter
const { MongoClient } = require('mongodb')
const { createAdapter } = require('@socket.io/mongo-adapter')
const mongoClient = new MongoClient(DB_URL)
await mongoClient.connect()
io.adapter(createAdapter(mongoClient.db('mydb').collection('socket.io-adapter-events')))
2. 연결 에러 처리
socket.on('disconnect', () => {
console.log('연결이 끊어졌습니다. 재연결 시도중...')
})
socket.on('connect', () => {
console.log('서버에 다시 연결되었습니다.')
socket.emit('ask-join', getCurrentRoomId())
})
보안 고려사항
1. 권한 검증
socket.on('ask-join', async (roomId) => {
const user = socket.request.session.passport?.user
const chatroom = await db.collection('chatroom')
.findOne({
_id: new ObjectId(roomId),
member: user.id
})
if (!chatroom) {
return socket.emit('error', '접근 권한이 없습니다')
}
socket.join(roomId)
})
2. 메시지 검증
socket.on('message-send', async (data) => {
if (!data.msg || data.msg.length > 1000) {
return socket.emit('error', '메시지가 너무 깁니다')
}
const sanitizedMessage = data.msg
.replace(/</g, '<')
.replace(/>/g, '>')
})
실전 체크리스트
기본 기능
고급 기능
성능 및 보안
사용자 경험