Flutter 개발 핵심 정리

Flutter는 Google이 개발한 UI 프레임워크로, Dart 언어를 사용해 하나의 코드베이스로 여러 플랫폼의 애플리케이션을 개발할 수 있도록 만들어졌다.

모바일 앱 개발을 시작하면 Android와 iOS를 각각 개발해야 하는 경우가 많다. Android는 Kotlin이나 Java, iOS는 Swift나 Objective-C를 사용하는 식이다.

Flutter는 이 문제를 다른 방식으로 해결한다.

Dart로 작성한 하나의 애플리케이션 코드에서 UI와 애플리케이션 로직을 구성하고, Flutter가 제공하는 렌더링 시스템과 플랫폼 연동 기능을 이용해 Android, iOS, Web, Desktop 등 다양한 환경에서 실행할 수 있다.

Dart Code -> Flutter Framework -> Platform -> Application

Flutter에서 가장 먼저 이해해야 하는 개념은 Widget이다.

Flutter에서는 화면을 구성하는 거의 모든 요소를 Widget으로 표현한다.

텍스트도 Widget이고 버튼도 Widget이며, 레이아웃을 담당하는 Row, Column도 Widget이다.


Flutter의 기본 구조

Flutter는 Dart로 작성하는 UI 프레임워크다.

화면을 구성하는 거의 모든 것이 Widget으로 표현된다.

Widget build(BuildContext context) {
  return Column(
    children: [
      Text('Hello'),
      ElevatedButton(
        onPressed: () {},
        child: Text('Start'),
      ),
    ],
  );
}

Text, Column, ElevatedButton 모두 Widget이다.

HTML처럼 화면 요소와 동작을 각각 별도의 시스템으로 나누기보다, UI의 구조와 속성을 Widget Tree로 표현하는 방식에 가깝다.

MaterialApp -> Scaffold -> AppBar / Body -> Column -> Text / ElevatedButton

Flutter를 이해할 때 이 Widget Tree가 출발점이다.


StatelessWidget과 StatefulWidget

Flutter에서 UI를 만들다 보면 결국 상태를 어떻게 관리할 것인지가 문제가 된다.

상태가 필요 없는 UI라면 StatelessWidget을 사용한다.

class Profile extends StatelessWidget {
  final String name;

  const Profile({
    super.key,
    required this.name,
  });

  @override
  Widget build(BuildContext context) {
    return Text(name);
  }
}

입력으로 받은 값에 따라 UI를 만들고, 내부에서 변경되는 상태를 직접 관리하지 않는다.

반면 UI가 시간에 따라 변해야 한다면 StatefulWidget이 필요하다.

class Counter extends StatefulWidget {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int count = 0;

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

  @override
  Widget build(BuildContext context) {
    return Text('$count');
  }
}

여기서 중요한 것은 StatefulWidget 자체에 상태를 넣는 것이 아니라 State 객체가 상태를 가지고 있다는 점이다.

StatefulWidget -> State -> count / build()

Widget은 UI를 구성하기 위한 불변 객체이고, 실제로 변경되는 데이터는 State가 관리한다.


setState()가 하는 일

setState()는 값을 변경하는 특별한 문법이 아니다.

다음 코드에서 실제 값 변경은 그냥 count++다.

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

setState()의 역할은 해당 State가 변경되었다는 사실을 Flutter에 알리는 것이다.

상태 변경 -> setState() -> Flutter에 변경 알림 -> build() 호출 -> 새로운 Widget Tree 구성 -> 변경된 부분 반영

그래서 다음처럼 작성하면 문제가 생긴다.

count++;

값 자체는 바뀌지만 Flutter에게 UI를 다시 구성해야 한다는 사실을 알리지 않는다.

상태 변경과 화면 갱신 요청을 같이 처리해야 한다.


Widget은 왜 계속 다시 만들어지는가

