본문 바로가기
TIL

TIL 240619 - socket.io 적용 (1) 기본 기능 구현 / 파일 분리 문제 해결

by lemonpie611 2024. 6. 19.

0. Socket.io

사용자의 브라우저와 서버 사이 실시간 통신이 가능한 프로토콜

오래된 브라우저에서 지원되지 않는 webSocket의 단점을 보완

 

1. 기본 구조 - Server

1) app.js에서, Express를 이용해서  Http 서버 생성

2) 생성된 Http 서버로 socket.io server 인스턴스 생성

import express from 'express';
import http from 'http';
import { Server } from 'socket.io';
import router from './routers/index.js';
import path from 'path';

const __dirname = path.resolve();
const app = express();
const PORT = 3000;
const server = http.createServer(app);
const io = new Server(server);

app.use(express.json());

server.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

 

3) /messages 경로로 접속하면 클라이언트에 index.html을 전송하도록 함

app.get('/messages', (req, res, next) => {
  try {
    res.sendFile(__dirname + '/assets/index.html');
  } catch (err) {
    next(err);
  }
});

 

4) 클라이언트가 socket.io 서버에 접속했을 때 connection 이벤트가 발생하도록 함

// 클라이언트가 소켓 서버에 연결될 때
io.on('connection', (socket) => {
  console.log('A user connected');

  // 클라이언트 연결 해제 시
  socket.on('disconnect', () => {
    console.log('A user disconnected');
  });
});

server.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

 

* 메시지 수신 메소드

socket.on('event_name', function(data) {
  console.log('Message from Client: ' + data);
});

 

5) 클라이언트에게 메시지 송신

// 접속된 모든 클라이언트에게 메시지를 전송한다
io.emit('event_name', msg);

// 메시지를 전송한 클라이언트에게만 메시지를 전송한다
socket.emit('event_name', msg);

// 메시지를 전송한 클라이언트를 제외한 모든 클라이언트에게 메시지를 전송한다
socket.broadcast.emit('event_name', msg);

// 특정 클라이언트에게만 메시지를 전송한다
// id는 socket 객체의 id 속성값
io.to(id).emit('event_name', data);

 

6) 전체 코드

import express from 'express';
import http from 'http';
import { Server } from 'socket.io';
import router from './routers/index.js';
import path from 'path';

const __dirname = path.resolve();
const app = express();
const PORT = ENV.PORT;
const server = http.createServer(app);
const io = new Server(server);

app.use((req, res, next) => {
  req.io = io; // io 객체를 req 객체에 추가하여 라우터에서 사용할 수 있도록 함
  next();
});

app.use(express.json());
app.use('/api/v1', [router]);

app.get('/', async (req, res) => {
  res.status(200).json({ message: '서버 정상 동작중' });
});

// localhost:3000으로 서버에 접속하면 클라이언트로 index.html을 전송한다
app.get('/messages', (req, res, next) => {
  try {
    res.sendFile(__dirname + '/assets/index.html');
  } catch (err) {
    next(err);
  }
});

// 클라이언트가 소켓 서버에 연결될 때
io.on('connection', (socket) => {
  console.log('A user connected');
  
  io.emit('order', {message: '새로운 주문이 접수되었습니다!'});

  // 클라이언트 연결 해제 시
  socket.on('disconnect', () => {
    console.log('A user disconnected');
  });
});

server.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

 

2. 기본 구조 - Client

1) script의 src attribute로 '/socket.io/socket.io.js' 지정하기. socket.io가 서버 가동 시 socket.io.js를 자동으로 생성해줌.

<script src="/socket.io/socket.io.js"></script>
<script>
  var socket = io();
  
  
</script>

 

2) 서버로 메시지 송신: emit 메서드 사용

socket.emit("event_name", msg);

 

3) 서버로부터 메시지 수신: on 메서드 사용

socket.on("event_name", function(data) {
  console.log('Message from Server: ' + data);
});

 

4) 전체 코드

<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Socket.IO Notification</title>
  <script src="/socket.io/socket.io.js"></script>
  <script>
    document.addEventListener('DOMContentLoaded', (event) => {
      const socket = io();

      // 알림을 표시할 div 요소 생성
      const orderDiv = document.createElement('div');
      orderDiv.id = 'order';
      document.body.appendChild(orderDiv);

      // 서버로부터 알림을 받을 때
      socket.on('order', (data) => {
        console.log('order received:', data.message);
        const time = new Date(data.order.createdAt);
        // 새로운 알림 요소 생성
        let orderElement = `<p>${data.message}</p>`
        for (let i = 0; i < data.order.orderItems.length; i++) {
          orderElement += `<p> ${data.order.orderItems[i].menuName}
            &nbsp;&nbsp;*&nbsp;&nbsp;${data.order.orderItems[i].quantity}</p>`;
        }
        orderElement+=`<br>`;


        alert(data.message);
        // 알림을 notificationsDiv에 추가
        orderDiv.insertAdjacentHTML("afterbegin", orderElement);
      });
    });
  </script>
</head>

<body>
  <h1>Socket.IO Notification Example</h1>
</body>

</html>

 

3. 프로젝트 파일에 적용 - 파일 분리 문제 및 해결방법

위에서는 app.js에 모든 코드를 다 넣었다면, 프로젝트에서는 app.js / router / service 파일에 각각 쪼개서 넣어야 한다. 이때, io 인스턴스는 서버가 열리는 파일인 app.js에만 넣을 수 있고, emit 메서드는 구현할 기능 특성상 service 파일에 넣어야 했는데, emit 함수를 호출하기 위해서는 io 인스턴스를 app.js에서 불러와야 했다.

 

