플러터의 성능 개선을 위한 5가지 방법

플러터의 성능 개선을 위한 5가지 방법
Cozy CodingPosted On Sep 10, 20245 min read

얼마 전에 'PocketBase의 놀라운 5가지 혜택'이라는 글을 게시했었는데 거기서 Go가 정말 빠르다고 토론했어. 그 기사에서, Go가 메모리를 어떻게 관리하는지, 특히 슬라이스와 메모리를 어떻게 관리하는지에 대해서 이야기했지.

나는 이것이 XML 파싱이 왜 느린지라고 생각했어. 벤치마킹을 한 후에는, 실제로 그게 이유인지는 잘 모르겠어. 데이터베이스와 클라이언트 앱 사이를 왔다갔다하면서 발생하는 성능 문제라고 의심해. 하지만 이것은 Dart가 메모리를 어떻게 관리하는지가 완벽하지 않다는 걸 의미하는 것이 아니야.

사실, 내가 오랫동안 Dart에 대해 불평했던 것은 바로 메모리 관리 방식이었어. 내 의견으로는, 이것은 매우 심각한 성능 문제야.

저는 널 안전 기능이 도입되기 전에 Dart를 처음 사용했어요. 그 전에 C#을 사용하다가 Dart를 쓰기 시작했는데요, 정말 편한 언어라고 느꼈어요. C#과 비슷한데 더 간결한 느낌이었어요. 조금 더 깔끔하고요.

하지만 조금 특이한 점을 발견했어요. Dart에서 어떤 유형이든 null로 설정할 수 있다는 거죠. 다시 말하면 int num = null 이 완전히 작동한다는 거예요. 이제 널 안전 기능이 추가되어서 다소 복잡해졌지만, 크게 달라진 건 없어요. int? num = null 이렇게 해야 하지만 기본 아이디어는 같아요.

이전에 C#이나 Java, 그리고 C++과 같은 많은 언어를 사용해봤고, 대부분의 언어에서 이것을 할 수 없다는 걸 알고 있었어요. 그 이유는 int가 값 형식으로 다뤄지기 때문이에요. 그 말은 말 그대로 메모리 상에 어딘가에 있는 블록이고, 그 블록을 null로 표시할 특별한 방법이 없다는 거죠. null을 사용하려면 포인터가 필요하며, 그 포인터가 널 포인터가 되어야 해요. 즉, 그것이 메모리 주소 0을 가리키게 되는 거예요.

그렇다면 Dart는 어떻게 이걸 구현하는 걸까요? Dart에서는 모든 것이 참조 유형이라는 것이 밝혀졌죠. 모든 변수에는 포인터가 있다는 거예요. 숫자에 대해 메모리를 두 배 더 사용해야 하기 때문에 조금 비효율적이지만, 더 나쁜 점도 있어요. 모든 변수가 참조 유형이기 때문에 그 대부분은 힙에 저장됩니다. 이에 관한 신뢰할만한 자료를 찾을 수 없어서 100% 확실하지는 않지만, 최적화가 많지 않다면 그렇게 동작할 것으로 예상됩니다.

힙에 것을 저장하는 문제는 힙이 정렬되어 있지 않다는 것입니다. 제 방처럼 쓰이는 것과 쓰이지 않는 것이 혼재되어 있어요. 방에 계속 물건을 넣다 보면 기억이 떠나는 메모리 누수가 발생할 수 있습니다.

그러므로 이를 피하기 위해 가비지 수집이 필요합니다. 주기적으로 사용 중인 것과 사용하지 않는 것을 확인하고, 사용하지 않는 것들을 비워내는 것이죠. 이 방법은 잘 동작하지만 문제가 있습니다: 이를 위해 메모리 전체에 독점적 잠금이 필요하다는 것은 다른 사람이 사용할 수 없음을 의미합니다. 그리고 프로그램이 메모리에 접근할 수 없으면 가비지 수집이 완료될 때까지 중지해야 합니다.

