게임 개발
226편
원점에서 멀어지면 떨리는 이유
오픈월드에서 원점에서 수십 km 떨어지면 캐릭터와 카메라가 덜덜 떨립니다. float 는 큰 수일수록 표현할 수 있는 값 사이가 벌어져서, 작은 움직임이 반올림으로 사라지거나 부풀기 때문입니다. 세상을 원점 쪽으로 다시 옮겨서 막습니다.
빠른 총알이 벽을 뚫고 지나가는 이유
물리는 0.02초마다 그 순간의 자리만 보고 겹쳤는지 검사합니다. 한 걸음에 가는 거리가 벽 두께보다 크면, 벽 앞과 벽 뒤만 보고 부딪힌 적이 없다고 판단합니다. 연속 충돌 검사와 레이캐스트로 그 사이를 메웁니다.
리지드바디가 떨려 보이는 이유
물리는 1초에 50번 움직이고 화면은 그보다 자주 그리니, Rigidbody 는 몇 프레임 제자리에 있다가 한 번에 뜁니다. deltaTime 으로는 안 고쳐지고, Interpolate 가 직전 두 물리 위치 사이를 메워 그려서 고칩니다.
FixedUpdate 가 따로 도는 이유
유니티 물리는 화면 프레임과 상관없이 0.02초마다 한 번씩 계산합니다. 프레임마다 지난 시간을 모아 두었다가 0.02초씩 덜어 내며 FixedUpdate 를 부르기 때문에, 한 프레임에 0번 불리기도 하고 두 번 불리기도 합니다. 간격을 고정하는 이유와 그 때문에 생기는 입력 씹힘을 숫자로 따라갑니다.
점프가 포물선이 되는 이유
중력은 위치가 아니라 속도를 바꿉니다. 매 프레임 속도에 중력을, 위치에 속도를 더하면 포물선이 나옵니다. 속도 · 가속도 · 포물선을 작은 숫자로 처음부터 따라가고, 계산 간격이 크면 점프 높이가 달라지는 이유와 원하는 높이로 뛰게 하는 처음 속도 √(2gh) 까지 봅니다.
매 프레임 Lerp(a, b, 0.1f) 를 쓰면 생기는 일
카메라를 부드럽게 따라가게 하려고 매 프레임 Lerp 에 0.1 을 넣으면, fps 가 높은 기계에서 더 빨리 따라갑니다. deltaTime 을 곱해도 딱 맞지 않습니다. 남은 거리가 곱셈으로 줄어들기 때문이고, 비율을 1 − Exp(−k × deltaTime) 로 주면 fps 와 상관없어집니다. 거듭제곱부터 숫자로 따라갑니다.
Time.deltaTime 을 곱하는 이유
내 컴퓨터에서는 알맞던 캐릭터가 다른 컴퓨터에서는 두 배로 빨라집니다. Update 가 1초에 몇 번 불릴지 모르기 때문이고, deltaTime 은 "한 번에 얼마" 를 "1초에 얼마" 로 바꿔 줍니다.
쿼터니언을 몰라도 쓰는 법
쿼터니언은 "축 하나와 그 축으로 돈 각도" 를 네 숫자에 담은 회전입니다. 네 숫자를 직접 건드리지 않고, 함수로 만들고, 곱해서 합치고, q * v 로 벡터를 돌립니다. 곱하는 순서가 월드 기준과 자기 기준을 가릅니다.
오일러 각: eulerAngles 가 넣은 값과 다르게 나오고 짐벌 락이 생기는 이유
x 를 100 으로 넣은 회전을 읽으면 (80, 180, 180) 이 나옵니다. 오일러 각은 세 축으로 차례로 세 번 도는 것이라 같은 자세를 여러 숫자로 쓸 수 있고, x 가 90 이 되면 두 축이 같은 일을 해서 회전 방향 하나를 잃는 짐벌 락이 생깁니다. 축과 회전부터 따라갑니다.
로컬 좌표와 월드 좌표
같은 (2, 1) 도 월드 기준이면 한 자리, 캐릭터 기준이면 다른 자리입니다. 좌표계와 기저가 무엇인지부터, TransformPoint 가 축을 섞어 월드 자리로 옮기고 InverseTransformPoint 가 내적으로 되돌리는 과정을 작은 숫자로 따라갑니다.
행렬: 크기 · 회전 · 이동을 한 번에 담는 표
부모를 돌리면 자식도 같이 도는 것은 엔진이 꼭짓점마다 행렬을 곱하기 때문입니다. 행렬이 무엇인지부터, 행렬 × 벡터가 열을 섞는 계산이라는 것, 크기 · 회전 · 이동을 TRS 순서로 합치는 이유까지 작은 숫자로 따라갑니다.
투영: 벽에 막힌 이동을 벽을 따라 미끄러지게 바꾸기
벽에 비스듬히 부딪힌 캐릭터가 멈추지 않고 벽을 따라 미끄러지게 하려면, 이동 벡터에서 벽으로 파고드는 조각만 빼면 됩니다. 그 조각을 내적으로 구하는 법과 ProjectOnPlane · Reflect 가 하는 일을 작은 숫자로 따라갑니다.
외적: 왼쪽인가 오른쪽인가
내적은 앞뒤만 알려 주고 왼쪽과 오른쪽은 가리지 못합니다. 외적은 두 벡터에 모두 수직인 벡터를 만들고, 바닥 위의 두 방향이면 그 y 부호가 왼쪽과 오른쪽을 알려 줍니다. 수직 · 법선 · 왼손 좌표계부터 작은 숫자로 따라갑니다.
내적: 적이 앞에 있나 뒤에 있나
백어택 판정과 시야각 판정은 Vector3.Dot 한 줄입니다. 내적은 같은 자리끼리 곱해서 더한 숫자이고, 부호는 앞과 뒤를, 값은 사이각의 cos 를 알려 줍니다.
방향 (x, y) 로 각도 구하기: 총구를 마우스로 돌리는 Atan2
2D 게임에서 총구를 마우스 쪽으로 돌리려면 방향 (x, y) 에서 각도를 구해야 합니다. 세로 ÷ 가로 가 tan 이고, 그 비율에서 각도를 거꾸로 찾는 것이 Atan 입니다. Atan 이 정반대 방향을 가리지 못하는 이유와 Atan2 가 그것을 고치는 과정을 숫자로 따라갑니다.
sin 과 cos 는 무엇인가: 비스듬한 화살표를 가로와 세로로 나누기
대포를 30도로 쏠 때 앞으로 얼마, 위로 얼마 가는지 구하는 도구가 cos 와 sin 입니다. 직각삼각형에서 가로 ÷ 빗변 이 cos, 세로 ÷ 빗변 이 sin 이고, 길이 1 인 원 위의 점이 (cos, sin) 입니다. 유니티의 Mathf.Sin 이 도가 아닌 라디안을 받는 이유까지 수학을 처음부터 따라갑니다.
대각선으로 가면 빨라지는 이유
W 와 D 를 같이 누르면 캐릭터가 41% 빨라집니다. 입력 (1, 1) 의 길이가 1 이 아니라 √2 라서입니다. 벡터의 길이를 재는 법과, 길이를 1 로 맞추는 정규화를 언제 쓰고 언제 쓰지 않는지 봅니다.
벡터 더하기는 화살표를 이어 붙이는 것이다
걷기, 바람, 넉백처럼 여러 움직임을 한 번에 합치는 방법은 벡터 더하기입니다. 같은 자리끼리 더하면 화살표가 이어 붙고, 숫자를 곱하면 방향은 두고 길이만 바뀝니다.
벡터는 위치가 아니라 화살표다
Vector3 하나에 위치도 방향도 담기지만 둘은 다른 것입니다. 벡터는 시작점 없이 방향과 길이만 가진 화살표이고, 두 위치 사이의 방향은 도착 − 출발로 구합니다.
Content Directories 는 아직 로컬 전용이다
Unity 6.6 의 Content Directories 는 Player 에 넣는 로컬 콘텐츠만 다룹니다. 원격 콘텐츠를 AssetBundle 로 남기면 공유 에셋이 양쪽에 따로 빌드되고, Addressables 4.x 에서 그룹을 바꿀 때는 빌드 스크립트에 따라 경고 없이 빈 빌드가 됩니다.
Content Directories 의 참조 카운트는 핸들만 센다
Content Directories 는 Loadable 핸들과 씬만 세어 마지막 쪽이 놓은 1프레임 뒤 에셋을 내립니다. Instantiate 한 인스턴스는 세지 않아서, 쓰는 중에 핸들을 놓으면 인스턴스의 머티리얼이 경고 없이 사라집니다.
Content Directories 는 등록할 때 실선까지만 올린다
Content Directories 는 디렉터리를 등록할 때 매니페스트를 읽고 Root 와 실선만 올립니다. 나머지는 Loadable.Load 가 매니페스트로 파일을 찾아 실선 순서대로 열고, 씬과 핸들로 각각 올린 같은 에셋은 하나의 객체로 공유됩니다.
Content Directories 의 파일 이름은 내용 해시다
Content Directories 는 에셋 하나를 내용 해시 이름의 파일 하나로 빌드합니다. 바뀌지 않은 파일은 다시 쓰지 않아 재빌드가 65초에서 9초로 줄었고, 대신 Player 빌드에 스트리핑 정보를 넘기지 않으면 콘텐츠 전용 컴포넌트가 에러 없이 빠집니다.
Content Directories 는 Root 에서 참조를 따라 빌드한다
Content Directories 빌드는 Root Asset 에서 GUID 참조를 따라가며 닿는 에셋을 모읍니다. 빌드는 Loadable 같은 점선 참조까지 다 따라가지만 런타임 로드는 점선에서 멈추므로, 수명이 갈리는 자리에 점선을 둡니다.
Content Directories 는 번들을 에셋 단위로 쪼갠다
AssetBundle 은 번들 하나가 배포, 로드, 해제 단위라 에셋 하나를 쓰려 해도 번들을 통째로 엽니다. Unity 6.6 의 Content Directories 는 이 일을 Root, 디렉터리, Loadable 셋으로 나눠 에셋 하나씩 올리고 내립니다.
Addressables 의 원격 배포는 카탈로그가 이끈다
Addressables 의 원격 배포는 카탈로그를 바꿔 끼우는 일입니다. 기본 설정에서는 초기화 때 원격 카탈로그 해시를 비교해 새 카탈로그를 받고, 콘텐츠 업데이트 빌드 뒤에는 바뀐 번들만 새로 받습니다.
Addressables 는 번들 단위로 참조를 센다
Addressables 는 에셋마다 참조를 세지만 실제로 내리는 단위는 번들이라, 같은 번들의 다른 에셋이 살아 있으면 Release 한 에셋도 남습니다. Object.Instantiate 인스턴스는 세지 않고, InstantiateAsync 를 Destroy 만 하면 씬이 내려갈 때까지 번들이 열려 있습니다.
Addressables 의 그룹 설정이 번들 모양을 정한다
Addressables 의 그룹은 에디터 개념이고 런타임 단위는 그룹 설정이 만든 번들입니다. 묶음 방식에 따라 같은 20개 에셋이 번들 1개, 20개, 4개가 되고, 그룹끼리 공유하는 에셋은 Analyze 로 찾아 따로 빼야 한 벌이 됩니다.
Addressables 는 AssetBundle 위에 주소를 얹는다
Addressables 는 에셋에 주소를 붙여 그 주소로 로드하게 하고, 어느 번들에 있는지와 의존 번들을 여는 일을 대신합니다. 다만 에디터 기본 모드는 그룹 설정을 보지 않아서, 에디터에서만 되는 로드가 생깁니다.
AssetBundle 의 Unload 는 어느 쪽이든 사고가 난다
AssetBundle.Unload(false) 뒤 같은 번들을 다시 열면 옛 객체와 새 객체가 메모리에 같이 있고, 옛 것은 UnloadUnusedAssets 로만 치워집니다. Unload(true) 를 쓰는 중에 부르면 인스턴스의 머티리얼이 경고 없이 사라집니다.
AssetBundle 은 배정하지 않은 공용 에셋을 번들마다 복사한다
두 번들이 같은 머티리얼을 쓰는데 그 머티리얼을 어느 번들에도 넣지 않으면, AssetBundle 은 번들마다 따로 복사하고 둘을 열면 메모리에도 두 벌이 올라옵니다. 공용 에셋을 공용 번들에 명시하면 한 벌로 줄어듭니다.
AssetBundle 의 의존성은 직접 열어야 따라온다
AssetBundle 은 프리팹의 머티리얼이 다른 번들에 있으면 그 번들을 직접 열기 전까지 머티리얼이 비어 마젠타로 그립니다. 나중에 열면 끊긴 참조가 이어지지만, AssetBundleManifest 로 의존 번들을 먼저 여는 순서를 지킵니다.
AssetBundle 은 압축 방식에 따라 여는 비용이 다르다
LZMA 번들을 LoadFromFile 로 열면 번들 전체를 풀어 메모리에 둡니다. LZ4 와 무압축은 1ms 안에 열리고, LoadFromMemory 는 바이트 배열과 사본을 같이 들며, UnityWebRequest 에 버전을 주면 디스크 캐시를 써서 메모리가 크게 줄어듭니다.
AssetBundle 은 에셋을 Player 밖 파일로 꺼낸다
유니티는 Scene List 에서 참조를 따라 에셋을 Player 에 싣고, Resources 폴더는 참조가 없어도 통째로 싣습니다. AssetBundle 은 에셋 일부를 Player 밖 아카이브 파일로 빼서 따로 받고, 따로 열고, 따로 내리게 합니다.
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 로 돌려줘 구독자 수와 무관하게 만듭니다.
클릭은 콜라이더가 받고 UI 는 월드에 놓여 있다
NGUI 의 버튼에는 콜라이더가 붙어 있고, UICamera 가 물리 레이캐스트로 그걸 맞힙니다. 가까운 것이 아니라 앞에 있는 것이 이기는 이유, SendMessage 로 오는 알림, UIRoot 가 루트를 2/화면높이로 축소해 두는 좌표계까지 소스에서 확인했습니다.
그리는 순서는 하이어라키가 아니라 숫자가 정한다
NGUI 는 위젯마다 depth 정수를 들고 그 숫자로 앞뒤를 정합니다. 오브젝트를 옮겨도 순서가 안 바뀌는 대신 숫자를 관리해야 합니다. 판과 위젯의 두 단계 정렬, 같은 숫자를 줬을 때의 재질 묶기, 그 숫자 배치가 드로우 콜을 가르는 과정까지 소스에서 확인했습니다.
uGUI 는 NGUI 에서 시작했다
유니티에 런타임 UI 가 없던 시절에 그 자리를 채운 것이 NGUI 입니다. 2025년 10월에 MIT 로 풀린 저장소를 열어 보면서, 무엇이 한 상자에 들어 있었는지와 유니티 UI 와 크게 갈리는 지점을 정리했습니다.
목록은 보이는 만큼만 만든다
ListView 는 항목이 천 개여도 요소를 열몇 개만 만들고 돌려 씁니다. makeItem 과 bindItem 이 갈려 있는 이유, 높이를 아느냐가 가르는 두 가지 가상화 방식, Rebuild 와 RefreshItems 의 차이, 그리고 안 되는 것까지 정리했습니다.
판을 화면이 아니라 씬에 놓는다
Panel Settings 의 render mode 를 World Space 로 바꾸면 UI 판이 씬 안의 오브젝트가 됩니다. Pixels Per Unit 으로 크기를 정하고, 기준점을 아홉 자리 중에 고르고, 카메라와의 거리로 순서가 정해지고, 입력은 물리 레이로 갑니다.
한 배치에 텍스처 여덟 장까지다
UI Toolkit 의 우버 셰이더는 한 배치에 텍스처 여덟 장을 받습니다. 그 한도와 배치가 깨지는 조건, Vertex Budget, 사각 마스크와 스텐실 마스크가 갈리는 지점, 오버드로와 메모리까지 공식 문서에서 확인해 정리했습니다.
무엇이 다시 계산되는지가 네 가지로 나뉜다
uGUI 는 한 칸이 바뀌면 캔버스가 통째로 다시 계산됐습니다. UI Toolkit 은 스타일 해석·레이아웃·정점 버퍼·렌더 상태 넷으로 갈립니다. 바꾼 값마다 어디까지 번지는지, 숨기는 방법 셋의 비용, UsageHints, 바인딩의 리플렉션 비용까지 정리했습니다.
내가 만든 요소도 UXML 에 태그로 들어간다
같은 묶음이 화면마다 반복되면 묶습니다. uxml 템플릿으로 다시 쓰는 방법과 C# 으로 요소를 만드는 방법, UxmlElement 와 UxmlAttribute, 옛 UxmlFactory 방식과의 차이, 에디터 창까지 정리했습니다.
움직임은 클립이 아니라 두 값 사이에서 만들어진다
UI Toolkit 에는 Animator 도 클립도 안 붙습니다. uss 에 한 줄 적어 두면 값이 바뀔 때 이어서 갑니다. 전환을 어디에 걸어야 배치가 안 다시 잡히는지, 전환 이벤트, 그리고 값이 바뀌면 화면이 따라오게 하는 데이터 바인딩까지 정리했습니다.
이벤트는 루트에서 내려갔다가 다시 올라온다
UI Toolkit 에는 인스펙터의 On Click 칸이 없습니다. 콜백을 코드로만 거는 대신 이벤트가 트리를 두 번 지나갑니다. 내려가는 길과 올라오는 길, 막는 방법, pickingMode, 포커스까지 정리했습니다.
해상도 대응은 씬이 아니라 에셋 한 장에 적힌다
UI Toolkit 의 Panel Settings 는 Canvas Scaler 와 하는 일이 거의 같은데 있는 자리가 다릅니다. 배율 방식 세 가지, Match 값, Sort Order 로 정하는 패널 순서, Render Texture, Safe Area 가 없다는 것까지 정리했습니다.
칸은 남는 자리를 나눠 갖는 방식으로 정해진다
UI Toolkit 에는 앵커도 피벗도 없습니다. 자리는 부모가 정하고 자식은 쌓입니다. 기본 방향이 column 인 것, flex-grow 로 남는 자리를 나누는 것, 주축과 교차축, absolute 를 언제만 쓰는지, 숨기는 방법 두 가지를 정리했습니다.
이름을 붙여야 스타일을 걸 수 있다
UI Toolkit 에서 모양은 요소에 붙지 않고 선택자에 붙습니다. 타입, 이름, 클래스, 전체 네 가지로 부르는 방법과
화면 하나가 파일 두 개와 오브젝트 하나가 된다
UI Toolkit 으로 화면을 만들면 무엇이 어디에 생기는지부터 봅니다. uxml 과 uss 가 각각 무엇을 적는 파일인지, VisualElement 가 uGUI 의 무엇 자리인지, UI Document 와 Panel Settings 가 왜 따로 있는지를 정리했습니다.
UI Toolkit 은 화면에 오브젝트를 놓지 않는다
유니티에는 UI 를 만드는 체계가 둘 있고, 이 시리즈가 열여섯 편 동안 다룬 것은 그중 하나입니다. 다른 하나는 어디에 무엇을 놓는지부터 다릅니다. 무엇이 되고 무엇이 안 되는지, 유니티가 어느 쪽을 권하는지, 그리고 시안을 넘기는 쪽에서 무엇이 달라지는지를 봅니다.
원본을 고치면 전부 따라오는데, 손댄 것만 안 따라온다
프리팹이 무엇이고 무엇을 어떻게 프리팹으로 만드는지부터 봅니다. 한 번 손댄 값이 왜 원본 수정을 빗나가는지, 시안의 Variant 와 무엇이 다른지, 레이어 이름과 그림 파일을 어떻게 넘겨야 하는지를 정리했습니다.
짧은 글자일수록 번역에서 더 길어진다
언어가 바뀌면 글자 길이가 바뀌고 한국어에 맞춰 잡은 칸이 깨집니다. 얼마나 늘어나는지, 폰트에 없는 글자는 어떻게 되는지, 오른쪽에서 왼쪽으로 읽는 언어에서 화면이 어떻게 뒤집히는지를 봅니다.
한 칸이 바뀌면 그 캔버스는 통째로 다시 계산된다
UI 가 무거워지는 자리는 그림의 퀄리티가 아니라 쌓아 둔 구조입니다. 캔버스 경계가 무엇까지 막아 주는지, 겹침이 어디서 묶음을 끊는지, 그리고 중첩 레이아웃 그룹과 오버드로우를 봅니다.
Animator 는 붙어 있는 동안 계속 일한다
UI 를 움직이는 방법은 애니메이션 클립과 코드 두 갈래입니다. Animator 가 매 프레임 하는 일, 무엇을 움직이면 무엇이 다시 도는지, 시안에서 시간과 이징을 어떻게 넘기는지를 그림으로 봅니다.
눌리는 것은 전부 같은 상태 다섯 개를 쓴다
시안의 버튼은 보통 기본과 눌림 두 장이지만 Unity 는 다섯 상태를 요구합니다. 상태와 전환 방식, 무엇이 클릭을 먹는지, 그룹 전체를 못 누르게 막는 법, 최소 터치 크기를 그림으로 봅니다.
반투명이 시안보다 밝게 나오면 색 공간 문제다
같은 색과 같은 알파인데 Unity 화면에서 다르게 보이는 이유는 색을 섞는 자리가 다르기 때문입니다. 색 공간, 그라데이션 중간 색, 없는 블렌드 모드, 정점을 복제하는 외곽선까지 그림으로 봅니다.
동적으로 채우는 글자는 본 만큼 램에 쌓인다
채팅이나 유저 이름처럼 나올 글자를 미리 알 수 없는 화면은 글자를 실행 중에 굽습니다. 한글과 중국어에서 그 텍스처가 얼마나 커지는지, 왜 채팅만 Text 기반 컴포넌트로 만드는지를 그림으로 봅니다.
입력창은 컴포넌트 하나가 아니라 오브젝트 묶음이다
시안의 입력창은 사각형 하나지만 Unity 에서는 배경, 안내 문구, 입력 글자, 커서가 각각 따로입니다. 구조와 인스펙터 값, Content Type, 글자 수 제한, 모바일 키보드까지 그림으로 봅니다.
크기가 고정이고 꾸밈이 없으면 일반 Text 로 충분하다
Unity 의 글자 컴포넌트는 둘입니다. 일반 Text 가 하는 일과 없는 기능, 폰트를 준비하는 방식의 차이, 그리고 어느 화면을 일반 Text 로 두고 어느 화면을 TextMeshPro 로 가져갈지를 그림으로 봅니다.
TextMeshPro 는 글자를 미리 그림으로 구워 둔다
한글은 영문과 글자 수부터 다릅니다. 폰트 에셋이 구워지는 방식, 외곽선과 그림자가 머티리얼인 이유, 자간과 행간의 기준 차이, 상자에 글이 안 들어갈 때 일어나는 일을 그림으로 봅니다.
버튼 이미지를 통째로 늘리면 모서리가 같이 늘어난다
버튼 배경 한 장을 여러 크기로 쓰려면 이미지를 아홉 조각으로 나눠야 합니다. Image Type 넷이 늘어나는 방식, 9-slice 경계, Pixels Per Unit, 색 곱하기, 투명 여백을 그림으로 봅니다.
스크롤 목록은 창문 뒤에서 긴 종이를 움직이는 것이다
인벤토리 같은 스크롤 목록을 칸(Grid Layout Group), 종이와 창(Scroll View), 가림막(Mask, RectMask2D) 세 부품으로 나눠, 칸이 꺾이는 규칙과 끝에서 튕기는 동작을 그림으로 봅니다.
Layout Group 은 칸을 먼저 나누고 그 안에 자식을 놓는다
Figma Auto Layout 과 닮았지만 규칙이 다릅니다. Control Child Size 와 Child Force Expand 가 버튼 모양을 어떻게 네 가지로 바꾸는지, 글에 맞춰 커지는 말풍선이 어떻게 만들어지는지 그림으로 봅니다.
Canvas Scaler 는 가로와 세로 중 한쪽만 지킨다
시안과 비율이 다른 화면에서는 가로와 세로를 동시에 맞출 수 없습니다. Match 값이 무엇을 지키고 무엇을 남기는지, 노치를 피하는 Safe Area 까지 봅니다.
앵커는 화면이 늘어날 때 어디에 붙어 있을지 정한다
같은 Pos X 60 이 앵커에 따라 왼쪽에서 60 이 되기도, 가운데에서 60 이 되기도 합니다. RectTransform 의 앵커와 피벗을 Figma 컨스트레인트와 나란히 놓고 봅니다.
하이어라키 맨 아래가 화면 맨 앞이다
Figma 레이어 패널과 Unity 하이어라키는 앞뒤를 반대 방향으로 셉니다. 캔버스가 무엇이고, 무엇이 앞에 그려지는지 정하는 규칙을 봅니다.
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 오브젝트의 코루틴 위에서 돌고, 시간은 스케줄러가 맡습니다.
노멀 매핑이 못 하는 것
법선만 바꾸고 모양은 그대로 두는 거래에는 대가가 따릅니다. 실루엣, 그림자, 시차 셋을 못 하고 스치는 각도에서 무너집니다.
노멀맵은 왜 파란가
방향 벡터를 색 채널에 욱여넣으면 그 밋밋한 하늘색이 나옵니다. 그리고 그 방향이 기준으로 삼는 축이 탄젠트 공간입니다.
평평한 면을 울퉁불퉁하게 보이게 하는 법
밝기를 정하는 게 모양이 아니라 법선이라면, 모양은 그대로 두고 법선만 자리마다 바꾸면 됩니다. 그 법선을 담아 둔 텍스처가 노멀맵입니다.
법선 하나가 밝기를 정한다
셰이더는 면이 진짜 어떻게 생겼는지 모릅니다. 각 자리의 법선이 어느 쪽을 보는지만 알고, 밝기는 거기서 나옵니다.
불 꺼진 방에서도 보이는 것
네온사인은 빛을 받아서 밝은 게 아닙니다. 조명 계산에 참여하지 않고 그냥 더해지는 항이 Emissive 입니다.
호수에 비친 태양은 내가 걸어가면 따라온다
네 항 중 유일하게 보는 방향이 들어가는 항입니다. Diffuse 와 Specular 를 가르는 것이 그 시선 의존성 하나입니다.
손전등을 비스듬히 비추면 어두워지는 이유
같은 빛이 넓게 퍼지면 단위 면적이 받는 양이 줄어듭니다. 그 각도를 내적 한 번으로 구하는 것이 Diffuse 입니다.
그늘이 완전히 검지 않은 이유
빛이 안 닿는 자리도 새까맣지는 않습니다. 그 간접광을 다 계산하는 대신 상수를 하나 깔아 두는 것이 Ambient 입니다.
하나를 위해 ECS 를 지불할 이유는 없다
적 1만은 ECS 이고 플레이어는 GameObject 입니다. 무엇을 옮기고 무엇을 두는지는 개수와 이터레이션 빈도가 정합니다.
5,330만 삼각형이 118 드로우 콜로 접힌다
최적화가 잘 되면 프로파일에서 시뮬레이션이 사라집니다. 그리고 병목이 렌더로 옮겨갑니다. Entities Graphics 와 GPU Resident Drawer 는 같은 뿌리입니다.
에러 0개로 틀리는 법
이 프로젝트에서 가장 오래 잡은 버그들은 전부 컴파일을 통과했습니다. 하나는 런타임 데모까지 통과했습니다.
결과가 같은 두 API 사이에서 33퍼센트가 갈린다
1만 마리가 동시에 죽을 때 DeadTag 를 AddComponent 로 붙이느냐 비트로 켜느냐. 코드는 몇 줄 차이인데 프레임 스파이크가 31.71ms 와 42.26ms 로 갈렸습니다.
브릿지는 표면적이 좁을수록 좋다
플레이어 체력을 ECS 에 넘길 뻔했는데 결국 bool 한 비트로 끝났습니다. 필드를 추가하는 기준은 반대편이 그걸 읽느냐입니다.
수신자가 살아 있으면 버퍼, 사라지면 큐
데미지는 적의 버퍼에 담고 사망은 월드 전역 큐에 담습니다. 주인공이 파괴되기 때문입니다. 그리고 값을 담을지 참조를 담을지도 같은 이유로 갈립니다.
엔티티는 물건만이 아니다
공격을 요청 엔티티로 모델링해 뒀더니, 나중에 광역기를 붙일 때 숫자 두 개만 바꾸면 됐습니다. 시스템 코드는 한 줄도 안 고쳤습니다.
방향이 다르면 통로의 모양도 다르다
GameObject 세계와 ECS 세계를 잇는 통로 세 개를 뚫었는데 셋의 모양이 전부 달랐습니다. 방향과 건수가 모양을 정합니다.
1만 마리 이웃 탐색을 6.26ms 로, 그리고 기울기가 0 이 된다는 것
셀 크기를 질의 반경에 맞추면 엔티티당 검사 수가 상수가 됩니다. 19.60ms 가 6.26ms 가 됐는데, 진짜 결과는 그 배수가 아닙니다.
잡의 시그니처가 곧 쿼리다
IJobEntity 는 Execute 파라미터를 보고 쿼리를 만듭니다. 그리고 잡의 필드와 파라미터는 각각 1만 개가 공유하는 값과 1만 번 반복되는 대상입니다.
캡슐 하나가 엔티티가 되기까지
SubScene 에 캡슐을 놓으면 커스텀 코드 없이 엔티티가 되어 GPU 인스턴싱으로 그려집니다. 그리고 Baker 는 자기 엔티티만 건드릴 수 있습니다.
C# 을 쓰는데 결과는 네이티브다
Burst 는 IL 을 LLVM 으로 넘겨 네이티브 코드를 뽑습니다. 대신 쓸 수 있는 C# 이 부분집합으로 줄고, 그 제약이 곧 최적화의 조건입니다.
스레드를 직접 만들지 않고 일을 맡긴다
잡 시스템은 워커 스레드를 미리 띄워 두고 일감을 나눠 줍니다. 스레드를 안 만드는 것이 핵심이고, 안전 장치가 나머지 절반입니다.
엔티티는 번호표이고 데이터는 청크에 줄지어 있다
ECS 의 세 조각이 각각 무엇인지, 아키타입과 청크가 왜 필요한지, 쿼리가 엔티티를 하나씩 뒤지지 않는 이유까지.
1만 개를 한 번 도는 것과 1개를 1만 번 부르는 것
적 1만 마리를 움직이는 코드가 느린 이유는 계산이 무거워서가 아니라 데이터가 흩어져 있어서입니다. ECS 는 그걸 붙여 놓는 것에서 시작합니다.
빌드 다섯 시간과 셰이더 0.5GB 는 같은 원인이다
셰이더 변형은 키워드 축의 곱으로 늘어납니다. 너무 많으면 빌드가 안 끝나고, 너무 적으면 화면이 조용히 깨집니다.
부드러움을 메모리로 살 것인가 연산으로 살 것인가
스프라이트 애니메이션은 메모리를 쓰고 스켈레탈은 CPU 를 씁니다. 캐릭터마다 다르게 고르는 게 답이고, 스켈레탈은 Burst 만 깔아도 달라집니다.
한 줄로 배터리를 절반 쓰는 방법
targetFrameRate 를 30 으로 내리면 프레임 예산이 두 배가 됩니다. 최적화 코드를 짜기 전에 이 한 줄을 먼저 재 봐야 하는 이유.
해상도를 낮춘다는 게 정확히 무엇을 낮추는 것인가
Render Scale 은 기기 해상도를 바꾸지 않습니다. 게임을 그려 넣는 캔버스의 크기를 바꿉니다. UI 는 안 따라 내려갑니다.
안 보이는 픽셀을 몇 번 칠하고 있나
2D 는 반투명이 겹겹이 쌓이는 구조라 같은 픽셀을 네다섯 번 칠합니다. 드로우 콜을 아무리 줄여도 이건 안 줄어듭니다.
2D 라이트는 배칭과 필레이트를 같이 낸다
2D 라이팅은 스프라이트를 그리기 전에 라이트 버퍼를 따로 렌더링합니다. 그래서 라이트 하나가 CPU 와 GPU 양쪽에 값을 매깁니다.
전부 올려둘 것인가 필요할 때 올릴 것인가
Late Binding, Variants, Runtime Atlas 는 전부 로딩 전략입니다. 그리고 Runtime Atlas 는 게임플레이 중에 부르면 확실히 걸립니다.
용량은 그대로인데 화질만 깎였다면
이중 압축은 원본과 아틀라스 양쪽에서 압축할 때 생깁니다. 최종 용량은 같고 품질만 두 번 깎입니다. 그리고 에러가 안 납니다.
텍스처를 합치는 것이 곧 배칭을 합치는 것
아틀라스는 메모리 최적화로 알려져 있지만 실제 효과는 배칭 쪽이 큽니다. 그리고 묶는 기준은 기능이 아니라 로딩 시점입니다.
PNG 를 넣든 PSD 를 넣든 결과는 같다
이미지 압축과 텍스처 압축은 다른 개념입니다. 그리고 모바일 빌드에 DXT 를 지정하면 런타임에 압축이 통째로 풀려서 앱이 죽습니다.
정답이 파이프라인에 따라 뒤집힌다
MaterialPropertyBlock 은 Built-in RP 의 정석이고 SRP Batcher 에서는 배치를 깹니다. 같은 코드가 한쪽에서는 답이고 한쪽에서는 문제입니다.
배칭은 다섯 가지 이유로 끊긴다
머티리얼도 텍스처도 같은데 배치가 끊깁니다. 정렬 레이어와 반투명 순서 때문입니다. 에셋을 아무리 합쳐도 이건 안 풀립니다.
합치지 않고도 빨라지는 법
SRP Batcher 는 메시를 안 합칩니다. 머티리얼 값을 GPU 에 상주시켜 두고 오프셋만 바꿉니다. 그래서 배치 조건이 머티리얼이 아니라 셰이더 변형입니다.
합치는 비용이 그리는 비용보다 클 때
다이나믹 배칭은 매 프레임 CPU 가 메시를 합칩니다. 그 부등호가 요즘 하드웨어에서 자주 뒤집혀서, URP 에서 우선순위가 내려갔습니다.
배칭이 왜 끊겼는지는 화면이 알려준다
Frame Debugger 는 배치가 끊긴 사유를 문장으로 적어 줍니다. 그 문장을 실제 원인으로 옮기는 표와, 도구마다 있는 함정 하나씩.
드로우 콜을 줄였는데 느려졌다면
드로우 콜과 SetPass Call 은 다른 것을 셉니다. 드로우 콜이 524 에서 765 로 늘었는데 오히려 빨라진 실측이 있습니다.
관리 힙은 30MB 인데 게임은 2GB 를 먹는다
텍스처와 메시는 관리 힙에 거의 없습니다. GC 를 아무리 줄여도 여기는 안 줄어듭니다.
CoreCLR 이 와도 모바일은 그대로다
유니티가 Mono 를 걷어내고 세대 GC 를 들여옵니다. 그런데 IL2CPP 는 이 변화에서 빠져 있습니다.
증분 GC 는 일을 여러 프레임에 나눈다
기본으로 켜져 있고 프레임 튐을 줄여 줍니다. 다만 없애 주지는 않고, 공짜도 아닙니다.
보헴 GC 는 객체를 옮기지 않는다
보수적이고, 비압축이고, 비세대입니다. 이 세 단어가 프레임에서 어떻게 나타나는지 하나씩 봅니다.
유니티에서 프레임이 가끔 튀는 이유
평균은 멀쩡한데 몇 초에 한 번 걸립니다. 대개 GC 인데, 그 GC 가 지금 유니티에서 갈아 끼워지는 중입니다.
타입을 고치지 않고 타입마다 다른 일을 시킨다
동작을 하나 추가할 때마다 모든 클래스를 열지 않아도 됩니다. 대신 타입을 하나 추가할 때 모든 동작을 고쳐야 합니다.
감싸는 것은 같고 무엇을 바꾸는지가 다르다
어댑터와 퍼사드와 데코레이터는 셋 다 다른 것을 감쌉니다. 갈리는 것은 감싼 뒤에 인터페이스가 어떻게 되느냐입니다.
전부 검사하지 않으려면 미리 나눠 둔다
적 500마리가 서로 부딪혔는지 보려면 12만 번을 비교해야 합니다. 공간을 칸으로 나눠 두면 같은 칸과 이웃 칸만 보면 됩니다.
같은 값을 인스턴스마다 복제하지 않는다
적 2000마리가 각자 들고 있는 이름과 아이콘과 기본 스탯은 전부 같은 값입니다. 하나만 두고 모두가 가리키면 됩니다.
오브젝트 풀은 언제 손해인가
풀링이 항상 이득이라는 통념을 실측으로 확인해봤고, 손해가 나는 구간이 분명히 있었다.
바뀌었다는 사실만 적어 두고 계산은 미룬다
한 프레임에 열 번 바뀌어도 화면에 나가는 것은 마지막 하나뿐입니다. 앞의 아홉 번은 계산해서 곧바로 버려집니다.
상태를 하나로 두면 화면은 그 상태의 결과가 된다
값이 흩어져 있으면 화면이 이상할 때 그 순간을 재구성할 수 없습니다. 상태를 하나로 모으고 바꾸는 길을 하나로 막으면 재현과 되돌리기가 따라옵니다.
값을 미는 대신 흐름을 잇는다
MV(R)P 는 MVP 의 이벤트 연결을 스트림으로 바꿉니다. 구독 해제가 자동이 되고 값 여러 개를 합치는 일이 한 줄이 됩니다.
무엇을 쪼개느냐가 아니라 누가 누구를 아는가
셋 다 데이터와 화면을 나누는 이야기입니다. 다른 것은 나누는 방법이 아니라 나눈 뒤에 남는 참조의 방향입니다.
new 를 부르는 자리를 한 곳으로 모은다
인터페이스로 갈아 끼울 수 있게 만들어 놔도 new 가 코드 곳곳에 흩어져 있으면 구체 타입 이름은 그대로 남습니다.
호출을 객체로 만들면 저장할 수 있게 된다
메서드는 부르고 나면 아무것도 남지 않습니다. 부르라는 지시를 객체로 만들어 두면 되돌리기, 기록, 리플레이, 전송이 한꺼번에 딸려 옵니다.
구조는 같고 누가 바꾸느냐가 다르다
전략과 상태는 코드만 보면 거의 구분이 안 됩니다. 갈리는 지점은 하나입니다. 다음에 무엇을 쓸지 밖에서 꽂아 주느냐, 자기가 정하느냐.
상태가 하나 늘면 조건은 하나만 늘지 않는다
bool 플래그는 있을 수 없는 조합까지 만들어 냅니다. enum 으로 한 번 정리되지만 switch 가 여러 곳에 흩어지면서 다시 무너집니다.
발신자를 지우면 추적할 단서도 같이 지워진다
이벤트 버스는 알리는 쪽과 듣는 쪽이 서로를 모르게 만듭니다. 결합은 더 느슨해지지만 이 이벤트를 누가 보냈는지 찾는 일이 어려워집니다.
버튼은 누가 듣고 있는지 몰라도 된다
반응이 늘 때마다 버튼을 고치는 대신 반응이 스스로 등록하게 합니다. C# 의 event 가 이미 그 구조라, 배우는 게 아니라 이름을 붙이는 일에 가깝습니다.
무엇을 쓸지는 부르는 쪽이 아니라 등록하는 쪽이 정한다
서비스 로케이터는 싱글톤에서 전역 접근은 남기고 구현만 떼어냅니다. 갈아 끼울 수 있게 되는 대신 컴파일 에러가 런타임 에러로 밀립니다.
어디서든 부를 수 있다는 것은 어디서나 묶인다는 뜻이다
싱글톤은 유일성과 전역 접근 두 가지를 한꺼번에 보장합니다. 편한 것은 두 번째인데 대가도 전부 두 번째에서 나옵니다.