[Unity] 자기 점검 (1 ~ 7장)
댓글 0
댓글을 작성하려면 로그인이 필요합니다.
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!
이 글은 <Unity Native 이해를 위한 로드맵> 시리즈의 중간 점검용 체크포인트. 이전 글들을 읽지 않았다면 맥락이 부족할 수 있습니다.
24/33. 9문제의 오답 먼저 정리한다.
문제는 Q. 내 답은 A. 오답은 F. 정답은 E.
[3] Cpp에도 런타임은 있으나(CRT) CLR은 아니다.
[7] Wrapper패턴과 C#참조 혼동
[8] 마찬가지로 Wrapper와 C#참조 혼동
[12] IDisposable 문제. 표준 규약이라는 키워드 누락.
[14] Mark n Sweep과 참조카운팅은 다르다.
[25] Unity가 Cpp에서 하는 일 유형. 암기 부족
[31] Wrapper 패턴이 무엇을 해결하는지 묻는 문제였음. 엉뚱한 대답했음.
[32] 이중 객체 구조라는 키워드 누락
[33] 바인딩생성기는 엔진의 빌드 툴. 그리고 Unity 타입에만 적용되고 사용자꺼는 안해줌
Q1) CPU가 실행할 수 있는 것은 무엇인가? C# 소스 코드, IL, 기계어 중 CPU가 직접 처리하는 것은 어느 것이며, 나머지는 누가 처리하는가?
A. 기계어. 나머지는 컴파일러가 최종적으로 기계어로 바꿀 대상이다. C#을 IL로 바꾸는건 컴파일러가 하고, IL을 기계어로 바꾸는 Mono와 IL을 Cpp로 바꾸는 IL2CPP는 구분된다. 이때 Cpp는 다시 플랫폼에 맞는 기계어로 컴파일된다.
Q2) C++ 소스 코드가 실행되기까지의 과정과 C# 소스 코드가 실행되기까지의 과정을 각각 설명하라. 두 과정의 근본적인 차이는 무엇인가?
A. Cpp 코드는 컴파일 시점에 플랫폼(os)에 맞는 기계어로 변환되고, 이는 CPU 위에서 직접 실행된다. C#의 코드는 JIT 또는 AOT 결과물을 런타임의 도움을 받아 실행한다.
Q3) "런타임"이라는 용어가 가리키는 것은 무엇인가? C++에도 런타임이 존재하는가?
A. 닷넷의 경우 CoreCLR, 유니티는 Mono또는 IL2CPP를 의미한다. 이들은 CLR 구현체이며 Cpp에는 존재하지 않는다. 런타임은 타입 안정성 검사, GC, Interop 등을 수행한다.
F. "Cpp에는 존재하지 않는다"가 틀렸다. C++에도 CRT(C Runtime Library)가 존재하며, 정적 변수 초기화, 예외 처리 인프라, 프로세스 진입/종료 처리 등을 담당한다.
E. C++에도 런타임(CRT)이 존재한다. 다만 CLR처럼 메모리를 관리하거나 타입을 검증하는 Managed Runtime은 아니다.
Q4) Mono 백엔드와 IL2CPP 백엔드에서 IL이 기계어가 되는 경로는 각각 어떻게 다른가?
A. Mono(JIT)는 개별 메소드 실행 시점에 기계어가 되고, IL2CPP(AOT)는 빌드시점에 이미 선택한 플랫폼의 네이티브 코드가 된다.
Q5) 프로세스가 OS로부터 부여받는 가상 주소 공간은 무엇이며, 왜 프로세스마다 독립적인가?
A. OS의 물리 메모리는 각 프로세스로부터 감추어져있고, 이 일부를 추상화하여 제공한 것이 가상 주소 공간이다. 가상 주소 공간으로부터 실제 물리 메모리의 접근이 불가능하므로 각 프로세스의 메모리 공간이 독립적이다.
Q6) 스택과 힙이 구분되는 이유를 설명하라. 둘의 할당/해제 방식은 어떻게 다른가?
A. 스택은 포인터를 한 칸 이동하는 것으로 할당과 해제가 구현된다. 스택 프레임을 벗어나면 그 안의 스택 객체는 곧바로 해제된다. 힙의 할당은 bump alloc의 경우 동일하게 빠르지만, 해제는 명시적으로 또는 GC에 의해 진행된다. 둘이 구분되는 이유는 그 장단점에 있다. 객체의 수명주기를 직접 관리하고 싶을 때 힙을 쓰고, 일시적인 할당과 해제에는 스택을 쓴다.
Q7) C#에서 참조 타입의 인스턴스는 힙에 할당되고, 그 참조(변수)는 스택에 놓인다. 이때 "참조"가 담고 있는 것은 무엇인가?
A. Wrapper이다. 힙에 할당되는 인스턴스는 Native 객체이고, 참조는 그 주소를 가리키는 래퍼를 의미한다.
F. Unity의 Wrapper 패턴과 순수 C# 참조를 혼동했다. 이 질문은 2장 범위로, UnityEngine.Object가 아닌 순수 C# 참조 의미론을 묻고 있다.
E. Managed Heap에 있는 해당 객체의 주소(또는 GC가 관리하는 핸들)다. Wrapper 패턴은 UnityEngine.Object 계열에만 해당하는 6~7장의 개념이다.
Q8) Managed Heap에서 객체가 이동(compaction)될 때 참조가 무효화되지 않는 이유는 무엇인가?
A. Moving GC에 해당하는 이야기이다. 이 GC는 객체를 이동할 때 Native 객체를 가리키는 포인터를 새로운 주소로 갱신한다. 스택 맵과 힙 메타데이터가 있는 Moving GC만의 능력이다.
F. "Native 객체를 가리키는 포인터를 갱신한다"가 틀렸다. Compaction은 Managed Heap 안에서 Managed Object를 이동시키는 과정이며, Native Object는 관여하지 않는다.
E. GC가 스택 맵과 힙 메타데이터를 통해 해당 Managed Object를 가리키는 모든 Managed 참조의 위치를 파악하고 있으므로, 객체 이동 후 그 참조들을 새 주소로 갱신할 수 있다.
Q9) Managed의 정의를 한 문장으로 서술하라. "누가" "무엇을" 관리하는 것인가?
A. CLR이 GC를 통해 Managed Heap 메모리를 관리한다.
Q10) Unmanaged Memory를 사용하는 주체를 두 가지 이상 들고, 각각이 왜 CLR의 관리 범위 밖에 있는지 설명하라.
A. OS 등의 플랫폼 API는 해당 플랫폼에 맞는 Native 언어로 구현된다. C나 Cpp로 구현된 라이브러리 또한 CLR 환경 없이 구동하도록 설계되었다. 이들은 GC에 의해 관리되지 않으므로 Unmanaged Memory를 사용한다.
Q11) C#에서 FileStream 객체를 GC가 수거하면 파일 핸들도 함께 해제되는가? 그 이유는 무엇인가?
A. 파일 핸들은 명시적인 해제가 필요하다. FileStream 객체는 OS의 커널로부터 요청한 객체이므로, 해제할 때에도 GC가 아닌 커널로의 요청을 보내야하기 때문이다. 수명주기 자체를 커널이 관리한다.
Q12) Managed Object와 Unmanaged 자원을 동시에 사용하는 C# 객체가 IDisposable을 구현해야 하는 이유를 설명하라.
A. 사실 꼭 구현할 필요는 없다. 단지 메모리 누수를 예방하기 위한 Fool Proof/Fail Safe 패턴의 일종일 뿐이다. 이 인터페이스를 구현하여 using문에서 사용할 수 있다는 장점은 있다.
F. "꼭 구현할 필요는 없다"는 질문의 의도를 벗어났다. 질문은 IDisposable이 왜 필요한가를 묻고 있다.
E. GC는 Managed Heap만 관리하므로 Unmanaged 자원(파일 핸들, 네이티브 메모리 등)을 자동으로 해제할 수 없다. 결정적(deterministic) 시점에 명시적으로 해제할 수단이 필요하고, IDisposable이 .NET에서 그 표준 계약이다.
Q13) CLR이 수행하는 역할을 세 가지 이상 열거하라.
A. 타입 안정성 검사, GC 메모리 관리, interop
Q14) GC가 객체의 생존 여부를 판단하는 기준은 무엇인가? "참조 카운팅"과 어떻게 다른가?
A. Mark가 있다면 생존, 없으면 사망처리. 참조 카운팅과는 다르게 Mark는 boolean 1비트 값이다.
F. 비교 기준이 자료구조 크기(1비트 vs 카운트)에 머물러 있다. 근본적 차이는 알고리즘이다.
E. 참조 카운팅은 각 객체가 자신을 가리키는 참조 수를 추적하여 0이 되면 제거하며, 순환 참조를 감지하지 못한다. Mark-and-Sweep은 GC Root로부터 참조 그래프를 추적하여 도달 가능한(reachable) 객체만 생존으로 판정한다. 순환 참조가 있더라도 Root에서 도달 불가능하면 수거된다.
Q15) Unity가 사용해 온 Boehm GC의 특성과 한계를 설명하라. Incremental GC는 어떤 문제를 완화하는가?
A. Non-moving이라 Compaction이 안 된다. GC spike 문제가 있다. Incremental GC는 Mark 단계를 여러 프레임에 나누어서 하므로 spike를 줄인다.
Q16) GC가 관리하는 메모리의 범위를 명확히 구분하라. Native Heap에 할당된 C++ 객체가 GC의 수거 대상이 되는가?
A. GC가 관리하는 메모리는 Managed Heap. 당연히 Native Heap의 그 어떤 객체도 GC의 관리 대상이 아니다.
Q17) Native Code의 정의를 서술하라. IL2CPP를 통해 변환된 C# 코드의 최종 산출물은 Native Code인가?
A. 플랫폼 위에서 직접 호환되고 실행가능한 코드. IL2CPP의 최종 산출물은 Native Code가 맞다. 다만 중간의 Cpp까지는 아니다. AOT를 거친 뒤에야 Native가 된다.
Q18) Native Object가 "Native"라고 불리는 이유는 무엇인가? Managed Object와의 차이를 메모리 위치와 생명주기 관리 주체의 관점에서 설명하라.
A. 메모리 상의 차이는 각각 Native, Managed Heap에 따로 존재한다는 점, 생명주기 관리 주체는 각각 Native Code와 GC로 다르다. Native라 불리는 이유는, Native Memory에 존재하기 때문이다.
Q19) IL2CPP 빌드에서 C# 코드는 최종적으로 기계어가 된다. 그렇다면 IL2CPP 빌드 결과물에서 실행되는 C# 로직은 "Native Code"라고 할 수 있는가? 그럼에도 불구하고 해당 코드가 생성한 객체가 여전히 Managed Object인 이유는 무엇인가?
A. C#로직도 결국 기계어로 변환되고, 이는 즉 Native Code이다. 그러나 이 코드가 생성한 객체는 Managed Heap에 할당되고 libil2cpp라는 런타임이 관리한다. 따라서 Managed Object이다.
Q20) 하나의 Unity 프로세스 안에서 Managed Heap과 Native Heap은 같은 가상 주소 공간에 존재하는가, 별도의 주소 공간에 존재하는가?
A. 같은 가상 주소 공간에 존재한다.
Q21) Interop이 해결해야 하는 세 가지 핵심 문제(호출 규약, 데이터 표현, 생명주기)를 각각 한 문장으로 설명하라.
A. 함수의 인자는 어떤 순서로 넣을 것인가? Cpp과 C#의 non-blittable 타입 객체는 어떻게 호환시킬 것인가? 두 객체의 생명주기를 어떻게 동기화 또는 호환할 것인가?
Q22) P/Invoke와 Internal Call의 차이를 설명하라. Unity 엔진이 Internal Call을 사용하는 이유는 무엇인가?
A. PInvoke는 외부 라이브러리의 함수 호출, Internal Call은 함께 링크된 동일한 프로세스의 함수 호출. 유니티는 그 엔진 자체에 Native 구현이 포함되어있으니, 굳이 외부 라이브러리에서 가져올 필요가 없다(당연히 비용도 비교적 적다).
Q23) C#의 string을 C++ 함수에 전달할 때 Marshaling이 필요한 이유를 설명하라. int를 전달할 때도 동일한 비용이 드는가?
A. nonblittable 타입은 두 세계에서 다른 자료구조를 가지므로, 이를 변환하는 마샬링이 필요하다. int와 같은 원시타입은 동일한 자료구조이므로 변환이 불필요하다.
Q24) Wrapper 패턴에서 C# 객체가 Native Object를 가리키기 위해 사용하는 수단은 무엇이며, 그것은 어떤 타입인가?
A. IntPtr 필드. 이는 정수로 표현된 포인터다.
Q25) Unity의 C++ 엔진 계층이 담당하는 역할을 다섯 가지 이상 열거하라.
A. 렌더링 파이프라인, 물리 시뮬레이션, 리소스 관리, 생명주기 관리, 프로파일러
F. 씬 그래프 관리와 메인 루프가 빠져 있고, "프로파일러"는 7장에서 다룬 핵심 역할이 아니다.
E. 렌더링 파이프라인, 물리 시뮬레이션, 씬 그래프 관리, 에셋 파이프라인, 메인 루프.
Q26) C#에서 transform.position을 읽을 때, 위치 데이터는 어디에 저장되어 있으며 어떤 메커니즘을 통해 C#으로 전달되는가?
A. Native에 저장됨. C#이 getter 호출하면 그건 icall을 통해 Cpp의 함수 호출함.
Q27) MonoBehaviour.Update를 호출하는 주체는 누구인가? 호출 방향은 어느 쪽에서 어느 쪽인가?
A. Cpp에서 C#방향으로 호출하고 임베디드 런타임 라이브러리가 mono_runtime_invoke와 같은 메소드로 호출함.
Q28) new List ()와 new GameObject()의 결과를 비교하라. 각각 어느 메모리 영역에 어떤 객체가 생성되는가?
A. 리스트는 관리 힙, GO는 네이티브 힙에 객체가 생성되는 동시에 관리 힙에는 그 래퍼 객체가 생성됨.
Q29) gameObject.name을 루프에서 반복 호출하는 것이 순수 C# 필드 접근보다 비용이 큰 이유를 두 가지 관점에서 설명하라.
A. 하나는 icall이 매번 발생한다는 점. 둘은 string 마샬링 비용.
Q30) "CPU는 기계어를 실행한다"(1장)는 사실과 "CLR이 C# 객체를 관리한다"(4장)는 사실은 모순되지 않는가? IL2CPP 환경에서 이 둘이 어떻게 양립하는지 설명하라.
A. 기계어로 변환된 것은 libil2cpp 임베디드 런타임 라이브러리를 포함한 모든 코드 조각들이다. 이들이 기계어라고 해서 그 로직도 Native가 되지는 않는다. 여전히 런타임은 C# 객체를 Managed Heap에서 관리한다.
Q31) Managed Heap의 객체(3장)와 Native Heap의 객체(5장)가 서로를 참조해야 할 때 발생하는 문제를 설명하고, 이를 해결하는 구조(6장)를 서술하라.
A. 둘을 가리키는 참조가 무의미해지는 dangling pointer 문제에 주의해야한다. Moving GC는 Managed를 옮길 때 그 객체에 대한 참조도 갱신하고, 스택 맵과 타입 메타데이터가 없는 non-Moving GC는 아예 이동을 안 한다. 절충적 GC도 있는데, 이들은 확신할 수 있는 것만 이동한다.
F. GC 종류를 나열했지만 6장의 해결 구조(Wrapper 패턴)를 서술하지 않았다.
E. Managed 객체가 GC에 의해 이동되면 Native 쪽이 들고 있던 주소가 무효화되고, Native 객체가 해제되면 Managed 쪽의 주소가 dangling pointer가 된다. 6장의 해결 구조는 Wrapper 패턴이다. Managed 쪽은 IntPtr(값 타입)로 Native Object의 주소를 보유하고, 모든 경계 간 호출을 Internal Call로 중개한다.
Q32) Unity에서 MonoBehaviour에 선언한 List 필드는 Managed Heap에 존재하고, 그 MonoBehaviour가 부착된 GameObject는 Native Heap에 핵심 데이터를 가진다. 이 두 사실이 동시에 성립할 수 있는 구조적 이유를 설명하라.
A. List는 C#의 System.Collection.Generic인가 거기 안에 있으니 Cpp에 그 데이터가 있을 필요가 없다. 순수 C# 로직으로 해결된다. Native 객체쪽에서는 이 List에 대해 모르고 알 필요도 없다.
F. 직관은 맞으나 "구조적 이유"를 설명하지 않았다.
E. MonoBehaviour는 UnityEngine.Object의 하위 타입이므로 이중 객체 구조를 가진다. C# 인스턴스(Managed Heap)와 C++ Native Object(Native Heap)가 IntPtr로 연결된 별개의 객체로 존재한다. List 은 C# 인스턴스의 필드로 Managed Heap에 속하고, GameObject의 핵심 데이터는 Native Object에 속한다. 이 분리가 가능한 구조적 이유가 이중 객체 구조다.
Q33) C++ 메인 루프(7장)가 C# Update를 호출하고, Update 안에서 transform.position에 접근하는 전체 흐름을 1장의 "실행"부터 6장의 "Interop"까지의 개념을 사용하여 설명하라.
A. Cpp가 실행되고 CLR을 초기화한다. CLR의 api는 바인딩 생성기를 통해 MonoBehavior 각각의 래퍼 클래스를 C#측에 생성하므로, 그 모든 MonoBehavior의 구체 Native 객체를 Cpp에서 들고 있다. 이제 그들을 순회하며 Update를 호출하는데, 여기서도 C#측 메소드를 호출해야하므로 런타임 api의 도움을 받는다. Update 메소드가 실행되고, 그 안의 transform.position에 접근할 때, transform이라는 래퍼가 가진 positon이라는 getter에 연결된 icall이 호출되고, 다시 cpp는 transform native 객체의 position value를 C#측에 반환한다. Interop은 Update 호출에서 한 번, position의 value를 읽을 때 한 번 일어나 총 두 번 발생했다.
F. "CLR의 api는 바인딩 생성기를 통해 MonoBehavior 각각의 래퍼 클래스를 C#측에 생성하므로"가 두 가지 면에서 틀렸다. 첫째, 바인딩 생성기는 CLR의 API가 아니라 Unity의 빌드 타임 도구다. 둘째, 바인딩 생성기가 생성하는 것은 엔진 타입(GameObject, Transform 등)의 C# Wrapper이지, 개발자가 작성한 각각의 MonoBehaviour에 대한 Wrapper가 아니다.
E. C엔진이 기동하며 런타임(Mono 또는 libil2cpp)을 초기화한다. 바인딩 생성기는 빌드 타임에 엔진 타입(GameObject, Transform 등)의 C# Wrapper를 생성해 둔 상태다. C 메인 루프가 Update 대상 MonoBehaviour의 네이티브 객체를 순회하며, 런타임 API(mono_runtime_invoke 등)를 통해 C# Update를 호출한다(C→ C#). Update 안에서 transform.position에 접근하면, Transform Wrapper의 getter에 연결된 Internal Call이 발생하여 C 쪽 네이티브 Transform의 위치 데이터를 읽어 반환한다(C# → C++). 경계를 넘는 Interop이 두 번 발생한다.