FireDAC: FDMemTable vs. ClientDataSet Android 성능 비교

firedac_mobileDelphi 및 C++Builder의 새로운 데이터 액세스 컴포넌트로 추가된 FireDAC은, 다양한 데이터베이스 서버들을 지원할 뿐만 아니라 기능 면에서도 대단히 강력합니다. 또한 성능도 매우 뛰어난데요.그런데 성능 면에서의 장점 설명들은 보통 윈도우 플랫폼에 치중되어 있습니다.

최근 저는 Delphi 기반 Android 프로젝트를 진행하던 과정에서, 안드로이드에서 FDMemTable의 로컬 동작들이 기이할 정도로 느린 현상을 발견했습니다. 구체적으로는, 로컬에 저장되어 있는 FDMemTable 데이터를 로드하고 인덱스를 설정하는 부분에서 큰 성능 저하가 있었습니다. 처음에는 윈도우 PC에 비해 모바일인 안드로이드 기기의 성능상 한계인 것으로 추정했었으나, 이래저래 테스트를 진행해보면서 그게 아니라 FDMemTable이 모바일에서 성능 문제가 있다는 것을 알게 됐습니다.

FDMemTable은 FireDAC에 포함된 메모리 기반 데이터셋 컴포넌트인데, 같은 메모리 데이터셋인 ClientDataSet에 비해 성능과 기능 모두 월등하기 때문에 엠바카데로에서도 FDMemTable을 사용하도록 권장하고 있습니다. 그런데 FDMemTable의 주요 내부 로직들은 FireDAC의 쿼리(TFDQuery) 등의 모든 데이터셋들과 같은 베이스 클래스를 공유하고 있기 때문에, 이 FDMemTable이 느리다는 것은 쿼리를 포함한 FireDAC 컴포넌트들이 안드로이드에서 동일하게 성능 문제가 있다고 봐야 합니다(아마 iOS도 마찬가지일 겁니다).

문제의 정확한 양상을 확인해보기 위해, FDMemTable과 ClientDataSet 두가지 컴포넌트로 동일한 동작을 수행하는 간단한 파이어몽키 앱을 만들어 양쪽의 성능을 비교해봤습니다. 동일한 프로젝트 소스를 Win32와 Android 양쪽 플랫폼으로 빌드, 실행함으로써 플랫폼들 사이의 두 컴포넌트의 상대 속도를 비교해볼 수 있었습니다.

코드가 다소 길고 조금 지저분해 보이겠지만, 실제 하는 일은 비교적 간단합니다.

  1. TMemoryStream 객체에 테이블 파일(fds 혹은 cds)을 로드합니다.
  2. 테이블 객체(FDMemTable / ClientDataSet)의 LoadFromStream으로 위 메모리스트림의 데이터를 읽어들입니다.
  3. Company 필드가 ‘DevQuest’이라는 값을 가진 레코드를 검색하여 Contact 필드의 값을 보여줍니다. (Locate 사용)
  4. Company 필드에 인덱스를 겁니다.
  5. 3번과 같이 Company 필드가 ‘DevQuest’인 레코드를 검색해 Contact 필드의 값을 보여줍니다. (Locate / FindKey)

이 1~5단계의 각 단계마다 매번 각각 소요되는 시간을 ms 단위로 측정하여 화면에 뿌려줍니다. 3번과 5번은 사실상 동일한 테스트인데, 인덱스가 걸려있지 않은 상태에서의 검색과 인덱스가 걸려 있는 상태에서의 검색의 차이입니다. 당연히 두 경우가 다른 성능을 보이겠지요. 또한 인덱스를 설정하는 자체의 시간도 측정했습니다.

아래는 Win32로 빌드하여 윈도우7에서 실행한 결과입니다.

FDMemTable_Windows

보시다시피, 파일 로드 시간에서 ClientDataSet이 조금 더 빠를 뿐 다른 모든 측정에서 FDMemTable이 더 빠릅니다. (파일 로드 시간에서 ClientDataSet이 더 빠른 이유는 간단한데, 같은 데이터에 대해 cds 파일이 fds 파일보다 작기 때문입니다. 여기에 사용된 테스트 테이블의 경우 customer.fds 파일은 199kB, customer.cds 파일은 139kB입니다) 과연 FDMemTable이 좋긴 좋네요. (이 캡쳐에서는 인덱스 기반 검색에서 ClientDataSet이 약간 더 빨라보이지만 여러번 반복 테스트를 해보면 사실 거의 동일한 성능을 보여줍니다)

자, 그럼 이제 안드로이드에서 실행한 결과를 봅시다.

