팡세영
Log sey
팡세영
전체 방문자
오늘
어제
  • 분류 전체보기 (82)
    • PS (45)
      • programmers (13)
      • 백준 (29)
    • Android (16)
    • Daily (0)
    • Kotlin (6)
    • Design Pattern (3)
    • Java (1)
    • Flutter (3)
    • 책 리뷰 (1)
      • 클린 아키텍처 (1)
    • 공식문서 읽기 운동 (4)
      • Flutter (4)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 자바
  • flutter
  • Kotlin
  • 플러터
  • 완전탐색
  • 단일책임원칙
  • 골드
  • TestCode
  • programmers #프로그래머스
  • Android
  • LEVEL2
  • ArcitecturePattern
  • DFS
  • 백준
  • mvvm
  • 문자열
  • 데이터계층
  • CustomView
  • 실버
  • 하단네비게이션바
  • 해쉬맵
  • 프로그래머스
  • 정렬
  • java
  • 구현
  • 안드로이드
  • vercel
  • BFS
  • 공식문서
  • 클린아키텍처

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
팡세영

Log sey

Flutter - UI Layer
공식문서 읽기 운동/Flutter

Flutter - UI Layer

2025. 7. 31. 04:24

 

해당 글은 플러터 공식 문서의 내용을 해석한 글입니다. 

https://docs.flutter.dev/app-architecture/case-study/ui-layer


UI 계층 사례 연구 (UI Layer Case Study)

Flutter 애플리케이션의 각 기능에 대한 UI 계층은 View와 ViewModel 두 가지 구성요소로 이루어져야 합니다.

 

일반적으로 ViewModel은 UI 상태를 관리하고, View는 그 상태를 렌더링합니다.

View와 ViewModel은 일대일 관계이며, 각각의 View는 해당 View의 상태를 관리하는 하나의 ViewModel을 가집니다.

이 View와 ViewModel의 쌍이 하나의 기능에 대한 UI를 구성합니다.

예를 들어, 앱에는 LogOutView와 LogOutViewModel이라는 클래스가 있을 수 있습니다.

 


ViewModel 정의하기

ViewModel은 UI 로직을 처리하는 Dart 클래스입니다.

ViewModel은 도메인 데이터 모델을 입력으로 받아 View가 필요로 하는 UI 상태(UI State)로 노출합니다.

또한 View의 이벤트 핸들러(예: 버튼 클릭)에 연결될 수 있는 로직을 캡슐화하며, 데이터 변경이 발생하는 데이터 계층으로 이벤트를 전달합니다.

class HomeViewModel {
  HomeViewModel({
    required BookingRepository bookingRepository,
    required UserRepository userRepository,
  })  : _bookingRepository = bookingRepository,
        _userRepository = userRepository;

  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;
}

 

ViewModel은 항상 하나 이상의 데이터 리포지토리(Repository)에 의존하며, 생성자의 인자로 주입받습니다.

대부분의 ViewModel은 여러 리포지토리에 의존하게 됩니다.

 

리포지토리는 ViewModel 내부에서 private 멤버로 관리되어야 하며, View가 직접 접근해서는 안 됩니다.


UI 상태 (UI State)

ViewModel의 출력(Output)은 View에서 렌더링에 필요한 데이터를 의미하며, 이를 UI 상태 또는 간단히 상태(State)라고 부릅니다.

UI 상태는 불변(immutable) 데이터여야 하며, View를 렌더링하는 데 필요한 스냅샷입니다.

class HomeViewModel {
  HomeViewModel({
   required BookingRepository bookingRepository,
   required UserRepository userRepository,
  }) : _bookingRepository = bookingRepository,
      _userRepository = userRepository;

  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;

  User? _user;
  User? get user => _user;

  List<BookingSummary> _bookings = [];

  UnmodifiableListView<BookingSummary> get bookings =>
      UnmodifiableListView(_bookings);
}

ViewModel에서 노출하는 데이터는 예시 코드에서 User 객체와 List<BookingSummary> 형태의 사용자 저장 예약 목록(saved itineraries) 입니다.

 

UI 상태는 항상 불변이어야 하며, 이는 버그 없는 소프트웨어 개발의 핵심입니다.

 

Compass 앱에서는 freezed 패키지를 사용해 데이터 클래스의 불변성을 보장합니다.

예를 들어, 아래는 User 클래스 정의 예시입니다.

freezed는 깊은 불변성(deep immutability)을 제공하며, copyWith, toJson 같은 유용한 메서드들의 구현도 자동으로 생성해줍니다.

// user.dart
@freezed
class User with _$User {
  const factory User({
    required String name,
    required String picture,
  }) = _User;

  factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}

 

뷰 모델 예시에서는 뷰를 렌더링하기 위해 두 개의 객체가 필요합니다.

특정 모델의 UI 상태가 점점 복잡해짐에 따라, 뷰 모델은 더 많은 리포지토리로부터 더 많은 데이터 조각들을 뷰에 노출시킬 수 있습니다.

이러한 경우, UI 상태를 명확하게 표현하는 객체를 별도로 생성하는 것이 좋습니다.

예를 들어, HomeUiState라는 클래스를 만들어서 사용할 수 있습니다.


UI 상태 업데이트

ViewModel은 데이터를 보관하는 것 외에도, 새로운 상태를 전달받았을 때 Flutter에게 화면을 다시 렌더링하라고 알리는 역할도 수행해야 합니다. Compass 앱에서는 이를 위해 ChangeNotifier를 상속합니다.

class HomeViewModel extends ChangeNotifier {
  // 생성자 생략

  User? _user;
  User? get user => _user;

  List<BookingSummary> _bookings = [];
  List<BookingSummary> get bookings => _bookings;
}

데이터가 업데이트되었을 때, notifyListeners()를 호출하여 View에게 상태 변경을 알립니다.

 

 

새로운 상태는 Repository로부터 뷰 모델에 제공됩니다.

뷰 모델은 UI 상태를 새로운 데이터에 맞게 갱신합니다.

ViewModel.notifyListeners가 호출되어, 새로운 UI 상태가 있음을 뷰에 알립니다.

뷰(위젯)는 이를 감지하고 다시 렌더링됩니다.

 

예를 들어, 사용자가 Home 화면으로 이동하고 뷰 모델이 생성되면, _load 메서드가 호출됩니다.

이 메서드가 완료되기 전까지는 UI 상태가 비어 있으며, 뷰는 로딩 인디케이터를 표시합니다.

_load 메서드가 성공적으로 완료되면, 뷰 모델에 새로운 데이터가 존재하게 되며,

뷰에 새로운 데이터를 사용할 수 있음을 알리기 위해 notifyListeners를 호출해야 합니다.

 

class HomeViewModel extends ChangeNotifier {
  // ...

 Future<Result> _load() async {
    try {
      final userResult = await _userRepository.getUser();
      switch (userResult) {
        case Ok<User>():
          _user = userResult.value;
          _log.fine('Loaded user');
        case Error<User>():
          _log.warning('Failed to load user', userResult.error);
      }

      // ...

      return userResult;
    } finally {
      notifyListeners();
    }
  }
}

 

ChangeNotifier와 이후에 설명할 ListenableBuilder는 Flutter SDK에 포함된 기능으로, 상태가 변경될 때 UI를 갱신하는 좋은 방법을 제공합니다.

 

또한, package:riverpod, package:flutter_bloc, package:signals와 같은 강력한 써드파티 상태 관리 솔루션을 사용할 수도 있습니다.

이러한 라이브러리들은 UI 업데이트를 처리하기 위한 다양한 도구들을 제공합니다.

 

ChangeNotifier 사용에 대해 더 자세히 알고 싶다면 Flutter의 상태 관리 문서를 참고하세요.


View 정의하기

View는 Flutter의 Widget입니다.

일반적으로 View는 Scaffold를 포함한 하나의 화면(Screen)을 나타내며, 해당 화면마다 별도의 라우트를 가질 수 있습니다.

하지만 반드시 전체 화면일 필요는 없으며, 앱 전반에서 재사용되는 UI 요소일 수도 있습니다.

 

예: LogoutButton은 Compass 앱에서 여러 위치에 삽입될 수 있으며, 자체 ViewModel(LogoutViewModel)을 가집니다.

뷰(View)는 추상적인 용어이며 하나의 뷰가 하나의 위젯을 의미하는 것은 아닙니다.
위젯은 조합 가능한(composable) 요소이기 때문에 여러 위젯이 모여 하나의 뷰를 구성할 수 있습니다.
따라서 뷰모델(ViewModel)은 개별 위젯과 1:1 관계를 맺는 것이 아니라, 여러 위젯으로 구성된 하나의 뷰와 1:1 관계를 맺습니다.

 

View의 책임은 다음 세 가지입니다.

  1. ViewModel에서 노출된 데이터를 표시한다.
  2. ViewModel의 변경을 수신하여 화면을 갱신한다.
  3. 사용자 이벤트에 대해 ViewModel의 메서드를 호출한다.
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key, required this.viewModel});
  final HomeViewModel viewModel;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
    );
  }
}

 

대부분의 경우, 뷰(View)의 입력값은 key와 해당 뷰에 대응하는 ViewModel만 있어야 합니다.