단순히 export를 쓰면 코드 실행 순서에서 꼬여버리기 때문에, io를 req.io로 받아 next로 전달하는 미들웨어를 추가해뒀다.

app.get('/messages', (req, res, next) => {
  try {
    res.sendFile(__dirname + '/assets/index.html');
  } catch (err) {
    next(err);
  }
});

 

따라서 전체코드..

// app.js

import express from 'express';
import http from 'http';
import { Server } from 'socket.io';
import { ENV } from './constants/env.constant.js';
import { globalErrorHandler } from './middlewares/error-handling.middleware.js';
import { requestLogger } from './middlewares/log.middleware.js';
import router from './routers/index.js';
import path from 'path';

const __dirname = path.resolve();
const app = express();
const PORT = ENV.PORT;
const server = http.createServer(app);
const io = new Server(server);

app.use((req, res, next) => {
  req.io = io; // io 객체를 req 객체에 추가하여 라우터에서 사용할 수 있도록 함
  next();
});

app.use(requestLogger);
app.use(express.json());
app.use('/api/v1', [router]);
app.use(globalErrorHandler);

app.get('/', async (req, res) => {
  res.status(200).json({ message: '서버 정상 동작중' });
});

// localhost:3000으로 서버에 접속하면 클라이언트로 index.html을 전송한다
app.get('/messages', (req, res, next) => {
  try {
    res.sendFile(__dirname + '/assets/index.html');
  } catch (err) {
    next(err);
  }
});

// 클라이언트가 소켓 서버에 연결될 때
io.on('connection', (socket) => {
  console.log('A user connected');

  // 클라이언트 연결 해제 시
  socket.on('disconnect', () => {
    console.log('A user disconnected');
  });
});

server.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});
// order.controller.js

import { HTTP_STATUS } from '../constants/http-status.constant.js';
import { MESSAGES } from '../constants/message.constant.js';

class OrderController {
  constructor(orderService) {
    this.orderService = orderService;
  }

  //주문
  createOrder = async (req, res, next) => {
    try {
      const { userId } = req.user;
      const io = req.io;
      const order = await this.orderService.createOrder(io, userId);

      return res.status(HTTP_STATUS.CREATED).json({
        status: HTTP_STATUS.CREATED,
        message: MESSAGES.ORDER.CREATE.SUCCESS,
        data: order,
      });
    } catch (err) {
      next(err);
    }
  };
  .
  .
  .
//order.service.js

import { MESSAGES } from '../constants/message.constant.js';
import {
  NotFoundError,
  BadRequestError,
  ConflictError,
} from '../errors/http.error.js';
import { USER_TYPE } from '../constants/user.constant.js';

class OrderService {
  constructor(
    orderRepository,
    cartRepository,
    restaurantRepository,
    menuRepository,
    userRepository,
  ) {
    this.orderRepository = orderRepository;
    this.cartRepository = cartRepository;
    this.restaurantRepository = restaurantRepository;
    this.menuRepository = menuRepository;
    this.userRepository = userRepository;
  }

  createOrder = async (io, userId) => {
    try {
      // 0. 카트 내역 가져오기
      const cart = await this.cartRepository.getCartById(userId);
      if (!cart) {
        throw new BadRequestError(MESSAGES.ORDER.CREATE.EMPTY);
      }

      const cartItems = await this.cartRepository.getAllCartItem(cart.cartId);
      if (!cartItems) {
        throw new BadRequestError(MESSAGES.ORDER.CREATE.EMPTY);
      }

      // 1. 주문 제대로 됐는지 확인 - 만약 식당이 없으면 에러
      const restaurant = await this.restaurantRepository.getRestaurantById(
        cart.restaurantId,
      );
      if (!restaurant) {
        throw new NotFoundError(MESSAGES.ORDER.COMMON.RESTAURANT_NOT_FOUND);
      }

      // 2. 메뉴별 가격 더하기, 메뉴 없으면 에러
      let totalPrice = 0;
      for (const item of cartItems) {
        const menu = await this.menuRepository.getMenuById(item.menuId);

        if (!menu) {
          throw new BadRequestError(MESSAGES.ORDER.CREATE.MENU_NOT_FOUND);
        }
        totalPrice += item.quantity * menu.price;
      }

      // 3. 포인트 있는지 확인 - 모자라면 에러
      const user = await this.userRepository.findById(userId);
      const balance = user.points - totalPrice;
      if (balance < 0) {
        throw new BadRequestError(MESSAGES.ORDER.CREATE.INSUFFICIENT);
      }

      // 4. 트랜잭션으로 포인트&카트삭제&주문추가 3개 묶기
      const order = await this.orderRepository.createOrder(
        userId,
        totalPrice,
        restaurant.ownerId,
        restaurant.restaurantId,
        cartItems,
        cart.cartId,
      );
      const orderItems = order.orderItems.map((item) => {
        return {
          orderItemId: item.orderItemId,
          menuId: item.menuId,
          menuName: item.menu.name,
          price: item.price,
          quantity: item.quantity,
        };
      });
      io.emit('order', {
        message: '새로운 주문이 접수되었습니다!',
        order: {
          customerId: order.customerId,
          orderId: order.orderId,
          customerNickName: order.customer.nickName,
          restaurantName: order.restaurant.name,
          orderStatus: order.orderStatus,
          createdAt: order.createdAt,
          totalPrice,
          balance: order.points,
          orderItems,
        },
      });
      return {
        customerId: order.customerId,
        orderId: order.orderId,
        customerNickName: order.customer.nickName,
        restaurantName: order.restaurant.name,
        orderStatus: order.orderStatus,
        createdAt: order.createdAt,
        totalPrice,
        balance: order.points,
        orderItems,
      };
    } catch (err) {
      throw err; // 예외는 상위로 전파
    }
  };