Search

리플렉션

class
구조
상태
완료
날짜
목차

리플렉션

리플렉션은 프로그램이 실행 중에 자기 자신을 거울 보듯 살펴볼 수 있게 해주는 기능이라고 생각할 수 있습니다.
마치 게임에서 캐릭터가 자기 인벤토리를 확인하고, 무슨 아이템을 가지고 있는지, 어떤 능력을 사용할 수 있는지를 확인하는 것처럼, 프로그램도 리플렉션을 통해 자신이 가지고 있는 코드의 부품들, 예를 들어 클래스, 메서드, 변수 등을 살펴보고 사용할 수 있게 됩니다.

간단한 예시

리플렉션을 이용해 간단하게 게임의 아이템클래스 중 하나의 인스턴스를 만들고, 그 아이템의 메서드를 호출하는 예를 들어보겠습니다.
using System; using System.Reflection; public class HealthPotion { public void Use() { Debug.Log("체력 포션을 사용했습니다. 체력이 회복됩니다!"); } } public class PublicHelperUse { public static void UseItem { // 아이템의 이름을 문자열로 받음 var itemName = "HealthPotion"; // 문자열로부터 아이템 클래스의 타입을 얻음 var itemType = Type.GetType(itemName); // 해당 타입의 인스턴스(여기서는 HealthPotion 객체)를 생성 var itemInstance = Activator.CreateInstance(itemType); // 'Use' 메서드 정보를 얻음 var useMethod = itemType.GetMethod("Use"); // 아이템 인스턴스에 대해 'Use' 메서드를 호출 useMethod.Invoke(itemInstance, null); } }
C#
복사

리플렉션과 메타데이터

리플렉션과 메타데이터의 관계

리플렉션은 메타데이터를 사용하여 프로그램의 구조를 조회하고 조작합니다.
즉, 리플렉션을 통해 메타데이터에 저장된 정보를 읽어, 특정 클래스의 인스턴스를 생성하거나, 메서드를 호출하거나, 필드의 값을 변경할 수 있습니다.
이 모든 작업은 메타데이터에 기록된 정보를 기반으로 수행됩니다.

메타데이터란?

메타데이터는 데이터에 대한 데이터로, 다른 데이터를 설명하는 정보입니다.
메타데이터는 프로그램이 컴파일 될 때 생성되며, 클래스, 메서드, 변수 등 프로그램의 구조와 관련된 정보를 담고 있습니다.
이 정보에는 타입 이름, 타입이 가지고 있는 메서드와 프로퍼티, 메서드의 매개변수 등이 포함됩니다.
예를 들어, 어떤 클래스가 있을 때, 이 클래스의 메타데이터는 클래스의 이름, 클래스가 상속하는 부모 클래스, 클래스가 구현하는 인터페이스, 클래스에 정의된 메서드와 필드 등의 정보를 포함합니다. 이 메타데이터는 리플렉션을 통해 런타임에 조회할 수 있습니다.

리플렉션의 실제 이용 사례

CSV 데이터를 읽어서 Monster 인스턴스 생성
CSV 파일에서 데이터를 읽고, 각 라인에 대해 Monster 인스턴스를 생성한 후 속성 값을 설정하는 예시 코드입니다.
using System; using System.Collections.Generic; using System.IO; using System.Reflection; public class Monster { public string Type { get; set; } public int Health { get; set; } public int Attack { get; set; } public Monster() { } } public static class PublicHelper { static void ReadMonster() { // CSV 파일 경로 var csvFilePath = "monsters.csv"; // Monster 인스턴스를 저장할 리스트 var monsters = LoadCsvData<Monster>(csvFilePath); } static List<T> LoadCsvData<T>(string filePath) where T : new() { var resultList = new List<T>(); var lines = File.ReadAllLines(filePath); // 첫 번째 줄은 헤더로, 속성 이름을 포함 var headers = lines[0].Split(','); // 데이터 라인 처리 for (int i = 1; i < lines.Length; i++) { string[] values = lines[i].Split(','); T item = new T(); for (int j = 0; j < headers.Length; j++) { PropertyInfo propertyInfo = typeof(T).GetProperty(headers[j]); if (propertyInfo != null) { // 속성 타입에 따라 적절한 변환을 수행 object value = Convert.ChangeType(values[j], propertyInfo.PropertyType); propertyInfo.SetValue(item, value, null); } } resultList.Add(item); } return resultList; } }
C#
복사
PlayGuideManager Ediotr 사용
이 부분은 제가 직접 Reflection을 활용한 방법입니다.
아래와 같이, 플레이가이드의 스텝 클래스들은 자동으로 플레이가이드 에디터에 추가되며, 해당 정보를 읽어 스크립터블 오브젝트를 생성하는 부분입니다.
private void CreateGUI() { rootVisualElement.styleSheets.Add(AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Scripts/PlayGuide/Editor/PlayGuide_Properties_Style.uss")); var titleContainer = new VisualElement(); var titleLabel = new Label(Title); titleContainer.Add(titleLabel); rootVisualElement.Add(titleContainer); _playGuideStepTypeList = Assembly .GetAssembly(typeof(PlayGuideStep)) .GetTypes() .Where(t => t.IsSubclassOf(typeof(PlayGuideStep))).ToList(); _addGuideStepListView = new ListView(_playGuideStepTypeList, 20f, MakeItem, BindItem); _addGuideStepListView.onSelectionChange -= OnSelectStepChange; _addGuideStepListView.onSelectionChange += OnSelectStepChange; _addGuideStepListView.AddToClassList("addPlayGuideStepListView-listView"); rootVisualElement.Add(_addGuideStepListView); var buttonContainer = new VisualElement(); var AgreeButton = new Button(OnAgreeAction); AgreeButton.text = "추가"; AgreeButton.AddToClassList("YESNO-button"); var DisAgreeButton = new Button(this.Close); DisAgreeButton.text = "취소"; DisAgreeButton.AddToClassList("YESNO-button"); buttonContainer.Add(AgreeButton); buttonContainer.Add(DisAgreeButton); buttonContainer.AddToClassList("horizontal-container"); rootVisualElement.Add(buttonContainer); }
C#
복사

리플렉션의 주의 사항

성능 저하

성능 비용 : 리플렉션은 일반적인 코드 실행보다 더 많은 성능 비용을 소모합니다. 특히, 게임 루프 내부나 빈번히 호출되는 메서드에서 리플렉션을 사용하는 경우, 게임의 프레임 속도에 영향을 줄 수 있습니다. 가능한 한 초기화 단계에서 한 번만 사용하거나, 캐싱 기법을 사용하여 성능 저하를 최소화해야 합니다.
리플렉션의 성능이 매우 느리다는 것을 명확히 인식하는 것이 중요합니다. 예를 들어, GetType()과 같은 작업을 자주 수행하면 낮은 성능 비용이 들지만, 이것도 리플렉션입니다. 그러나 클래스 인스턴스를 생성하거나 메소드를 정적으로 생성하는 것은 성능 차이가 10배에서 최대 450배까지 나게 됩니다. 실제 분석 과정에서 리플렉션의 성능에 얼마나 영향을 주는지 확인하는 것은 어려울 수 있습니다. 이와 관련하여 클래스를 생성할 때 GC.Alloc()이 발생하는 부분과 혼동하는 경우가 많습니다. 이 부분은 클래스를 생성할 때, 리플렉션의 사용과 무관하게 발생합니다. 제 개인적인 의견으로는, 매 프레임 호출하는 곳이 아니라면 리플렉션을 사용해도 괜찮다고 생각합니다. 무작정 사용하라는 말은 아닙니다. 예를 들어, Json과 같은 데이터를 처리해야 할 때 리플렉션이 효율적일 수 있습니다. 이런 경우에는, 리플렉션을 강제로 사용하지 않고 대응하는 것보다 리플렉션을 사용하는 것이 더 좋을 수 있습니다.
높은 성능을 위해 초기에 자주 측정하라. 2부
높은 성능을 위해 초기에 자주 측정하라. 2부 지난 칼럼에서 필자는 고성능 프로그램을 안정적으로 작성하려면 사용할 개별 구성 요소의 성능을 디자인 프로세스의 초기에 파악해야 한다는 점을 강조한 바 있습니다. 이러한 성능 파악을 위해서는 성능 데이터가 필요하므로 성능 측정은 디자인 프로세스의 필수 요소라 할 수 있습니다.또한 현명한 디자인 결정을 내리는 데 필요한 데이터를 신속히 수집할 수 있도록 새로운 벤치마크 항목을 간편하게 만들 수 있는 MeasureIt이라는 도구도 소개했습니다. MeasureIt 같은 도구가 제공하는 수치 자체도 매우 중요하지만 이러한 수치가 의미하는 바를 일반적으로 해석하는 능력을 기르는 것도 중요합니다. 이러한 능력이 있으면 성능을 실제로 측정하기 전에 미리 예측할 수 있습니다. 여기서 다루고자 하는 것도 바로 이러한 부분입니다. MeasureIt 요약 MeasureIt 도구를 아직 다운로드하지 않았다면 지금 바로 다운로드하시기 바랍니다. 이 도구는 MSDN Magazine 웹 사이트에 있는 이 칼럼의 코드 다운로드에 있으며, EXE 파일 하나만 다운로드하면 됩니다. 이 도구를 실행하면 일련의 벤치마크 실행 결과를 표시하는 웹 페이지가 생성됩니다. 설치 후 다음 명령을 실행하면 추가 설명서에 액세스할 수 있습니다. measureIt /usersGuide MeasureIt과 함께 제공되는 소스 코드는 /edit 한정자를 사용하여 간편하게 압축을 풀 수 있습니다. 따라서 코드 한두 줄만 작성하여 적절한 시점에 제공하면 손쉽게 새로운 벤치마크 항목을 추가할 수 있습니다. MeasureIt 벤치마크 항목은 여러 가지 성능 영역과 관련이 있으며, 도구를 실행할 때 명령줄에서 항목을 지정할 수 있습니다. MeasureIt은 명령줄 매개 변수를 사용하지 않는 경우 기본적으로 광범위한 기본 Microsoft .NET Framework 런타임 작업에 해당하는 50여 개의 벤치마크를 실행합니다. 그림 1에는 그 중 일부에 해당하는 샘플 출력이 나와 있습니다. Figure 1 샘플 벤치마크 이름 중간값 평균 표준편차 최소값 최대값 샘플 NOTHING [count=1,000] 0.000 0.037 0.110 0.000 0.366 10 MethodCalls: EmptyStaticFunction() [count=1000 scale=10.0] 1.000 1.103 0.496 0.857 2.577 10 ObjectOps: new Class() [count=1000 scale=10.0] 5.060 10.223 13.927 3.340 51.215 10 ObjectOps: new FinalizableClass() [count=1000 scale=10.0] 78.552 155.408 168.595 64.997 629.243 10 ObjectOps: (Class) Activator.CreateInstance(classType)] 102.510 102.949 4.076 96.876 109.819 10 Arrays: localIntPtr[i] = 1 [count=1,000 scale=10.0] 0.713 0.664 0.076 0.574 0.773 10 Arrays: string[i] = aString [count=1,000 scale=10.0] 3.402 3.405 0.012 3.397 3.442 10 Delegates: aInstanceDelegate() [count=1,000 scale=10.0] 1.235 1.205 0.111 1.094 1.475 10 MethodReflection: Method.Invoke EmptyStaticFunction() 472.283 472.744 5.409 466.291 482.094 10 P/Invoke: FullTrustCall() [count=1,000] 6.184 6.254 0.793 5.469 7.599 10 P/Invoke: 10 FullTrustCall() (10 call average) 2.669 2.688 0.061 2.665 2.870 10 P/Invoke: 1 PartialTrustCall [count=1,000] 27.806 30.440 8.735 26.343 56.582 10 MeasureIt은 각 벤치마크를 10회씩 실행하여 그 결과를 바탕으로 통계를 산출합니다. 보고된 값은 빈 메서드를 한 번 호출하는 데 소요되는 시간을 한 단위 시간으로 하도록 조정됩니다. 예를 들어 그림 1에서 작은 개체 하나를 할당하는 데 소요되는 시간의 중간값이 5.06이므로 작은 개체 하나를 할당하는 데에는 보통 메서드를 호출하는 데 소요되는 시간의 5배 이상이 소요된다는 것을 알 수 있습니다. 그러나 이것이 전부는 아닙니다. 개체 할당에 소요되는 최대 시간이 51단위 이상이므로 평균 시간보다 훨씬 더 많이 걸리는 경우도 있다는 것을 알 수 있습니다. 사실 벤치마크를 실행할 때 힙의 전체 가비지 수집이 이루어지는 경우에는 메서드에서 여기에 보고된 최대값보다 훨씬 오랜 시간이 소요될 수도 있습니다. 어쨌든 MeasureIt 도구가 얼마나 유용한지는 파악했을 것입니다. 별다른 수고를 들이지 않고도 작은 개체를 할당하는 데 대략 얼마만큼의 리소스가 소모되는지 즉시 파악할 수 있으니까요. 뿐만 아니라 이 도구는 여러 샘플을 수집하여 통계를 산출하기 때문에 개체 할당과 같은 일부 작업의 성능이 매우 다양하게 나타날 수 있다는 사실도 알 수 있습니다. 이렇게 최소값, 최대값, 표준 편차 정보가 제공되므로 측정 결과가 신뢰할만 한지 여부를 확인할 수 있습니다. 간단한 조사 그림 1을 통해 .NET Framework에서 작은 개체를 할당하는 데 소요되는 시간을 개략적으로 파악할 수 있습니다. 그러나 좀 더 자세한 내용도 쉽게 추정할 수 있습니다. 예를 들어 종료자(C#에서 ~ClassName()으로 선언됨)가 있는 개체는 종료자가 없는 개체에 비해 시간이 10배 이상 소요된다는 사실도 알 수 있습니다. 게다가 종료자는 상속되기 때문에 하위 클래스의 인스턴스를 할당할 때에도 비슷한 성능 저하 현상이 나타납니다. 따라서 일반적으로 인스턴스가 많은 클래스에는 가능한 한 종료자를 사용하지 않는 것이 좋다는 결론을 얻게 됩니다. Activator.CreateInstance에서와 같이 리플렉션을 사용하여 개체를 할당하면 일반적인 방법으로 개체를 할당할 때보다 10배 이상 시간이 소요된다는 사실도 알 수 있습니다. 따라서 리플렉션 API에 대해서는 정적 API에 비해 시간이 많이 소요되므로 성능이 중요한 경로에는 사용하지 않는 것이 좋다는 일반적인 원칙을 적용할 수 있습니다. 뿐만 아니라 MethodInfo.Invoke에서와 같이 리플렉션을 사용하여 메서드를 호출하는 경우 메서드를 정적으로 호출할 때보다 450배 이상 느리다는 사실도 데이터를 통해 알 수 있습니다. 일반적인 방법으로 메서드를 호출하는 데 드는 시간이 매우 짧으므로 특히 차이가 크게 납니다. 마찬가지로 배열 액세스는 메서드 호출보다도 시간이 적게 소요되지만 string[]의 경우와 같이 개체 참조의 배열에 요소를 설정하는 데에는 일반적인 배열 설정보다 4배 이상 시간이 걸린다는 결과도 얻을 수 있습니다. 또한 그림 1에서 대리자(메서드에 대한 포인터)를 호출하는 속도는 컴파일 타임에 대상이 알려진 메서드를 호출하는 것보다 20%밖에 느리지 않다는 사실을 알 수 있습니다. 마지막으로, 보안 검사를 실행하지 않는 경우 비관리 코드(P/Invoke)를 호출하는 데 시간이 크게 많이 걸리지는 않으며(일반 메서드 호출의 6배) 같은 메서드에서 여러 번 호출될 때 평균 소요 시간은 더 단축(2.6배)되는 것으로 나타났습니다. 그러나 보안 검사를 사용하는 경우(기본값)에는 성능이 크게 저하됩니다(일반 메서드 호출의 27 ~ 30배). 이렇게 간단하게 기본 제공 MeasureIt 벤치마크에서 여러 가지 유용한 사실을 파악할 수 있습니다. 게다가 MeasureIt 다운로드에는 소스 코드가 포함되어 있으므로 데이터를 분석하기 위해 필요한 세부 정보를 모두 얻을 수도 있습니다. 예를 들어 P/Invoke 호출의 보안 검사를 중지하는 자세한 방법이나 처음부터 네이티브 코드를 호출하는 방법까지도 알 수 있습니다. 다음 명령을 사용하여 소스의 압축을 풀기만 하면 됩니다. measureIt /edit 그리고 P/Invoke 벤치마크를 찾습니다. 그러면 필요한 코드를 조사하고 디버그하고 용도에 맞게 수정할 수도 있습니다. 데이터 유효성 검사 MeasureIt으로 .NET 런타임에서 수행되는 일부 기본 작업의 소요 시간을 신속하게 알 수는 있지만 이러한 수치가 왜 이렇게 나타나는지는 확인할 수 없습니다. 따라서 측정하려고 한 항목이 아니라 엉뚱한 항목을 측정하게 될 수도 있습니다. 벤치마크(특히 마이크로 벤치마크의 경우)를 잘못 만들기가 얼마나 쉬운지는 지난 달 칼럼에서 이미 설명했습니다. 그리고 성능 측정 결과를 신뢰하기 전에 먼저 유효성을 검사하는 것이 얼마나 중요한지도 강조한 바 있습니다. 그런데 기본 제공 벤치마크를 통해 얻어진 데이터의 경우에도 유효성 검사는 필요합니다. 유효성 검사에는 .NET 런타임의 몇 가지 내부 기능이 유용하게 사용됩니다. 런타임 내에서 작업이 시스템 명령으로 어떻게 변환되는지를 정확히 알면 여러 가지 작업에 일반적으로 소요되는 시간을 계산할 수 있습니다. 이는 그림 2에 요약되어 있습니다. 하드웨어 자체에서 수행하는 최적화로 인해 명령 개수가 실행 시간을 정확하게 나타내지는 않지만 개략적인 계산에는 충분합니다. 명령 개수에 나타난 명령 실행 시간이 예상보다 훨씬 길면 데이터가 올바르지 않을 가능성이 큽니다. 그림 2의 데이터는 MeasureIt의 정량적 정보를 자세히 제공하는 작업에 대한 정질적 정보로 보는 것이 가장 좋습니다. Figure 2 MeasureIt에서 수집된 .NET 런타임 작업 성능 작업 명령 개수 설명 정수 연산 1 확인되지 않은 간단한 연산이 해당하는 시스템 명령으로 컴파일되며 소요 시간은 시스템 사이클 하나 미만인 경우가 많습니다. 부동 소수점 연산 1 x87 명령(32비트의 경우)으로 컴파일됩니다. 현재 런타임은 벡터화를 수행하거나 새로운 하드웨어 명령(SSE2)을 적극적으로 활용하지 않으므로 일반적으로 부동 소수점 연산이 많은 응용 프로그램에는 뛰어난 비관리 컴파일러가 적합합니다. 인스턴스 필드 가져오기 또는 설정 1-10 대부분의 작업에서 1개 명령을 사용합니다. 그러나 기본 형식이 아니라 개체 참조인 필드를 설정하려면 6 ~ 10개 명령을 사용하는 쓰기 방지 루틴을 사용해야 합니다. 정적 필드 가져오기 1-12 한 AppDomain에서 실행되는 일반적인 JIT(Just-In-Time) 컴파일 코드의 경우에는 명령이 하나만 필요합니다. 그러나 ASP.NET과 같은 호스트에서는 메모리와 JIT 시간을 크게 절약하기 위해 런타임이 모든 AppDomain에서 공유되는 코드를 생성해야 할 수도 있습니다. 이 경우 정적 필드 가져오기에는 약 12개의 명령이 사용됩니다. JIT 컴파일러는 이러한 일반 작업을 루프 밖으로 끌어내 이러한 작업에 걸리는 시간을 어느 정도 단축합니다. Ngen(Native Image Generation)을 통해 미리 컴파일된 코드는 코드 공유가 필요한지 여부에 관계없이 작동하도록 생성되므로 최적의 경우에도 Ngen 생성 코드는 정적 필드를 가져오는 데 6개의 명령을 사용합니다. 비가상 또는 정적 메서드 호출 1 단일 호출 명령으로 컴파일되며 가장 빠른 호출 유형입니다. 가상 메서드 호출 2 C++처럼 발송 테이블을 사용합니다. 간접 호출 유형 중 가장 빠릅니다. 인터페이스 메서드 호출 4-20 인터페이스 메서드는 특정 호출 사이트의 대상이 거의 항상 동일한 경우 매우 빠른 속도의 스텁(총 명령 수 4)을 통해 발송됩니다. 단일 호출 사이트에서 많은 수의 대상으로 발송하는 경우 발송을 위해 해시 테이블 조회를 수행하는 데 10 ~ 20개의 명령이 사용됩니다. 대리자 발송 4-15 단일 대상 메서드가 인스턴스(비정적) 메서드인 경우(일반적인 경우) 4개 명령이 사용되며 다른 간접 발송 유형과 비슷한 속도를 냅니다. 대상이 정적인 경우 인수의 순서를 섞어 전달된 불필요한 "this" 포인터를 제거해야 합니다. 이러한 포인터에는 명령이 몇 개 사용될 수 있습니다. 대리자의 구독자(예: 이벤트)가 여러 개인 경우 발송에 루프가 포함되어 시간이 더 많이 소요되지만 자주 발생하는 시나리오는 아닙니다. 개체 할당 10-1,000+ 대부분의 개체에 대한 "new"의 코드 경로에는 10 ~ 15개 명령이 사용되지만 일부 개체 유형(예: 종료 가능한 개체)의 경우 더 많이 사용될 수도 있습니다. 모든 경우에서 할당 작업은 나중에 추가적으로 많은 시간이 소요됩니다. 여기에는 메모리 정리(크기에 비례)에 걸리는 시간과 개체 수명 주기 동안 실행되는 일부 GC(가비지 수집)에서의 검색에 걸리는 시간 등이 포함됩니다. 수명 주기가 짧은 개체는 수명 주기가 긴 개체에 비해 GC 오버헤드가 훨씬 적게 필요하지만 그래도 많은 시간이 소요되므로 성능이 중요한 코드 경로에서는 할당을 충분히 최소화해야 합니다. 배열 액세스 1-25 일반적으로 배열 액세스는 바운드 검사와 가져오기의 두 명령으로 이루어집니다. 배열의 모든 요소에 대해 반복되는 간단한 루프에서는 JIT 컴파일러가 바운드 검사를 없애 명령을 하나로 만들 수 있습니다. 사실 이러한 최적화의 결과로 배열 액세스 대신 안전하지 않은 포인터 액세스를 사용하게 되면 큰 성능 향상 효과를 얻지 못하는 경우가 많습니다. 설정되는 요소가 개가 필요하므로 코드 경로의 크기가 약 25개 명령으로 커집니다. 캐스팅 4-100+ 개체를 특정 형식으로 캐스팅하는 속도(명령 4개)는 빠르지만 슈퍼클래스로 캐스팅하는 속도는 느리며, 인터페이스로 캐스팅하는 속도는 그보다 더 느립니다. 게다가 배열 캐스팅은 인터페이스로 캐스팅하는 것보다 더 느립니다. C# "is" 또는 "as" 연산자를 사용할 때 많이 발생하는 캐스팅 실패도 느린 편입니다. 잠금 20-1,000+ 잠금을 설정하고 해제하는 작업(System.Monitor.Enter 또는 c# lock 문)에는 최상의 경우에도 시간이 어느 정도 소요됩니다(call-ret의 10 ~ 15배). 잠금 경합이 있는 경우에는 속도가 크게 저하될 수 있습니다. P/Invoke 호출 15-1,000+ 보안 검사를 사용하지 않고 인수 변환이 필요하지 않은 최상의 경우 비관리 코드에서 네이티브 코드를 호출(P/Invoke)하는 데에는 15 ~ 20개의 명령이 사용됩니다. 하지만 문자열이 전송되어 변환이 필요하거나 COM interop이 포함된 경우에는 속도가 훨씬 느립니다. 리플렉션 1,000-10,000+ System.Type.GetType 같은 형식에 대해 간단한 리플렉션 API를 호출하는 데 필요한 명령은 적지만(명령 10개 미만), 메서드 호출, 필드 설정, 개체 생성 등의 다른 용도로 리플렉션 API를 사용하는 경우에는 비리플렉션 방식보다 훨씬 명령이 많이 필요합니다(10 ~ 100배). 따라서 성능이 중요한 코드 경로에는 리플렉션을 사용하지 않는 것이 좋습니다. 제네릭 관계없음 제네릭 형식의 성능은 비제네릭 형식과 비슷할 수 있습니다. 제네릭 형식의 형식 인수가 구조체가 아니라 클래스이면 코드는 형식의 모든 인스턴스에서 공유됩니다. 이러한 공유는 공간을 절약하지만 형식 매개 변수를 사용하는 작업이 예상보다 느려질 수 있습니다. 따라서 성능이 중요한 경로에 제네릭을 사용하는 경우 사전에 성능을 측정해 보아야 합니다. 그림 2의 정보는 MeasureIt에서 제공하는 성능 수치의 유효성을 검사하는 데 유용할 뿐만 아니라 프로그램의 성능과 다양한 디자인 절충안의 비용을 개략적으로 예측하는 데 사용할 수도 있습니다. 예를 들어 관리 코드와 비관리 코드(P/Invoke)의 경계를 넘나드는 횟수가 다른 두 가지 코드 팩터링 디자인의 비용을 절충할 수 있는 방안을 예측할 수 있습니다. 또한 리플렉션 메서드를 사용하지 않을 경우 절약되는 시간이나 스레드를 보호하기 위해 프로그램에 잠금을 추가하는 데 걸리는 시간을 예측할 수도 있습니다. 주의 사항 개인적으로 필자는 MeasureIt을 통해 얻은 데이터가 다양한 성능 관련 결정을 내리는 데 매우 유용하다는 사실을 알게 되었습니다. 하지만 기본 제공 벤치마크는 CPU를 집중적으로 사용하는 작업에 초점을 맞추고 있습니다. 응용 프로그램의 성능이 CPU의 제약을 받지 않는다면 CPU 최적화에 초점을 맞추는 것은 바람직하지 않습니다. CPU가 결정적인 성능 요인으로 작용하지 않는 대표적인 세 가지 시나리오로는 응용 프로그램 응답 시간이 I/O 소요 시간이나 네트워크 지연에 따라 결정되는 경우, 응용 프로그램 응답 시간이 메모리 캐싱에 걸리는 시간에 따라 결정되는 경우, 다중 스레드 응용 프로그램에서 응용 프로그램 응답 시간이 스레드 직렬화 지연의 영향을 받는 경우가 있습니다. 일부 응용 프로그램의 경우 실제 명령 실행 속도보다 느린 경우가 많은 디스크 및 네트워크 I/O를 실행하는 속도에 따라 응용 프로그램 성능이 제약을 받기도 합니다. 이러한 현상은 응용 프로그램이 아직 메모리에 캐시되지 않은 파일을 요청하는 응용 프로그램 시작(콜드 시작) 시에 발생할 수 있습니다. 또한 응용 프로그램에서 메모리를 너무 많이 소모하여 페이지 오류가 많이 발생할 때도 이런 현상이 나타날 수 있습니다. 일반적으로 응용 프로그램의 현재 프로세서 사용률이 100%가 아니라면 이러한 다른 지연 요인이 중요합니다. 이 칼럼은 CPU에 국한된 성능 병목 현상을 파악하는 데 초점을 맞추고 있으므로 여기서 소개하는 대부분의 정보는 이러한 경우와는 관련이 없습니다. 메모리 캐싱에 걸리는 시간에 따라 응답 시간이 결정되는 응용 프로그램도 있습니다. 이러한 현상은 보통 대규모의 메모리 내 데이터 구조가 있을 때 발생합니다. 응용 프로그램의 CPU에서 병목 현상(프로세서 소모율 100%)이 발생한 것처럼 보이지만 사실 CPU는 대부분의 시간을 메모리 하위 시스템을 기다리는 데 허비하고 있는 것입니다. 이 경우 문제를 정확히 진단하려면 메모리 하위 시스템에 대한 통계에 액세스할 수 있는 프로파일러가 필요하지만 대개 많은 메모리 소모량(50MB 이상)과 빈번한 페이지 오류는 메모리 문제 때문입니다. 일반적으로 이러한 문제를 해결하려면 명령을 여러 개 실행하더라도 항목의 크기를 줄이는 디자인 전략을 적용해야 합니다. 다중 프로세서 하드웨어에서 작동하는 다중 스레드 운영 프로그램의 경우 스레드가 코드(한 번에 하나의 스레드로만 액세스할 수 있는 코드)의 중요한 섹션으로 진입하기 위해 대기하면서 지연되는 현상이 많이 나타납니다. 이러한 직렬화 지연은 동시에 실행되는 스레드 수가 증가함에 따라 더욱 심해지는 경향이 있습니다. 이러한 잠금 경합은 CPU에 국한되지 않는 문제(대기 시간이 길고 대기 중인 스레드가 대기 상태인 경우)로 나타날 수도 있고 CPU에 국한된 문제(대기 시간이 매우 짧지만 자주 발생하는 경우)로 나타날 수도 있습니다. 아쉽지만, 바람직한 디자인에 대한 개념을 심어 줄 정도로 CPU에 국한되지 않는 문제를 잘 설명하려면 별도의 칼럼을 한두 개 더 할애해야 할 것입니다. 따라서 여기서는 메모리 소모량도 중요한 요인(특히 대규모 응용 프로그램의 경우)이며 명령 개수에 큰 변화가 없다면 크기가 1MB 이상인 데이터 구조의 크기를 줄여야 한다는 사실만 기억하시기 바랍니다. 그러나 응용 프로그램이 실행 중에 CPU를 100% 소모하고, 페이지 오류는 없거나 적으며, CPU에 의해 성능이 좌우될 때 메모리 소모량이 많지 않고(핫 데이터 구조가 1MB 미만), 공유 데이터를 사용하는 고도로 병렬화된 응용 프로그램이 아닌 경우 성능 문제는 실행되는 명령 수와 관련이 있을 가능성이 큽니다. 따라서 그림 2의 정보와 MeasureIt에서 제공되는 데이터가 성능을 예측하는 데 유용할 것입니다. 일정하지 않은 성능 범위 .NET 런타임 팀은 가능한 한 작업을 최적화하기 위해 노력합니다. 일반적으로는 좋은 일이지만 최적화를 적용할 수 없는 경우도 있기 때문에 성능 범위가 일정하지 않게 되는 결과를 초래합니다. 이미 살펴본 종료 가능한 할당이 좋은 예입니다. 이와 관련한 예를 몇 가지 더 살펴보겠습니다. 먼저, 인터페이스 호출은 특 경우에 맞게 최적화되어 있습니다. 예를 들어 List<개체>를 사용하여 각 요소에 대해 ICompareable.Compare를 호출하는 루틴이 있을 수 있습니다. 이 루틴에 문자열 목록이 전달되면 인터페이스 호출이 항상 String.Compare로 발송되므로 속도가 빠릅니다. 그러나 목록에 여러 가지 형식의 데이터가 들어 있으면 동일한 호출 사이트가 여러 가지 대상에 발송되어야 하므로 동일한 형식의 데이터만 있는 경우보다 성능이 크게 저하됩니다. 둘째로, 개체가 필요한 작업에 int와 같은 기본 형식을 전달해야 하는 경우 해당 값을 개체로 변환해야 합니다. 박싱(boxing)이라고 하는 이 작업에는 개체 할당이 필요하므로 예상보다 시간이 오래 걸립니다. 이 박싱은 컴파일러에 의해 자동으로 삽입되는 경우가 많으므로 이러한 현상이 자주 발생합니다. 셋째로, C#의 가변 인수 params 기능(예: Console.WriteLine)을 사용하는 메서드의 경우 각 호출에 대해 배열을 할당해야 하며 일부 매개 변수에 대해 개체를 박싱(할당)할 수 있습니다. 이는 일반적인 호출에 비해 10배로 시간이 더 소요되는 결과를 낳습니다. 이 외에도 수십 가지 예를 더 들 수 있지만 이만하면 충분하리라 생각합니다. 런타임의 내부 구조에 대해 아는 사람이라면 누구나 이러한 문제를 인지하고 있을 것이며, 열거할 수 있는 예는 무수히 많습니다. 그림 2의 정보와 MeasureIt에서 수집되는 데이터를 개략적인 예측에만 사용해야 하는 것도 바로 이 때문입니다. 우리는 언제든지 잘못된 성능 코드 경로에 빠질 수 있습니다. 따라서 아무리 주의를 기울여도 지나치지 않습니다. 핫 코드 경로가 최적화되지 않았을 가능성이 있더라도 그림 2의 데이터를 통해 추정한 결과만으로 판단해서는 안 되며, 핫 코드 경로를 매우 정확하게 보여 주는 간단한 마이크로 벤치마크를 작성하여 직접 측정해 보아야 합니다. 질문이나 의견이 있으면 다음 전자 메일 주소로 보내시기 바랍니다: clrinout@microsoft.com. 필자소개 Vance Morrison은 Microsoft에서 .NET 런타임 컴파일러 설계자로 일하면서 .NET을 처음 개발할 당시부터 설계에 참여해 왔습니다. Vance는 .NET Intermediate Language 설계를 주도했으며 Just-In-Time 컴파일러 팀을 이끌기도 했습니다.

IL2CPP과의 호환성

IL2CPP 호환성 : IL2CPP는 런타임에 코드를 생성하거나 수정하는 Reflection.Emit과 같은 기능을 지원하지 않습니다.

Reflection.Emit이란?

Reflection.Emit 런타임에 동적으로 새로운 타입을 생성하고, 코드를 컴파일하여 실행할 수 있게 해주는 API입니다. 즉, 이를 사용하면 실행 중인 프로그램이 자기 자신의 코드를 생성하고 수정할 수 있습니다.

IL2CPP에서 Reflection.Emit 사용 불가의 이유

IL2CPP가 중간 언어를 C++ 코드로 변환한 다음 네이티브 코드로 컴파일하는 과정은 전적으로 빌드 타임에 이루어집니다. 즉, 모든 코드는 빌드 시점에 이미 "고정"되어 있으며, 실행 시점에 새로운 코드를 생성하거나 기존 코드를 변경할 수 있는 JIT 컴파일 기능을 지원하지 않습니다. Reflection.Emit은 런타임에 새로운 코드를 생성하고 실행하는 기능을 제공하기 때문에, AOT 컴파일 방식을 사용하는 IL2CPP 환경에서는 사용할 수 없습니다.