🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Riverpod Select로 불필요한 리빌드 방지하는 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 11. · 12 Views

Riverpod Select로 불필요한 리빌드 방지하는 완벽 가이드

Flutter 앱에서 상태 변화 시 모든 위젯이 다시 빌드되어 성능이 저하되는 문제를 겪고 계신가요? Riverpod의 select를 사용하면 필요한 부분만 선택적으로 구독하여 리빌드를 최소화할 수 있습니다. 실무에서 바로 적용할 수 있는 예제와 함께 성능 최적화 기법을 배워보세요.


목차

  1. select_기본_사용법
  2. 사용자_이름만_구독하기
  3. 리스트_길이만_구독하기
  4. 중첩_select_패턴
  5. 리빌드_횟수_비교
  6. 언제_select를_사용할까
  7. 위젯의 빌드 비용이 큰가? → Yes면 select 고려

1. select 기본 사용법

어느 날 김개발 씨가 플러터 앱을 만들다가 심각한 문제를 발견했습니다. 사용자 정보 중 이름만 표시하는 위젯인데, 나이가 변경될 때도 계속 리빌드가 일어나는 것이었습니다.

박시니어 씨가 코드를 보더니 "select를 사용해보세요"라고 조언했습니다.

select는 Provider의 상태 중에서 특정 부분만 선택적으로 구독하는 기능입니다. 마치 신문에서 관심 있는 섹션만 골라 읽는 것처럼, 전체 상태가 아닌 필요한 값만 감지합니다.

이를 통해 불필요한 위젯 리빌드를 막아 앱 성능을 크게 향상시킬 수 있습니다.

다음 코드를 살펴봅시다.

// 사용자 모델 정의
class User {
  final String name;
  final int age;

  User(this.name, this.age);
}

// Provider 정의
final userProvider = StateProvider<User>((ref) => User('김개발', 25));

// select 없이 사용 - 모든 변화에 반응
Consumer(
  builder: (context, ref, child) {
    final user = ref.watch(userProvider);
    return Text(user.name); // age 변경 시에도 리빌드됨
  },
)

// select 사용 - name 변화에만 반응
Consumer(
  builder: (context, ref, child) {
    final name = ref.watch(userProvider.select((user) => user.name));
    return Text(name); // name 변경 시에만 리빌드됨
  },
)

김개발 씨는 입사 6개월 차 플러터 개발자입니다. 오늘도 회사에서 사용자 프로필 화면을 개발하던 중, 이상한 현상을 발견했습니다.

사용자의 나이를 증가시키는 버튼을 누르면, 이름만 표시하는 위젯까지 함께 리빌드되는 것이었습니다. "이건 비효율적인데..." 김개발 씨는 고민에 빠졌습니다.

옆자리의 박시니어 씨가 모니터를 보더니 웃으며 말했습니다. "아, select를 사용하면 되는데요." select란 정확히 무엇일까요? 쉽게 비유하자면, select는 마치 뷔페에서 원하는 음식만 골라 담는 것과 같습니다.

뷔페에 수십 가지 음식이 있지만, 여러분은 자신이 좋아하는 메뉴만 선택합니다. 새로운 음식이 추가되어도 여러분이 선택한 음식이 변하지 않는다면 신경 쓸 필요가 없습니다.

select도 마찬가지로 상태 객체 전체가 아닌 필요한 부분만 골라서 구독합니다. 왜 select가 필요할까요? select가 없던 시절, 아니 정확히는 select를 모르던 시절의 개발자들은 어땠을까요?

개발자들은 ref.watch로 Provider를 구독하면 상태 객체의 어떤 속성이 변경되든 무조건 위젯이 리빌드되는 문제를 겪었습니다. 예를 들어 사용자 객체에 이름, 나이, 이메일, 주소 등 10가지 속성이 있다면, 그중 하나만 변경되어도 모든 위젯이 다시 그려졌습니다.

더 큰 문제는 앱이 복잡해질수록 이런 불필요한 리빌드가 눈덩이처럼 쌓여 성능 저하로 이어진다는 것이었습니다. select의 등장 바로 이런 문제를 해결하기 위해 select가 등장했습니다.

select를 사용하면 특정 속성만 선택적으로 구독할 수 있습니다. 또한 해당 속성이 실제로 변경될 때만 리빌드가 일어나도록 제어할 수 있습니다.

무엇보다 코드 한 줄 추가만으로 성능을 최적화할 수 있다는 큰 이점이 있습니다. 코드 분석 단계별 살펴보기 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 일반적인 방식인 ref.watch(userProvider)를 보면 User 객체 전체를 구독한다는 것을 알 수 있습니다. 이 방식은 간단하지만 name이든 age든 무엇이 변경되든 위젯이 리빌드됩니다.

반면 ref.watch(userProvider.select((user) => user.name))을 보면 어떨까요? 여기서는 콜백 함수를 통해 User 객체에서 name 속성만 추출합니다.

Riverpod은 이 콜백이 반환한 값을 기억했다가, 다음번에 상태가 변경될 때 다시 콜백을 실행하여 이전 값과 비교합니다. 값이 실제로 달라졌을 때만 위젯을 리빌드하는 것입니다.

실무에서는 어떻게 활용할까요? 예를 들어 전자상거래 앱을 개발한다고 가정해봅시다. 장바구니 화면에서 상품 개수만 표시하는 배지 위젯이 있습니다.

