프로그래밍 기초

Socket.IO 소개 2 - Event

hs-archive 2023. 7. 28. 15:09

이전 글...

Socket.IO 소개 1 - server

 

Emitting events

서버와 클라이언트 간에 이벤트를 보내는 방법에는 여러 가지가 있습니다.

 

Basic emit

// Server
io.on("connection", (socket) => {
  // "world"라는 데이터를 담은 
  // "hello"라는 event를 보냅니다.
  socket.emit("hello", "world");
});



// Client
// "hello"라는 이벤트에 대한 리스너입니다. 
socket.on("hello", (arg) => {
  // 해당 이벤트가 보낸 arg를 출력합니다.
  console.log(arg); // world
});

Socket.IO API는 Node.js EventEmitter에서 영감을 얻었습니다. 한쪽에서는 이벤트를 emit하고 다른 쪽에서는 리스너를 등록할 수 있습니다.

 

// Server
io.on("connection", (socket) => {
  socket.emit("hello", 1, "2", { 3: '4', 5: Buffer.from([6]) });
});



// Client
socket.on("hello", (arg1, arg2, arg3) => {
  console.log(arg1); // 1
  console.log(arg2); // "2"
  console.log(arg3); // { 3: '4', 5: ArrayBuffer (1) [ 6 ] }
});

원하는 만큼의 arguments를 보낼 수 있으며 Buffer 또는 TypedArray와 같은 이진 객체를 포함하여 직렬화 가능한 모든 데이터 구조가 지원됩니다.

 

// There is no need to run JSON.stringify() on objects as it will be done for you.

// BAD
socket.emit("hello", JSON.stringify({ name: "John" }));

// GOOD
socket.emit("hello", { name: "John" });



// DO
const serializedMap = [...myMap.entries()];
const serializedSet = [...mySet.keys()];

객체에 대해 JSON.stringify()를 실행할 필요가 없지만 Map과 Set은 수동으로 직렬화해야 합니다.

 

class Hero {
  #hp;

  constructor() {
    this.#hp = 42;
  }

