Medium - Building a Node.js WebSocket Chat App with Socket.io and React

원문 - Vincent Mühler

Trend 파악을 위한 Medium 기고문 포스팅 - 소켓.io, 리액트를 사용해서 노드로 웹소켓 채팅 앱 만들기

The Walking Dead Flavor

웹소켓은 실시간 대화, 채팅이나 이미지, 미디어를 스트림하는 것과 같이 웹에서 데이터 스트리밍 응용프로그램을 만들 때 아주 유용합니다. 추가로 브라우저의 자바스크립트 웹소켓의 API를 활용하면 어떤 소켓 엔드포인트라도 쉽게 연결할 수 있습니다. 이 예제에서는 socket.io와 리엑트를 사용해서 소켓을 기반으로하는 간단한 채팅 앱을 만들어 보겠습니다.

Why WebSockets instead of HTTP(S)?

웹소켓을 사용하면 서버와 클라이언트 사이에 영구적인 연결을 만들 수 있습니다. 요즘에는 거의 HTTP를 사용한 REST API를 웹에서 사용할 것입니다. 이런 API들은 다음과 같을 때 사용됩니다. 클라이언트가 페이지나 자원을 요청하고 서버가 응답하는 것입니다 (request-response). 그러므로 HTTP 대신 웹소켓을 사용하면 다음과 같은 장점이 있습니다.

1.Realtime Communication

요청-응답 시나리오 에서는 서버는 클라이언트가 요청을 하지 않는 이상 데이터를 보낼 방법이 없습니다. 클라이언트는 지속적으로 특정 주기를 이용해 요청을 해야하는데 (polling), 이는 실시간 방식이 아닌 것이죠. 새로운 메시지를 봐야하는데 30초 간격으로 그것이 가능하다고 생각해보세요 꽤나 짜증날 겁니다.

HTTP 메시지를 이용하면 서버가 클라이언트에게 새로운 이벤트를 알려주는 것이 가능하긴 합니다. 클라이언트 브라우저에서 동작하고 있는 서비스 워커에 메시지를 날리거나 서버가 보내는 이벤트를 클라이언트가 구독하는 것이죠(server-sent event, SSE). 그러나 웹소켓을 사용하면 이런 특성들을 그냥 사용할 수 있으며 쉽게 전파를 통해서 연결된 클라이언트에게 새로운 메시지를 전달할 수 있습니다.

2.Less overhead

HTTP는 상태가 없는 프로토콜로 HTTP의 모든 단일 메시지에는 헤더가 포함되고 이것은 오버헤드가 됩니다. 이것은 특히나 빈번하게 작은 페이로드를 보내는 환경에서는 꽤나 크게 작용하게 됩니다. (페이로드와 헤더가 비슷하기 때문에 오버헤드가 그만큼 크게 된다.) 게다가 HTTP 커넥션은 대개 요청이 들어온 경우에만 살아있고 나머지는 종료되어 대기상태가 됩니다. 그렇기 때문에 빈번하게 연결이 재설정 된다면 TCP 3-way Handshake에 걸리는 시간이 소비될 것입니다.

3.Stream processing

웹소켓을 사용하면 클라이언트와 서버 사이의 바이너리 데이터를 자유롭게 처리할 수 있습니다. 이 때문에 이미지 프로세싱 앱과 같은 곳에서 작업을 처리하기에 매우 유용합니다. 이미지나 비디오 데이터를 앞뒤로 스트림 할 수 있는 것이죠. 이만하면 왜 웹소켓이 유용한지 감이 오셨을 겁니다.

Let’s finally code the app!

여기서 우리는 socket.io npm 페키지를 사용할 것입니다. 브라우저 단의 자바스크립트 클라이언트와 채팅 서버의 노드 웹소켓 API를 제공해 줍니다. 이 방식은 데이터를 JSON 형태로 시리얼라이즈 해서 패키징 하기 떄문에 실제 바이너리 데이터를 처리하지 않아도 됩니다.

So, what are we going to build?

그다지 대단한 것은 아닙니다. 클라이언트가 케릭터중 하나를 선택해서 연결합니다. 그리고 그 캐릭터는 채팅룸에 들어가거나 나올 수 있고 같은 채팅룸에 있는 사람들에게 메시지를 전달할 수 있죠. 제가 말씀드린 것을 시각화한 예제를 살펴보세요.

그럼 한번 만들어 봅시다.

Listening for incoming socket connections

이 예제에서 우리는 간단한 HTTP 서버에 socket.io를 붙일 겁니다. 만약 하고 싶다면 socket.io를 익스프레스에 주입하셔도 됩니다.