만약 select 없이 전체 장바구니 상태를 구독하면, 상품의 옵션이나 수량이 변경될 때마다 배지까지 리빌드됩니다. 하지만 select((cart) => cart.items.length)로 개수만 구독하면, 실제로 상품이 추가되거나 삭제될 때만 배지가 업데이트됩니다.

주의할 점 하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 select 콜백 안에서 복잡한 계산이나 새로운 객체를 생성하는 것입니다.

예를 들어 select((user) => User(user.name, 0))처럼 매번 새 객체를 만들면, 값은 같아도 참조가 달라져서 항상 리빌드가 발생합니다. 따라서 select에서는 원시 타입(String, int 등)이나 불변 값을 반환하도록 해야 합니다.

정리하며 다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 눈이 반짝였습니다.

"아, 필요한 부분만 구독하면 되는 거군요!" select를 제대로 이해하면 더 효율적이고 성능이 좋은 플러터 앱을 만들 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - select는 원시 타입(String, int, bool 등)을 반환할 때 가장 효과적입니다

  • 복잡한 객체를 반환해야 한다면 equatable 패키지를 활용하여 동등성 비교를 구현하세요
  • 디버깅 시 print문을 builder 안에 넣어 실제로 리빌드 횟수가 줄어드는지 확인해보세요

2. 사용자 이름만 구독하기

김개발 씨는 select의 기본 개념을 이해했습니다. 이제 실전 예제를 만들어볼 차례입니다.

사용자 프로필에서 이름만 표시하는 위젯을 만드는데, 나이나 다른 정보가 변경되어도 리빌드되지 않도록 최적화하고 싶었습니다.

특정 속성만 구독하기는 select의 가장 기본적이고 실용적인 활용법입니다. 상태 객체에서 필요한 하나의 속성만 선택하여 구독함으로써, 해당 속성이 변경될 때만 위젯이 반응하도록 만듭니다.

이를 통해 불필요한 렌더링을 획기적으로 줄일 수 있습니다.

다음 코드를 살펴봅시다.

class User {
  final String name;
  final int age;
  final String email;

  User({required this.name, required this.age, required this.email});
}

final userProvider = StateNotifierProvider<UserNotifier, User>((ref) {
  return UserNotifier();
});

class UserNotifier extends StateNotifier<User> {
  UserNotifier() : super(User(name: '김개발', age: 25, email: 'kim@dev.com'));

  void updateAge(int newAge) => state = User(name: state.name, age: newAge, email: state.email);
  void updateName(String newName) => state = User(name: newName, age: state.age, email: state.email);
}

// 이름만 구독하는 위젯 - age나 email이 변경되어도 리빌드 안 됨
class UserNameDisplay extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final name = ref.watch(userProvider.select((user) => user.name));
    print('UserNameDisplay 리빌드됨'); // 디버깅용
    return Text('이름: $name', style: TextStyle(fontSize: 20));
  }
}

김개발 씨는 이제 본격적으로 코드를 작성하기 시작했습니다. 사용자 프로필 화면에는 이름을 크게 표시하는 헤더 위젯이 있습니다.

문제는 사용자가 나이를 수정할 때마다 이 헤더까지 불필요하게 리빌드된다는 것이었습니다. "음, 이름만 필요한데 전체 User 객체를 구독하고 있네." 김개발 씨는 곧바로 select를 적용하기로 했습니다.

속성 하나만 선택하는 방법 select의 가장 간단한 사용법은 객체에서 원시 타입 속성 하나를 뽑아내는 것입니다. ref.watch(userProvider.select((user) => user.name)) 이 코드를 보면, 화살표 함수가 User 객체를 받아서 name 문자열만 반환합니다.

Riverpod은 내부적으로 이전 name 값을 기억하고 있다가, 상태가 업데이트될 때마다 새로운 name과 비교합니다. 만약 "김개발"에서 "박개발"로 변경되면 리빌드하지만, name은 그대로인데 age만 25에서 26으로 바뀌었다면 리빌드하지 않습니다.

왜 String 같은 원시 타입이 좋을까요? 원시 타입은 값 자체로 비교되기 때문에 동등성 판단이 명확합니다. Dart에서 "김개발" == "김개발"은 항상 true입니다.

하지만 객체는 다릅니다. User("김개발", 25) == User("김개발", 25)는 기본적으로 false입니다.

같은 내용이어도 서로 다른 인스턴스이기 때문입니다. 따라서 select로 원시 타입을 반환하면 Riverpod이 값의 변화를 정확하게 감지할 수 있습니다.

실제 동작 과정 코드가 어떻게 동작하는지 단계별로 살펴보겠습니다. 처음 UserNameDisplay 위젯이 빌드될 때, ref.watch(userProvider.select((user) => user.name))가 실행됩니다.

현재 user의 name은 "김개발"이므로 이 값이 반환되고, Riverpod은 "김개발"을 기억합니다. 그리고 Text 위젯에 이 값이 전달되어 화면에 표시됩니다.

이제 사용자가 나이 증가 버튼을 눌러서 updateAge(26)이 호출되었다고 가정해봅시다. userProvider의 상태가 업데이트되고, Riverpod은 이를 감지합니다.

