문자열과 텍스트 취급 문제는 Unity 프로젝트에서 성능 문제를 주로 유발하는 원인 중 하나입니다. C#에서 모든 문자열은 변하지 않습니다. 문자열을 조작하면 전체 문자열이 새로 할당됩니다. 이것은 상대적으로 비용이 비싸며, 반복적인 문자열 연결은 크기가 큰 문자열, 큰 데이터세트 또는 빠르게 반복하는 루프에서 수행될 때 성능 문제가 발생할 수 있습니다.
또한, N개의 문자열 연결은 N–1개의 중간 문자열을 할당해야 하므로, 연쇄적인 연결은 관리되는 메모리에 큰 부담을 줄 수 있습니다.
문자열이 빠르게 반복하는 루프나 매 프레임에서 연결되어야 할 경우 실제 연결 작업을 하기 위해 StringBuilder를 사용합니다. 또한 StringBuilder 인스턴스는 불필요한 메모리 할당을 좀더 최소화하기 위해 재사용될 수 있습니다.
Microsoft는 C# 에서 문자열 작업에 대한 베스트 프랙티스 리스트를 제공하고 있습니다. MSDN 웹사이트인 msdn.microsoft.com를 참조하십시오.
문자열 관련 코드에서 종종 발견되는 중요한 성능 문제 중의 하나는 속도가 느린 기본 문자열 API를 의도치 않게 사용했을 때 발생합니다. API는 업무용 애플리케이션용으로 만들어졌으며 텍스트에 있는 문자를 고려하여 여러가지 다른 문화적, 언어적 규칙으로 문자열을 다루려고 시도합니다.
예를 들어, 다음의 예제 코드는 미국식 영어 로케일에서 실행된 경우 true 를 리턴하지만, 다수의 유럽어 로케일에서는 false를 리턴합니다(1).
참고: Unity 버전 5.3과 5.4의 경우, Unity의 스크립팅 런타임은 항상 미국식 영어(en-US) 로케일에서 실행된다는 사실을 기억하십시오.
String.Equals("encyclopedia", "encyclopædia");
대부분의 Unity 프로젝트에서 이런 방식은 전혀 필요하지 않습니다. C나 C++ 프로그래머들이 친숙한 방식으로 문자열을 비교하는 서수 비교 방식을 사용하는 것이 대략 열 배는 빠릅니다. 이것은 바이트로 표현되는 문자를 고려하지 않고 문자열의 각 바이트를 단순히 순차 비교하는 방식입니다.
서수 문자열 비교 방식으로 바꾸는 것은 간단한데, String.Equals
의 마지막 인수로 StringComparison.Ordinal
를 작성하기만 하면 됩니다.
myString.Equals(otherString, StringComparison.Ordinal);
서수 비교로 전환하는 것 외에, 특정 C# String
API는 매우 비효율적인 것으로 알려져 있습니다. 그 중에는 String.Format
, String.StartsWith
, String.EndsWith. String.Format
등이 있습니다. 특히 String.Format은 대체하기 어렵지만, 비효율적인 문자열 비교 메서드를 간단하게 최적화됩니다.
Microsoft는 현지화를 위해 조정될 필요가 없는 모든 문자열 비교에 StringComparison.Ordinal
을 전달하는 것을 추천하고 있지만, Unity 벤치마크에 의하면 사용자 구현 방식과 비교했을 때 이것의 영향은 상대적으로 아주 적은 것으로 나타납니다.
메서드 | 십만 개의 짧은 문자열 처리 시간(ms) |
---|---|
String.StartsWith , 기본값 |
137 |
String.EndsWit h, 기본값 |
542 |
String.StartsWith , 서수 |
115 |
String.EndsWith , 서수 |
34 |
커스텀 StartsWith 으로 교체 |
4.5 |
커스텀 EndsWith 으로 교체 |
4.5 |
String.StartsWith
와 String.EndsWith
둘 다 다음의 예제와 같이, 간단하게 핸드 코딩한 것으로 교체할 수 있습니다.
public static bool CustomEndsWith(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 && a.Length >= b.Length) ||
(ap < 0 && b.Length >= a.Length);
}
public static bool CustomStartsWith(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 && aLen >= bLen) ||
(ap == aLen && bLen >= aLen);
}
정규 표현식은 문자열을 비교하고 조작할 수 있는 강력한 방법이지만, 성능을 극단적으로 요구할 수 있습니다. 또한, C# 라이브러리의 정규 표현식 구현 방식으로 인하여, 간단한 bool 값의 IsMatch
질의라도 “엔진 내부”에 큰 일시적인 데이터 구조를 할당합니다. 이러한 일시적인 관리되는 메모리의 변동은 초기화 중일 때를 제외하고는 적합하지 않는 것으로 간주합니다.
정규 표현식이 필요하다면, 정규 표현식을 문자열 파라미터로 받는 Regex.Match
나 Regex.Replace
등의 정적 메서드는 사용하지 않는 것을 강력히 권장합니다. 이 메서드는 정규 표현식을 바로바로 컴파일하며, 생성된 오브젝트를 캐시하지 않습니다.
이 예제는 무해해 보이는 한 줄 코드입니다.
Regex.Match(myString, "foo");
하지만, 이 코드가 실행될 때마다 5KB 정도의 가비지가 생성됩니다. 간단한 리펙토링으로 대부분의 가비지를 제거할 수 있습니다.
var myRegExp = new Regex("foo");
myRegExp.Match(myString);
이 예제에서, myRegExp.Match
을 호출하면 “오직” 320바이트의 가비지만 생성됩니다. 물론 이는 간단한 매치 작업이라는 것을 감안할 때 여전히 비용이 많이 들지만 이전 예제보다 상당히 개선된 것입니다.
따라서 정규 표현식이 불변의 문자열 리터럴인 경우, 이를 Regex 오브젝트 생성자의 첫 파라미터로 전달하여 미리 컴파일하는 것이 상당히 효율적입니다. 그 다음 미리 컴파일된 Regex 오브젝트는 재사용되어야 합니다.
텍스트 파싱은 로딩 시간에 발생하는 가장 무거운 작업 중에 하나입니다. 몇몇 경우, 텍스트를 파싱하는 데 걸리는 시간이 에셋을 로드하고 인스턴스화하는 데 걸리는 시간보다 더 클 수 있습니다.
이는 주로 어떤 구문 분석을 사용하는가에 따라 달라집니다. C# 빌트인 구문분석은 대단히 유연하지만, 결과적으로 특정 데이터 구조에 대해는 최적화할 수 없습니다.
많은 수의 서드 파티 구문 분석은 반사를 기반으로 빌드되어 있습니다. 물론 반사는 개발 중에는 탁월한 선택이지만(데이터 구조의 변경에도 구문 분석이 빠르게 이에 적응할 수 있기 때문), 느린 것으로 악명이 높습니다.
Unity는 빌트인 JSONUtility API로 부분적인 해결책을 도입했습니다. 이 API는 JSON을 읽고 작성하는 Unity 직렬화 시스템에 대한 인터페이스를 제공합니다. 대부분의 벤치 마크에서 순수한 C# JSON 구문 분석보다 빠르지만, 다른 Unity 직렬화 시스템에 대한 인터페이스와 동일한 제약을 가지고 있습니다. 즉 추가 코드 없이 Dictionary 같은 다수의 복잡한 데이터 형식을 직렬화할 수 없습니다. (2) (참고: Unity 직렬화 과정 중에 복잡한 데이터 형식을 양방향으로 전환하는 데 필요한 추가적인 과정을 쉽게 넣는 한가지 방법으로 ISerializationCallbackReceiver 인터페이스를 참조하십시오.)
텍스트 데이터 구문 분석으로 인하여 성능 문제를 겪는 경우, 다음의 세 가지 대안을 고려하십시오.
텍스트 구문 분석의 부하를 피하는 가장 좋은 방법은, 런타임 시점에 텍스트 구문 분석을 완전히 제거하는 방법입니다. 일반적으로 이는, 일종의 빌드 단계를 통해 텍스트 데이터를 바이너리 포맷으로 “베이킹”한다는 것을 의미합니다.
이러한 방법을 선택하는 대부분의 개발자들은 데이터를 ScriptableObject 파생 클래스 계층 구조 등으로 이동시킨 후, 에셋 번들을 통해 데이터를 배포합니다. ScriptableObject의 사용에 관련된 훌륭한 강연이 있습니다. YouTube에 있는 Richard Fine’s Unite 2016 talk를 참조하십시오.
이 방법은 최상의 성능을 보장하지만, 동적으로 생성되어야 할 필요가 없는 데이터에 적합합니다. 게임 디자인 파라미터나 기타 콘텐츠에 가장 적합합니다.
두 번째 방법은 구문 분석되어야 하는 데이터를 작은 부분으로 분할하는 것입니다. 데이터를 분할하면 이를 구문 분석하는 데 필요한 비용이 다수의 프레임으로 분산됩니다. 이상적으로는 사용자에게 원하는 경험을 제공하기 위해 필요한 데이터의 특정 부분을 파악하고, 해당 데이터만 로드하는 것입니다.
간단한 예제를 들어보겠습니다. 만약 프로젝트가 플랫폼 게임인 경우 모든 레벨 데이터를 거대한 덩어리 하나로 직렬화할 필요는 없습니다. 각각의 레벨마다 개별 에셋으로 데이터를 분할하고, 레벨을 세그먼트 별로 세분화했다면, 플레이어가 레벨에 접근할 때 데이터를 구문 분석할 수 있게 됩니다.
이는 쉬워보이지만, 현실적으로는 툴 코드에 대한 상당한 투자를 필요로 하며, 데이터 구조를 재구성해야 할 수도 있습니다.
순수 C# 오브젝트에 전부 파싱되고, Unity API와 상호작용을 필요로 하지 않는 데이터의 경우, 해당 데이터의 파싱 작업을 워커 스레드가 담당하도록 할 수 있습니다.
이 방법은 멀티 코어를 가진 플랫폼에서 매우 강력합니다. (3) (참고: iOS 장치는 많아야 2개의 코어를, Android 디바이스는 24개를 가지고 있다는 점을 기억하십시오. 이 방법은 스탠드얼론 및 콘솔 빌드 타겟을 빌드할 때 적합합니다) 하지만, 이 방법을 사용할 경우 데드록이나 레이스 조건을 방지하기 위해서는 조심스럽게 프로그래밍하여야 합니다.
스레딩을 도입하는 프로젝트는 일반적으로 워커 스레드를 관리하기 위한 목적으로 내장 C# 스레드 와 ThreadPool 클래스(msdn.microsoft.com를 참조하십시오)를 표준 C# 동기화 클래스와 더불어 사용합니다.
각주
(1) Unity 버전 5.3과 5.4의 경우, Unity의 스크립팅 런타임은 항상 미국식 영어(en-US) 로케일에서 실행된다는 사실을 기억하십시오.
(2) Unity 직렬화 과정 중에 복잡한 데이터 형식을 양방향으로 전환하는 데 필요한 추가적인 과정을 쉽게 넣는 방법은 ISerializationCallbackReceiver 인터페이스를 참조하십시오.
(3) iOS 장치는 많아야 2개의 코어를, Android 디바이스는 24개를 가지고 있다는 점을 기억하십시오. 이 방법은 스탠드얼론 및 콘솔 빌드 타겟을 빌드할 때 적합합니다.