반응형

 

이전 장. [채팅 웹사이트 구현 - 1장] WebSocket과 Socket.io에 대해 알고 시작해볼까?

 

 

 

express로 node.js 서버 열기

 

프로젝트를 시작합니다. 터미널을 열고 원하는 디렉터리에서 다음 명령어를 실행합니다.

 

npm init -y

 

다음으로 express를 설치해줍니다.

 

yarn add express

 

그리고 프로젝트 폴더에 server.js를 생성하고 다음과 같이 코드를 작성합니다.

 

* server.js

const express = require("express");
const app = express();
const server = require("http").createServer(app);

const port = 4000;

server.listen(port, () => {
  console.log(
    `##### server is running on http://localhost:4000. ${new Date().toLocaleString()} #####`
  );
});

 

express는 node.js로 서버를 쉽게 만들도록 도와주는 프레임워크입니다. 저렇게 간단하게 require로 express모듈을 불러와 express()로 실행해주면 서버를 만들 수 있습니다. 저는 4000번 포트를 열어 Node.js 서버를 할당해주었습니다. server.listen()에서 첫 번째 파라미터는 포트번호, 두 번째 파라미터는 서버가 열린 후 실행될 콜백 함수를 받습니다. 저는 서버가 열렸음을 알리는 콘솔 로그 출력 함수를 콜백 함수 내에 작성했습니다.

이제 서버를 여는 스크립트 명령어를 만듭니다.

 

* package.json

{
  "scripts": {
    "start": "node server.js"
  }
}

 

터미널에 다음 명령어를 입력하여 서버를 실행해봅니다.

 

yarn start

 

서버가 정상적으로 실행되면 server.js에서 만든 다음과 같은 텍스트가 터미널에 출력될 것입니다.

 

##### server is running on http://localhost:4000. {현재 시각} #####

 

 

 

nodemon으로 개발환경 개선하기

 

지금 상태에서는 코드의 변경내용을 서버에 반영하려면 서버를 다시 껐다 키는 작업을 손수 해줘야 합니다. 당연히 개발 피로도가 증가합니다. 하지만 nodemon을 사용하면 소스코드가 변경될 때마다 자동으로 서버를 재시작할 수 있습니다.

 

yarn add nodemon

 

nodemon을 설치했으면 스크립트를 수정해줍니다.

 

* package.josn

"scripts": {
  "start": "nodemon --exec node server.js"
},

 

서버를 종료 후 다시 yarn start로 서버를 켜줍니다. 이제부터는 코드를 변경할 때마다 (파일이 저장되어 파일 체인지가 되었을 때) 서버가 자동으로 재시작될 것입니다.

 

 

 

socket.io 설치하고 소켓 서버 열기

 

socket.io와 cors를 설치합니다.

 

yarn add socket.io cors

 

socket.io 라이브러리는 1장에서 소개한 대로 양방향 통신을 가능하게 만들어주는 실시간 채팅 기능을 구현하기 위해 필요한 핵심 라이브러리입니다.

cors는 우리가 만들 프론트엔드 URL에 대해서만 통신을 허락하고 나머지는 거부하기 위해서 필요합니다.

이제 server.js를 수정합니다.

 

* server.js

const express = require("express");
const app = express();
const server = require("http").createServer(app);
const cors = require("cors");
const socketIo = require("socket.io")(server, {
  cors: {
    origin: "http://localhost:3000",
    credentials: true,
  },
});
const socket = require("./src/socket"); // 이 다음으로 바로 만들 파일!

const port = 4000;

// express의 미들웨어 사용 방식!
app.use(cors({ origin: "http://localhost:3000", credentials: true })); // cors 미들웨어 사용
socket(socketIo); // 이제 곧 만들 파일에서 정의할 모듈에 socketIo 객체를 전달해줄 것입니다

server.listen(port, () => {
  console.log(
    `##### server is running on http://localhost:4000. ${new Date().toLocaleString()} #####`
  );
});

 

코드를 위에서부터 보면 express로 만든 서버에 socket을 열어줬고 cors로 localhost:3000 url만 통신을 허용하도록 설정했습니다. src/socket은 아직 만들진 않았지만 이제 바로 만들 것이기 때문에 미리 작성해뒀습니다. src/socket.js 파일에서 소켓의 이벤트에 따른 로직들을 작성할 것입니다.

 

 

 

 

 

 

