서버에게 말 한마디 전하려다가 링커와 싸우고 컴퓨터까지 멈춰버린 이야기(Feat RPC, _Implementation, 50명의 손님(?))

정윤재·5일 전
post-thumbnail

안녕하세요! 이번에는 멀티플레이 채팅을 구현하면서 배운 RPC와, 그 과정에서 만난 링커 오류를 정리해 보려고 합니다.

채팅 메시지를 서버로 보내고, 다른 클라이언트 화면에 출력하고, 누군가 접속하면 입장 안내도 띄워봤습니다. 그런데 함수 이름 뒤에 붙여야 할 글자를 빠뜨려서 빌드가 막혔고, 실행 개수를 늘려보는 과정에서는 컴퓨터까지 멈췄어요. 채팅 공부에 컴퓨터 체력 측정까지 따라왔습니다(?).

  1. 채팅 메시지는 서버를 한 번 들렀다 옵니다
  2. NetMulticast라고 온 세상에 들리는 건 아닙니다
  3. 함수 하나를 구현했는데 중복 정의와 미정의가 같이?
  4. HasAuthority와 Reliable에도 조건이 있습니다
  5. 빌드 파일을 정리했더니 에디터 설정도 다시 확인하게 됐습니다
  6. 4개는 돌아갔는데 50개는 컴퓨터가 멈췄습니다
  7. 마무리

1. 채팅 메시지는 서버를 한 번 들렀다 옵니다

RPC는 Remote Procedure Call, 원격 프로시저 호출이라는 뜻입니다. 지금까지 일반 함수를 호출하면 그 프로그램 안에서 실행됐지만, RPC를 사용하면 네트워크로 연결된 다른 실행 환경에 함수를 실행해 달라고 요청할 수 있습니다.

이번에 실습한 구조에서는 클라이언트끼리 직접 채팅 메시지를 주고받지 않습니다. 클라이언트가 서버에 메시지를 보내면, 서버가 다시 각 클라이언트에게 전달합니다.

클라이언트에서 채팅 입력
    ↓ Server RPC
서버에서 메시지 수신
    ↓ 각 플레이어의 PlayerController에 Client RPC 호출
각 클라이언트에서 메시지 출력

강의 코드에서는 ServerRPCPrintChatMessageString()으로 서버에 메시지를 보내고, 서버가 TActorIterator<ACXPlayerController>로 PlayerController들을 순회하면서 각각의 ClientRPCPrintChatMessageString()을 호출합니다. 클라이언트 쪽 구현부에서는 PrintChatMessageString()으로 메시지를 출력하는 흐름입니다.

여기서 Client RPC의 목적지는 아무 클라이언트나 되는 것이 아닙니다. 그 RPC를 호출한 Actor의 소유 연결(owning connection)을 따라갑니다. 서버가 A의 PlayerController에서 Client RPC를 호출하면 A에게, B의 PlayerController에서 호출하면 B에게 전달되는 식이에요. Epic 공식 문서: Actor의 소유 연결

반대로 클라이언트에서 Server RPC를 보낼 때도 소유권이 필요합니다. 자신의 연결이 소유한 복제 Actor를 통해 호출해야 서버로 전달됩니다. 다른 플레이어가 소유한 Actor에서 호출한다고 그 플레이어를 대신해 서버에 요청할 수 있는 것은 아닙니다.

2. NetMulticast라고 온 세상에 들리는 건 아닙니다

이번에 다룬 세 종류를 나란히 놓으면 다음과 같습니다. 네트워크로 전달되도록 Actor의 복제와 연결 조건을 갖춘 상황을 기준으로 정리했습니다.

종류이번에 사용할 호출 방향실행 대상에서 확인할 조건
Server RPC클라이언트 → 서버호출한 클라이언트가 해당 Actor를 소유해야 함
Client RPC서버 → 소유 클라이언트해당 Actor의 owning connection으로 전달
NetMulticast RPC서버 → 서버와 관련 클라이언트해당 Actor가 각 클라이언트에 relevant해야 함

NetMulticast는 서버에서 호출하면 서버 자신과, 그 Actor가 네트워크상 관련된(relevant) 현재 연결 클라이언트들에서 실행됩니다. 여기서 relevant는 서버가 그 클라이언트에게 해당 Actor의 정보를 보내야 하는 대상으로 판단한다는 뜻입니다.

따라서 “NetMulticast니까 무조건 모든 클라이언트에서 실행된다”라고 외우면 조건 하나를 놓칩니다. 클라이언트가 NetMulticast를 호출한 경우에는 호출한 클라이언트 안에서만 실행되고, 서버나 다른 클라이언트로 퍼지지 않습니다. Epic 공식 문서: RPC 실행 조건

