[기술 면접] 02.C# & Unity
출처
C# & Unity
Unity 생명주기
- Awake vs Start
- Awake
- 스크립트와 연결된 GameObject가 인스턴스화 되거나 스크립트가 처음 로드될 때 불림
- 해당 오브젝트가 Enable 상태가 아니라고 해도 위 조건에 따라 로드되면 호출됨
- 다른 오브젝트에 대한 참조를 생성할 때 주로 사용하게 됨
- 단 Awake 호출은 무작위
- 무작위성으로 인해 다른 스크립트의 참조를 통해 접근을 하면
NullReferenceException이 발생하게 됨
- Start
- 해당 스크립트 컴포넌트가 활성화 되는 순간 불리게 됨
- 호출 시기는 Awake 보다는 느리게 첫 Update보다는 빠르게 불린다
- Start에서는 참조를 통해 접근하는 작업이 가능함
- OnEnable vs Start
- 둘 다
컴포넌트가 활성화 될 때불린다는 공통점이 있어 묶이게 되지만 Start는 한번, OnEnable은 활성화 될 때마다 불리게 된다는 차이점이 있음 - 초기화 작업에 OnEnable을 활용하면 안됨
- OnEnable은 주로 오브젝트 풀링에 사용하게 되는 함수라고 볼 수 있다
- 둘 다
- Awake
- Update vs FixedUpdate vs LateUpdate
- Update : 프레임 단위로 호출됨
- LateUpdate : Update 호출 뒤 불리게 됨
- FixedUpdate : 고정단위로 불리게 되는 함수
- FixedUpdate의
고정단위를 아는 것이 중요 - 이 고정 단위는 물리 엔진에 의해 결정이 되므로 컴퓨터 성능에 따라 프레임이 다르게 나와 호출 간격이 일정하지 않은 Update와는 달리 일정하게 불리게 됨
- 이러한 이유로 인해서 Rigidbody를 조작할 때는 FixedUpdate를 사용하게 됨
- FixedUpdate의
박싱과 언박싱
- 값 타입
- C#에서 구조체, 열거형 등은 값타입
System.ValueType로부터 항상 상속- 스레드 스택에 할당됨
- 참조 타입
- C#에서는 모든 클래스는 참조 타입이 됨
System.Object로부터 상속- 힙에 저장이 되며 GC가 관리
- 이 힙 메모리의 주소를 가리키는 값은 스택에 저장 됨
- 박싱
- 값 타입을 참조 타입으로 변경
- 언박싱
- 참조 타입을 값 타입으로 변경
박싱, 언박싱 과정을 통해 힙에 가비지가 쌓여 GC에 무리를 줄 수 있음 (기본 작업보다는 비용이 큼)
가급적 제네릭을 활용하는 방식으로 타입 변경을 진행하는 것을 추천!
직렬화와 역직렬화
- 직렬화
- 특정 객체를 바이트 단위로 변경한 뒤 디스크에 저장하거나 네트워크로 보낼 수 있게 만들어 주는 것
- 역직렬화
- 직렬화된 바이트 배열을 원래 객체로 변경하는 과정
- 직렬화를 하는 이유
- 현재 사용하고 있는 데이터에 대해서
영속성을 부여하기 위함 - 영속성은 프로그램을 종료하더라도 사라지지 않는 특성을 의미
- 프로그램 종료 후에도 객체에 관한 정보를 남겨두고 싶을 때 직렬화를 사용
- 주로 플레이어의 데이터처리 등을 의미
- 현재 사용하고 있는 데이터에 대해서
- 유니티에서 직렬화가 되는 것
- public이거나 [SerializeField] 속성이 있어야 함
- static, const, readonly가 아니여야 함
- 직렬화할 수 있는 필드 타입이 있어야 함
- [Serializable] 속성이 있는 비추상, 비일반 커스텀 클래스
- [Serializable] 속성이 있는 커스텀 구조체
UnityEngine.Object에서 파생된 오브젝트에 대한 레퍼런스- int, double, bool 같은 기본 데이터 형식
- 열거형 타입
- Vector2, Vector3, Color 등과 같은 특정 Unity 내장 타입
const와 readonly
- const
- 컴파일 타임 상수 (컴파일 시 변수가 값으로 대체)
- 스택에 위치하게 된다
- 선언과 동시에 값을 할당
- 내장 자료형에만 사용 가능
- 사용자 정의 클래스로는 불가능
- readonly
- 런타임 상수 (런타임 시 상수에 대한 참조)
- 힙에 위치하게 된다
- 생성자에서 초기화 가능 (그 외 변경 불가능)
- 어떤 타입과도 사용 가능
- const 보다는 readonly가 좋다
- 둘 중 가장 큰 차이는 readonly는 상수에 대한 참조 코드를 생성한다. 때문에 const의 값을 변경하게 된다면 이를 사용하는 곳은 전부 재컴파일을 해야함
- readonly의 경우 일부만 리빌드 해도 이를 사용하는 다른 코드들은 참조를 가지고 있으므로 리빌드 없이 올바르게 사용 가능
- const는 스택에 있어 속도가 빠름
- readonly를 사용하면 좋은 케이스
- 특성의 매개변수
- switch/case 문의 레이블
- enum 정의
string
- C#의 string은 immutable(불변) 속성을 가짐
- 멀티스레드 환경을 고려해 여러 스레드들이 엑세스할 때 이들에 대한 동기화 처리를 하는 것 보다 변경이 안되게 읽기전용으로 만드는게 값이 더 싸다고 생각
- string에 대한 조작을 하게 되면 이전의 객체에서 복사 후 연산을 한 뒤 이를 대입해주므로 이전의 객체는 가비지가 되어 이후 GC의 처리를 받게 됩니다
수정이 많이 일어나는 문자열은
StringBuilder등의 클래스를 사용하는 게 좋음- StringBuilder
- 기본적으로 16문자를 담을 수 있는 자리를 잡음
- 할당된 크기 내에서는 어떠한 수정을 해도 가비지가 생성되지 않음
- 미리 할당한 버퍼가 다 찬상태에서 append 하게 되면 새 버퍼를 할당한 뒤 버퍼간 링크를 구성
Garbage Collection
C++과의 대표적인 차이가 GC의 유무. C# 즉 .NET의 GC Mark and Sweep 알고리즘을 사용하고 있습니다.
- GC 동작 과정
- 전역 변수, 현재 함수의 로컬 변수 등을 Root로 잡게 됨
- 이 Root를 기반으로 점점 참조를 타고 다니면서 방문한 것들을 Mark 해줌
- 이러한 Mark 작업이 끝나게 된다면 Sweep 단계로 진입
- Sweep 단계에서는 Mark 되지 않은 것들을 가비지로 판단해 처리하게 됨
Root 부터 사용하는 객체들을 타고 가면서 사용하는 객체들을 mark 하고 이후 mark 되지 않은 객체들을 전부 제거!
- .Net과 Unity의 GC
- 공통
- GC의 알고리즘은
Mark and Sweep을 기반으로 함
- GC의 알고리즘은
- .Net
- 0~2세대까지 총 3개의 세대를 통해서 관리
- Unity
Boehm-Demers-Weiser알고리즘을 통해 GC 작업을 하게됨Mark and Sweep인 것은 같으나 세대 구분이 없고 메모리 정렬도 없음- 점진적 GC 작업을 활용하거나 오브젝트 풀링 기법들을 활용해서 최대한 최적화를 해줘야 할 필요가 있음
- 공통
- 상호 참조 해결법
- C#에서 상호참조 중인 객체 해제에 대해서는 위
Mark and Sweep알고리즘을 설명하면 됨 - 두 객체가 서로 참조 중이라 하더라도 외부에서 참조가 없어 Mark 되지 않는다면 Sweep 단계에서 해제되게 됨
- C#에서 상호참조 중인 객체 해제에 대해서는 위
상호 참조 제거의 과정 예시)
1, 2가 서로를 참조하고 있을 때 1이 더이상 사용되지 않는 상황이 되었다고 가정. 이 경우 GC가 한번 동작하면 2가 1을 참조하고 있으므로 1이 살아있을 수 있게 됨. 물론 2는 직접 사용하고 있으니 당연히 사라지지 않음.
이후 2도 사용을 하지 않게 되면 mark 단계에서 1, 2는 더이상 mark되지 않고 이후 sweep 과정에서 둘 다 사라지게 됨. 이런 식으로 상호 참조중인 객체들이 delete되지 않는걸 피할 수 있게 됨.
delegate & event
delegate : 대표, 위임하다
- delegate (델리게이트)
- C#에서 델리게이트는 함수를 타입화한 것.
- C++에서 함수 포인터와 비슷한 개념
- 파라미터와 리턴 타입을 통해 정의하게 되며 이후 리턴, 파라미터 타입이 같은 메소드들과 호환되어 이 메소드들에 대한 참조를 가질 수 있게 됨
public delegate void VoidAndIntEx(int i);
public class ExampleClass
{
public void DoSomething(VoidAndIntEx exFunc)
{
// 인자로 받은 함수를 호출
exFunc(1);
}
}
C#에서 이런 델리게이트를 활용해 메소드를 담아두는 역할을 하거나 함수 인자로 넘겨 콜백 패턴을 구현하는 등 다양한 곳에 사용하게 됨
- event
- 델리게이트와 비슷한 역할을 함
- 이벤트를 호출할 수 있는 건 해당 이벤트를 가진 클래스만 가능
class ExampleClass
{
// Action에 대한 설명은 아래
public event Action ExampleEvent;
// ...
if(ExampleEvent != null)
{
ExampleEvent();
}
}
- Action, Func, Predicate
- Action
- 함수 파라미터가 T이고 반환값이 void 인 경우
- Func<T, TResult>
- 함수 파라미터가 T이고 반환값이 TResult 인 경우
- Predicate
- 함수 파라미터가 T이고 반환값이 bool 인 경우
- Action
자주 사용하게 되는 델리게이트를 템플릿화 한 것들
- null 조건부 연산자 (?.)
델리게이트나 이벤트를 다루다 보면 null인지 체크를 해줘야 한다.
만일 null인 델리게이트를 호출한다면 NullReferenceException이 발생하게 됨
if(ExampleEvent != null)
{
ExampleEvent();
}
위 코드의 문제점은 2가지 존재
- 멀티 스레드에서 호출할 경우의 문제
- 타자가 많다
if(ExampleEvent != null) // 여기서는 문제가 없었는 데
{
// 여기서 다른 스레드가 구독을 취소해서 null이 됨
ExampleEvent(); // NullReferenceException!
}
이런 복작한 문제는 검출이 어렵기에 아래 ‘복사후 실행’이란 방법을 통해서 예방할 수 있음
var CopiedEvent = ExampleEvent;
if(CopiedEvent != null)
{
// 여기서 ExampleEvent 구독 취소해도 문제 없음
CopiedEvent();
}
다만 이 경우 위에 언급한 ‘타자가 많다’ 문제는 해결할 수 없음. 매 번 복사하는 것도 비효율적
때문에 ?.연산자 활용
?.:?왼쪽의 항이 null이 아니라면.뒷부분을 실행하겠다는 의미
ExampleEvent?.Invoke();
함수의 경우 Invoke를 붙여서 호출 가능. 그리고 이 연산자의 경우 원자적으로(처리 중간에 다른 것들이 끼어들 여지를 주지 않음) 수행이 되는 연산자라서 이 연산 도중 다른 스레드가 개입할 여지가 없어 멀티 스레드 환경에서도 안전하게 돌아감
this
- this 키워드
- 클래스의 현재 인스턴스를 가리키는 키워드
- 매개변수 이름과 클래스 필드가 이름이 같다면 this로 구분할 수 있음
- 클래스 내에서 클래스 필드를 사용할 때는 다 this가 생략된 경우
- 생성자 this()
생성자의 이름은 클래스 이름과 동일해야 하며 void 형식이어야 함
class MyClass
{
int a;
int b;
public MyClass()
{
a = 10;
}
public MyClass(int b)
{
a = 10;
this.b = b;
}
}
다만 이 경우 너무 중복되는 코드들이 양산될 수 있어서 this() 생성자를 사용
this()는 자기 자신의 생성자를 가리키며 이는 생성자에서만 활용이 가능
class MyClass
{
int a;
int b;
public MyClass()
{
a = 10;
}
public MyClass(int b) : this()
{
this.b = b;
}
}
this() 키워드는 생성자를 가리키기에 인자를 줘서 인자를 받는 다른 생성자를 가리킬 수도 있음
- 정적 함수 파라미터의 this
- 정적 함수 파라미터에 하는 this는
확장 메서드를 만드는 데 사용되는 키워드 - 멤버 함수를 호출하듯 함수 호출 가능
- 정적 함수 파라미터에 하는 this는
public static void Shuffle<T>(this IList<T> list);
List<MyClass> exList = new List<MyClass>();
Shuffle(exList);
exList.Shuffle(); // this 덕분에 가능
이런식으로 클래스나 인터페이스를 확장할 수 있음.
다만 기존 클래스의 함수나 동일한 시그니처로 정의하면 호출하지 않게 됨.
=> 확장 메서드는 컴파일 타임에 바인딩되는 데 컴파일러가 함수 호출을 볼 때 인스턴스 함수를 먼저 보게 되고 그 다음 확장 메서드를 보게 됨.
따라서 확장 메서드가 우선순위에서 밀려 호출되지 않음
List와 Dictionary
- 내부 자료구조
| 컨테이너 | 자료구조 |
|---|---|
List<> | 배열 |
SortedSet<> | 레드-블랙 트리 |
HashSet<> | 해시 테이블 |
Dictionary<,> | 해시 테이블 |
SortedList<,> | 배열 |
SortedDictionary<,> | 레드-블랙 트리 |
- List는 C++의 Vector와 유사
- 메모리에는 배열처럼 올라감
- 원소 삽입이 있을 때,
List의 용량을 초과하게 되면 새 공간을 할당해 기존 원소들을 복사해가기에 최대 O(N) 시간 복잡도를 가짐 - Remove의 경우 용량 변화 없음
- SortedSet은 set, HashSet은 unordered_set
- 내부적으로 레드-블랙트리를 사용하는 자료구조는 정렬된 완전 이진트리이므로 삽입, 삭제에 있어 O(logN) 시간이 소요되며 해시를 사용하는 자료구조들은 대부분 O(1)이지만 최악의 경우 O(N)이 될 수 있음
- Dictionary는 unordered_map, SortedDictionary는 map
- 항시 정렬된 상태로 데이터를 저장하는 것 외에는
Dictionary사용이 더 좋음
- 항시 정렬된 상태로 데이터를 저장하는 것 외에는
C# vs C++
- GC
- 두 언어의 가장 큰 차이
- C#의 GC는
Mark and Sweep알고리즘에 기반을 두고 있으므로 힙에 할당된 객체에 대한 포인터 추적을 진행하게 됨. 이로 인해 C++의 생성-소멸 주기보다는 오버헤드가 걸릴 수 있음 - 수 많은 객체들을 생성한 후 나중에 GC로 돌리게 되면 더 많은 시간을 사용하게 되므로 속도가 더 느려짐
- 최근에는 세대 기반 알고리즘을 통해 ‘살아있을 가능성이 있는 객체’는 뒤로 물러나게 해서 GC 시간을 줄이려는 방향으로 발전 중
- 가상머신 (VM - Virtual Machine)
- 초기 구동시에 JIT 컴파일러를 위해 한 번 대기하는 과정이 있음
- C++로 작성된 프로그램은 대부분 32bit 코드로 컴파일 되며 64bit 운영체제에서도 32bit로 돌아감. JIT 컴파일러를 사용하는 C#의 경우 타겟 플랫폼에 대한 이해도를 가질 수 있어서 64bit에 맞춰 컴파일을 하게 됨.
JIT 컴파일러 : Just-In-Time 컴파일러 바이트 코드를 컴퓨터 프로세스(CPU)로 직접 보낼 수 있는 명령어로 바꾸는 프로그램
- C++의 TMP / C#의 리플렉션
- TMP
- 정적인 프로그램
- 컴파일 시간에 계산을 할 수 있다는 장점이 존재 (런타임에는 불가)
- TMP로 팩토리얼을 O(1)로 계산할 수 있지만 이는 컴파일 타임, 사용자 입력에 따른 유동성을 고려하지 않은 것
- 리플렉션
- 런타임에 리플렉션을 통해 수행. trade-off(안정성-성능)가 존재
- TMP
TMP(Template Meta Programming) : 컴파일 도중에 실행되는 템플릿 기반의 프로그램을 작성하는 일
리플렉션 : 어떤 Type에 대한 정보를 가져오거나 접근하는 등의 작업을 런타임에 동적으로 수행할 수 있도록 해주는 기능. 리플렉션을 사용하면 런타임에서 메서드를 호출하거나 필드의 값을 바꾸는 등의 작업을 할 수 있다.