socket event 정의하고 event에 대한 로직 작성하기

 

이제 프로젝트 디렉터리에 src/socket/index.js 파일을 생성합니다. 제일 처음 작성할 이벤트 로직은 connection과 disconnection입니다.

 

* src/socket/index.js

module.exports = function (socketIo) {
  socketIo.on("connection", function (socket) {
    // 클라이언트와 연결이 되면 연결된 사실을 출력합니다.
    console.log("socket connection succeeded.");
    
    socket.on("disconnect", reason => {
      // 클라이언트와 연결이 끊어지면 이유를 출력해줍니다.
      console.log(`disconnect: ${reason}`);
    }
  }
}

 

jquery의 이벤트 리스너와 이벤트 핸들러 작성 방식과 비슷합니다. on() 메서드의 첫 파라미터에서 이벤트 타입을 적어주고 두 번째 파라미터에서 콜백 함수인 이벤트 핸들러를 적어줍니다.

connection 이벤트는 클라이언트와 소켓 서버가 서로 연결되었을 때 발생합니다. 이때 연결되었음을 알리는 텍스트를 소켓 서버 터미널에 출력해줍니다.  disconnect는 연결이 끊겼을 때 발생합니다. 마찬가지로 연결이 끊겼음을 로그로 출력해줍니다. 이 이벤트는 연결이 끊어진 이유를 콜백 함수의 파라미터로 받을 수 있어 같이 출력해줬습니다.

 

connection과 disconnect는 socket.io에서 기본적으로 지원해주는 이벤트 타입이었습니다.

반면, 이제부터는 저희가 마음대로 몇 가지 이벤트를 더 정의하고 그에 대한 로직을 작성하여 채팅 애플리케이션으로서 기능할 수 있도록 본격적인 작업을 할 것입니다. 앞으로 정의할 이벤트는 다음과 같습니다.

 

"JOIN_ROOM": 유저가 방에 참가했을 때 발생
"UPDATE_NICKNAME": 유저가 닉네임을 변경했을 때 발생
"SEND_MESSAGE": 유저가 메시지를 전송했을 때 발생
"RECEIVE_MESSAGE": 유저가 메시지를 받을 때 발생

 

총 4가지 이벤트를 정의할 것이고 RECEIVE_MESSAGE를 제외한 모든 이벤트들에 대해 각각 이벤트 리스너를 선언하여 로직을 작성할 것입니다. RECEIVE_MESSAGE는 클라이언트에게 보내는(emit) 용도로만 사용될 것입니다. 

 

먼저 "JOIN_ROOM"부터 시작합니다. JOIN_ROOM은 유저가 채팅방에 참가했을 때 발생하는 이벤트입니다. 아까 만들었던 socketIo.on() 내의 콜백 함수에서 코드를 추가할 것입니다.

 

socketIo.on("connection", function (socket) {
  // ...
    
  // 구현 편의상, 모든 클라이언트의 방 번호는 모두 "room 1"으로 배정해줍니다.
  const roomName = "room 1";

  // JOIN 이벤트 기능 추가
  socket.on("JOIN_ROOM", requestData => {
    // 콜백함수의 파라미터는 클라이언트에서 보내주는 데이터입니다.
    socket.join(roomName); // user를 "room 1" 방에 참가시킵니다.
    const responseData = {
      ...requestData,
      type: "JOIN_ROOM",
      time: new Date(),
    };
    // "room 1"에는 이벤트타입과 서버에서 받은 시각을 덧붙여 데이터를 그대로 전송해줍니다.
    socketIo.to(roomName).emit("RECEIVE_MESSAGE", responseData);
    console.log(`JOIN_ROOM is fired with data: ${JSON.stringify(responseData)}`);
  });
    
  // ...
});

 

클라이언트에서 JOIN_ROOM이라는 이벤트 타입으로 데이터를 소켓 서버에 던져주면 소켓 서버는 데이터를 받아 콜백 함수를 실행합니다. 콜백 함수 안에서 RECEIVE_MESSAGE 이벤트를 emit하는 코드가 있는데 이것은 클라이언트에 이벤트를 전달하는 것입니다. 마찬가지로 클라이언트에서는 RECEIVE_MESSAGE 이벤트 리스너를 가지고 있어서 그쪽 콜백 함수가 또 실행될 것입니다. 클라이언트 구현은 서버 구현을 마친 후에 진행할 것입니다. 일단은 서버 구현을 완성해보도록 합니다!

 

다음으로 유저가 닉네임을 변경하는 것을 의미하는 UPDATE_NICKNAME 이벤트에 대한 기능을 만들어봅니다.

JOIN_ROOM 이벤트 리스너에 대한 코드와 같은 스코프에 작성하면 됩니다.

 

* src/socket/index.js

socketIo.on("connection", function (socket) {
  // ...
    
  socket.on("UPDATE_NICKNAME", requestData => {
      const responseData = {
        ...requestData,
        type: "UPDATE_NICKNAME",
        time: new Date(),
      };
      socketIo.to(roomName).emit("RECEIVE_MESSAGE", responseData);
      console.log(`UPDATE_NICKNAME is fired with data: ${JSON.stringify(responseData)}`);
  });
  
  // ...
});

 

바로 이어서 유저가 채팅 메시지를 송신했음을 의미하는 SEND_MESSAGE 이벤트에 대한 기능도 구현해봅니다.

 

socket.on("SEND_MESSAGE", requestData => {
  const responseData = {
    ...requestData,
    type: "SEND_MESSAGE",
    time: new Date(),
  };
  socketIo.to(roomName).emit("RECEIVE_MESSAGE", responseData);
  console.log(`SEND_MESSAGE is fired with data: ${JSON.stringify(responseData)}`);
});

 

이렇게 해서 저희가 정의한 이벤트 타입들에 대한 로직 작성도 끝이 났습니다. 

 

 

 

 

 

 

마무리 - 리팩터링

 

그런데 지금은 중복 코드도 많고 하드코딩되어 있는 부분들이 많아서 아쉬움이 남습니다. 리팩터링으로 아쉬운 부분들을 조금이라도 개선하고 소켓 서버 백엔드 개발은 끝내보려고 합니다. 기능이 완성된 지금 해볼 만한 가치가 있는 일을 몇 가지 제시해보겠습니다.

 

 

 

1. 환경변수를 따로 관리

 

우선, 환경변수를 따로 관리하는 것입니다. server.js를 보면 포트번호와 url이 코드에 그대로 명시되어 있습니다. 지금은 로컬 호스트를 사용하지만 나중에 운영서버로 넘어가게 되면 보안상 좋지 않을 뿐더러 저런 값은 전역에서 사용하는 "상수"이기 때문에 전역 변수에 따로 담아 관리하는 것이 좋습니다. 또한, 앱 전역에서 같은 변수명으로 접근할 수 있기 때문에 추후 값 변경을 하게 되더라도 수정이 용이해진다는 이점도 취할 수 있습니다.

 

env-cmd라는 라이브러리를 설치하면 환경변수 관리를 용이하게 할 수 있습니다.

 

yarn add env-cmd

 

서버 실행 전에 환경변수 파일을 사용할 수 있도록 스크립트를 수정해줍니다.

 

* package.json

"scripts": {
  "start": "env-cmd -f .env.development nodemon --exec node server.js"
},

 

프로젝트 루트 디렉터리에서는 .env.development 파일을 생성합니다.

 

* .env.development

FRONT_URL=http://localhost:3000
BACK_URL=http://localhost:4000
PORT=4000

 

이제 코드에서는 process.env 객체로 환경변수들에 접근할 수 있습니다. process.env.FRONT_URL, process.env.BACK_URL, process.env.PORT와 같이 사용하면 됩니다.

 

그리고 .gitignore에서 .env.development를 추가해줍니다.

 

나중에 개발서버와 테스트 서버, 프로덕션 서버에 대해 각각 환경변수를 따로따로 만들어줄 수도 있습니다. 자세한 것은 라이브러리 공식 문서를 참조하면 좋습니다.

 

 

 

2. 이벤트 타입 정의 분리

 

이벤트 타입은 스트링 타입의 상수값이고 여러 곳에서 사용됩니다. 또한 이벤트 타입은 재할당이 되지도 않습니다. 이런 특성들을 가진 이벤트 타입은 변수에 할당해주면 재사용성을 가지게 되고, 값 수정 및 관리하기 편해집니다. src/socket/index.js에서 소켓 로직을 작성했던 module.export 위에 SOCKET_EVENT라는 변수로 선언하겠습니다.

 

* src/socket/index.js

// ...

const SOCKET_EVENT = {
  JOIN: "JOIN",
  UPDATE_NICKNAME: "UPDATE_NICKNAME",
  SEND: "SEND",
  RECEIVE: "RECEIVE",
};

module.exports = function (socketIo) {
// ...

 

이벤트 리스너 코드는 다음과 같이 리팩터링할 수 있습니다.

 

socket.on(SOCKET_EVENT.SEND_MESSAGE, requestData => {
  const responseData = {
    ...requestData,
    type: SOCKET_EVENT.SEND_MESSAGE,
    time: new Date(),
  };
  socketIo.to(roomName).emit(SOCKET_EVENT.RECEIVE_MESSAGE, responseData);
  console.log(`${SOCKET_EVENT.SEND_MESSAGE} is fired with data: ${JSON.stringify(responseData)}`);
});

 

 

 

3. 중복 코드 제거 - 함수로 분리하기

 

로직들을 보면 중복해서 사용되는 패턴들이 있습니다. 지금 만든 소켓 이벤트 콜백 함수를 보면 그렇습니다. 우리가 따로 정의한 소켓 이벤트 콜백 함수들은 특정한 흐름을 가지고 있는데 "방에 처음 참가한 유저는 room 1에 할당해주기 -> 이벤트 타입과 함께 받은 데이터를 가공하여 응답해줄 데이터를 만들기 -> 클라이언트에 RECEIVE 이벤트 emit하기 -> 클라이언트에 응답해준 이벤트와 데이터를 로그에 출력하기"입니다.

 

이런 같은 흐름을 가진 콜백 함수들은 패턴의 중복성을 가지게 됩니다. 이러한 중복을 제거하는 것은 코드를 더 관리하기 쉽게 만듭니다. "방에 처음 참가한 유저인 경우 room 1에 할당해주기" 단계는 JOIN_ROOM 이벤트일 때에만 발생할 것이지만, 나머지 단계들은 모든 이벤트 타입이 거쳐야 할 단계입니다. 그것을 고려하여 콜백 함수를 하나로 통합해봅니다.

 

socket.on(type, requestData => {
  const firstVisit = type === SOCKET_EVENT.JOIN_ROOM;

  if (firstVisit) {
    socket.join(roomName);
  }

  const responseData = {
    ...requestData,
    type,
    time: new Date(),
  };
  socketIo.to(roomName).emit(SOCKET_EVENT.RECEIVE_MESSAGE, responseData);
  console.log(`${type} is fired with data: ${JSON.stringify(responseData)}`);
});

 

이벤트 리스너 코드는 위와 같이 하나의 코드로 만들 수 있습니다.

이제, 위에서 이벤트 타입들을 정의한 객체 SOCKET_EVENT를 이터러블로 만들어 이벤트 리스너들을 루프로 한 번에 설치할 수 있습니다.

 

Object.keys(SOCKET_EVENT).forEach(typeKey => {
  const type = SOCKET_EVENT[typeKey];

  socket.on(type, requestData => {
    const firstVisit = type === SOCKET_EVENT.JOIN_ROOM;

    if (firstVisit) {
      socket.join(roomName);
    }

    const responseData = {
      ...requestData,
      type,
      time: new Date(),
    };
    socketIo.to(roomName).emit(SOCKET_EVENT.RECEIVE_MESSAGE, responseData);
    console.log(`${type} is fired with data: ${JSON.stringify(responseData)}`);
  }); 
});

 

이렇게 해서 소켓 서버 백엔드 개발은 모두 완료되었습니다. 이제 클라이언트 쪽인 프론트엔드 개발로 넘어가보겠습니다.

 

 

다음 장. [채팅 웹사이트 구현 - 3장] React로 채팅 웹 만들기

 

 

 

  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기