Recent Posts
Recent Comments
반응형
«   2026/09   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Archives
Today
Total
관리 메뉴

오늘도 공부

Flutter Signals 실전 가이드 본문

스터디/Flutter

Flutter Signals 실전 가이드

행복한 수지아빠 2026. 8. 26. 14:17
반응형

Riverpod과 무엇이 다르고, 언제 사용해야 하는가

기준일: 2026년 8월 26일

패키지확인된 최신 버전

signals / signals_flutter 7.1.0
riverpod / flutter_riverpod 3.4.2

Signals 7.1.0은 세밀한 반응성, 자동 의존성 추적, 지연 계산, 부분적인 위젯 재빌드를 중심으로 설계되어 있습니다. Riverpod 3.4.2는 반응형 캐시, 데이터 바인딩, 비동기 상태, 의존성 주입, 생명주기와 테스트 오버라이드를 함께 다루는 프레임워크에 가깝습니다. (Dart packages)


1. 먼저 결론부터

Signals와 Riverpod은 모두 Flutter 상태를 반응형으로 관리하지만, 추상화 수준이 다릅니다.

Signals
└─ 값을 반응형으로 만드는 핵심 프리미티브

Riverpod
├─ 상태 관리
├─ 의존성 주입
├─ 비동기 캐시
├─ 생명주기
├─ 스코프
├─ 오버라이드
└─ 테스트 컨테이너

Signals 공식 문서도 Signals를 완성형 애플리케이션 프레임워크가 아니라, 다른 DI 방식과 함께 사용할 수 있는 핵심 반응성 라이브러리로 설명합니다. Riverpod은 공식적으로 반응형 캐싱 및 데이터 바인딩 프레임워크를 표방합니다. (dartsignals.dev)

[Inference] 실무적인 선택을 요약하면 다음과 같습니다.

상황우선 검토

화면 내부의 간단한 상태 Signals
편집기, 게임 UI, 실시간 수치처럼 자주 바뀌는 상태 Signals
기존에 GetIt, Provider 등 DI가 구축된 프로젝트 Signals 추가 도입
API 요청, 캐시, 재시도, 무효화가 많은 앱 Riverpod
테스트에서 구현체를 자주 교체해야 하는 앱 Riverpod
기능과 팀 규모가 큰 애플리케이션 Riverpod 또는 혼합 구조
이미 Riverpod으로 안정적으로 운영 중인 프로젝트 Riverpod 유지
Riverpod의 인프라와 Signals의 세밀한 UI 반응성이 모두 필요 Riverpod + Signals

기존 Riverpod 프로젝트를 Signals로 전면 교체해야 할 일반적인 이유는 크지 않습니다. Signals가 필요한 화면이나 기능부터 제한적으로 넣는 편이 위험이 작습니다.


2. Signals란 무엇인가

Signals의 핵심은 값을 읽은 곳을 자동으로 기억한다는 것입니다.

Signal<T>
    │
    ├── Computed<R>
    │       │
    │       ├── UI
    │       └── 다른 Computed
    │
    └── Effect

예를 들어 count.value를 SignalBuilder 안에서 읽으면, Signals가 해당 SignalBuilder와 count 사이의 의존성을 자동으로 연결합니다.

이후 count.value가 변경되면 count를 읽었던 SignalBuilder만 다시 빌드됩니다. 의존성을 직접 등록하는 ref.watch, addListener, notifyListeners와 같은 코드가 필요하지 않습니다. Signals는 이러한 자동 의존성 추적과 부분 재빌드를 핵심 기능으로 제공합니다. (Dart packages)


3. 설치 방법

Flutter에서는 통합 패키지인 signals를 설치하는 방식이 권장됩니다.

flutter pub add signals

Flutter 코드에서는 다음 경로로 가져옵니다.

import 'package:signals/signals_flutter.dart';

signals 통합 패키지는 실행 환경에 따라 Dart용 또는 Flutter용 바인딩을 제공하며, Flutter 전용 패키지인 signals_flutter를 직접 설치하는 방법도 있습니다. 현재 Flutter용 최신 안정 버전은 7.1.0입니다. (dartsignals.dev)


4. Signals의 네 가지 핵심 요소

4.1 signal: 변경 가능한 상태

signal()은 값을 담는 반응형 컨테이너를 만듭니다.

final count = signal(0);

void increment() {
  count.value++;
}

void reset() {
  count.value = 0;
}

값 읽기와 쓰기 모두 .value를 사용합니다.

print(count.value);

count.value = 10;

다만 .value를 읽었다고 해서 항상 구독되는 것은 아닙니다. SignalBuilder, SignalWidget, computed, effect처럼 반응성 컨텍스트 내부에서 읽었을 때 의존성이 등록됩니다. (Dart packages)