그리고 select 콜백을 다시 실행하여 새로운 name을 얻습니다. 결과는 여전히 "김개발"입니다.

이전 값과 비교해보니 동일하므로, Riverpod은 UserNameDisplay 위젯을 리빌드하지 않습니다. 만약 updateName("박개발")이 호출되면 어떨까요?

이번에는 select 콜백이 "박개발"을 반환합니다. 이전 값 "김개발"과 다르므로 위젯이 리빌드되고, 화면에 새로운 이름이 표시됩니다.

실무에서의 활용 실제 프로젝트에서는 이런 패턴이 매우 자주 사용됩니다. 예를 들어 소셜 미디어 앱에서 사용자 프로필 사진만 표시하는 아바타 위젯이 있다고 해봅시다.

User 객체에는 이름, 나이, 이메일, 프로필 사진 URL, 팔로워 수 등 수십 가지 정보가 들어있습니다. 하지만 아바타 위젯은 프로필 사진 URL만 필요합니다.

이때 select((user) => user.profileImageUrl)을 사용하면, 팔로워 수가 아무리 변해도 아바타는 리빌드되지 않습니다. 성능 향상 확인하기 김개발 씨는 정말로 효과가 있는지 확인하고 싶었습니다.

그래서 build 메서드 안에 print('UserNameDisplay 리빌드됨')을 추가했습니다. 그리고 앱을 실행하여 나이 증가 버튼을 여러 번 눌러봤습니다.

콘솔에는 아무것도 출력되지 않았습니다. 리빌드가 일어나지 않은 것입니다.

반면 이름 변경 버튼을 누르자 바로 메시지가 출력되었습니다. 주의사항 여기서 한 가지 실수를 조심해야 합니다.

간혹 select((user) => user.name.toUpperCase())처럼 변환 로직을 넣는 경우가 있습니다. 이것도 문제없이 작동하지만, 변환 비용이 큰 작업이라면 매번 상태가 업데이트될 때마다 실행됩니다.

따라서 간단한 속성 접근만 하고, 복잡한 변환은 build 메서드나 별도의 계산 로직에서 처리하는 것이 좋습니다. 정리 김개발 씨는 드디어 완벽한 최적화를 이뤄냈습니다.

이름 표시 위젯은 이제 정말로 이름이 변경될 때만 업데이트됩니다. 이처럼 select로 특정 속성만 구독하는 것은 간단하지만 강력한 최적화 기법입니다.

여러분의 앱에서 불필요한 리빌드가 일어나는 곳을 찾아 select를 적용해보세요.

실전 팁

💡 - 여러 속성을 동시에 구독해야 한다면 튜플이나 레코드를 사용하세요: select((user) => (user.name, user.email))

  • select는 ConsumerWidget뿐 아니라 Consumer, HookConsumer 등 모든 곳에서 사용 가능합니다
  • Flutter DevTools의 성능 탭에서 리빌드 횟수를 시각적으로 확인할 수 있습니다

3. 리스트 길이만 구독하기

다음 날 김개발 씨는 새로운 과제를 받았습니다. 할 일 목록 앱에서 완료되지 않은 작업의 개수를 표시하는 배지를 만드는 것이었습니다.

문제는 할 일의 제목이나 내용이 수정될 때마다 배지까지 리빌드된다는 점이었습니다. "이것도 select로 해결할 수 있을 것 같은데?" 김개발 씨는 자신감이 생겼습니다.

리스트의 길이만 구독하기는 컬렉션 데이터를 다룰 때 매우 유용한 패턴입니다. 전체 리스트가 아닌 길이나 특정 조건을 만족하는 항목의 개수만 구독함으로써, 리스트의 내용이 변경되어도 개수가 동일하면 위젯이 리빌드되지 않습니다.

배지, 카운터, 페이지네이션 등에서 필수적인 최적화 기법입니다.

다음 코드를 살펴봅시다.

class Todo {
  final String id;
  final String title;
  final bool isCompleted;

  Todo({required this.id, required this.title, this.isCompleted = false});

  Todo copyWith({String? title, bool? isCompleted}) {
    return Todo(
      id: id,
      title: title ?? this.title,
      isCompleted: isCompleted ?? this.isCompleted,
    );
  }
}

final todoListProvider = StateNotifierProvider<TodoListNotifier, List<Todo>>((ref) {
  return TodoListNotifier();
});

class TodoListNotifier extends StateNotifier<List<Todo>> {
  TodoListNotifier() : super([]);

  void addTodo(String title) {
    state = [...state, Todo(id: DateTime.now().toString(), title: title)];
  }

  void toggleTodo(String id) {
    state = [
      for (final todo in state)
        if (todo.id == id) todo.copyWith(isCompleted: !todo.isCompleted) else todo
    ];
  }

  void updateTitle(String id, String newTitle) {
    state = [
      for (final todo in state)
        if (todo.id == id) todo.copyWith(title: newTitle) else todo
    ];
  }
}

// 미완료 작업 개수만 표시하는 배지 - 제목 수정 시 리빌드 안 됨
class PendingTodoBadge extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final pendingCount = ref.watch(
      todoListProvider.select((todos) => todos.where((t) => !t.isCompleted).length)
    );
    print('PendingTodoBadge 리빌드됨: $pendingCount');
    return Container(
      padding: EdgeInsets.all(8),
      decoration: BoxDecoration(color: Colors.red, shape: BoxShape.circle),
      child: Text('$pendingCount', style: TextStyle(color: Colors.white)),
    );
  }
}