key는 모든 Flutter 위젯이 선택적으로 받는 인자이며 ViewModel은 해당 뷰의 상태 및 동작을 관리합니다.


View에서 UI 데이터 표시하기

뷰(View)는 자신의 상태를 ViewModel에 의존합니다.

Compass 앱에서는 ViewModel이 뷰의 생성자 인자로 전달됩니다.

다음은 HomeScreen 위젯에서 가져온 예시 코드입니다.

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: SafeArea(
      child: ListenableBuilder(
        listenable: viewModel,
        builder: (context, _) {
          return CustomScrollView(
            slivers: [
              SliverToBoxAdapter(...),
              SliverList.builder(
                itemCount: viewModel.bookings.length,
                itemBuilder: (_, index) => _Booking(
                  key: ValueKey(viewModel.bookings[index].id),
                  booking: viewModel.bookings[index],
                  onTap: () => context.push(
                      Routes.bookingWithId(viewModel.bookings[index].id)),
                  onDismissed: (_) => viewModel.deleteBooking.execute(
                      viewModel.bookings[index].id),
                ),
              ),
            ],
          );
        },
      ),
    ),
  );
}

 

HomeScreen 위젯은 ListenableBuilder 위젯을 사용하여 ViewModel의 변경 사항을 수신 대기(Listen) 합니다.

ListenableBuilder 아래의 위젯 서브트리는 제공된 Listenable 객체가 변경될 때마다 모두 다시 렌더링됩니다.

이 예제에서는 제공된 Listenable이 ViewModel입니다.

ViewModel은 ChangeNotifier 타입이며 이 타입은 Listenable의 하위 타입입니다.


사용자 이벤트 처리

마지막으로, 뷰(View)는 사용자로부터 발생하는 이벤트를 수신해야 하며 이를 뷰모델(ViewModel)이 처리할 수 있어야 합니다.

이를 위해, 모든 로직을 캡슐화한 콜백 메서드를 뷰모델 클래스에 정의하고 외부에 노출합니다.

 

HomeScreen에서는 사용자가 Dismissible 위젯을 스와이프하여 이전에 예약한 이벤트를 삭제할 수 있습니다.

Future<Result<void>> _deleteBooking(int id) async {
  try {
    final resultDelete = await _bookingRepository.delete(id);
    switch (resultDelete) {
      case Ok<void>():
        _log.fine('Deleted booking $id');
      case Error<void>():
        _log.warning('Failed to delete booking $id', resultDelete.error);
        return resultDelete;
    }

    // Some code was omitted for brevity.
    // final  resultLoadBookings = ...;

    return resultLoadBookings;
  } finally {
    notifyListeners();
  }
}

 

HomeScreen에서 사용자의 저장된 여행 일정은 _Booking 위젯으로 표시됩니다.

이 위젯이 삭제되면 viewModel.deleteBooking 메서드가 실행됩니다.

 

저장된 예약은 세션이나 뷰의 생명주기를 넘어 지속되는 애플리케이션 상태이며, 이러한 상태는 오직 레포지토리만 수정해야 합니다.

따라서 HomeViewModel.deleteBooking 메서드는 데이터 계층에 있는 레포지토리 메서드를 호출하여 상태를 변경합니다.

  final resultDelete = await _bookingRepository.delete(id);

 

Compass 앱에서는 사용자 이벤트를 처리하는 메서드들을 커맨드(Command)라고 부릅니다.


커맨드 객체 (Command Object)

Command는 UI 계층에서 시작된 상호작용이 데이터 계층까지 도달하는 흐름을 담당하는 객체입니다.

Compass 앱에서는 특히 비동기 작업의 상태에 따라 UI를 안전하게 업데이트할 수 있도록 돕는 타입으로 사용됩니다.

 

Command 클래스는 하나의 메서드를 래핑(wrap)하고, 해당 메서드의 실행 상태를 다음과 같이 추적합니다:

  • running: 작업이 진행 중인지 여부
  • complete: 작업 완료 여부
  • error: 오류가 발생했는지 여부

이러한 상태들을 통해 UI에서 로딩 스피너나 오류 메시지를 적절히 보여줄 수 있습니다.

예를 들어 command.running == true일 때 로딩 인디케이터를 표시할 수 있습니다.

abstract class Command<T> extends ChangeNotifier {
  Command();
  bool running = false;
  Result<T>? _result;

  bool get error => _result is Error;
  bool get completed => _result is Ok;

  Future<void> _execute(action) async {
    if (running) return;

    running = true;
    _result = null;
    notifyListeners();

    try {
      _result = await action();
    } finally {
      running = false;
      notifyListeners();
    }
  }
}

 

