본문 바로가기
Unreal Engine/언리얼-ROS-Physical 통합 프로젝트

[UnrealRobotics: SO-101] (4) 언리얼 Output Log에 로그 출력 (C++ 파일 생성 및 디버깅)

by 테크앤아트 2026. 4. 9.
728x90
반응형

 

 

 


 

 

언리얼에서 Hello World 메시지를 받아서 Output Log에 찍기

이전 단계에서 PowerShell에 들어왔던 로그를 이제는 언리얼의 Output Log에 찍히도록 설정해야 한다.

 

 

1단계: .Build.cs에 의존성 추가

언리얼에서 C++ 코드가 다른 모듈의 기능(예: WebSocket, JSON 파서)을 쓰려면 모듈의 .Build.cs 파일에 의존성을 명시해야 한다. 그냥 #include만 해서는 링크 단계에서 unresolved external symbol 에러가 남. 이건 C++의 일반적인 빌드 시스템과는 다른 Unreal Build Tool (UBT)만의 방식.

 

 

언리얼 프로젝트 경로 내 Source/프로젝트명 폴더 안에 프로젝트명.Build.cs를 수정한다.

PrivateDependencyModuleNames.AddRange(new string[] {
    "WebSockets",
    "Json",
    "JsonUtilities"
});

PrivateDependencyModuleNames 부분을 수정한다.

  • WebSockets — 언리얼의 WebSocket 모듈. IWebSocket 인터페이스와 FWebSocketsModule을 쓸 수 있게 해줌. rosbridge와의 연결 자체를 담당
  • Json — 언리얼의 JSON 파서. FJsonObject, TJsonReaderFactory, FJsonSerializer 같은 저수준 JSON 빌드/파싱 도구들이 여기 있다. rosbridge가 보내는 JSON 메시지를 해석하고, 우리가 보낼 메시지를 만들 때 사용.
  • JsonUtilities — 더 고수준 도구. FJsonObjectConverter 클래스를 통해 USTRUCT와 JSON을 자동 변환해줌. 나중에 ROS 메시지 타입을 구조체로 정의할 때 유용함

 

 


 

 

2단계: 로그 카테고리 정의

언리얼에서는 로그를 출력할 때 카테고리를 지정한다.

카테고리의 장점은

  1. 필터링: Output Log 창에서 LogRosBridge만 보기로 필터할 수 있음. 수많은 엔진 로그 속에서 우리 메시지만 골라보기 편함.
  2. 로그 레벨 제어: 카테고리별로 Verbose/Log/Warning/Error 레벨을 따로 조정 가능.
  3. 디버깅 추적: 에러가 났을 때 어느 시스템에서 났는지 즉시 알 수 있음.

 

(1) 폴더 만들기

RosBridge 관련 파일들을 한 폴더에 모으기 위해서 프로젝트 폴더 Source/프로젝트명/ 경로안에 RosBridge 이름의 폴더 생성

 

 

(2) RosBridgeLog.h 파일 만들기

RosBridge 폴더 안에 파일 생성 후 작성

// Copyright ... (저작권 표시는 생략하거나 원하는 대로)
#pragma once

#include "CoreMinimal.h"
#include "Logging/LogMacros.h"

/**
 * Log category for the rosbridge WebSocket client and all related
 * ROS message handling code in this module.
 *
 * Usage:
 *   UE_LOG(LogRosBridge, Log, TEXT("Connected to %s"), *Url);
 *   UE_LOG(LogRosBridge, Warning, TEXT("Reconnect attempt %d"), Attempt);
 *   UE_LOG(LogRosBridge, Error, TEXT("JSON parse failed: %s"), *RawPayload);
 */
DECLARE_LOG_CATEGORY_EXTERN(LogRosBridge, Log, All);
  • #pragma once: C++ 헤더 중복 포함 방지 지시어.
  • #include "CoreMinimal.h": 언리얼의 기본 타입들(FString, TArray, FVector 등)을 쓰기 위한 최소 헤더.
  • #include "Logging/LogMacros.h": DECLARE_LOG_CATEGORY_EXTERN 매크로가 정의된 헤더
  • DECLARE_LOG_CATEGORY_EXTERN(LogRosBridge, Log, All);
    • LogRosBridge는 카테고리 이름
    • Log는 기본 verbosity 레벨. 에디터가 컴파일될 때 이 레벨 이상만 출력된다.(낮은 순서대로 Fatal → Error → Warning → Display → Log → Verbose → VeryVerbose) Log로 두면 VeryVerbose와 Verbose는 기본적으로 무시.
    • All: 컴파일 타임 최대 verbosity. All이면 컴파일러가 모든 레벨을 컴파일한다. 나중에 런타임에 Log.LogRosBridge Verbose 콘솔 명령으로 동적으로 레벨을 올릴 수 있다. Shipping 빌드에서도 디버깅이 가능하게 하려면 All로 두는 게 편함.
  • EXTERN이 붙은 이유: 이건 선언만 하는 매크로. 실제 변수 정의는 .cpp 파일에서 DEFINE_LOG_CATEGORY로 한다. 이렇게 해야 여러 .cpp 파일에서 이 헤더를 include해도 링크 에러가 안남.

 

 

(3) RosBridgeLog.cpp 파일 만들기

#include "RosBridgeLog.h"

DEFINE_LOG_CATEGORY(LogRosBridge);
  • DEFINE_LOG_CATEGORY(LogRosBridge);: 실제 카테고리 변수를 정의한다. 이게 없으면 컴파일은 되어도 링크 단계에서 unresolved external symbol이 될 수 있다. 프로젝트 전체에서 정확히 한 번만 호출되어야 하기 때문에 .cpp에 두는 것.

 

 


 

 