  toJSON() {
    return { hp: this.#hp };
  }
}

socket.emit("here's a hero", new Hero());

toJSON() 메서드를 사용하여 개체의 직렬화를 사용자 지정할 수도 있습니다.

 

Acknowledgements

// Server
io.on("connection", (socket) => {
  socket.on("update item", (arg1, arg2, callback) => {
    console.log(arg1); // 1
    console.log(arg2); // { name: "updated" }
    callback({
      status: "ok"
    });
  });
});



// Client
socket.emit("update item", "1", { name: "updated" }, (response) => {
  console.log(response.status); // ok
});

Events는 훌륭하지만 경우에 따라 보다 고전적인 request-response API가 필요할 수 있습니다. Socket.IO에서 이 기능은 Acknowledgements라고 합니다. emit()의 마지막 인수로 콜백을 추가할 수 있으며 이 콜백은 상대방이 이벤트를 인정(acknowledges)하면 호출됩니다.

 

With timeout

socket.timeout(5000).emit("my-event", (err) => {
  if (err) {
    // 주어진 시간 (5000ms) 동안 상대방이 ack를 하지 않으면 error가 발생
  }
});

Socket.IO v4.4.0부터 각 emit에 대한 시간 제한(timeout)을 할당할 수 있습니다.

 

socket.timeout(5000).emit("my-event", (err, response) => {
  if (err) {
    // 주어진 시간 (5000ms) 동안 상대방이 ack를 하지 않으면 error가 발생
  } else {
    console.log(response);
  }
});

timeout과 acknowledgement를 함께 사용할 수도 있습니다.

 

Volatile events

socket.volatile.emit("hello", "might or might not be received");

volatile events는 기본 연결이 준비되지 않은 경우 전송되지 않는 이벤트입니다. (reliability 측면에서 UDP와 비슷함) 예를 들어, 온라인 게임에서 캐릭터의 위치를 보내야 하는 경우(최신 값만 유용하므로) 이 기능이 흥미로울 수 있습니다.

 

// Server
io.on("connection", (socket) => {
  console.log("connect");

  socket.on("ping", (count) => {
    console.log(count);
  });
});

// Client
let count = 0;
setInterval(() => {
  socket.volatile.emit("ping", ++count);
}, 1000);



// 서버를 다시 시작하면 콘솔에 다음이 표시됩니다.
connect
1
2
3
4
// 서버가 다시 시작되면 클라이언트가 자동으로 다시 연결됩니다.
connect
9
10
11

// volatile 플래그가 없으면 다음과 같이 표시됩니다.
connect
1
2
3
4
// 서버가 다시 시작되면 클라이언트가 자동으로 다시 연결되고 클라이언트는 서버에게 버퍼링된 이벤트를 보냅니다.
connect
5
6
7
8
9
10
11

또 다른 사용 사례는 클라이언트가 연결되지 않은 경우 이벤트를 삭제하는 것입니다. (일반적으로 이벤트는 다시 연결할 때까지 버퍼링되는 것과는 상반됨)

 

Listening to events

서버와 클라이언트 간에 전송되는 이벤트를 핸들링하는 방법에는 여러 가지가 있습니다.

 

EventEmitter methods

서버 측에서 Socket 인스턴스는 Node.js EventEmitter 클래스를 확장합니다. 클라이언트 측에서 Socket 인스턴스는 EventEmitter 메서드의 하위 집합을 노출하는 component-emiiter 라이브러리에서 제공하는 emitter를 사용합니다.

 

socket.on(eventName, listener)

socket.on("details", (...args) => {
  // ...
});

eventName이라는 이벤트에 대한 listener 함수를 listeners array의 끝에 추가합니다.

 

socket.once(eventName, listener)

socket.once("details", (...args) => {
  // ...
});

eventName이라는 이벤트에 대한 일회성 리스너 함수를 추가합니다.

 

socket.off(eventName, listener)

const listener = (...args) => {
  console.log(args);
}

socket.on("details", listener);

// and then later...
socket.off("details", listener);

eventName이라는 이벤트에 대한 특정 listener를 listener array에서 제거합니다.

 

socket.removeAllListeners([eventName])

// for a specific event
socket.removeAllListeners("details");
// for all events
socket.removeAllListeners();

모든 listener 혹은 지정된 eventName을 listener array에서 제거합니다.

 

Catch-all listeners

Socket.IO v3부터 EventEmitter2 라이브러리에서 영감을 받은 새로운 API를 통해 범용 listener를 선언할 수 있습니다. 이 기능은 서버, 클라이언트 모두에서 사용할 수 있습니다.

 

socket.onAny(listener)

const listener = (eventName, ...args) => {
  console.log(eventName);
  console.log(args);
}
socket.onAny(listener);

상대방이 이벤트를 emit할 때 실행될 listener를 추가합니다. 어느 이벤트를 수신하던 해당 listener가 실행되므로 이 listener를 범용 리스너라고 합니다.

 

socket.prependAny(listener)

socket.prependAny((eventName, ...args) => {
  // ...
});

상대방이 이벤트를 emit할 때 실행될 리스너를 추가합니다. 리스너는 listeners array의 "시작" 부분에 추가됩니다.

 

socket.offAny([listener])

const listener = (eventName, ...args) => {
  console.log(eventName, args);
}

socket.onAny(listener);

// and then later...
socket.offAny(listener);

// or all listeners
socket.offAny();

모든 범용 리스너 또는 지정된 리스너를 제거합니다.

 

socket.onAnyOutgoing(listener)

socket.onAnyOutgoing((event, ...args) => {
  // ...
});

발신(outgoing) 패킷에 대한 범용 리스너를 등록합니다.

 

socket.prependAnyOutgoing(listener)

socket.prependAnyOutgoing((event, ...args) => {
  // ...
});

발신(outgoing) 패킷에 대한 범용 리스너를 등록합니다. 리스너는 listeners array의 "시작" 부분에 추가됩니다.

 

socket.offAnyOutgoing([listener])

const listener = (eventName, ...args) => {
  console.log(eventName, args);
}

socket.onAnyOutgoing(listener);

// remove a single listener
socket.offAnyOutgoing(listener);

// remove all listeners
socket.offAnyOutgoing();

이전에 등록된 리스너를 제거합니다. 리스너가 제공되지 않으면 모든 범용 리스너가 제거됩니다.

 

Validation

이벤트 arguments의 유효성 검사는 Socket.IO 라이브러리의 범위를 벗어납니다. JS 생태계에는 이 사용 사례를 다루는 많은 패키지가 있습니다.

  - joi

  - ajv