Command 클래스 자체는 ChangeNotifier를 상속합니다.

내부의 execute 메서드에서는 notifyListeners()가 여러 번 호출됩니다.

이를 통해 상태 변경마다 UI에 알림을 전달할 수 있습니다.

UI는 복잡한 조건문 없이도 다양한 상태(로딩 중, 성공, 실패 등)에 대응할 수 있습니다.

 

Command는 추상 클래스(abstract class)입니다.

실제 사용되는 구현 클래스들은 다음과 같은 형태로 존재합니다

  • Command0: 인자가 없는 함수용
  • Command1: 인자가 1개인 함수용
  • 숫자는 해당 커맨드 클래스가 래핑하는 메서드가 몇 개의 인자를 받는지를 나타냅니다.
  • 이런 구현 클래스들은 Compass 앱의 utils 디렉토리에서 확인할 수 있습니다.

flutter_command

  • 직접 Command 클래스를 구현하는 대신, flutter_command 패키지를 사용하는 것도 좋은 방법입니다.
  • 이 패키지는 위에서 설명한 기능들을 더 안정적이고 확장성 있게 구현해둔 라이브러리입니다.
  • 다음과 같은 기능을 제공합니다
    • 상태 추적 (isExecuting, thrownExceptions, value 등)
    • UI 바인딩을 쉽게 만들 수 있는 구조
    • 다양한 매개변수 개수를 지원하는 커맨드 생성기

View보다 먼저 데이터가 로드될 수 있는 경우

Command는 생성자에서 정의되며 실행도 즉시 이루어질 수 있습니다.

class HomeViewModel extends ChangeNotifier {
  HomeViewModel({
    required BookingRepository bookingRepository,
    required UserRepository userRepository,
  })  : _bookingRepository = bookingRepository,
        _userRepository = userRepository {
    load = Command0(_load)..execute();
    deleteBooking = Command1(_deleteBooking);
  }

  late Command0 load;
  late Command1<void, int> deleteBooking;

  // ...
}

해당 Command는 View보다 먼저 실행될 수 있지만,

View는 ListenableBuilder를 통해 현재 상태(running, error, completed)를 안전하게 처리할 수 있습니다.

child: ListenableBuilder(
  listenable: viewModel.load,
  builder: (context, child) {
    if (viewModel.load.running) {
      return const Center(child: CircularProgressIndicator());
    }

    if (viewModel.load.error) {
      return ErrorIndicator(
        title: AppLocalization.of(context).errorWhileLoadingHome,
        label: AppLocalization.of(context).tryAgain,
        onPressed: viewModel.load.execute,
      );
    }

    return child!;
  },
),

 

 

Command.execute는 비동기 메서드입니다

Command.execute()는 비동기(async)로 동작합니다.

따라서 데이터가 언제 도착할지 보장할 수 없습니다.

즉, 뷰가 렌더링되는 시점에 데이터가 아직 도착하지 않았을 수 있습니다.

 

loadCommand는 ViewModel 내에 영구적으로 존재하는 속성이기 때문에 언제 실행되었고 언제 완료되었는지는 중요하지 않습니다.

예를 들어, 사용자가 HomeScreen으로 진입하기 전에 loadCommand가 이미 완료되었다고 해도 뷰모델에 그 결과가 남아 있기 때문에 화면에서는 여전히 정확한 상태를 반영할 수 있습니다.

 

이러한 패턴은 로딩, 성공, 실패 상태를 일관성 있게 다룰 수 있도록 표준화해주며 코드의 안정성과 유지보수성을 높입니다.

하지만 앱의 전체 아키텍처에 따라 이 패턴이 적절하지 않을 수도 있습니다.

 

예를 들어, Stream을 기반으로 상태를 관리하는 앱에서는 StreamBuilder가 제공하는 AsyncSnapshot이 이미 비슷한 기능

(예: 연결 상태, 에러, 데이터 존재 여부)을 갖추고 있으므로 굳이 Command 패턴을 사용할 필요는 없습니다.

'공식문서 읽기 운동 > Flutter' 카테고리의 다른 글

Flutter - DataLayer  (2) 2025.07.31
Flutter - 앱 아키텍처 가이드  (2) 2025.07.30
Flutter - 앱 아키텍처 핵심개념  (3) 2025.07.30
    '공식문서 읽기 운동/Flutter' 카테고리의 다른 글
    • Flutter - DataLayer
    • Flutter - 앱 아키텍처 가이드
    • Flutter - 앱 아키텍처 핵심개념
    팡세영
    팡세영
    Android, CS, PS

    티스토리툴바