3단계: URosBridgeSubsystem 헤더 파일

언리얼에는 Subsystem이라는 개념이 있습니다. 간단히 말하면 "자동으로 생성되고 자동으로 관리되는 싱글톤". 직접 new로 만들지 않고, 언리얼이 적절한 시점에 만들고 파괴해준다.

 

Subsystem 종류는 여러 가지가 있는데, 각자 수명(lifetime)이 다르다.

 

  • UEngineSubsystem: 엔진이 뜰 때 생성, 엔진이 꺼질 때 파괴. 에디터 툴 같은 것에 씀.
  • UGameInstanceSubsystem: 게임 인스턴스가 만들어질 때 생성, 게임이 완전히 끝날 때 파괴. 레벨 변경에도 살아남음.
  • UWorldSubsystem: 월드(레벨) 단위로 생성/파괴. 레벨 바뀌면 새로 만들어짐.
  • ULocalPlayerSubsystem: 로컬 플레이어 단위.

 

 

이 프로젝트에 UGameInstanceSubsystem을 쓰는 이유

 

  • WebSocket 연결은 레벨 변경에 영향받으면 안 됨. 레벨 바꾸는 동안 rosbridge와의 연결이 끊어졌다 다시 붙는 건 낭비고 불안정하기 때문.
  • 전역에서 쉽게 접근 가능. 어느 액터에서든 GetGameInstance()->GetSubsystem<URosBridgeSubsystem>()으로 바로 가져올 수 있음.
  • Initialize/Deinitialize 훅이 있음. WebSocket 연결/해제 시점이 명확함.

 

 

(1) RosBridgeSubsystem.h 파일 생성

 

마찬가지로 RosBridge 폴더 안에 파일 생성 후 작성

#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "RosBridgeSubsystem.generated.h"

// Forward declarations — avoid pulling heavy headers into this header.
class IWebSocket;

/**
 * Delegate fired when a subscribed topic receives a message.
 *
 * @param Topic      The topic name (e.g. "/chatter")
 * @param MessageJson  The "msg" field of the rosbridge publish message,
 *                     already extracted as a JSON string for the caller
 *                     to parse into whatever struct they need.
 */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(
    FOnRosTopicMessage,
    const FString&, Topic,
    const FString&, MessageJson
);

/**
 * Game instance subsystem that owns the WebSocket connection to
 * rosbridge_server and dispatches incoming topic messages to subscribers.
 *
 * Typical usage from any actor or component:
 *
 *   URosBridgeSubsystem* Ros =
 *       GetGameInstance()->GetSubsystem<URosBridgeSubsystem>();
 *   Ros->Connect(TEXT("ws://localhost:9090"));
 *   Ros->Subscribe(TEXT("/chatter"), TEXT("std_msgs/String"));
 *   Ros->OnTopicMessage.AddDynamic(this, &AMyActor::HandleRosMessage);
 */
UCLASS()
class SO101_TWIN_API URosBridgeSubsystem : public UGameInstanceSubsystem
{
    GENERATED_BODY()

public:
    // --- Subsystem lifecycle ---
    virtual void Initialize(FSubsystemCollectionBase& Collection) override;
    virtual void Deinitialize() override;

    // --- Connection control ---

    /** Open a WebSocket connection to the given rosbridge URL. */
    UFUNCTION(BlueprintCallable, Category = "ROS|Bridge")
    void Connect(const FString& Url = TEXT("ws://localhost:9090"));

    /** Close the WebSocket connection if open. */
    UFUNCTION(BlueprintCallable, Category = "ROS|Bridge")
    void Disconnect();

    /** True if the WebSocket is currently open. */
    UFUNCTION(BlueprintPure, Category = "ROS|Bridge")
    bool IsConnected() const;

    // --- Topic operations ---

    /**
     * Subscribe to a ROS topic through rosbridge.
     * Must be called after IsConnected() returns true, OR queued
     * internally and flushed on connect. In this minimal version we
     * require the connection to be open already.
     */
    UFUNCTION(BlueprintCallable, Category = "ROS|Bridge")
    void Subscribe(const FString& Topic, const FString& Type);

    // --- Events ---

    /** Broadcast whenever a publish message arrives on a subscribed topic. */
    UPROPERTY(BlueprintAssignable, Category = "ROS|Bridge")
    FOnRosTopicMessage OnTopicMessage;

private:
    // --- WebSocket event handlers (all run on non-game thread!) ---
    void HandleConnected();
    void HandleConnectionError(const FString& Error);
    void HandleClosed(int32 StatusCode, const FString& Reason, bool bWasClean);
    void HandleMessage(const FString& Message);

    // --- Game-thread message processing ---
    void ProcessIncomingMessage(const FString& Message);

    // --- State ---

    /** The underlying WebSocket. Kept as a TSharedPtr because IWebSocket
     *  is a plain C++ interface, not a UObject, so UPROPERTY does not apply. */
    TSharedPtr<IWebSocket> Socket;