Flutter를 처음 보면 build()가 다시 호출될 때마다 Widget을 새로 만드는 것이 비효율적으로 느껴진다.

실제로 Widget은 매우 가볍게 취급된다.

@override
Widget build(BuildContext context) {
  return Container(
    child: Text('Hello'),
  );
}

상태가 변경되면 build()가 다시 실행되고 새로운 Widget 객체들이 만들어질 수 있다.

하지만 Flutter는 Widget 객체 자체를 화면에 직접 그리는 것이 아니다.

Widget은 UI의 선언적인 설명에 가깝고, Flutter 내부에서는 이를 Element와 RenderObject가 관리한다.

Widget -> Element -> RenderObject

따라서 build()가 다시 실행되는 것 자체를 성능 문제로 볼 필요는 없다.

문제는 build() 안에서 무거운 작업을 계속 수행하는 경우다.

예를 들어 네트워크 요청이나 대규모 계산을 build()에서 직접 실행하는 것은 피하는 편이 좋다.


BuildContext

Flutter 코드에서 BuildContext는 거의 항상 보인다.

Widget build(BuildContext context) {
  ...
}

BuildContext는 현재 Widget이 Widget Tree에서 어디에 위치하고 있는지를 나타내는 핸들에 가깝다.

이것을 통해 상위 Widget이 제공하는 정보나 특정 기능에 접근할 수 있다.

대표적으로 다음과 같은 코드가 있다.

Theme.of(context)

현재 위치를 기준으로 적용되는 Theme을 가져온다.

Navigator.of(context)

현재 위치에서 사용할 수 있는 Navigator를 가져온다.

MediaQuery.of(context)

화면 크기나 시스템 UI 관련 정보를 가져올 수 있다.

그래서 context는 단순한 파라미터가 아니라 Widget Tree와 현재 Widget의 위치를 연결하는 역할을 한다.


InheritedWidget과 상태 전달

Widget Tree가 깊어지면 문제가 하나 생긴다.

하위 Widget에 데이터를 전달하려고 계속 파라미터를 넘기면 코드가 복잡해진다.

A -> B -> C -> D -> E

A가 가지고 있는 데이터를 E가 필요하다고 해서 모든 단계에서 데이터를 전달할 필요는 없다.

Flutter에는 이런 상황을 위해 InheritedWidget 계열의 메커니즘이 존재한다.

상위에서 데이터를 제공하고 하위 Widget이 BuildContext를 통해 가져오는 구조다.

Provider -> Widget A / Widget B / Widget C -> 데이터 접근

Flutter의 상태 관리 라이브러리들이 결국 이 Widget Tree 구조와 밀접하게 연결되는 이유도 여기에 있다.


상태 관리

Flutter에서 프로젝트가 커지면 setState()만으로 모든 상태를 관리하기 어려워진다.

상태를 크게 나누면 생각하기 편하다.

Local State / Global State / Shared State / Server State

버튼을 눌렀는지, 특정 위젯이 열려 있는지처럼 해당 화면에서만 필요한 상태는 로컬 상태로 처리할 수 있다.

반면 로그인 사용자, 장바구니, 설정값처럼 여러 화면에서 공유해야 하는 상태는 별도의 상태 관리 구조가 필요하다.

Flutter 생태계에는 다양한 방법이 있다.

  • Provider
  • Riverpod
  • BLoC / Cubit
  • GetX
  • ValueNotifier
  • ChangeNotifier

어떤 라이브러리가 무조건 정답이라고 보기는 어렵다.

작은 화면에서 상태 하나를 바꾸려고 거대한 상태 관리 구조를 도입하는 것도 과하다.

반대로 애플리케이션 전체에서 공유되는 상태를 각각의 Widget 내부에서 관리하면 나중에 구조가 꼬인다.

상태의 범위를 먼저 판단하고 도구를 선택하는 편이 낫다.


비동기 처리

Flutter 애플리케이션에서는 네트워크 요청, 파일 접근, 데이터베이스 처리처럼 시간이 걸리는 작업이 자주 발생한다.

