텐센트 라이트스피드 스튜디오에서 개발 관련 하여 엔지니어로 일 하고 있는 "查利鹏" 엔지니어 블로그 발췌 토픽을 번역 했습니다.

언리얼 엔진으로 도구를 개발할 때, 자산 처리와 데이터 내보내기가 필요한 경우가 상당히 많습니다. 이러한 작업은 자주 자동화하여 실행해야 하므로 보통 CI/CD 시스템에 통합합니다.
구체적인 구현에서는 UE의 Commandlet 메커니즘을 활용하여 명령줄 형식으로 엔진을 구동하고, 사용자 정의 동작을 실행합니다.
제가 개발한 플러그인에서 지원하는 Commandlet 기능을 예로 들면:
- HotPatcher: 기본 패키지 정보 내보내기, 패치 패키징
- ResScannerUE: 변경된 자산의 증분 스캔
- HotChunker: 독립적인 청크 패키징
- libZSTD: 셰이더 사전 학습
- ExportNavMesh: NavMesh 데이터 내보내기
Commandlet을 사용하면 이러한 기능들을 CI/CD에 쉽게 통합하여 자동화할 수 있습니다.
이 글에서는 UE의 Commandlet 메커니즘을 주로 소개하고, 그 구현 원리를 분석하며, 개발 팁과 제가 개발 과정에서 얻은 몇 가지 생각을 공유하겠습니다.
또한, 이것은 제 UE 플러그인 및 도구 개발 시리즈의 두 번째 글이며, 앞으로도 계속 업데이트할 예정이니 기대해 주세요.
Commandlet이란 무엇인가
UE의 Commandlet은 명령줄 방식으로 엔진을 구동할 수 있는 메커니즘으로, 전통적인 CLI 프로그램과 유사합니다:
arg_printer.cpp
intmain(int argc,char* agrv[]){
for (int i =0; i < argc; i++){
cout << argv[i] << endl;
}
}
그러면 명령줄 형식으로 호출하고 매개변수를 전달할 수 있습니다:
./arg_printer -test1 -test2
Commandlet도 이와 같은 호출 형식을 제공합니다. 다만, 호출할 프로그램은 엔진이며, uproject 파일 경로와 기타 매개변수 등을 전달해야 합니다.
Cook Commandlet을 예로 들면:
UE4Editor-cmd.exe D:\\Client\\FGame.uproject -run=cook
이런 방식으로 명령줄 엔진을 시작하면 에디터를 시작하지 않고 정의된 동작을 실행합니다.
Commandlet 생성
프로젝트나 플러그인에 Commandlet을 생성할 때는 일반적으로 모듈 타입이 Editor인 Module에 추가합니다. Commandlet 실행 로직은 런타임과 관련이 적고 대부분 엔진 환경에서 실행되기 때문입니다.
사용자 정의 Commandlet을 생성하려면 UCommandlet을 상속받는 UObject 클래스를 생성해야 합니다:
ResScannerCommandlet.h
DECLARE_LOG_CATEGORY_EXTERN(LogResScannerCommandlet, All, All);
UCLASS()
classRESSCANNER_API UResScannerCommandlet :public UCommandlet
{
GENERATED_BODY()
public:
virtual int32Main(const FString& Params)override;
};
Main 함수는 -run= 명령으로 해당 Commandlet을 시작할 때 실행되는 로직으로, 앞서 살펴본 순수 C++의 main 함수와 유사합니다.
다만, Commandlet은 사실상 완전한 엔진 환경이므로 실행 시 등록된 모듈들의 시작을 불러오게 됩니다. 따라서 실행해야 할 전처리 로직이 많이 있으며, Main 함수가 실행될 때는 이미 완전한 엔진 상태가 됩니다.
이 함수 내에서 사용자 정의 작업을 수행할 수 있으며, 이때 완전한 엔진 환경을 갖추고 있어 필요에 따라 데이터 내보내기나 자원 처리를 할 수 있습니다.
Commandlet 실행
앞서 언급했듯이, Commandlet을 실행하려면 다음과 같은 명령 형식을 사용해야 합니다:
UE4Editor-cmd.exe PROJECT_NAME.uproject -run=CMDLET_NAME
프로젝트의 Commandlet을 시작할 때는 프로젝트 경로를 반드시 지정해야 합니다. 프로젝트 및 플러그인의 모듈을 로드해야 하기 때문입니다.
매개변수 -run=은 실행할 Commandlet의 이름을 지정합니다. 이 이름은 앞서 생성한 UCommandlet을 상속받은 클래스 이름과 직접 관련이 있습니다. 예를 들어 UResScannerCommandlet의 Commandlet 이름은 ResScanner가 됩니다. 규칙은 앞부분의 U와 뒷부분의 Commandlet을 제거하는 것입니다.
엔진이 -run= 매개변수를 받으면 UClass를 찾고 자동으로 Commandlet 접미사를 붙입니다:

UClass를 가져온 후, 인스턴스를 생성하고 Main 함수를 호출합니다:
Runtime\Launch\Private\LaunchEngineLoop.cpp
UCommandlet* Commandlet =NewObject<UCommandlet>(GetTransientPackage(), CommandletClass);
check(Commandlet);
Commandlet->AddToRoot();
// Execute the commandlet.
double CommandletExecutionStartTime = FPlatformTime::Seconds();
// Commandlets don't always handle -run= properly in the commandline so we'll provide them
// with a custom version that doesn't have it.
Commandlet->ParseParms( CommandletCommandLine );
#ifSTATS
// We have to close the scope, otherwise we will end with broken stats.
CycleCount_AfterStats.StopAndResetStatId();
FStats::TickCommandletStats();
int32 ErrorLevel = Commandlet->Main( CommandletCommandLine );
FStats::TickCommandletStats();
RequestEngineExit(FString::Printf(TEXT("Commandlet %s finished execution (result %d)"), *Commandlet->GetName(), ErrorLevel));
주의: 모든 모듈이 시작된 후에 Commandlet의 Main으로 진입합니다. 즉, 모듈의 StartupModule이 Main 이전에 실행됩니다.
엔진이 Commandlet Main 함수를 실행하는 호출 스택:

컨텍스트
Commandlet 감지
위 내용에 따라 다음 두 가지 핵심 사항을 기억해야 합니다:
- Commandlet은 Editor 모듈 내에 위치합니다
- Commandlet이 Main으로 진입한 후에는 완전한 엔진 환경입니다
Commandlet이 Editor 모듈 내에 위치하기 때문에 모듈 내 다른 함수를 호출할 수 있습니다. Editor 시작인지 Commandlet 시작인지 Commandlet 시작인지 구분하기 위해 다음 함수를 사용할 수 있습니다:
if(::IsRunningCommandlet())
{
// do something
}
매개변수 수신
Commandlet 함수의 원형은 다음과 같습니다:
int32Main(const FString& Params)
전달되는 매개변수는 엔진 경로와 프로젝트 경로를 포함하지 않습니다.
예를 들어, 다음 명령을 실행하면:
UE4Editor-cmd.exe G:\\Client\\FGame.uproject -skipcompile -run=HotChunker -TargetPlatform=IOS
수신되는 매개변수는 다음과 같습니다:
-skipcompile -run=HotChunker -TargetPlatform=IOS