    /** Topics we have sent a subscribe op for, kept so we can re-subscribe on reconnect later. */
    UPROPERTY()
    TMap<FString, FString> SubscribedTopics;
};
  • RosBridgeSubsystem.generated.h는 언리얼 헤더 툴(UHT)이 자동 생성하는 파일. 파일 안에 UCLASS, UFUNCTION, UPROPERTY 같은 리플렉션 매크로가 있으면 UHT가 자동으로 이 generated.h를 만들어서 필요한 추가 코드를 넣어줌. 반드시 마지막 include여야 한다. 안 그러면 UCLASS: undeclared identifier 같은 에러가 남.
  • 헤더에서 IWebSocket을 쓰고 실제 정의는 include하지 않는다. IWebSocket.h를 include하는 순간 우리 헤더를 include하는 모든 파일이 WebSocket 모듈 헤더를 따라 들어가야 해서 컴파일 시간이 늘어나기 때문. 실제 정의가 필요한 건 .cpp 파일이고, 거기서만 #include "IWebSocket.h"를 한다.
  • 델리게이트 선언부: 이 선언을 통해 FOnRosTopicMessage라는 타입이 생기고, 그걸 아래 UPROPERTY(BlueprintAssignable)에 쓸 수 있게 된다.
    • DYNAMIC: Blueprint에서도 쓸 수 있는 델리게이트. 리플렉션을 통해 동작하므로 느리지만 Blueprint 친화적.
    • MULTICAST: 여러 함수가 동시에 등록 가능. 한 토픽을 여러 액터가 구독할 수 있게 함.
    • TwoParams: 인자 2개. Topic 이름과 JSON 문자열.
    • * 인자로 FString JSON을 그대로 주는 이유는 가장 유연하기 때문. 토픽마다 메시지 타입이 달라서 Subsystem이 모든 타입을 알 수는 없다. 대신 "일단 JSON 문자열로 줄 테니 필요한 쪽에서 파싱해" 방식으로 책임을 분리하는 것.
  • UCLASS 선언부
    • SO101_TWIN_API: 모듈의 DLL export 매크로.  언리얼이 모듈별로 DLL을 분리해서 빌드하기 때문에, 다른 모듈에서 이 클래스를 쓸 수 있게 하려면 이 매크로가 필요하다.
    • public UGameInstanceSubsystem: 이게 핵심적인 부모 클래스. 이걸 상속받으면 언리얼이 자동으로 인스턴스화해줌.
    • GENERATED_BODY(): UHT가 자동 생성한 코드를 클래스 안에 삽입하는 매크로. 클래스 시작 직후에 반드시 있어야 함.
  • Initialize / Deinitialize: Subsystem이 만들어질 때 Initialize가 자동으로 호출되고, 파괴될 때 Deinitialize가 호출 됨. 이 안에서 WebSocket 모듈 로드/언로드, 자동 재연결 타이머 설정 등을 할 수 있다.
  • Connection API
    • UFUNCTION(BlueprintCallable): Blueprint에서도 호출 가능한 함수로 만든다. 나중에 Blueprint로 UI 버튼 만들어서 "연결" 누르면 이 함수가 호출되게 할 수 있다.
    • Category = "ROS|Bridge": Blueprint 에디터에서 이 함수를 찾을 때 ROS > Bridge 카테고리 아래에 표시된다. 파이프(|)는 계층 구분자.
    • IsConnected에 BlueprintPure: "Pure" 함수는 상태를 변경하지 않고 값만 돌려주는 getter. Blueprint 그래프에서 실행 핀(흰색 화살표) 없이 직접 값을 뽑을 수 있다.
  • OnTopicMessage 이벤트
    • BlueprintAssignable: Blueprint에서 이 이벤트에 핸들러 함수를 바인딩할 수 있게 해준다. 멀티캐스트 델리게이트에만 쓸 수 있음.
  • 상태 변수
    • UPROPERTY가 아니고 TSharedPtr인 이유는 IWebSocket은 UObject 파생이 아닌 일반 C++ 인터페이스이기 때문. UPROPERTY는 UObject 포인터 관리 전용이다. IWebSocket 같은 non-UObject는 TSharedPtr로 소유권을 관리하는 게 맞다. 
    • 반면 TMap<FString, FString>은 UPROPERTY()가 가능하다. UPROPERTY()를 붙이는 이유는 GC가 이 맵을 올바르게 추적하게 하기 위해서이다. FString에는 GC 대상 UObject가 없어서 사실 없어도 동작은 하지만, 습관적으로 붙이는 게 안전하다. 이 맵은 "어떤 토픽을 어떤 타입으로 구독했는지" 기록해서 나중에 재연결 시 다시 구독할 수 있게 하는 용도.

 

 


 

 

4단계: URosBridgeSubsystem 구현 파일

헤더에서 선언한 함수들의 실제 동작을 정의한다.

 

  1. 생명주기: Initialize에서 WebSockets 모듈 로드, Deinitialize에서 연결 정리
  2. 연결 관리: Connect, Disconnect, IsConnected
  3. 토픽 작업: Subscribe가 JSON을 만들어 rosbridge로 전송
  4. 메시지 수신과 스레드 마샬링: 다른 스레드에서 들어온 메시지를 게임 스레드로 넘겨서 JSON을 파싱하고 델리게이트를 쏨

 

 

(1) RosBridgeSubsystem.cpp 파일 생성

#include "RosBridgeSubsystem.h"
#include "RosBridgeLog.h"

#include "WebSocketsModule.h"
#include "IWebSocket.h"
#include "Async/Async.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"

// =============================================================================
// Subsystem lifecycle
// =============================================================================

void URosBridgeSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
    Super::Initialize(Collection);

    // The WebSockets module may not be loaded yet at this point (depends on
    // module startup order), so force-load it before we try to create a socket.
    if (!FModuleManager::Get().IsModuleLoaded("WebSockets"))
    {
        FModuleManager::Get().LoadModule("WebSockets");
    }

    UE_LOG(LogRosBridge, Log, TEXT("RosBridgeSubsystem initialized"));
}

