Flutter에서 좋은 코드는 뭘까 (1) -UI와 비즈니스 로직을 분리

고랭지참치·2025년 4월 9일
0

Flutter

목록 보기
19/24

.

Flutter로 앱을 개발하면서 어떻게하면 유지보수에 용이하고 가독성을 높힐 수 있는지 고민해왔다.

여러가지 블로그와 오픈소스 프로젝트에서 공통적으로 얘기하는 건 다음과 같았다.

  1. UI와 비즈니스 로직을 분리해라
  2. 반복적으로 사용되는 UI코드를 컴포넌트화 해라.
  3. 레이어 간 관심사를 명확히 해라.

UI와 비즈니스 로직을 분리해라

flutter 공식문서의 UI와 비즈니스(데이터) 로직을 분리하라는 정의는 다음과 같다.

Use clearly defined data and UI layers.
https://docs.flutter.dev/app-architecture/recommendations
Separation of concerns is the most important architectural principle. The data layer exposes application data to the rest of the app, and contains most of the business logic in your application. The UI layer displays application data and listens for user events from users. The UI layer contains separate classes for UI logic and widgets.(Strongly recommend)

한국어 번역
> 관심사의 분리는 가장 중요한 아키텍처 원칙입니다. 데이터 계층은 애플리케이션의 데이터를 앱의 다른 부분에 노출하며, 애플리케이션의 대부분의 비즈니스 로직을 포함합니다. UI 계층은 애플리케이션 데이터를 표시하고, 사용자로부터의 이벤트를 수신합니다. UI 계층은 UI 로직과 위젯을 분리된 클래스로 구성합니다.

UI 레이어는 데이터를 관리하지 않고 디스플레이하며 사용자의 입출력을 처리하는 역할에 충실해야 한다.

하나의 예시로 다음과 같은 코드가 있을 수 있겠다.

Bad Case

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

 /// 데이터에 직접 접근할 수 있다. 
  final Data? data;
  
 /// 비즈니스 로직이 UI 레이어 안에 존재.
  Future<void> _getData() async {
    try {
      data = await dio.get('http://example.com');
    } catch (e) {
      // Handle error
    }
  }

  
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _getData(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Center(child: CircularProgressIndicator());
        } else if (snapshot.hasError) {
          return const Center(child: Text('Error'));
        } else {
          return Text('Data: ${data?.toString()}');
        }
      },
    );
  }
}

아무리 단순한 요구사항이나 데이터더라도, UI를 표시하는 Stl 클래스 내부에서 해당 데이터를 직접 관리하게 되면, 특정 데이터와 UI의 결합도가 높아져 유지보수가 어려워지고 서비스가 커지면 에러가 발생해도 왜 발생하는지 찾기가 어려워질 것이다.

시도하고 있는 방식

class GoodUIViewModel extends ChangeNotifier {
  final ValueNotifier<User?> user = ValueNotifier(null);

  Future<void> getUser() async {
	/// usecase 등으로 연결하여 데이터를 가져오기
	user.value = User();
  }
}

class GoodUI extends HookWidget {
  const GoodUI({super.key, required this.viewModel});

  final GoodUiViewModel viewModel;

  
  Widget build(BuildContext context) {
    useListenable(viewModel.user);

    useEffect(() {
      viewModel.getUser();
      return null;
    }, []);

    return Scaffold(
      appBar: AppBar(title: const Text('Good UI')),
      body: Center(
        child:
            viewModel.user.value == null
                ? const CircularProgressIndicator()
                : const Text('User Loaded'),
      ),
    );
  }
}

위 코드는 Flutter 아키텍처 문서를 참고하여 시도하고 있는 UI 상태관리 방식이다.
비즈니스 로직을 처리하는 ViewModel을 의존성 주입받아 로직과 데이터를 호출하고,
flutter_hook을 활용하여 ValueNotifier의 State를 최신화 시켜주고 있고, 이를 통해 UI와 데이터를 분리할 수 있다.

https://docs.flutter.dev/app-architecture/case-study/ui-layer
위 문서의 예시에 따르면, ListenableBuilder와 notifyListener를 사용하여 UI State를 업데이트 시켜주고 있으나, hook을 사용하여 반복되는 코드를 줄일 수 있었다.

* 상태관리 라이브러리는?

위 구조를 적용하기 직전까지는 Riverpod을 사용하여 상태관리를 진행했다.
Riverpod은 객체 단위로 UI State를 관리하고 효율적인 의존성관리를 할 수 있도록 도와준다. 또한 전역적으로 데이터에 접근할 수 있어, MVVM의 고질적인 문제인 ViewModel 비대화를 피할 수 있었다.

하지만 반대로 어디서든 데이터에 접근할 수 있어, 프로젝트 내 Riverpod Provider가 늘어날 수록 관리하기가 어려워진다는 어려움이 있었다.

또한 CodeGeneration(build_runner를 통한 코드 자동생성)을 베이스로 Riverpod Provider를 사용했기 때문에, provider가 많아지니 build_runner를 돌리는 것도 꽤 시간이 소요됐다.

Riverpod을 사용하면서 위와 같은 이슈들을 어떻게 해결하면 좋을지 고민하던 와중에, Flutter Architecture 공식문서가 나오게 됐고, UI State를 ChangeNotifier를 통해서 관리하는 방식을 권장하는 것을 고려하여 써드파티 상태관리 라이브러리를 제거하고 프로젝트를 진행해보자고 팀원들과 논의했고, 결론적으로 위 방식을 도입하게 되었다.

profile
소프트웨어 엔지니어 / Flutter 개발자

1개의 댓글

comment-user-thumbnail
2025년 7월 8일

아닛 또!

답글 달기