상태 관리

개요

Flitter의 Provider는 React의 Context API와 유사한 개념으로, 위젯 트리 전체에서 상태를 효율적으로 관리하고 공유할 수 있습니다. Provider는 InheritedWidget을 기반으로 하며, 위젯 트리의 어느 위치에서든 상태에 접근할 수 있게 해줍니다.

┌─────────────────────────────────────────────┐
│              Provider 패턴                   │
│                                             │
│   ┌─────────────────────────────────┐       │
│   │     ChangeNotifierProvider      │       │
│   │   (React의 Context.Provider)    │       │
│   └──────────────┬──────────────────┘       │
│                  │                           │
│                  ▼                           │
│   ┌─────────────────────────────────┐       │
│   │         Widget Tree             │       │
│   │  ┌─────────────────────────┐   │       │
│   │  │    Provider.of(context)  │   │       │
│   │  │  (React의 useContext)    │   │       │
│   │  └─────────────────────────┘   │       │
│   └─────────────────────────────────┘       │
└─────────────────────────────────────────────┘

왜 Provider가 필요한가?

복잡한 애플리케이션에서는 다음과 같은 상황이 자주 발생합니다:

  • 깊은 위젯 트리: 여러 단계를 거쳐 데이터를 전달해야 하는 prop drilling 문제
  • 상태 공유: 여러 위젯이 동일한 상태를 사용하고 업데이트해야 하는 경우
  • 전역 상태: 사용자 인증 정보, 테마 설정 등 앱 전체에서 사용되는 데이터
  • 성능 최적화: 상태 변경 시 필요한 위젯만 재빌드하여 성능 향상

핵심 개념

1. Provider Key 정의하기

Provider를 식별하기 위한 고유한 키를 정의합니다:

const COUNTER_KEY = Symbol('CounterProvider');

2. Provider로 값 제공하기

Provider를 사용하여 위젯 트리에 값을 제공합니다:

// 간단한 값 제공
Provider({
  value: { count: 0 },
  providerKey: COUNTER_KEY,
  child: MyApp({})
})

// Provider 함수로 감싸기
function CounterProvider({ child }: { child: Widget }) {
  return Provider({
    value: { count: 0, increment: () => {} },
    providerKey: COUNTER_KEY,
    child
  });
}

3. Provider.of로 값 가져오기

Provider.of를 사용하여 Provider 값에 접근합니다:

// Provider.of 사용
const counter = Provider.of(COUNTER_KEY, context);

// 위젯 내에서 사용
class MyWidget extends StatelessWidget {
  build(context: BuildContext): Widget {
    const counter = Provider.of(COUNTER_KEY, context) as CounterData;
    
    return Text(`Count: ${counter.count}`);
  }
}

// Provider 함수에 of 메서드 추가 (편의성)
function CounterProvider({ child }: { child: Widget }) {
  return Provider({
    value: { count: 0 },
    providerKey: COUNTER_KEY,
    child
  });
}

CounterProvider.of = (context: BuildContext) => {
  return Provider.of(COUNTER_KEY, context) as CounterData;
};

코드 예제

기본 Provider 사용

Provider를 사용한 카운터 앱

여러 위젯에서 동일한 상태를 공유하고 업데이트하는 예제

1. Provider Key 정의

const COUNTER_KEY = Symbol('CounterProvider');

2. Provider로 값 제공하기

Provider({
  value: { count: this.count },
  providerKey: COUNTER_KEY,
  child: MyApp({})
})

3. Provider.of로 값 가져오기

// 직접 사용
const data = Provider.of(COUNTER_KEY, context) as CounterData;

// Provider 함수의 of 메서드 사용 (편의성)
const data = CounterProvider.of(context);

Provider 함수 패턴

function CounterProvider({ child }: { child: Widget }) {
  return Provider({
    value: { count: 0 },
    providerKey: COUNTER_KEY,
    child
  });
}

CounterProvider.of = (context: BuildContext) => {
  return Provider.of(COUNTER_KEY, context) as CounterData;
};