const server = require('http').createServer()
const io = require('socket.io')(server)

io.on('connection', function (client) {
  client.on('register', handleRegister)
  client.on('join', handleJoin)
  client.on('leave', handleLeave)
  client.on('message', handleMessage)
  client.on('chatrooms', handleGetChatrooms)
  client.on('availableUsers', handleGetAvailableUsers)
  client.on('disconnect', () => {
    console.log('client disconnect...', client.id)
    handleDisconnect()
  })

  client.on('error', err => {
    console.log('received error from client: ', client.id)
    console.log(err)
  })
})

server.listen(3000, (err) => {
  if ( err ) throw err
  console.log('listening on port 3000')
})

io.on('connection',cb)를 입력하면 들어오는 소켓 연결을 리스닝 할 수 있습니다. 새로운 클라이언트가 연결되면 스트림에다 여러개의 이벤트 리스너를 붙일 수 있습니다. 에러와 디스커넥트는 사전정의 되었습니다. 다른 이벤트들은 제가 말씀드린대로 채팅 API를 구현하기 위해 커스텀을 할 것입니다. 이 서버는 3000번 포트로 연결하는 소켓 클라이언트를 리스닝 할 것입니다.

Connecting to the socket server with a client

다시 한번 말씀드리지만 클라이언트 단은 socket.io-client가 필요합니다. 번들러를 사용하지 않는다면 (여기서는 웹팩을 사용합니다) 여러분의 문서에 io 클라이언트 스크립트를 포함시키셔야 할 겁니다.

const io = require('socket.io-client')

const socket = io.connect('http://localhost:3000')

const registerHandler = onMessageReceived => {
  socket.on('message', onMessageReceived)
}

const unregisterHandler = () => {
  socket.off('message')
}

socket.on('error', err => {
  console.log(`received socket error: ${err}`)
})

const register = ( name, cb ) => {
  socket.emit('register', name, cb)
}

const join = ( chatroomName, cb ) => {
  socket.emit('join', chatroomName, cb)
}

const leave = ( chatroomName, cb ) => {
  socket.emit('leave', chatroomName, cb)
}

const message = ( chatroomName, msg, cb ) => {
  socket.emit('message', { chatroomName, message: msg }, cb )
}

const getChatrooms = cb => {
  socket.emit('chatrooms', null, cb)
}

const getAvailableUsers = cb => {
  socket.emit('availableUsers', null, cb)
}

클라이언트에서는 socket.on에서 이벤트 리스너를 사용할 수 있으며 이벤트 리스너 제거는 socket.off를 사용해서 할 수 있습니다. registerHandler를 사용해서 onMessageReceived 콜백에 Chatroom 컴포넌트를 등록해서 새로 받은 메시지들을 출력하도록 할 것입니다. socket.emit은 커스텀 이벤트를 소비할 수 있습니다. 두번째 매개변수들은 실제 데이터를 전달하게 됩니다. 게다가 세번째 매개변수를 콜백으로 전달하면 요청-응답 형식의 커뮤니케이션을 구현할 수도 있습니다.

Implementing the API

우리의 채팅 서버는 모든 연결된 소켓들을 계속 추적하면서 어떤 캐릭터가 선택되었는지, 각 채팅룸의 상태는 어떤지 확인합니다. 예를 들면 채팅룸에 입장한 유저의 리스트와 채팅룸에 보내진 메시지들의 히스토리 같은 것들 말이죠. 먼저 우리가 추가해야 할 것은 클라이언트를 위한 엔드포인트로 케릭터를 선택하도록 하는 것입니다.

const handleRegister = ( userName, callback ) => {
  if ( !clientManager.isUserAvailable(userName) ) {
    return callback('user is not available')
  }

  const user = clientManager.getUserByName(userName)
  clientManager.registerClient(client, user)

  return callback(null, user)
}

우리는 콜백 패턴을 추가해서 첫번째 매개변수가 에러가 났을 경우 메시지와 함께 출력하도록 처리했습니다. 기본적으로 여기서 하는 것은 선택된 케릭터가 사용가능한지 검사하는 것이고 그렇지 않으면 클라이언트에게 에러메시지를 날려줍니다. 여기서 일어나는 더욱 흥미로운 점은 유저가 채팅방에 입장하고 나갈 때 혹은 메시지를 보낼 때 입니다. 이런 이벤트들과 일치하는 핸들러들이 다음과 같은 헬퍼 기능을 사용합니다.

const handleEvent = ( chatroomName, createEntry ) => {
  return ensureValidChatroomAndUserSeleceted(chatroomName)
  .then( {chatroom, user} => {
    const entry = { user, ...createEntry() }
    chatroom.addEntry(entry)

    chatroom.broadcastMessage({ chat: chatroomName, ...entry})
    return chatroom
  })  
}