이를 'stop-the-world 가비지 수집기'라고 합니다. 이는 사용자가 마주하게 되는 어떤 응용 프로그램에 대해 멍청한 아이디어라고 항상 분명히 생각해왔습니다. 왜냐하면 GC가 동작하는 동안 매우 눈에 띄는 프레임이 누락될 수 있기 때문이죠.

물론 이 방법의 장점은 바보같이 간단하다는 점입니다. 'stop-the-world 가비지 수집기'가 있는 언어에서도 메모리 누수가 발생할 수 있습니다: 항상 추가하지만 삭제하지 않는 목록을 가지고 있으면 알 수 있지만 상당히 오래 걸릴 것입니다.

지금 궁금해하고 계실지도 모르겠지만, 쓰레기 수거의 더 나은 방법이 있을까요? 사실은 '쓰레기 수집'이 아니라 '메모리 관리'에 더 가까운데요, 네. 제가 선호하는 방법은 ARC(자동 참조 계수)입니다. 이 시스템은 객체가 참조하는 횟수를 세어 그 수가 제로가 될 때 자동으로 삭제하는 방식입니다.

"그럼 왜 모두가 이렇게 하지 않을까요?"라고 물을 수 있습니다. 하지만, 이 방법에도 한 가지 문제가 있습니다: 순환 참조입니다. A가 B를 가리키고 B가 A를 가리키면 A와 B는 사용되지 않더라도 참조 수가 제로로 가지 않습니다. 이 문제에 대한 간단한 해결책이 '약한 참조'라는데요, 이는 'stop-the-world-GC'만큼 바로바로 효과적이지는 않습니다. 그래도 저는 이 방식을 좋아합니다. 왜냐하면 실제로 두 곳에서 무언가를 가리키는 경우가 그리 흔치 않기 때문입니다.

또한 메모리 관리 기술로는 그 밖에도 여러 가지가 있습니다. Unity는 최근 증분 쓰레기 수거기로 전환했습니다. 참조 계수와 일반 GC를 결합할 수도 있습니다. 그리고 동시성 GC도 있죠.

그렇기에 다양한 방법이 존재합니다. 사실 Dart는 '세대별 쓰레기 수집기'를 사용하여 'GC 스파이크'를 완화하려고 시도하지만 완전히 해결하지는 못합니다. 그리고 Dart의 작동 방식 때문에, 세대별 GC만으로는 충분하지 않아요.

그 이유는 플러터가 어떻게 작동하는지 때문입니다. UI를 구축하기 위해 위젯을 사용합니다. 매 프레임이 빌드될 때마다 전체 위젯 트리가 폐기되고 다시 구축됩니다. 좀 너무 과도한 간단화인데, 일부 부분은 재사용되지만 충분하지 않습니다. 이로 인해 많은 가비지가 생성됩니다.

제가 처음으로 이 현상을 발견한 건 제 언어 학습 앱 Litany에서였습니다.

이미지

하지만 이 앱은 최소한의 애니메이션만 존재해서 매우 드물합니다. 정말 문제가 심각해지는 곳은 제 RSS 리더 Stratum입니다.

FluttersBigPerformanceProblem_2

이 UI는 엄청 복잡해요. 모든 것이 커스텀된 상태 입니다. 그리고 천천히 스크롤하면 GC에 많은 지연이 발생합니다.

아직 완전히 모든 것이 GC 지연인지 확실하지는 않아요. 화면에 이미지가 있는 경우에 더 자주 일어나는 것처럼 보이는데, 어떤 비동기 작업이 UI 스레드를 차단하는 것이 가능합니다.

