Recent Posts
Recent Comments
반응형
«   2026/07   »
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 31
Archives
Today
Total
관리 메뉴

오늘도 공부

Flutter 상태관리에도 Signals가 온다: Riverpod, BLoC 이후의 선택지가 될 수 있을까? 본문

스터디/Flutter

Flutter 상태관리에도 Signals가 온다: Riverpod, BLoC 이후의 선택지가 될 수 있을까?

행복한 수지아빠 2026. 7. 3. 09:57
반응형

Flutter에서 상태관리는 늘 중요한 주제입니다.
처음에는 setState로 충분해 보입니다. 하지만 화면이 복잡해지고, 상태가 여러 위젯에 걸쳐 공유되기 시작하면 이야기가 달라집니다.

검색창에 글자 하나를 입력했을 뿐인데 카드 목록 전체가 다시 그려진다거나, 좋아요 버튼 하나를 눌렀는데 부모 위젯과 형제 위젯까지 rebuild되는 상황을 경험해 본 적이 있다면, 이 글의 주제는 꽤 흥미로울 수 있습니다.

최근 Flutter 생태계에서 다시 주목받고 있는 개념이 있습니다. 바로 Signals입니다.

Signals는 새로운 개념은 아닙니다. 웹 프론트엔드에서는 이미 SolidJS, Preact, Angular 등에서 fine-grained reactivity, 즉 세밀한 반응형 업데이트 방식으로 활용되어 왔습니다. Flutter에서도 signals와 signals_flutter 패키지를 통해 비슷한 방식의 상태관리를 사용할 수 있습니다. signals 패키지는 값을 담는 signal을 만들고 .value로 읽거나 업데이트하는 방식을 제공한다고 설명합니다.

이 글에서는 Signals가 무엇인지, 기존 setState, BLoC, Riverpod과 무엇이 다른지, 그리고 실제 Flutter 앱에서 어떤 식으로 적용할 수 있는지 정리해 보겠습니다.


1. Flutter rebuild 문제는 왜 생길까?

Flutter의 UI는 상태가 바뀌면 다시 build됩니다. 이 자체는 문제가 아닙니다. Flutter는 원래 선언형 UI 프레임워크이고, 상태 변화에 따라 UI를 다시 그리는 방식으로 동작합니다.

문제는 불필요한 rebuild입니다.

예를 들어 하나의 ChangeNotifier 안에 다음과 같은 상태가 모두 들어 있다고 해봅시다.

class ProductViewModel extends ChangeNotifier {
  String searchKeyword = '';
  List<Product> products = [];
  bool isLoading = false;
  int selectedCategoryId = 0;
  bool showOnlyFavorites = false;

  void updateSearchKeyword(String value) {
    searchKeyword = value;
    notifyListeners();
  }
}

이 구조에서는 searchKeyword 하나만 바뀌어도 notifyListeners()가 호출됩니다. 그러면 이 notifier를 구독하고 있는 여러 위젯이 함께 반응할 수 있습니다.

검색창에 글자 하나를 입력했을 뿐인데 상품 카드 목록, 필터 영역, 상단 배너까지 다시 build될 수 있습니다.

물론 해결책은 있습니다.

Selector를 쓰거나, notifier를 쪼개거나, provider 구조를 세분화하거나, rebuild 범위를 직접 조절하면 됩니다. 하지만 이 작업은 꽤 번거롭습니다.

상태가 복잡해질수록 개발자는 계속 이런 고민을 하게 됩니다.

“이 위젯이 정말 이 상태를 구독해야 하나?”
“이 provider를 더 쪼개야 하나?”
“여기서 Consumer를 감싸는 위치가 맞나?”
“왜 이 카드까지 rebuild되지?”

Signals는 바로 이 지점에서 등장합니다.


2. Signals란 무엇인가?

Signals는 쉽게 말해 반응형 값을 담는 컨테이너입니다.

