Node 강의/주특기 플러스

1-4 패킷 구조 설계하기 및 코드 구현

kagan-draca 2024. 10. 17. 21:07

1. 패킷 구조 설계하기

기존에 Web Socket Server 통신 설계에서는 

위와 같은 데이터 구조로 설계를 했다면

 

TCP Server 통신 설계에서는

위와 같은 데이터 구조로 크기라는 항목이 추가 돼 있습니다.

 

각각의 크기는

totalLength는 4Byte, handlerId는 2Byte, message는 가변(어떤 데이터냐에 따라 다르기 때문에 가변)인 크기를 가집니다.

 

message에는 String 타입의 값이 들어오게 되고, 간단한 예시로 데이터 교환을 확인해보겠습니다.

위와 같이 Byte 배열이 들어왔을 때, 앞에서 4Byte 만큼이 TotalLength 입니다. 그럼

 

왼쪽에서부터 4개의 Byte가 TotalLength 가 됩니다. HandlerId는 2Byte이므로

TotalLength 이후의 2개의 Byte가 HandlerId가 됩니다.

 

그 후, 나머지가 

message에 속하게 됩니다.

 

우리는 이런 식으로 Buffer 객체에서 해당 정보의 길이 만큼 뽑아와 사용해야 합니다.

 

그래서, 우리는 아래의 그림과 같이 6byte 크기의 정보를 Header라고 부릅니다.

2. VsCode에서 코드로 구현해보기

 

constants.js를 만들어줍니다. 그리고,

export const TOTAL_LENGTH_SIZE = 4;
export const HANDLER_ID = 2;

같이 상수로 TotalLength와 HandlerId의 Size를 적어줍니다.

 

그리고, utils.js 파일을 만들어,

// 해더를 읽는 함수
export const readHeader = () =>{

}

// 해더를 쓰는 함수
export const writeHeader = () =>{

}

 

해더를 읽는 함수와 해더를 쓰는 함수를 만들어줍니다.

그리고, readHeader는 해더를 읽는 함수니깐 매개변수로 buffer를 가져 옵니다.

 

3. 해더를 읽는 함수 구현

// 해더를 읽는 함수
export const readHeader = (buffer) =>{

    // 앞의 4Byte가 TotalLength(전체) 길이니깐
    const length = buffer.readUInt32BE(0);
    //전체 길이는 앞에서부터 32bit를 읽어옵니다(빅인디안 방식)
    //32bit = 4Byte

    // 빅인디안 방식 : 버퍼를 앞에서 부터 읽어온다.
    // 리틀인디안 방식 : 버퍼를 뒤에서 부터 읽어온다.
}

그리고, 앞에서 부터 4Byte가 TotalLength(전체 길이)가 될 것이기 때문에

    const length = buffer.readUInt32BE(0);

위와 같이 앞에서 부터 32 bit를 읽어옵니다.

 

add) 

방식 설명
빅인디안 버퍼를 앞에서 부터 읽어온다.
리틀인디안 버퍼를 뒤에서 부터 읽어온다.

 

동일한 방식으로 HandlerId 2Byte를 읽어옵니다.

// 해더를 읽는 함수
export const readHeader = (buffer) => {
  // 앞의 4Byte가 TotalLength(전체) 길이니깐
  const length = buffer.readUInt32BE(0);
  //전체 길이는 앞에서부터 32bit를 읽어옵니다(빅인디안 방식)
  //32bit = 4Byte

  // 빅인디안 방식 : 버퍼를 앞에서 부터 읽어온다.
  // 리틀인디안 방식 : 버퍼를 뒤에서 부터 읽어온다.
  const handlerId = buffer.readUInt16BE(TOTAL_LENGTH_SIZE);
  // handlerId 또한, buffer에서 읽어올 것인데 2Byte이기 때문에 readUInt16BE로 읽어옵니다.
  // 16bit = 2Byte
  // 이때, 시작 위치는 TotalLength를 읽어온 이후 위치부터이기 떄문에
  // constant.js에 상수로 적은 TOTAL_LENGTH_SIZE를 offset 값으로 적어줍니다.

  return { length, handerId };
};