김개발 씨는 이번에 조금 더 복잡한 상황에 직면했습니다. 할 일 목록은 배열이고, 각 항목은 여러 속성을 가지고 있습니다.

단순히 하나의 속성을 뽑는 것보다 더 고민이 필요했습니다. "배지에는 미완료 작업의 개수만 표시하면 돼.

그럼 그 개수만 구독하면 되겠네!" 김개발 씨는 곧바로 코드를 작성했습니다. 리스트에서 값 추출하기 리스트를 다룰 때 select는 어떻게 활용될까요?

가장 간단한 방법은 select((todos) => todos.length)처럼 전체 길이를 구독하는 것입니다. 하지만 실무에서는 더 복잡한 조건이 필요한 경우가 많습니다.

미완료 작업만 세야 하거나, 특정 카테고리의 항목만 필터링해야 할 수도 있습니다. where와 length의 조합 위 코드에서 핵심은 todos.where((t) => !t.isCompleted).length 부분입니다.

먼저 where 메서드가 조건을 만족하는 항목들만 필터링합니다. !t.isCompleted는 완료되지 않은 작업을 의미하므로, 미완료 작업들만 남게 됩니다.

그리고 length로 개수를 세어 정수 값을 반환합니다. Riverpod은 이 정수 값만 기억하고 비교합니다.

언제 리빌드가 일어날까요? 이제 몇 가지 시나리오를 생각해봅시다. 첫 번째, 사용자가 새로운 할 일을 추가했습니다.

addTodo("장보기")가 호출되면 리스트에 항목이 하나 늘어나고, 미완료 개수도 1 증가합니다. select 콜백이 새로운 개수를 반환하므로 배지가 리빌드되어 숫자가 업데이트됩니다.

두 번째, 사용자가 할 일 하나를 완료 처리했습니다. toggleTodo("1")이 호출되면 해당 항목의 isCompleted가 true로 변경됩니다.

미완료 개수가 줄어들었으므로 배지가 리빌드됩니다. 세 번째, 사용자가 할 일의 제목을 수정했습니다.

updateTitle("1", "마트 장보기")가 호출되면 해당 항목의 title만 변경됩니다. 미완료 개수는 그대로이므로, select 콜백은 동일한 값을 반환하고 배지는 리빌드되지 않습니다.

바로 이 부분이 최적화의 핵심입니다. 실무에서의 활용 사례 이런 패턴은 실제 앱에서 매우 흔하게 사용됩니다.

쇼핑몰 앱의 장바구니 아이콘을 생각해보세요. 우측 상단에 작은 빨간 배지로 담긴 상품 개수를 표시합니다.

사용자가 상품의 옵션을 변경하거나 수량을 조정할 때, 배지까지 리빌드될 필요는 없습니다. 오직 상품이 추가되거나 삭제될 때만 배지가 업데이트되어야 합니다.

이때 select((cart) => cart.items.length)를 사용하면 완벽하게 해결됩니다. 또 다른 예로 이메일 앱의 읽지 않은 메일 개수가 있습니다.

메일 목록에서 제목이나 발신자 정보가 업데이트되어도, 읽지 않은 메일 개수가 변하지 않는다면 배지는 그대로 유지되어야 합니다. select((mails) => mails.where((m) => !m.isRead).length)로 간단히 구현할 수 있습니다.

성능 고려사항 여기서 한 가지 주의할 점이 있습니다. select 콜백 안의 wherelength는 매번 상태가 업데이트될 때마다 실행됩니다.

리스트에 항목이 수천 개라면 매번 전체 리스트를 순회하는 비용이 발생합니다. 하지만 걱정하지 마세요.

Dart의 where는 지연 평가되고, length 계산은 매우 빠릅니다. 그리고 무엇보다 중요한 것은, 이 계산 비용보다 위젯 리빌드 비용이 훨씬 크다는 점입니다.

만약 정말로 복잡한 계산이 필요하다면, StateNotifier 내부에 계산된 값을 캐싱하는 방법도 있습니다. 하지만 대부분의 경우 select만으로 충분합니다.

디버깅 팁 김개발 씨는 실제로 최적화가 작동하는지 확인하고 싶었습니다. build 메서드에 print('PendingTodoBadge 리빌드됨: $pendingCount')를 추가하고 앱을 실행했습니다.

할 일을 하나 추가하자 "PendingTodoBadge 리빌드됨: 1"이 출력되었습니다. 그 할 일의 제목을 여러 번 수정해봤지만 더 이상 메시지가 나타나지 않았습니다.

완벽하게 작동했습니다. 정리 김개발 씨는 만족스러운 표정으로 코드를 커밋했습니다.

배지는 이제 정말로 필요할 때만 업데이트됩니다. 리스트의 길이나 필터링된 개수만 구독하는 패턴은 간단하면서도 강력한 최적화 기법입니다.

여러분의 앱에서도 비슷한 케이스를 찾아 적용해보세요.

실전 팁

💡 - 복잡한 필터링 조건은 별도의 메서드로 분리하여 가독성을 높이세요

  • isEmpty나 isNotEmpty를 구독하는 것도 유용합니다: select((list) => list.isEmpty)
  • 정렬 순서나 내용은 상관없고 개수만 중요한 경우 이 패턴을 적극 활용하세요

