Wednesday, August 5, 2020

Flutter 성능 최적화의 비밀: Future.wait로 비동기 코드를 200% 개선하는 방법

Flutter와 Dart를 사용하여 현대적인 애플리케이션을 개발할 때, 비동기 프로그래밍은 더 이상 선택이 아닌 필수입니다. 사용자 경험을 저해하지 않으면서 네트워크 요청, 파일 I/O, 데이터베이스 접근 등 시간이 걸리는 작업을 처리하려면 Futureasync/await 문법에 대한 깊은 이해가 필요합니다. 하지만 여러 개의 비동기 작업을 동시에 처리해야 하는 상황에 직면하면 많은 개발자가 비효율적인 코드를 작성하는 함정에 빠지곤 합니다.

예를 들어, 사용자의 프로필 화면을 로딩하는 시나리오를 상상해 봅시다. 화면에 표시해야 할 정보는 다음과 같습니다.

  1. 사용자의 기본 정보 (이름, 이메일 등)
  2. 사용자가 작성한 최신 게시물 목록
  3. 사용자의 친구 목록

이 세 가지 데이터는 각각 다른 API 엔드포인트에서 가져와야 하는 독립적인 비동기 작업입니다. 가장 직관적인 방법은 아마도 await를 순차적으로 사용하는 것일 겁니다. 하지만 이것이 최선일까요? 이 글에서는 순차적 await의 숨겨진 비용을 파헤치고, Future.wait를 사용하여 어떻게 코드의 성능과 가독성을 극적으로 향상시킬 수 있는지 심층적으로 알아보겠습니다.

1. 순차적 `await`의 함정: 보이지 않는 성능 저하

가장 먼저 떠올릴 수 있는 코드는 아래와 같을 것입니다. 각 비동기 함수가 완료되기를 순서대로 기다리는 방식입니다.

// 각 API 호출을 흉내 내는 비동기 함수들
Future<String> fetchUserProfile() async {
  await Future.delayed(Duration(seconds: 1));
  print('사용자 프로필 로딩 완료!');
  return '사용자 A';
}

Future<List<String>> fetchUserPosts() async {
  await Future.delayed(Duration(seconds: 2));
  print('게시물 목록 로딩 완료!');
  return ['게시물 1', '게시물 2'];
}

Future<List<String>> fetchUserFriends() async {
  await Future.delayed(Duration(seconds: 1.5));
  print('친구 목록 로딩 완료!');
  return ['친구 B', '친구 C'];
}

// 순차적으로 데이터를 로딩하는 비효율적인 방법
Future<void> loadProfileDataSequentially() async {
  print('프로필 데이터 로딩 시작 (순차적)');
  final stopwatch = Stopwatch()..start();

  final userProfile = await fetchUserProfile();
  final userPosts = await fetchUserPosts();
  final userFriends = await fetchUserFriends();

  stopwatch.stop();
  print('모든 데이터 로딩 완료! 총 소요 시간: ${stopwatch.elapsedMilliseconds}ms');
  // 이제 userProfile, userPosts, userFriends를 사용하여 UI를 그린다.
}

// 실행 결과 예상:
// 프로필 데이터 로딩 시작 (순차적)
// (1초 후) 사용자 프로필 로딩 완료!
// (2초 후) 게시물 목록 로딩 완료!
// (1.5초 후) 친구 목록 로딩 완료!
// 모든 데이터 로딩 완료! 총 소요 시간: 4500ms 근방

위 코드의 문제점은 명확합니다. fetchUserProfile()이 완료되기 전까지 fetchUserPosts()는 시작조차 하지 않습니다. 마찬가지로, fetchUserPosts()가 끝나야만 fetchUserFriends()가 실행됩니다. 이 세 작업은 서로 의존성이 없으므로 동시에 시작할 수 있음에도 불구하고, 코드의 구조 때문에 직렬로 처리되고 있습니다.