이때, 2Byte는 16bit이므로 byffer.readUInt16BE()로 읽어올 수 있고,

offset 값으로는 TOTAL_LENGTH_SIZE로 적어 TotalLength(전체) 길이

다음의 위치부터 읽어올 수 있게 만들어줍니다.

 

위의 코드를 간략하게 작성해보면,

 

export const readHeader = (buffer) => {
  return { length: buffer.readUInt32BE(0), handlerId: buffer.readUInt16BE(TOTAL_LENGTH_SIZE) };

  // 앞의 4Byte가 TotalLength(전체) 길이니깐
  // 전체 길이는 앞에서부터 32bit를 읽어옵니다(빅인디안 방식)
  // 32bit = 4Byte

  // handlerId 또한, buffer에서 읽어올 것인데 2Byte이기 때문에 readUInt16BE로 읽어옵니다.
  // 16bit = 2Byte
  // 이때, 시작 위치는 TotalLength를 읽어온 이후 위치부터이기 떄문에
  // constant.js에 상수로 적은 TOTAL_LENGTH_SIZE를 offset 값으로 적어줍니다.
};

줄일 수 있습니다.

 

4. 해더를 쓰는 함수 구현

해더를 쓰는 함수에는 Buffer에 쓸 messageLength(메시지 길이)와

handlerId(핸들러 아이디)를 적어줘야 합니다.

그래서 매개변수에 messageLength, handlerId를 작성해줍니다.

 

// 해더를 쓰는 함수
export const writeHeader = (messageLength, handlerId) => {
  //                  messageLength : 메시지(message)의 길이
  const headerSize = TOTAL_LENGTH_SIZE + HANDLER_ID;
  // Buffer에 길이와 핸들러 Id를 적어줘야 하기 때문에
  // headerSize로 constant.js의 TOTAL_LENGTH_SIZE + HANDLER_ID로 할당해줍니다.
  const buffer = Buffer.alloc(headerSize);
  // 그리고 Buffer.alloc(headerSize)로 headerSize만큼의 Byte 배열을 만들어줍니다.

  buffer.writeUint32BE(messageLength + headerSize, 0);
  // 이제 Byte 배열에 앞에서 부터 32bit(4Byte) 만큼 써 줄 것 입니다.
  // buffer에 쓸 내용은 message의 길이와 headerSize(해더) 크기를 같이 써준 크기가 돼야 합니다.
  // => 우리가 전송하는 진짜 Byte 크기

  buffer.writeUint16BE(handlerId, TOTAL_LENGTH_SIZE)
  // 마지막으로, handlerId도 Total_LENGTH_SIZE 다음 위치부터 크기 만큼 작성해줍니다.
  return buffer;
};

 

header의 사이즈는 메시지의 전체 길이와 HanderId의 크기 일 것이기 때문에,

  const headerSize = TOTAL_LENGTH_SIZE + HANDLER_ID;

으로 작성해주고,

 

  const buffer = Buffer.alloc(headerSize);

Buffer.alloc(headerSize) 만큼 Buffer의 공간을 만들어주고,

  buffer.writeUint32BE(messageLength + headerSize, 0);

buffer에 32bit(4Byte) 크기 만큼 메시지의 크기와 해더의 크기 만큼 써줍니다.

 

마지막으로 어떤 HandlerId에서 해당 데이터를 처리해줄 것인지가 필요하기 때문에

  buffer.writeUint16BE(handlerId, TOTAL_LENGTH_SIZE)
  // 마지막으로, handlerId도 Total_LENGTH_SIZE 다음 위치부터 크기 만큼 작성해줍니다.

handlerId를 TOTAL_LENGTH_SIZE에 다음 위치에 16bit(2Byte) 크기 만큼 작성해줍니다.

 

