InheritedWidget 심화 — BuildContext와 의존성 전파 원리

InheritedWidget은 Flutter의 상태 관리 기반 기술입니다. Provider, Theme, MediaQuery 모두 InheritedWidget 위에 구현되어 있습니다. 동작 원리를 이해하면 상태 관리 라이브러리도 더 깊이 이해할 수 있습니다.


BuildContext란?

BuildContext는 사실 Element 의 추상 인터페이스입니다. 위젯 트리에서 현재 위젯의 위치 정보를 담고 있습니다.

DART
@override
Widget build(BuildContext context) {
  // context = 이 위젯의 Element
  // context를 통해 트리 상위의 데이터에 접근 가능

  // Theme 접근 → 내부적으로 InheritedWidget 조회
  final theme = Theme.of(context);

  // MediaQuery 접근
  final size = MediaQuery.sizeOf(context);

  return Text('화면 너비: ${size.width}');
}

면접 포인트: "BuildContext가 뭔가요?"라는 질문에 "위젯 트리에서 현재 위치를 나타내는 Element의 핸들"이라고 답할 수 있어야 합니다.


InheritedWidget 동작 원리

DART
class CounterData extends InheritedWidget {
  final int count;
  final VoidCallback increment;

  const CounterData({
    super.key,
    required this.count,
    required this.increment,
    required super.child,
  });

  // of 패턴: 하위 위젯에서 접근하는 정적 메서드
  static CounterData of(BuildContext context) {
    final result = context.dependOnInheritedWidgetOfExactType<CounterData>();
    assert(result != null, 'CounterData를 찾을 수 없습니다');
    return result!;
  }

  // 선택적: 의존성 없이 데이터만 읽기
  static CounterData? maybeOf(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<CounterData>();
  }

  @override
  bool updateShouldNotify(CounterData oldWidget) {
    // true를 반환하면 의존하는 위젯들이 리빌드됨
    return count != oldWidget.count;
  }
}

전체 사용 예제

DART
// 상태를 관리하는 StatefulWidget
class CounterProvider extends StatefulWidget {
  final Widget child;
  const CounterProvider({super.key, required this.child});

  @override
  State<CounterProvider> createState() => _CounterProviderState();
}

class _CounterProviderState extends State<CounterProvider> {
  int _count = 0;

  void _increment() {
    setState(() => _count++);
  }

  @override
  Widget build(BuildContext context) {
    return CounterData(
      count: _count,
      increment: _increment,
      child: widget.child,
    );
  }
}

// 하위 위젯에서 사용
class CounterDisplay extends StatelessWidget {
  const CounterDisplay({super.key});

  @override
  Widget build(BuildContext context) {
    final data = CounterData.of(context);
    return Text('${data.count}');
  }
}

class CounterButton extends StatelessWidget {
  const CounterButton({super.key});

  @override
  Widget build(BuildContext context) {
    final data = CounterData.of(context);
    return ElevatedButton(
      onPressed: data.increment,
      child: const Text('증가'),
    );
  }
}

dependOn vs getElement

DART
// dependOnInheritedWidgetOfExactType
// → 의존성 등록 + 데이터 반환
// → InheritedWidget이 변경되면 이 위젯도 리빌드
final data = context.dependOnInheritedWidgetOfExactType<CounterData>();

// getInheritedWidgetOfExactType (또는 getElementForInheritedWidgetOfExactType)
// → 데이터만 반환, 의존성 등록 안 함
// → InheritedWidget이 변경되어도 리빌드 안 됨
// → initState 등에서 사용 가능

이 차이가 Provider의 watch vs read의 원리입니다.


의존성 전파 흐름

PLAINTEXT
CounterData (InheritedWidget)
  ├─ WidgetA (의존O → 리빌드됨)
  │    └─ WidgetB (의존X → 리빌드 안 됨)
  │         └─ WidgetC (의존O → 리빌드됨)
  └─ WidgetD (의존X → 리빌드 안 됨)

dependOnInheritedWidgetOfExactType을 호출한 위젯만 리빌드됩니다. 중간 위젯은 리빌드되지 않습니다. 이것이 Prop Drilling 없이 데이터를 전파할 수 있는 이유입니다.


updateShouldNotify의 중요성

DART
@override
bool updateShouldNotify(CounterData oldWidget) {
  // false를 반환하면 의존 위젯들이 리빌드되지 않음
  return count != oldWidget.count;
}

// 여러 필드가 있을 때
@override
bool updateShouldNotify(AppData oldWidget) {
  return count != oldWidget.count ||
         name != oldWidget.name ||
         theme != oldWidget.theme;
}

InheritedModel — 세밀한 의존성 제어

InheritedWidget은 데이터가 변하면 모든 의존 위젯이 리빌드됩니다. InheritedModel을 사용하면 특정 부분(aspect)만 감시할 수 있습니다.

DART
class AppModel extends InheritedModel<String> {
  final int count;
  final String theme;

  const AppModel({
    super.key,
    required this.count,
    required this.theme,
    required super.child,
  });

  static AppModel of(BuildContext context, {String? aspect}) {
    return InheritedModel.inheritFrom<AppModel>(context, aspect: aspect)!;
  }

  @override
  bool updateShouldNotify(AppModel oldWidget) {
    return count != oldWidget.count || theme != oldWidget.theme;
  }

  @override
  bool updateShouldNotifyDependent(
    AppModel oldWidget,
    Set<String> dependencies,
  ) {
    if (dependencies.contains('count') && count != oldWidget.count) {
      return true;
    }
    if (dependencies.contains('theme') && theme != oldWidget.theme) {
      return true;
    }
    return false;
  }
}

// count만 감시 (theme 변경 시 리빌드 안 됨)
final model = AppModel.of(context, aspect: 'count');

of 패턴 관례

DART
// Theme.of(context) → ThemeData
// MediaQuery.of(context) → MediaQueryData
// Navigator.of(context) → NavigatorState
// Scaffold.of(context) → ScaffoldState

// 내 InheritedWidget도 같은 패턴으로
static MyData of(BuildContext context) {
  return context.dependOnInheritedWidgetOfExactType<MyData>()!;
}

// null 안전 버전
static MyData? maybeOf(BuildContext context) {
  return context.dependOnInheritedWidgetOfExactType<MyData>();
}

정리

  • BuildContext는 Element의 추상 인터페이스로, 트리에서 현재 위치를 나타냅니다
  • InheritedWidget은 dependOn으로 의존성을 등록한 위젯만 선택적으로 리빌드합니다
  • updateShouldNotify에서 실제 변경 여부를 비교하여 불필요한 리빌드를 방지합니다
  • Provider의 watch/readdependOn/getElement의 래퍼입니다
  • InheritedModel로 특정 속성만 감시하는 세밀한 최적화가 가능합니다
댓글 로딩 중...