XML 데이터 로딩 최적화 사례

2023. 12. 28. 23:26Unity, C# 프로그래밍

회사에서 현재 작업중인 프로젝트는 기획자 분들이 데이터를 XML로 관리하고 있다.

 

이 XML이라는 포맷은 사람이 읽고 쓰기는 쉽지만 용량이 크고 파싱이 어려운 문제가 있는데

프로젝트 라이브 초창기에는 절대적인 데이터 양이 많지 않아 성능 문제가 없었다. (발견이 늦었다..)

 

이런 상태로 오랫동안 라이브 서비스가 진행되다 보니 XML 파일의 개수만 200개가 넘어갔고

총 파일 용량도 150MB 정도로 매우 커지면서 여러 문제가 생기기 시작했다.

 

  • 로딩 속도가 많이 느려졌다 (PC 빌드 기준으로도 거의 10초가량 소요)
  • 파일 포맷 특성상 파싱하는 과정에서 임시 문자열 (string) 객체가 많이 생성되어 앱 시작과 동시에 메모리 파편화가 심하게 일어나고, 또 메모리를 많이 사용하다보니 저사양 모바일 디바이스에서 크래시가 일어났다.

그래서 최적화를 위해서 많이 고민을 해본 결과 몇가지 방법들이 떠올랐었는데

 

 

1. 멀티 스레드 로딩

멀티 스레드 로딩은 예전에 라이브 초창기 시절에도 고민해 봤었던 방법이다.

그때 당시에는 굉장히 보수적으로 관리했던 시절이라 고민만 하고 실행하지는 못했는데

해당 시기에는 이런 일도 있었다.
코드에 nameof() 구문을 사용했었는데 프로젝트에 사용 전례가 없어서 코드 리뷰에서 반려당했다.
(프로젝트가 많이 잘 되고 있었으므로 최대한 안전하게 하자고 하셨다)

 

많은 시간이 지난 지금 문제가 커지고 나서 손을 대려고 하니

데이터 클래스 및 로딩 로직이 200개가 넘어가는 시점에서 이쪽 부분을 전부 수정하고 검증하기는 쉽지 않아서

다른 방법을 선택하기로 했다.

 

그리고 멀티 스레드로 전환한다고 해도

메모리 파편화 및 Out Of Memory 크래시는 해결되지 않는다.. 

 

2. 데이터 클래스를 Probocol Buffer 또는 Flat Buffer 등의 직렬화 라이브러리로 교체

이것도 같은 팀 내 동료 개발자분이 R&D 를 했던 결과가 있는데

확실히 메모리 사용량과 속도면에서 월등하게 좋았다.

 

하지만 데이터를 로딩하는 200개의 로직을 전부 교체 및 검증해야 하는데 이것부터 쉽지 않는데다가

라이브 프로젝트에서 적용하면 이슈가 발생할 여지가 너무 많을 것 같아 무산되었다.

 

게임 출시 전에 미리 이런 방식으로 데이터 관리를 했더라면 좋았을 것 같다는 생각이 많이 들었는데..

다른 팀의 신작 개발은 우리팀의 이런 선례를 보고 XML을 버리고 Flat Buffer를 사용하고 있었다..ㅜ

 

3. 로딩 로직은 그대로 두고 데이터 로딩을 최적화 할 수는 없을까?

위의 두 케이스는 적용이 어려울 것 같고..

코드를 많이 수정하지 않으면서 성능을 개선할 방법이 없을까? 하던 중에

 

XML 파일을 데이터 포맷에 맞춰 로딩 후 직렬화 하는 방식이 아닌 XML 파일 구조 자체를 Binary화 하면 좋을 것 같다고 생각했다. (Binary-XML)

 

그래서 집에 와서 구조를 한번 고민해 보고 주말에 잠깐씩 만들어 봤는데 생각보다 괜찮을 것 같아서

회사 프로젝트에 적용했다.

 

아래부터는 Binary-XML 파일 포맷을 고민한 내용이다.

 

3.1 Xml 노드의 중복 이름 제거

예를 들어서 아래와 같은 XML 파일이 있다고 해보자.

<GameItems>
    <Item ID="10001">
        <Name>단검</Name>
        <Desc>작은 단검이다. 적에게 던지는 방식으로 공격할 수 있다.</Desc>
        <Damage>100</Damage>
    </Item>
</GameItems>

 

여기서 Item, Name, Desc, Damage 등은 아이템 개수가 많이 늘어날수록 비례해서 많이 사용하게 되는데

이 부분에서 중복을 제거하면 텍스트 사용량을 많이 줄일 수 있을 거라고 생각했다.

 

그래서 파일 상단에 이런 노드들의 이름만을 모아두는 Name Table 공간을 만들고, 중복 제거 후

각 노드들이 이 Name Table의 위치를 참조하도록 (포인터 개념) 해서 한번 적용해 봤다.

 

파일 사이즈는 줄었지만 결국 XML은 Tree 형태로 노드들이 존재하기 때문에 노드들을 탐색하려면 시간이 많이 소요되었다.

 

3.2 XmlNode 및 XmlAttribute를 직렬화된 트리 구조로 저장

위의 방법에서 개선하지 못했던 탐색 속도를 빠르게 하기 위해서

Node 및 Attribute를 노드 크기가 고정된 트리 구조로 저장하면 좋을 것 같다고 생각했다.

더보기

노드 크기를 고정해야 하는 이유

 