이로 인한 총 소요 시간은 각 작업 시간의 합계가 됩니다: 1초 + 2초 + 1.5초 = 4.5초. 사용자는 4.5초라는 긴 시간 동안 로딩 화면을 봐야만 합니다. 이는 앱의 반응성을 크게 떨어뜨리는 원인이 됩니다.

이러한 처리 방식을 타임라인으로 그려보면 다음과 같습니다.

시간 축 ---->
|--- fetchUserProfile (1s) ---|
                             |--- fetchUserPosts (2s) ---|
                                                         |--- fetchUserFriends (1.5s) ---|
|<-------------------------- 총 4.5초 ------------------------------------------------->|

이 비효율을 어떻게 해결할 수 있을까요? 바로 여기에 Future.wait가 등장합니다.

2. 동시 실행의 구원자, `Future.wait` 소개

Future.wait는 Dart의 `dart:async` 라이브러리에 포함된 정적(static) 메서드로, 여러 개의 Future를 담은 리스트(Iterable)를 인자로 받습니다. 그리고 이 모든 Future들이 성공적으로 완료될 때 완료되는 단일 Future를 반환합니다.

핵심은 `Future.wait`가 이 Future들을 동시에 실행시킨다는 점입니다. 어느 하나가 끝날 때까지 다른 하나가 기다리지 않습니다. 모든 작업이 동시에 시작되고, 그중 가장 오래 걸리는 작업이 끝날 때 전체 작업이 완료됩니다.

앞서 본 순차적 코드를 Future.wait를 사용하여 다시 작성해 보겠습니다.

Future<void> loadProfileDataConcurrently() async {
  print('프로필 데이터 로딩 시작 (동시)');
  final stopwatch = Stopwatch()..start();

  // 모든 Future를 리스트에 담아 Future.wait에 전달한다.
  final results = await Future.wait([
    fetchUserProfile(),
    fetchUserPosts(),
    fetchUserFriends(),
  ]);

  stopwatch.stop();
  print('모든 데이터 로딩 완료! 총 소요 시간: ${stopwatch.elapsedMilliseconds}ms');

  // 결과는 Future를 전달한 순서대로 리스트에 담겨 반환된다.
  final userProfile = results[0] as String;
  final userPosts = results[1] as List<String>;
  final userFriends = results[2] as List<String>;

  print('프로필: $userProfile');
  print('게시물: $userPosts');
  print('친구: $userFriends');
}

// 실행 결과 예상:
// 프로필 데이터 로딩 시작 (동시)
// (1초 후) 사용자 프로필 로딩 완료!
// (1.5초 후) 친구 목록 로딩 완료!
// (2초 후) 게시물 목록 로딩 완료!
// 모든 데이터 로딩 완료! 총 소요 시간: 2000ms 근방

결과를 보십시오! 총 소요 시간이 4.5초에서 약 2초로 극적으로 단축되었습니다. 이는 가장 오래 걸리는 작업인 fetchUserPosts()의 실행 시간인 2초와 거의 같습니다. Future.wait는 모든 작업을 동시에 시작하고, 모든 작업이 완료될 때까지 기다렸다가 결과를 반환하기 때문입니다.

타임라인으로 보면 그 효율성이 더욱 명확해집니다.

시간 축 ---->
|--- fetchUserProfile (1s) ---|
|--- fetchUserPosts (2s) -------------------|
|--- fetchUserFriends (1.5s) -------|
|<------------------ 총 2초 ----------------->| (가장 긴 작업 시간에 수렴)

이것만으로도 Future.wait를 사용해야 할 이유는 충분합니다. 하지만 `Future.wait`의 강력함은 여기서 그치지 않습니다. 더 깊이 파고들어 그 기능과 주의사항을 알아보겠습니다.

3. `Future.wait` 심층 분석: 알아두어야 할 모든 것

Future.wait를 효과적으로 사용하기 위해서는 몇 가지 중요한 특징들을 정확히 이해해야 합니다.

3.1. 결과의 순서는 100% 보장됩니다