void URosBridgeSubsystem::Deinitialize()
{
    Disconnect();
    UE_LOG(LogRosBridge, Log, TEXT("RosBridgeSubsystem deinitialized"));

    Super::Deinitialize();
}

// =============================================================================
// Connection control
// =============================================================================

void URosBridgeSubsystem::Connect(const FString& Url)
{
    if (Socket.IsValid() && Socket->IsConnected())
    {
        UE_LOG(LogRosBridge, Warning,
            TEXT("Connect called but socket is already connected. Ignoring."));
        return;
    }

    UE_LOG(LogRosBridge, Log, TEXT("Connecting to %s ..."), *Url);

    Socket = FWebSocketsModule::Get().CreateWebSocket(Url);

    // Bind handlers. All of these fire on a non-game thread.
    Socket->OnConnected().AddUObject(this, &URosBridgeSubsystem::HandleConnected);
    Socket->OnConnectionError().AddUObject(this, &URosBridgeSubsystem::HandleConnectionError);
    Socket->OnClosed().AddUObject(this, &URosBridgeSubsystem::HandleClosed);
    Socket->OnMessage().AddUObject(this, &URosBridgeSubsystem::HandleMessage);

    Socket->Connect();
}

void URosBridgeSubsystem::Disconnect()
{
    if (Socket.IsValid())
    {
        if (Socket->IsConnected())
        {
            Socket->Close();
        }
        Socket.Reset();
    }
}

bool URosBridgeSubsystem::IsConnected() const
{
    return Socket.IsValid() && Socket->IsConnected();
}

// =============================================================================
// Topic operations
// =============================================================================

void URosBridgeSubsystem::Subscribe(const FString& Topic, const FString& Type)
{
    if (!IsConnected())
    {
        UE_LOG(LogRosBridge, Warning,
            TEXT("Subscribe('%s') called but not connected. Ignoring."), *Topic);
        return;
    }

    // Build: {"op":"subscribe","topic":"<Topic>","type":"<Type>"}
    const TSharedRef<FJsonObject> Json = MakeShared<FJsonObject>();
    Json->SetStringField(TEXT("op"), TEXT("subscribe"));
    Json->SetStringField(TEXT("topic"), Topic);
    Json->SetStringField(TEXT("type"), Type);

    FString Payload;
    const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Payload);
    FJsonSerializer::Serialize(Json, Writer);

    Socket->Send(Payload);
    SubscribedTopics.Add(Topic, Type);

    UE_LOG(LogRosBridge, Log, TEXT("Subscribed to %s (%s)"), *Topic, *Type);
}

// =============================================================================
// WebSocket event handlers (NON-GAME THREAD)
// =============================================================================

void URosBridgeSubsystem::HandleConnected()
{
    UE_LOG(LogRosBridge, Log, TEXT("WebSocket connected"));
}

void URosBridgeSubsystem::HandleConnectionError(const FString& Error)
{
    UE_LOG(LogRosBridge, Error, TEXT("WebSocket connection error: %s"), *Error);
}

void URosBridgeSubsystem::HandleClosed(int32 StatusCode, const FString& Reason, bool bWasClean)
{
    UE_LOG(LogRosBridge, Warning,
        TEXT("WebSocket closed (code=%d, clean=%s): %s"),
        StatusCode, bWasClean ? TEXT("true") : TEXT("false"), *Reason);
}

void URosBridgeSubsystem::HandleMessage(const FString& Message)
{
    // We are NOT on the game thread. Marshal to the game thread before
    // touching any UObject state (including broadcasting the delegate).
    TWeakObjectPtr<URosBridgeSubsystem> WeakThis(this);
    AsyncTask(ENamedThreads::GameThread, [WeakThis, Message]()
        {
            if (URosBridgeSubsystem * StrongThis = WeakThis.Get())
            {
                StrongThis->ProcessIncomingMessage(Message);
            }
        });
}

// =============================================================================
// Game-thread message processing
// =============================================================================