4.2 computed: 파생 상태

기존 상태로부터 계산되는 값은 computed()로 만듭니다.

final count = signal(0);

final doubled = computed(() {
  return count.value * 2;
});

final isEven = computed(() {
  return count.value.isEven;
});

사용할 때는 일반 Signal과 마찬가지로 .value를 읽습니다.

print(doubled.value);
print(isEven.value);

computed에는 다음 특성이 있습니다.

  • 읽힐 때 계산되는 지연 평가 방식입니다.
  • 의존성이 변경되지 않았다면 이전 계산 결과를 재사용합니다.
  • 콜백 내부에서 읽은 Signal을 자동으로 추적합니다.
  • 조건문에 따라 실제로 읽는 Signal이 바뀌면 의존성도 동적으로 조정됩니다.
  • 네트워크 요청이나 다른 Signal 변경 같은 부수 효과를 넣지 않는 것이 원칙입니다.

공식 문서도 computed 콜백을 순수 함수로 유지하고, 데이터베이스·네트워크 작업이나 다른 Signal 쓰기를 피하도록 설명합니다. (dartsignals.dev)


4.3 effect: 상태 변화에 따른 부수 효과

UI를 만드는 것이 아니라 로깅, 저장, 분석 이벤트 등의 작업을 실행해야 한다면 effect()를 사용합니다.

final count = signal(0);

final stopEffect = effect(() {
  debugPrint('현재 count: ${count.value}');
});

count.value++;
count.value++;

// 더 이상 effect가 필요하지 않을 때
stopEffect();

effect() 안에서 읽은 Signal이 변경되면 콜백이 다시 실행됩니다. effect()는 정리 함수도 반환하므로, 기능이나 위젯의 수명이 끝날 때 호출해야 합니다. (Dart packages)

잘못된 예: 순환 업데이트

final count = signal(0);

effect(() {
  count.value++;
});

이 코드는 count를 읽고 다시 변경하므로 effect가 자신을 계속 실행하는 순환 구조가 됩니다.

수정 방법

가능한 경우 상태 변경 로직을 effect 바깥의 명령 메서드로 옮기는 것이 좋습니다.

final count = signal(0);

void increment() {
  count.value++;
}

final stopEffect = effect(() {
  debugPrint('count changed: ${count.value}');
});

특수한 상황에서는 peek()이나 untracked()로 구독 없이 값을 읽을 수 있지만, 일반적인 상태 흐름에서는 .value가 우선입니다. (Dart packages)


4.4 batch: 여러 상태를 한 번에 변경

서로 관련된 Signal을 연속으로 변경할 때는 batch()를 사용할 수 있습니다.

final firstName = signal('Kim');
final lastName = signal('Minsoo');

final fullName = computed(
  () => '${firstName.value} ${lastName.value}',
);

void updateName() {
  batch(() {
    firstName.value = 'Park';
    lastName.value = 'Jisoo';
  });
}

batch() 안에서 여러 Signal을 변경하면 콜백이 끝나는 시점에 관련 구독자와 effect가 정리된 상태로 갱신됩니다. 중간 상태마다 effect가 반복 실행되는 상황을 줄일 수 있습니다. (Dart packages)


5. Flutter UI에서 Signals 사용하기

Signals 7에서는 다음 세 가지가 중심 API입니다.

API사용 시점

SignalBuilder 기존 위젯 안의 일부 영역만 반응형으로 만들 때
SignalWidget StatelessWidget 전체가 Signal을 사용할 때
SignalStatefulWidget StatefulWidget 생명주기와 Signal 반응성이 모두 필요할 때

예전 자료에서 자주 보이는 Watch 위젯과 SignalsMixin은 현재 deprecated 상태입니다. 신규 코드는 SignalBuilder, SignalWidget, SignalStatefulWidget을 기준으로 작성하는 것이 맞습니다. (dartsignals.dev)


5.1 SignalBuilder: 가장 명확한 부분 재빌드

import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';

final count = signal(0);
final doubled = computed(() => count.value * 2);

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Signals Counter'),
      ),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Text('이 텍스트는 count를 구독하지 않습니다.'),

            SignalBuilder(
              builder: (context) {
                return Column(
                  children: [
                    Text('Count: ${count.value}'),
                    Text('Doubled: ${doubled.value}'),
                  ],
                );
              },
            ),

            const SizedBox(height: 16),

            ElevatedButton(
              onPressed: () => count.value++,
              child: const Text('증가'),
            ),
          ],
        ),
      ),
    );
  }
}

