필기한 내용과 트러블 슈팅 과정을 정리한 뒤, Claude에게 넘겨서 받은 초안을 수정했습니다.
오늘 학습한 것
1. 코딩 테스트 - 쿼드압축 후 개수 세기
2. 스켈레톤 본 기반 트레이스
3. 언리얼 모듈(Module) 개념 및 생성
4.MODULENAME_API매크로 조사
5. 멀티플레이 서버 초기화 & 재접속 트러블슈팅
큰 정사각형을 작은 정사각형 4개로 쪼개면서 압축을 하는 알고리즘으로 보입니다.
처음에는 unionset 처럼 한 칸에서 시작해서 묶는 방법을 생각했는데 예외 처리할 것들이 너무 많아져서 복잡해지더군요. '이건 아니네' 라는 생각이 들어 다른 접근 방식을 찾던 중 다른 분의 댓글을 통해 재귀함수를 사용해야 한다는 것을 깨달았습니다.
#include <string>
#include <vector>
using namespace std;
pair<int, int> addPair(const pair<int, int>& a, const pair<int, int>& b)
{
return make_pair(a.first + b.first, a.second + b.second);
}
pair<int, int> DFS_searchRange(const vector<vector<int>>& arr, int len, pair<int, int> startPos)
{
pair<int, int> oneZeroCnts = make_pair(0, 0);
bool shouldDivided = false;
pair<int, int> endPos = make_pair(startPos.first + len, startPos.second + len);
for(int i = startPos.first; i < endPos.first; i++)
{
for(int j = startPos.second; j < endPos.second; j++)
{
if(arr[i][j] == 1)
oneZeroCnts.first++;
else
oneZeroCnts.second++;
if(oneZeroCnts.first != 0 && oneZeroCnts.second != 0)
{
shouldDivided = true;
break;
}
}
if(shouldDivided) break;
}
if(shouldDivided)
{
int newLen = len / 2;
oneZeroCnts = make_pair(0, 0);
oneZeroCnts = addPair(oneZeroCnts, DFS_searchRange(arr, newLen, make_pair(startPos.first, startPos.second)));
oneZeroCnts = addPair(oneZeroCnts, DFS_searchRange(arr, newLen, make_pair(startPos.first + newLen, startPos.second)));
oneZeroCnts = addPair(oneZeroCnts, DFS_searchRange(arr, newLen, make_pair(startPos.first, startPos.second + newLen)));
oneZeroCnts = addPair(oneZeroCnts, DFS_searchRange(arr, newLen, make_pair(startPos.first + newLen, startPos.second + newLen)));
}
else
{
oneZeroCnts.first = oneZeroCnts.first != 0 ? 1 : 0;
oneZeroCnts.second = oneZeroCnts.second != 0 ? 1 : 0;
}
return oneZeroCnts;
}
vector<int> solution(vector<vector<int>> arr) {
vector<int> answer;
pair<int, int> oneZeroCnts = DFS_searchRange(arr, arr.size(), make_pair(0, 0));
answer.push_back(oneZeroCnts.second);
answer.push_back(oneZeroCnts.first);
return answer;
}
모듈(Modules)은 언리얼 엔진 소프트웨어 아키텍처의 가장 기본적인 구성 요소입니다.
모듈은 특정 에디터 툴, 런타임 기능, 라이브러리 등의 기능을 독립된 코드 단위로 캡슐화합니다.
모든 프로젝트와 플러그인에는 기본적으로 자체 프라이머리 모듈이 있지만, 모듈을 따로 정의하여 코드를 정리할 수 있습니다.
모든 언리얼 엔진 프로젝트는 최소 하나의 Primary 모듈, 하위 모듈, ThirdParty 모듈, Plugin 모듈들의 집합으로 구성됩니다.
Primary 모듈은 언리얼 프로젝트가 실행될 때 가장 먼저 로드되어 엔진과 프로젝트를 연결해주는 메인 진입점(Entry Point) 모듈입니다.
| 특징 | 설명 |
|---|---|
| 진입점 역할 | 게임이 켜질 때 엔진에게 "여기서부터 시작해!"라고 알려주는 역할 |
| 프로젝트 이름과 동일 | ProjectName.Build.cs 파일이 있는 모듈이 프라이머리 모듈 |
| 구현 매크로 | IMPLEMENT_PRIMARY_GAME_MODULE 매크로로 선언 |
왜 "Primary(첫째의, 제1의, 근본적인)"라고 부를까?
언리얼 프로젝트는 여러 개의 모듈을 가질 수 있지만, 이 중 단 하나는 반드시 엔진과 직접 통신하며 게임의 전체 라이프사이클을 관리해야 합니다. 다른 서브 모듈이나 플러그인들이 연결되더라도, 결국 프로젝트의 핵심 바이너리를 생성하는 기준점은 프라이머리 모듈입니다.
| 구분 | 프라이머리 모듈 | 일반/하위 모듈 | 플러그인 모듈 |
|---|---|---|---|
| 필수 여부 | 프로젝트당 1개 필수 | 선택 사항 | 선택 사항 |
| 매크로 | IMPLEMENT_PRIMARY_GAME_MODULE | IMPLEMENT_MODULE | IMPLEMENT_MODULE |
| 목적 | 프로젝트 전체 실행 및 관리 | 특정 기능의 모듈화 | 재사용 가능한 독립 기능 패키징 |
프라이머리 모듈 선언 예시 (ModuleAndPlugin.cpp)
일반적으로 프라이머리 모듈은 Source/[ProjectName]/[ProjectName].cpp 파일에서 선언되고 있습니다
#include "ModuleAndPlugin.h"
#include "Modules/ModuleManager.h"
IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl, ModuleAndPlugin, "ModuleAndPlugin" );
DEFINE_LOG_CATEGORY(LogModuleAndPlugin)
정리하자면 프라이머리 모듈은 "언리얼 엔진이 내 게임 코드를 인식하고 실행하기 위해 반드시 필요한, 프로젝트와 이름이 같은 메인 코드 묶음"입니다.
MySpartaLog.Build.cs, MySpartaLog.h, MySpartaLog.cpp 파일을 생성하고 내용을 작성했습니다.
Target.cs에 모듈 추가
// ModuleAndPluginTarget.cs
public class ModuleAndPluginTarget : TargetRules
{
public ModuleAndPluginTarget(TargetInfo Target) : base(Target)
{
Type = TargetType.Game;
DefaultBuildSettings = BuildSettingsVersion.V5;
IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_6;
ExtraModuleNames.Add("ModuleAndPlugin");
ExtraModuleNames.Add("MySpartaLog");
}
}
// ModuleAndPluginEditorTarget.cs
public class ModuleAndPluginEditorTarget : TargetRules
{
public ModuleAndPluginEditorTarget(TargetInfo Target) : base(Target)
{
Type = TargetType.Editor;
DefaultBuildSettings = BuildSettingsVersion.V5;
IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_6;
ExtraModuleNames.Add("ModuleAndPlugin");
ExtraModuleNames.Add("MySpartaLog");
}
}
.uproject에 모듈 추가
"Modules": [
{
"Name": "MySpartaLog",
"Type": "Runtime",
"LoadingPhase": "PreDefault",
"AdditionalDependencies": [ "Engine" ]
},
{
"Name": "ModuleAndPlugin",
"Type": "Runtime",
"LoadingPhase": "Default",
"AdditionalDependencies": [ "Engine", "AIModule", "UMG" ]
}
]
⚠️ 설정 후 반드시
.uproject우클릭 → Generate Visual Studio project files를 해주어야 합니다.
"LoadingPhase": "PreDefault" — 해당 모듈이 게임 본체보다 먼저 로드되도록 설정한 것입니다.
MODULENAME_API 매크로 조사테스트 액터를 작성하다가 class _API ATestActor 부분에서 _API가 궁금해졌습니다. 어떤 모듈에 속했는지 알리는 용도 정도라고 생각했고, 프로젝트 명_API라고 작성해야 한다고 생각 중이었습니다만 커스텀 모듈의 경우 그냥 _API라고 적어야 하는 건지 싶어서 조사를 해보았습니다.
실제 컴파일이 되기 위해서는 반드시 모듈명_API 형태로 작성해야 합니다. 이 매크로의 정체는 DLL(Dynamic Link Library) 익스포트/임포트 지정자입니다.
언리얼 엔진은 수많은 모듈(DLL 파일들)로 구성됩니다. 기본적으로 C++에서 한 DLL에 있는 클래스나 함수들은 다른 DLL에서 보이지 않습니다.
MODULENAME_API를 붙였을 때: "이 클래스는 외부 모듈에서도 가져다 쓸 수 있도록 공개(Export)하겠다!"는 뜻| 상황 | 치환 결과 |
|---|---|
| 해당 모듈을 빌드할 때 | __declspec(dllexport) — 내 기능을 밖으로 내보내겠다 |
| 다른 모듈에서 이 모듈을 참조할 때 | __declspec(dllimport) — 밖에 있는 기능을 가져오겠다 |
_API를 붙입니다.① TestActor.generated.h 파일을 찾지 못함
임시 파일 삭제 후 Generate Visual Studio files를 했지만 해결되지 않았습니다. 알아본 결과 언리얼 엔진에서는 커스텀 모듈의 경우 Public, Private 폴더에 소스 파일을 넣지 않으면 제대로 인식되지 못하는 오류가 있다고 합니다. 폴더를 생성해 소스 파일을 이동시키고 Build.cs에 기본 경로를 지정하니 해결됐습니다.
② 프라이머리 모듈에서 TestActor에 접근하지 못함
임시 파일 삭제 후 재생성을 시도했지만 해결되지 않았습니다. 원인은 프라이머리 모듈의 Build.cs에서 모듈을 선언하지 않은 것이었다. 교재를 따라 진행하다보니Target.cs만 수정하였습니다. 모듈을 사용하기 위해서는 Build.cs에서도 선언해야 한다는 사실을 잊고 있었습니다.
PublicDependencyModuleNames.AddRange(new string[] {
"Core", "CoreUObject", "Engine", "InputCore",
"EnhancedInput", "AIModule", "StateTreeModule",
"GameplayStateTreeModule", "UMG", "Slate",
"MySpartaLog" // ← 추가
});
해결 후 정상 동작 로그:
LogMySpartaModule: Display: Actor call MySpartaLog Module!
프로젝트: MyDedicatedX | 엔진: UE 5.5
멀티플레이 게임에서 게임이 종료된 후 서버를 초기화하고, 클라이언트들이 타이틀로 복귀한 뒤 서버에 재접속하는 기능을 구현하는 과정에서 여러 크래시와 비정상 종료 문제가 발생했습니다. 로그 분석을 통해 원인을 하나씩 찾아 수정한 과정을 기록합니다.
목표 동작 흐름
EMatchState::Ending 카운트다운 완료)ClientRPCReturnToTitle() RPC 전송?listen 옵션으로 재로드 (초기화)OnRep_ServerLightColor() 크래시발생 위치: DXBox.cpp:128 ADXBox::OnRep_ServerLightColor()
에러: EXCEPTION_ACCESS_VIOLATION reading address 0x0000001100000080
원인
BeginPlay에서 [&] 캡처 람다로 1초마다 OnRep_ServerLightColor()를 호출하는 타이머를 등록했는데, 레벨 전환 시 DXBox 액터가 먼저 소멸된 후 타이머가 뒤늦게 실행되어 이미 소멸된 PointLight 포인터에 접근했습니다. 또한 [&] 캡처는 this가 소멸된 후 댕글링 참조가 되는 추가 위험이 있습니다.
해결
① 람다 → CreateUObject 방식으로 변경 (핵심)
UE의 타이머 시스템이 UObject 수명을 자동으로 추적하여, 객체 소멸 시 콜백을 자동으로 무효화합니다.
// ❌ Before - 람다 캡처 (소멸 후 댕글링 위험)
GetWorld()->GetTimerManager().SetTimer(TimerHandle01,
FTimerDelegate::CreateLambda([&]() -> void {
OnRep_ServerLightColor();
}), 1.f, true);
// ✅ After - CreateUObject (소멸 시 자동 무효화)
GetWorld()->GetTimerManager().SetTimer(
LightTimerHandle, this, &ADXBox::UpdateLightColor, 1.f, true);
② 타이머 핸들을 로컬 변수 → 멤버 변수로 승격
로컬 변수는 BeginPlay 종료 후 핸들이 사라져 EndPlay에서 ClearTimer가 불가능합니다.
③ EndPlay에서 명시적 ClearTimer 추가
void ADXBox::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
Super::EndPlay(EndPlayReason);
GetWorldTimerManager().ClearTimer(LightTimerHandle);
}
④ OnRep_ServerLightColor에 IsValid 체크 추가
void ADXBox::OnRep_ServerLightColor()
{
if (IsValid(PointLight) == false) return;
PointLight->SetLightColor(ServerLightColor);
}
NotifyToAllPlayer() 크래시발생 위치: DXGameModeBase.cpp:166 ADXGameModeBase::NotifyToAllPlayer()
에러: EXCEPTION_ACCESS_VIOLATION reading address 0x000000020003b838
원인
Ending 상태에서 ClientRPCReturnToTitle()를 호출하면 클라이언트 접속이 끊기면서 서버 측 Logout()이 호출됩니다. 이때 MatchState가 아직 Ending인 상태라 타이머가 계속 실행되어 NotifyToAllPlayer()에서 이미 소멸된 PlayerController에 접근했습니다.
해결
① MatchState를 End로 먼저 변경하여 타이머 재진입 차단
if (RemainWaitingTimeForEnding <= 0)
{
DXGameState->MatchState = EMatchState::End; // 먼저 변경!
for (auto* PC : AlivePlayerControllers)
if (IsValid(PC)) PC->ClientRPCReturnToTitle();
for (auto* PC : DeadPlayerControllers)
if (IsValid(PC)) PC->ClientRPCReturnToTitle();
GetWorld()->GetTimerManager().ClearTimer(MainTimerHandle);
// ...
}
② NotifyToAllPlayer에 IsValid 체크 추가 (안전망)
void ADXGameModeBase::NotifyToAllPlayer(const FString& Str)
{
for (auto* PC : AlivePlayerControllers)
if (IsValid(PC)) PC->NotificationText = FText::FromString(Str);
for (auto* PC : DeadPlayerControllers)
if (IsValid(PC)) PC->NotificationText = FText::FromString(Str);
}
ClientRPCReturnToTitle() RPC 미도달 (서버/클라이언트 동시 종료)발생 위치: DXGameModeBase.cpp EMatchState::Ending 처리 블록
증상: 서버가 OpenLevel을 실행하면 클라이언트가 타이틀로 이동하지 않고 그냥 종료됨
원인
ClientRPCReturnToTitle()를 호출한 직후 바로 OpenLevel()을 실행하면, RPC 패킷이 클라이언트에 전달되기 전에 서버의 NetDriver가 종료됩니다. 결과적으로 클라이언트는 RPC를 받지 못하고 HostClosedConnection 에러만 수신하여 그냥 종료됩니다.
로그에서 확인한 타임라인:
// 클라이언트 로그
06:34:43 → NMT_CloseReason: HostClosedConnection
06:34:43 → BroadcastNetworkFailure: Host closed the connection.
06:34:43 → PIE Shutting down
// ClientRPCReturnToTitle 로그는 없음 → RPC가 전달되지 않음
해결
RPC 전송과 OpenLevel 사이에 딜레이 타이머를 삽입하여, 클라이언트가 RPC를 수신하고 타이틀로 이동 완료할 충분한 시간을 확보해줍니다.
for (auto* PC : AlivePlayerControllers)
if (IsValid(PC)) PC->ClientRPCReturnToTitle();
for (auto* PC : DeadPlayerControllers)
if (IsValid(PC)) PC->ClientRPCReturnToTitle();
GetWorld()->GetTimerManager().ClearTimer(MainTimerHandle);
// ✅ RPC 전달 시간 확보 후 서버 초기화
FTimerHandle ResetTimerHandle;
GetWorld()->GetTimerManager().SetTimer(ResetTimerHandle, [this]()
{
FName LevelName = FName(UGameplayStatics::GetCurrentLevelName(this));
UGameplayStatics::OpenLevel(this, LevelName, true, TEXT("listen"));
}, 2.0f, false);
4-1. NotifyToAllPlayer 복사-붙여넣기 실수
DeadPlayerControllers를 순회해야 하는 두 번째 루프에서 AlivePlayerControllers를 두 번 순회하고 있었습니다.
// ❌ Before
for (ADXPlayerController* Dead : AlivePlayerControllers) // 버그!
// ✅ After
for (ADXPlayerController* Dead : DeadPlayerControllers)
4-2. PostLogin에서 Cast 실패 시 nullptr 배열 삽입
IsValid(NewPlayer)를 체크하면서 실제로 배열에 넣는 것은 NewPlayerController(Cast 결과)였다. Cast가 실패하면 nullptr가 배열에 들어갑니다.
// ❌ Before
ADXPlayerController* NewPlayerController = Cast<ADXPlayerController>(NewPlayer);
if (IsValid(NewPlayer) == true) // NewPlayer를 체크하지만
AlivePlayerControllers.Add(NewPlayerController); // Cast 실패 시 nullptr 삽입!
// ✅ After
ADXPlayerController* NewPlayerController = Cast<ADXPlayerController>(NewPlayer);
if (IsValid(NewPlayerController) == true) // Cast 결과를 체크
AlivePlayerControllers.Add(NewPlayerController);
4-3. Playing 상태에서 AlivePlayerControllers[0] 접근 위험
AlivePlayerControllerCount <= 1 조건은 0일 때도 진입합니다. 배열이 비어있는 상태에서 [0] 접근 시 크래시가 발생할 수 있습니다.
// ❌ Before
if (DXGameState->AlivePlayerControllerCount <= 1)
AlivePlayerControllers[0]->ClientRPCShowGameResultWidget(1); // 빈 배열이면 크래시!
// ✅ After
if (DXGameState->AlivePlayerControllerCount <= 1)
{
if (AlivePlayerControllers.Num() > 0 && IsValid(AlivePlayerControllers[0]))
AlivePlayerControllers[0]->ClientRPCShowGameResultWidget(1);
}
4-4. Logout에서 Ending/End 상태 미처리
ClientRPCReturnToTitle() 호출 → 클라이언트 접속 해제 → Logout() 호출 순서로 진행될 때, Ending/End 상태임에도 DeadPlayerControllers에 또 추가하여 배열이 오염되는 문제가 있었습니다.
void ADXGameModeBase::Logout(AController* Exiting)
{
Super::Logout(Exiting);
ADXPlayerController* PC = Cast<ADXPlayerController>(Exiting);
if (IsValid(PC) == false) return;
// ✅ Ending/End 상태에서는 배열 조작 하지 않음
ADXGameStateBase* GS = GetGameState<ADXGameStateBase>();
if (IsValid(GS) &&
(GS->MatchState == EMatchState::Ending ||
GS->MatchState == EMatchState::End)) return;
if (AlivePlayerControllers.Find(PC) != INDEX_NONE)
{
AlivePlayerControllers.Remove(PC);
DeadPlayerControllers.Add(PC);
}
}
모든 수정 적용 후 아래 동작이 정상 확인됐습니다.
ClientRPCReturnToTitle RPC 정상 전달람다 캡처([&], [this])는 위험하다. 람다 타이머는 UE의 UObject 수명 추적 밖에 있어서 액터가 소멸된 후에도 실행될 수 있다.
FTimerDelegate::CreateUObject를 사용하라. UE가 객체 소멸을 감지하면 자동으로 콜백을 무효화한다.
타이머 핸들은 멤버 변수로 관리하라. 로컬 변수 핸들은 BeginPlay 종료 후 소멸되어 EndPlay에서 ClearTimer를 호출할 수 없다.
EndPlay에서 반드시 타이머를 정리하라. 레벨 전환 시 모든 액터의 EndPlay가 호출되므로 이 시점에 명시적으로 ClearTimer를 해야 한다.
이전에도 겪은 문제들이 몇가지 섞여있었습니다. 강의 내용을 무작정 따르기보다는 오늘처럼 내가 생각하기에 더 안전한 방식으로 수정해 보는 것도 좋은 경험인 것 같습니다.
OpenLevel은 NetDriver를 즉시 종료시키므로, 이전에 전송한 RPC가 미처 도달하지 못할 수 있습니다.MatchState를 먼저 변경하라. End 상태로 전환해두어야 타이머 재진입이나 Logout 콜백에서 비정상 처리를 막을 수 있습니다.nullptr이 배열에 들어가면 나중에 원인 파악이 어려운 크래시가 발생합니다.OnRep 함수에서 컴포넌트 유효성을 항상 체크하라. 레플리케이션 콜백은 예상치 못한 타이밍에 실행될 수 있습니다.Num() 체크를 습관화하라. <= 1 조건은 0도 포함함을 항상 인지해야 합니다.Public/Private 폴더에 넣어야 한다. 그렇지 않으면 UHT가 헤더를 제대로 인식하지 못합니다.Build.cs와 Target.cs 모두 수정해야 한다. Target.cs만 수정하는 것으로는 부족합니다.MODULENAME_API는 DLL 익스포트/임포트 지정자다. 다른 모듈에서 접근이 필요한 클래스에는 반드시 붙여줘야 합니다.