void URosBridgeSubsystem::ProcessIncomingMessage(const FString& Message)
{
    // Parse the top-level JSON object.
    TSharedPtr<FJsonObject> Json;
    const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Message);
    if (!FJsonSerializer::Deserialize(Reader, Json) || !Json.IsValid())
    {
        UE_LOG(LogRosBridge, Error, TEXT("Failed to parse incoming JSON: %s"), *Message);
        return;
    }

    // rosbridge v2 uses an "op" field to describe the operation.
    // For a subscribed topic we care about {"op":"publish","topic":...,"msg":{...}}.
    FString Op;
    if (!Json->TryGetStringField(TEXT("op"), Op))
    {
        UE_LOG(LogRosBridge, Warning, TEXT("Incoming message has no 'op' field: %s"), *Message);
        return;
    }

    if (Op == TEXT("publish"))
    {
        FString Topic;
        if (!Json->TryGetStringField(TEXT("topic"), Topic))
        {
            UE_LOG(LogRosBridge, Warning, TEXT("publish without topic: %s"), *Message);
            return;
        }

        // Re-serialize the "msg" sub-object back into a string. This keeps the
        // subsystem message-type-agnostic: the receiver parses whatever it needs.
        const TSharedPtr<FJsonObject>* MsgObjectPtr = nullptr;
        FString MsgJson;
        if (Json->TryGetObjectField(TEXT("msg"), MsgObjectPtr) && MsgObjectPtr && MsgObjectPtr->IsValid())
        {
            const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&MsgJson);
            FJsonSerializer::Serialize(MsgObjectPtr->ToSharedRef(), Writer);
        }

        OnTopicMessage.Broadcast(Topic, MsgJson);
    }
    else
    {
        // Other ops (status, service_response, etc.) - just log for now.
        UE_LOG(LogRosBridge, Verbose, TEXT("Unhandled op '%s': %s"), *Op, *Message);
    }
}
  • 상단 include: 헤더에서는 가볍게, .cpp에서 전체 가져오기.
    • WebSocketsModule.h: FWebSocketsModule::Get().CreateWebSocket(...)을 쓰기 위함
    • IWebSocket.h: IWebSocket 인터페이스의 실제 메서드 정의
    • Async/Async.h: 스레드 마샬링용 AsyncTask
    • Dom/JsonObject.h, JsonValue.h: JSON 객체를 만드는 타입
    • Serialization/JsonReader.h, JsonSerializer.h, JsonWriter.h: JSON 문자열 ↔ 객체 변환기
  • Initialize와 Deinitialize
    • Super::Initialize(Collection): 부모 클래스(UGameInstanceSubsystem)의 Initialize를 먼저 호출한다. 언리얼에서 virtual override는 거의 항상 Super 호출을 포함한다. 빼먹으면 부모 초기화가 안 되어서 이상한 버그가 생김.
    • FModuleManager::Get().LoadModule("WebSockets"): .Build.cs에 의존성을 추가했지만, 그건 링크 시 얘기. 런타임에 실제로 모듈이 로드되어 있는지는 또 다른 문제다. 엔진 기동 순서에 따라 WebSockets 모듈이 아직 안 떠 있을 수 있어서, 강제로 로드한다. 이미 로드되어 있으면 두 번째 호출은 무시된다.
  • Connect 부분
    • CreateWebSocket: WebSocket 객체를 만드는 팩토리 함수.
    • AddUObject(this, &...): 델리게이트에 멤버 함수를 바인딩한다. UObject 포인터를 넘기는 게 핵심인데, 언리얼이 이 포인터를 약참조(weak reference)로 추적해서 this가 GC된 뒤 콜백이 호출되면 자동으로 무시해준다. 생 포인터로 람다 캡처하는 것보다 훨씬 안전함.
    • Socket->Connect(): 실제 연결 시도. 이건 비동기. 호출 직후에 바로 연결되는 게 아니라, 나중에 HandleConnected 또는 HandleConnectionError 중 하나가 다른 스레드에서 호출된다.
  • Disconnect와 IsConnected: TSharedPtr의 IsValid()를 먼저 확인하는 게 안전하다. null을 역참조하면 크래시. Socket.Reset()은 참조 카운트를 떨어뜨리고 객체를 파괴시킨다.
  • Subscribe: rosbridge에 보낼 JSON을 만드는 과정
    • FJsonObject는 언리얼의 JSON 객체 표현. SetStringField로 키-값을 추가한다. TSharedRef는 "절대 null이 아닌 shared pointer"인데, MakeShared로 만들면 바로 유효한 상태.
    • FJsonObject를 실제 JSON 문자열로 직렬화한다. TJsonWriter에 &Payload를 넘기면 결과가 그 FString에 쓰인다. 완료되면 Payload는 {"op":"subscribe","topic":"/chatter","type":"std_msgs/String"} 같은 문자열이 된다.
    • PowerShell에서 직접 문자열로 만들었던 것과 같은 내용이지만, FJsonObject를 거치는 게 안전하다. — 이스케이프 처리가 자동이고, 나중에 복잡한 메시지를 만들 때도 이 패턴이 그대로 확장된다.
    • 직렬화된 문자열을 WebSocket으로 보내고, 나중에 재연결 시 다시 구독할 수 있게 기록.
  • 스레드 마샬링 (HandleMessage):  여기가 이 파일의 가장 중요한 부분
    • TWeakObjectPtr<URosBridgeSubsystem> WeakThis(this): this의 약참조를 만든다. 람다가 실행될 시점에 subsystem이 이미 파괴되어 있을 수 있기 때문에, 강참조로 잡으면 dangling pointer가 된다.
    • AsyncTask(ENamedThreads::GameThread, [...](): "이 람다를 게임 스레드에서 실행해달라"는 요청. 즉시 실행되지 않고 게임 스레드의 다음 프레임에 예약된다.
    • if (URosBridgeSubsystem* StrongThis = WeakThis.Get()): 람다가 실제로 실행되는 시점에, 약참조를 강참조로 변환 시도한다. 만약 subsystem이 그 사이에 파괴됐다면 Get()이 nullptr을 반환하고, if 블록이 실행되지 않아서 안전하게 넘어간다.
    • StrongThis->ProcessIncomingMessage(Message): 이제 안전하게 게임 스레드에서 처리한다. 여기서부터는 UObject를 맘대로 건드려도 된다.
  • ProcessIncomingMessage — JSON 파싱
    • Deserialize는 문자열을 FJsonObject 트리로 파싱한다. 실패하면 로그 찍고 빠져나옴. 실제 로봇과 붙이면 가끔 깨진 패킷이 오는 경우도 있어서, 파싱 실패에 크래시하면 안 된다.
    • rosbridge v2는 모든 메시지에 "op" 필드가 있다.(publish, subscribe, call_service, service_response, status 등) 우리는 지금 publish만 처리한다.
    • 받은 JSON에서 "msg" 서브 객체를 다시 문자열로 직렬화해서 델리게이트에 넘긴다. 이유는,
      • Subsystem은 토픽이 어떤 타입인지 모름 (std_msgs/String인지 nav_msgs/Odometry인지)
      • 구독한 쪽(테스트 액터 등)은 알고 있음
      • 그래서 "msg 부분을 문자열로 줄 테니 너가 알아서 파싱해"라는 계약
  • OnTopicMessage.Broadcast(Topic, MsgJson)이 마지막에 델리게이트에 등록된 모든 함수에 이벤트를 쏜다.

 

 


 

 

5단계: 테스트 액터 ARosTestActor

이 액터가 할 일

 

  1. BeginPlay에서 URosBridgeSubsystem을 가져옴
  2. 아직 연결 안 되어 있으면 Connect 호출
  3. 잠깐 기다렸다가 /chatter를 구독 (연결이 비동기라서 즉시 구독하면 실패)
  4. OnTopicMessage 델리게이트에 핸들러 바인딩
  5. 메시지가 들어오면 화면과 Output Log에 출력

 

 

(1) RosTestActor.h 생성

마찬가지로 RosBridge 폴더 아래에 계속 파일을 만든다.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "RosTestActor.generated.h"

/**
 * Minimal test actor that verifies the rosbridge subsystem end-to-end.
 *
 * Drop one instance of this actor into any level, then Play In Editor.
 * With rosbridge_server running in WSL2 and demo_nodes_cpp talker publishing
 * on /chatter, the Output Log should print a "Received on /chatter: ..."
 * line every second and the same message will appear on-screen.
 */
UCLASS()
class SO101_TWIN_API ARosTestActor : public AActor
{
    GENERATED_BODY()

public:
    ARosTestActor();

protected:
    virtual void BeginPlay() override;
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

    /** URL of the rosbridge WebSocket server. Editable from the Details panel. */
    UPROPERTY(EditAnywhere, Category = "ROS|Test")
    FString RosBridgeUrl = TEXT("ws://localhost:9090");

    /** Topic we want to subscribe to. */
    UPROPERTY(EditAnywhere, Category = "ROS|Test")
    FString TopicName = TEXT("/chatter");

    /** ROS message type string that rosbridge expects for the subscribe op. */
    UPROPERTY(EditAnywhere, Category = "ROS|Test")
    FString TopicType = TEXT("std_msgs/String");

    /** Delay (seconds) between Connect and Subscribe, to give the handshake time to complete. */
    UPROPERTY(EditAnywhere, Category = "ROS|Test")
    float SubscribeDelaySeconds = 1.0f;

private:
    /** Handler bound to URosBridgeSubsystem::OnTopicMessage. */
    UFUNCTION()
    void OnRosMessage(const FString& Topic, const FString& MessageJson);

    /** Timer handle for the delayed Subscribe call. */
    FTimerHandle SubscribeTimerHandle;

    /** Actually send the subscribe op (called by the timer). */
    void DoSubscribe();
};
  • UPROPERTY
    • EditAnywhere: 에디터의 Details 패널에서 값을 바꿀 수 있게 해준다. 액터를 레벨에 놓은 뒤 클릭하면 오른쪽 Details 패널에서 ROS > Test 카테고리 아래에 Ros Bridge Url, Topic Name, Topic Type, Subscribe Delay Seconds가 보인다. 코드 재컴파일 없이 URL이나 토픽을 바꿔가며 테스트할 수 있다는 뜻.
    • 기본값을 = TEXT("...") 형태로 헤더에서 바로 준다. UE5에서는 이 in-place 초기화 방식이 표준.
  • UFUNCTION() 델리게이트 핸들러
    • UFUNCTION()이 필수인 이유: AddDynamic으로 바인딩되는 함수는 반드시 UFUNCTION()이어야 한다. Dynamic delegate가 리플렉션 기반이기 때문. 빼먹으면 AddDynamic이 컴파일 에러를 낸다. 카테고리나 Blueprint 노출은 필요 없으니 괄호는 비워둔다.

 

 

(2) RosTestActor.cpp 생성

#include "RosTestActor.h"
#include "RosBridgeSubsystem.h"
#include "RosBridgeLog.h"

#include "Engine/Engine.h"       // GEngine
#include "Engine/World.h"        // GetWorld
#include "TimerManager.h"        // FTimerManager
#include "Kismet/GameplayStatics.h"

ARosTestActor::ARosTestActor()
{
    PrimaryActorTick.bCanEverTick = false;
}

void ARosTestActor::BeginPlay()
{
    Super::BeginPlay();

    UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
    if (!GI)
    {
        UE_LOG(LogRosBridge, Error, TEXT("RosTestActor: no GameInstance"));
        return;
    }

    URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
    if (!Ros)
    {
        UE_LOG(LogRosBridge, Error, TEXT("RosTestActor: RosBridgeSubsystem not found"));
        return;
    }

    // Bind the delegate BEFORE connecting so we do not miss early messages.
    Ros->OnTopicMessage.AddDynamic(this, &ARosTestActor::OnRosMessage);

    if (!Ros->IsConnected())
    {
        Ros->Connect(RosBridgeUrl);
    }

    // Connect is asynchronous. Schedule the subscribe call slightly later so
    // the WebSocket handshake has a chance to finish. This is a minimal
    // approach — a more robust version would wait for an "on connected" event.
    GetWorld()->GetTimerManager().SetTimer(
        SubscribeTimerHandle,
        this,
        &ARosTestActor::DoSubscribe,
        SubscribeDelaySeconds,
        false // not looping
    );
}

void ARosTestActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    if (UGameInstance* GI = UGameplayStatics::GetGameInstance(this))
    {
        if (URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>())
        {
            Ros->OnTopicMessage.RemoveDynamic(this, &ARosTestActor::OnRosMessage);
        }
    }

    if (GetWorld())
    {
        GetWorld()->GetTimerManager().ClearTimer(SubscribeTimerHandle);
    }

    Super::EndPlay(EndPlayReason);
}