SignalBuilder는 builder 안에서 읽은 Signal을 동적으로 추적합니다. 값이 바뀌면 주변 CounterPage 전체가 아니라 해당 SignalBuilder가 반환하는 영역이 다시 빌드됩니다. 공식 문서도 가능한 한 작은 하위 영역에 배치하는 패턴을 권장합니다. (dartsignals.dev)


5.2 SignalWidget: 위젯 자체를 반응형으로 만들기

class CounterText extends SignalWidget {
  const CounterText({super.key});

  @override
  Widget build(BuildContext context) {
    return Text(
      '현재 값: ${count.value}',
      style: Theme.of(context).textTheme.headlineMedium,
    );
  }
}

SignalWidget의 build() 안에서 동기적으로 읽은 Signal은 자동 구독됩니다.

class CounterPanel extends SignalWidget {
  const CounterPanel({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Count: ${count.value}'),
        Text('Doubled: ${doubled.value}'),
        Text('Even: ${count.value.isEven}'),
      ],
    );
  }
}

count가 변경되면 CounterPanel 요소가 다시 빌드됩니다. 위젯 내부의 아주 작은 부분만 갱신해야 한다면 SignalWidget 전체 대신 SignalBuilder를 더 아래쪽에 배치하는 편이 적합합니다. (Dart packages)


5.3 SignalStatefulWidget: 로컬 상태와 생명주기

class SearchPage extends SignalStatefulWidget {
  const SearchPage({super.key});

  @override
  State<SearchPage> createState() => _SearchPageState();
}

class _SearchPageState extends State<SearchPage> {
  final query = signal('');
  late final void Function() stopLoggingEffect;

  @override
  void initState() {
    super.initState();

    stopLoggingEffect = effect(() {
      debugPrint('검색어 변경: ${query.value}');
    });
  }

  @override
  void dispose() {
    stopLoggingEffect();
    query.dispose();

    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(
              onChanged: (value) {
                query.value = value;
              },
            ),
            const SizedBox(height: 16),
            Text('현재 검색어: ${query.value}'),
          ],
        ),
      ),
    );
  }
}

SignalStatefulWidget는 State의 build()가 실행되는 동안 동기적으로 읽은 Signal을 추적합니다. 비동기 콜백, 이벤트 리스너 또는 지연 작업 안에서 나중에 읽은 값은 build() 의존성으로 등록되지 않습니다. (dartsignals.dev)


6. Controller 또는 Store 패턴으로 사용하기

전역 변수로 Signal을 바로 선언하면 예제는 간단하지만, 규모가 커지면 상태 변경 위치를 추적하기 어려워집니다.

실제 프로젝트에서는 다음과 같이 변경 가능한 Signal을 비공개로 두고, 외부에는 ReadonlySignal을 노출하는 구조가 적합합니다.

import 'package:signals/signals_flutter.dart';

class CounterController {
  final Signal<int> _count = signal(0);

  late final Computed<int> _doubled = computed(
    () => _count.value * 2,
  );

  ReadonlySignal<int> get count => _count;
  ReadonlySignal<int> get doubled => _doubled;

  void increment() {
    _count.value++;
  }

  void decrement() {
    _count.value--;
  }

  void reset() {
    _count.value = 0;
  }

  void dispose() {
    _doubled.dispose();
    _count.dispose();
  }
}

UI는 Signal 값을 직접 수정하지 않고 Controller 메서드를 호출합니다.

class CounterView extends SignalWidget {
  const CounterView({
    required this.controller,
    super.key,
  });

  final CounterController controller;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Count: ${controller.count.value}'),
        Text('Doubled: ${controller.doubled.value}'),
        ElevatedButton(
          onPressed: controller.increment,
          child: const Text('증가'),
        ),
        ElevatedButton(
          onPressed: controller.reset,
          child: const Text('초기화'),
        ),
      ],
    );
  }
}

ReadonlySignal은 외부 소비자가 .value를 직접 변경하지 못하게 하고, 상태 변경을 Controller 메서드로 모으는 데 사용됩니다. 공식 문서에서도 private Signal과 public ReadonlySignal을 결합하는 방식을 단방향 상태 흐름 패턴으로 소개합니다. (dartsignals.dev)


7. 비동기 API 호출

Signals는 futureSignal()을 통해 Future를 AsyncState 형태로 표현할 수 있습니다.

final userId = signal(1);

final user = futureSignal(() async {
  // await 이전에 동기적으로 읽었으므로 의존성으로 추적됩니다.
  final id = userId.value;

  return userRepository.fetchUser(id);
});

userId.value가 바뀌면 user FutureSignal이 무효화되고 새로운 요청을 실행합니다.

userId.value = 2;