  - validatorjs

 

아래는 joi와 함께 acknowledgements를 사용하는 예시입니다.

const Joi = require("joi");

const userSchema = Joi.object({
  username: Joi.string().max(30).required(),
  email: Joi.string().email().required()
});

io.on("connection", (socket) => {
  socket.on("create user", (payload, callback) => {
    if (typeof callback !== "function") {
      // not an acknowledgement
      return socket.disconnect();
    }
    const { error, value } = userSchema.validate(payload);
    if (error) {
      return callback({
        status: "Bad Request",
        error
      });
    }
    // do something with the value, and then
    callback({
      status: "OK"
    });
  });

});

 

에러 핸들링

io.on("connection", (socket) => {
  socket.on("list items", async (callback) => {
    try {
      const items = await findItems();
      callback({
        status: "OK",
        items
      });
    } catch (e) {
      callback({
        status: "NOK"
      });
    }
  });
});



// 위 코드는 다음과 같이 리팩터링할 수 있습니다.
const errorHandler = (handler) => {
  const handleError = (err) => {
    console.error("please handle me", err);
  };

  return (...args) => {
    try {
      const ret = handler.apply(this, args);
      if (ret && typeof ret.catch === "function") {
        // async handler
        ret.catch(handleError);
      }
    } catch (e) {
      // sync handler
      handleError(e);
    }
  };
};

// server or client side
socket.on("hello", errorHandler(() => {
  throw new Error("let's panic");
}));

현재 Socket.IO 라이브러리에는 오류 처리 기능이 내장되어 있지 않으므로 발생할 수 있는 모든 오류를 listener에서 잡아야 합니다.

 

Broadcasting events

Socket.IO를 사용하면 연결된 모든 클라이언트에게 이벤트를 쉽게 emit할 수 있습니다. 참고로 broadcasting은 서버 전용 기능입니다.

 

To all connected clients

모든 클라이언트에게 전송

// 모든 클라이언트에게 
// "world"라는 데이터를 담은 
// "hello"라는 이벤트를 emit합니다.
io.emit("hello", "world");

 

To all connected clients except the sender

발신자를 제외한 현재 네임스페이스의 모든 클라이언트에게 전송

io.on("connection", (socket) => {
  socket.broadcast.emit("hello", "world");
});

발신자를 제외한 현재 네임스페이스의 모든 클라이언트에게 전송합니다. 예를 들어 기존 client B, client C가 server와 접속해 있을 때 client A가 server와 접속을 하면 서버는 client A를 제외한 client B와 client C에게 "world"라는 데이터가 담긴 "hello"라는 이벤트를 전송합니다.

 

With acknowledgements 

io.timeout(5000).emit("hello", "world", (err, responses) => {
  if (err) {
    // 주어진 5000ms 동안 어떤 clinet가 해당 이벤트에대한 ack를 하지 않으면 발생.
  } else {
    console.log(responses); // one response per client
  }
});

Socket.IO v4.5.0부터 여러 클라이언트에게 이벤트를 브로드캐스트하고 각 클라이언트로부터 ack를 기대할 수 있습니다.

 

// 특정 room
io.to("room123").timeout(5000).emit("hello", "world", (err, responses) => {
  // ...
});

// 특정 socket
socket.broadcast.timeout(5000).emit("hello", "world", (err, responses) => {
  // ...
});

// 특정 namespace
io.of("/the-namespace").timeout(5000).emit("hello", "world", (err, responses) => {
  // ...
});

모든 broadcasting 형식이 지원됩니다.

 

With multiple Socket.IO servers

브로드캐스팅은 여러 Socket.IO 서버에서도 작동합니다. Redis 어댑터 또는 다른 호환 어댑터로 기본 어댑터를 교체하기만 하면 됩니다.

 

io.local.emit("hello", "world"); // local 플래그 사용

경우에 따라 현재 서버에 연결된 클라이언트에만 브로드캐스트할 수 있습니다. 로컬 플래그를 사용하여 이를 달성할 수 있습니다.

 

Rooms

rooms

(room)은 소켓이 가입하고(join) 나갈 수 있는(leave) 임의의 채널입니다. 클라이언트 하위 집합에 이벤트를 브로드캐스트하는 데 사용할 수 있습니다. 방은 서버 전용 개념입니다. 즉, 클라이언트는 자신이 참가한 방 목록에 액세스할 수 없습니다.

 

Joining and leaving

io.on("connection", (socket) => {
  socket.join("some room");
});

소켓이 지정된 채널(방)을 구독하도록 join을 호출합니다.

 

io.to("some room").emit("some event");

그런 다음 브로드캐스팅하거나 emit할 때 to 또는 in(to나 in이나 같은 동작입니다.)을 사용하면 됩니다.

 

io.to("room1").to("room2").to("room3").emit("some event");

동시에 여러 방에 emit할 수도 있습니다. 위의 경우 room1, room2, room3에 속한 socket과 연결된 모든 client에게 emit합니다. 이 경우 union이 수행됩니다. 소켓이 둘 이상의 방에 있는 경우에도 이벤트는 한 번만 받습니다.

 

io.on("connection", (socket) => {
  socket.to("some room").emit("some event");
});

위와 같이 주어진 소켓에서 방으로 브로드캐스트할 수도 있습니다. 이 경우 발신자를 제외한 룸 안의 모든 소켓이 이벤트를 받습니다. 채널(방)을 나가려면 join할 때와 동일한 방식으로 leave를 호출하면 됩니다.

 

Sample use cases 

io.on("connection", async (socket) => {
  const userId = await fetchUserId(socket);

  socket.join(userId);

  // and then later
  io.to(userId).emit("hi");
});

주어진 사용자의 각 device/탭에 데이터 브로드캐스트

 

io.on("connection", async (socket) => {
  const projects = await fetchProjects(socket);

  projects.forEach(project => socket.join("project:" + project.id));

  // and then later
  io.to("project:4321").emit("project updated");
});

주어진 엔티티에 대한 notifications 보내기

 

Disconnection

io.on("connection", socket => {
  socket.on("disconnecting", () => {
    console.log(socket.rooms); // the Set contains at least the socket ID
  });

  socket.on("disconnect", () => {
    // socket.rooms.size === 0
  });
});

연결이 끊어지면 소켓은 자동으로 모든 채널을 떠납니다. 사용자 측에서 특별한 작업이 필요하지 않습니다. disconnecting event를 수신하여 소켓이 있던 방을 가져올 수도 있습니다.

 

With multiple Socket.IO servers

broadcasting to rooms

글로벌 브로드캐스팅(위에서 배운 Broadcasting events의 With multiple Socket.IO servers 단락 참조)과 마찬가지로 rooms에 대한 브로드캐스팅도 여러 Socket.IO 서버에서 작동합니다. 기본 어댑터를 Redis 어댑터로 교체하기만 하면 됩니다. 자세한 내용은 여기를 참조하세요.

 

Implementation details 

"room" 기능은 우리가 어댑터라고 부르는 것에 의해 구현됩니다. 이 어댑터는 다음을 담당하는 서버 측 구성 요소입니다.