void ARosTestActor::DoSubscribe()
{
    UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
    if (!GI) return;

    URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
    if (!Ros) return;

    if (!Ros->IsConnected())
    {
        UE_LOG(LogRosBridge, Warning,
            TEXT("RosTestActor: not connected yet, retrying subscribe in %.1fs"),
            SubscribeDelaySeconds);

        // Retry once more.
        GetWorld()->GetTimerManager().SetTimer(
            SubscribeTimerHandle,
            this,
            &ARosTestActor::DoSubscribe,
            SubscribeDelaySeconds,
            false);
        return;
    }

    Ros->Subscribe(TopicName, TopicType);
}

void ARosTestActor::OnRosMessage(const FString& Topic, const FString& MessageJson)
{
    UE_LOG(LogRosBridge, Log, TEXT("Received on %s: %s"), *Topic, *MessageJson);

    if (GEngine)
    {
        const FString ScreenMsg = FString::Printf(TEXT("%s: %s"), *Topic, *MessageJson);
        GEngine->AddOnScreenDebugMessage(
            -1,        // key (-1 means always add a new line)
            2.0f,      // display time in seconds
            FColor::Green,
            ScreenMsg
        );
    }
}
  • ARosTestActor 생성자
    • 이 액터는 Tick이 필요 없다(이벤트 기반으로만 동작). 기본값이 true인데 꺼주는 게 성능상 좋음. 실제 로봇 비주얼라이저도 Tick 최소화 원칙을 따를 것.
  • BeginPlay의 핵심 흐름
    • Subsystem 접근 패턴: "게임 인스턴스 가져오기 → 거기서 Subsystem 가져오기". 어느 액터에서든 이 두 줄이면 된다. UGameplayStatics::GetGameInstance(this)의 this는 "나를 기준으로 world context를 찾아라"라는 의미인데, 에디터 PIE에서도 올바른 인스턴스를 리턴해준다.
  • 델리게이트 바인딩을 연결 전에 하는 이유: Connect와 Subscribe가 비동기라 타이밍이 미묘하다. 바인딩을 먼저 해두면 첫 메시지를 놓칠 일이 없음. 일반 원칙.
  • 타이머로 지연 구독
    • Connect는 비동기이기 때문에 호출 직후에 IsConnected가 true가 아니다. HandleConnected 콜백이 다른 스레드에서 불린 뒤에야 연결 상태가 된다. 그래서 1초 지연 후에 DoSubscribe를 호출한다.
    • TimerManager는 언리얼의 표준 "N초 후 함수 호출" 메커니즘. SetTimer의 마지막 인자 false는 "반복하지 않음"을 의미함. 만약 1초 후에도 연결이 안 되어 있으면 DoSubscribe 안에서 한 번 더 재시도한다.
  • EndPlay에서 정리
    • 에디터 PIE에서 Stop을 누르면 액터는 파괴되지만 Subsystem은 살아있다(GameInstance 수명이기 때문). 파괴된 액터의 멤버 함수가 델리게이트에 남아있으면 다음 메시지 수신 시 dangling 호출이 된다. 명시적으로 RemoveDynamic으로 빼준다.
    • 타이머도 마찬가지로 클리어. 안 하면 액터가 파괴된 뒤에 타이머가 터져서 파괴된 this를 참조하려다 크래시할 수 있음.
  • OnRosMessage
    • UE_LOG는 Output Log로. GEngine->AddOnScreenDebugMessage는 뷰포트 좌상단에 초록 글씨로. 둘 다 쓰는 이유는 PIE 중에 화면에서도 바로 보이고, 나중에 로그를 뒤질 때도 찾을 수 있게 하기 위함.
    • *Topic의 *의 의미: FString을 TEXT("...") 포맷 문자열에 넣으려면 const TCHAR*로 변환해야 하는데, FString::operator*가 그 역할을 한다. 언리얼 로그의 가장 흔한 관용구.
    • -1 첫 인자: 메시지를 "새 줄로 추가". 같은 key를 주면 해당 줄을 덮어쓴다. (예: 로봇 포즈를 한 줄에 계속 업데이트하려면 key = 0 식으로)

 

 


 

 