FDMemTable_Android

놀랍게도 윈도우와는 거의 정반대의 결과가 나왔습니다. 파일 로드가 ClientDataSet이 더 빠른 것은 윈도우와 동일하고, 인덱스 기반 검색은 비슷한 성능이 나온 것은 같지만, 데이터셋 로드, 인덱스 없이 검색, 인덱스 설정 등은 모두 ClientDataSet 쪽이 압도적으로 빠릅니다. 여러번 테스트해보면 데이터셋 로드의 경우 최대 10배까지 차이가 나고, 인덱스 설정은 최대 30배까지 벌어집니다.

당연하게도, 실제 필드 프로젝트에서 이런 정도의 성능 차이는 도저히 용인될 수가 없습니다. 물론 이 테스트 결과만 봐서는 그래봐야 1초보다 훨씬 작은 시간이니 별거 아니라고 생각할 수도 있겠지만, 이 테이블은 레코드가 겨우 1,100건에 200kB도 안되는 아주 작은 테이블입니다.

제가 이 문제를 발견한 제 필드 프로젝트의 케이스에서는 레코드가 59,000건이고 파일 크기가 5MB에 가까운 로컬 fds 파일을 로드합니다(제 프로젝트에서는 네트워크가 종종 끊어질 수 있는 환경을 상정해야 하기 때문에 로컬 테이블들을 많이 사용합니다). 이 큰 테이블의 경우, FDMemTable에서 파일 로드와 인덱스 설정에만 24초 이상 걸렸습니다. 이런 정도면 거의 모든 사용자가 앱이 다운되었다고 생각하고 강제종료를 하고도 남을 시간입니다. FDMemTable을 ClientDataSet으로 바꾼 후 3~4초 이내에 끝났습니다.

이 테스트에서의 1,100건 / 199kB의 테이블(파일 로드 및 인덱스 설정을 합쳐도 200ms 미만)의 케이스와 레코드 59,000건 / 파일 크기 5MB의 테이블(파일 로드와 인덱스 설정에 24초 이상 소요)의 케이스를 비교해보면, 레코드 건수와 파일 크기 증가에 비해 성능 저하가 비례관계가 아닌 기하급수 관계로 늘어난다는 것을 짐작할 수 있습니다. 즉 파일 크기가 크면 클수록 FDMemTable은 더더욱 써서는 안됩니다.

아래에 이 테스트 프로젝트 소스와 윈도우 실행파일, 그리고 안드로이드 APK 설치파일을 첨부합니다.

[프로젝트 소스]

[윈도우 실행파일 ]

[안드로이드 APK]

사실 ClientDataSet은 델파이/C++빌더에서는 아주아주 오래된 컴포넌트로, 1997년에 발표된 델파이3 버전에서 처음 등장한 것입니다. 반면에 FDMemTable이 포함된 FireDAC은 불과 몇년전인 2013년에 XE4 버전에 추가된 것입니다(XE3 버전에 대해서는 별도 다운로드로 추가 제공). 벤더가 권장하는 최신 루틴이 아주 오래된 구 루틴보다 훨씬 느린 것은 아주 아이러니하죠. 물론 ClientDataSet은 무려 20년 가까이 계속 튜닝 및 업그레이드가 된 코드이긴 하지만, 그렇다고 딱히 모바일을 위해 더 최적화를 하거나 한 것도 아니지요.

즉 이것은 FDMemTable이 모바일에서 심각한 성능 버그가 있다는 얘기인데요. 사실은 FDMemTable 하나뿐만 아니라 FireDAC 전체가 마찬가지입니다. 앞서 말했다시피 FDMemTable은 대부분의 로컬 동작들이 자체 클래스에서 구현된 것이 아니라 TFDQuery 등 FireDAC 데이터셋들과 공유되는 베이스 클래스에서 구현되어 상속받은 것이기 때문입니다. 당장 LoadFromStream부터가 TFDMemTable의 멤버가 아닌 그 베이스 클래스인 TFDDataSet의 멤버입니다.

따라서 이걸 피하려면, 모바일에서는 FDMemTable을 회피하고 ClientDataSet을 사용해야 하고, 그 외 FireDAC의 FDQuery 등을 사용할 경우에도 만약 로컬 프로세싱이 많다면(예를 들어 위에서처럼 인덱스를 설정해 검색한다든지), 경우에 따라 FDQuery로 쿼리한 결과를 ClientDataSet으로 데이터를 복사한 후 처리하는 것이 더 빠를 수도 있습니다. 물론 먼저 FDQuery에서 쿼리한 결과를 다시 복사하는 오버헤드를 무시할 수는 없으므로, 로컬 프로세싱이 일정 이상이면 각각의 케이스마다 성능 테스트가 필요합니다.