  - 소켓 인스턴스와 방 사이의 관계 저장

  - 모든(또는 일부) 클라이언트에게 이벤트 브로드캐스팅

 

여기에서 기본 in-memory adapter 코드를 볼 수 있습니다. 기본적으로 아래 두 개의 ES6 Maps로 구성됩니다.

  - sides: Map<SocketId, Set<Room>>

  - rooms: Map<Room, Set<SocketId>>

 

socket.join("the-room")을 호출하면 다음과 같은 결과가 발생합니다.

  - sides 맵에서 소켓 ID로 식별되는 Set에 "the-room"을 추가합니다. 

  - rooms 맵에서 문자열 "the-room"으로 식별되는 Set에 소켓 ID를 추가합니다.

 

이 두 맵(sides와 rooms)은 broadcasting할 때 사용됩니다.

  - 모든 소켓에 대한 브로드캐스트(io.emit())는 sides 맵을 통해 루프하며 모든 소켓에 패킷을 보냅니다.

  - 주어진 방에 대한 브로드캐스트(e.g., io.to("room21").emit())는 rooms 맵의 세트를 통해 루프하며 일치하는 모든 소켓에 패킷을 보냅니다.

 

다음을 사용하여 이러한 개체에 액세스할 수 있습니다.

// main namespace
const rooms = io.of("/").adapter.rooms;
const sids = io.of("/").adapter.sids;

// custom namespace
const rooms = io.of("/my-namespace").adapter.rooms;
const sids = io.of("/my-namespace").adapter.sids;

 

참고 사항:

  - 이러한 개체는 직접 수정할 수 없으므로 항상 socket.join(...) 및 socket.leave(...)를 사용해야 합니다.

  - 다중 서버 설정에서 rooms 및 sides 개체는 Socket.IO 서버 간에 공유되지 않습니다. (room은 다른 서버가 아닌 한 서버에만 존재할 수 있음)

 

Room events 

socket.io@3.1.0부터 기본 어댑터는 다음 이벤트를 emit합니다. 아래 join과 leave의 argument에 있는 id는 socketID를 뜻합니다.

 

  - create-room (argument: room): room을 만들 때 해당 이벤트가 emit됩니다.

  - delete-room (argument: room): room을 없앨 때 해당 이벤트가 emit됩니다.