PlayerController도 여기서 눈여겨볼 부분입니다. 일반적인 네트워크 게임에서 PlayerController는 서버와 자신의 소유 클라이언트에 존재하고, 다른 클라이언트에게는 복제되지 않습니다. A의 PlayerController에서 서버가 NetMulticast를 호출해도 B의 화면까지 채팅을 전달하는 방법이 되지는 않는 것이죠. 그래서 강의의 채팅 전달 코드는 서버에서 각 PlayerController에 Client RPC를 호출하는 구조입니다. Epic 공식 문서: PlayerController의 소유권과 복제

접속 안내는 강의에 따라 GameState에 NetMulticast RPC를 구현했습니다. GameMode는 서버에만 존재하지만, GameState는 서버에서 생성되어 클라이언트에도 복제됩니다. 강의 코드에서는 GameMode의 OnPostLogin()에서 GameState를 가져와 입장 안내 RPC를 호출합니다. Epic 공식 문서: GameMode와 GameState

서버의 GameMode에서 OnPostLogin 실행
    ↓
서버의 GameState에서 입장 안내 NetMulticast 호출
    ↓
서버와 관련 클라이언트의 GameState에서 RPC 구현부 실행
    ↓
구현부의 조건을 통과한 곳에서 메시지 출력

마지막 줄이 붙는 이유는 RPC 함수에 도착하는 것과, 그 안의 출력 코드까지 실행되는 것이 서로 다른 단계이기 때문입니다. 이 부분은 아래에서 다시 살펴보겠습니다.

3. 함수 하나를 구현했는데 중복 정의와 미정의가 같이?

입장 안내 RPC의 헤더 선언은 다음과 같습니다.

//서버가 입장 안내를 전달하도록 NetMulticast RPC를 선언합니다.
UFUNCTION(NetMulticast, Reliable)
void MulticastRPCBroadcastLoginMessage(
    const FString& InNameString = FString(TEXT("XXXXXXX"))
);

그런데 CPP에서 일반 C++ 함수처럼 같은 이름으로 본문을 작성했습니다.

//문제가 발생한 구현 이름입니다. 본문은 생략했습니다.
void ACXGameStateBase::MulticastRPCBroadcastLoginMessage(
    const FString& InNameString)
{
    //접속 안내 메시지를 출력하는 로직
}

이 상태로 빌드하니 LNK2005, LNK2001, LNK1120 오류가 발생했습니다. 중복 정의 오류와 정의를 찾을 수 없다는 오류가 함께 나왔습니다.

하지만 오류가 가리키는 함수 이름은 달랐습니다.

LNK2005는 MulticastRPCBroadcastLoginMessage()가 Module.ChatX.gen.cpp.obj에 이미 정의되어 있다는 오류였습니다. UnrealHeaderTool(UHT)이 생성한 호출용 함수와, 제가 CPP에 직접 작성한 같은 이름의 함수가 겹친 것입니다.

반면 LNK2001은 MulticastRPCBroadcastLoginMessage_Implementation()의 정의를 찾지 못했다는 오류였습니다. 자동 생성 코드가 연결해야 할 실제 구현부는 이 이름인데, 저는 그 이름으로 본문을 작성하지 않았습니다. 마지막 LNK1120은 해결되지 않은 외부 참조가 남아서 최종 링크가 실패했다는 결과입니다.

즉, 같은 함수를 두고 “있다, 없다”가 싸우는 상황이 아니었습니다. 호출용 함수는 중복으로 정의했고, 필요한 구현용 함수는 정의하지 않았던 것입니다.

RPC에서는 선언과 호출에 원래 함수명을 사용하고, CPP의 실제 동작은 _Implementation이 붙은 함수에 작성합니다. 호출용 함수와 네트워크 처리에 필요한 연결은 엔진이 생성한 코드가 담당합니다. Epic 공식 문서: UFUNCTION의 RPC 구현 규칙

수정한 구현부는 다음과 같습니다.

//실제 실행할 본문은 _Implementation에 작성해야 생성된 RPC 코드와 연결됩니다.
void ACXGameStateBase::MulticastRPCBroadcastLoginMessage_Implementation(
    const FString& InNameString)
{
    //복제된 GameState의 클라이언트 쪽에서만 아래 출력 로직을 실행합니다.
    if (HasAuthority() == false)
    {
        //각 실행 월드에서 0번 PlayerController를 찾아 그곳의 화면에 출력합니다.
        APlayerController* PC =
            UGameplayStatics::GetPlayerController(GetWorld(), 0);

        if (IsValid(PC) == true)
        {
            //채팅 출력 함수를 가진 사용자 정의 PlayerController인지 확인합니다.
            ACXPlayerController* CXPC = Cast<ACXPlayerController>(PC);

            if (IsValid(CXPC) == true)
            {
                FString NotificationString =
                    InNameString + TEXT(" has joined the game.");

                CXPC->PrintChatMessageString(NotificationString);
            }
        }
    }
}

