Today I Learn
[TIL][C++] 250523 MMO 서버 개발 24일차: friend 연산자 멤버 함수 패턴 이해하기, 언리얼의 Send와 Recv함수는 일부만 전송해도 true가 반환된다. 등등…
pledge24
2025. 5. 24. 00:47
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다.
friend 연산자 멤버 함수 패턴 이해하기
struct FPacketHeader {
friend FArchive& operator<<(FArchive& Ar, FPacketHeader& Header) {
Ar << Header.PacketSize;
Ar << Header.PacketID;
return Ar;
}
uint16 PacketSize;
uint16 PacketID;
};
- 위 코드는 패킷 헤더를 정의한 FPacketHeader 구조체 내부에 friend 연산자 멤버 함수를 정의한 코드이다. 이 함수를 통해 ar << header와 같이 연산자를 사용하면 header에 serialize 된 데이터가 들어가는, 즉, PacketSize와 PacketId가 저장된다.
연산자 오버로딩 함수
friend FArchive& operator<<(FArchive& Ar, FPacketHeader& Header) {
Ar << Header.PacketSize;
Ar << Header.PacketID;
return Ar;
}
- 연산자 오버로딩 함수의 종류는 2가지로, 멤버 함수 타입과, 전역 함수 타입이 있다. 종류를 구분하는 방법이 조금 특이한데, 바로 매개변수 시그니처의 패턴을 고려한다는 것이다.
위 연산자 함수는 “전역 함수” 타입이다.
- “<< 연산자 오버로딩 함수는 FPacketHeader 내부에 정의된 연산자 오버로딩 함수이므로 멤버 연산자 함수다!”라고 생각할 수 있지만 그렇지 않다. << 연산자는 2개의 피연산자를 요구하는 이항 연산자인데, 멤버 연산자 함수의 경우 왼쪽 피연산자는 항상 본인이 여야 하기 때문에 하나의 매개변수를 받는다.
// 만약 FPacketHeader의 멤버 연산자라면:
class FPacketHeader {
FArchive& operator<<(FArchive& Ar) {
// this(FPacketHeader*)가 암시적 첫 번째 매개변수
}
};
- 그런데 정의된 연산자 함수를 보니 매개변수가 2개가 있다. 이는 전역 연산자 함수를 정의할 때의 패턴으로 이 함수는 전역 연산자 함수로 분류된다.
그렇다면 굳이 FPacketHeader 구조체 내부에 정의하였는가? 밖에 빼두는 게 맞지 않은가?
- 일반적인 경우에선 밖에 빼두는 게 전역 연산자 함수라는 것을 보다 명시적으로 표현할 수 있기 때문에 빼두는 것이 좋다. 하지만 여기서는 조금 다르다.
friend 전역 연산자 함수 패턴
- 기본적으로 전역 함수 타입의 연산자 오버로딩 함수는 하나 고질적인 문제가 있다. 그것은 바로 각 객체의 가려진 멤버 변수(protected/private)에 접근할 수 없다는 것이다. 그래서 연산자 함수에서 각 멤버 변수에 접근할 수 있도록 접근할 멤버 변수가 존재하는 클래스 내부에 정의한 다음, friend 키워드를 붙여 해당 연산자 함수가 멤버 변수에 접근할 수 있도록 허용한다.
- (지금 봤는데 예전에 한 번 정리해 둔 개념이었다… 아래 예제는 예전 글에서 가져왔다)
class Point {
public:
Point(int x = 0, int y = 0) : x(x), y(y) {}
// 전역 연산자 함수가 private 멤버 변수를 사용할 수 있도록 열어준다.
friend Point operator+(const Point& a, const Point& b);
void printPoint() { cout << "Point : " << x << " " << y << '\\\\n';}
private:
int x, y;
};
// 전역 함수로 '+' 연산자 오버로딩
Point operator+(const Point& a, const Point& b) {
return Point(a.x + b.x, a.y + b.y);
}
int main(void){
Point p1(1, 2);
Point p2(10, 11);
Point p3 = p1 + p2;
p3.printPoint();
return 0;
}
그런데 뭔가 이상하다
- 사실 지금와서 하는 말이지만, 멤버 변수를 public로 열어둔 상황(struct는 기본 접근 제어자가 public)이기 때문에 friend를 안 넣어도 잘만 돌아갈 것이다. 그럼에도 불구하고 이렇게 해둔 건, 자주 사용하는 패턴이기도 하고, 앞으로 멤버 변수(PacketSize, PacketId)가 private으로 캡슐화될 수도 있는 거니, 유지보수상 명분 + 연산자 함수의 관련성 명시와 같은 효과도 볼 겸 이렇게 해둔 것 같다.
결론
- 코드에서 사용한 연산자 함수는 전역 연산자 함수이다.
- friend 키워드를 통해 가려진 멤버 변수에 접근하여 사용하는 전형적인 전역 연산자 함수 사용 패턴이다.
- 하지만 이 코드에서 헤더 변수가 public으로 열려 있는터라 friend 키워드가 영향을 주진 않는다.
- 그럼에도 불구하고, 여러 이유에서 유지보수가 편하니 이 패턴을 유지하는 것이다.
언리얼의 Send와 Recv함수는 일부만 전송해도 true가 반환된다.
// Recv
while (Size > 0)
{
int32 NumRead = 0;
Socket->Recv(Results + Offset, Size, OUT NumRead);
check(NumRead <= Size);
if (NumRead <= 0)
return false;
Offset += NumRead;
Size -= NumRead;
}
// Send
while (Size > 0)
{
int32 BytesSent = 0;
if (Socket->Send(Buffer, Size, BytesSent) == false)
return false;
Size -= BytesSent;
Buffer += BytesSent;
}
- 언리얼 네트워크 코드를 보면서 조금 의아해했던 게 동기 함수임에도 불구하고 while문을 돈다는 것이었다. 어차피 동기 함수라면 다 받을 때까지 기다리다 다 받으면 반환되면 되는 건데 굳이 while문을 돌 필요가 없어 보였기 때문이다.
- 하지만, 내가 착각했던 것이었다. Recv와 Send는 요구한 바이트를 전부 처리해야지 반환하지 않는다! 일부 바이트만 보내고 반환되며, 어쨌든 일부 바이트라도 넣는 데 성공했다면 true를 반환한다. 그래서 while문을 돌며 다 넣을 때까지 반복하는 것이다.
AsShared()의 반환값은 rvalue
- 조금 뻔한 내용인데, AsShared 함수(표준의 shared_from_this 함수)의 반환값은 rvalue이기 때문에 레퍼런스 타입의 매개변수에는 곧바로 넘겨줄 수 없다. 굉장히 당연한 내용인데 삽질해서 글로 남겨둔다…
// 함수 시그니처
static bool HandlePacket(PacketSessionRef& session, BYTE* buffer, int32 len)
// 올바른 코드
PacketSessionRef ThisPtr = AsShared();
ClientPacketHandler::HandlePacket(ThisPtr, Packet.GetData(), Packet.Num());
// 잘못된 코드(컴파일 에러)
ClientPacketHandler::HandlePacket(AsShared(), Packet.GetData(), Packet.Num());
게임 서버에서 사용하는 id는 총 세 가지
- 게임 서버에서 사용하는 id는 총 세 가지로, 1) templateId, 2) DBId, 3) GameId가 있다.
- 템플릿 Id는 데이터시트 id로 XML과 같은 파일에서 관리하는 아이템, 몬스터의 고유 id이다.
- DBId는 이름 그대로 데이터베이스에서 사용하는 id로, 각 유저의 계정 및 인벤토리 아이템 등에 붙은 id이다.
- GameId는 현재 게임에서 존재하는 인스턴스의 id로, 게임 서버가 게임의 상태를 관리하기 위해 사용하는 id이다. GameId는 모든 엔티티에 붙으며, 계속해서 증가하는 방향으로 관리된다.
GameId의 데이터 낑겨넣기
- uint64와 같은 타입으로 GameId를 관리하면 약 2^64까지 숫자가 커질 수 있기 때문에 다 사용해서 0으로 돌아오는 일은 불가능할 것이다. 그래서 비트 마스크 방식을 통해 하위 N비트만 증가하는 id로 사용하고, 상위 비트는 분류 코드로 사용하기도 한다.
// GameId
[[TypeId][templateId][----increaseId----]]
|-------------------64-------------------|
- TypeId는 캐릭터인지, NPC인지, 몬스터인지 구분하는 분류 코드이다.
아이템은 GameId를 가질까?
- 프로젝트마다 다르겠지만, 보통은 가지지 않는다.
- 우선, 아이템이 게임 상에 인스턴스로 등장하는 일은 별로 없는데, 유일한(?) 상황이 바로 드롭된 상황일 것이다.
- 아이템이 드롭되면, 해당 아이템이 게임 상에 인스턴스로 존재하기 때문에 GameId를 구분해야 할 것 같지만, 아이템 자체에 GameId를 부여하진 않고, 대신 아이템을 들고 있는 아이템 홀더에 GameId을 부여해서 사용한다.
- (아이템 홀더는 그냥 아이템을 하위 계층으로 들고 있는 투명한 박스 같은 거라고 생각하면 된다.)