가장 많이 하는 질문 중 하나는 "결과 리스트의 순서가 어떻게 될까?"입니다. Future들이 완료되는 순서대로 결과가 담길까요? 아닙니다. `Future.wait`는 입력으로 제공된 Future 리스트의 순서와 동일한 순서로 결과 리스트를 반환하는 것을 보장합니다.

앞선 예제에서 `fetchUserProfile()`이 가장 먼저 완료되었지만, 결과 리스트 `results`의 첫 번째 요소(`results[0]`)는 `fetchUserProfile()`의 결과입니다. 두 번째 요소(`results[1]`)는 두 번째로 전달된 `fetchUserPosts()`의 결과입니다. 이 특성 덕분에 우리는 결과를 예측 가능하게 처리할 수 있습니다.

final results = await Future.wait([
  fetchUserProfile(), // 이 결과는 항상 results[0]
  fetchUserPosts(),   // 이 결과는 항상 results[1]
  fetchUserFriends(), // 이 결과는 항상 results[2]
]);

이 예측 가능성은 코드의 안정성과 가독성을 크게 높여주는 중요한 장점입니다.

3.2. 타입이 다른 Future 다루기 (Type Safety)

우리의 예제처럼 각 Future가 반환하는 값의 타입이 다른 경우가 많습니다. `fetchUserProfile`은 `Future`을, `fetchUserPosts`는 `Future>`을 반환합니다. 이들을 `Future.wait`에 함께 전달하면, 반환되는 `Future`의 타입은 `Future>`가 됩니다 (최신 Dart 버전 기준, 이전에는 `Future>`).

따라서 결과를 사용할 때는 적절한 타입으로 캐스팅(casting)을 해주어야 합니다.

final results = await Future.wait([
  fetchUserProfile(), // Future<String>
  fetchUserPosts(),   // Future<List<String>>
]);

// results는 List<Object> 타입이다.
final userProfile = results[0] as String;
final userPosts = results[1] as List<String>;

만약 더 강력한 타입 안정성을 원한다면, Dart 3.0에 도입된 'Records(레코드)'를 활용하는 것이 훌륭한 대안이 될 수 있습니다. 레코드를 사용하면 타입 캐스팅 없이도 타입 안전성을 확보할 수 있습니다.

// Dart 3.0 이상에서 Records 활용
Future<(String, List<String>, List<String>)> loadProfileWithRecords() async {
  // 병렬 실행은 동일하게 Future.wait를 사용
  final (userProfile, userPosts, userFriends) = await (
    fetchUserProfile(),
    fetchUserPosts(),
    fetchUserFriends(),
  ).wait;

  // 반환된 튜플에서 바로 타입-안전하게 값에 접근 가능
  print(userProfile); // String 타입
  print(userPosts);   // List<String> 타입
  return (userProfile, userPosts, userFriends);
}

레코드의 .wait 확장 메서드는 내부적으로 `Future.wait`를 사용하지만, 결과를 타입 안전한 레코드로 반환해주어 훨씬 깔끔하고 안전한 코드를 작성하게 해줍니다. 새로운 프로젝트라면 이 방식을 적극적으로 고려해 보세요.

3.3. 가장 중요한 것: 에러 핸들링

비동기 작업, 특히 네트워크 요청은 언제든지 실패할 수 있습니다. Future.wait는 에러를 어떻게 처리할까요? 기본적으로 `Future.wait`는 'All or Nothing(전부 아니면 전무)' 전략을 따릅니다.

리스트에 포함된 Future단 하나라도 에러를 발생시키며 실패하면, `Future.wait`가 반환한 전체 `Future`도 즉시 그 에러와 함께 실패합니다. 이때 다른 Future들이 아직 실행 중이더라도 기다리지 않습니다.

Future<String> fetchWithError() async {
  await Future.delayed(Duration(seconds: 1));
  throw Exception('네트워크 연결 실패!');
}