  - join-room (argument: room, id): room에 join할 때 해당 이벤트가 emit됩니다. 

  - leave-room (argument: room, id): room을 leave할 때 해당 이벤트가 emit됩니다. 

 

example:

io.of("/").adapter.on("create-room", (room) => {
  console.log(`room ${room} was created`);
});

io.of("/").adapter.on("join-room", (room, id) => {
  console.log(`socket ${id} has joined room ${room}`);
});

 

Emit cheatsheet

Server-side

io.on("connection", (socket) => {

  // 상대 sender에게 emit
  socket.emit(/* ... */);

  // sender를 제외한 현재 네임스페이스의 모든 클라이언트에게 emit
  socket.broadcast.emit(/* ... */);

  // sender를 제외한 "room1"의 모든 클라이언트에게 emit
  socket.to("room1").emit(/* ... */);

  // sender를 제외한 room1, room2의 모든 클라이언트에게 emit
  socket.to(["room1", "room2"]).emit(/* ... */);



  // "room1"의 모든 클라이언트에게 emit
  io.in("room1").emit(/* ... */);

  // "room3"를 제외한 "room1", "room2"의 모든 클라이언트에게 emit
  io.to(["room1", "room2"]).except("room3").emit(/* ... */);

  // "myNamespace" 네임스페이스의 모든 클라이언트에게 emit
  io.of("myNamespace").emit(/* ... */);

  // "myNamespace" 네임스페이스의 "room1"에 있는 모든 클라이언트에게 emit
  io.of("myNamespace").to("room1").emit(/* ... */);

  // 개별 socketId로 emit (비공개 메시지)
  io.to(socketId).emit(/* ... */);

  // 이 노드의 모든 클라이언트에게 emit (여러 노드를 사용하는 경우)
  io.local.emit(/* ... */);

  // 연결된 모든 클라이언트에게 emit
  io.emit(/* ... */);

  // 클라이언트 하나당 하나의 ack로 아래 로직을 실행함. 모든 클라이언트에게 emit
  io.timeout(5000).emit("hello", "world", (err, responses) => {
    if (err) {
      // some clients did not acknowledge the event in the given delay
    } else {
      console.log(responses); // one response per client
    }
  });

  // WARNING: `socket.to(socket.id).emit()` will NOT work, as it will send to everyone in the room
  // named `socket.id` but the sender. Please use the classic `socket.emit()` instead.

  // ack와 함께 emit
  socket.emit("question", (answer) => {
    // ...
  });

  // 압축(compress)하지 않고 emit
  socket.compress(false).emit(/* ... */);

  // 저수준 전송이 사용 불가능 한 경우 삭제될 수도 있는 메시지를 emit. 
  // 다시 말해, 연결 끊기면 해당 메시지는 안 도달하지 못할 수도 있음.
  socket.volatile.emit(/* ... */);

  // timeout 설정과 함께 emit
  socket.timeout(5000).emit("my-event", (err) => {
    if (err) {
      // the other side did not acknowledge the event in the given delay
    }
  });
});

 

 

Client-side

// 기본 emit
socket.emit(/* ... */);

// ack와 함께 emit
socket.emit("question", (answer) => {
  // ...
});

// 압축하지 않고 emit
socket.compress(false).emit(/* ... */);

// 저수준 전송이 쓸 수 없는 경우 삭제될 수도 있는 메시지
socket.volatile.emit(/* ... */);

// timeout과 함께 emit
socket.timeout(5000).emit("my-event", (err) => {
  if (err) {
    // the other side did not acknowledge the event in the given delay
  }
});

 

Reserved events

아래 이벤트는 사전에 예약되어 있으며, 따라서 애플리케이션에서 이벤트 이름으로 사용해선 안 됩니다.

  - connect

  - connect_error

  - disconnect

  - disconnecting

  - newListener

  - removeListener 

 

// BAD, will throw an error
// "disconnecting"은 사전 예약된 이벤트 이름이므로,
// 애플리케이션에서 사용해선 안 됩니다.
socket.emit("disconnecting");

 

 

 

 

 


https://socket.io/docs/v4

 

Introduction | Socket.IO

What Socket.IO is

socket.io

'프로그래밍 기초' 카테고리의 다른 글

PostgreSQL vs MySQL  (0) 2023.08.12
Socket.IO 소개 3 - adapter  (0) 2023.07.28
Socket.IO 소개 1 - Server  (0) 2023.07.27
CORS란 (Origin, SOP, CORS, CSP)  (0) 2023.07.19
실시간 통신 - WebSocket이란  (0) 2023.07.18