라이브러리
100편
UniText 의 병렬은 세 조건을 모두 넘긴 프레임에만 켜진다
멀티코어 텍스트라는데 프로파일러의 워커는 대개 조용합니다. UniText 는 글자 합 500 초과, 컴포넌트 3개 이상, 논리 코어 3개 이상일 때만 상주 워커에 일을 나누고, 그 밖에는 메인 스레드에서 차례로 돕니다.
UniText 의 모디파이어는 방금 찍은 쿼드 네 정점만 고친다
모디파이어마다 완성된 메시를 다시 훑으면 효과 수만큼 정점 배열을 돕니다. UniText 는 쿼드를 한 장 찍을 때마다 OnGlyph 를 불러, 모디파이어가 vertexCount - 4 부터 네 정점만 고치게 합니다.
UniText 는 이모지 폰트를 빌드에 넣지 않고 OS 에서 빌려 쓴다
컬러 이모지 폰트 하나가 10 MB 를 넘어 빌드에 넣기 부담스럽습니다. UniText 는 이모지를 폰트 고르기 전에 표시해 두고 실행 중에 OS 의 이모지 폰트를 찾아 그려, 빌드 크기 대신 플랫폼마다 다른 그림과 메모리를 치릅니다.
UniText 는 글리프를 처음 쓰일 때 SDF 로 굽는다
UniText 의 폰트 에셋에는 아틀라스가 저장되지 않아, 새 글자가 처음 나오는 프레임에 굽는 비용이 몰립니다. 굽기는 가운데만 병렬로 도는 세 단계이고, 선반 패킹으로 쌓다 차면 새 페이지를 엽니다.
UniText 는 줄을 그리디로 끊고 폰트 크기를 폭으로 바꿔 푼다
폰트 크기를 트윈할 때마다 다시 셰이핑하면 비쌉니다. UniText 는 줄을 되감기 지점 하나짜리 그리디로 끊고, 크기 변경은 가용 폭을 배율로 나누는 것으로 바꿔 셰이핑 없이 줄만 다시 끊습니다.
UniText 는 양방향 알고리즘 UAX #9 를 C# 으로 직접 구현했다
영어 문장에 아랍어가 섞이면 저장한 순서와 화면 순서가 달라지는데, HarfBuzz 는 그 순서를 정하지 않습니다. UniText 는 글자마다 방향 레벨을 매기는 UAX
UniText 는 셰이핑을 HarfBuzz 에 맡기고 캐시만 쥔다
아랍어 연결 형태와 합자는 폰트의 GSUB, GPOS 규칙을 돌려야 나옵니다. UniText 는 이 셰이핑을 HarfBuzz 에 통째로 맡기고, C# 쪽에는 문단째 넘기는 호출과 폰트, 글리프, 스레드 버퍼 캐시 세 겹만 둡니다.
UniText 는 폭을 모르는 일을 한 번만 하고 바뀐 층만 다시 돈다
정렬만 바꿨는데 셰이핑부터 다시 하면 낭비입니다. UniText 는 폭과 무관한 분석과 셰이핑을 첫 패스에 모아 두고, 무엇이 바뀌었는지에 따라 첫 패스, 줄, 배치 세 층 중 필요한 층부터 다시 돕니다.
UniText 는 Graphic 을 상속하지만 그리기는 숨은 자식 렌더러가 한다
uGUI 의 Graphic 하나는 텍스처 하나만 그리는데, 다국어 텍스트는 폰트마다 아틀라스가 다릅니다. UniText 는 Graphic 의 갱신 경로를 비우고 캔버스 훅 두 개로 모아 처리해, 폰트마다 숨은 자식 CanvasRenderer 로 그립니다.
UniText 의 태그는 등록한 것만 읽힌다
UniText 에 <b> 를 넣으면 등록 전에는 글자 그대로 나옵니다. 태그는 찾는 규칙과 효과를 넣는 모디파이어를 한 쌍으로 등록해야 읽히고, 셰이핑 전에 지워진 뒤 코드포인트 번호로 효과가 붙습니다.
UniText 는 TextMeshPro 가 못 그리는 글을 그리고, 그 대신 잃은 것이 있다
UniText 는 TextMeshPro 에 없는 문맥 셰이핑과 양방향 처리, 층별 캐시를 얻었습니다. 대신 미리 구운 아틀라스, 기본으로 켜진 태그, 순수 C# 이라는 단순함을 잃었고, 입력 필드는 유료판에만 있습니다.
UniText 는 글리프 고르기만 HarfBuzz 에 맡기고 나머지를 C# 으로 짰다
TextMeshPro 는 글자 하나에 글리프 하나를 붙여서 아랍어와 섞인 방향을 그리지 못합니다. UniText 는 글리프 고르기를 HarfBuzz 에 맡기고 방향 분석, 줄나누기, 배치를 C# 으로 짜서 이 빈자리를 채운 uGUI 텍스트 엔진입니다.
Reflex 의 비용은 조회가 아니라 스코프 생성과 IL2CPP 생성에 있다
Reflex 의 조회는 컨테이너 깊이와 상관없이 쌉니다. 그 값을 부모 크기에 비례하는 스코프 생성과 IL2CPP 의 생성마다 부르는 리플렉션에서 치르고, 락이 없어 여러 스레드의 첫 조회는 싱글턴을 여럿 만들 수 있습니다.
Reflex 는 실패를 예외로만 알린다
주입이 안 될 때 Reflex 의 로그에는 원인이 없습니다. 로그는 수명 이벤트만 찍고, 조회와 주입의 실패는 겹겹이 감싼 예외로만 나오며, 자세한 진단은 REFLEX_DEBUG 심볼로 컴파일할 때만 붙습니다.
Reflex 의 씬 주입은 가장 먼저 도는 Awake 하나에서 끝난다
씬의 Awake 에서 [Inject] 필드를 바로 쓸 수 있는 것은 실행 순서 -10억인 ContainerScope.Awake 가 씬 전체를 먼저 주입하기 때문입니다. 그 한 번 밖에서 생긴 오브젝트와 씬 해제 순서는 쓰는 쪽이 챙겨야 합니다.
Reflex 는 생성자와 [Inject] 멤버를 두 단계로 채운다
MonoBehaviour 는 생성자로 의존을 받을 수 없습니다. Reflex 는 생성자 단계와 [Inject] 멤버 단계를 나눠 채우고, 소스 제너레이터는 뒤쪽만 대신 씁니다. 두 단계의 규칙이 기본값, 할당, 순환 참조에서 차이를 만듭니다.
Reflex 의 인스턴스는 컨테이너가 아니라 리졸버에 산다
같은 등록인데 자식 컨테이너에서 꺼내면 다른 객체가 나오기도 합니다. Reflex 는 조회를 자기 표 한 번으로 끝내고, 인스턴스는 부모와 자식이 공유하는 리졸버 안에 두며, 해제는 자식부터 만든 역순으로 합니다.
Reflex 의 등록은 그 줄에서 리졸버로 굳는다
Reflex 는 RegisterType 을 부르는 줄에서 수명별 리졸버를 만들고 계약을 검사합니다. Build 는 부모 표를 복사해 그 뒤에 덧붙이고 Eager 를 만들 뿐이라, 같은 계약을 두 번 등록하면 조용히 목록에 쌓입니다.
Reflex 는 조립을 빠르고 작은 등록 표 하나로 바꾼다
싱글턴과 FindObjectOfType 은 Awake 순서에 묶이고, 손으로 new 하는 조립 코드는 클래스 수만큼 길어집니다. Reflex 는 빠르고 작지만 빠진 것 없는 컨테이너를 목표로, 조립을 등록 표로 바꾸고 객체를 만들어 넣는 데서 멈춥니다.
Zenject 의 조회 비용은 딕셔너리가 아니라 파이프라인에 있다
이미 만든 싱글턴 하나를 Zenject 로 꺼내는 데 1 µs 안팎이 듭니다. 딕셔너리 조회는 그중 100 ns 이고 나머지는 컨텍스트 대여, 조상 확정, 거리 계산, 순환 감지입니다. 조회는 프레임 밖으로 빼고 숫자는 목표 빌드에서 잽니다.
Zenject 의 검증은 생성자를 부르지 않고 그래프만 따라간다
빠뜨린 바인딩은 Zenject 에서 처음 조회할 때 터집니다. 검증은 설치자를 돌리고 의존 그래프를 따라가되 생성자 대신 표식을 만듭니다. 기본 설정은 시작 때 닿는 것만 보고, 오류를 예외 대신 로그로 모읍니다.
Zenject 의 풀 반납은 쉬는 객체 수만큼 비싸다
Zenject 의 MemoryPool.Despawn 은 이중 반납을 막으려고 쉬는 객체 스택을 끝까지 훑어, 풀이 클수록 반납이 느려집니다. 이 검사는 기본 설정에서 출시 빌드에도 남고, ZEN_STRIP_ASSERTS_IN_BUILDS 로 뺄 수 있습니다.
Zenject 의 진입점은 MonoKernel 의 Update 안에서 돈다
MonoBehaviour 가 아닌 객체의 Tick 을 Zenject 는 컨테이너마다 붙는 MonoKernel 의 Update 에서 부릅니다. 그래서 순서는 .meta 의 실행 순서와 BindExecutionOrder 숫자가 정하고, Tick 하나가 던지면 그 프레임의 뒤 순서가 전부 건너뜁니다.
Zenject 의 주입은 리플렉션으로 읽은 타입 정보로 돈다
Zenject 는 타입마다 한 번 생성자와 [Inject] 멤버를 리플렉션으로 읽어 캐시하고, 그 정보대로 생성자, 필드, 메서드 순으로 채웁니다. 생성자가 여럿이면 인자가 가장 적은 것을 조용히 고르고, IL2CPP High 스트리핑에서는 필드가 예외 없이 비었습니다.
Zenject 의 수명은 Context 계층이 정한다
게임 전체에서 하나, 씬마다 하나, 적마다 하나는 Zenject 에서 Context 계층으로 정합니다. 자식마다 하나는 바인딩 문장을 자식에서 다시 확정해 만들고, 해제는 IDisposable 계약으로 건 것만 커널의 OnDestroy 에서 역순으로 합니다.
Zenject 의 Provider 는 인스턴스를 먼저 내주고 주입은 나중에 한다
Zenject 에서 필드끼리 서로를 가리키면 풀리고 생성자끼리 가리키면 예외가 나는 이유는 Provider 계약에 있습니다. 인스턴스를 캐시에 먼저 넣고 멤버 주입은 따로 돌려주므로, 필드 순환의 상대는 주입 도중 덜 채워져 있습니다.
Zenject 의 조회는 가까운 컨테이너와 조건 있는 바인딩을 고른다
같은 계약이 부모와 자식에, 또는 한 컨테이너에 둘 있으면 Zenject 는 무엇을 줄까요. 단일 조회는 가까운 컨테이너, 조건 있는 쪽 순으로 하나를 고르고 같은 등급이 둘이면 예외, 목록 조회는 전부 모읍니다.
Zenject 의 Bind 문장은 다음 문장이 시작될 때 확정된다
Zenject 의 Container.Bind<T>().AsSingle() 한 줄은 그 자리에서 등록되지 않고 큐에 쌓입니다. 다음 Bind 가 앞 문장을 Provider 로 확정하므로, 스코프 누락 같은 실수는 잘못이 없는 다음 줄에서 터집니다.
Zenject 는 기능을 다 갖춘 채 new 로 잇던 조립을 대신한다
싱글턴과 FindObjectOfType 은 의존을 숨기고, new 로 조립하면 MonoBehaviour 에 넘길 길이 없습니다. Zenject 는 유니티에 DI 가 없던 때 기능을 다 갖춘 쪽으로 나와, 설치 파일의 바인딩으로 씬 시작 때 조립과 주입을 끝냅니다.
LitMotion 은 모션마다 드는 할당을 없앤 대신 러너마다 고정 비용을 낸다
LitMotion 의 Zero Allocation 은 모션을 만들고 돌리는 동안 GC 할당이 0 이라는 뜻입니다. 대신 타입과 타이밍마다 하나인 러너가 모션이 없어도 매 프레임 일해서, 모션이 적을수록 하나당 비용이 커집니다.
LitMotion 은 끝난 모션을 표시만 바꿔 두고 다음 프레임에 지운다
코루틴은 StopCoroutine 하면 그 자리에서 사라집니다. LitMotion 의 Cancel 과 Complete 는 상태 값만 바꾸고, 배열에서 빼는 일은 다음 프레임의 Job 이 합니다. 그 한 프레임과 거절 조건, 콜백 예외, 기다리는 방법이 수명 API 의 모양을 정합니다.
LitMotion 의 시퀀스는 자식의 시각을 밀어 주는 모션 하나다
코루틴으로 연출을 이으면 yield 로 차례를 잇고 동시에 도는 것은 코루틴을 더 띄웁니다. LitMotion 의 LSequence 는 0 에서 전체 길이까지 가는 double 모션 하나를 만들고, 그 값을 시각으로 삼아 자식마다 SetTime 을 부릅니다.
LitMotion 의 어댑터는 Job 본문에 그대로 박히는 구조체다
Burst Job 안에서는 보간 방법을 델리게이트로 넘길 수 없습니다. LitMotion 은 값 타입마다 상태 없는 어댑터 구조체를 타입 인자로 넣어 default(TAdapter).Evaluate 로 부르고, 그 계산은 Job 본문에 인라인됩니다.
LitMotion 은 절대 시각 하나에서 루프와 진행률을 계산한다
손으로 쓴 보간은 델타를 쌓고 길이를 넘을 때마다 빼며 루프를 셉니다. LitMotion 은 누적 시각 하나를 길이로 나눠 floor 한 번으로 루프 수와 진행률을 뽑습니다. 그래서 점프가 공짜지만 경계의 끝값과 콜백 수는 보장하지 않습니다.
LitMotion 은 Burst Job 으로 나눠 계산하고 그 자리에서 기다린다
큐브 천 개를 손으로 보간하면 메인 스레드가 하나씩 계산합니다. LitMotion 은 계산을 Burst Job 으로 여러 코어에 나누되 예약한 줄에서 곧바로 기다립니다. 값은 이번 프레임에 들어가고, 콜백은 메인 스레드에 남습니다.
LitMotion 은 PlayerLoop 맨 앞에 끼어들어 모션을 돌린다
코루틴은 MonoBehaviour 가, Update 보간은 컴포넌트가 돌립니다. LitMotion 에는 그런 주인이 없습니다. 씬보다 먼저 PlayerLoop 여덟 단계의 맨 앞에 끼어들고, 타입과 타이밍마다 하나인 러너를 정적 필드로 찾아 돌립니다.
LitMotion 은 모션을 빈칸 없는 배열에 모으고 맨 끝 원소로 구멍을 메운다
수천 개 모션을 Job 이 한 번에 훑으려면 배열에 구멍이 없어야 합니다. LitMotion 은 모션을 꼬리에 붙이고 빠질 때 맨 끝 원소로 메웁니다. 그 대가로 순서가 바뀌고, 한 번 커진 배열과 곡선 메모리는 줄지 않습니다.
LitMotion 의 핸들은 슬롯의 버전으로 끝난 모션을 걸러 낸다
끝난 모션의 자리는 곧 새 모션이 씁니다. LitMotion 의 MotionHandle 은 움직이지 않는 슬롯 번호와 그 슬롯의 버전을 들고 있어서, 옛 핸들로 새 모션을 건드리지 못합니다. 단 Clear 뒤에는 이 보호가 풀립니다.
LitMotion 의 빌더는 풀에서 빌린 버퍼를 가리키는 구조체다
LMotion.Create 부터 Bind 까지 사슬 어디에서도 힙 객체가 생기지 않는 것은 빌더가 풀에서 빌린 버퍼를 가리키는 작은 구조체이기 때문입니다. 버전 번호가 옛 복사본을 막고, state 인자가 클로저를 없앱니다.
LitMotion 은 트윈 하나를 구조체 배열의 한 칸으로 만든다
코루틴은 부를 때마다 객체를 만들고, Update 보간은 값마다 필드가 늘고, Animator 는 실행 중에 정해지는 값을 못 다룹니다. LitMotion 은 트윈 하나를 구조체 배열의 한 칸에 두고 Burst Job 으로 계산합니다.
DOTween 은 트윈을 만들 때만 할당한다
DOTween 의 DOMove 한 번은 캡처 객체, 델리게이트 둘, 트윈 객체로 656 B 를 할당하고, 재활용은 기본으로 꺼져 있으며 켜도 람다 몫 280 B 가 남습니다. 도는 동안은 0 B 라, 줄이는 길은 만드는 횟수를 줄이는 것입니다.
DOTween 의 경로 트윈은 시작할 때 곡선을 완성하고 길이 표로 등속을 맞춘다
DOTween 의 DOPath 는 첫 갱신에서 현재 위치를 앞에 끼워 곡선을 완성하고, 곡선 위를 같은 빠르기로 가도록 길이 표를 만듭니다. resolution 은 그 표의 촘촘함이고, Pro 의 DOTweenPath 는 이 표를 에디터에서 미리 만들어 둡니다.
DOTween 의 Kill 은 갱신 도중이면 표시만 하고 나중에 치운다
DOTween 트윈은 어떤 오브젝트에도 묶여 있지 않아 수명을 코드가 챙깁니다. 모든 파기는 Despawn 하나로 모이고, 갱신 도중의 Kill 은 표시만 한 뒤 루프 끝에 치웁니다. DOKill 은 전체를 훑고 SetLink 는 매 차례 묻습니다.
DOTween 의 시퀀스는 넣은 트윈을 빼내 시간표대로 돌린다
DOTween 의 Sequence 는 넣은 트윈을 매니저의 활성 목록에서 빼 자기 시간표에 적고, 매 프레임 자식마다 로컬 시각으로 Goto 합니다. 그래서 자식을 따로 Kill 하거나 DOKill(target) 로 찾을 수 없고, 되감을 때는 뒤에서부터 돕니다.
DOTween 은 타입마다 하나인 플러그인에 값 계산을 맡긴다
DOTween 의 트윈은 시간만 알고, 값은 만들 때 타입 조합으로 고른 플러그인이 매 프레임 시작값에서 계산해 덮어씁니다. 축 제약 트윈만 지금 값을 읽고, 이징은 초 단위로 받아 Elastic 의 period 도 초입니다.
DOTween 은 시작값을 딜레이가 끝난 첫 갱신에서 읽는다
DOTween 트윈의 시작값은 만들 때가 아니라 딜레이가 끝난 첫 갱신에서 getter 로 읽고, 상대값과 변화량과 속도 기반 길이도 그때 한 번에 정합니다. From 만은 붙이는 줄에서 곧바로 값을 씁니다.
DOTween 은 매 프레임 위치와 루프 수를 새로 정해 그 자리로 간다
DOTween 은 경과 시간을 쌓지 않고 매 프레임 목표 위치와 완료 루프 수를 새로 계산해 DoGoto 로 놓습니다. 그래서 넘긴 루프는 빠짐없이 세고, 딜레이의 남는 시간을 버리지 않으며, Yoyo 는 시간만 뒤집습니다.
DOTween 의 트윈은 숨은 오브젝트 하나의 Update 에서 돈다
DOTween 의 모든 트윈은 [DOTween] 오브젝트의 Update·LateUpdate·FixedUpdate 가 배열 하나를 훑으며 돌립니다. 실행 순서를 정하지 않았고, 콜백에서 만든 트윈은 다음 프레임부터 돌며, 1.3.030 은 unscaled 시간에서 자기 갱신 시간을 뺍니다.
DOTween 은 모든 트윈을 배열 하나에 두고 빈칸은 한 번에 메운다
DOTween 의 TweenManager 는 모든 트윈을 배열 하나에 만든 순서대로 두고, 뺄 때는 null 만 쓴 뒤 다음 갱신에 한 번에 당깁니다. 풀은 타입을 선형으로 찾고, 용량은 넘칠 때 2.5배로 늘며 할당이 몰립니다.
DOTween 의 트윈 하나는 값 세 개와 람다 두 개를 든 객체다
DOMove 가 돌려주는 것은 값 세 개, getter·setter, 옵션 구조체를 든 TweenerCore 객체 하나입니다. 만들 때 전역 기본값을 복사해 두고, 첫 갱신부터 루프·지연 같은 설정을 잠그며, 재활용 때 Reset 이 두 필드를 남깁니다.
DOTween 은 getter 와 setter 두 람다로 어떤 값이든 트윈한다
코루틴이나 Update 보간은 움직임마다 시작값, 경과 시간, 이징, 적용을 새로 짭니다. DOTween 은 그 보간을 한 번만 짜 두고, 바꿀 값을 getter 와 setter 두 람다로 받아 어떤 값이든 같은 엔진으로 돌립니다.
UniTask 의 에디터 할당은 코드 최적화가 만든다
에디터에서 UniTask 호출마다 찍히는 48 B 는 꺼진 코드 최적화가 상태 머신을 클래스로 만들어 생깁니다. 최적화를 켜면 0 이고, 그 뒤에도 남는 할당은 첫 대량 실행과 정상 경로 밖의 호출입니다.
UniTask 스트림은 당길 때만 값을 준다
UniTask 스트림은 소비자가 당길 때만 값을 주므로 바쁜 동안 온 값은 저절로 버려집니다. 유니티 메시지를 스트림으로 바꾸는 트리거는 도는 중에 목록이 바뀌어도 한 번씩 건네지만, 한 발행 안에 둘 이상 구독하면 하나가 떨어집니다.
UniTask 는 깨운 쪽 스레드에서 이어진다
UniTask 에는 원래 스레드로 되돌려 주는 장치가 없어, await 다음 줄은 완료를 알린 쪽 스레드에서 돕니다. 메인으로 돌아갈 자리는 예외 경로까지 코드에 직접 적고, 코루틴을 await 하면 UniTask 가 깨우는 쪽이 됩니다.
UniTask 의 예외는 꺼내 가야 보고된다
WhenAll 로 묶은 로딩이 둘 실패해도 catch 에는 하나만 오고, 버린 UniTask 의 예외는 한참 뒤에 찍힙니다. 완료는 처음 한 번만 이기고, 예외는 누군가 GetResult 로 꺼내 가야 보고되기 때문입니다.
UniTask 의 취소는 예외로 끝난다
UniTask 의 취소는 토큰을 받은 대기가 OperationCanceledException 으로 끝나는 일입니다. finally 는 돌지만 한 번마다 수백 바이트가 들고, 즉시 취소나 SuppressCancellationThrow 로 끝나면 대기 객체가 풀로 돌아가지 않습니다.
UniTask 의 대기는 무엇을 세느냐만 다르다
UniTask 의 대기는 모두 매 프레임 한 번 아직인가를 묻고 무엇을 세는지만 다릅니다. 그래서 Yield 는 같은 프레임에 끝날 수 있고, Delay 는 일시정지에 멈추거나 조금 일찍 끝납니다.
UniTask 는 PlayerLoop 에서 await 를 이어 준다
UniTask 는 첫 씬 전에 PlayerLoop 단계마다 맨 앞과 맨 뒤, 열여섯 자리에 큐와 러너를 꽂습니다. await 뒤 코드는 그 자리에서 메인 스레드로 이어지고, 언제 깨어나는지는 고른 타이밍이 정합니다.
UniTask 는 다 쓴 대기 객체를 풀에서 다시 쓴다
UniTask 대기를 반복해도 GC 가 없는 것은 다 쓴 대기 객체를 타입별 풀에 돌려 다시 쓰기 때문입니다. 풀은 할당도 락도 없고, 경합하면 기다리지 않고 포기하며, 가장 바빴던 순간만큼 자라서 줄지 않습니다.
UniTask 는 완료와 이어 부르기를 딱 한 번씩만 한다
시간 만료와 취소가 겹치거나 완료가 워커 스레드에서 와도 await 뒤 코드는 정확히 한 번 돕니다. 대기 객체 안의 구조체 하나가 원자적 연산으로 이것을 지키고, 할당은 없습니다.
UniTask 는 기다릴 때만 풀에서 빌려 GC 를 만들지 않는다
UniTask 의 async 메서드는 await 없이 끝나면 아무것도 만들지 않고, 정말 기다릴 때만 풀에서 러너를 빌렸다가 돌려줍니다. 이 0 은 최적화 컴파일에서만 성립하고, IL2CPP 에서는 반환이 프레임 끝으로 밀립니다.
UniTask 는 풀에서 빌린 객체로 await 를 처리한다
UniTask 값은 풀에서 빌린 대기 객체를 가리키는 참조 하나와 번호 하나입니다. 복사는 공짜지만 await 가 끝나면 그 객체는 풀로 돌아가므로 한 번만 기다릴 수 있습니다. 번호 검사와 Preserve 를 봅니다.
UniTask 는 코루틴의 빈칸을 메우고 Task 의 GC 를 없앤다
코루틴은 결과를 돌려주지 못하고, 멈추면 finally 가 돌지 않습니다. Task 로 옮기면 풀리지만 부를 때마다 GC 가 생깁니다. UniTask 는 async/await 를 그대로 쓰면서 그 할당과 유니티에 필요 없는 컨텍스트를 버렸습니다.
VContainer 의 CreateScope 스코프는 씬이 바뀌어도 닫히지 않는다
자식 스코프는 CreateChild 로 만들면 게임오브젝트라 씬과 함께 닫히고, CreateScope 로 만들면 순수 C# 객체라 씬이 바뀌어도 부모를 닫아도 살아 있습니다. 씬 단위는 CreateChild, 직접 관리할 것만 CreateScope 로 만듭니다.
VContainer 의 수명은 받는 쪽이 정한다
Singleton 이 Scoped 를 생성자로 받으면 첫 인스턴스가 필드에 박혀 전투가 바뀌어도 그대로입니다. 주는 쪽 수명을 바꾸거나 싱글턴 팩토리를 넣어도 안 풀리고, 받는 쪽을 Scoped 로 내려야 풀립니다.
VContainer 는 자기가 처음 만든 것만 해제한다
Transient 로 등록한 IDisposable 은 요청마다 핸들을 흘립니다. VContainer 는 이번에 처음 만들었고, IDisposable 이고, 자기가 만든 것만 해제 목록에 올리고, 그 목록을 생성 역순으로 닫습니다.
VContainer 는 모노비헤이비어를 꺼 둔 채로 주입하고 켠다
프리팹을 복제한 뒤 주입하면 Awake 에서 주입 필드가 널입니다. VContainer 는 새로 만드는 컴포넌트를 꺼진 채 복제해 주입한 뒤 켜고, 씬에 있는 것은 스코프의 Awake 안에서 빌드 직후 찾아 채웁니다.
VContainer 의 진입점은 PlayerLoop 에 꽂힌다
순수 C# 클래스가 MonoBehaviour 없이 매 프레임 호출을 받습니다. 스코프마다 디스패처가 인터페이스별로 진입점을 모아 플레이어 루프에 꽂은 러너에 올리고, 자식 스코프는 부모의 싱글턴 진입점만 걸러 냅니다.
VContainer 에서는 누가 만드느냐가 무엇을 받느냐를 정한다
전투 스코프에 다시 등록했는데 어떤 객체는 새 것을, 어떤 객체는 옛것을 받습니다. VContainer 는 수명에 따라 객체를 만드는 스코프가 정해지고, 생성자 인자도 그 만든 스코프에서 찾기 때문입니다.
VContainer 의 등록은 빌드할 때 굳는다
Register<IWeapon, Sword>() 뒤에 Resolve<Sword>() 가 실패하고, 빠진 등록은 빌드가 알려 주지 않습니다. VContainer 는 빌드 때 등록을 불변 테이블로 굳히고, 그때 검사하는 것은 순환 참조뿐입니다.
VContainer 스코프는 부모를 못 찾으면 조용히 기다린다
애디티브로 자식 씬을 먼저 열면 그 씬의 Awake 와 Start 가 주입 없이 돕니다. 스코프는 부모를 여섯 단계로 찾고, 못 찾으면 로그 없이 대기열에 누웠다가 부모가 빌드될 때 깨어나기 때문입니다.
VContainer 는 스코프의 Awake 안에서 컨테이너를 세운다
등록 두 줄을 적으면 진입점이 불리지만 언제 불리는지 모르면 유니티 Start 와 섞어 씁니다. VContainer 는 스코프의 Awake 안에서 등록을 모아 빌드하고, 진입점은 다음 프레임 플레이어 루프에서 부릅니다.
VContainer 는 유니티 DI 의 네 제약을 빠르고 작게 푼다
직접 new 하고 싱글턴으로 꺼내 쓰는 코드는 바꾸지도 테스트하지도 못합니다. 유니티 DI 는 생성자, 씬 수명, Awake, Update 네 제약을 넘어야 하고, VContainer 는 이것을 빠른 Resolve 와 최소 할당을 목표로 풉니다.
VitalRouter 는 자식 스코프에서도 라우터를 새로 만들지 않는다
씬 스코프에서 RegisterVitalRouter 로 붙인 로그 인터셉터가 씬을 다섯 번 불러오면 다섯 번 돕니다. 부모의 라우터를 공유하는데 스코프를 닫을 때 인터셉터는 떼지 않아서입니다. Isolated 를 켜면 쌓이지 않습니다.
VitalRouter 의 Sequential 은 다른 클래스와도 줄을 선다
대사 Presenter 에만 Sequential 을 붙였는데 인벤토리 Presenter 와 함께 한 줄로 섭니다. VitalRouter 의 순서 제어는 발행 사이를 줄 세우는 인터셉터이고, 줄의 범위는 그 인스턴스를 나눠 갖는 범위이기 때문입니다.
VitalRouter 의 연속 발행은 백그라운드 스레드에서 멈출 수 있다
기다리는 구독자가 둘 이상인 커맨드를 백그라운드 스레드에서 연달아 발행하면 PublishAsync 가 영영 끝나지 않을 수 있습니다. VitalRouter 2.8.0 의 두 줄 순서가 원인이고, 유니티 메인 스레드에서는 걸리지 않았습니다.
VitalRouter 의 할당 0 은 기다리지 않는 발행에서만이다
VitalRouter 는 zero-allocation 을 내세우고, 동기 발행은 정말 0 B 입니다. 그런데 인터셉터를 하나 달면 24 B, 구독자가 실제로 기다리면 유니티에서 발행 한 번에 1 KB 가까이 붙습니다. 어디까지 막고 어디서부터 못 막는지 봅니다.
VitalRouter 인터셉터의 next 는 커서 하나다
재시도 인터셉터가 next 를 두 번 부르면 두 번째는 뒤 인터셉터를 건너뜁니다. VitalRouter 의 next 는 발행 문맥 하나가 가진 델리게이트와 커서 int 하나라서입니다. 지웠다 넣은 인터셉터가 앞에 끼어드는 것도 같은 구조에서 나옵니다.
VitalRouter 는 구독자 하나가 던지면 뒤를 부르지 않는다
Hit 을 이펙트, 체력 바, 사운드가 구독할 때 이펙트가 던지면 나머지는 불리지 않습니다. VitalRouter 의 발행 루프에 try/catch 가 없어서입니다. 실패는 하나만 남고, 늦은 실패는 다음 발행에 옮겨 붙기도 합니다.
VitalRouter 의 구독과 해제는 목록을 처음부터 찾는다
씬을 나갈 때 구독자 4,000명을 하나씩 Dispose 하면 IL2CPP 에서 3 ms 가 넘습니다. VitalRouter 는 빈 칸도 해제할 구독자도 배열 처음부터 찾기 때문입니다. 함께 사라지는 구독은 라우터째 버리면 탐색이 없습니다.
VitalRouter 의 발행은 관심 없는 구독자까지 전부 훑는다
Damage 구독자 3명과 Heal 구독자 997명이 한 라우터에 있으면 Damage 발행은 1,000명을 다 들릅니다. 라우터에 타입별 목록이 없어서입니다. 거르기는 싸지만 0 이 아니고, 부르는 순서는 약속이 아닙니다.
VitalRouter 는 라우터 하나에 모든 커맨드를 흘린다
C# event 는 커맨드마다 누가 누구를 아는 선이 생기고, 발행한 쪽이 처리 끝을 기다릴 수 없습니다. VitalRouter 는 라우터 하나에 모든 커맨드를 흘리고, 발행이 늘 ValueTask 를 돌려주게 해서 둘을 풉니다.
MessagePipe 의 구독 진단은 숫자도 위치도 틀릴 수 있다
MessagePipe 는 해제 누락을 찾으라고 구독 수와 구독한 곳을 기록합니다. 그런데 비동기 브로커에서는 구독 수가 음수로 내려가고, 구독한 곳은 네임스페이스 이름이나 IL2CPP 설정에 따라 엉뚱하게 찍히거나 사라집니다.
MessagePipe 의 필터는 구독할 때 한 번 접힌다
MessagePipe 필터는 발행 때 목록을 도는 것이 아니라 구독 때 핸들러를 델리게이트 체인으로 한 번 감싸 둡니다. 그래서 같은 Order 면 합친 순서의 반대로 돌고, 비용은 구독에 몰리며, 람다 구독에는 속성 필터가 붙지 않습니다.
MessagePipe 의 병렬 발행은 앞이 던지면 뒤를 시작하지 않는다
MessagePipe 의 PublishAsync 는 기본이 병렬이지만, 핸들러를 차례로 시작하다 곧바로 실패한 핸들러를 만나면 뒤는 시작하지 않습니다. async 여도 첫 await 전에 던지면 같고, 늦게 실패하면 나머지를 기다리지 않고 끝납니다.
MessagePipe 의 버퍼드와 키 브로커는 일반 브로커에 규칙 하나를 얹는다
MessagePipe 의 버퍼드 브로커와 키 브로커는 일반 구독 목록을 그대로 쓰고 규칙 하나만 바꿉니다. 마지막 값은 버퍼드 발행자만 적고, 키 목록은 비는 순간 지웁니다. 그래서 일반 발행은 재생되지 않고, 키 구독은 매번 할당합니다.
MessagePipe 는 자식 스코프에서 꺼내도 나뉘지 않는다
MessagePipe 브로커의 기본 수명은 싱글턴이라, 자식 스코프에서 꺼낸 발행자와 구독자도 루트의 구독 목록 하나를 씁니다. 스코프 수명으로 등록해야 나뉘고, 그 스코프를 닫으면 구독이 한꺼번에 걷힙니다.
MessagePipe 의 동시 해제는 남의 구독을 지운다
같은 MessagePipe 구독을 두 스레드가 동시에 Dispose 하면 해제 검사가 락 밖이라 둘 다 통과합니다. 목록은 칸 번호만 보고 지우므로 KeyNotFoundException 이 나거나, 그사이 칸을 받은 다른 구독이 조용히 사라집니다.
MessagePipe 는 구독자를 칸 번호가 고정된 배열에 둔다
MessagePipe 의 발행이 락 없이 할당 0 으로 도는 것도, 호출 순서가 구독 순서와 다른 것도, 999명이 빠져도 1,024칸을 훑는 것도 모두 칸 번호를 바꾸지 않는 구독 목록에서 나옵니다.
MessagePipe 는 이벤트를 DI 컨테이너의 서비스로 만든다
C# event 는 발행자를 직접 알아야 하고 해제를 잊으면 구독자가 붙들립니다. MessagePipe 는 브로커를 컨테이너에 등록해 양쪽이 메시지 타입만 알게 하고, 해제를 IDisposable 로 돌려줘 구독자 수와 무관하게 만듭니다.
R3 코어는 유니티를 모른다
R3 코어는 시간, 프레임, 미처리 예외 핸들러라는 전역값 셋만 들고 유니티를 모릅니다. R3.Unity 가 어셈블리 로드 직후 이것을 채우고, 그 덕에 FakeFrameProvider 로 기다리지 않고 프레임 로직을 테스트합니다.
R3 의 값이 오는 스레드와 프레임은 소스가 정한다
SubscribeOn 은 구독 호출만 옮겨 값의 스레드는 소스가 정하고, ObserveOnMainThread 는 다음 러너 차례에, Timer 는 프레임 델타를 더해 발동합니다. 겹치는 비동기 처리는 AwaitOperation 으로 고릅니다.
R3 의 구독은 끝나지 않는 스트림만 직접 거둔다
R3 는 완료된 구독을 스스로 정리하니 직접 거둘 것은 EveryUpdate 같은 무한 스트림뿐입니다. 수집기는 할당과 개별 제거로 고르고, AddTo(this) 는 파괴에만 묶이며 2022.2 이상 활성 컴포넌트에는 아무것도 붙이지 않습니다.
R3 의 Subject 는 스레드 안전하지 않다
R3 의 Subject 는 구독자 목록을 연결 리스트로 바꿔 구독과 해제를 O(1) 로 만들고 발행 경로의 락을 뺐습니다. 대신 여러 스레드에서 OnNext 를 부르면 한 관찰자가 동시에 불립니다.
R3 는 씬에 오브젝트를 만들지 않는다
R3 는 MainThreadDispatcher 같은 오브젝트 없이 PlayerLoop 에 러너를 직접 꽂습니다. 고를 수 있는 타이밍이 아홉으로 늘었고, 매 프레임 경로에서 락이 빠졌고, 시간과 프레임은 각자의 프로바이더로 갈라졌습니다.
R3 는 구독 핸들을 따로 만들지 않는다
R3 의 Subscribe 는 관찰자 자신을 구독 핸들로 돌려줍니다. 해제가 상류 끝까지 번지고 살아 있는 구독을 모두 추적할 수 있습니다. 할당 이득은 연산자를 걸 때부터 나고, 체인은 구독마다 새로 조립됩니다.
R3 는 에러로 스트림을 끊지 않는다
UniRx 에서는 연산자 안에서 예외가 한 번 나면 그 버튼이 조용히 죽습니다. R3 는 예외를 OnErrorResume 으로 흘리고 구독을 살려 둡니다. 안 멈춤에서 멈춤으로는 연산자 하나로 갈 수 있지만 반대는 불가능해서입니다.
R3 는 UniRx 를 고치지 않고 다시 쓴 라이브러리다
R3 를 UniRx 의 다음 버전으로 보고 옮기면 중간에 막힙니다. 같은 저자가 범위를 이벤트 처리로 좁혀 계약부터 다시 만들었고, 옛 연산자 이름은 별칭 없이 지워 컴파일 에러로 드러나게 했습니다.
UniRx 의 비용은 무언가를 사려고 낸 값이다
Subject 에 구독 4,000개를 붙였다 떼면 123.1 MB 가 나옵니다. 배열 복사, 값마다 하는 재예약, 매 프레임 락은 락 없는 발행, 가상 시간, 스레드 자유를 사려고 낸 값입니다.
UniRx 는 씬의 오브젝트 하나로 프레임을 돌린다
버튼 연타 방지 같은 규칙을 UniRx 는 값의 흐름으로 다룹니다. 그 프레임 연산은 런타임에 스스로 만드는 MainThreadDispatcher 오브젝트의 코루틴 위에서 돌고, 시간은 스케줄러가 맡습니다.