이 명령줄 매개변수를 파싱하여 Commandlet에서 특별한 동작을 실행할 수 있습니다. HotPatcher를 예로 들면, -config=를 통해 구성 파일을 지정할 수 있으며, Commandlet은 이 구성 파일을 읽고 그에 따라 실행합니다.
Tick 구동
Commandlet은 기본적으로 독립적인 프로세스 동작으로, 엔진 Tick을 구동하지 않습니다. 일반 함수처럼 모든 코드가 실행 완료된 후 현재 스코프를 벗어나면 엔진 종료를 요청합니다.
Runtime\Launch\Private\LaunchEngineLoop.cpp
int32 ErrorLevel = Commandlet->Main( CommandletCommandLine );
// ...
RequestEngineExit(FString::Printf(TEXT("Commandlet %s finished execution (result %d)"), *Commandlet->GetName(), ErrorLevel));
그러나 일부 요구사항에서는 Commandlet 내에서 Tick을 구동하여 로직을 실행해야 할 필요가 있습니다. 예를 들어 HotPatcher에서의 프레임별 Cook과 같이, 한 프레임 내에서 너무 많은 리소스를 처리하는 것을 피하기 위해 완전한 엔진 Tick 환경에서 실행되어야 합니다.
이러한 필요성에 따라, Commandlet에서 Tick을 구동하는 함수를 캡슐화했습니다:
voidCommandletHelper::MainTick(TFunction<bool()> IsRequestExit)
{
GIsRunning =true;
FDateTime LastConnectionTime = FDateTime::UtcNow();
while (GIsRunning &&
// !IsRequestingExit() &&
!IsRequestExit())
{ GEngine->UpdateTimeAndHandleMaxTickRate();
GEngine->Tick(FApp::GetDeltaTime(),false);
// update task graph
FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread);
// execute deferred commands
for (int32 DeferredCommandsIndex=0; DeferredCommandsIndex<GEngine->DeferredCommands.Num(); DeferredCommandsIndex++)
{ GEngine->Exec( GWorld, *GEngine->DeferredCommands[DeferredCommandsIndex], *GLog);
}
GEngine->DeferredCommands.Empty();
// flush log
GLog->FlushThreadedLogs();
if (ComWrapperShutdownEvent->Wait(0))
{RequestEngineExit();
}
}
//@todo abstract properly or delete
FPlatformProcess::ReturnSynchEventToPool(ComWrapperShutdownEvent);
ComWrapperShutdownEvent =nullptr;
GIsRunning =false;
}
Expand
Main 함수에서 호출하고, 각 Tick에서 Tick 로직을 종료할지 요청하는 콜백을 전달합니다:
if(IsRunningCommandlet())
{
CommandletHelper::MainTick([&]()->bool
{
bool bIsFinished =true;
// state control
return bIsFinished;
});
}
HotChunker에서는 이 특성을 활용하여 프레임별 Chunk 패키징 메커니즘을 구현했습니다.

주의: 구 버전 엔진에서는 commandlet에서 엔진을 구동할 때 버그가 있어 FSlateApplication에서 충돌합니다. Commandlet은 완전한 엔진 환경이지만 Editor 관련 부분을 포함하지 않습니다. Commandlet에서 엔진을 구동하면 FSlateApplication의 실행이 트리거되어 널 참조가 발생합니다.
FSlateApplication 버그 수정
Commandlet 상황에서는 FSlateApplication이 초기화되지 않으므로 사용하는 모듈에서 검사를 수행해야 합니다.
UnrealEd/Private/EditorEngine.cpp
voidFAssetThumbnailPool::Tick(float DeltaTime )
{
// ++[lipengzha] fix commandlet tick crash
if(!FSlateApplication::IsInitialized())
{
return;
}
// --[lipengzha]
// ...
}