UI에서는 AsyncState.map()으로 로딩, 성공, 오류를 분기할 수 있습니다.

class UserView extends SignalWidget {
  const UserView({super.key});

  @override
  Widget build(BuildContext context) {
    return user.value.map(
      loading: () {
        return const CircularProgressIndicator();
      },
      data: (value) {
        return Text(value.name);
      },
      error: (error, stackTrace) {
        return Text('사용자 조회 실패: $error');
      },
    );
  }
}

FutureSignal은 다음 작업도 제공합니다.

user.refresh(); // 기존 데이터를 유지하면서 다시 요청
user.reload();  // 기존 상태를 버리고 Loading부터 다시 시작
user.reset();   // 초기 상태로 되돌림

Future 콜백 안에서 Signal을 의존성으로 만들려면 기본적으로 await 이전에 동기적으로 읽어야 합니다. await 이후 읽은 Signal까지 추적해야 할 경우에는 AsyncSignalOptions의 명시적 dependencies 설정이 필요합니다. (dartsignals.dev)


8. Riverpod의 현재 권장 사용 방식

Riverpod 3에서 사용자 액션에 따라 변경되는 상태는 Notifier 또는 AsyncNotifier가 중심입니다. 예전 코드에서 많이 사용되던 StateNotifier보다 현재 공식 문서의 권장 방향에 가깝습니다. (riverpod.dev)

Riverpod 카운터 예제

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final counterProvider =
    NotifierProvider<CounterNotifier, int>(
  CounterNotifier.new,
);

class CounterNotifier extends Notifier<int> {
  @override
  int build() {
    return 0;
  }

  void increment() {
    state++;
  }

  void reset() {
    state = 0;
  }
}