이제 Client에서 Server로 보낼 내용을 전송해보겠습니다.

 

5. Client에서 message 전송 수정하기

client.connect(PORT, HOST, () => {
  // 서버와 연결 과정
  console.log('Connected To The Server...');



  const message = 'Hello';
  const buffer = Buffer.from(message);
  // 서버 연결과 동시에 Hello라는 문자열을 버퍼에 담는다.
  client.write(buffer);
  // 서버에게 buffer를 data로 전송한다.
});

 

현재 client.js의 Buffer에는 message만을 담아 Server로 보내고 있습니다.

하지만, 위에서 우리는 message의 길이, 어떤 Handler에서 Data를 가공할 것인지 정하였습니다.

 

따라서, message의 길이와 HanderId에서 Data를 가공할 id를 Buffer에 넣어줘야 합니다.

 

  const header = writeHeader(buffer.length, 10);
  // writeHeader()로 message와 header 합치기 buffer의 길이 만큼 만들어줍니다.
  // 먼저 우리가 만든 함수 writeHeader로 buffer의 길이
  // 만큼 지정하고 handerId를 10으로 설정합니다.

const header에 우리가 만든 writeHeader()로 buffer의 길이 만큼 지정해주고, handerId는 10을 사용한다고 정하겠습니다.

 

그 후,

  const packet = Buffer.concat([header, buffer]);

Buffer.concat()로 header와 buffer를 하나의 Buffer로 연결해줍니다.

  client.write(packet);

그렇게 만들어진 Buffer인 packet을 서버에 보내줍니다.

 

수정된 코드 : 

 

client.connect(PORT, HOST, () => {
  // 서버와 연결 과정
  console.log('Connected To The Server...');

  const message = 'Hello';
  const buffer = Buffer.from(message);
  // 서버 연결과 동시에 Hello라는 문자열을 버퍼에 담는다.

  const header = writeHeader(buffer.length, 10);
  // writeHeader()로 message와 header 합치기 buffer의 길이 만큼 만들어줍니다.
  // 먼저 우리가 만든 함수 writeHeader로 buffer의 길이
  // 만큼 지정하고 handerId를 10으로 설정합니다.
  const packet = Buffer.concat([header, buffer]);
  // 이제 header 배열과 buffer 배열을 합쳐서
  client.write(packet);
  // 서버에게 packet을 data로 전송합니다.
});

6. Server에서 Client가 보낸 message 수신 수정하기

  socket.on('data', (data) => {
    // 서버가 클라이언트로부터 데이터를 받을 때 마다 발생
    // data는 버퍼 형태로 제공되며, 이를 문자열로 변환하거나 원하는 형식으로 처리할 수 있다.
   
    console.log(data);
    socket.write(data);
    // data를 보낸 Client의 주소로 data를 그대로 되돌려 준다.
  });

이 부분에서

const { length, handlerId } = readHeader(data)

 서버는 utils.js에서 우리가 만든 readHeader를 가져옵니다.

 

그리고, length와 handlerId를 객체분해 할당으로 바로 가져옵니다.

 

    console.log('length : ', length);
    console.log('handlerId : ', handlerId);

console.log()로 length와 handlerId를 찍어보면,

 

length와 handerId가 정상으로 찍히는 걸 볼 수 있습니다.

 

그리고, Server는 Client에게 그대로 socket.write(data)로 보내주고 있기 때문에

 

Client도 확인해보면,

length가 11인 Byte 배열 송수신한 것을 볼 수 있습니다.

 

6. Client에서 Server가 보낸 message 수신 수정하기

client.on('data', (data) => {
  console.log(data);
});

으로 Server가 보낸 data를 수신 하는 곳을 아래와 같이 변경하면,

client.on('data', (data) => {
  const { length, handlerId } = readHeader(data);
  console.log('length : ', length);
  console.log('handlerId : ', handlerId);
});

 

위와 같이 Client도 서버와 같이 length와 handlerId 를 출력할 수 있습니다.

 