4. 중첩 select 패턴

김개발 씨는 점점 더 복잡한 상황을 마주했습니다. 사용자 정보 안에 주소 객체가 있고, 그 안에서 도시 이름만 표시해야 하는 경우였습니다.

"객체 안의 객체에서 값을 뽑아야 하는데..." 박시니어 씨가 지나가다 말했습니다. "중첩된 select도 가능해요."

중첩 select 패턴은 깊게 중첩된 객체 구조에서 특정 값만 추출할 때 사용합니다. 여러 단계의 객체를 거쳐 최종적으로 필요한 속성 하나에만 반응하도록 만들 수 있습니다.

복잡한 데이터 모델을 다룰 때 필수적인 고급 최적화 기법입니다.

다음 코드를 살펴봅시다.

class Address {
  final String city;
  final String street;
  final String zipCode;

  Address({required this.city, required this.street, required this.zipCode});
}

class UserProfile {
  final String name;
  final int age;
  final Address address;

  UserProfile({required this.name, required this.age, required this.address});
}

final profileProvider = StateNotifierProvider<ProfileNotifier, UserProfile>((ref) {
  return ProfileNotifier();
});

class ProfileNotifier extends StateNotifier<UserProfile> {
  ProfileNotifier() : super(
    UserProfile(
      name: '김개발',
      age: 25,
      address: Address(city: '서울', street: '강남대로', zipCode: '12345'),
    )
  );

  void updateCity(String newCity) {
    state = UserProfile(
      name: state.name,
      age: state.age,
      address: Address(city: newCity, street: state.address.street, zipCode: state.address.zipCode),
    );
  }

  void updateStreet(String newStreet) {
    state = UserProfile(
      name: state.name,
      age: state.age,
      address: Address(city: state.address.city, street: newStreet, zipCode: state.address.zipCode),
    );
  }
}

// 도시 이름만 표시 - street이나 zipCode 변경 시 리빌드 안 됨
class CityDisplay extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final city = ref.watch(profileProvider.select((profile) => profile.address.city));
    print('CityDisplay 리빌드됨: $city');
    return Text('거주 도시: $city');
  }
}

// 또 다른 방법: 여러 중첩 값 동시에 구독
class AddressSummary extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final (city, street) = ref.watch(
      profileProvider.select((profile) => (profile.address.city, profile.address.street))
    );
    return Text('주소: $city $street');
  }
}

김개발 씨는 이번 작업이 조금 까다롭다는 것을 알았습니다. UserProfile 객체 안에 Address 객체가 있고, 그 안에서 city만 필요했습니다.

처음에는 복잡해 보였지만, select의 원리를 이해하고 나니 해결책이 보였습니다. "결국 콜백 함수가 반환하는 값만 비교하는 거니까, 중첩되어 있어도 최종 값만 뽑아내면 되겠네!" 김개발 씨는 자신 있게 코드를 작성했습니다.

중첩 접근의 기본 중첩된 select는 사실 특별한 문법이 아닙니다. 일반적인 select와 똑같이 ref.watch(provider.select(...))를 사용하는데, 콜백 함수 안에서 점 표기법으로 깊게 들어가는 것입니다.

profile.address.city처럼 여러 단계를 거쳐 최종 값에 도달합니다. Dart의 화살표 함수는 이런 체이닝을 간결하게 표현할 수 있어 코드가 매우 깔끔해집니다.

어떤 변경에 반응할까요? 이제 구체적으로 언제 리빌드가 일어나는지 살펴봅시다. CityDisplay 위젯은 profile.address.city를 구독합니다.

만약 updateCity("부산")이 호출되면 city 값이 "서울"에서 "부산"으로 변경되므로 위젯이 리빌드됩니다. 당연한 결과입니다.

그런데 updateStreet("테헤란로")가 호출되면 어떻게 될까요? state는 새로운 UserProfile 인스턴스로 교체됩니다.

내부의 Address도 새로운 인스턴스입니다. 하지만 select 콜백이 반환하는 city 값은 여전히 "서울"입니다.

Riverpod이 이전 값과 비교해보니 동일하므로, CityDisplay는 리빌드되지 않습니다. 왜 이게 중요할까요? 실무에서는 데이터 구조가 훨씬 복잡합니다.

예를 들어 전자상거래 앱의 주문 상세 화면을 생각해봅시다. Order 객체 안에 User, Product, ShippingAddress, PaymentInfo 등 여러 중첩 객체가 있습니다.

배송지 도시만 표시하는 위젯이라면, 결제 정보가 업데이트되거나 상품 옵션이 변경될 때마다 리빌드될 필요가 없습니다. select((order) => order.shippingAddress.city)로 도시만 구독하면 완벽하게 최적화됩니다.

여러 값 동시에 구독하기 때로는 중첩된 객체에서 여러 값이 동시에 필요한 경우도 있습니다. 위 코드의 AddressSummary 위젯을 보면, city와 street를 함께 표시합니다.

이때 두 번 select를 호출하는 대신, 튜플(레코드)로 묶어서 한 번에 반환할 수 있습니다. (profile.address.city, profile.address.street)처럼 말이죠.