다중 Provider 사용

여러 Provider를 중첩하여 사용할 수 있습니다:

const THEME_KEY = Symbol('ThemeProvider');
const USER_KEY = Symbol('UserProvider');
const CART_KEY = Symbol('CartProvider');

function AppProviders({ child }: { child: Widget }) {
  return Provider({
    value: new ThemeController(),
    providerKey: THEME_KEY,
    child: Provider({
      value: new UserController(),
      providerKey: USER_KEY,
      child: Provider({
        value: new CartController(),
        providerKey: CART_KEY,
        child
      })
    })
  });
}

// 사용
AppProviders({
  child: MyApp({})
})

실습 예제

1. 간단한 카운터 예제

const COUNTER_KEY = Symbol('CounterProvider');

interface CounterData {
  count: number;
}

// StatefulWidget으로 상태 관리
class CounterApp extends StatefulWidget {
  createState() {
    return new CounterAppState();
  }
}

class CounterAppState extends State<CounterApp> {
  count = 0;
  
  increment() {
    this.setState(() => {
      this.count++;
    });
  }
  
  build(context: BuildContext): Widget {
    return Provider({
      value: { count: this.count },
      providerKey: COUNTER_KEY,
      child: Column({
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          CounterDisplay({}),
          SizedBox({ height: 20 }),
          GestureDetector({
            onClick: () => this.increment(),
            child: Container({
              padding: EdgeInsets.all(12),
              decoration: new BoxDecoration({
                color: '#3b82f6',
                borderRadius: BorderRadius.circular(8)
              }),
              child: Text("증가", {
                style: new TextStyle({
                  color: '#ffffff'
                })
              })
            })
          })
        ]
      })
    });
  }
}

// Provider에서 값을 읽는 별도 위젯
class CounterDisplay extends StatelessWidget {
  build(context: BuildContext): Widget {
    const data = Provider.of(COUNTER_KEY, context) as CounterData;
    
    return Text(`카운트: ${data.count}`, {
      style: new TextStyle({
        fontSize: 24,
        color: '#ffffff'
      })
    });
  }
}

2. Provider 함수 패턴

const THEME_KEY = Symbol('ThemeProvider');

interface ThemeData {
  isDarkMode: boolean;
  primaryColor: string;
}

// Provider 함수 정의
function ThemeProvider({ child }: { child: Widget }) {
  return Provider({
    value: {
      isDarkMode: true,
      primaryColor: '#3b82f6'
    },
    providerKey: THEME_KEY,
    child
  });
}

// of 메서드 추가
ThemeProvider.of = (context: BuildContext) => {
  return Provider.of(THEME_KEY, context) as ThemeData;
};

// 사용 예제
class ThemedApp extends StatelessWidget {
  build(context: BuildContext): Widget {
    return ThemeProvider({
      child: MainContent({})
    });
  }
}

class MainContent extends StatelessWidget {
  build(context: BuildContext): Widget {
    const theme = ThemeProvider.of(context);
    
    return Container({
      color: theme.isDarkMode ? '#1a1a1a' : '#ffffff',
      child: Text("테마 적용 예제", {
        style: new TextStyle({
          color: theme.primaryColor,
          fontSize: 24
        })
      })
    });
  }
}

3. 복잡한 상태 관리 예제

const APP_STATE_KEY = Symbol('AppStateProvider');

class AppController {
  user: User | null = null;
  isDarkMode = false;
  items: Item[] = [];
  
  constructor() {
    // 초기화 로직
  }
  
  login(email: string, password: string) {
    // 로그인 로직
    this.user = new User("1", "사용자", email);
  }
  
  logout() {
    this.user = null;
  }
  
  toggleTheme() {
    this.isDarkMode = !this.isDarkMode;
  }
  
  addItem(item: Item) {
    this.items.push(item);
  }
}

// Provider 함수
function AppStateProvider({ child }: { child: Widget }) {
  return Provider({
    value: new AppController(),
    providerKey: APP_STATE_KEY,
    child
  });
}