6. Server에서 Client가 보낸 message 가공하기

  socket.on('data', (data) => {
    // 서버가 클라이언트로부터 데이터를 받을 때 마다 발생
    // data는 버퍼 형태로 제공되며, 이를 문자열로 변환하거나 원하는 형식으로 처리할 수 있다.
    const { length, handlerId } = readHeader(data);

    console.log('length : ', length);
    console.log('handlerId : ', handlerId);

    socket.write(data);
    // data를 보낸 Client의 주소로 data를 그대로 되돌려 준다.
  });

위와 같은 코드는 현재 서버가 Client로 부터 받은

그냥 다시 Client에게 보내고 있을 뿐 입니다.

그로인해, data에 message 뿐만 아니라 header의 정보가 포함 돼 있습니다.

 

우리는 Server에서 Client가 보낸 data에서 header를 제거해 message만 확인하고,

Server가 응답할 메시지를 Buffer에 담아 Client에게 응답으로 제공해줍시다.

 

먼저, data에서 해더를 잘라내기 위해서는

    const buffer = Buffer.from(data);
    // data에서 header를 자르기(subarray) 위해, Buffer에 담아준다.

data를 Buffer에 담아줘야 합니다.

    const headerSize = TOTAL_LENGTH_SIZE + HANDLER_ID; // 6
    const message = buffer.subarray(headerSize);
    //headerSize ~ buffer.length - 1 만큼 buffer의 내용을 자른다.

해더의 길이를 알기 위해 TOTAL_LENGTH_SIZE + HANDLER_ID로 headerSize를 구합니다.

그리고 buffer.subarray로 header 부분을 잘라내줍니다.

    console.log(`client 에게 받은 메세지 : ${message}`);

그리고, 메세지를 console로 출력해 확인해줍니다.

 

이제 응답으로 서버가 보낼 메시지를 만들어보겠습니다.

    const responseMessage = 'Hi, There';
    // 서버가 클라이언트에게 제공할 message

    const responseBuffer = Buffer.from(responseMessage);
    // Buffer.from()로 보낼 메세지를 Buffer에 담아줍니다.

    const header = writeHeader(responseBuffer.length, handlerId);
    // Buffer에 담긴 메세지의 길이와 handerId로 Header를 만든다.

    const packet = Buffer.concat([header, responseBuffer]);
    // Header의 내용과 응답으로 돌려줄 메시지를 하나의 Buffer로 만들어 Client에게 보낸다.

    socket.write(packet);
    // data를 보낸 Client의 주소로 data를 그대로 되돌려 준다.
  });

 

responseMessage로 Client에게 보낼 메세지를 담고, 그 내용을 Buffer에 담아줍니다.

 

header를 만들기 위해 responseBuffer.length(버퍼에 담긴 message의 길이)와 handerId를

우리가 만든 writeHeader함수로 Buffer 형식으로 만들어 줍니다.

 

마지막으로 Buffer.concat([header, responseBuffer])로 해더와 message를 하나의 Buffer형태로 만들어줍니다.

 

그리고 Client에게 전송해줍니다.

 

7. Client에서 Server가 보낸 message 가공하기

client.on('data', (data) => {
  const buffer = Buffer.from(data);
  // data에서 header를 자르기(subarray) 위해, Buffer에 담아준다.

  const { length, handlerId } = readHeader(buffer);
  console.log('length : ', length);
  console.log('handlerId : ', handlerId);

  const headerSize = TOTAL_LENGTH_SIZE + HANDLER_ID; // 6
  const message = buffer.subarray(headerSize);
  // handerSize ~ buffer.length - 1까지 자르기
  console.log(`server 에게 받은 메세지 : ${message}`);
});

Client에서도 Server와 마찬가지로 수신 부분에서

Header를 잘라내 메시지만 추출해서 출력해줍니다.

 

결과를 출력해보면,

 

왼쪽은 Server, 오른쪽은 Client 입니다.