Dart에서는 Futureasync / await를 사용한다.

Future<User> fetchUser() async {
  final response = await api.getUser();
  return response;
}

await는 작업이 끝날 때까지 해당 비동기 함수의 실행을 이어가지 않고 기다린다.

중요한 것은 이 과정이 UI 스레드를 그대로 멈추게 하는 방식이 아니라는 점이다.

Flutter에서 비동기 작업을 제대로 처리하면 네트워크 요청 중에도 UI는 계속 동작할 수 있다.

실제 UI에서는 보통 상태를 함께 관리한다.

Loading -> Request -> Success / Error

이 세 가지 상태를 명확하게 나누면 UI 코드도 자연스럽게 정리된다.


FutureBuilder

간단한 비동기 데이터를 화면에 표시할 때는 FutureBuilder를 사용할 수 있다.

FutureBuilder<User>(
  future: fetchUser(),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const CircularProgressIndicator();
    }

    if (snapshot.hasError) {
      return const Text('Error');
    }

    return Text(snapshot.data!.name);
  },
)

FutureBuilder는 Future의 상태에 따라 Widget을 다시 구성하기 편하게 만들어준다.

다만 build()가 반복 호출될 수 있다는 점을 생각해야 한다.

다음처럼 future를 직접 생성하면 의도하지 않은 요청이 반복될 가능성이 있다.

FutureBuilder(
  future: fetchUser(),
  ...
)

비동기 작업의 생명주기를 어디에서 관리할 것인지 먼저 결정하는 것이 좋다.

간단한 화면에서는 FutureBuilder가 편하지만, 복잡한 애플리케이션에서는 상태 관리 계층에서 비동기 상태까지 관리하는 경우가 많다.


Navigator와 화면 이동

Flutter에서 화면 전환은 Navigator가 담당한다.

기본적인 방식은 다음과 같다.

Navigator.push(
  context,
  MaterialPageRoute(
    builder: (_) => const DetailPage(),
  ),
);

현재 화면 위에 새로운 Route를 추가한다.

Route A -> push -> Route B -> push -> Route C

뒤로 가기는 Stack의 마지막 Route를 제거하는 방식이다.

Navigator.pop(context);

그래서 Navigator는 사실상 화면 전환을 Stack 구조로 관리한다고 이해하면 편하다.

프로젝트가 커지면 직접 push()pop()을 관리하는 것보다 선언적인 라우팅 구조를 사용하는 경우도 있다.

대표적으로 go_router 같은 패키지를 사용할 수 있다.


Layout의 기본

Flutter에서 화면 배치는 Widget의 조합으로 만들어진다.

자주 사용하는 것은 다음 정도다.

Row / Column / Stack / Container / Expanded / Flexible / Padding / Align / Center / ListView / GridView

여기서 중요한 건 RowColumn 자체보다 Flutter의 Layout 규칙이다.

Flutter의 레이아웃은 대략 다음 흐름으로 생각할 수 있다.

Parent가 제약 조건을 전달 -> Child가 자신의 크기를 결정 -> Parent가 Child의 위치를 결정

흔히

Constraints go down, sizes go up, parent sets position

이라는 식으로 설명한다.

이 구조를 이해하지 못하면 RenderFlex overflowed, unbounded constraints 같은 오류가 나왔을 때 원인을 찾기 어렵다.

예를 들어 Column 안에서 ListView를 무작정 사용하면 세로 방향의 크기 제약이 충돌할 수 있다.

그래서 다음과 같은 구조를 자주 사용한다.

Column(
  children: [
    const Header(),
    Expanded(
      child: ListView(
        children: [...],
      ),
    ),
  ],
)

Expanded가 남은 공간을 ListView에 할당해 주기 때문에 레이아웃 제약이 맞는다.


ListView와 성능