값 하나를 signal로 만들고, 그 값을 읽는 곳이 자동으로 의존성으로 등록됩니다. 이후 값이 바뀌면, 그 값을 실제로 읽은 부분만 다시 업데이트됩니다.

가장 단순한 예시는 다음과 같습니다.

import 'package:signals/signals.dart';

final count = signal(0);

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

count는 일반적인 int가 아닙니다.
int 값을 담고 있는 signal입니다.

값을 읽을 때는 다음처럼 .value를 사용합니다.

print(count.value);

값을 바꿀 때도 .value를 사용합니다.

count.value = 10;

여기까지 보면 ValueNotifier와 비슷해 보일 수 있습니다. 하지만 Signals의 핵심은 단순히 값을 감싸는 것이 아닙니다.

핵심은 fine-grained dependency tracking, 즉 세밀한 의존성 추적입니다.

어떤 계산식이 어떤 signal을 읽었는지, 어떤 위젯이 어떤 signal을 읽었는지를 런타임이 추적합니다. 그래서 count가 바뀌면 count를 실제로 읽은 곳만 반응합니다.


3. computed: signal에서 파생된 값 만들기

Signals에는 computed라는 개념도 있습니다.

computed는 하나 이상의 signal을 읽어서 새로운 값을 계산합니다. 그리고 의존하는 signal이 바뀌면 자동으로 다시 계산됩니다.

final count = signal(0);

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

위 코드에서 isEven은 count에 의존합니다.

count.value가 0이면 isEven.value는 true입니다.
count.value가 1이면 isEven.value는 false입니다.

count.value = 1;

print(isEven.value); // false

이 방식은 UI에서 자주 유용합니다.

예를 들어 퀴즈 앱이라면 다음과 같은 상태를 만들 수 있습니다.

final selectedAnswerIndex = signal<int?>(null);
final correctAnswerIndex = signal(2);

final isCorrect = computed(() {
  return selectedAnswerIndex.value == correctAnswerIndex.value;
});

사용자가 선택한 답이 바뀌면 isCorrect도 자동으로 바뀝니다.


4. Flutter에서는 SignalBuilder를 사용한다

Flutter에서 signal 값을 UI에 연결하려면 signals_flutter 쪽 기능을 사용합니다.

signals_flutter 패키지는 Flutter 위젯 트리에 signal을 연결해 국소적인 UI 업데이트를 가능하게 한다고 설명합니다.

가장 기본적인 패턴은 SignalBuilder입니다.

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

final count = signal(0);
final isEven = computed(() => count.value.isEven);

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

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        SignalBuilder(
          builder: (context) {
            return Text(
              'Count: ${count.value} (${isEven.value ? "even" : "odd"})',
            );
          },
        ),
        ElevatedButton(
          onPressed: () {
            count.value++;
          },
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

이 코드에서 중요한 부분은 SignalBuilder 안입니다.

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

SignalBuilder의 builder 안에서 count.value를 읽고 있습니다.
그러면 이 builder는 count에 의존하는 UI가 됩니다.

이후 count.value++가 실행되면, SignalBuilder 안쪽만 다시 build됩니다.

부모 Column 전체가 다시 build되는 것이 아닙니다.
형제 위젯이 다시 build되는 것도 아닙니다.
count.value를 읽은 builder만 다시 실행됩니다.

이것이 Signals가 말하는 “surgical rebuild”, 즉 수술하듯 정밀한 rebuild의 핵심입니다.


5. Watch는 이제 피하는 것이 좋다

Signals 관련 예전 글이나 튜토리얼을 보면 Watch 위젯을 사용하는 예제가 나올 수 있습니다.

예전에는 이런 식의 코드가 사용되었습니다.

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

하지만 현재 문서에서는 Watch가 deprecated로 표시되어 있고, SignalBuilder 사용을 권장합니다. 공식 문서에는 Watch가 deprecated된 위젯이며, 더 일관적인 API와 self-contained reactivity를 위해 SignalBuilder를 사용하라고 안내되어 있습니다.

또한 .watch(context) extension도 signals v7에서 deprecated되었고, Flutter build lifecycle과 관련된 예상치 못한 side effect 및 불필요한 rebuild 가능성 때문에 SignalBuilder, SignalWidget, SignalStatefulWidget 같은 전용 reactive component로 마이그레이션하라고 설명합니다.

따라서 새 프로젝트에서 Signals를 사용한다면 Watch보다는 다음 패턴을 기준으로 잡는 것이 좋습니다.

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

6. setState와 비교하면 무엇이 다른가?

setState는 Flutter에서 가장 기본적인 상태관리 방식입니다.

setState(() {
  count++;
});

간단하고, 별도 패키지도 필요 없습니다. 작은 위젯 내부 상태라면 여전히 좋은 선택입니다.

하지만 setState는 해당 State 객체 아래의 위젯 트리를 다시 build합니다. 따라서 상태를 어디에 두느냐가 중요합니다.

예를 들어 다음 구조를 생각해 봅시다.

class ProductPageState extends State<ProductPage> {
  String keyword = '';

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        SearchInput(
          onChanged: (value) {
            setState(() {
              keyword = value;
            });
          },
        ),
        ProductGrid(),
        RecommendedBanner(),
      ],
    );
  }
}