Dart 3의 레코드 문법을 사용하면 구조 분해 할당도 가능하여 코드가 매우 깔끔해집니다. 이 경우 city나 street 중 하나라도 변경되면 위젯이 리빌드됩니다.

하지만 zipCode가 변경될 때는 리빌드되지 않습니다. 필요한 값만 정확히 구독하는 것입니다.

성능과 가독성의 균형 여기서 고민해볼 점이 있습니다. 지나치게 중첩된 select는 가독성을 해칠 수 있습니다.

profile.company.department.team.leader.name처럼 5단계, 6단계로 들어가면 코드를 이해하기 어려워집니다. 이런 경우 중간 단계의 값을 별도 Provider로 분리하거나, 계산된 속성을 모델에 추가하는 것을 고려해보세요.

예를 들어 ProfileNotifier에 String get cityName => state.address.city; 같은 getter를 추가하고, 별도 Provider로 노출하는 방법도 있습니다. 코드의 복잡도와 성능 요구사항에 따라 적절한 방법을 선택하세요.

null 안정성 고려 실무에서는 중첩된 객체 중 일부가 null일 수 있습니다. `profile.address?.city ??

'알 수 없음'`처럼 안전하게 접근해야 합니다. select 콜백 안에서도 null 처리가 가능하므로, 항상 안전한 기본값을 제공하는 것이 좋습니다.

이렇게 하면 데이터가 로딩 중이거나 없는 경우에도 앱이 안정적으로 작동합니다. 정리 김개발 씨는 중첩된 select를 완벽히 이해했습니다.

"결국 최종 값만 보면 되는 거구나!" 복잡한 데이터 구조를 다룰 때 중첩 select는 강력한 무기가 됩니다. 하지만 가독성과 유지보수성도 함께 고려하여 적절히 사용하세요.

실전 팁

💡 - 3단계 이상 중첩된 경우 getter나 별도 Provider 분리를 고려하세요

  • 레코드 문법을 활용하면 여러 값을 깔끔하게 구독할 수 있습니다
  • null 안정성을 위해 ?. 연산자와 ?? 기본값을 적극 활용하세요

5. 리빌드 횟수 비교

박시니어 씨가 김개발 씨의 코드를 리뷰하며 물었습니다. "select를 쓰면 정말로 성능이 좋아지나요?" 김개발 씨는 확신이 없었습니다.

"음... 그런 것 같은데 실제로 측정은 안 해봤어요." 박시니어 씨가 웃으며 말했습니다.

"그럼 직접 비교해봅시다. 숫자로 확인하면 확실하니까요."

리빌드 횟수 비교는 select의 성능 개선 효과를 정량적으로 측정하는 방법입니다. 일반적인 watch와 select를 사용한 경우를 직접 비교하여, 불필요한 리빌드가 얼마나 줄어드는지 확인할 수 있습니다.

실제 숫자로 검증함으로써 최적화의 가치를 명확히 이해할 수 있습니다.

다음 코드를 살펴봅시다.

class Counter {
  final int count;
  final String label;

  Counter({required this.count, required this.label});
}

final counterProvider = StateNotifierProvider<CounterNotifier, Counter>((ref) {
  return CounterNotifier();
});

class CounterNotifier extends StateNotifier<Counter> {
  CounterNotifier() : super(Counter(count: 0, label: '카운터'));

  void increment() => state = Counter(count: state.count + 1, label: state.label);
  void updateLabel(String newLabel) => state = Counter(count: state.count, label: newLabel);
}

// select 없이 전체 구독 - count와 label 모두 변경 시 리빌드
class WithoutSelect extends ConsumerWidget {
  static int buildCount = 0;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(counterProvider);
    buildCount++;
    print('[WithoutSelect] 리빌드 #$buildCount - count: ${counter.count}');
    return Text('카운트: ${counter.count}');
  }
}

// select 사용 - count만 변경 시에만 리빌드
class WithSelect extends ConsumerWidget {
  static int buildCount = 0;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider.select((c) => c.count));
    buildCount++;
    print('[WithSelect] 리빌드 #$buildCount - count: $count');
    return Text('카운트: $count');
  }
}

// 테스트용 화면
class ComparisonScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      children: [
        WithoutSelect(),
        WithSelect(),
        ElevatedButton(
          onPressed: () => ref.read(counterProvider.notifier).increment(),
          child: Text('카운트 증가'),
        ),
        ElevatedButton(
          onPressed: () => ref.read(counterProvider.notifier).updateLabel('새 라벨'),
          child: Text('라벨 변경'),
        ),
      ],
    );
  }
}

김개발 씨와 박시니어 씨는 실험을 설계했습니다. 같은 Provider를 구독하는 두 위젯을 만들되, 하나는 select 없이, 다른 하나는 select를 사용하기로 했습니다.

그리고 각각의 리빌드 횟수를 세어 비교하기로 했습니다. "코드는 간단합니다.

정적 변수로 빌드 횟수를 세고, build 메서드가 호출될 때마다 증가시키면 돼요." 박시니어 씨가 설명했습니다. 실험 설계 실험은 공정해야 합니다.

두 위젯 모두 동일한 counterProvider를 구독합니다. Counter 객체에는 count와 label 두 속성이 있습니다.