최근에 async await에 대한 개인적인 입장을 적은 것이 대유행을 일으켰어요. 제가 생각했던 것보다 굉장히 명백하다고 생각했고 전혀 잘 될 것이라고 생각하지 않았는데 Medium은 정말 예상할 수 없는 것으로 가득 차 있어요. 어쨌든, 'async await is a lie'라는 제목의 후속 기사를 쓰려고 생각 중이에요. 왜냐하면 실제로 '비동기'가 아니라 '비동기간격이 있는 동기 코드의 시퀀스'이기 때문이에요. 진정한 멀티스레딩이 있었으면 좋겠어요. 이 모든 독립된 것들에 대한 말이죠.

어쨌든, 저는 문제의 근본적인 원인이 Dart의 GC 시스템인 것으로 강력하게 의심합니다. 이는 주로 무작위성 때문입니다. 그리고 스크롤을 하는 동안 뿐만 아니라 이야기를 탭할 때에도 발생할 수 있습니다.

Dart은 쓰레기를 청소하는 데에 그다지 효과적이지 않습니다. 그래서 저는 무엇을 보고 싶어할까요? 저는 자동 참조 계산을 보고 싶습니다. 비록 이것이 조금 이기적인 요구일 수는 있지만요. 제가 항상 대형 프로젝트에 대해 자동 참조 계산을 사용한 언어를 사용해보고 싶어했지만, 안타깝게도 대부분의 언어가 GC를 사용하고 있습니다.

하지만 이 문제를 해결할 다른 방법들도 있습니다. 만약 스크롤이나 애니메이션이 재생 중일 때에 GC를 우선 순위를 낮추는 것도 한 가지 방법입니다. 그러나 이렇게 하면 많은 쓰레기가 쌓일 수 있습니다. 다른 옵션으로 Unity가 사용하는 점증적 가비지 콜렉터를 사용하거나, Go가 사용하는 동시 가비지 콜렉터를 사용할 수도 있습니다. 어쨌든, 중요한 것은 UI 프레임워크에서는 절대 중단-세계-GC를 사용해서는 안 된다는 것입니다. 특히, 메모리 할당이 매우 빈번한 Dart에서는요.

'X에 대한 제가 좋아하지 않는 점'이라는 글을 많이 썼습니다. Go에 대한 글과 PocketBase에 대한 글을 썼어요.

다트에 대해 글을 쓰려고 생각했는데, 제가 할 수 없을 것 같아요. 대부분의 불만이 표준 언어 기능에서의 일탈. 다트는 그다지 일탈하지 않아요. 사실 다트는 지루하다고까지 말할 수 있어요. 그저 수많은 프로그래밍 기능을 갖추고 있고, 더 추가하려고 노력하고 있어요. 이것이 불만이 될 수도 있겠죠. C#에 대한 제 불만 중 하나는 무겁다는 건데, 그렇게 심하진 않아요.

그 게시물을 구글링하면 레딧과 해커 뉴스에까지 올라갔다는 걸 깨달았어요. 사람들이 저는 그 기능을 싫어한다고 말했어요. 그건 사실이 아니에요. 저는 그 기능이 중복되었다고 말하고 싶어요. 다트의 기능들도 마음에 들지 않지만, 그것들이 중복된다고 할 수는 없어요. 이전에 할 수 없던 일을 하게 해주기 때문에, 제가 좋아하지 않아도 다른 사람에겐 매력적일 수 있어요.

하지만 그것 역시 대가가 따라와요: 성능. 일부 기능은 본질적으로 느릴 수 있어요. 그래서 Go에서는 패키지의 순환 종속성을 허용하지 않아요. 이것은 제한이 될 수 있지만 컴파일 속도가 빠른 Go의 장점이 되어요.

다트는 모두를 위한 모든 것이 되려고 노력하고 있어요. 그것은 저에게 언어에서 좋아하는 점이에요. 하지만 조금 너무 나가고 있는 것 같아요. 다트는 기능에 너무 많은 초점을 맞추고 있어요. 성능에 대해 집중하지 않고 있어요. 그리고 그것이 다트 (그리고 플러터)의 큰 성능 문제입니다.