이 경우 keyword가 바뀔 때마다 ProductPageState의 build가 다시 실행됩니다.
구조에 따라 ProductGrid, RecommendedBanner도 영향을 받을 수 있습니다.

Signals를 사용하면 검색어를 별도 signal로 분리하고, 검색어를 실제로 읽는 위젯만 반응하게 만들 수 있습니다.

final keyword = signal('');

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

  @override
  Widget build(BuildContext context) {
    return TextField(
      onChanged: (value) {
        keyword.value = value;
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return SignalBuilder(
      builder: (context) {
        return Text('검색어: ${keyword.value}');
      },
    );
  }
}

이 구조에서는 keyword.value를 읽는 SignalBuilder만 반응합니다.


7. BLoC와 비교하면 무엇이 다른가?

BLoC는 이벤트와 상태 전이를 명확하게 분리합니다.

예를 들어 검색 기능을 BLoC로 만들면 대략 다음과 같은 구조가 됩니다.

abstract class SearchEvent {}

class SearchKeywordChanged extends SearchEvent {
  final String keyword;

  SearchKeywordChanged(this.keyword);
}

class SearchState {
  final String keyword;

  SearchState({required this.keyword});
}

그리고 Bloc 클래스에서 이벤트를 받아 상태를 변경합니다.

class SearchBloc extends Bloc<SearchEvent, SearchState> {
  SearchBloc() : super(SearchState(keyword: '')) {
    on<SearchKeywordChanged>((event, emit) {
      emit(SearchState(keyword: event.keyword));
    });
  }
}

UI에서는 BlocBuilder를 사용합니다.

BlocBuilder<SearchBloc, SearchState>(
  builder: (context, state) {
    return Text('검색어: ${state.keyword}');
  },
)

BLoC의 장점은 명확합니다.

비즈니스 로직이 복잡하고, 이벤트 흐름을 명시적으로 관리해야 하며, 테스트가 중요한 프로젝트에서는 좋은 구조를 제공합니다.

하지만 단점도 있습니다.
간단한 상태 하나를 관리하기 위해 이벤트 클래스, 상태 클래스, bloc 클래스, builder를 모두 만들어야 할 수 있습니다.

Signals는 훨씬 가볍습니다.

final keyword = signal('');

그리고 UI에서 읽습니다.

SignalBuilder(
  builder: (context) {
    return Text('검색어: ${keyword.value}');
  },
)

이 차이는 작은 앱이나 UI 중심 앱에서 크게 느껴집니다.

다만 BLoC가 제공하는 명시적인 이벤트 기록, 상태 전이 구조, 팀 단위 컨벤션은 Signals가 자동으로 제공하는 영역은 아닙니다. 복잡한 도메인 로직이 많은 앱이라면 BLoC가 여전히 더 적합할 수 있습니다.


8. Riverpod과 비교하면 무엇이 다른가?

Riverpod은 현재 Flutter 실무에서 매우 강력한 선택지입니다.
비동기 상태, 의존성 주입, 캐싱, 테스트, provider override 등에서 장점이 큽니다. pub.dev의 Riverpod 설명도 비동기 코드 작업을 쉽게 해주는 reactive caching and data-binding framework라고 소개합니다.

Riverpod에서는 상태를 provider 단위로 관리합니다.

final keywordProvider = StateProvider<String>((ref) => '');

UI에서는 다음처럼 읽습니다.

final keyword = ref.watch(keywordProvider);

또는 특정 값만 구독하고 싶다면 select를 사용할 수 있습니다.

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

Riverpod도 충분히 세밀한 최적화가 가능합니다.
하지만 기본 사고방식은 provider 중심입니다.

Signals의 사고방식은 조금 다릅니다.

Riverpod이 “이 provider가 바뀌었으니 구독자가 반응한다”에 가깝다면, Signals는 “이 signal 값을 실제로 읽은 표현식만 반응한다”에 가깝습니다.

즉, Signals는 provider 단위보다 더 작은 값 단위의 반응성을 지향합니다.

간단히 비교하면 다음과 같습니다.

구분RiverpodSignals

중심 단위 Provider Signal value
강점 async, DI, override, 테스트, 구조화 값 단위 반응성, 적은 boilerplate, 국소 rebuild
적합한 영역 앱 전역 상태, 서버 상태, 인증, API 데이터 UI 상태, 입력값, 필터, 선택 상태, 자주 바뀌는 작은 상태
팀 컨벤션 상대적으로 풍부함 아직 작고 발전 중
학습 난이도 중간 낮은 편

따라서 Signals가 Riverpod을 완전히 대체한다고 보기는 어렵습니다.

현실적인 접근은 둘을 함께 쓰는 것입니다.


9. 추천 아키텍처: Riverpod + Signals 조합

실무에서는 다음 조합이 꽤 현실적입니다.

Riverpod:
- 로그인 사용자
- API 호출 결과
- 서버 상태
- 앱 설정
- 인증 상태
- repository/service 주입
- async loading/error/data 상태

Signals:
- 검색어
- 탭 선택
- 필터 선택
- 좋아요 버튼 UI
- 카드 선택 상태
- 폼 입력값
- 퀴즈 선택 답안
- 로컬 UI 상태

예를 들어 한국어 퀴즈 앱을 만든다고 가정해 보겠습니다.

앱 전체 데이터는 Riverpod으로 관리할 수 있습니다.

final quizRepositoryProvider = Provider<QuizRepository>((ref) {
  return QuizRepository();
});

final quizListProvider = FutureProvider<List<Quiz>>((ref) async {
  final repository = ref.watch(quizRepositoryProvider);
  return repository.fetchQuizzes();
});

반면 사용자가 현재 고른 답안, 현재 문제 index, 정답 여부 같은 화면 내부 상태는 Signals로 관리할 수 있습니다.

final currentQuestionIndex = signal(0);
final selectedAnswerIndex = signal<int?>(null);

final isAnswered = computed(() {
  return selectedAnswerIndex.value != null;
});

UI에서는 다음처럼 사용할 수 있습니다.

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

  @override
  Widget build(BuildContext context) {
    return SignalBuilder(
      builder: (context) {
        if (!isAnswered.value) {
          return const Text('답을 선택해 주세요.');
        }

        return Text('선택한 답: ${selectedAnswerIndex.value}');
      },
    );
  }
}