WithoutSelect는 전체 Counter 객체를 구독하고, WithSelect는 count만 select로 추출합니다. 그리고 각각 정적 변수 buildCount로 리빌드 횟수를 추적합니다.

시나리오 1: 카운트 증가 먼저 "카운트 증가" 버튼을 5번 눌러봤습니다. 콘솔에는 이런 메시지들이 나타났습니다.

[WithoutSelect] 리빌드 #1 - count: 1 [WithSelect] 리빌드 #1 - count: 1 [WithoutSelect] 리빌드 #2 - count: 2 [WithSelect] 리빌드 #2 - count: 2 [WithoutSelect] 리빌드 #3 - count: 3 [WithSelect] 리빌드 #3 - count: 3 ... count가 변경될 때는 두 위젯 모두 리빌드됩니다.

당연한 결과입니다. WithSelect가 구독하는 값이 실제로 변경되었으니까요.

지금까지는 동점입니다. 시나리오 2: 라벨 변경 이제 핵심 테스트입니다.

"라벨 변경" 버튼을 5번 눌러봤습니다. [WithoutSelect] 리빌드 #6 - count: 5 [WithoutSelect] 리빌드 #7 - count: 5 [WithoutSelect] 리빌드 #8 - count: 5 [WithoutSelect] 리빌드 #9 - count: 5 [WithoutSelect] 리빌드 #10 - count: 5 놀랍게도 WithSelect에서는 아무런 메시지도 나타나지 않았습니다.

WithoutSelect만 계속 리빌드되고 있었습니다. count 값은 여전히 5인데도 불구하고 말이죠.

결과 분석 최종 점수를 집계해봅시다. 카운트 증가 5회, 라벨 변경 5회, 총 10번의 상태 업데이트가 있었습니다.

WithoutSelect는 10번 모두 리빌드되어 buildCount가 10이 되었습니다. 반면 WithSelect는 카운트 증가 시에만 리빌드되어 buildCount가 5에 머물렀습니다.

정확히 50%의 리빌드를 절약한 것입니다. "이게 바로 select의 위력입니다." 박시니어 씨가 설명했습니다.

"실제 앱에서는 차이가 더 클 수 있어요. 상태 객체에 10개, 20개의 속성이 있다면 어떻게 될까요?" 실제 앱에서의 영향 김개발 씨는 계산기를 두드렸습니다.

만약 상태 객체에 10개의 속성이 있고, 위젯이 그중 하나만 필요하다고 가정해봅시다. select 없이 전체를 구독하면, 10가지 속성 중 무엇이 변경되든 리빌드됩니다.

하지만 select로 하나만 구독하면, 통계적으로 90%의 리빌드를 절약할 수 있습니다. 더 나아가 화면에 그런 위젯이 10개 있다면 어떨까요?

select 없이는 한 번의 상태 업데이트에 10개 위젯이 모두 리빌드됩니다. select를 사용하면 실제로 영향받는 위젯만 리빌드됩니다.

성능 차이는 기하급수적으로 커집니다. Flutter DevTools로 시각화 박시니어 씨가 Flutter DevTools를 열었습니다.

Performance 탭에서 "Track Widget Rebuilds"를 활성화하면, 리빌드되는 위젯이 화면에 형광색으로 표시됩니다. 라벨을 변경할 때 WithoutSelect 위젯만 깜빡이고, WithSelect는 가만히 있는 모습을 눈으로 직접 확인할 수 있었습니다.

"시각적으로 보니 더 확실하네요!" 김개발 씨가 감탄했습니다. 프로덕션 환경 고려사항 물론 개발 모드와 릴리즈 모드는 다릅니다.

개발 모드에서는 핫 리로드, 디버그 정보 생성 등 추가 오버헤드가 있습니다. 릴리즈 모드에서는 최적화가 적용되어 전반적인 성능이 훨씬 좋아집니다.

하지만 중요한 것은 select를 사용한 경우와 사용하지 않은 경우의 상대적인 차이입니다. 이 차이는 릴리즈 모드에서도 동일하게 유지됩니다.

메모리 사용량 리빌드 횟수뿐 아니라 메모리 사용량도 줄어듭니다. 위젯이 리빌드될 때마다 새로운 Widget 인스턴스가 생성되고, 이전 인스턴스는 가비지 컬렉션 대상이 됩니다.

리빌드가 빈번하면 메모리 할당과 해제가 자주 일어나 GC 압력이 증가합니다. select로 리빌드를 줄이면 메모리 사용 패턴도 안정화됩니다.

정리 김개발 씨는 확신을 가지게 되었습니다. "숫자로 보니까 확실하네요.

select는 선택이 아니라 필수예요!" 실제로 측정하고 비교하는 것은 매우 중요합니다. 추측이 아닌 데이터로 최적화 효과를 검증하세요.

실전 팁

💡 - Flutter DevTools의 Performance Overlay로 실시간 리빌드를 시각적으로 확인하세요

  • 프로파일 모드(flutter run --profile)에서 더 정확한 성능 측정이 가능합니다
  • 복잡한 위젯일수록 리빌드 비용이 크므로 select의 효과가 더 큽니다

6. 언제 select를 사용할까

마지막 날, 김개발 씨는 박시니어 씨에게 물었습니다. "그럼 모든 곳에 select를 써야 하나요?" 박시니어 씨가 고개를 저었습니다.

