1인 개발 가디언 슬래시 출시 후기4
이번 포스트에서는 서버에 대한 내용을 조금 작성해볼까 합니다.
가디언 슬래시를 개발하면서 서버에 대해 처음 접하고 개발한 만큼 지식의 크기는 남들과 다르지는 않습니다.
다만 서버를 개발하고 실제 프로젝트에 적용하기까지 많은 시행착오가 있었고 경험을 쌓았다는 것이 큰 재산이 된 것 같습니다. 백문이 불여일견이라는 말처럼 한 번 경험해보니 생각보다 조심해야할 것도 많고 고민해야할 것도 많았습니다.
가디언 슬래시 프로젝트에서는 TCP/IP 프로토콜을 사용했습니다. 데이터의 일관성이 중요한 만큼 UDP 보다 신뢰도가 높은 TCP 프로토콜을 선택하는 것이 알맞았습니다. 물론 UDP 를 이용해서 TCP 처럼 신뢰도가 강한 연결을 만들수는 있겠지만, 직접 만들어서 처리하는 것이 더 느려질 수 있습니다. 데이터를 체크하고, 정렬하고, 손실은 없는지 체크하고...
연결 끊김
TCP를 사용하면서 문제가 없는 것은 아니었습니다. 모바일 환경에서 서버와 클라이언트를 연결 해놨지만 사용자들은 이 앱을 백그라운드로 켜놓고 다른 작업을 하기도 하고, 지하철과 같이 통신이 원활하지 않은 환경에서 연결이 계속 끊어지기도 한다는 점이 의외의 복병으로 다가왔습니다. UDP의 경우에는 연결이 되든 안되는 보내고 끝이지만, TCP의 경우 연결이 끊어졌을 경우 다시 연결을 해야하는 로직이 필요했습니다.
게임을 출시하고 나서 급히 위와 같은 문제를 해결하는데 꽤 많은 시간이 들어갔습니다. 처음 겪어보는 문제이기도 하고 둘 중 하나가 끊어졌는데 어떻게 추적해야하는지 어떤 방식이 효율적인지 정답에 가까운 해결책을 찾는 것이 가장 큰 문제였습니다. 해결책은 한 커뮤니티에서 힌트를 얻을 수 있었습니다. 서버에도 심장 박동을 체크하는 기능이 필요하다. 하트비트 패킷을 구현하는 것이었습니다. 서버에서는 연결이 되었던 클라이언트에게 일정 시간 텀을 두고 "너 살았니?"하고 물어보는 것이 전부지만 새로운 접근방식이라 생각되었습니다. 처음에는 단순히 Socket이 연결되었는지 끊겼는지에 대한 체크만 했습니다. 프레임 워크에 기능이 내장되어 있어서 편하다 생각했지만, 물리적인 연결이 끊긴 경우 서버와의 연결을 정상적으로 끊지 못하는 상황이 발생했던 것입니다. 그렇게 Socket에 내장되어 있는 IsConnected는 불필요 했고 클라이언트 측에서 인터넷 연결이 끊겼는지 실제 연결이 되어있는지를 계속 체크하는 로직도 추가하면서 새로운 소켓 연결을 생성할 수 있는 상태가 완성되었습니다. 그렇게 인터넷 끊김에 대한 이슈를 무사히 넘겼고 이러한 상황도 발생할 수 있다는 것을 뼈저리게 느꼈습니다.
버퍼
소켓을 연결하여 서버와 클라이언트간 통신을 한다고 하면 대부분 byte 배열을 만들어서 송수신 할 것입니다. 저 또한 강의를 수강하기 전에는 단순히 생성하여 주고받는 로직을 생각했습니다. 만약 바이트 배열을 만들어서 송수신한다고 했을 때 사용자가 많을 경우 사용량 초과, 공간 낭비, 처리 속도 저하 등 문제가 발생했을 것입니다.
데이터 처리의 효율 성을 높이기 위해 버퍼를 만드는 것이 서버 성능에 큰 도움이 되었습니다. 먼저 송신할때의 바이트 배열을 먼저 논하자면 적절한 사용량을 계산하고 여유롭게 바이트 배열을 생성합니다. 개인 적인 생각이지만 예를 들자면 클라이언트와 서버간 전송하는 데이터 중 가장 큰 데이터가 100 byte라고 했을 때 버퍼를 크게 잡습니다. 1024 byte 크기의 전송용 데이터를 미리 준비하고 사용량 만큼만 반환하는 것이 송신 버퍼의 핵심입니다. 1024 중 100을 사용한다면 0~99까지를 반환하고 다음 데이터 요청시 100번 인덱스 부터 데이터를 작성하는 방식입니다. 여기서 1024의 데이터 끝까지 사용하게 되면 어떻게 처리할 것인지 의문점이 들어야 합니다. 처리방식은 여러가지가 있겠지만 대표적인? 개인적인 생각으로는 2가지 방법이 떠오릅니다.
1. 요청시 송신 버퍼를 새로 만든다. (가장 간단하면서도 직관적인 방법입니다.)
2. 사용이 완료되고 처리가 끝난 데이터를 앞으로 옮긴다. (처리방법이 복잡하지만 생성 삭제에 효율적입니다.)
1번 방법은 직관적이지만 2번 방법은 조금더 생각해봐야합니다. 처리 요청이 없고 송신 처리가 모두 끝난 경우 write 포지션을 단순히 앞으로 당기는 방법이 있을테고, 송신이 아직 다 처리되지 않은 경우에는 앞으로 복사하여 옮기는 방법이 있다고 생각합니다. 저는 그 중에서도 1번 방법이 가장 직관적이어서 사용했지만 여유가 있다면 2번 방법을 조금 더 연구해보는 것이 성장에 도움된다고 생각됩니다.
public class SendBuffer
{
byte[] _Buffer;
int UsedSize = 0;
public int FreeSize { get { return _Buffer.Length - UsedSize; } }
public SendBuffer(int chunkSize)
{
_Buffer = new byte[chunkSize];
}
public ArraySegment<byte> Open(int reserveSize)
{
if (reserveSize > FreeSize)
return null;
return new ArraySegment<byte>(_Buffer, UsedSize, reserveSize);
}
public ArraySegment<byte> Close(int usedSize)
{
ArraySegment<byte> segment = new ArraySegment<byte>(_Buffer, UsedSize, usedSize);
UsedSize += usedSize;
return segment;
}
}
다음으로는 수신 버퍼를 살펴봐야 합니다. 수신 버퍼도 마찬가지로 적절한 공간을 만들고 재사용 할 수 있도록 만드는 것이 핵심입니다. 먼저 데이터의 수신 과정에서 패킷이 잘려서 오는 경우가 있다는 것을 염두에 두고 처리해야합니다. 먼저 도착한 패킷이 있고 뒤늦게 도착한 패킷이 있을 경우를 고려해 처리해야합니다. 이 처리를 위해서 버퍼에는 읽기 위치와 쓰기 위치가 필요합니다. 읽기 위치는 프로그램 내부에서 개발자가 읽고 처리하는 것을 담당하고 쓰기 위치는 데이터가 수신되어 배열에 저장되는 위치입니다.
송 수신 데이터를 어떻게 정의하느냐에 따라 처리방식이 달라지겠지만, 대부분은 제일 앞 데이터에 총 데이터 크기를 보낸다고 알고있습니다. 읽을경우 가장 앞에 들어온 데이터 크기(총 패킷 크기)를 확인하고 전체 데이터가 들어왔는지를 체크합니다. 전체 데이터가 정상적으로 수신된 경우 프로그램상에서 데이터를 처리하며, 읽기 위치를 처리한 만큼 옮겨줍니다.
만약 읽기 위치와 쓰기 위치가 같은 경우에는 처리할 데이터가 없으며, 수신되고 있는 데이터도 없다는 뜻으로 위치를 모두 0번 인덱스로 옮겨주기만 한다면 같은 데이터 공간을 계속 재사용할 수 있습니다. 물론 처리 여유가 있는 경우에는 Read Position과 Write Position 만큼을 복사해서 앞으로 이동시키기만 한다면 충분히 재사용 가능합니다.
public class ReceiveBuffer
{
// [r][][w][][][][][][][]
ArraySegment<byte> RecvBuffer;
int ReadPosition;
int WritePosition;
public ReceiveBuffer(int bufferSize)
{
RecvBuffer = new ArraySegment<byte>(new byte[bufferSize], 0, bufferSize);
}
public int DataSize { get { return WritePosition - ReadPosition; } }
public int FreeSize { get { return RecvBuffer.Count - WritePosition; } }
public ArraySegment<byte> ReadSegment
{
get
{
return new ArraySegment<byte>(RecvBuffer.Array, RecvBuffer.Offset + ReadPosition, DataSize);
}
}
public ArraySegment<byte> WriteSegment
{
get
{
return new ArraySegment<byte>(RecvBuffer.Array, RecvBuffer.Offset + WritePosition, FreeSize);
}
}
public void Clean()
{
int dataSize = DataSize;
if(dataSize == 0)
{
// 남은 데이터가 없음
ReadPosition = WritePosition = 0;
}
else
{
Array.Copy(RecvBuffer.Array, RecvBuffer.Offset + ReadPosition, RecvBuffer.Array, RecvBuffer.Offset, dataSize);
ReadPosition = 0;
WritePosition = dataSize;
}
}
public bool OnRead(int numOfBytes)
{
if (numOfBytes > DataSize)
return false;
ReadPosition += numOfBytes;
return true;
}
public bool OnWrite(int numOfBytes)
{
if (numOfBytes > FreeSize)
return false;
WritePosition += numOfBytes;
return true;
}
}
이렇게 버퍼를 구성할 수 있었고 단순히 생성하여 송수신하는 것보다 서버의 성능이 비약적으로 상승하는 것을 확인할 수 있을 것입니다.
데이터 베이스
위와 같이 서버의 송수신 데이터를 안정적으로 받을 수 있다면 다중 사용자 환경에서 데이터 베이스도 일을 열심히 해야합니다. 데이터베이스를 사용하면서 꽤 많은 난관에 부딪혔습니다. 먼저 데이터베이스의 처리량이 문제였습니다. 데이터가 쌓이는 만큼 데이터베이스의 성능은 비례적으로 낮아질수밖에 없습니다. 그만큼 처리시간도 증가하므로 서버의 처리보다 데이터베이스의 처리속도가 느리기 때문에 효율적인 처리 방법이 필요했습니다. 큐를 사용하거나 스케줄링을 사용한다고 해도 직접적인 처리속도는 빨라지지 않기 때문에 사용자가 몰린만큼 지연이 발생할 것이 분명했습니다. 다른 문제는 클라이언트가 사용 도중에 연결이 끊긴다면 데이터베이스의 연결이 오동작하는 문제도 있었습니다. 데이터베이스의 일관성을 유지하기에는 다중 사용자에 대한 조치가 필요한 상황이 발생했습니다. 다행이도 MySQL 데이터 베이스에서도 커넥션 풀을 지원했고 데이터베이스에서는 서로 영향을 주는 테이블이 없었기 때문에 적용에 큰 무리가 없었습니다. 다만 커넥션 또한 같은 자원을 공유하고 있었기 때문에 각 스레드별로 커넥션을 새로 생성해줘야 하는 불편함이 있었고, 데이터의 일관성 유지를 위한 트랜잭션 또한 개별적인 스레드로 관리해야 했습니다.
// 트랜잭션 시작
public void BeginTransaction()
{
connection.Value = new MySqlConnection(connectionAddress); // 각 요청마다 새 연결 생성
if (connection.Value.State != System.Data.ConnectionState.Open)
{
connection.Value.Open();
ConnectCount++;
Console.WriteLine($"MySQL Connection Open : {ConnectCount}");
}
transaction.Value = connection.Value.BeginTransaction();
//Console.WriteLine("Transaction started.");
}
// 트랜잭션 커밋
public void CommitTransaction()
{
try
{
if (transaction.Value != null)
{
transaction.Value.Commit();
//Console.WriteLine("Transaction committed.");
}
}
catch (Exception ex)
{
Console.WriteLine($"Error during commit: {ex.Message}");
RollbackTransaction(); // 오류 시 롤백
throw;
}
finally
{
CleanupTransaction(); // 트랜잭션 정리 및 연결 닫기
}
}
// 트랜잭션 롤백
public void RollbackTransaction()
{
try
{
if (transaction.Value != null)
{
transaction.Value.Rollback();
Console.WriteLine("Transaction rolled back.");
}
}
catch (Exception ex)
{
Console.WriteLine($"Error during rollback: {ex.Message}");
}
finally
{
CleanupTransaction(); // 트랜잭션 정리 및 연결 닫기
}
}
// 트랜잭션 정리 및 연결 종료
private void CleanupTransaction()
{
if (transaction.Value != null)
{
transaction.Value.Dispose();
transaction.Value = null;
}
if (connection.Value != null && connection.Value.State == System.Data.ConnectionState.Open)
{
connection.Value.Close();
ConnectCount--;
Console.WriteLine($"MySQL Connection Close : {ConnectCount}");
//Console.WriteLine("Connection closed.");
}
}
추가적으로 소켓 연결이 끊길 경우 데이터 베이스도 같이 끊어줘야 데이터의 일관성을 유지할 수 있었습니다.
마지막으로 서버의 성능은 다음과 같습니다.
CPU : 쿼드 코어
RAM : 1GB
SSD : 30GB
트래픽 제한 : 300GB
이것으로 이번 포스트를 마치고 다음 포스트에서는 개발 과정, 광고, 수익화에 대한 내용을 작성하겠습니다.
'Unity > Records' 카테고리의 다른 글
1인 개발 가디언 슬래시 출시 후기 5 (1) | 2025.01.01 |
---|---|
1인 개발 가디언 슬래시 출시 후기3 (0) | 2025.01.01 |
1인 개발 가디언 슬래시 출시 후기2 (0) | 2025.01.01 |
1인 개발 가디언 슬래시 출시 후기 (2) | 2025.01.01 |