저는 게임 회사에서 유니티 엔진으로 상업 게임을 개발하고 있습니다. 직장에서 제 업무의 상당 부분은 다음과 같은 과정을 반복적으로 수행하는 방식으로 진행됩니다.
- IDE나 텍스트 에디터에서 코드 작성 및 편집
- 유니티 에디터 윈도우를 클릭해 맨 앞으로(Foreground) 가져오기
- 스크립트 컴파일, 경우에 따라서 테스트 코드를 실행하거나 유니티 에디터를 플레이 모드로 전환해 동작을 확인
- 터미널에서 Git 명령 실행
위 과정 중 대부분은 터미널, 혹은 IDE 및 IDE 내장 터미널에서 윈도우 전환 없이 키보드만으로 수행할 수 있습니다. 하지만 2, 3번의 경우 윈도우를 전환해야 하며 경우에 따라 오른손을 키보드에서 떼고 마우스에 올려 유니티 에디터에서 필요한 메뉴나 버튼을 찾아 조작해야 하는데 이 부분이 늘 성가시고 불편했습니다. 유니티 에디터의 플레이 모드의 경우 꼭 에디터에서만 실행하고 확인해야 하는 작업이지만 나머지 작업은 터미널에서 충분히 제어할 수 있다고 생각해 이에 필요한 툴을 개발하기로 했습니다.
요구사항
이러한 목적을 수행할 툴인 Unity CLI에 필요한 요구사항을 다음과 같이 정리해 보았습니다.
- 터미널에
ucli compile
,ucli test SomeTestFunctionOrFixtureName
등의 명령을 입력하는 방식으로 사용합니다. - 명령을 통해 스크립트 컴파일, 테스트 러너 실행 등의 자주 사용하는 유니티 기능을 실행할 수 있어야 합니다.
- 각 프로젝트에서 유니티 에디터 스크립트를 사용해 개발한 다양한 기능(코드 생성, 에셋 관련 디펜던시 검사 등)을 명령으로 등록해 실행할 수 있어야 합니다.
- 명령 처리 결과를 STDOUT, STDERR 등을 통해 터미널에서 쉽게 확인할 수 있어야 합니다.
- 여러 개의 유니티 에디터 및 여러 개의 터미널 세션이 동시에 실행될 수 있으니 각 유니티 에디터 인스턴스마다 명령 실행을 위한 인스턴스가 하나씩 있어야 하며 각 인스턴스는 여러 개의 터미널 세션에 명령 실행 기능을 제공(Multitenancy)해야 합니다.
- 터미널 세션과 인스턴스 사이의 접속이 편리해야 합니다. 명령 실행을 위해 포트 등 접속에 필요한 정보를 직접 조회해서 입력할 필요가 가급적 적어야 합니다.
- Windows, MacOS, Linux 등 유니티 에디터를 실행할 수 있는 각 OS 환경에서 동작해야 합니다.
예상되는 동작 시나리오는 다음과 같습니다.
- 각 유니티 에디터 인스턴스를 실행 시 에디터 스크립트를 통해 구현된 서버 인스턴스가 실행됩니다.
- 서버 인스턴스는 CLI 클라이언트와 통신할 수 있는 프로세스 간 통신(IPC) 및 해당 통신에 쉽게 접속할 수 있는 수단을 제공합니다.
- 터미널에서 명령 실행 시
PATH
등의 환경변수에 등록된 CLI 클라이언트가 실행되며 이 툴은 위의 Service Discovery를 이용해 알맞는 서버 인스턴스에 접속한 후 사용자가 입력한 명령을 전달합니다. - 서버 인스턴스는 전달받은 명령을 큐에 쌓아둔 후 유니티 에디터의 메인 스레드에서 순차적으로 실행하고 처리 결과를 클라이언트에 전달합니다. 클라이언트는 처리 결과를 터미널에 출력합니다.
기술 스택 결정
위 요구사항을 구현하기 위한 구체적 기술 명세는 다음과 같이 결정했습니다.
IPC 및 접속 방식
임의의 시점에 실행된 CLI 클라이언트가 이미 실행 중인 유니티 에디터의 서버 인스턴스와 통신하려면 통신 채널을 특정할 수 있는 정보가 필요합니다. 단순하게는 서버에서 지정된 포트에 소켓을 바인딩하고 클라이언트에서 해당 포트에 접속하는 방식이 있겠으나 이러한 방식에는 다음과 같은 문제가 있습니다.
- 다수의 유니티 에디터를 동시에 실행 중일 경우 하나를 제외한 나머지 서버 인스턴스는 소켓 바인딩에 실패합니다.
- 이외에도 다른 프로세스가 이미 해당 포트를 사용하고 있을 경우 마찬가지로 소켓 바인딩에 실패합니다.
빈 포트에 소켓을 바인딩하고 클라이언트에서 해당 포트를 지정해 접속할 수도 있겠으나 이 경우 현재 서버 인스턴스가 어느 포트에서 실행 중인지 쉽게 알 수 있는 수단이 필요합니다. 유니티 에디터에서 콘솔 로그나 UI 등을 통해 포트를 보여줄 수도 있겠지만 이 경우 포트 확인을 위해 유니티 에디터 윈도우를 활성화해야 하니 개발 목적이 많이 퇴색됩니다.
네임드 파이프, 도메인 소켓, mmap
을 통한 메모리 매핑 등 파일시스템 객체를 통한 IPC를 이용하는 방식도 고려해 보았습니다.
해당 파일시스템 객체 이름에 접두사 등 특정 규칙을 부여하고 패턴이 일치하는 객체를 찾아 접속 가능 여부 및 인스턴스 정보를 조회하는 방식입니다.
그러나 이 방식은 유니티 에디터가 정상적으로 종료되지 않을 경우(슬프게도 굉장히 빈번한 일입니다) 해당 파일시스템 객체가 제대로 지워지지 않을 수 있고 OS에 따라 서로 다른 구현을 사용해야 하는 불편이 있어 채택하지 않았습니다.
최종적으로 선택한 방식은 사무실 프린터 등 주변 기기 자동 검색이나 Mac 기기의 파일 공유 등에 사용되는 Zero-configuration networking(zeroconf)입니다. Multicast DNS(mDNS)를 활용한 DNS Service Discovery(DNS-SD)를 이용해 구현된 기술로, 서비스 명칭을 통해 해당 서비스가 제공되는 IP와 포트 번호를 쉽게 쿼리할 수 있어 목적에 잘 맞습니다. 유명한 구현체로는 Apple의 Bonjour가 있으나 오픈소스 라이브러리를 통해 쉽게 구현할 수 있는 점도 주된 채택 이유 중 하나입니다.
유니티 에디터 서버 인스턴스
유니티는 스크립팅 언어로 C#을 제공하므로 유니티 에디터에서 실행되는 서버 인스턴스는 C#을 통해 구현하는 것이 일반적으로 좋은 방법일 것입니다.
그러나 저는 통신과 관련된 기능은 네이티브 플러그인으로 작성하고 유니티 API 및 프로젝트의 C# 코드를 직접적으로 호출하는 부분을 C#으로 작성하는 방식을 채택했습니다. 물론 이러한 방식은 다음과 같은 비효율 및 위험성을 내포하고 있습니다.
- 프로젝트 구조 및 빌드 과정이 복잡해집니다.
- 네이티브 플러그인과 C# 스크립트 사이에서 C 스타일 ABI를 통해 FFI를 하기 때문에 포인터와 관련된 메모리 이슈가 발생할 수 있습니다.
- 유니티 에디터에서 어셈블리를 리로드할 수 있는 C# 코드와 달리 네이티브 플러그인은 유니티 에디터 실행 중 메모리에서 내려가지 않아 플러그인 변경 시 유니티 에디터를 재시작해야 합니다. 이로 인해 서버 플러그인 개발 과정에서 불편이 따릅니다.
위 단점에도 불구하고 네이티브 플러그인을 채택한 이유는 역설적이게도 3번째 항목 때문입니다.
네이티브 플러그인은 “영생"한다
위 3번 항목처럼 유니티 에디터에서 네이티브 플러그인을 변경하는 경우 변경 내용이 바로 적용되지 않아 불편할 때가 많습니다. 네이티브 플러그인을 업데이트 후 유니티 에디터를 재시작하지 않아 업데이트 내역이 제대로 반영되지 않아 혼란을 겪기도 합니다. 네이티브 플러그인을 개발하면서 코드의 작은 부분을 변경하며 실행해볼 때마다 매번 유니티 에디터를 재시작하는 것은 매우 지겨운 과정입니다.
C# 스크립트를 수정해 C# 어셈블리를 리로드하거나 유니티 에디터를 플레이 모드로 전환했다가 다시 빠져나오는 등의 동작에도 네이티브 플러그인은 요지부동입니다. 이는 네이티브 플러그인은 유니티 에디터 프로세스와 생명 주기가 거의 일치한다는 의미일지도 모릅니다. 실험을 통해 확인해 보도록 합시다.
먼저 다음과 같은 간단한 Rust 프로젝트를 작성해 보았습니다.
# Cargo.toml
[package]
name = "unity-assembly-reload-test"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
// lib.rs
use std::{
ffi::{c_char, CStr, CString},
sync::Mutex,
time::Duration,
};
static INSTANCE: Mutex<Option<Instance>> = Mutex::new(None);
struct Instance {
name: String,
heartbeats: u32,
}
#[no_mangle]
pub extern "C" fn start(name: *const c_char) -> *mut c_char {
let name = unsafe { CStr::from_ptr(name).to_string_lossy().to_string() };
let mut lock = INSTANCE.lock().unwrap();
if let Some(instance) = lock.as_ref() {
let message = CString::new(format!(
"Try to start: {}, but already running: {} (heartbeats: {})",
name, instance.name, instance.heartbeats
))
.unwrap();
message.into_raw()
} else {
*lock = Some(Instance {
name: name.clone(),
heartbeats: 0,
});
std::thread::spawn(|| loop {
std::thread::sleep(Duration::from_millis(1000));
if let Some(instance) = INSTANCE.lock().unwrap().as_mut() {
instance.heartbeats += 1;
}
});
let message = CString::new(format!("Start new: {}", name)).unwrap();
message.into_raw()
}
}
#[no_mangle]
pub extern "C" fn free_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
unsafe {
drop(CString::from_raw(ptr));
}
}
위 코드는 start
함수를 최초 호출 시 전역 상태인 Instance
구조체를 생성 후 해당 인스턴스 안의 heartbeats
값을 1
초마다 1
씩 증가시키는 스레드를 실행합니다.
start
함수 호출이 최초가 아닌 경우 기존 Instance
에 대한 정보를 문자열로 리턴합니다.
이를 cargo build
커맨드를 사용해 빌드 후 <project-root>/target/debug
아래에 있는 동적 라이브러리 파일(Windows: *.dll
, MacOS: *.dylib
, Linux: *.so
)을 유니티 프로젝트의 Assets/Editor
디렉토리 아래로 옮긴 후 해당 위치에 다음과 같은 C# 스크립트를 작성합니다.
// AssemblyReloadTest.cs
using System;
using System.Runtime.InteropServices;
using UnityEngine;
using UnityEditor;
public static class AssemblyReloadTest
{
[DllImport("unity_assembly_reload_test", EntryPoint = "start")]
private static extern IntPtr Start([MarshalAs(UnmanagedType.LPUTF8Str)] string name);
[DllImport("unity_assembly_reload_test", EntryPoint = "free_string")]
private static extern void FreeString(IntPtr ptr);
[InitializeOnLoadMethod]
private static void InvokeNaitiveDll()
{
var name = "Foo";
var messagePtr = Start(name);
var message = Marshal.PtrToStringUTF8(messagePtr);
Debug.Log(message);
FreeString(messagePtr);
}
}
매 C# 어셈블리 로드 시마다 [InitializeOnLoadMethod]
어트리뷰트가 붙은 InvokeNativeDll
메서드가 호출됩니다.
이 어트리뷰트는 Unity CLI 실제 구현에도 유용하게 사용됩니다.
여기서 var name = "Foo";
의 값을 "Bar"
, "Baz"
등으로 바꿔 가면서 스크립트를 새로 컴파일해 C# 어셈블리 리로드를 시도하거나 유니티 에디터를 플레이 모드로 전환하고 빠져나가면 다음과 같은 유니티 에디터 콘솔 로그를 확인할 수 있습니다.
Start new: Foo
Try to start new: Bar, but already running: Foo (heartbeats: 29)
Try to start new: Baz, but already running: Foo (heartbeats: 48)
Try to start new: Baz, but already running: Foo (heartbeats: 85)
위 실험을 통해 다음과 같은 사실을 확인할 수 있습니다.
- 스크립트 컴파일 및 풀레이 모드 진입 / 중단 시 유니티 에디터는 C# 어셈블리를 언로드 / 리로드합니다.
- 유니티 에디터에서 C# 어셈블리 언로드 / 리로드와 무관하게 네이티브 플러그인에서 할당한 메모리 및 스레드는 그대로 유지됩니다.
일반적인 개발 과정에서 위와 같은 성질은 매우 성가시지만 Unity CLI의 사례에서는 이는 매우 유용합니다. 유니티 개발 과정에서 스크립트 컴파일 및 플레이 모드 진입 / 중단으로 C# 어셈블리가 언로드 / 리로드되는 일은 매우 잦은데 해당 경우마다 매번 Unity CLI 서버 인스턴스를 재실행하면 해당 서버 인스턴스가 다시 실행되기까지 클라이언트와 연결이 불가능하고 C# 어셈블리 리로드 소요 시간에도 악영향을 줍니다.
또한 C# 어셈블리 언로드 시 C# 코드에서 바인딩한 소켓을 제대로 릴리즈하지 않으면 소켓이 바인딩된 포트는 사용 불가능한 상태가 됩니다.
일반적인 경우 프로세스가 종료되면 해당 프로세스가 점유하던 포트를 OS에서 다시 사용 가능한 상태로 반환합니다.
그러나 유니티 에디터 환경에서는 C# 어셈블리가 언로드되어 포트를 사용하던 맥락이 더이상 유효하지 않더라도 여전히 유니티 에디터 프로세스가 실행 중이기 때문에 OS가 포트를 반환하지 않아 유니티 에디터 종료 시까지 포트를 사용할 수 없게 됩니다.
어셈블리 언로드 이벤트에 소켓 릴리즈를 등록하는 등의 처리가 필요하며 Exception 등으로 인해 해당 릴리즈 로직이 제대로 실행되지 않을 경우 여전히 이러한 사용 불가능한 포트가 늘어나게 됩니다.
마지막으로 Unity CLI에서 스크립트 컴파일 명령 실행 시 서버와 클라이언트 간의 접속이 끊어지지 않은 상태로 컴파일 과정에서의 오류나 경고 등의 메시지를 CLI 환경에서 확인 가능하게끔 하는 기능을 구현하려면 C# 어셈블리가 언로드 / 리로드되더라도 서버 인스턴스가 종료되지 않을 필요가 있습니다.
이처럼 반응성 및 성능, 시스템 자원 해제, 유니티 에디터 기능에 대한 폭넓은 제어의 관점에서 Unity CLI 서버의 생명 주기가 해당 유니티 에디터 프로세스의 생명 주기와 거의 일치하는 것은 바람직한 일입니다.
따라서 개발 과정에서의 비효율과 위험성을 감안하더라도 네이티브 플러그인 형태로 유니티 에디터 서버 인스턴스를 구현하기로 결정했습니다.
개발 언어로는 다음과 같은 이유로 Rust를 채택했습니다.
- 비동기 런타임 및 기타 동시성 프로그래밍 등에 유용한 훌륭한 라이브러리 생태계를 갖추고 있습니다.
- 각 OS및 플랫폼마다 별도의 코드를 작성할 필요가 적은, 높은 이식성(Portability)을 제공합니다.
- C / C++과 달리 안전한 코드를 작성하기에 적합합니다.
- C / C++과 유사한 수준의 높은 성능을 낼 수 있습니다.
CLI 클라이언트
CLI 클라이언트는 Zeroconf 쿼리를 통해 접속 가능한 유니티 에디터 서버 인스턴스 목록을 탐색하고 실행 터미널 세션의 작업 디렉토리(CWD)나 기타 전달 인자를 바탕으로 일치하는 유니티 에디터 인스턴스를 찾아 사용자의 명령을 전달한 뒤 서버에서 보낸 메시지를 터미널에 출력하는 비교적 간단한 기능을 합니다.
마찬가지로 CLI 클라이언트 개발에도 개발 언어로 Rust를 채택했는데 위의 이유와 함께 다음의 이유 때문이기도 합니다.
- 서버와 클라이언트 언어가 일치하면 데이터 전송 객체(DTO) 및 송수신 로직 등을 공통으로 작성할 수 있어 편리합니다.
- Rust 생태계에는 CLI 도구 작성에 매우 유용한
clap
,crossterm
등의 라이브러리가 있습니다.
다음 포스트에서는 실제 Unity CLI 구현에 대해 다뤄보겠습니다.