크기가 제각각인 노드 10개가 있다고 해보자.

 

Write: 각 노드를 파일에 쓰기 전에 노드의 크기를 먼저 파일에 쓰고, 그 다음에 노드 내용을 쓴다.

Read: 이렇게 한 경우 내가 5번째 노드를 읽고 싶을 때 앞의 0 ~ 4번 노드를 읽어야지만 5번 노드를 읽을 수 있다.

 

하지만 크기가 n바이트인 노드 10개가 바이트 배열로 있다고 한다면?

 

Write: 각 노드를 파일에 쓸 때 노드의 크기는 고정되어 있으므로 노드의 내용만 연속적으로 쓴다.

Read: 이렇게 한 경우 내가 5번째 노드를 읽고 싶다면 n * 5 위치에서 바로 노드를 읽을 수 있다.

 

하지만 "단검""작은 단검이다. 적에게 던지는 방식으로 공격할 수 있다" 처럼 XML 노드의 내용은

크기가 제각각 다르다.

이걸 고정된 사이즈로 표현하기 위해 XML 노드의 내용 영역을 따로 분리하고,

트리 노드에서는 그 데이터 영역의 위치만 참조하도록 했다.

 

고안한 노드는 두 종류로

 

XmlNode를 위한 트리 노드는

  • NameTableOffset
  • DataOffset
  • ChildCount
  • ChildOffset
  • AttributeCount
  • AttributeOffset

XmlAttribute를 위한 트리 노드는

  • NameTableOffset
  • DataOffset

이렇게 되어 있다.

 

처음에는 공용으로 쓰려다가 Attribute는 자식 노드도 없고, Attribute에 Attribute를 붙이는 문법 같은건 존재하지 않기 때문에 용량 절약을 위해 분리했다.

 

각각의 이름은 BXmlElementEntry, BXmlAttributeEntry 라고 지었다.

 

그리고 이때 파일 포맷 영역은 아래와 같이 구분된다.

Header
NameTable
ElementEntries
AttributeEntries
Data

 

아래 4개 영역은 위에서 설명했던 부분들이 들어가고, 최초에 파일을 읽었을 때 영역을 구별하기 위해서 헤더 영역을 추가했다.

헤더 영역은 다른건 없고 아래 4가지 영역에 대한 오프셋 저장을 담당한다.

 

Binary-XML 파일 구조는 이정도로 마무리했다.

 

3.3 Utf8Parser

위의 과정들을 적용하니 검색 속도 및 파일 용량은 많이 줄었다.

하지만 XML 내부의 데이터들은 전부 텍스트 형식이고 실제 런타임에 사용할 타입은 int, long, enum등 다양하다.

원래는 int.Parse() 함수와 같은 string에서 파싱하는 함수들을 사용했었는데

Data 영역은 현재 구조상 UTF-8 인코딩된 문자열로, C# 에서는 byte 배열이다.

 

이때 Encoding.UTF8.GetString 와 같은 함수를 사용할 수도 있지만, 임시 문자열을 생성하지 않는게 좋을 것 같아서

더 찾아봤다.

 

이때 구글링으로 Utf8Parser 클래스를 발견했고

해당 클래스는 UTF-8로 인코딩된 byte 배열에서 바로 파싱을 할 수 있게 해주는 유틸리티 클래스였다.

 

해당 클래스를 이용해서 런타임에 XML 조회 시

 

1. Entry를 통해서 내가 원하는 데이터 위치를 ReadOnlySpan<byte>형식으로 가져오고

2. Utf8Parser.TryParse 함수를 호출하는 방식으로 힙 할당 없이 사용할 수 있도록 만들었다.

 

4. 테스트

여기까지가 Binary-XML 의 구조가 만들어진 과정이고,

이제 만든 포맷으로 적용해서 얼마나 빨라졌는지를 테스트해야 했는데

 

최초 로딩 10초 이상 걸렸던 시간이 5초 근처로 줄었고, 로딩을 위한 임시 메모리 할당도 200MB 이상 줄였다.

(위 내용 적용하면서 병목이 있는 부분을 찾아서 추가 최적화도 했다)

 

또한 해당 라이브러리의 검증을 위해 Binary-XML to XML 함수를 만들고

같은지 비교하는 과정을 거쳤다.

 

적용할 때 https://evens.tistory.com/2로 적용했다.
(**/*.xml 파일 일괄적으로 임포팅 타임에 Binary-XML화 이후에 런타임에는 byte[] 에서부터 로딩을 시작하는 방식)

 

위 내용은 깃허브에도 올려두었다.

 

집에서 구현했다가. 회사에서 다시 구현했는데 블로그에 올려보려고 또 다시 회사에 사용했던 느낌으로 다시 옮겨왔다.

(깃허브에 있는 내용은 완벽하지 않을 수 있습니다. 집에서 다시 구현한거기 때문에..)

 

깃허브: https://github.com/wisemin01/BinaryXml

 

GitHub - wisemin01/BinaryXml: Binary Format Xml Reader & Writer

Binary Format Xml Reader & Writer. Contribute to wisemin01/BinaryXml development by creating an account on GitHub.

github.com

 

여담으로

 

데이터 로딩 로직이 사람 손으로 100개 넘게 만들어진게 너무 아쉬운 것 같다.

CodeGenerator 등으로 처음부터 관리했다면 Protobuf나 FlatBuf 적용도 쉬웠을 듯..!