6단계: 프로젝트 파일 재생성 + 빌드

(1) 프로젝트 파일 재생성

 

이제 Visual Studio 솔루션에 새로 만든 파일 4개를 인식시키고 C++ 코드를 컴파일하고 DLL로 링크한다.

 

위에서 만든 모든 파일은 같은 폴더에 위치하고 있다.

 

 

프로젝트 파일 재생성을 위해서 프로젝트 파일에 마우스 오른쪽 버튼 클릭 후 [Generate Visual Studio project files]를 누른다.

 

 

(2) Visual Studio에서 솔루션 열기

프로젝트명.sln 파일을 열어서 Visual Studio의 오른쪽 Solution Explorer(솔루션 탐색기) 프로젝트 트리에 만든 파일들이 모두 보이는지 확인

 

 

(3) 빌드 구성 확인

Visual Studio 상단의 툴바를 Development Editor와 Win64로 설정한다.

 

 

(4) 빌드 실행

빌드/솔루션 빌드 또는 Ctrl+Shift+B 단축키로 빌드한다.

에러 없이 성공했다.

.gen.cpp 파일들은 UHT(Unreal Header Tool)가 UCLASS, UFUNCTION, UPROPERTY 매크로를 읽고 자동 생성한 코드인데, 이게 함께 컴파일됐다는 건 리플렉션 매크로도 다 정상이라는 뜻.

