[Node.js-08] 채팅

Comely·2025년 3월 12일

Node.js

목록 보기
8/14

채팅 기능 설계

기능 요구사항 분석

  1. 채팅방 생성: 글 작성자와 현재 사용자 간 1:1 채팅방
  2. 채팅방 목록: 내가 참여한 채팅방들 보기
  3. 실시간 채팅: Socket.IO를 활용한 양방향 통신
  4. 메시지 저장: DB에 채팅 내역 영구 보관

데이터 구조 설계

// chatroom 컬렉션
{
  _id: ObjectId,
  member: [user1_id, user2_id],  // 참여자 목록
  date: new Date()               // 생성일
}

// chatMessage 컬렉션  
{
  _id: ObjectId,
  parentRoom: ObjectId,          // 채팅방 ID
  content: "메시지 내용",
  who: ObjectId,                 // 발송자 ID
  date: new Date()               // 발송시간
}

1단계: 채팅방 생성 기능

채팅 버튼 추가

<!-- detail.ejs -->
<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] } } // 모든 값 포함 검사

채팅방 목록 페이지 레이아웃

<!-- chatList.ejs -->
<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
// server.js 상단
const { createServer } = require('http')
const { Server } = require('socket.io')
const server = createServer(app)
const io = new Server(server)

// app.listen을 server.listen으로 변경
server.listen(8080, function () {
  console.log('listening on 8080')
})

2. 클라이언트 설정

<!-- chatDetail.ejs -->
<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. 기본 연결 테스트

// server.js
io.on('connection', (socket) => {
  console.log('웹소켓 연결됨')
})

Socket.IO 통신 기본 문법

클라이언트 → 서버

// chatDetail.ejs
socket.emit('메시지명', '데이터')
socket.emit('메시지명', { key: 'value' })  // 객체도 가능

서버에서 메시지 수신

// server.js
io.on('connection', (socket) => {
  socket.on('메시지명', (data) => {
    console.log('클라이언트가 보낸 데이터:', data)
  })
})

서버 → 클라이언트

// 모든 클라이언트에게
io.emit('메시지명', '데이터')

// 특정 룸에만
io.to('룸이름').emit('메시지명', '데이터')

클라이언트에서 메시지 수신

// chatDetail.ejs
socket.on('메시지명', (data) => {
  console.log('서버가 보낸 데이터:', data)
})

Socket.IO Room 기능

Room 개념

  • Room: 사용자들을 그룹화하는 가상 공간
  • 용도: 특정 사용자들에게만 메시지 전송
  • 보안: 서버에서만 Room 입장/퇴장 가능

Room 입장

// 서버에서만 가능
socket.join('룸이름')

클라이언트가 Room 입장 요청

// chatDetail.ejs
socket.emit('ask-join', '룸이름')
// server.js
socket.on('ask-join', (roomName) => {
  socket.join(roomName)
  console.log(`사용자가 ${roomName} 룸에 입장`)
})

특정 Room에 메시지 전송

// server.js
io.to('룸이름').emit('메시지명', '데이터')

실시간 채팅 구현

1. 채팅방 입장 처리

// chatDetail.ejs
<script>
  const socket = io()
  // 페이지 로드시 해당 채팅방 룸에 입장
  socket.emit('ask-join', '<%= result._id %>')
</script>
// server.js
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. 메시지 전송 처리

<!-- chatDetail.ejs -->
<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 = ''
    }
  })
  
  // Enter 키 처리
  document.querySelector('.chat-input').addEventListener('keypress', function(e) {
    if (e.key === 'Enter') {
      document.querySelector('.chat-button').click()
    }
  })
</script>

3. 서버에서 메시지 처리 및 저장

// server.js
socket.on('message-send', async (data) => {
  try {
    // DB에 메시지 저장
    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. 메시지 수신 및 화면 표시

// chatDetail.ejs
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. 로그인 정보 연동

// Passport와 Socket.IO 연동 설정
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 사용

// MongoDB 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', '메시지가 너무 깁니다')
  }
  
  // XSS 방지 (클라이언트에서 HTML 이스케이프)
  const sanitizedMessage = data.msg
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
  
  // DB 저장 및 브로드캐스트
})

실전 체크리스트

기본 기능

  • 채팅방 생성
  • 채팅방 목록 조회
  • 실시간 메시지 송수신
  • 메시지 DB 저장

고급 기능

  • 메시지 페이지네이션
  • 온라인 사용자 표시
  • 파일/이미지 전송
  • 메시지 읽음 표시

성능 및 보안

  • 권한 검증
  • 메시지 검증
  • 연결 에러 처리
  • DB Adapter 적용

사용자 경험

  • 반응형 디자인
  • 푸시 알림
  • 타이핑 인디케이터
  • 이모지 지원
profile
App, Web Developer

0개의 댓글