이렇게 하면 서버 데이터와 앱 구조는 Riverpod이 담당하고, 자주 바뀌는 UI 상태는 Signals가 담당합니다.

둘의 역할이 충돌하지 않습니다.
오히려 서로 보완할 수 있습니다.


10. 예제: 검색 화면을 Signals로 만들기

이번에는 조금 더 현실적인 예제를 보겠습니다.

상품 검색 화면이 있다고 가정하겠습니다.

요구사항은 다음과 같습니다.

  1. 사용자가 검색어를 입력한다.
  2. 검색어에 따라 상품 목록이 필터링된다.
  3. 검색어가 비어 있으면 전체 상품을 보여준다.
  4. 검색어 미리보기 영역만 검색어 변경에 반응한다.
  5. 필터링된 목록 영역만 필터 결과에 반응한다.

먼저 상태를 정의합니다.

import 'package:signals/signals.dart';

class Product {
  final String name;
  final int price;

  Product({
    required this.name,
    required this.price,
  });
}

final searchKeyword = signal('');

final products = signal<List<Product>>([
  Product(name: 'Korean Grammar Book', price: 18000),
  Product(name: 'TOPIK Vocabulary Cards', price: 12000),
  Product(name: 'Hangul Writing Notebook', price: 8000),
  Product(name: 'Korean Listening Practice', price: 15000),
]);