AppStateProvider.of = (context: BuildContext) => {
  return Provider.of(APP_STATE_KEY, context) as AppController;
};

// StatefulWidget과 함께 사용
class MyApp extends StatefulWidget {
  createState() {
    return new MyAppState();
  }
}

class MyAppState extends State<MyApp> {
  controller = new AppController();
  
  build(context: BuildContext): Widget {
    return Provider({
      value: this.controller,
      providerKey: APP_STATE_KEY,
      child: AppContent({
        onLogin: (email: string, password: string) => {
          this.setState(() => {
            this.controller.login(email, password);
          });
        },
        onThemeToggle: () => {
          this.setState(() => {
            this.controller.toggleTheme();
          });
        }
      })
    });
  }
}

주의사항

1. Provider 범위와 접근성

Provider는 위젯 트리에서 제공된 위치보다 하위에서만 접근 가능합니다:

// ❌ 나쁜 예: Provider가 제공되기 전에 접근 시도
class BadExample extends StatelessWidget {
  build(context: BuildContext): Widget {
    const data = Provider.of(MY_KEY, context); // 에러!
    
    return Provider({
      value: { count: 0 },
      providerKey: MY_KEY,
      child: Text(`Count: ${data.count}`)
    });
  }
}

// ✅ 좋은 예: Provider 하위에서 접근
class GoodExample extends StatelessWidget {
  build(context: BuildContext): Widget {
    return Provider({
      value: { count: 0 },
      providerKey: MY_KEY,
      child: ChildWidget({})  // 여기서 접근 가능
    });
  }
}

class ChildWidget extends StatelessWidget {
  build(context: BuildContext): Widget {
    const data = Provider.of(MY_KEY, context);
    return Text(`Count: ${data.count}`);
  }
}

2. 타입 안전성 유지

Provider 사용 시 타입 캐스팅을 명확히 해야 합니다:

// 타입 정의
interface UserData {
  id: string;
  name: string;
  email: string;
}

const USER_KEY = Symbol('UserProvider');

// Provider 함수에 제네릭 사용
function UserProvider({ child }: { child: Widget }) {
  const userData: UserData = {
    id: '1',
    name: '사용자',
    email: '[email protected]'
  };
  
  return Provider({
    value: userData,
    providerKey: USER_KEY,
    child
  });
}

// 타입 안전한 of 메서드
UserProvider.of = (context: BuildContext): UserData => {
  return Provider.of(USER_KEY, context) as UserData;
};

// 사용
const user = UserProvider.of(context);
console.log(user.name); // 타입 안전!

3. 동적 Provider 값 업데이트

Provider의 값을 업데이트하려면 StatefulWidget과 함께 사용해야 합니다:

const SETTINGS_KEY = Symbol('SettingsProvider');

interface Settings {
  fontSize: number;
  isDarkMode: boolean;
}

class SettingsWidget extends StatefulWidget {
  createState() {
    return new SettingsWidgetState();
  }
}

class SettingsWidgetState extends State<SettingsWidget> {
  settings: Settings = {
    fontSize: 16,
    isDarkMode: false
  };
  
  updateFontSize(size: number) {
    this.setState(() => {
      this.settings = { ...this.settings, fontSize: size };
    });
  }
  
  toggleDarkMode() {
    this.setState(() => {
      this.settings = { 
        ...this.settings, 
        isDarkMode: !this.settings.isDarkMode 
      };
    });
  }
  
  build(context: BuildContext): Widget {
    return Provider({
      value: this.settings,
      providerKey: SETTINGS_KEY,
      child: SettingsContent({
        onFontSizeChange: (size) => this.updateFontSize(size),
        onThemeToggle: () => this.toggleDarkMode()
      })
    });
  }
}

다음 단계

Provider 패턴을 마스터했다면, 다음 주제들을 학습해보세요:

  • 애니메이션 기초 - AnimationController를 활용한 동적 UI 구현
  • 고급 위젯 활용 - 커스텀 렌더링 로직 구현
  • 실전 예제 - 실제 프로젝트에서의 Provider 활용 사례