[10/12], [11/12] 부분에서 링크 단계도 성공. .Build.cs에 추가한 WebSockets, Json, JsonUtilities 모듈이 제대로 연결됐다는 증거

 

 

.dll도 잘 생성 된 것을 확인할 수 있다.

 

 


 

 

7단계: 에디터에서 실제 동작 확인

지금까지 만든 모든 게 실제로 동작하는지 확인

 

 

(1) WSL2: rosbridge + talker 준비

WSL2 터미널 1 — rosbridge:

source /opt/ros/humble/setup.bash
ros2 launch rosbridge_server rosbridge_websocket_launch.xml

 

 

WSL2 터미널 2 — talker:

source /opt/ros/humble/setup.bash
ros2 run demo_nodes_cpp talker

 

 

 

 

(2) 언리얼 에디터 열기

프로젝트이름.uproject 파일을 더블 클릭하여 언리얼 프로젝트를 실행한다.

 

 

(3) 테스트 액터를 레벨에 배치

[Place Actors]에서 "ROS Test Actor"를 검색하여 뷰포트로 드래그해서 놓는다.

 

 

(4) 액터의 설정 확인

[Outliner]에서 RosTestActor를 선택하여 나오는 [Details] 창에서 Test 세부 항목에 값이 알맞게 들어있는지 확인한다.

 

 

(5) Output Log 창 열기

아래 Output Log에 LogRosBridge를 미리 입력하여 로그 필터를 걸어놓는다.

 

 

(6) Play

에디터 상단 툴바에서 Play 버튼을 눌러 PIE (Play In Editor) 상태로 진입한다.

 

 

연결 안 됨 오류

 

해결책을 찾아서 고쳤다!!!

Ros Bridge Url에 들어가는 주소의 문제...

주소에 ws://127.0.0.1:9090/websocket 이렇게 넣으면 wsl에
[rosbridge_websocket-1] WARNING:tornado.access:404 GET /websocket (127.0.0.1) 0.36ms

주소에 ws://127.0.0.1:9090/ 이렇게 넣으면 
[rosbridge_websocket-1] WARNING:tornado.access:404 GET // (127.0.0.1) 0.30ms

즉, 경로가 비어있거나, /만 있을 때만 libwebsockets가 //를 붙였다. 경로에 뭐라도 있으면 정상. 그래서 ?x=1라는 더미 쿼리 스트링이 "경로는 비어있지만 URL은 비어있지 않다"는 상태를 만들어줘서 파싱이 제대로 될 수 있었다.

 

주소에 ws://127.0.0.1:9090/?x=1를 넣으니 언리얼 Output Log에도 잘 찍혔다.

 

 

이 해결책을 반영해서 코드를 수정한다.

RosTestActor.h 수정

RosBridgeUrl이 디폴트로 써지는 부분을 수정하고 주석을 달아놓는다.

    /**
    *URL of the rosbridge WebSocket server.
    *
    * NOTE: The trailing "/?x=1" is a workaround for a libwebsockets quirk in
    * Unreal's IWebSocket implementation. When the path is empty or just "/",
    * the underlying library sends "GET //" which rosbridge rejects with 404.
    * Any non - trivial query string(or path) avoids the double - slash bug.
    */
    UPROPERTY(EditAnywhere, Category = "ROS|Test")
    FString RosBridgeUrl = TEXT("ws://127.0.0.1:9090/?x=1");

 

RosBridgeSubsystem.h 수정

마찬가지로 Url 부분을 수정한다.

    /**
    * Open a WebSocket connection to the given rosbridge URL.
    *
    * Default URL uses 127.0.0.1 explicitly (not "localhost") and includes
    * a dummy "?x=1" query string to work around a libwebsockets path-parsing
    * quirk. See RosTestActor.h for details.
    */
    UFUNCTION(BlueprintCallable, Category = "ROS|Bridge")
    void Connect(const FString& Url = TEXT("ws://127.0.0.1:9090/?x=1"));

 

 


 

 

이번 글에서 구현한 체크리스트

 

  • ✅ Unreal 5.4.4 프로젝트에 C++ 모듈 구성 (.Build.cs + 의존성)
  • ✅ 네 개의 C++ 파일로 완전 동작하는 rosbridge 클라이언트 구축:
    • RosBridgeLog.h/.cpp (로그 카테고리)
    • RosBridgeSubsystem.h/.cpp (GameInstanceSubsystem + WebSocket + JSON + 스레드 마샬링)
    • RosTestActor.h/.cpp (델리게이트 바인딩 + 타이머 구독 + 화면 출력)
  • ✅ 첫 C++ 빌드 성공 (리플렉션 매크로, UHT, IWYU 모두 클리어)
  • ✅ Unreal libwebsockets URL 파싱 버그(// double slash) 진단 및 ?x=1 워크어라운드 발견
  • ✅ localhost vs 127.0.0.1 조용한 실패 이슈 발견 및 회피
  • ✅ 에디터 PIE에서 ROS2 메시지를 Output Log와 뷰포트에 실시간 표시

 

  • WSL2 네트워킹 디버깅
  • ROS2 + FastDDS 이슈 진단
  • 커스텀 C++ Subsystem 구현 (첫 언리얼 C++)
  • 첫 UCLASS/UFUNCTION/UPROPERTY 리플렉션 매크로 사용
  • 스레드 마샬링 (AsyncTask)
  • JSON 직렬화/역직렬화
  • 델리게이트 시스템
  • Epic 빌드 시스템 (UBT, UHT)
  • 그리고 libwebsockets의 악명 높은 URL 파싱 quirk 우회

 

 

 

 


 

 

 

728x90
반응형