문자열과 텍스트
Reduce the memory allocations that string and text manipulation cause in your project.
읽는 시간 5분최근 업데이트: 한 달 전
문자열과 텍스트 처리는 Unity 프로젝트에서 흔히 발생하는 성능 문제입니다. C#에서 모든 문자열은 변경할 수 없습니다. 문자열을 조작하면 전체 새 문자열이 할당됩니다. 이렇게 되면 상대적으로 많은 리소스를 소모하며, 반복된 문자열 연결로 인해 대규모 문자열, 대규모 데이터 집합 또는 빠른 루프에서 수행될 때 성능 문제가 발생할 수 있습니다.
또한 N개의 문자열 연결은 N-1개의 중간 문자열을 할당해야 하므로, 연쇄적인 연결은 관리되는 메모리에 큰 부담을 줄 수 있습니다.
문자열이 빠르게 반복하는 루프나 매 프레임에서 연결되어야 할 경우 실제 연결 작업을 하기 위해 StringBuilder를 사용합니다. StringBuilder 인스턴스를 재사용하여 불필요한 메모리 할당을 더욱 최소화할 수도 있습니다.
Microsoft는 C#에서 문자열 작업에 대한 베스트 프랙티스 목록을 제공하고 있습니다. MSDN 웹사이트인 msdn.microsoft.com을 참조하십시오.
로케일 강제 변환 및 서수 비교
문자열 관련 코드에서 자주 발생하는 주요 성능 문제 중 하나는 의도치 않게 느린 기본 문자열 API를 사용하는 것입니다. 이러한 API는 비즈니스 애플리케이션용으로 제작되었으며 텍스트에 있는 문자와 관련하여 다양한 문화적 및 언어 규칙의 문자열을 처리하려고 합니다.
예를 들어, 다음의 예제 코드는 미국식 영어 로케일에서 실행된 경우 true를 반환하지만, 다수의 유럽어 로케일에서는 false를 반환합니다.
참고: Unity 5.3 및 5.4의 경우 Unity의 스크립팅 런타임은 항상 미국식 영어(en-US) 로케일에서 실행됩니다.
String.Equals("encyclopedia", “encyclopædia”);
대부분의 Unity 프로젝트에서 이런 방식은 전혀 필요하지 않습니다. C 및 C++ 프로그래머들이 친숙한 방식으로 문자열을 비교하는 서수 비교 방식을 사용하는 것이 대략 열 배는 빠릅니다. 이는 바이트로 표현되는 문자를 고려하지 않고 문자열의 각 바이트를 단순히 순차 비교하는 방식입니다.
서수 문자열 비교 방식으로 전환하는 것은 에 최종 인자로 을 제공하는 것만큼 간단합니다.
String.EqualsStringComparison.OrdinalmyString.Equals(otherString, StringComparison.Ordinal);
비효율적인 빌트인 문자열 API
서수 비교로 전환하는 것 외에도 특정 C# API는 매우 비효율적인 것으로 알려져 있습니다. 이들 중에는 , , 가 있습니다. 은 교체하기 어렵지만 비효율적인 문자열 비교 방식은 아주 쉽게 최적화됩니다.
StringString.FormatString.StartsWithString.EndsWithString.FormatMicrosoft는 현지화를 위해 조정될 필요가 없는 모든 문자열 비교에 을 전달하는 것을 권장하지만, Unity 벤치마크에 의하면 사용자 구현 방식과 비교했을 때 그 영향이 상대적으로 미미한 것으로 나타났습니다.
StringComparison.Ordinal메서드 | 십만 개의 짧은 문자열 처리 시간(ms) |
|---|---|
| 137 |
| 542 |
| 115 |
| 34 |
커스텀 | 4.5 |
커스텀 | 4.5 |
다음 예시와 같이 및 둘 다 핸드 코딩된 간단한 버전으로 교체할 수 있습니다.
String.StartsWithString.EndsWithpublic static bool CustomEndsWith(this string a, string b) { int ap = a.Length - 1; int bp = b.Length - 1; while (ap >= 0 && bp >= 0 && a [ap] == b [bp]) { ap--; bp--; } return (bp < 0); } public static bool CustomStartsWith(this string a, string b) { int aLen = a.Length; int bLen = b.Length; int ap = 0; int bp = 0; while (ap < aLen && bp < bLen && a [ap] == b [bp]) { ap++; bp++; } return (bp == bLen); }
정규 표현식
정규 표현식은 문자열을 매칭하고 조작하기 위한 최상의 방법이지만 성능이 매우 많이 소모될 수 있습니다. 또한 C# 라이브러리의 정규 표현식 구현으로 인해 심지어 단순한 부울 쿼리도 대용량 임시 데이터 구조를 ‘내부적으로’ 할당합니다. 이 일시적인 관리되는 메모리의 불필요한 활동은 초기화 시점을 제외하고는 허용하지 않아야 합니다.
IsMatch정규 표현식이 필요한 경우 정규 표현식을 문자열 파라미터로 허용하는 정적 또는 메서드를 사용하지 않는 것이 좋습니다. 이러한 메서드는 정규 표현식을 실시간으로 컴파일하고 생성된 오브젝트를 캐싱하지 않습니다.
Regex.MatchRegex.Replace이 예제는 무해해 보이는 한 줄 코드입니다.
Regex.Match(myString, "foo");
하지만 실행될 때마다 5KB의 가비지를 생성합니다. 간단한 리팩터링을 통해 이러한 가비지를 상당 부분 제거할 수 있습니다.
var myRegExp = new Regex("foo");myRegExp.Match(myString);
이 예시에서는 를 호출할 때마다 가비지가 320바이트만 생성됩니다. 간단한 매치 작업이라는 것을 감안할 때 여전히 비용이 많이 들지만 이전 예시보다 상당히 개선된 결과입니다.
myRegExp.Match따라서 정규 표현식이 불변의 문자열 리터럴인 경우 이를 Regex 오브젝트 생성자의 첫 파라미터로 전달하여 미리 컴파일하는 것이 상당히 효율적입니다. 그런 다음 이러한 사전 컴파일된 Regex를 재사용해야 합니다.
XML, JSON과 그 외 긴 형식의 텍스트 파싱
텍스트 파싱은 로드 시간에 발생하는 복잡한 작업 중 하나입니다. 경우에 따라 텍스트를 파싱하는 데 소요되는 시간이 에셋을 로드하고 인스턴스화하는 시간보다 더 길어질 수도 있습니다.
그 이유는 사용되는 특정 파서에 따라 달라집니다. C# 빌트인 XML 파서는 대단히 유연하지만 결과적으로 특정 데이터 레이아웃에는 최적화할 수 없습니다.
많은 타사 파서는 반사를 기반으로 빌드됩니다. 반사는 개발 중에는 탁월한 선택이지만(데이터 레이아웃의 변경에도 파서가 빠르게 적응할 수 있기 때문), 느린 것으로 악명이 높습니다.
Unity는 JSON을 읽고 방출하는 Unity 직렬화 시스템에 대한 인터페이스를 제공하는 빌트인 JSONUtility API를 통해 부분적인 솔루션을 도입했습니다. 대부분의 벤치마크에서는 순수 C# JSON 파서보다 빠르지만, Unity의 직렬화 시스템에 대한 다른 인터페이스와 동일한 제약이 있습니다. 그 제약은 추가 코드 없이 딕셔너리 같은 복잡한 데이터 유형을 직렬화할 수 없다는 것입니다.
참고: Unity 직렬화 과정 중에 복잡한 데이터 유형을 양방향으로 전환하는 데 필요한 과정을 추가하는 방법은 ISerializationCallbackReceiver 인터페이스를 참조하십시오.
텍스트 데이터 구문 분석으로 인하여 성능 문제를 겪는 경우, 다음의 세 가지 대안을 고려하십시오.
옵션 1: 빌드 시 파싱
텍스트 파싱 비용을 줄이는 가장 좋은 방법은 런타임 시 텍스트 파싱을 완전히 제거하는 것입니다. 일반적으로 이는 일종의 빌드 단계를 통해 텍스트 데이터를 바이너리 포맷으로 ‘베이크’하는 것을 의미합니다.
이 경로를 선택하는 대부분의 개발자는 데이터를 일종의 ScriptableObject 파생 클래스 계층 구조로 이동한 후 에셋 번들을 통해 데이터를 배포합니다. ScriptableObject 사용에 대한 자세한 내용은 YouTube에서 리처드 파인의 유나이트 2016 강연(영어)을 참조하십시오.
이 방법으로 최상의 성능을 얻을 수 있지만, 동적으로 생성되어야 할 필요가 없는 데이터에만 적합하며 게임 디자인 파라미터나 기타 콘텐츠에 가장 적합합니다.
옵션 2: 분할 및 지연 로드
두 번째 방법은 파싱해야 하는 데이터를 더 작은 청크로 분할하는 것입니다. 분할하면 데이터 파싱 비용을 여러 프레임으로 분할할 수 있습니다. 이상적인 경우 사용자에게 원하는 경험을 제공하는 데 필요한 특정 데이터 부분을 파악하고 해당 부분만 로드합니다.
간단한 예시: 프로젝트가 플랫폼 게임이라면 모든 레벨의 데이터를 하나의 거대한 블롭(blob)으로 직렬화할 필요가 없습니다. 데이터를 각 레벨에 개별 에셋으로 분할하고, 특히 레벨을 영역으로 세분화하면 플레이어가 접근할 때 데이터를 파싱할 수 있습니다.
언뜻 쉬워 보이는 방법이지만 현실적으로는 툴 코드에 대한 상당한 투자를 필요로 하며, 데이터 구조를 재구성해야 할 수도 있습니다.
옵션 3: 스레드
순수 C# 오브젝트에 전부 파싱되고 Unity API와 상호 작용을 필요로 하지 않는 데이터의 경우, 해당 데이터의 파싱 작업을 워커 스레드가 담당하도록 할 수 있습니다.
이 방법으로 멀티 코어를 가진 플랫폼에서 매우 강력한 성능을 발휘할 수 있습니다. 하지만 신중하게 프로그래밍하여 교착 상태와 경쟁 상태를 방지해야 합니다.
참고: iOS 기기에는 최대 2개의 코어가 있습니다. 대부분의 Android 기기에는 2-4개가 있습니다. 이 기법은 스탠드얼론 및 콘솔 빌드 타겟용으로 빌드할 때 더 적합합니다.
스레딩을 구현하려는 프로젝트는 빌트인 C# Thread 및 ThreadPool 클래스(msdn.microsoft.com 참조)를 사용하여 표준 C# 동기화 클래스와 함께 워커 스레드를 관리합니다.