UnrealEd/Private/AssetThumbnail.cpp
boolUEditorEngine::AreAllWindowsHidden()const
{
// ++[lipengzha] fix commandlet tick crash
if(!FSlateApplication::IsInitialized())
{
returnfalse;
}
// --[lipengzha]
// ...
}
이러한 수정 후에는 Commandlet에서 정상적으로 엔진 Tick을 구동할 수 있습니다.
시작 가속화
앞서 언급했듯이, UE는 Commandlet을 시작할 때도 엔진과 프로젝트의 의존 모듈을 모두 로드하고 StartupModule을 실행합니다. 그러나 일부 상황에서는 일부 기능이 필요하지 않으면서도 시간이 많이 소요되는 경우가 있어, 필요에 따라 이를 비활성화할 수 있습니다.
엔진 시작 시 각 모듈의 시작 시간, 즉 각 모듈의 StartupModule 시간을 분석하고, 시간이 오래 걸리는 모듈에 명령줄에서 매개변수를 확인하여 동적으로 켜고 끌 수 있습니다.
이 내용은 UE 중 다단계 자동화 리소스 검사 방안 글에서 엔진 모듈 로딩 시간을 분석하는 방법과 시간이 오래 걸리는 LiveCoding/AssetRegistry에 대한 최적화 전략을 자세히 설명했습니다.
ResScanner의 리소스 스캔을 예로 들면, Commandlet 엔진 전체 시작 시간을 약 20초로 줄일 수 있고, 리소스 스캔 과정을 20초 이내로 줄일 수 있습니다. git hook과 같은 시나리오에서 커밋 시 Commandlet을 실행하는 시간 체감이 크게 줄어듭니다.
특정 Cmdlet 프로세스에 개입
Cook을 예로 들면, CookCommandlet을 실행합니다. 기본적으로 엔진은 Cook의 각 단계에 개입할 수 있는 방법을 제공하지 않습니다. 패키징 과정에서 일부 작업을 수행하려면 UAT를 직접 조작하여 빌드 프로세스를 분리하고 각 프로세스를 직접 관리해야 합니다.
하지만 이 방식은 비용이 높습니다. 다음과 같은 요구사항을 고려해 봅시다:
- Cook 완료 후 Shader 라이브러리를 학습하고, 라이브러리로 압축된 ShaderLibrary를 원본으로 대체합니다.
기본 상황에서 이를 구현하려면 Cook 후에 패키징 프로세스를 중지하고, 커스텀 프로세스를 처리한 다음, UnrealPak 단계로 들어가 계속 실행해야 합니다.
전체 과정이 복잡하지만, Cook 후 자동으로 실행하려면 Commandlet 실행 프로세스에 개입하는 다른 방법을 사용할 수 있습니다.
앞서 설명했듯이, Commandlet 실행 중에도 모듈의 StartupModule 및 ShutdownModule이 실행됩니다. Cmdlet 실행에 개입하려면 이 특성을 활용해야 합니다.
Cook 후 엔진이 커스텀 처리 프로세스를 실행하는 예를 들어, 이 요구사항을 분석해 보겠습니다:
- CookCommandlet에서 실행 중인지 확인
- Cook 실행 완료 후, 엔진 종료 전 시점에 실행
첫 번째 점에 대해, 시작 명령줄 매개변수를 파싱하고 -run= 토큰이 Cook인지 확인하여 CookCommandlet에서 실행 중인지 확인할 수 있습니다:
boolCommandletHelper::GetCommandletArg(const FString& Token,FString& OutValue)
{
OutValue.Empty();
FString Value;
bool bHasToken = FParse::Value(FCommandLine::Get(), *Token, Value);
if(bHasToken && !Value.IsEmpty())
{ OutValue = Value;
}return bHasToken && !OutValue.IsEmpty();
}
boolCommandletHelper::IsCookCommandlet()
{
bool bIsCookCommandlet =false;
if(::IsRunningCommandlet())
{ FString CommandletName;
bool bIsCommandlet = CommandletHelper::GetCommandletArg(TEXT("-run="),CommandletName);//FParse::Value(FCommandLine::Get(), TEXT("-run="), CommandletName);
if(bIsCommandlet && !CommandletName.IsEmpty())
{ bIsCookCommandlet = CommandletName.Equals(TEXT("cook"),ESearchCase::IgnoreCase);
} }return bIsCookCommandlet;
}
두 번째 점에 대해, 엔진 시작 모듈의 StartupModule에서OnPreEngineExit
콜백을 등록할 수 있습니다.
voidFlibZSTDEditorModule::StartupModule()
{
FCoreDelegates::OnEnginePreExit.AddRaw(this,&FlibZSTDEditorModule::OnPreEngineExit_Commandlet);
}
엔진이 종료를 요청하면 이 콜백이 자동으로 트리거되어 검사하고 실행할 수 있습니다:
voidFlibZSTDEditorModule::OnPreEngineExit_Commandlet()
{
FCoreUObjectDelegates::PackageCreatedForLoad.Clear();
FScopeRAII ScopeRAII;
if(CommandletHelper::IsCookCommandlet())
{
// do something
}
}
주의: 이 콜백은 Editor든 Commandlet이든 엔진을 시작한 경우 모두 엔진 종료 시 트리거됩니다. 따라서 필요에 따라 올바른 환경에서 실행되는지 확인해야 합니다.
이 방식을 통해 엔진을 수정하지 않고도 어떤 Commandlet에도 개입할 수 있습니다. 이는 엔진 종료 시점만이 아니라, Module의 LoadingPhase와 결합하여 다양한 프로세스 삽입 지점을 구현할 수 있습니다.
이전 글 유연하고 비침투적인 기본 패키지 분할 방안과 리소스 관리: UE의 패키지 분할 방안 재구성에서 소개한 HotChunker도 이 방식을 활용하여 CookOnTheFlyServer 실행 완료 후 자동으로 HotChunker를 실행하여 Chunk 패키징을 수행하며, 엔진에 비침투적입니다:

결론
본 글에서는 Commandlet의 생성, 실행, 환경 감지, 엔진 Tick 구동 및 시간 분석과 시작 가속, Cmdlet 프로세스 개입에 대한 내용을 연구했습니다.
Commandlet은 도구 개발 과정에서 널리 사용되는 기술로, 적절하게 활용하면 CI/CD 시스템에 쉽게 통합하여 자동화를 구현하고, 수동 개입을 줄이며, 효율성을 높일 수 있습니다.
UE 핫 업데이트: HotPatcher 기반 자동화 프로세스
HotPatcher는 제가 이전에 오픈 소스로 공개한 UE4 핫 업데이트 버전 관리 및 리소스 패키징 도구로, 버전 간 차이 분석과 pak 패키징을 쉽게 수행할 수 있습니다. 이전 글에서는 직관적인 소개를 위해 에디터에서 수동으로 구성하고 패키징하는 방식을 기반으로 했지만, 실제 엔지니어링 관행에서는 자동화할 수 있는 반복 작업은 수동 개입을 피해야 합니다. 일찍이 플러그인에 commandlet 지원을 추가했으며, 최근에는 몇 가지 문제를 수정하고 commandlet을 위한 많은 최적화를 추가했습니다. 이 글은 HotPatcher 기반의 자동화된 핫 업데이트 프로세스의 엔지니어링 실습입니다.
이전 글에서 HotPatcher의 작동 메커니즘을 소개했으며, 다음 글에서 더 자세한 정보를 얻을 수 있습니다:
- UE4 리소스 핫 업데이트 패키징 도구 HotPatcher
- UE4 핫 업데이트: 요구사항 분석 및 설계
- 2020 Unreal Open Day
프로세스는 간단히 다음 단계로 나눌 수 있습니다:
- UE 방식으로 기본 패키지 빌드
- 기본 패키지의 paklist 파일 추출, HotPatcher로 가져와 분석
- 기본 패키지의 Release 정보 생성(다중 플랫폼에서 하나의 Release 사용)
- 핫 업데이트 버전은 Release 정보를 기반으로 비교
- 패치 생성
따라서 이 글의 목적은 이러한 핵심 단계를 구성 가능한 자동화 프로세스로 구현하고, 이 프로세스와 모든 구성 파일이 엔진, 프로젝트 경로 등 중요하지 않은 요소의 영향을 받지 않도록 하여 진정으로 범용적인 구성과 프로세스를 구현하는 것입니다.
'TECH.ART.FLOW.IO' 카테고리의 다른 글
n8n 자동화 시스템 구축 방안 (0) | 2025.09.15 |
---|---|
Blender 애드온 개발 학습 순서 (0) | 2025.09.08 |
[번역] The application and development of toon shading technology in mobile games (3) | 2025.08.30 |
[번역] 버젯,언리얼 엔진 게임 최적화 (8) | 2025.08.08 |
[번역][연재물] 언리얼 엔진 개발 가이드. 파티클 시스템. 파트1 (0) | 2025.06.30 |