final filteredProducts = computed(() {
  final keyword = searchKeyword.value.trim().toLowerCase();

  if (keyword.isEmpty) {
    return products.value;
  }

  return products.value.where((product) {
    return product.name.toLowerCase().contains(keyword);
  }).toList();
});

이제 UI를 만듭니다.

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

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('상품 검색'),
      ),
      body: Column(
        children: const [
          SearchBox(),
          SearchKeywordLabel(),
          Expanded(
            child: ProductList(),
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return TextField(
      decoration: const InputDecoration(
        hintText: '검색어를 입력하세요',
      ),
      onChanged: (value) {
        searchKeyword.value = value;
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return SignalBuilder(
      builder: (context) {
        final keyword = searchKeyword.value;

        if (keyword.isEmpty) {
          return const Text('전체 상품을 보고 있습니다.');
        }

        return Text('"$keyword" 검색 중');
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return SignalBuilder(
      builder: (context) {
        final items = filteredProducts.value;

        if (items.isEmpty) {
          return const Center(
            child: Text('검색 결과가 없습니다.'),
          );
        }

        return ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) {
            final product = items[index];

            return ListTile(
              title: Text(product.name),
              subtitle: Text('${product.price}원'),
            );
          },
        );
      },
    );
  }
}

이 구조에서 SearchBox 자체는 signal 값을 읽지 않습니다.
따라서 검색어가 바뀐다고 SearchBox가 다시 build될 필요가 없습니다.

SearchKeywordLabel은 searchKeyword.value를 읽습니다.
따라서 검색어가 바뀌면 이 영역이 반응합니다.

ProductList는 filteredProducts.value를 읽습니다.
따라서 검색어 변화로 필터링 결과가 바뀌면 목록 영역이 반응합니다.

상태 변화가 발생했을 때, 전체 페이지가 아니라 필요한 UI 조각만 반응하는 구조를 만들 수 있습니다.


11. 예제: 퀴즈 앱에서 Signals 사용하기

이번에는 교육 앱 예제를 보겠습니다.

한국어 학습 앱에서 퀴즈를 푸는 화면을 만든다고 가정해 보겠습니다.

class QuizQuestion {
  final String question;
  final List<String> options;
  final int correctIndex;

  QuizQuestion({
    required this.question,
    required this.options,
    required this.correctIndex,
  });
}

상태는 다음처럼 만들 수 있습니다.

final currentQuestion = signal(
  QuizQuestion(
    question: '"밥을 먹어요"의 과거형은?',
    options: [
      '밥을 먹어요',
      '밥을 먹었어요',
      '밥을 먹을 거예요',
      '밥을 먹고 있어요',
    ],
    correctIndex: 1,
  ),
);

final selectedIndex = signal<int?>(null);

final hasSelected = computed(() {
  return selectedIndex.value != null;
});

final isCorrect = computed(() {
  final selected = selectedIndex.value;

  if (selected == null) {
    return false;
  }

  return selected == currentQuestion.value.correctIndex;
});

UI는 다음처럼 구성할 수 있습니다.

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

  @override
  Widget build(BuildContext context) {
    final question = currentQuestion.value;

    return Scaffold(
      appBar: AppBar(
        title: const Text('한국어 퀴즈'),
      ),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            question.question,
            style: const TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          const SizedBox(height: 16),
          ...List.generate(question.options.length, (index) {
            return AnswerButton(
              index: index,
              text: question.options[index],
            );
          }),
          const SizedBox(height: 24),
          const QuizFeedback(),
        ],
      ),
    );
  }
}