위의 헬퍼함수는 전달받은 채팅룸과 앞에서 선택한 캐릭터가 유효한지 검사합니다. 그리고 채팅룸에 입장하거나 떠날 때 혹은 메시지를 보낼 때 채팅룸의 히스토리에 추가합니다. 추가적으로 채팅룸에 있는 모든 유저들에게 전파합니다.

const members = new Map()
let chatHistory = []

function broadcastMessage(message) {
  members.forEach(m => m.emit('message', message))
}

function addEntry(entry) {
  chatHistory = chatHistory.concat(entry)
}

function getChatHistory() {
  return chatHistory.slice()
}

function addUser(client) {
  members.set(client.id, client)
}

function removeUser(client) {
  members.delete(cleint.id)
}

function serialize() {
  return {
    name,
    image,
    numMembers: members.size
  }
}

간단하게 채팅 히스토리는 배열이며 채팅룸에 유저들은 모두 매핑되어 가지고 있을 것입니다. 이렇게 해서 우리는 메시지를 모든 클라이언트에게 전파할 수 있습니다. 실제적은 핸들러는 다른 것과 비슷하기 때문에 채팅방 입장 핸들러만 보여드리겠습니다.

function handleJoin(chatroomName, callback) {
  const createEntry = () => ({
    event: `joined ${chatroomName}`
  })

  handleEvent(chatroomName, createEntry)
  .then(function ( chatroom ) {
    chatroom.addUser(client)

    callback(null, chatroom.getChatHistory())
  })
  .catch(callback)
}

이벤트를 처리한 다음에 우리는 채팅룸에 유저를 추가하고 현재 채팅 기록에도 추가합니다. 마지막으로 클라이언트가 연결을 종료하면 유저를 모든 채팅룸에서 삭제하고 클라이언트 정보를 삭제해서 다른 사용자들이 그 캐릭터를 선택할 수 있도록 할 것입니다.

function handleDisconnect() {
  clientManager.removeClient(client)
  chatroomManager.removeClient(client)
}

Displaying messages in realtime on the client

채팅룸 컴포넌트에 다음과 같이 추가합시다.

export default class Chatroom extends React.Component {

  constructor(props, context) {
    super(props, context)

    const { chatHistory } = props

    this.state = {
      chatHistory,
      input: ''
    }

    this.updateChatHistory = this.updateChatHistory.bind(this)
    this.onMessageReceived = this.onMessageReceived.bind(this)
    this.onSendMessage = this.onSendMessage.bind(this)
    ...
  }

  componentDidMount() {
    this.props.registerHandler(this.onMessageReceived)
  }

  componentDidUpdate() {
    this.scrollChatToBottom()
  }

  componentWillUnmount() {
    this.props.unregisterHandler()
  }

  updateChatHistory(entry) {
    this.setState({ chatHistory: this.state.chatHistory.concat(entry) })
  }

  onMessageReceived(entry) {
    this.updateChatHistory(entry)
  }

  onSendMessage() {
    if (!this.state.input)
    return

    this.props.onSendMessage(this.state.input, (err) => {
      if (err)
      return console.error(err)

      return this.setState({ input: ' '})
    })
  }
  ...
}

새로운 채팅방에 들어가면 채팅 서버가 조인 이벤트를 받고 채팅 히스토리를 전달해줍니다. 그리고 우리는 그것을 초기화 할 겁니다. 컴포넌트가 마운트되면 onMessageReceived 콜백을 클라이언트 API 핸들러에 추가합니다. 등록되고 나면 클라이언트는 채팅 서버가 브로드캐스팅 하는 메시지 이벤트를 받게 됩니다. 이 방식은 새로운 메시지가 도착하는 즉시 바로 업데이트 되게 할 수 있습니다.

Summary

  • HTTP 기반 REST API 대신 소켓을 쓰는 이유는 실시간, 페이로드가 작을 경우 헤더가 오버헤드가 될 수 있다는 점을 소켓을 이용한 실시간 통신이 커버되기 때문이다
  • HTTP3이 나오고 HTTP 기반 REST API 호출을 거의 실시간 처럼 많이 날려도 레디스만 잘쓰면 무리없이 잘 버티더라 라는 조언도 들었다. 이미지/비디오와 같은 미디어 스트림이 아닌 이상 소켓을 쓰는 것은 오버엔지니어링이 아닌지 고민해봐야 할 부분이다.

© 2019. All rights reserved.

Powered by Hydejack v8.1.1