긴 목록을 보여줄 때는 모든 항목을 한 번에 만들어 놓기보다 필요한 영역을 중심으로 처리하는 것이 좋다.

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ListTile(
      title: Text(items[index].name),
    );
  },
)

ListView.builder()는 대량의 데이터를 표시할 때 사용하는 대표적인 방식이다.

특히 게임의 인벤토리, 채팅 목록, 상품 목록처럼 데이터가 계속 변하는 UI에서 자주 사용하게 된다.

단순히 리스트를 보여주는 것보다 아이템의 상태와 키, 이미지 로딩, 불필요한 rebuild까지 같이 봐야 실제 성능을 관리할 수 있다.


Key

Flutter에서 Key는 Widget을 식별할 때 사용한다.

특히 리스트처럼 Widget의 위치가 변경될 수 있는 상황에서 중요하다.

ListTile(
  key: ValueKey(item.id),
  title: Text(item.name),
)

예를 들어 다음과 같은 데이터가 있다고 하자.

A / B / C

순서가

C / A / B

로 변경되었다.

Flutter가 Widget의 위치만 보고 상태를 연결하면 기존 상태가 다른 데이터에 붙는 문제가 생길 수 있다.

고유한 Key를 사용하면 어떤 Widget이 어떤 데이터를 나타내는지 식별하는 데 도움을 줄 수 있다.

특히 StatefulWidget 리스트에서 상태가 이상하게 이동하는 문제가 발생하면 Key를 먼저 의심해볼 만하다.


Flutter의 렌더링 구조

Flutter의 화면 출력 구조를 조금 더 보면 세 가지 트리를 생각할 수 있다.

Widget Tree -> Element Tree -> RenderObject Tree

Widget

UI를 선언하는 불변 객체다.

Text('Hello')

Element

Widget과 실제 트리의 연결을 유지한다.

Widget이 rebuild되어 새로운 객체가 만들어져도 Element를 통해 기존 상태와 연결 관계를 유지할 수 있다.

RenderObject

실제 레이아웃과 페인팅에 관여한다.

크기와 위치를 계산하고 화면에 그릴 정보를 관리한다.

개발할 때 매번 이 세 가지를 직접 다룰 필요는 없다.

다만 build()가 다시 실행된다고 해서 화면 전체를 처음부터 무작정 다시 그리는 것은 아니라는 정도는 알고 있는 편이 좋다.


Flutter에서 성능을 볼 때

Flutter 성능 문제는 단순히 Widget 개수가 많아서 발생한다고 보면 안 된다.

다음과 같은 부분을 확인해야 한다.

  • 불필요한 rebuild
  • 무거운 build() 로직
  • 큰 이미지 처리
  • 과도한 layout / paint
  • 비효율적인 리스트
  • 메인 isolate에서 수행하는 무거운 연산
  • 불필요한 네트워크 요청
  • 상태 범위가 지나치게 넓은 경우

const Widget을 사용하는 것도 도움이 된다.

const Text('Hello');

변하지 않는 Widget을 const로 만들면 Flutter가 객체를 재사용할 수 있는 여지가 생긴다.

다만 const를 붙이는 것 자체를 성능 최적화의 전부처럼 생각할 필요는 없다.

실제로 문제가 발생하면 DevTools의 성능 도구를 이용해서 어디에서 비용이 발생하는지 확인하는 것이 더 중요하다.


Isolate

Dart는 기본적으로 이벤트 루프를 사용하는 구조이고, Flutter의 UI 작업은 메인 isolate에서 처리된다.

따라서 CPU를 많이 사용하는 작업을 메인 isolate에서 오래 실행하면 화면이 끊길 수 있다.

예를 들어 대규모 JSON 파싱이나 복잡한 계산을 한 번에 수행하면 UI가 버벅일 수 있다.

이런 작업은 필요에 따라 별도의 isolate에서 처리할 수 있다.

Main Isolate -> UI / Input / Rendering
Worker Isolate -> Heavy Computation