아래는 이 테스트에 연관된 기타 유의미한 사족들입니다.

  1. 코드 시간 측정은 코드에서 보시다시피 TStopwatch 클래스를 사용하였습니다. TStopwatch 클래스는 이렇게 소요시간을 측정하는 데에 유용한 클래스로서, System.Diagnostics 유닛의 유일한 클래스입니다. 이 TStopwatch 클래스는 델파이/C++빌더 2010 버전에서 추가되었으며, MacOS X와 모바일에서도 사용 가능합니다. 윈도우 플랫폼에서는 QueryPerformanceFrequency 및 QueryPerformanceCounter로 구현되어 있고 다른 플랫폼들에서도 고성능 타이머로 구현되어 있으므로 코드 성능 테스트에 적합합니다.
    또한 TStopwatch 클래스는 RTL 레벨의 유닛이므로 당연히 파이어몽키가 아닌 VCL 애플리케이션에서도 사용할 수 있습니다. 즉 윈도우용 코드인 경우에도 Win32 API 함수인 QueryPerformanceFrequency와 QueryPerformanceCounter 대신 TStopwatch로 구현하면 향후 모바일 등의 타 플랫폼으로도 이식이 쉬워지는 장점도 있죠.
  2. 인덱스 기반 검색에서 FDMemTable에서는 Locate를 사용하고 ClientDataSet에서는 FindKey를 사용한 것은 이유가 있습니다. ClientDataSet에도 Locate 메소드가 있고 저도 종종 사용합니다만, ClientDataSet을 비롯한 거의 모든 데이터셋 컴포넌트에서 Locate 메소드는 인덱스가 설정되어 있어도 인덱스를 타지 않는 것이 기본입니다. 실제로 위 코드를 수정해서 테스트를 해보면, TClientDataSet.Locate를 사용하면 윈도우이든 모바일이든 인덱스가 걸려 있어도 인덱스가 걸리지 않은 상태와 동일한 속도가 나옵니다. 반면, FDMemTable은 특이하게도 Locate를 사용해도 인덱스를 타도록 구현되어 있습니다.
  3. 위 테스트 코드에서, 실제 필드용 코드에서는 LoadFromFile()로 한번에 호출하는 게 당연합니다만, 물리적 파일을 메모리에 로드하는 시간과 이미 메모리에 적재된 데이터를 테이블에 로드하는 시간을 분리하기 위해 메모리스트림에 불러들였다가 LoadFromStream()으로 테이블에 적재했습니다. 같은 코드를 반복해서 여러번 실행해보면 파일에서 메모리스트림으로 로드하는 시간은 2회차부터 크게 짧아지지만, 메모리스트림에서 테이블 컴포넌트로 불러들이는 속도는 반복해서 실행해도 편차만 있을 뿐 전혀 줄어들지 않습니다. 물론 테이블 로드를 이렇게 두 단계로 나눠서 진행해도 한번에 LoadFromFile()을 하는 것과 소요 시간은 동일합니다. LoadFromFile의 내부에서도 동일한 동작을 하고 있기 때문입니다.
  4. 테스트에 사용된 테이블은 델파이/C++빌더에 기본 번들되어 있는 데모용 테이블 customer.cds/customer.fds를 레코드 건수만 늘린 것입니다. 원래의 customer 테이블은 레코드 건수가 54개로 테스트 용도로는 너무 적어서, Copy&Paste로 1,100건으로 늘렸습니다. (다만 데이터가 동일하게 반복되면 테이블 후순위에 있는 레코드를 검색하는 시간 테스트가 무의미해지므로, 전체 중 마지막 근처에서 하나의 레코드를 골라서 Company 필드와 Contact 필드의 값을 수정해서 검색 테스트가 유의미하도록 했습니다)
  5. FDMemTable은 파일 포맷으로 위에서처럼 Binary 외에 JSON과 XML도 지원합니다. 특히 JSON은 요즘 대세(?) 포맷이라서 많은 분들이 FDMemTable에서 Binary 대신 JSON을 쓰고 싶은 유혹이 들 겁니다. 하지만 성능을 생각한다면 JSON을 사용하는 것은 접는 것이 좋습니다. 동일한 내용의 테이블을 FDMemTable에서 JSON 포맷으로 불러들이면 안그래도 너무나 느린 Binary보다도 두배 이상이나 더 느려집니다. 물론 파일 크기도 훨씬 큽니다.

답글 남기기

이메일 주소는 공개되지 않습니다.