기능 및 기구학적 설계가 실제 산업 현장의 자원 소비 모델과 결합된 고신뢰성 3D 디지털 트윈 시뮬레이터입니다. 언리얼 엔진 5 C++ 아키텍처를 기반으로 분산형 통신 인프라를 직접 구축하고, 가상(Sim)과 현실(Real)의 물리적·소프트웨어적 격차인 Sim-to-Real Gap을 제어 알고리즘 및 트러블 슈팅 코드로 명확히 극복했습니다.
본 시뮬레이터는 네트워크 지연 및 외부 인터럽트 환경에서도 유기적으로 동작할 수 있도록 하이브리드 스타(Star) 토폴로지 및 4대 분산 제어 계층 아키텍처를 채택했습니다.
graph LR
%% ==========================================
%% 레이어별 고대비 기술 표준 컬러 정의
%% ==========================================
classDef L1 fill:#E3F2FD,stroke:#1565C0,stroke-width:2.5px,color:#0D47A1,font-weight:bold;
classDef L2 fill:#EDE7F6,stroke:#4527A0,stroke-width:2.5px,color:#1A237E,font-weight:bold;
classDef L3 fill:#F1F8E9,stroke:#33691E,stroke-width:2.5px,color:#1B5E20,font-weight:bold;
classDef L4 fill:#FFF3E0,stroke:#E65100,stroke-width:2.5px,color:#D84315,font-weight:bold;
%% ==========================================
%% 4대 핵심 분산 제어 계층 정의
%% ==========================================
subgraph Layer1 [Layer 1: 인프라 & 통신 허브]
OpenWeather["☀️ OpenWeather API"]
HiveMQ["☁️ HiveMQ MQTT Broker"]
end
subgraph Layer2 [Layer 2: UE5 중앙 관제 코어]
GameInstance["💾 GameInstance<br/>백로그 버퍼링"]
MQTTClient["🔌 MyMQTTClient<br/>스레드 크래시 방어"]
ProcessManager["⚙️ ProcessManager<br/>글로벌 비상 제어"]
end
subgraph Layer3 [Layer 3: 스마트 자동화 설비 액터군]
Vehicle["🚗 Vehicle (진단 및 상태)"]
Lift["🏗️ Lift (소비전력 연산)"]
AGV["🤖 AGV (정밀 도킹 및 안전)"]
RobotArm["🦾 RobotArm (비상제어 동결)"]
end
subgraph Layer4 [Layer 4: HMI 관제 프레임워크]
GameState["📊 GameState (데이터 취합)"]
Dashboard["💻 WBP_Dashboard (관제 HMI)"]
end
%% 클래스 지정
class OpenWeather,HiveMQ Layer1;
class GameInstance,MQTTClient,ProcessManager Layer2;
class Vehicle,Lift,AGV,RobotArm Layer3;
class GameState,Dashboard Layer4;
%% 데이터 흐름 인터랙션
OpenWeather --> GameState
MQTTClient --> HiveMQ
HiveMQ --> MQTTClient
GameInstance --> MQTTClient
MQTTClient --> GameInstance
Vehicle --> ProcessManager
ProcessManager --> Lift
ProcessManager --> AGV
ProcessManager --> RobotArm
Vehicle --> GameState
Lift --> GameState
AGV --> GameState
RobotArm --> GameState
GameState --> Dashboard
ProcessManager -.-> RobotArm
🛠️ 2. 핵심 구현 기술 및 킬러 로직 쇼케이스 (C++ Core)
문제 상황: 오픈소스 Paho MQTT Async C API 라이브러리의 메시지 수신 콜백 함수(GlobalOnMessageArrived)는 언리얼 엔진의 메인 게임 스레드가 아닌 완전히 독립된 외부 백그라운드 스레드에서 비동기적으로 호출됩니다. 이 상태에서 블루프린트 멀티캐스트 델리게이트를 직접 브로드캐스트하면 메인 게임 스레드와의 메모리 레이스 컨디션으로 인해 즉시 엔진 크래시가 발생했습니다.
해결 방안: 수신 콜백 내부에서 TWeakObjectPtr를 가동하여 가비지 컬렉션(GC)으로 인한 액터 소멸 가능성을 방어하고, AsyncTask(ENamedThreads::GameThread, ...) 디스패처를 가동하여 백그라운드 스레드의 데이터를 메인 스레드로 안전하게 컨텍스트 스위칭하여 안전성을 확보했습니다.
int GlobalOnMessageArrived(void* context, char* topicName, int topicLen, MQTTAsync_message* message) { if (AMyMQTTClient* Actor = static_cast<AMyMQTTClient*>(context)) { if (message) { FString Topic(UTF8_TO_TCHAR(topicName));
// 메모리 침범 없이 안전하게 페이로드 문자열 변환
TArray<uint8> PayloadData;
PayloadData.Append((uint8*)message->payload, message->payloadlen);
PayloadData.Add(0); // 널 종료 문자 삽입
FString Payload = UTF8_TO_TCHAR(PayloadData.GetData());
// [핵심] GameThread로 컨텍스트 스위칭을 강제하여 언리얼 렌더러/GC 크래시 원천 차단
TWeakObjectPtr<AMyMQTTClient> WeakThis(Actor);
AsyncTask(ENamedThreads::GameThread, [WeakThis, Topic, Payload]() {
if (WeakThis.IsValid()) {
WeakThis->OnMessageArrived.Broadcast(Topic, Payload); // UI 및 데이터 미러링 트리거
UE_LOG(LogTemp, Log, TEXT("MQTT Message Received - Topic: %s, Message: %s"), *Topic, *Message);
}
});
MQTTAsync_freeMessage(&message);
MQTTAsync_free(topicName);
}
}
return 1;
}
문제 상황: 비동기 메시지 발행(PublishMessage) 요청 시, 외부 백그라운드 스레드가 해당 문자열 데이터를 완전히 복사 및 처리하여 송신하기 전에 언리얼 엔진의 스택 영역에서 FString 포인터 데이터가 먼저 해제(Pop)되어 메모리가 오염되는 위험이 존재했습니다.
해결 방안: 통신 연산이 완전히 완료될 때까지 컨텍스트를 유지하기 위해 순수 C 스타일 구조체를 FMemory::Malloc으로 수동 힙 할당한 후, 발행 완료 정적 콜백 함수(OnPublishSuccess / Failure) 내부에 FMemory::Free 메커니즘을 구성하여 메모리 라이프사이클을 하드웨어 레벨에서 통제했습니다.
void AMyMQTTClient::PublishMessage(FString Topic, FString Message, int32 QoS) { if (!Wrapper || !Wrapper->ClientHandle) return;
FTCHARToUTF8 Utf8Topic(*Topic);
FTCHARToUTF8 Utf8Message(*Message);
// [핵심] 백그라운드 전송 연산 완료 시까지 스택 메모리 유실을 막기 위해 힙 영역 수동 할당(Malloc)
FPubContext* ctx = (FPubContext*)FMemory::Malloc(sizeof(FPubContext));
ctx->TopicStr = (char*)FMemory::Malloc(Utf8Topic.Length() + 1);
FMemory::Memcpy(ctx->TopicStr, Utf8Topic.Get(), Utf8Topic.Length() + 1);
ctx->PayloadStr = (char*)FMemory::Malloc(Utf8Message.Length() + 1);
FMemory::Memcpy(ctx->PayloadStr, Utf8Message.Get(), Utf8Message.Length() + 1);
MQTTAsync_message pubmsg = MQTTAsync_message_initializer;
pubmsg.payload = ctx->PayloadStr;
pubmsg.payloadlen = Utf8Message.Length();
pubmsg.qos = QoS;
MQTTAsync_responseOptions opts = MQTTAsync_responseOptions_initializer;
opts.context = ctx;
opts.onSuccess = OnPublishSuccess; // 내부에서 FMemory::Free(ctx) 수행
opts.onFailure = OnPublishFailure;
int ReturnCode = MQTTAsync_sendMessage(Wrapper->ClientHandle, ctx->TopicStr, &pubmsg, &opts);
if (ReturnCode != MQTTASYNC_SUCCESS) {
FMemory::Free(ctx->TopicStr); FMemory::Free(ctx->PayloadStr); FMemory::Free(ctx); // 즉시 실패 시 예외 복구
}
}
문제 상황: 실제 산업 공장 내 Wi-Fi 음영 지역 발생이나 외부 MQTT 브로커 다운 시, 설비 액터가 발행하는 로그 데이터가 유실되어 가상 세계와 실제 자원 재고 데이터가 불일치하는 심각한 간극이 발생합니다.
해결 방안: UProjectGameInstance 내에 오프라인 상태 감시 인프라를 구축했습니다. 연결 단절 시 모든 데이터 패킷은 FMqttMessage 구조체 내부의 FDateTime::Now() 정밀 타임스탬프와 함께 C++ 대기 버퍼 큐(PendingQueue)에 안전하게 동적 누적됩니다. 통신 허브가 온라인으로 정상 복구되는 순간 ProcessPendingQueue 함수가 재귀적으로 호출되어 생성된 시간 순서대로 순차 전송(FIFO) 완료시킵니다.
void UProjectGameInstance::SetMqttConnectionState(bool bConnected) { bool bOldState = bIsMqttConnected; bIsMqttConnected = bConnected;
// 통신 복구(Offline -> Online) 감지 즉시 버퍼 큐에 누적된 백로그 데이터 플러시 시작
if (!bOldState && bIsMqttConnected && PendingQueue.Num() > 0) {
ProcessPendingQueue();
}
}
void UProjectGameInstance::ProcessPendingQueue() { if (!bIsMqttConnected || PendingQueue.Num() == 0) return;
// 큐의 맨 앞 메시지 참조 (구조체 내 FDateTime::Now() 시점의 타임스탬프 보관)
FMqttMessage& MessageToSend = PendingQueue[0];
bool bPublishSuccess = InternalMqttPublish(MessageToSend); // Paho 라이브러리 송신 시도
if (bPublishSuccess) {
PendingQueue.RemoveAt(0); // 송신 성공 확인 후 큐에서 제거
// 데이터가 남아있다면 다음 백로그 데이터를 재귀적으로 즉시 호출 및 순차 전송(FIFO)
if (PendingQueue.Num() > 0) {
ProcessPendingQueue();
}
}
}
문제 상황: 현장 비상 셧다운 발생 시 단순 시각적 애니메이션만 중지할 경우, C++ 가상 환경 백그라운드에서 독자적으로 흘러가던 공정 스와핑 타이머, 재고 체크 비동기 타이머 핸들들이 지속해서 연산되어, 비상 해제 시 시퀀스가 엉키거나 장비 액터가 공중에서 충돌 파손되는 논리적 역전 현상이 발생했습니다.
해결 방안: 글로벌 비상 차단 신호 수집 즉시 모든 로봇팔 액터들의 상태 엔진을 EmergencyStop으로 전환하고, 구동 중이던 모든 비동기 공정용 타이머 핸들들을 GetWorldTimerManager().PauseTimer() 로직으로 강제 동결하여 하드웨어 레벨의 시간 흐름을 완전 제어했습니다.
void ARobotArmBase::HandleEmergencyStop(bool bEnable) { UAnimInstance* AnimInst = ArmMesh ? ArmMesh->GetAnimInstance() : nullptr;
if (bEnable) {
// [비상 상황 발령]: 현재 설비 스탯 백업 및 비상 정지 모드 전환
SavedStateBeforeStop = CurrentState; CurrentState = ERobotState::EmergencyStop;
// [핵심] 백그라운드에서 가동 중인 모든 비동기 공정용 FTimerHandle 흐름을 동결(Freeze) 처리
if (GetWorldTimerManager().IsTimerActive(WorkPauseTimer)) GetWorldTimerManager().PauseTimer(WorkPauseTimer);
if (GetWorldTimerManager().IsTimerActive(RetryTimer)) GetWorldTimerManager().PauseTimer(RetryTimer);
if (GetWorldTimerManager().IsTimerActive(PhaseTransitionTimer)) GetWorldTimerManager().PauseTimer(PhaseTransitionTimer);
if (GetWorldTimerManager().IsTimerActive(RecoveryTimerHandle)) GetWorldTimerManager().PauseTimer(RecoveryTimerHandle);
if (AnimInst && AnimInst->IsAnyMontagePlaying()) {
CurrentPausedMontage = AnimInst->GetCurrentActiveMontage();
AnimInst->Montage_Pause(); // 구동 애니메이션 일시정지
bWasMontagePlayingBeforeStop = true;
}
}
else {
// [비상 해제 및 복구]: 이전 스탯 복원 및 동결되었던 타이머들의 가동 잔여 프레임 시점부터 카운트다운 언파이프 재개
CurrentState = SavedStateBeforeStop;
if (GetWorldTimerManager().IsTimerPaused(WorkPauseTimer)) GetWorldTimerManager().UnPauseTimer(WorkPauseTimer);
if (GetWorldTimerManager().IsTimerPaused(RetryTimer)) GetWorldTimerManager().UnPauseTimer(RetryTimer);
if (GetWorldTimerManager().IsTimerPaused(PhaseTransitionTimer)) GetWorldTimerManager().UnPauseTimer(PhaseTransitionTimer);
if (GetWorldTimerManager().IsTimerPaused(RecoveryTimerHandle)) GetWorldTimerManager().UnPauseTimer(RecoveryTimerHandle);
if (bWasMontagePlayingBeforeStop && AnimInst && CurrentPausedMontage) {
AnimInst->Montage_Resume(CurrentPausedMontage); // 정지되었던 프레임부터 애니메이션 복구 재생
}
}
OnRobotStateChanged(CurrentState); // HMI 및 시각 경고등 비동기 브로드캐스트
}
문제 상황: 하드웨어 고장 트리거를 특정 상한 온도 기준의 이진법 논리로만 처리할 경우, 장비 누적 마모도와 노이즈 변수가 상존하는 실제 현장의 '확률론적 고장 위험 구간'을 정밀하게 모사하지 못하는 한계가 존재했습니다.
해결 방안: 과열 경고 범위(80°C ~ 100°C) 영역에 진입했을 때 FMath::GetMappedRangeValueClamped 함수를 통해 모터 온도를 최소 10%에서 최대 29% 사이의 선형적 고장 발생 확률(BreakdownChance)로 실시간 매핑 연산하고 FMath::RandRange 난수 룰렛을 실행함으로써, 실제 설비의 통계적 노후화 마모 모델을 성공적으로 구현했습니다.
void ARobotArmBase::CheckTemperatureState() { if (CurrentTemperature >= 100.0f) { TriggerBreakdown(); // 100도 이상 하드웨어 세이프티 가드 강제 잠금 트리거 } else if (CurrentTemperature >= 80.0f) { if (CurrentState != ERobotState::Warning) { CurrentState = ERobotState::Warning; OnRobotStateChanged(CurrentState); }
// [핵심] 80°C~99°C 범위를 10%~29% 사이의 선형적 고장 확률로 가변 매핑
float BreakdownChance = FMath::GetMappedRangeValueClamped(
FVector2D(80.0f, 99.0f), // 입력 범위 (온도)
FVector2D(10.0f, 29.0f), // 출력 범위 (고장 확률 %)
CurrentTemperature
);
// 런타임 난수 룰렛 대조를 통한 비결정론적 고장 제어
if (FMath::RandRange(0.0f, 100.0f) <= BreakdownChance) {
TriggerBreakdown();
}
}
}