Future<void> testErrorHandling() async {
  try {
    print('에러 핸들링 테스트 시작');
    final results = await Future.wait([
      fetchUserProfile(), // 성공 (1초)
      fetchWithError(),   // 실패 (1초)
      fetchUserPosts(),   // 성공 (2초), 하지만 실행될 기회를 얻지 못할 수 있음
    ]);
    // 이 코드는 절대 실행되지 않는다.
    print('모든 작업 성공!');
  } catch (e) {
    // fetchWithError에서 발생한 Exception이 여기서 잡힌다.
    print('에러 발생: $e');
  }
}

// 실행 결과 예상:
// 에러 핸들링 테스트 시작
// (1초 후) 사용자 프로필 로딩 완료!
// (1초 후) 에러 발생: Exception: 네트워크 연결 실패!

위 예제에서 fetchWithError()가 1초 후에 실패하면, Future.wait는 즉시 중단되고 catch 블록으로 제어가 넘어갑니다. 2초가 걸리는 fetchUserPosts()가 완료될 때까지 기다리지 않습니다. 이는 대부분의 경우에 합리적인 동작입니다. 프로필 화면을 구성하는 데 필요한 데이터 중 하나라도 가져오지 못했다면, 화면을 제대로 그릴 수 없으므로 빠르게 실패 처리하고 사용자에게 오류 메시지를 보여주는 것이 낫기 때문입니다.

4. `Future.wait` 고급 활용법: `eagerError`와 `cleanUp`

Future.wait는 두 개의 선택적 파라미터, `eagerError`와 `cleanUp`을 제공하여 에러 처리 방식을 더 세밀하게 제어할 수 있게 해줍니다.

4.1. `eagerError` 파라미터: 실패 시 즉시 중단할 것인가?

  • eagerError: true (기본값): 위에서 설명한 동작입니다. 하나의 Future가 실패하면 즉시 전체가 실패합니다.
  • eagerError: false: 하나의 Future가 실패하더라도, 모든 Future가 완료될 때까지 기다립니다. 모든 작업이 완료된 후, 발생했던 첫 번째 에러로 전체 Future를 실패시킵니다.

언제 eagerError: false가 유용할까요? 여러 개의 파일을 서버에 업로드하고, 일부가 실패하더라도 모든 업로드 시도가 끝날 때까지 기다리고 싶을 때 사용할 수 있습니다. 또는, 일부 작업이 실패하더라도 성공한 작업들의 결과로 무언가(예: 로그 기록, 부분적 데이터 저장)를 해야 할 때 유용합니다. 하지만 이 경우에도 최종 결과는 에러이므로 성공한 결과값에 직접 접근할 수는 없습니다. (성공한 결과까지 모두 얻고 싶다면 JavaScript의 `Promise.allSettled`와 유사한 기능을 직접 구현하거나 패키지를 사용해야 합니다.)

Future<void> testEagerError() async {
  print('eagerError: false 테스트 시작');
  final stopwatch = Stopwatch()..start();
  try {
    await Future.wait(
      [
        fetchUserProfile(), // 성공 (1초)
        fetchWithError(),   // 실패 (1초)
        fetchUserPosts(),   // 성공 (2초)
      ],
      eagerError: false,
    );
  } catch (e) {
    stopwatch.stop();
    print('에러 발생: $e');
    print('총 소요 시간: ${stopwatch.elapsedMilliseconds}ms');
  }
}

// 실행 결과 예상:
// eagerError: false 테스트 시작
// (1초 후) 사용자 프로필 로딩 완료!
// (2초 후) 게시물 목록 로딩 완료!
// (2초 후) 에러 발생: Exception: 네트워크 연결 실패!
// (2초 후) 총 소요 시간: 2000ms 근방

결과에서 보듯이, fetchWithError가 1초 만에 실패했음에도 불구하고 프로그램은 가장 오래 걸리는 fetchUserPosts가 완료될 때까지(총 2초) 기다린 후 catch 블록을 실행합니다. 이 차이점을 이해하는 것은 중요합니다.