CPP의 함수 이름에 _Implementation을 붙인 뒤에는 빌드가 정상적으로 진행됐습니다. 호출하는 쪽은 그대로 유지합니다.

//엔진의 RPC 처리 경로를 거치도록 원래 함수 이름으로 호출합니다.
CXGameStateBase->MulticastRPCBroadcastLoginMessage(TEXT("XXXXXXXX"));

흐름을 단순하게 그리면 다음과 같습니다.

원래 함수명으로 RPC 호출
    ↓
엔진이 호출 위치와 RPC 종류에 따라 로컬 실행·네트워크 전달 처리
    ↓
실행 대상에서 _Implementation의 본문 실행

_Implementation()을 직접 호출하면 일반 C++ 함수 호출처럼 그 자리의 본문을 실행하게 됩니다. 네트워크로 RPC를 보내려면 원래 이름을 호출해야 해요. 이름 뒤에 글자를 붙이는 규칙 하나지만, 호출 경로에서 맡는 역할이 달랐습니다.

4. HasAuthority와 Reliable에도 조건이 있습니다

앞의 구현부에는 다음 조건이 들어 있습니다.

//이 GameState가 권한을 가진 쪽인지 검사합니다.
if (HasAuthority() == false)

HasAuthority()는 이 Actor가 권한을 가진 인스턴스인지 확인합니다. 이번처럼 서버에서 복제되는 GameState에서는 서버 쪽이 권한을 갖고, 클라이언트 쪽 복사본은 권한을 갖지 않습니다.

따라서 NetMulticast 자체는 서버에서도 실행되지만, 서버에서는 위 조건을 통과하지 못해 메시지 출력 부분을 건너뜁니다. 데디케이티드 서버 실습에서는 클라이언트 화면에만 메시지를 출력하는 흐름이 됩니다.

다만 리슨 서버에서는 호스트도 서버 쪽 GameState를 사용합니다. 이 조건을 그대로 사용하면 호스트 화면의 출력도 건너뜁니다. 서버이면서 직접 플레이하는 사람이 있다는 차이가 여기서 드러납니다. 그리고 클라이언트에만 생성한 비복제 Actor도 권한을 가질 수 있으므로, HasAuthority()를 모든 상황에서 단순한 서버·클라이언트 구분 함수로 생각하면 안 됩니다. Epic 공식 문서: Actor의 Role과 권한

Reliable도 이름만 보고 범위를 넓혀 해석하면 안 됩니다. 전달 대상이 되는 RPC를 수신 확인이 올 때까지 재전송하는 신뢰성 옵션이지, 연결된 모든 클라이언트를 새로 실행 대상에 포함하는 옵션은 아닙니다. 수신한 뒤 IsValid()나 권한 조건에서 출력 로직을 건너뛰는 것까지 막아주지는 않습니다. Epic 공식 문서: RPC의 Reliability

또한 입장 안내 RPC가 한 번 실행됐다고 해서 과거 안내가 저장되어 나중에 접속한 사람에게 자동으로 다시 표시되는 것은 아닙니다. 현재 게임 상태를 뒤늦게 접속한 클라이언트에게도 전달하는 프로퍼티 복제와, 그 순간의 이벤트를 보내는 RPC는 구분해서 봐야 합니다. Epic 공식 문서: 복제 변수와 RPC

5. 빌드 파일을 정리했더니 에디터 설정도 다시 확인하게 됐습니다

오류를 해결하는 과정에서 Binaries와 Intermediate를 삭제하고 Visual Studio 프로젝트 파일을 재생성했습니다. 이후 에디터를 다시 열었더니 Run Under One Process를 비롯한 멀티플레이 실행 설정이 초기화되어 있었습니다.

프로젝트 파일을 재생성한 뒤에는 실행 환경 설정까지 다시 확인하게 됐어요. 홀리몰리….

여기서 폴더의 역할은 구분해 둘 필요가 있습니다. Binaries에는 컴파일된 모듈 같은 결과물이, Intermediate에는 빌드 중간 산출물과 UHT 생성 파일 등이 들어 있습니다. 이 폴더들을 지워 자동 생성 파일을 다시 만들 수는 있어도, CPP에 잘못 작성한 RPC 함수 이름까지 고쳐주지는 않습니다. 이번 링커 오류의 코드 수정은 _Implementation을 붙이는 것이었습니다. Epic 공식 문서: 프로젝트 폴더 구조

