9일 일지
# 오늘 개발 진행 상황
금요일에 중간 발표가 끝나고 아직까지 의문인 점
왜 클라이언트는 패킷을 똑바로 받지 못하는가...??
오늘도 그 문제에 봉착했다.
혹시나 하면서 생각해본 내용
1. 서버 문제?
2. 코드 구조 최적화?
3. 너무 많은 패킷을 보내 밀려서 안되는거라면 우선순위를 정해보기?
코드 최적화는 천천히 해 나가면 되는거고
1, 3번을 한번에 확인 할 수 있는 방법!
브렌치를 따로 파고 서버를 내가 열어보자!
일단 브렌치를 따로 테스트로 하나 만들었다.
import CustomError from './../utils/errors/customError.js';
import ErrorCodes from './../utils/errors/errorCodes.js';
import config from './../config/config.js';
import validateSequence from './../utils/socket/sequence.js';
import packetParser from './../utils/packet/parser/packetParser.js';
import { getHandlerByPacketType } from './../handlers/index.js';
import handleError from './../utils/errors/errorHandler.js';
// 패킷 우선순위 정의
const PACKET_PRIORITY = {
HIGH: 1,
MEDIUM: 2,
LOW: 3,
};
// 패킷 타입별 우선순위 매핑
const getPacketPriority = (packetType) => {
const { packetType: PACKET_TYPE } = config.packet;
// 높은 우선순위: 공격, 카드 사용, 리액션 등 즉각적인 응답이 필요한 패킷
if (
[
PACKET_TYPE.USE_CARD_REQUEST,
PACKET_TYPE.REACTION_REQUEST,
PACKET_TYPE.PASS_DEBUFF_REQUEST,
].includes(packetType)
) {
return PACKET_PRIORITY.HIGH;
}
// 중간 우선순위: 게임 상태 변경, 방 참가/퇴장 등
if (
[
PACKET_TYPE.GAME_PREPARE_REQUEST,
PACKET_TYPE.GAME_START_REQUEST,
PACKET_TYPE.JOIN_ROOM_REQUEST,
PACKET_TYPE.LEAVE_ROOM_REQUEST,
].includes(packetType)
) {
return PACKET_PRIORITY.MEDIUM;
}
// 낮은 우선순위: 위치 업데이트 등 지연 처리 가능한 패킷
return PACKET_PRIORITY.LOW;
};
class PacketQueue {
constructor() {
this.queues = {
[PACKET_PRIORITY.HIGH]: [],
[PACKET_PRIORITY.MEDIUM]: [],
[PACKET_PRIORITY.LOW]: [],
};
this.processing = false;
}
enqueue(packet) {
const priority = getPacketPriority(packet.packetType);
this.queues[priority].push(packet);
this.processQueue();
}
async processQueue() {
if (this.processing) return;
this.processing = true;
try {
// 우선순위 순서대로 처리
for (const priority of Object.values(PACKET_PRIORITY)) {
const queue = this.queues[priority];
while (queue.length > 0) {
const packet = queue.shift();
try {
const handler = getHandlerByPacketType(packet.packetType);
await handler(packet);
} catch (error) {
handleError(packet.socket, error);
}
}
}
} finally {
this.processing = false;
// 큐에 남은 패킷이 있다면 계속 처리
if (Object.values(this.queues).some((queue) => queue.length > 0)) {
this.processQueue();
}
}
}
}
// 소켓별 패킷 큐 저장
const socketQueues = new Map();
const onData = (socket) => async (data) => {
if (!socket) {
throw new CustomError(
ErrorCodes.SOCKET_ERROR,
`소켓을 찾을 수 없거나 연결이 끊겼다.`,
socket.sequence,
);
}
socket.buffer = Buffer.concat([socket.buffer, data]);
const totalHeaderLength = config.packet.totalHeaderLength;
while (socket.buffer.length >= totalHeaderLength) {
let offset = 0;
const packetType = socket.buffer.readUInt16BE(offset);
offset += config.packet.payloadOneofCaseLength;
const versionLength = socket.buffer.readUInt8(offset);
offset += config.packet.versionLength;
const version = socket.buffer.subarray(offset, offset + versionLength).toString('utf-8');
offset += versionLength;
if (version !== config.client.version) {
throw new CustomError(
ErrorCodes.CLIENT_VERSION_MISMATCH,
'클라이언트 버전이 일치하지 않습니다.',
socket.sequence,
);
}
const sequence = socket.buffer.readUInt32BE(offset);
offset += config.packet.sequenceLength;
const isValidSequence = validateSequence(socket, sequence);
if (!isValidSequence) {
throw new CustomError(
ErrorCodes.INVALID_SEQUENCE,
`패킷이 중복되거나 누락되었다: 예상 시퀀스: ${socket.sequence + 1}, 받은 시퀀스: ${sequence}`,
socket.sequence,
);
}
const payloadLength = socket.buffer.readUInt32BE(offset);
offset += config.packet.payloadLength;
if (socket.buffer.length >= payloadLength + totalHeaderLength) {
const payloadBuffer = socket.buffer.subarray(offset, offset + payloadLength);
try {
const { payload } = packetParser(payloadBuffer);
socket.buffer = socket.buffer.subarray(offset + payloadLength);
// 소켓별 패킷 큐 가져오기 또는 생성
if (!socketQueues.has(socket)) {
socketQueues.set(socket, new PacketQueue());
}
const packetQueue = socketQueues.get(socket);
// 패킷을 큐에 추가
packetQueue.enqueue({
socket,
packetType,
payload,
sequence,
});
break;
} catch (error) {
handleError(socket, error);
break;
}
} else {
break;
}
}
};
export default onData;
패킷을 큐로 지정해 우선순위를 정해보았다.
그리고 이 브렌치로 서버를 파서 내 게임용 컴퓨터랑 맥북이랑 두 클라이언트로 테스트를 해보았다.
굳이 테스트를 파서 서버까지 따로 판 이유는 배포된 서버에서 확인 하려면 메인으로 올려야하는데 단순히 테스트 용을 메인에 올리고 아니면 빼는 작업이 더 귀찮을 것 같다는 생각
결과는??
큰 차이가 없었다.
우선 순위가 문제가 아니였다.
이후로 팀원들끼리 회의를 통해 알게 된 사실
저번 주 까지는 모두가 패킷에 대한 반응이 늦었었는데 그 동안 뭘 건드렸는진 모르겠지만 일부 클라이언트는 동작이 바로바로 되는 이유모를 해결 사항이 생겼다.
하지만 일부는 아직 안되기에 완벽한 해결은 아니지만...
이게 정말 이상한게 처음에 테스트로 판 서버에서는 1명은 아에 안되고 한명은 빌드한 클라이언트에서 안되었다가 유니티 환경에서 실행하면 또 잘 되었다.
그 다음 비교를 위해 메인에 이미 배포가 되어있는 서버로 이동해서 테스트를 하니 이제는 내가 패킷 반응이 늦었다.
클라이언트 버전은 동일하고 서버 로직도 큐로 관리하냐 마냐의 차이인데 이런 이유는.......... 뭐지?
아무튼 공통적으로 잘 작동하는 2명이 있기에 일단 이 문제보단 진도를 좀 더 나가다가 원인을 찾아보기로 하고
나는 저번 주에 아직 해결하지 못한 테스트 코드를 작성하기로 했다.
제스트랑 다른 부하테스트때 쓸 jmeter 나 artillery말고 오직 노드에 내장되어있는 모듈로 테스트 코드 짜기!
import assert from 'assert';
import { Buffer } from 'buffer';
import config from '../../config/config.js';
// 테스트용 패킷 생성 함수
function createTestPacket(packetType, payload) {
const version = '1.0.0';
const versionBuffer = Buffer.from(version);
const sequence = 1;
const payloadBuffer = Buffer.from(JSON.stringify(payload));
const headerSize = 2 + 1 + versionBuffer.length + 4 + 4;
const buffer = Buffer.alloc(headerSize + payloadBuffer.length);
let offset = 0;
buffer.writeUInt16BE(packetType, offset);
offset += 2;
buffer.writeUInt8(versionBuffer.length, offset);
offset += 1;
versionBuffer.copy(buffer, offset);
offset += versionBuffer.length;
buffer.writeUInt32BE(sequence, offset);
offset += 4;
buffer.writeUInt32BE(payloadBuffer.length, offset);
offset += 4;
payloadBuffer.copy(buffer, offset);
return buffer;
}
console.log('패킷 핸들러 유닛 테스트 시작');
// 1. 패킷 타입 테스트
try {
const testPayload = { test: 'data' };
const packet = createTestPacket(config.packet.packetType.USE_CARD_REQUEST, testPayload);
assert.strictEqual(
packet.readUInt16BE(0),
config.packet.packetType.USE_CARD_REQUEST,
'패킷 타입이 일치해야 합니다',
);
console.log('✓ 패킷 타입 테스트 통과');
} catch (error) {
console.error('✗ 패킷 타입 테스트 실패:', error.message);
}
// 2. 버전 테스트
try {
const testPayload = { test: 'data' };
const packet = createTestPacket(1, testPayload);
const versionLength = packet.readUInt8(2);
const version = packet.toString('utf8', 3, 3 + versionLength);
assert.strictEqual(version, '1.0.0', '버전이 일치해야 합니다');
console.log('✓ 버전 테스트 통과');
} catch (error) {
console.error('✗ 버전 테스트 실패:', error.message);
}
// 3. 시퀀스 번호 테스트
try {
const testPayload = { test: 'data' };
const packet = createTestPacket(1, testPayload);
const versionLength = packet.readUInt8(2);
const sequence = packet.readUInt32BE(3 + versionLength);
assert.strictEqual(sequence, 1, '시퀀스 번호가 일치해야 합니다');
console.log('✓ 시퀀스 번호 테스트 통과');
} catch (error) {
console.error('✗ 시퀀스 번호 테스트 실패:', error.message);
}
console.log('패킷 핸들러 유닛 테스트 완료');
import net from 'net';
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
import { Buffer } from 'buffer';
import protobuf from 'protobufjs';
import path from 'path';
import config from '../../config/config.js';
const HOST = '0.0.0.0';
const PORT = 5555;
const NUM_CLIENTS = 10;
const TEST_DURATION = 30000;
const CONNECTION_DELAY = 1000;
const RECONNECT_DELAY = 3000;
const DB_OPERATION_TIMEOUT = 5000;
const MAX_RETRY_COUNT = 3;
const MAX_RECONNECT_ATTEMPTS = 3;
if (!isMainThread) {
class TestClient {
constructor(id) {
this.id = id;
this.socket = new net.Socket();
this.messageCount = 0;
this.startTime = null;
this.reconnectAttempts = 0;
this.connected = false;
this.sequence = 0;
this.expectedSequence = 1;
this.buffer = Buffer.alloc(0);
this.root = null;
this.GamePacket = null;
this.setupSocketHandlers();
this.initializeProtobuf();
}
async initializeProtobuf() {
try {
// protobuf 초기화
this.root = await protobuf.load(
path.resolve(process.cwd(), 'src/protobufs/common/gamePacket.proto'),
);
this.GamePacket = this.root.lookupType('packet.GamePacket');
console.log(`클라이언트 ${this.id} protobuf 초기화 완료`);
} catch (err) {
console.error(`클라이언트 ${this.id} protobuf 초기화 실패:`, err);
}
}
setupSocketHandlers() {
this.socket.on('connect', () => {
console.log(`클라이언트 ${this.id} 연결됨`);
this.connected = true;
this.startGameFlow().catch((err) => {
console.error(`클라이언트 ${this.id} 게임 플로우 오류:`, err.message);
});
});
this.socket.on('error', (err) => {
console.error(`클라이언트 ${this.id} 오류:`, err.message);
this.connected = false;
if (err.code === 'ECONNREFUSED' && this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
this.reconnectAttempts++;
console.log(
`클라이언트 ${this.id} 재연결 시도 ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`,
);
setTimeout(() => this.connect(), RECONNECT_DELAY);
} else {
parentPort.postMessage({
id: this.id,
error: err.message,
});
}
});
this.socket.on('close', () => {
this.connected = false;
console.log(`클라이언트 ${this.id} 연결 종료`);
});
this.socket.on('data', (data) => {
this.buffer = Buffer.concat([this.buffer, data]);
this.processBuffer();
});
}
processBuffer() {
const headerSize = config.packet.totalHeaderLength;
while (this.buffer.length >= headerSize) {
const payloadLength = this.buffer.readUInt32BE(headerSize - 4);
const totalLength = headerSize + payloadLength;
if (this.buffer.length >= totalLength) {
const packet = this.buffer.slice(0, totalLength);
this.buffer = this.buffer.slice(totalLength);
this.handlePacket(packet);
} else {
break;
}
}
}
handlePacket(packet) {
try {
const packetType = packet.readUInt16BE(0);
const versionLength = packet.readUInt8(2);
const version = packet.slice(3, 3 + versionLength).toString('utf8');
const sequence = packet.readUInt32BE(3 + versionLength);
const payloadLength = packet.readUInt32BE(3 + versionLength + 4);
const payload = packet.slice(3 + versionLength + 8, 3 + versionLength + 8 + payloadLength);
if (this.GamePacket) {
const decodedPayload = this.GamePacket.decode(payload);
console.log(`클라이언트 ${this.id} 패킷 수신:`, {
type: packetType,
version,
sequence,
payloadType: Object.keys(decodedPayload)[0],
});
}
} catch (err) {
console.error(`클라이언트 ${this.id} 패킷 처리 오류:`, err);
}
}
createPacket(packetType, payload) {
if (!this.GamePacket) {
throw new Error('Protobuf가 초기화되지 않았습니다');
}
const version = config.client.version;
const versionBuffer = Buffer.from(version);
// Protobuf로 페이로드 인코딩
const message = this.GamePacket.create(payload);
const payloadBuffer = this.GamePacket.encode(message).finish();
const headerSize = 2 + 1 + versionBuffer.length + 4 + 4;
const buffer = Buffer.alloc(headerSize + payloadBuffer.length);
let offset = 0;
// 1. 패킷 타입 (2 bytes)
buffer.writeUInt16BE(packetType, offset);
offset += 2;
// 2. 버전 길이 (1 byte)
buffer.writeUInt8(versionBuffer.length, offset);
offset += 1;
// 3. 버전
versionBuffer.copy(buffer, offset);
offset += versionBuffer.length;
// 4. 시퀀스 (4 bytes)
this.sequence++;
buffer.writeUInt32BE(this.sequence, offset);
offset += 4;
// 5. 페이로드 길이 (4 bytes)
buffer.writeUInt32BE(payloadBuffer.length, offset);
offset += 4;
// 6. 페이로드
payloadBuffer.copy(buffer, offset);
return buffer;
}
async startGameFlow() {
try {
if (!this.GamePacket) {
console.log(`클라이언트 ${this.id} protobuf 초기화 대기 중...`);
await new Promise((resolve) => setTimeout(resolve, 1000));
if (!this.GamePacket) {
throw new Error('Protobuf 초기화 실패');
}
}
await this.sendRegisterRequest();
await new Promise((resolve) => setTimeout(resolve, 2000));
await this.sendLoginRequest();
await new Promise((resolve) => setTimeout(resolve, 2000));
await this.sendRoomRequest();
await new Promise((resolve) => setTimeout(resolve, 2000));
await this.sendGamePrepareRequest();
await new Promise((resolve) => setTimeout(resolve, 2000));
this.startPositionUpdates();
} catch (err) {
console.error(`클라이언트 ${this.id} 게임 플로우 오류:`, err.message);
this.socket.end();
}
}
async sendRegisterRequest() {
return await this.retryOperation(async () => {
const timestamp = Date.now();
const registerPayload = {
C2SRegisterRequest: {
email: `test${this.id}_${timestamp}@test.com`,
nickname: `Player${this.id}_${timestamp}`,
password: 'testpass',
},
};
console.log(
`클라이언트 ${this.id} 회원가입 요청:`,
registerPayload.C2SRegisterRequest.email,
);
this.socket.write(
this.createPacket(config.packet.packetType.REGISTER_REQUEST, registerPayload),
);
// 회원가입 응답 대기
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.socket.removeListener('data', handler);
reject(new Error('회원가입 응답 타임아웃'));
}, DB_OPERATION_TIMEOUT);
const handler = (data) => {
try {
const packet = this.parsePacket(data);
if (packet && packet.type === config.packet.packetType.REGISTER_RESPONSE) {
clearTimeout(timeout);
this.socket.removeListener('data', handler);
// 이메일과 닉네임 저장
this.email = registerPayload.C2SRegisterRequest.email;
this.nickname = registerPayload.C2SRegisterRequest.nickname;
resolve();
}
} catch (err) {
console.error(`클라이언트 ${this.id} 회원가입 응답 처리 오류:`, err);
}
};
this.socket.on('data', handler);
});
}, '회원가입');
}
async sendLoginRequest() {
return await this.retryOperation(async () => {
const loginPayload = {
C2SLoginRequest: {
email: this.email,
password: 'testpass',
},
};
console.log(`클라이언트 ${this.id} 로그인 요청:`, loginPayload.C2SLoginRequest.email);
this.socket.write(this.createPacket(config.packet.packetType.LOGIN_REQUEST, loginPayload));
// 로그인 응답 대기
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.socket.removeListener('data', handler);
reject(new Error('로그인 응답 타임아웃'));
}, DB_OPERATION_TIMEOUT);
const handler = (data) => {
try {
const packet = this.parsePacket(data);
if (packet && packet.type === config.packet.packetType.LOGIN_RESPONSE) {
clearTimeout(timeout);
this.socket.removeListener('data', handler);
resolve();
}
} catch (err) {
console.error(`클라이언트 ${this.id} 로그인 응답 처리 오류:`, err);
}
};
this.socket.on('data', handler);
});
}, '로그인');
}
async retryOperation(operation, operationName) {
for (let attempt = 1; attempt <= MAX_RETRY_COUNT; attempt++) {
try {
await operation();
console.log(
`클라이언트 ${this.id} ${operationName} 성공 (시도 ${attempt}/${MAX_RETRY_COUNT})`,
);
return;
} catch (err) {
console.error(
`클라이언트 ${this.id} ${operationName} 실패 (시도 ${attempt}/${MAX_RETRY_COUNT}):`,
err.message,
);
if (attempt < MAX_RETRY_COUNT) {
const delay = Math.min(1000 * Math.pow(2, attempt), 10000); // 지수 백오프
console.log(`클라이언트 ${this.id} ${delay}ms 후 재시도...`);
await new Promise((resolve) => setTimeout(resolve, delay));
} else {
throw new Error(`${operationName} 최대 재시도 횟수 초과`);
}
}
}
}
parsePacket(data) {
try {
let offset = 0;
// 1. 패킷 타입 (2 bytes)
const packetType = data.readUInt16BE(offset);
offset += 2;
// 2. 버전 길이 (1 byte)
const versionLength = data.readUInt8(offset);
offset += 1;
// 3. 버전
const version = data.slice(offset, offset + versionLength).toString('utf8');
offset += versionLength;
// 4. 시퀀스 (4 bytes)
const sequence = data.readUInt32BE(offset);
offset += 4;
// 5. 페이로드 길이 (4 bytes)
const payloadLength = data.readUInt32BE(offset);
offset += 4;
// 6. 페이로드
const payload = data.slice(offset, offset + payloadLength);
if (this.GamePacket) {
const decodedPayload = this.GamePacket.decode(payload);
return {
type: packetType,
version,
sequence,
payload: decodedPayload,
};
}
} catch (err) {
console.error(`클라이언트 ${this.id} 패킷 파싱 오류:`, err);
}
return null;
}
async sendRoomRequest() {
const isCreator = this.id % 2 === 0;
const roomPayload = isCreator
? {
createRoomRequest: {
name: `Room${this.id}`,
maxUserNum: 8,
},
}
: {
joinRandomRoomRequest: {},
};
const packetType = isCreator
? config.packet.packetType.CREATE_ROOM_REQUEST
: config.packet.packetType.JOIN_RANDOM_ROOM_REQUEST;
this.socket.write(this.createPacket(packetType, roomPayload));
}
async sendGamePrepareRequest() {
const preparePayload = {
gamePrepareRequest: {},
};
this.socket.write(
this.createPacket(config.packet.packetType.GAME_PREPARE_REQUEST, preparePayload),
);
}
startPositionUpdates() {
this.startTime = Date.now();
const interval = setInterval(() => {
if (!this.connected || Date.now() - this.startTime >= TEST_DURATION) {
clearInterval(interval);
if (this.connected) {
this.socket.end();
parentPort.postMessage({
id: this.id,
messageCount: this.messageCount,
duration: Date.now() - this.startTime,
});
}
return;
}
const positionPayload = {
positionUpdateRequest: {
x: Math.random() * 100,
y: Math.random() * 100,
},
};
try {
this.socket.write(
this.createPacket(config.packet.packetType.POSITION_UPDATE_REQUEST, positionPayload),
);
this.messageCount++;
} catch (err) {
console.error(`클라이언트 ${this.id} 패킷 전송 오류:`, err.message);
}
}, 100);
}
connect() {
this.socket.connect(PORT, HOST);
}
}
// 클라이언트 시작
const client = new TestClient(workerData.id);
setTimeout(() => client.connect(), workerData.id * CONNECTION_DELAY);
} else {
// 메인 스레드 코드
console.log('서버 부하 테스트 시작');
console.log(`동시 접속자 수: ${NUM_CLIENTS}`);
console.log(`테스트 시간: ${TEST_DURATION / 1000}초`);
console.log(`연결 지연 시간: ${CONNECTION_DELAY}ms`);
const workers = new Array(NUM_CLIENTS);
const results = new Map();
let completedWorkers = 0;
function checkTestCompletion() {
completedWorkers++;
if (completedWorkers === NUM_CLIENTS) {
let totalMessages = 0;
let errors = 0;
let connectedClients = 0;
results.forEach((result) => {
if (result.error) {
errors++;
} else {
totalMessages += result.messageCount;
connectedClients++;
}
});
console.log('\n테스트 결과:');
console.log(`연결된 클라이언트: ${connectedClients}/${NUM_CLIENTS}`);
console.log(`총 메시지 수: ${totalMessages}`);
if (connectedClients > 0) {
console.log(`클라이언트당 평균 메시지: ${(totalMessages / connectedClients).toFixed(2)}`);
console.log(`초당 평균 메시지: ${(totalMessages / (TEST_DURATION / 1000)).toFixed(2)}`);
}
console.log(`에러 발생 클라이언트: ${errors}`);
console.log(`성공률: ${((connectedClients / NUM_CLIENTS) * 100).toFixed(2)}%`);
process.exit(0);
}
}
let currentWorker = 0;
function createNextWorker() {
if (currentWorker >= NUM_CLIENTS) return;
const worker = new Worker(new URL(import.meta.url), {
workerData: { id: currentWorker },
});
workers[currentWorker] = worker;
worker.on('message', (result) => {
results.set(result.id, result);
checkTestCompletion();
});
worker.on('error', (err) => {
console.error(`워커 ${currentWorker} 오류:`, err);
results.set(currentWorker, { error: err.message });
checkTestCompletion();
});
worker.on('exit', (code) => {
if (code !== 0) {
console.error(`워커 ${currentWorker} 비정상 종료: ${code}`);
if (!results.has(currentWorker)) {
results.set(currentWorker, { error: `Worker exited with code ${code}` });
checkTestCompletion();
}
}
});
currentWorker++;
if (currentWorker < NUM_CLIENTS) {
setTimeout(createNextWorker, CONNECTION_DELAY);
}
}
createNextWorker();
}
일단 패킷을 주고 받는 단순 유닛 테스트랑 서버가 얼마나 버티는지에 대한 부하 테스트 둘을 작성했다.
부하 테스트는 지금 로그인 중 서버가 뻗는 현상이 있어서 아직 보류고
추가로 경계 테스트?? 라는걸 더 작성 해볼 예정이다.
끝