"아니요. select는 강력하지만, 항상 필요한 건 아니에요.

적재적소에 사용하는 게 중요합니다."

select 사용 시기 판단은 성능과 코드 복잡도 사이의 균형을 찾는 것입니다. 모든 곳에 select를 남발하면 코드가 복잡해지고, 전혀 사용하지 않으면 성능 문제가 생깁니다.

상황에 따라 적절히 판단하여 사용하는 것이 진정한 실력입니다.

다음 코드를 살펴봅시다.

// ❌ 불필요한 select - 전체 객체가 필요한 경우
class UserCard extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 이름, 나이, 이메일 모두 사용하므로 select 불필요
    final user = ref.watch(userProvider);
    return Card(
      child: Column(
        children: [
          Text(user.name),
          Text('${user.age}세'),
          Text(user.email),
        ],
      ),
    );
  }
}

// ✅ 적절한 select - 특정 속성만 필요한 경우
class UserNameBadge extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 이름만 사용하므로 select 필수
    final name = ref.watch(userProvider.select((u) => u.name));
    return CircleAvatar(child: Text(name[0]));
  }
}

// ✅ 적절한 select - 조건부 렌더링
class HasUnreadNotification extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // boolean 값만 필요
    final hasUnread = ref.watch(
      notificationProvider.select((list) => list.any((n) => !n.isRead))
    );
    return hasUnread ? Badge(child: Icon(Icons.notifications)) : Icon(Icons.notifications);
  }
}

// ❌ 과도한 select - 단순한 Provider
class LoadingIndicator extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // isLoading Provider는 이미 boolean이므로 select 불필요
    final isLoading = ref.watch(loadingProvider.select((loading) => loading));
    return isLoading ? CircularProgressIndicator() : SizedBox.shrink();
  }
}

// ✅ 올바른 방법 - 단순한 Provider는 그냥 watch
class LoadingIndicatorCorrect extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final isLoading = ref.watch(loadingProvider);
    return isLoading ? CircularProgressIndicator() : SizedBox.shrink();
  }
}

김개발 씨는 지난 며칠간 select의 강력함에 매료되어 모든 곳에 적용하려 했습니다. 하지만 박시니어 씨의 코드 리뷰를 받고 깨달았습니다.

때로는 select가 오히려 코드를 복잡하게 만든다는 것을요. "도구는 적절히 사용할 때 가치가 있어요.

망치가 좋은 도구라고 해서 나사까지 망치로 박지는 않잖아요?" 박시니어 씨의 비유가 명확했습니다. select가 필요한 경우 먼저 select를 꼭 사용해야 하는 상황을 정리해봅시다.

첫째, 복잡한 객체에서 일부 속성만 필요한 경우입니다. User 객체에 10가지 속성이 있는데 이름만 표시한다면, select는 필수입니다.

다른 9가지 속성이 변경될 때마다 리빌드되는 것은 명백한 낭비입니다. 둘째, 리스트에서 개수나 조건만 확인하는 경우입니다.

할 일 목록에서 미완료 개수만 표시하거나, 알림 목록에서 읽지 않은 것이 있는지만 확인할 때 select는 큰 효과를 발휘합니다. select((list) => list.length) 또는 select((list) => list.any(...))처럼 사용합니다.

셋째, 성능이 중요한 위젯입니다. 자주 업데이트되는 상태를 구독하는데 위젯의 빌드 비용이 크다면, select로 불필요한 리빌드를 막아야 합니다.

복잡한 차트, 애니메이션, 대량의 데이터를 렌더링하는 위젯이 여기 해당합니다. select가 불필요한 경우 반대로 select를 사용하지 말아야 하는 경우도 있습니다.

첫째, 전체 객체를 사용하는 경우입니다. UserCard 예제처럼 이름, 나이, 이메일을 모두 표시한다면 select는 의미가 없습니다.

어차피 세 속성 중 하나라도 변경되면 리빌드되어야 하니까요. 오히려 ref.watch(userProvider)가 더 간결하고 명확합니다.

둘째, Provider가 이미 단순한 값인 경우입니다. StateProvider<bool>이나 StateProvider<int>처럼 원시 타입을 직접 제공하는 Provider라면, select로 감쌀 필요가 없습니다.

ref.watch(loadingProvider.select((loading) => loading))ref.watch(loadingProvider)와 완전히 동일하면서 코드만 길어집니다. 셋째, 위젯이 자주 리빌드되지 않는 경우입니다.

초기 로딩 시 한 번만 표시되고 이후 변경되지 않는 정보라면, 최적화의 효과가 미미합니다. 코드 복잡도만 증가시킬 뿐입니다.

판단 기준 그렇다면 실제로 어떻게 판단할까요? 박시니어 씨는 간단한 체크리스트를 알려줬습니다.

"이 세 가지 질문을 스스로에게 해보세요."


3. 위젯의 빌드 비용이 큰가? → Yes면 select 고려

실전 팁

💡 - 복잡한 객체 + 일부 속성 사용 + 빈번한 업데이트 = select 필수

  • 단순한 값 Provider나 전체 객체 사용 시 = select 불필요
  • 확신이 서지 않으면 먼저 측정하고, 그다음 최적화하세요 (premature optimization 방지)

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Flutter#Riverpod#Select#StateManagement#Performance#Flutter,Riverpod

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스