그리고 삭제·재생성 이후 설정이 초기화된 것은 제가 겪은 일이지만, Binaries와 Intermediate 삭제가 직접 원인이라고 단정할 수는 없습니다. PIE 실행 설정은 EditorPerProjectUserSettings 설정 범주에 저장되고, Windows 에디터에서는 프로젝트의 Saved/Config/WindowsEditor/EditorPerProjectUserSettings.ini 등을 확인할 수 있습니다. 설정이 초기화된 정확한 원인은 별도로 확인이 필요한 부분입니다. Epic 공식 문서: PIE 설정 클래스

Run Under One Process는 여러 멀티플레이 창을 하나의 언리얼 엔진 프로세스 안에서 실행하는 옵션입니다. 체크하면 실행이 빠르고 자원을 공유할 수 있으며, 해제하면 여러 프로세스로 분리해 실행합니다. 하나의 프로세스로 실행해도 서로 다른 네트워크 월드 사이의 통신을 실습할 수 있지만, 프로세스까지 분리한 실행 환경과는 차이가 있습니다. Epic 공식 문서: Play In Editor 설정

강의에서는 이 옵션을 해제하고 실습합니다. 같은 프로세스에서는 PrintString() 같은 디버그 출력이 여러 플레이 창에 함께 보일 수 있어서, 다른 창에도 글자가 떴다는 사실만으로 메시지가 RPC를 통해 전달됐다고 판단하면 안 됩니다.

6. 4개는 돌아갔는데 50개는 컴퓨터가 멈췄습니다

멀티플레이 창을 여러 개 실행해 보니 CPU와 RAM 사용량이 99%까지 올라갔습니다. 현재 사용 중인 컴퓨터는 Intel Core Ultra 9 275HX, RAM 32GB, NVIDIA GeForce RTX 5060 Laptop GPU 구성인데, 이 사양에서도 부하가 상당했어요.

멀티플레이 실행 중 기록한 화면

컴퓨터가 말을 안 들어서 캡처 대신 휴대폰으로 찍었습니다. CPU와 RAM 사용량이 높아진 데다 비디오 메모리 쪽에서도 문제가 생겼어요. 멀티플레이 실습을 하면서 컴퓨터의 여러 부품을 동시에 바쁘게 만들었습니다(?).

멀티플레이 실행 중 남긴 사진

4개로 실행했을 때는 부하가 높아도 돌아갔고, 화면 캡처도 할 수 있었습니다. 여유롭게 잘 돌아간다고 하기는 어려웠지만, 실행은 유지되는 정도였습니다.

4개로 실행한 화면

그리고 실행 개수를 50개까지 늘려봤습니다. 결과는 에디터가 터지고 컴퓨터도 제대로 반응하지 않아서 강제 종료했습니다.

50개 실행 뒤 기록한 상황

이 결과를 게임 서버가 50명을 처리하지 못했다는 뜻으로 해석할 수는 없습니다. 이번에는 한 컴퓨터에서 여러 클라이언트의 실행과 화면 렌더링까지 함께 맡겼습니다. 각각 다른 컴퓨터에서 접속하는 상황의 서버 성능과는 측정 조건이 다릅니다.

실행 창 수가 늘면 게임 로직뿐 아니라 렌더링과 메모리 사용도 함께 늘어납니다. 여러 프로세스로 실행하는 환경에서는 프로세스마다 필요한 자원도 들어가고요. 이 실행 결과만으로 멈춘 원인을 하나로 특정할 수는 없지만, 실행 개수를 늘렸을 때 PC에 큰 부담이 생기는 상황은 직접 봤습니다.

7. 마무리

이번에는 RPC로 채팅과 접속 안내를 전달하면서, 선언·호출에 쓰는 함수 이름과 실제 본문을 작성하는 _Implementation을 구분했습니다. NetMulticast도 호출 위치와 Actor의 복제 범위를 확인해야 하고, 함수 안의 조건에 따라 화면 출력 결과가 달라질 수 있다는 점을 함께 정리했습니다.

빌드 오류를 해결한 뒤에는 에디터 실행 설정을 다시 확인했고, 클라이언트를 많이 실행하면서 CPU·RAM·비디오 메모리의 부담도 경험했습니다. 코드가 빌드되는 것과 여러 실행 창을 컴퓨터가 버텨주는 것은 따로 확인할 부분이었네요.

오늘은 여기까지 하겠습니다. 다음에는 컴퓨터가 작동하는 상태에서 인사드릴게요…! 50명의 손님은 잠시 대기해 주십쇼(?).

profile
게임 개발자 꿈나무

0개의 댓글