다만 모든 비동기 작업에 isolate가 필요한 것은 아니다.

네트워크 요청처럼 I/O를 기다리는 작업과 CPU를 계속 사용하는 작업은 구분해서 생각해야 한다.


프로젝트 구조

Flutter 프로젝트를 처음 만들면 모든 코드를 lib/ 아래에 넣기 시작하기 쉽다.

작을 때는 문제가 없지만 규모가 커지면 기능별로 역할을 분리하는 편이 관리하기 좋다.

예를 들어 이런 구조를 사용할 수 있다.

lib/ -> core / features / main.dart
core -> network / storage / theme
features -> auth / profile
auth -> data / domain / presentation
profile -> data / domain / presentation

여기서 중요한 건 폴더 이름 자체가 아니다.

UI 코드와 네트워크 요청, 데이터 모델, 비즈니스 로직이 한 Widget 안에 뒤섞이지 않게 만드는 것이다.

예를 들어 버튼을 눌렀다고 해서 Widget에서 직접 HTTP 요청을 만들고 JSON을 파싱하고 DB까지 수정하기 시작하면 나중에 테스트와 유지보수가 어려워진다.

Presentation -> State / Controller -> Repository -> Data Source -> API / Database

정도의 경계를 잡아두면 코드의 책임이 분명해진다.


Flutter 개발에서 자주 마주치는 문제

Flutter를 사용할 때 결국 자주 만나는 문제는 몇 가지로 압축된다.

화면이 갱신되지 않는다

상태는 변경했는데 setState()나 상태 관리 시스템을 통해 변경 사실을 알리지 않은 경우가 많다.

Overflow 오류가 발생한다

대부분 Widget의 크기 제약을 다시 봐야 한다.

특히 Row, Column, ListView, Expanded, Flexible의 관계를 확인한다.

비동기 요청이 반복된다

build()가 다시 호출되는 구조와 Future의 생성 위치를 확인한다.

리스트의 상태가 이상하다

Widget 재사용 과정에서 Key가 필요한 상황인지 확인한다.

화면이 버벅인다

무조건 Widget 개수부터 줄일 게 아니라 rebuild, layout, paint, 이미지, CPU 작업 등을 DevTools로 확인하는 게 먼저다.


Flutter를 이해하는 하나의 흐름

지금까지의 내용을 하나로 묶으면 대략 이런 구조가 된다.

Flutter App -> Widget Tree -> StatelessWidget / StatefulWidget -> State -> setState() -> Build -> Element Tree -> RenderObject Tree -> Layout / Paint -> 화면

애플리케이션 구조를 붙이면 다음과 같다.

UI -> State Management -> Repository -> API / Database

이 구조를 기반으로 보면 Flutter에서 사용하는 수많은 API와 패키지도 각각 어디에 속하는지 판단하기 쉬워진다.


정리

Flutter를 공부하면서 느낀 건 Widget 문법을 많이 아는 것과 Flutter를 이해하는 건 조금 다른 문제라는 것이다.

중심에는 Widget Tree가 있고, 상태가 변경되면 새로운 UI 구성이 만들어진다. Flutter는 Widget, Element, RenderObject를 통해 이 구조를 관리하고 실제 화면을 layout하고 paint한다.

개발하면서 자주 만나는 setState(), BuildContext, Key, FutureBuilder, Navigator, Expanded 같은 것들도 각각 따로 외우기보다 이 구조 안에서 보면 역할이 명확해진다.

Widget -> State -> Build -> Element -> RenderObject -> Layout / Paint -> 화면

애플리케이션 규모가 커지면 여기에 상태 관리, 라우팅, 네트워크, 저장소, 아키텍처가 추가된다.

결국 Flutter 개발에서 중요한 건 Widget API를 얼마나 많이 외웠느냐보다 UI, 상태, 데이터, 렌더링의 책임을 어디에 둘 것인가를 판단하는 능력에 가깝다.