카테고리 없음

Dart 에서의 메모리 릭과 GC - Flutter 공식 문서 정리

파랑o 2023. 7. 13. 16:40

Flutter 공식 문서, (아직은 부족한) Bard, 나의 영어 독해 능력과 추측이 결합된 문서로 신뢰성이 높지 않음을 주의하자.

 

Root Object & Reachability

 모든 Dart 앱은 실행 시에 Dart VM 으로부터 'Root Object'(우리가 아는 모든 클래스의 root 인 'Object' 클래스와는 별개이다)를 할당 받고, 그 Root Object 는, 직접적으로든 간접적으로든, 메모리에 할당된 모든 다른 객체들을 참조한다. 앱의 실행 중에, 특정 객체가 Root Object 로부터 Unreachable(닿을 수 없는) 상태가 되면 GC는 그 객체의 메모리 공간을 해제한다. Root Object 까지의 참조를 Retaining Path(메모리를 유지시켜주는 경로?)라고 하고, 최소 1개 이상의 Retaining Path 를 가지는 객체를 Reachable(닿을 수 있는) 상태라고 한다.

Shallow Size & Retained Size

 객체 또는 인스턴스의 메모리 크기는, Shallow Size 와 Retained Size 두 가지 방식으로 표현할 수 있다. Shallow Size 는 인스턴스 자체와 인스턴스가 가진 레퍼런스의 크기만을 포함한다. 아래의 코드에서 Car 인스턴스의 Shallow Size 는 Car 인스턴스의 크기에 wheels 레퍼런스의 크기를 더한 것이다. 반면에 Retained Size 는 인스턴스의 Shallow Size 에, 해당 인스턴스에서 reachable 한 모든 인스턴스들의 Shallow Size 들을 더한 것이다. 아래의 예에서 Car 인스턴스의 크기는 wheels, tire, radius, type 등의 레퍼런스들과 그 인스턴스 들의 크기 또한 포함한다. 즉, 위에서 언급한 Root Object의 Retained Size가 곧 Dart VM이 Heap 영역에 할당한 메모리의 크기이다.

class Car {
    final List<Wheel> wheels;
    ...
}

class Wheel {
    final Tire tire;
    final double radius;
    ...
}

class Tire {
    final TireType type;
    ...
}

Dart 에서의 Memory Leak

우선, 메모리 릭이란, 프로그래머가 의도하지 않았는데 메모리 영역이 점유되어 있는 것을 말한다. 메모리 릭에 해당하는 영역이 증가하여 크래시가 발생하는 현상은 메모리 릭의 누적으로 인한 결과임을 인지하기 바란다.

 Dart 의 GC 는 모든 unreachable 한 메모리 영역을 담당한다. 이 말은 곧, reachable 한 메모리 영역에서 문제가 생길 경우, GC 는 아무것도 할 수 없다는 뜻이고, 이는 곧 앱 크래시로 이어진다. 전역(global) 데이터, 정적(static) 데이터 또는 비교적 오랜 기간 참조된 상태로 남아있을 데이터가 연속적으로 누적 생성되며 메모리 영역을 채워 나가다 보면, out-of-memory 에러와 함께 프로세스가 종료된다. 또 다른 경우로, 생명주기가 긴 객체가 생명주기가 더 짧은 객체를 참조할 때 메모리 릭이 발생한다.

주의 1 : Closure

  final handler = () => print(veryHugeObject.toString());  
  setHandler(handler);

 위 코드에서 veryHugeObject 는 클로저에 의해 참조되고, handler 는 해당 클로저를 참조한다. setHandler 를 통해 handler 가 또 다른 참조자에 의해 참조되면, 그 참조가 끊어질 때까지 veryHugeObject 는 reachable 한 상태로 남아있게 된다. 위 코드를 아래처럼 수정하면 veryHugeObject 의 참조를 끊어낼 수 있다.

final handler = () {
    final tempObject = veryHugeObject;
    print(veryHugeObject.toString());
}
setHandler(handler);

 위 코드에서는 veryHugeObject 가 tempObject 에만 참조된다. 이 때, tempObject 참조자는 지역 변수이므로 클로저의 실행이 끝나면 제거되고, 적어도 위 코드에서는, veryHugeObject 에 대한 참조는 남아있지 않게 된다.

주의 2 : BuildContext

@override
Widget build(BuildContext context) {
  final handler = () => apply(Theme.of(context));  
  setHandler(handler);
…

 위의 코드에서 setHandler 함수는 handler 인스턴스를 생명주기가 긴 영역에 참조시킨다고 하자. handler 는 클로저를, 클로저는 context 를 참조한다. 비교적 생명주기가 짧은 context 는 더 이상 사용될 필요가 없는 메모리 영역을 참조하고 있어 GC 에 의해 해제 되는 것이 마땅하지만, 여전히 reachable 한 상태로 남아있게 되어 메모리 릭에 해당한다. 이는 아래와 같이 수정할 수 있다.

@override
Widget build(BuildContext context) {
  final theme = Theme.of(context);
  final handler = () => apply(theme);  
  useHandler(handler);
…

 수정된 코드에서는, 일시적으로만 유효한 context 참조자를 이용해 오랜 유지가 가능한 Theme 인스턴스를 생성한다. 즉, 생명주기가 짧은 인스턴스에 대한 참조에서, 생명주기가 긴 인스턴스에 대한 참조자로 변환한 것이다. 더 이상 클로저는 생명주기가 짧은 인스턴스를 참조하지 않으며, GC 는 위젯이 제거되면 context 가 참조하는 영역을 해제할 것이다.

 추가로, StatefulWidget 의 State 는 생명주기가 긴 인스턴스이므로, State 에서 Widget 의 context 를 절대 참조하지 않아야 한다.