class AnswerButton extends StatelessWidget {
  final int index;
  final String text;

  const AnswerButton({
    super.key,
    required this.index,
    required this.text,
  });

  @override
  Widget build(BuildContext context) {
    return SignalBuilder(
      builder: (context) {
        final selected = selectedIndex.value;
        final isSelected = selected == index;

        return ListTile(
          title: Text(text),
          leading: Icon(
            isSelected
                ? Icons.radio_button_checked
                : Icons.radio_button_unchecked,
          ),
          onTap: () {
            selectedIndex.value = index;
          },
        );
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return SignalBuilder(
      builder: (context) {
        if (!hasSelected.value) {
          return const Text('정답을 선택해 주세요.');
        }

        if (isCorrect.value) {
          return const Text('정답입니다!');
        }

        return const Text('틀렸습니다. 다시 확인해 보세요.');
      },
    );
  }
}

이 예제에서 selectedIndex가 바뀌면 선택 상태와 피드백 영역만 반응합니다.

퀴즈 앱, 학습 앱, 리워드 앱처럼 사용자의 작은 상호작용이 자주 발생하는 UI에서는 Signals가 꽤 잘 맞습니다.


12. Signals를 쓸 때 주의할 점

Signals는 매력적이지만, 모든 상황의 정답은 아닙니다.

1) 전역 상태를 아무 곳에나 만들면 관리가 어려워질 수 있다

예제에서는 편의를 위해 top-level 변수로 signal을 만들었습니다.

final count = signal(0);

하지만 실무 앱에서 모든 상태를 top-level로 만들면 상태의 생명주기와 소유권이 불분명해질 수 있습니다.

작은 예제에서는 괜찮지만, 실제 프로젝트에서는 화면 단위, feature 단위, service 단위로 상태 위치를 명확히 정하는 것이 좋습니다.

2) 서버 상태 관리는 별도 전략이 필요하다

Signals는 값 단위 반응성에 강합니다.
하지만 API 호출, 캐싱, 에러 처리, retry, pagination, 인증 상태 관리까지 모두 Signals만으로 처리하려고 하면 오히려 구조가 애매해질 수 있습니다.

서버 상태는 Riverpod, Repository 패턴, Query 계열 패턴과 함께 설계하는 편이 더 안정적일 수 있습니다.

3) 팀 컨벤션이 아직 넓게 정착된 것은 아니다

Riverpod이나 BLoC는 이미 많은 Flutter 팀에서 사용하고 있고, 예제와 레퍼런스도 많습니다.

반면 Signals는 아직 상대적으로 작은 생태계입니다.
공식 문서와 패키지는 존재하지만, 대규모 실무 패턴은 Riverpod/BLoC만큼 많이 축적되었다고 보기는 어렵습니다.

4) 기존 프로젝트를 무리하게 마이그레이션할 필요는 없다

