
총람과 가이드
머리말
UE6 코드가 며칠 전에 공개되었다. ue6-main 브랜치에 올라와 있다. 내려받고 나서 내가 가장 궁금했던 것은 하나였다. Verse가 UE6 안에서 지금 어떤 상태인가.
UE4, UE5를 써온 사람들의 Verse에 대한 인상은 대부분 UEFN(Unreal Editor for Fortnite, 포트나이트 언리얼 에디터) 시절에 머물러 있다. 말하자면 포트나이트 창작 도구 안에 들어 있던 그 스크립트 언어다. 인터넷 자료도 대개 2~3년 전 자료이고, 이야기하는 것도 UEFN 쪽 내용이다.
하지만 UE6 안에서는 다르다. Claude의 도움을 받아 소스 코드를 훑어보니, Verse는 더 이상 예전처럼 엔진 밖에 독립적으로 놓인 스크립트 레이어가 아니다. 이미 엔진 코어 안으로 통합되어 있다. 다만 현재는 아직 비활성화 상태이고, 기본 경로도 VerseVM이 아니라 여전히 블루프린트 VM을 탄다.
유래
Verse는 Epic이 처음부터 새로 설계한 언어다. 주도자는 Tim Sweeney다. 언어 설계에는 Simon Peyton Jones와 Lennart Augustsson도 참여했다. 두 사람 모두 함수형 프로그래밍 분야의 베테랑이다.
그래서 이 언어는 함수형을 기반으로 하면서, 논리 프로그래밍과 명령형 프로그래밍도 흡수했다. 공식적인 포지셔닝은 “함수형 논리 프로그래밍 언어”다.
세 가지 핵심 특징
첫째, 실패를 타입 시스템이 강제로 처리한다. null도 없고 예외도 없다. 어떤 연산이 실패할 수 있다면, 그 사실이 타입에 적히고 컴파일러가 처리하라고 요구한다. 실패하면 그 과정에서 발생한 부작용은 롤백된다. 밑바닥에는 소프트웨어 트랜잭셔널 메모리 지원이 깔려 있다.
둘째, 부작용이 함수 시그니처에 적힌다. 함수가 일시 중단될 수 있는지, 전역 상태를 수정하는지, 순수 계산인지, 실패할 수 있는지 같은 것들이 모두 <suspends>, <transacts> 같은 표기로 시그니처에 적힌다. 컴파일러는 이를 바탕으로 정적 검사를 한다. C#이나 Lua에는 이렇게 완전한 메커니즘이 없다.
셋째, 독립적인 가상 머신 VerseVM과 독립적인 GC 힙을 가진다. 이것이 블루프린트와 본질적으로 다른 점이다. 블루프린트는 UObject 리플렉션 시스템 위의 인터프리터이고, Verse는 독립 런타임을 가진 언어다.
설계 동기
이미 있는 스크립트 언어도 많은데, Epic은 왜 굳이 처음부터 새로 설계했을까?
우리가 UE 로직을 작성하는 길은 크게 두 가지다. C++는 성능이 가장 좋지만 컴파일이 느리고, 실수하기 쉽다. 생 포인터, 댕글링 참조, 데이터 레이스는 흔한 문제이고, 기획자가 직접 쓰기도 어렵다. 블루프린트는 시각적이고, 핫 리로드가 빠르고, 상대적으로 안전하며, 기획자도 손댈 수 있다. 하지만 성능이 나쁘다. 노드 하나하나가 리플렉션 조회와 가상 함수 호출이고, 큰 로직은 유지보수하기 어렵다. 버전 관리와 diff에도 잘 맞지 않는다.
Epic이 마주한 시나리오는 Fortnite, UEFN 같은 플랫폼이다. 수많은 창작자의 코드가 같은 서버 위에서 동시에 돌아간다. 이런 환경에서는 언어가 몇 가지 조건을 동시에 만족해야 한다. 안전해야 한다. 한 창작자의 스크립트 오류가 전체 서버에 영향을 주면 안 된다. 높은 동시성을 지원해야 한다. 장기적으로 진화할 수 있어야 하며, 코드가 배포된 뒤에도 호환성을 유지해야 한다. 소스 안에는 @available{MinUploadedAtFNVersion := 3800} 같은 버전 가드가 많이 있는데, 이미 공개된 프로젝트와의 이름 충돌을 피하기 위한 것이다. 그리고 빨라야 한다. 목표는 C++에 가깝게 가는 것이다.
블루프린트는 이 요구를 만족하지 못하고, C++는 위험이 너무 크다. 다른 스크립트 언어는 엔진과 깊게 맞춤 통합하기 어렵다. 그래서 Epic은 직접 언어를 설계했다.
모듈 구성
.verse 소스 코드는 먼저 컴파일 프런트엔드로 들어간다. 프런트엔드는 uLang이라고 부르고, 컴파일러 코드명은 Solaris다. 위치는 Engine/Source/Runtime/Solaris/uLangCore와 Engine/Source/Runtime/VerseCompiler다. 자체 LSP(Language Server Protocol, 언어 서버 프로토콜)와 DAP(Debug Adapter Protocol, 디버그 어댑터 프로토콜)도 가지고 있다. uLangLSP는 에디터 자동완성과 오류 표시를 담당하고, uLangDAP는 중단점 디버깅을 담당한다.
프런트엔드는 소스 코드를 바이트코드로 컴파일하고, VerseVM이 이를 실행한다. VerseVM은 독립 DLL이 아니다. CoreUObject 모듈 안에 있으며, WITH_VERSE_VM 매크로로 조건부 컴파일된다. 코드는 Engine/Source/Runtime/CoreUObject/Public/VerseVM에 있고, 헤더 파일만 200개가 넘는다. CoreUObject 안에 있다는 것은 Verse 타입이 UObject 서브클래스로 직접 리플렉션 시스템에 들어갈 수 있다는 뜻이다.
메모리 쪽에서 Verse는 UE의 GC를 쓰지 않는다. WebKit의 libpas를 이식했다. 이것은 JavaScript 엔진용 할당자이며, Engine/Source/ThirdParty/libpas에 있다. verse_heap 관련 파일만 30개가 넘는다. Verse는 독립 힙을 관리하고, UObject GC와는 두 시스템이 협력해서 동작한다.
또 AutoRTFM이 있다. 소프트웨어 트랜잭셔널 메모리다. 실패 롤백은 이것이 지원한다. Epic은 이를 위해 Clang과 LLVM 전체를 fork해 Engine/Source/Programs/EpicClang에 두었다.
마지막으로 VNI(Verse Native Interface, Verse 네이티브 인터페이스)가 있다. Verse가 C++를 호출하는 메커니즘이다. UE6에서는 UHT(UnrealHeaderTool, 언리얼 헤더 툴)의 일부 역할을 대체하고 있다.
글 구성
이 시리즈는 총 7편이다. 순서는 “먼저 쓸 줄 알고, 그다음 원리를 이해하고, 마지막에 안전성과 성능을 이야기한다”는 흐름이다.
00 총람.
01 문법 소개.
02 구현 원리.
03 타입 안전성.
04 메모리 관리.
05 메모리 안전성.
06 성능 관련.
설명
UE6는 아직 정식 출시되지 않았고, Verse도 여전히 개발 중이다. 구현을 설명하는 부분은 현재 버전의 소스 코드를 기준으로 한다. 언어 규격 차원에서는 공식 문서와 《The Verse Calculus》 논문을 함께 참고했다. 정식 버전과 다르다면 공식 문서와 로컬 소스 코드를 기준으로 보면 된다.
Verse의 문법
머리말
“먼저 쓸 줄 알고 그다음 원리를 이해한다”는 순서에 따라, 이 글에서는 먼저 문법을 이야기한다. 목표는 읽고 나면 기본적인 Verse를 읽고 쓸 수 있게 되는 것이다.
Class
먼저 실제 파일 하나를 보자. 새 클래스를 만들 때의 템플릿이다.
# Engine/Plugins/Solaris/ScriptTemplates/ClassTemplate.verse
new_class_template := class:
#TODO: Add members and methods here
C++나 Lua와 다른 점 몇 가지를 먼저 기억해두자.
:= 는 정의다. 타입, 함수, 상수, 클래스 모두 이것을 쓴다.
타입은 콜론 오른쪽에 쓴다. X:int 는 X가 int라는 뜻이다. C++의 int X 와는 반대이고, TypeScript의 x: number 와는 같다.
들여쓰기는 의미가 있다. class: 뒤에 들여쓴 부분이 클래스 본문이다. Python과 같다. 콜론과 들여쓰기는 Verse가 코드 블록을 구성하는 기본 방식이다. # 는 한 줄 주석이다. @doc("...") 는 문서 주석 어노테이션이다.
변수와 가변성
X := 10 # 불변 바인딩, X는 항상 10
var Y : int = 0 # 가변 변수, var로 선언
set Y = 5 # 가변 변수 수정은 set 사용
이 점은 Lua와 차이가 크다. Lua 변수는 기본적으로 가변이고, 그냥 y = 5 라고 쓰면 바뀐다. Verse는 기본이 불변이다. X := 10 이후 X는 다시 바꿀 수 없다. 가변으로 쓰려면 var 로 선언해야 하고, 값을 바꿀 때는 반드시 set 을 써야 한다. Y = 5 라고 쓰면 안 된다. 그것은 다른 의미다.
표준 라이브러리의 실제 예시는 다음과 같다.
# Engine/Plugins/Verse/Verse/Source/Verse/Verse/Verse/Array.native.verse
var Result:[]t = array{} # 가변 배열, 초기값은 빈 배열
var I:int = 0
var SliceBeginIndex:int = 0
# ...
set I += ToReplaceLength # set으로 수정
set SliceBeginIndex = I
var Result:[]t = array{} 는 원소 타입이 t인 가변 배열을 선언한다. []t 는 배열 타입 표기이고, array{} 는 빈 배열 리터럴이다.
기본 타입
자주 쓰는 내장 타입은 다음과 같다.
int 정수, 임의 정밀도다. 고정 32/64비트가 아니며 오버플로하지 않는다.
float 부동소수점.
logic 불리언이다. 값은 true 와 false 다. bool이라고 부르지 않는다는 점에 주의하자.
char8 / char32 문자.
string 문자열.
[]t 원소 타입이 t인 배열.
[k]v 키가 k이고 값이 v인 map.
?t option, 값이 있을 수도 없을 수도 있다. null을 대체한다. 뒤에서 이야기한다.
tuple(a, b) 튜플.
void 반환값 없음.
type 타입 자체도 하나의 타입이다. 타입이 곧 값이라는 이야기인데, 뒤에서 이야기한다.
연산자
산술 연산자는 + - * / 이고, 단항 음수 -X 도 있다.
비교는 대부분의 언어와 다르니 따로 기억해야 한다.
= 같음. 대입이 아니다! Verse에서 대입은 := 또는 set이다.
<> 다름. != 가 아니다.
< <= > >= 크기 비교
표준 라이브러리의 실제 비교는 다음과 같다.
# Array.native.verse
if (ToReplaceLength = 0): # = 는 같음 판단
return Input
if (Input[I + J] <> ElementsToReplace[J]): # <> 는 다름
set Matches = false
그리고 특별한 점이 하나 더 있다. 비교를 수학처럼 이어 쓸 수 있다.
# Array.native.verse, 0 <= StartIndex <= StopIndex <= Length를 한 번에 판단
0<=StartIndex<=StopIndex<=Input.Length
C++나 Lua에서는 이것을 여러 개의 &&로 쪼개야 하지만, Verse에서는 그대로 이어 쓴다.
논리는 and, or, not 을 쓴다. && || ! 가 아니다.
logic 값의 “판정”에는 물음표를 쓴다. logic 변수 Matches 가 있을 때, Matches? 는 “Matches가 true라면”이라는 뜻이다. 이것은 실패할 수 있는 표현식이다. false일 때 실패한다.
# Array.native.verse
if (Matches?): # Matches가 true일 때 진입
# ...
else:
set I += 1
제어 흐름: if / else
if는 if (조건): 형태로 쓰고, 그다음 들여쓴 코드 블록을 둔다. else는 else: 를 쓴다.
# Array.native.verse
if (ToReplaceLength > Length - I):
# 주석
if (set Result += Input.Slice[SliceBeginIndex, Length]) { }
return Result
else가 붙은 형태는 다음과 같다.
if (Matches?):
set I += ToReplaceLength
set SliceBeginIndex = I
else:
set I += 1
하지만 Verse의 if에는 Lua나 C#과 근본적으로 다른 점이 있다. 이것은 “참/거짓”을 판단하는 것이 아니라 “성공/실패”를 판단한다. 조건 자리에 들어가는 것은 하나의 표현식이고, 표현식이 성공하면 then으로 가고 실패하면 else로 간다. Matches? 같은 logic 판정, Map[Key] 같은 찾지 못할 수도 있는 조회, X > 0 같은 비교가 모두 “실패할 수 있는 표현식”이다. 이 메커니즘을 failure라고 부른다. Verse의 핵심이다. 타입 안전성 편에서 따로 이야기할 것이다. 여기서는 일단 이렇게 기억하면 된다. if는 “성공할 수 있느냐”를 받는다.
if는 표현식으로도 쓸 수 있고, 값을 반환할 수 있다.
# 의사 코드
Max(A:int, B:int):int =
if (A > B):
A
else:
B
제어 흐름: 반복
Verse의 반복은 주로 두 가지다. loop 와 for 다.
loop 는 무한 반복이고, break 로 빠져나온다. C의 while(true) 와 비슷하다.
# Array.native.verse
var J:int = 0
loop:
if (J >= ToReplaceLength):
break # 반복 탈출
if (Input[I + J] <> ElementsToReplace[J]):
set Matches = false
break
set J += 1
for 는 순회할 때 쓴다. 문법은 for (원소 : 컬렉션) 이다.
# 의사 코드
for (Element : MyArray):
Print(Element)
for 는 인덱스와 원소를 동시에 받을 수도 있다. Index -> Element 를 쓴다. 또한 필터 조건을 붙일 수 있다. 이 점이 Lua의 for보다 강한 부분이다.
# Array.native.verse, Input을 순회하되 인덱스가 [StartIndex, StopIndex) 밖에 있는 원소만 보존
for(Index->Element : Input; Index < StartIndex or Index >= StopIndex) do Element
이 한 줄은 세 가지 일을 한다. Input 을 순회하면서 Index -> Element 를 얻는다. 세미콜론 뒤의 Index < StartIndex or Index >= StopIndex 는 필터 조건이다. 만족하는 것만 들어간다. do Element 는 보존된 각 원소에 대해 계산되는 결과다. 전체 for 표현식은 이 결과들을 모아 새 배열을 만든다. 이런 “순회 + 필터 + 매핑”을 한 줄로 끝낸다. 다른 언어의 리스트 컴프리헨션과 비슷하고, Lua의 for 루프보다 표현력이 훨씬 강하다.
주의할 점은 Verse에는 while 키워드가 없다는 것이다. 조건 반복을 하려면 loop 에 if ... break 를 조합하거나, 조건이 붙은 for를 쓰면 된다.
return과 함수 반환
함수는 := 또는 = 로 정의한다. 대부분의 경우 return 이 필요 없다. 함수 본문의 마지막 표현식 값이 반환값이다. Rust나 함수형 언어와 같다. 다만 중간에 먼저 반환해야 할 때는 return 이 있다.
# Array.native.verse
if (ToReplaceLength = 0):
return Input # 조기 반환
완전한 함수 하나의 모습은 다음과 같다. 표준 라이브러리에서 가져왔고, 일부 세부 사항은 제거했다.
# Array.native.verse, 배열에서 한 구간 제거
(Input:[]t where t:type).Remove<public>(StartIndex:int, StopIndex:int)<computes><decides>:[]t =
0<=StartIndex<=StopIndex<=Input.Length
for(Index->Element : Input; Index < StartIndex or Index >= StopIndex) do Element
이 시그니처를 뜯어보면 Verse 함수의 특징 몇 가지가 보인다. (Input:[]t where t:type) 는 receiver다. 이것이 배열 타입 위의 확장 메서드라는 뜻이다. where t:type 은 제네릭 제약이다. .Remove 는 메서드 이름이다. <public> 은 가시성이다. (StartIndex:int, StopIndex:int) 는 매개변수다. <computes><decides> 는 효과다. 순수 계산이고, 실패할 수 있다는 뜻이다. :[]t 는 반환 타입이다. 함수 본문 첫 줄 0<=...<=Length 는 실패할 수 있는 검사다. 범위를 벗어나면 실패한다. 둘째 줄의 for 표현식이 결과를 만든다.
효과 지정자
시그니처 안의 꺾쇠괄호 표기는 Verse의 특징이다. 효과 지정자라고 부르며, 함수에 부작용이 있는지 선언한다. 자주 보는 것은 다음과 같다.
<computes> 순수 계산, 부작용 없음
<decides> 실패할 수 있음
<transacts> 상태를 수정하며, 트랜잭션 롤백 지원
<suspends> 일시 중단 가능. 병행성에 사용
<reads> 외부 상태 읽기
<native> C++로 구현됨
<public> <private> <internal> 가시성
실제 예시는 다음과 같다.
# Math.native.verse
Clamp<native><public>(Val:int, A:int, B:int)<computes>:int
Lua, C#, TypeScript에는 부작용을 시그니처에 적는 일이 없다. 이것은 Verse만의 특징이고, 타입 안전성 편에서 따로 이야기한다.
option: null이 없다
Verse에는 null이 없다. 없을 수도 있는 값은 option으로 표현하고, ?t 라고 쓴다.
# Event.native.verse
var InternalEvent<internal><native>:?event_base_intrnl # ?T가 option
option은 직접 사용할 수 없다. 실패 컨텍스트 안에서 먼저 “열어”야 한다. Lua의 nil은 어디에나 들어갈 수 있고 런타임에 터진다. C#의 null도 마찬가지다. 반면 Verse에는 언어 차원에서 빈 포인터가 없다. 자세한 내용은 뒤의 타입 안전성 편에서 말한다.
클래스와 메서드
class는 := 로 정의한다. 멤버와 메서드는 그 안에 들여쓴다.
# 의사 코드
my_class := class:
Health:int = 100 # 필드, 기본값 있음
var Score:int = 0 # 가변 필드
TakeDamage(Amount:int):void = # 메서드
# ...
interface도 비슷하다.
# Result.native.verse
result<public><native>(success_type:type, error_type:type) := interface<internal><computes>:
GetSuccess<public><native_callable>()<computes><decides>:success_type
GetError<public><native_callable>()<computes><decides>:error_type
(success_type:type, error_type:type) 는 타입 매개변수다. 타입 자체가 인자로 들어갈 수 있다. 이것이 Verse의 “타입이 곧 값”이라는 부분이고, C# 제네릭보다 더 철저하다. 타입 안전성 편에서 자세히 이야기한다.
명명 스타일을 보면 표준 라이브러리는 snake_case를 쓴다. new_class_template, error_type 같은 식이다. UE의 C++ 습관인 PascalCase와는 다르고, Rust나 스크립트 언어에 더 가깝다.
병행성 키워드
Verse는 병행성을 라이브러리가 아니라 언어 키워드로 만든다. 자주 쓰는 것은 다음과 같다.
# 몇 가지 일을 동시에 하고, 전부 끝나야 계속 진행
sync:
LoadModel()
LoadTexture()
# 먼저 끝난 것을 쓰고, 나머지는 취소
race:
Button.AwaitClick()
Sleep(5.0)
sync 는 모두 끝나야 계속된다. race 는 첫 번째로 끝난 것이 있으면 계속하고 나머지는 취소한다. rush 는 첫 번째로 끝난 것이 있으면 계속하지만 나머지는 백그라운드에서 계속 돈다. spawn 은 독립 작업을 시작한다. branch 는 기다리지 않는 작업을 시작한다. <suspends> 가 붙은 함수는 일시 중단될 수 있다.
Lua에서는 coroutine을 직접 써야 하고, C#에서는 async/await를 쓴다. 진짜 멀티스레드라면 락도 신경 써야 한다. Verse의 병행성은 언어 레벨 표현식이다. 쓰는 느낌은 동기 코드에 가깝고, 밑바닥은 단일 스레드 협력식 스케줄링이다. 구현과 이것이 안전성에 갖는 의미는 뒤의 글에서 이야기한다.
키워드 정리
이 글에 나온 키워드를 분류해두면 나중에 찾기 편하다.
정의와 대입: :=(정의), var(가변 선언), set(수정), =(정의/비교 문맥).
제어 흐름: if, else, loop, for, do, break, return.
논리와 비교: and, or, not, =(같음), <>(다름), < <= > >=, ?(logic 판정).
타입과 구조: class, interface, enum, type, where(제약).
병행성: sync, race, rush, spawn, branch.
효과 지정자: <computes>, <decides>, <transacts>, <suspends>, <reads>, <native>, <public> 등.
Lua처럼 키워드가 열몇 개뿐인 작은 언어와 비교하면, Verse의 키워드와 표기는 많다. 특히 효과 지정자 체계는 다른 곳에 없는 것이다. 하지만 기본 제어 흐름(if/else/loop/for/break/return)은 사실 익숙한 언어들과 크게 다르지 않다. 주된 차이는 문법이 콜론과 들여쓰기로 바뀌었고, 비교 연산자가 = 와 <> 로 바뀌었으며, while이 없고 loop를 쓴다는 정도다.
정리
정의에는 := 를 쓰고, 타입은 콜론 오른쪽에 쓴다. 콜론과 들여쓰기로 블록을 나눈다. 기본은 불변이고, 값을 바꾸려면 var와 set을 쓴다. 비교는 =(같음)와 <>(다름)를 쓰며, 이어 쓸 수 있다. 제어 흐름은 if/else, loop(무한 반복 + break), for(필터와 매핑 가능)이고 while은 없다. 함수의 마지막 표현식이 반환값이며, 조기 반환에는 return을 쓴다. option(?t)이 null을 대체한다. 효과 지정자는 부작용을 시그니처에 적는다. 병행성은 sync/race/rush 같은 언어 키워드다. 전체적으로 기본 제어 흐름은 주류 언어와 통하지만, 특징은 기본 불변성, failure 방식의 if, 효과 시스템, 그리고 네이티브 병행성에 있다.
솔직히 말하면, Verse의 선언·대입·효과 지정자 문법은 꽤 낯설어 보인다. 실제로 쓰면 좀 불편할 것 같은 느낌도 있다.
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [번역] Gdc2024 Open World Rendering Techniques in 'Hogwarts Legacy' (1) | 2026.06.22 |
|---|---|
| [UFSH2025] 《록왕국 세계》 모바일 파이프라인 설계와 최적화 — 주곡재, 텐센트 게임 모어펀 스튜디오 클라이언트 개발, 영상 요약 (0) | 2026.06.22 |
| [번역] RenderDoc For VSCode: 에디터를 떠나지 않고 GPU Debug (0) | 2026.06.22 |
| [번역] RecaNoMaho 처음부터 시작하는 체적광 렌더링 (1) | 2026.06.19 |
| [번역] 텐센트 델타포스 대형 월드 RVT 지형 렌더링 분석과 재현 (0) | 2026.06.18 |