class CounterPage extends ConsumerWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);

    return Scaffold(
      body: Center(
        child: Text('Count: $count'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref
              .read(counterProvider.notifier)
              .increment();
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

Riverpod에서는 다음 역할이 명시적으로 구분됩니다.

// UI 또는 다른 Provider가 상태를 구독
final value = ref.watch(counterProvider);

// 상태 변경 객체를 한 번 읽음
final notifier = ref.read(counterProvider.notifier);

// 상태 변경에 따른 부수 효과
ref.listen(counterProvider, (previous, next) {
  debugPrint('$previous -> $next');
});

// 캐시를 버리고 다시 계산
ref.invalidate(counterProvider);

ref.watch는 선언적인 구독, ref.listen은 부수 효과, ref.read는 버튼 클릭과 같은 명령 실행에 사용됩니다. (riverpod.dev)


9. Signals와 Riverpod 상세 비교

비교 항목SignalsRiverpod

기본 성격 세밀한 반응성 프리미티브 반응형 캐싱 및 데이터 바인딩 프레임워크
상태 정의 signal(value)로 객체 생성 Provider, NotifierProvider 등 선언
상태 읽기 .value ref.watch(provider)
상태 변경 .value =, Controller 메서드 Notifier 메서드와 state
의존성 추적 반응성 컨텍스트에서 읽은 Signal을 자동 추적 ref.watch로 의존성을 명시
파생 상태 computed() Provider 안에서 다른 Provider를 ref.watch
부수 효과 effect(), SignalEffect ref.listen()
UI 연결 SignalBuilder, SignalWidget ConsumerWidget, Consumer
비동기 상태 FutureSignal, AsyncState FutureProvider, AsyncNotifier, AsyncValue
캐시 반응성 값 자체의 계산 캐시 중심 Provider 결과 캐시가 핵심 개념
DI 별도 DI, 생성자 주입, SignalProvider 등 선택 Provider 그래프 자체가 DI 역할
스코프 직접 설계하거나 SignalProvider 활용 ProviderScope, ProviderContainer
구현 교체 생성자 주입 또는 사용 중인 DI에서 처리 Provider override가 내장됨
자동 정리 Signal 옵션, Provider 또는 명시적 dispose autoDispose, ref.onDispose, keepAlive
파라미터 상태 직접 Factory 또는 Store 구조 설계 family 또는 codegen 파라미터
테스트 Controller를 일반 Dart 객체로 테스트 가능 ProviderContainer.test, override 제공
코드 생성 필수 아님 선택 사항
DevTools 초기 단계의 Signals DevTools 제공 전용 DevTools와 이전 상태 검사 제공

Signals의 의존성은 .value를 읽는 순간 자동으로 구성됩니다. Riverpod은 ref.watch를 통해 Provider 간 의존성을 코드에 명시하며, Provider의 반환값을 캐시합니다. (Dart packages)

Riverpod은 Provider별 자동 정리, ref.onDispose, keepAlive, 스코프별 override, 테스트 전용 ProviderContainer를 기본 설계에 포함합니다. Signals에서는 Signal 자체의 생명주기나 사용 중인 DI 컨테이너의 생명주기에 맞춰 관리해야 합니다. SignalProvider가 직접 생성한 Signal은 위젯 트리에서 제거될 때 dispose됩니다. (riverpod.dev)


10. 재빌드 방식 비교

Signals

SignalBuilder(
  builder: (context) {
    return Text('${count.value}');
  },
);

builder 안에서 실제로 읽은 Signal을 자동 추적합니다. count가 변경되면 해당 SignalBuilder만 다시 빌드됩니다.

Riverpod

class CountText extends ConsumerWidget {
  const CountText({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);

    return Text('$count');
  }
}

Riverpod은 counterProvider의 출력값이 변경되면 CountText를 다시 빌드합니다.

객체의 일부 속성만 구독하고 싶다면 select를 사용합니다.

final userName = ref.watch(
  userProvider.select((user) => user.name),
);

Riverpod 공식 문서는 select가 불필요한 재빌드를 줄일 수 있지만, 읽기 비용과 코드 복잡도가 조금 추가되므로 성능 측정 없이 일괄 적용하지 말라고 설명합니다. (riverpod.dev)

성능에 대한 정확한 해석

Signals는 반응성 단위를 잘게 나누기 쉽습니다. 그러나 이것만으로 모든 애플리케이션에서 Riverpod보다 빠르다고 결론내릴 수는 없습니다.

성능은 다음 요소의 영향을 함께 받습니다.

  • SignalBuilder 또는 Consumer의 배치 위치
  • 상태 객체의 크기
  • 변경 빈도
  • 위젯 트리 깊이
  • 리스트 및 이미지 렌더링 비용
  • API 호출 및 직렬화 비용
  • 파생 상태 계산량
  • 불변 객체 생성량

[Inference] 실무에서는 대표 화면을 대상으로 Flutter DevTools의 rebuild 통계와 프레임 시간을 측정한 후 판단하는 것이 적절합니다. Riverpod 공식 문서 역시 최적화 전에 벤치마크할 것을 권고합니다. (riverpod.dev)


11. 생명주기 비교

Signals

전역 Signal은 애플리케이션 종료까지 유지할 수 있지만, 기능 단위 상태라면 소유자가 명확해야 합니다.

class EditorStore {
  final title = signal('');
  final content = signal('');

  void dispose() {
    title.dispose();
    content.dispose();
  }
}
class EditorHostState extends State<EditorHost> {
  late final EditorStore store;

  @override
  void initState() {
    super.initState();
    store = EditorStore();
  }

  @override
  void dispose() {
    store.dispose();
    super.dispose();
  }
}

또는 SignalProvider가 Signal 생성과 정리를 담당하게 할 수 있습니다.

SignalProvider<MySignal>(
  create: () => MySignal(),
  child: const FeaturePage(),
);

외부에서 이미 만든 인스턴스를 제공하고 Provider가 dispose하지 않게 하려면 .value 생성자를 사용합니다.

SignalProvider<MySignal>.value(
  value: existingSignal,
  child: const FeaturePage(),
);

SignalProvider의 기본 생성자는 내부에서 생성한 Signal의 생명주기를 관리하고, .value 생성자는 외부 인스턴스의 생명주기에 관여하지 않습니다. (dartsignals.dev)

Riverpod

final searchProvider = Provider<SearchService>(
  isAutoDispose: true,
  (ref) {
    final service = SearchService();

    ref.onDispose(service.dispose);

    return service;
  },
);

Riverpod codegen을 사용하면 자동 정리가 기본값이며, codegen을 사용하지 않을 때는 isAutoDispose: true로 활성화할 수 있습니다. 마지막 리스너가 제거된 뒤 한 프레임 동안 다시 사용되지 않으면 Provider 상태가 제거되고 ref.onDispose가 호출됩니다. (riverpod.dev)


12. 테스트와 의존성 교체

Signals 테스트

Controller가 Flutter에 의존하지 않는다면 일반 Dart 단위 테스트로 검증할 수 있습니다.

void main() {
  test('increment increases count', () {
    final controller = CounterController();

    controller.increment();

    expect(controller.count.value, 1);

    controller.dispose();
  });
}

Repository를 생성자로 받도록 만들면 테스트 구현체를 직접 전달할 수 있습니다.

class LoginController {
  LoginController(this.repository);

  final AuthRepository repository;
  final loading = signal(false);

  Future<void> login() async {
    // ...
  }
}
final controller = LoginController(
  FakeAuthRepository(),
);

Riverpod 테스트

Riverpod은 Container와 Provider override가 정식 API로 포함되어 있습니다.

void main() {
  test('provider override example', () {
    final container = ProviderContainer.test(
      overrides: [
        authRepositoryProvider.overrideWithValue(
          FakeAuthRepository(),
        ),
      ],
    );

    final repository = container.read(
      authRepositoryProvider,
    );

    expect(repository, isA<FakeAuthRepository>());
  });
}

모든 Provider는 ProviderScope 또는 ProviderContainer에서 다른 값이나 구현으로 교체할 수 있습니다. 이 기능은 테스트뿐 아니라 개발·운영 환경별 구현 교체에도 활용됩니다. (riverpod.dev)

[Inference] 복잡한 의존성 그래프에서 테스트마다 Repository, API, 인증 상태를 교체해야 한다면 Riverpod의 override 체계가 더 구조적입니다. Signals는 일반 객체 테스트가 단순하지만, DI 및 스코프 체계는 프로젝트가 직접 선택해야 합니다.


13. Riverpod 코드를 Signals로 옮길 때의 대응 관계

RiverpodSignals에서의 유사 개념

NotifierProvider Controller 또는 Store + private Signal
ref.watch(provider) 반응성 컨텍스트에서 signal.value 읽기
Provider 파생값 computed()
ref.listen() effect()
ref.read(provider.notifier).method() Controller 메서드 호출
FutureProvider futureSignal()
AsyncValue<T> AsyncState<T>
ref.invalidate() FutureSignal의 reload, refresh, reset 또는 직접 상태 초기화
autoDispose SignalProvider, 명시적 dispose, Signal 옵션
family 파라미터별 Controller Factory 또는 별도 DI 구조
Provider override 생성자 주입, DI override, SignalProvider.value
ProviderScope 별도 DI Scope 또는 SignalProvider 트리

중요한 점은 완전한 일대일 변환이 아니라는 것입니다.

예를 들어 Riverpod의 Provider는 단순 계산 함수만 의미하지 않습니다. ProviderContainer 내부의 캐시, 생명주기, 의존성 그래프와 함께 동작합니다. 따라서 Riverpod의 Provider를 무조건 computed()로 바꾸면 캐시 무효화와 스코프 의미가 달라질 수 있습니다. (riverpod.dev)


14. Riverpod과 Signals를 함께 사용하는 방법

Signals 공식 문서에는 Riverpod Provider가 Signal을 생성해 제공하는 예제가 포함되어 있습니다. 즉, 두 라이브러리는 기술적으로 배타적인 관계가 아닙니다. (dartsignals.dev)

[Inference] 실무에서는 다음과 같이 역할을 구분할 수 있습니다.

Riverpod
├─ API Client
├─ Repository
├─ 로그인 세션
├─ 앱 설정
├─ 원격 데이터 캐시
└─ 기능별 Store 생성 및 생명주기

Signals
├─ 화면 입력 상태
├─ 편집기 선택 상태
├─ 드래그 좌표
├─ 확대·축소 배율
├─ 재생 위치
└─ 자주 변경되는 파생 UI 값

혼합 예제

final editorRepositoryProvider =
    Provider<EditorRepository>((ref) {
  return EditorRepositoryImpl();
});

final editorStoreProvider = Provider<EditorStore>((ref) {
  final repository = ref.watch(
    editorRepositoryProvider,
  );

  final store = EditorStore(repository);

  ref.onDispose(store.dispose);

  return store;
});

Riverpod으로 Store 생성과 수명을 관리하고, Store 내부에서는 Signals를 사용합니다.

class EditorStore {
  EditorStore(this.repository);

  final EditorRepository repository;

  final zoom = signal(1.0);
  final selectedLayerId = signal<String?>(null);
  final isSaving = signal(false);

  late final canZoomIn = computed(
    () => zoom.value < 4.0,
  );

  void zoomIn() {
    if (!canZoomIn.value) {
      return;
    }

    zoom.value += 0.1;
  }

  void selectLayer(String id) {
    selectedLayerId.value = id;
  }

  void dispose() {
    canZoomIn.dispose();
    zoom.dispose();
    selectedLayerId.dispose();
    isSaving.dispose();
  }
}

UI에서는 Riverpod으로 Store 인스턴스를 받고, SignalBuilder로 필요한 영역만 구독합니다.

class EditorToolbar extends ConsumerWidget {
  const EditorToolbar({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final store = ref.watch(editorStoreProvider);

    return SignalBuilder(
      builder: (context) {
        return Row(
          children: [
            Text(
              '${(store.zoom.value * 100).round()}%',
            ),
            IconButton(
              onPressed: store.canZoomIn.value
                  ? store.zoomIn
                  : null,
              icon: const Icon(Icons.zoom_in),
            ),
          ],
        );
      },
    );
  }
}

[Inference] 이 구조에서는 Riverpod이 애플리케이션 수준의 의존성과 생명주기를 담당하고, Signals가 기능 내부의 세밀한 반응성을 담당합니다. 만화 편집기, 오디오 플레이어, 지도, 게임 UI처럼 빈번한 상태 변경이 있는 프로젝트에 적용하기 좋은 형태입니다.


15. Signals 사용 시 주의할 점

15.1 build() 안에서 Signal을 생성하지 않기

@override
Widget build(BuildContext context) {
  // 잘못된 위치
  final count = signal(0);

  return Text('${count.value}');
}

위젯이 다시 빌드될 때마다 새로운 Signal 인스턴스가 생성됩니다.

Signal은 다음 위치 중 하나에 두는 것이 적절합니다.

  • Controller 또는 Store 필드
  • State 객체의 필드
  • Provider 또는 DI Container
  • 애플리케이션 수명과 동일한 제한적 전역 상태

Signals는 build() 안에서 Signal을 생성하는 패턴을 탐지하는 별도 lint 패키지도 제공합니다. (dartsignals.dev)


15.2 SignalWidget을 지나치게 크게 만들지 않기

class EntireHomePage extends SignalWidget {
  // 수많은 Signal을 읽는 매우 큰 위젯
}

이 위젯에서 여러 Signal을 읽으면 그중 하나만 바뀌어도 해당 위젯 요소가 다시 빌드됩니다.

변경 빈도가 다른 영역은 작은 위젯이나 SignalBuilder로 분리하는 편이 적절합니다.

Column(
  children: [
    const StaticHeader(),
    SignalBuilder(
      builder: (_) => CounterSection(),
    ),
    SignalBuilder(
      builder: (_) => ProgressSection(),
    ),
  ],
);

Signals 공식 문서도 부분 재빌드를 위해 SignalBuilder를 작은 하위 영역에 배치하는 패턴을 설명합니다. (dartsignals.dev)


15.3 effect에서 상태를 무분별하게 변경하지 않기

effect(() {
  total.value = price.value * quantity.value;
});

이 값이 파생 상태라면 effect보다 computed()가 적합합니다.

final total = computed(
  () => price.value * quantity.value,
);

effect는 로깅, 파일 저장, 분석 이벤트, 외부 시스템 호출처럼 반응형 그래프 밖으로 나가는 작업에 사용하는 편이 좋습니다. 파생 상태는 computed로 표현해야 지연 계산과 캐시 특성을 활용할 수 있습니다. (dartsignals.dev)


15.4 List를 제자리에서 수정할 때 주의하기

다음과 같이 기존 List 인스턴스를 제자리에서 수정하면 일반적인 값 비교만으로 변경이 감지되지 않을 수 있습니다.

final todos = signal<List<Todo>>([]);

todos.value.add(newTodo);

새 객체를 할당하는 방식이 명확합니다.

todos.value = [
  ...todos.value,
  newTodo,
];

또는 강제 갱신이 필요한 경우 다음 API를 사용할 수 있습니다.

todos.value.add(newTodo);
todos.set(todos.value, force: true);

Signals에는 ListSignal, MapSignal, SetSignal과 같은 반응형 컬렉션도 포함되어 있습니다. (dartsignals.dev)


15.5 오래된 Watch 예제를 그대로 사용하지 않기

과거 예제:

Watch(
  (context) => Text('$count'),
);

현재 방식:

SignalBuilder(
  builder: (context) {
    return Text('${count.value}');
  },
);

Signals 7 기준으로 Watch는 deprecated이며 SignalBuilder가 대체 API입니다. 또한 Signals 7의 SignalBuilder는 builder:라는 이름 있는 파라미터를 사용합니다. (dartsignals.dev)


16. 추천 프로젝트 구조

[Inference] Signals를 단순 전역 변수 모음으로 사용하기보다 기능별 Store 구조를 잡는 것이 유지보수에 유리합니다.

lib/
├─ core/
│  ├─ network/
│  ├─ storage/
│  └─ di/
│
├─ features/
│  └─ cart/
│     ├─ data/
│     │  ├─ cart_api.dart
│     │  └─ cart_repository_impl.dart
│     │
│     ├─ domain/
│     │  ├─ cart_item.dart
│     │  └─ cart_repository.dart
│     │
│     ├─ application/
│     │  └─ cart_store.dart
│     │
│     └─ presentation/
│        ├─ cart_page.dart
│        └─ cart_total_view.dart
│
└─ main.dart

cart_store.dart는 다음 원칙으로 구성할 수 있습니다.

class CartStore {
  CartStore(this.repository);

  final CartRepository repository;

  final Signal<List<CartItem>> _items = signal([]);

  final Signal<bool> _loading = signal(false);

  final Signal<Object?> _error = signal(null);

  ReadonlySignal<List<CartItem>> get items => _items;

  ReadonlySignal<bool> get loading => _loading;

  ReadonlySignal<Object?> get error => _error;

  late final Computed<int> itemCount = computed(
    () => _items.value.length,
  );

  late final Computed<int> totalPrice = computed(
    () => _items.value.fold(
      0,
      (sum, item) => sum + item.price * item.quantity,
    ),
  );

  Future<void> load() async {
    batch(() {
      _loading.value = true;
      _error.value = null;
    });

    try {
      _items.value = await repository.getItems();
    } catch (error) {
      _error.value = error;
    } finally {
      _loading.value = false;
    }
  }

  void remove(String itemId) {
    _items.value = _items.value
        .where((item) => item.id != itemId)
        .toList();
  }

  void dispose() {
    itemCount.dispose();
    totalPrice.dispose();
    _items.dispose();
    _loading.dispose();
    _error.dispose();
  }
}

핵심 규칙은 다음과 같습니다.

  1. 변경 가능한 Signal은 비공개로 둡니다.
  2. 외부에는 ReadonlySignal을 노출합니다.
  3. UI는 Signal을 직접 수정하지 않고 Store 메서드를 호출합니다.
  4. 파생값은 computed로 표현합니다.
  5. 외부 부수 효과만 effect에 배치합니다.
  6. Store를 생성한 계층이 Store의 정리도 담당합니다.

17. 최종 선택 기준

Signals가 잘 맞는 경우

[Inference]

  • 상태 모델이 비교적 단순합니다.
  • 코드 생성 없이 빠르게 개발하고 싶습니다.
  • 화면의 일부 값이 자주 변경됩니다.
  • 편집기, 플레이어, 게임, 지도처럼 세밀한 갱신이 중요합니다.
  • 이미 GetIt, Provider 또는 자체 DI가 있습니다.
  • Controller를 일반 Dart 객체로 구성하고 싶습니다.
  • React, SolidJS, Preact 스타일의 반응성에 익숙합니다.

Riverpod이 잘 맞는 경우

[Inference]

  • Repository와 API 의존성이 많습니다.
  • 비동기 상태와 캐시가 핵심입니다.
  • 로그인 세션이나 앱 설정처럼 여러 화면이 공유하는 상태가 많습니다.
  • Provider 무효화와 자동 정리가 중요합니다.
  • 테스트에서 의존성을 자주 교체합니다.
  • 기능별 Scope가 필요합니다.
  • 여러 개발자가 동일한 아키텍처 규칙을 따라야 합니다.
  • DevTools에서 전체 Provider 상태를 관찰하고 싶습니다.

Riverpod은 모든 Provider 상태를 확인하고 이전 상태를 살펴볼 수 있는 전용 Flutter DevTools 확장과 Riverpod 전용 lint/refactor 기능을 제공합니다. (riverpod.dev)


18. 종합 평가

[Inference] Signals는 Riverpod의 단순 대체재라기보다, Riverpod보다 낮은 수준에서 반응성을 제공하는 도구로 보는 것이 정확합니다.

Signals가 주로 답하는 질문
“이 값이 바뀌었을 때 필요한 UI만 어떻게 반응시킬 것인가?”

Riverpod이 주로 답하는 질문
“상태를 어디서 생성하고, 캐시하고, 공유하고,
언제 제거하며, 테스트에서 어떻게 교체할 것인가?”

따라서 기존 Riverpod 프로젝트에서는 다음 전략이 가장 현실적입니다.

1. 현재 Riverpod 구조는 유지
2. 빈번하게 갱신되는 복잡한 화면을 선별
3. 해당 기능 내부에만 Signals Store 도입
4. Riverpod이 Store 생성과 dispose 담당
5. 실제 rebuild 및 frame 성능 측정
6. 효과가 확인된 영역만 점진적으로 확대

앱 전체가 API 중심의 일반적인 업무 앱이라면 Riverpod만으로 충분한 경우가 많습니다. 반대로 만화 편집기, 실시간 자막, 오디오 재생 위치, 지도 이동, 드래그·리사이즈, 게임 HUD처럼 짧은 시간에 여러 값이 반복적으로 바뀌는 화면에서는 Signals 또는 Riverpod과 Signals의 혼합 구조를 검토할 가치가 있습니다.

반응형