이미 Riverpod이나 BLoC로 안정적으로 운영 중인 앱이라면 Signals로 갈아타는 것이 항상 이득은 아닙니다.

불필요한 rebuild 문제가 실제로 있고, 그 문제가 사용자 경험이나 성능에 영향을 주고 있으며, 기존 구조로 해결하기 어렵다면 일부 화면부터 도입하는 것이 현실적입니다.


13. Signals가 특히 잘 맞는 경우

Signals는 다음과 같은 상황에서 유용합니다.

- 새 Flutter 프로젝트를 시작한다.
- 혼자 또는 작은 팀으로 빠르게 개발한다.
- BLoC의 boilerplate가 부담스럽다.
- Riverpod까지 쓰기에는 화면 상태가 단순하다.
- 검색, 필터, 탭, 토글 같은 UI 상태가 많다.
- 특정 위젯만 정밀하게 rebuild하고 싶다.
- SolidJS, Preact Signals 같은 웹의 반응형 모델에 익숙하다.

반대로 다음 상황에서는 신중해야 합니다.

- 이미 Riverpod/BLoC 기반의 큰 앱이 안정적으로 운영 중이다.
- 팀원들이 기존 상태관리 방식에 익숙하다.
- 엄격한 아키텍처 컨벤션이 필요하다.
- 서버 상태, 인증, 캐싱, 에러 처리가 핵심이다.
- 외부 레퍼런스와 커뮤니티 사례가 매우 중요하다.

14. 그래서 Signals를 써야 할까?

결론부터 말하면, Signals는 Flutter 상태관리의 “완전한 대체재”라기보다 정밀한 UI 반응성을 위한 좋은 도구에 가깝습니다.

setState보다 세밀하고,
BLoC보다 가볍고,
Riverpod보다 값 단위 반응성에 직접적입니다.

하지만 Riverpod이나 BLoC가 해결하던 모든 문제를 Signals가 그대로 대체한다고 보기는 어렵습니다.

가장 현실적인 결론은 다음과 같습니다.

앱 전체 구조와 서버 상태는 Riverpod이나 기존 아키텍처로 관리하고,
화면 내부의 자주 바뀌는 UI 상태는 Signals로 관리하는 방식이 실용적이다.

특히 검색창, 필터, 선택 상태, 퀴즈 답안, 토글, 카운터, 좋아요 버튼처럼 작은 상호작용이 자주 발생하는 영역에서는 Signals가 매우 깔끔한 선택지가 될 수 있습니다.


15. 마무리

Flutter 상태관리의 역사는 계속 “어디까지 rebuild할 것인가”의 문제와 함께 발전해 왔습니다.

setState는 단순하지만 넓게 rebuild될 수 있습니다.
BLoC는 명확하지만 코드가 많습니다.
Riverpod은 강력하지만 provider 설계가 필요합니다.
Signals는 값이 실제로 읽힌 지점을 기준으로 반응합니다.

Signals의 장점은 바로 이 지점에 있습니다.

상태가 바뀌었을 때, 그 상태를 실제로 읽은 UI만 다시 그린다.

이 단순한 아이디어가 Flutter UI를 더 가볍고 직관적으로 만들 수 있습니다.

다만 아직 생태계와 실무 패턴은 Riverpod이나 BLoC만큼 넓지 않습니다. 따라서 기존 프로젝트를 전면 전환하기보다는, 새 프로젝트나 특정 화면에서 먼저 실험해 보는 접근이 좋습니다.

Flutter 앱에서 불필요한 rebuild를 줄이고 싶다면, Signals는 한 번쯤 진지하게 살펴볼 만한 선택지입니다.

혁명이라기보다는, 정확한 문제를 꽤 우아하게 해결하는 실용적인 도구에 가깝습니다.

 

GitHub - rodydavis/signals.dart: Reactive programming made simple for Dart and Flutter

Reactive programming made simple for Dart and Flutter - rodydavis/signals.dart

github.com

 

반응형