4.2. `cleanUp` 파라미터: 실패 시 뒷정리하기

cleanUp 파라미터는 `eagerError: true`일 때 특히 유용합니다. 하나의 Future가 실패하여 전체 작업이 중단될 때, 그 시점까지 성공적으로 완료된 다른 Future들의 결과를 가지고 무언가 정리 작업을 할 수 있게 해줍니다.

예를 들어, 여러 개의 임시 파일을 생성하고 처리하는 작업을 동시에 수행하다가 하나가 실패했을 때, 이미 성공적으로 생성된 임시 파일들을 삭제하는 등의 뒷정리 작업에 사용할 수 있습니다.

Future<String> createTempFile(String name) async {
  await Future.delayed(Duration(milliseconds: 500));
  print('$name 생성 성공');
  return name;
}

Future<void> testCleanUp() async {
  try {
    await Future.wait(
      [
        createTempFile('file_A'), // 0.5초 후 성공
        fetchWithError(),         // 1초 후 실패
        createTempFile('file_C'), // 0.5초 후 성공 (fetchWithError보다 먼저)
      ],
      cleanUp: (successValue) {
        // fetchWithError가 실패하기 전, file_A와 file_C는 성공했을 수 있다.
        // successValue는 성공한 결과값이다. 여기서는 'file_A' 또는 'file_C'
        print('Cleanup 실행! 성공적으로 생성된 파일 삭제: $successValue');
        // 여기서 실제 파일 삭제 로직을 구현
      },
    );
  } catch (e) {
    print('작업 중 에러 발생: $e');
  }
}

// 실행 결과 예상 (순서는 약간 다를 수 있음):
// file_A 생성 성공
// file_C 생성 성공
// Cleanup 실행! 성공적으로 생성된 파일 삭제: file_A
// Cleanup 실행! 성공적으로 생성된 파일 삭제: file_C
// 작업 중 에러 발생: Exception: 네트워크 연결 실패!

cleanUp 콜백은 성공적으로 완료된 Future가 생길 때마다 호출됩니다. 이를 통해 리소스 누수 없이 안전하게 동시 작업을 관리할 수 있습니다.

5. 실전! Flutter 위젯과 `Future.wait` 결합하기

이론은 충분히 배웠으니, 이제 실제 Flutter 앱에서 Future.wait를 어떻게 사용하는지 보겠습니다. FutureBuilder 위젯과 결합하면 매우 강력하고 깔끔한 비동기 UI 코드를 작성할 수 있습니다.

프로필 화면 예제를 다시 가져와 FutureBuilder로 구현해 봅시다.

import 'package:flutter/material.dart';

// 이전에 정의한 비동기 함수들을 여기에 포함시킵니다.
// fetchUserProfile(), fetchUserPosts(), fetchUserFriends(), ...

class ProfileScreen extends StatefulWidget {
  @override
  _ProfileScreenState createState() => _ProfileScreenState();
}

class _ProfileScreenState extends State<ProfileScreen> {
  late final Future<List<Object>> _profileData;

  @override
  void initState() {
    super.initState();
    // initState에서 단 한 번만 Future를 생성합니다.
    _profileData = _fetchAllData();
  }

  // Future.wait를 사용하여 모든 데이터를 동시에 가져오는 메서드
  Future<List<Object>> _fetchAllData() {
    return Future.wait([
      fetchUserProfile(),
      fetchUserPosts(),
      fetchUserFriends(),
    ]);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('사용자 프로필')),
      body: FutureBuilder<List<Object>>(
        future: _profileData, // initState에서 생성한 Future를 전달
        builder: (context, snapshot) {
          // 1. 로딩 중일 때
          if (snapshot.connectionState == ConnectionState.waiting) {
            return Center(child: CircularProgressIndicator());
          }

          // 2. 에러가 발생했을 때
          if (snapshot.hasError) {
            return Center(child: Text('데이터 로딩 실패: ${snapshot.error}'));
          }

          // 3. 데이터 로딩이 성공적으로 완료되었을 때
          if (snapshot.hasData) {
            final data = snapshot.data!;
            final userProfile = data[0] as String;
            final userPosts = data[1] as List<String>;
            final userFriends = data[2] as List<String>;

            return ListView(
              padding: EdgeInsets.all(16.0),
              children: [
                Text('프로필', style: Theme.of(context).textTheme.headlineMedium),
                Text(userProfile),
                SizedBox(height: 24),
                Text('최신 게시물', style: Theme.of(context).textTheme.headlineMedium),
                for (final post in userPosts) ListTile(title: Text(post)),
                SizedBox(height: 24),
                Text('친구 목록', style: Theme.of(context).textTheme.headlineMedium),
                for (final friend in userFriends) ListTile(title: Text(friend)),
              ],
            );
          }
          
          // 4. 데이터가 없는 경우 (이론상 여기에 도달하면 안 됨)
          return Center(child: Text('데이터가 없습니다.'));
        },
      ),
    );
  }
}

이 코드의 핵심은 다음과 같습니다.

  1. `initState`에서 Future 생성: `build` 메서드는 리빌드될 때마다 호출되므로, API 호출을 하는 `_fetchAllData()`를 `build` 안에 두면 안 됩니다. `initState`에서 한 번만 호출하여 그 결과를 상태 변수(`_profileData`)에 저장하고, `FutureBuilder`는 이 변수를 사용해야 불필요한 네트워크 요청을 막을 수 있습니다.
  2. 상태에 따른 UI 분기: `FutureBuilder`의 `builder` 콜백은 `snapshot` 객체를 통해 Future의 현재 상태(`connectionState`, `hasError`, `hasData` 등)를 알려줍니다. 이를 통해 로딩 중, 에러 발생, 성공 등 각 상황에 맞는 UI를 손쉽게 구성할 수 있습니다.
  3. 효율적인 데이터 로딩: `Future.wait` 덕분에 사용자는 세 개의 API 호출 중 가장 오래 걸리는 시간만큼만 기다리면 모든 정보를 한 번에 볼 수 있습니다. 이는 사용자 경험을 크게 향상시킵니다.

결론: `Future.wait`는 당신의 비동기 코드를 구원할 수 있다

우리는 `Future.wait`가 단순한 편의 기능을 넘어 Flutter 애플리케이션의 성능과 안정성을 좌우하는 핵심 도구임을 확인했습니다. 순차적 `await`의 함정을 피하고 `Future.wait`를 적극적으로 활용함으로써 얻을 수 있는 이점은 명확합니다.

  • 압도적인 성능 향상: 서로 독립적인 비동기 작업들을 동시에 실행하여 전체 대기 시간을 가장 오래 걸리는 작업 하나에 가깝게 단축시킵니다.
  • 코드 가독성 및 유지보수성 증가: 여러 비동기 호출과 그 결과 처리를 한 곳으로 모아 코드의 의도를 명확하게 표현할 수 있습니다.
  • 강력하고 예측 가능한 에러 처리: 'All or Nothing' 전략과 `try-catch`를 통해 비동기 작업 그룹의 실패를 일관되게 처리할 수 있으며, `eagerError`와 `cleanUp` 옵션으로 더 정교한 제어가 가능합니다.

지금 바로 당신의 Flutter 프로젝트 코드를 살펴보세요. 여러 개의 `await`가 줄지어 있는 곳이 보인다면, 그 작업들이 정말로 순차적으로 실행되어야 하는지 자문해 보십시오. 만약 그렇지 않다면, `Future.wait`를 사용하여 리팩토링하는 것을 강력히 추천합니다. 단 몇 줄의 코드 변경만으로도 사용자는 훨씬 빠르고 쾌적한 앱을 경험하게 될 것입니다.


0 개의 댓글:

Post a Comment