10 Lesser-Known Dart and Flutter Functionalities You Should Start Using
Let’s uncover some of lesser-known functionalities that can simplify your development process and add a unique touch to your apps.
medium.com
* 이 글은 원문을 있는 그대로 번역하기 보단, 최대한 원문을 반영해 보다 나은 이해를 위해 정리하고, 쉽게 풀어쓴 글입니다
더 잘 알고 싶다면 원래 게시물을 확인하세요
https://dcm.dev/blog/2025/02/27/ten-lesser-known-dart-flutter-functionalities/
저는 2019년에 다트 코드를 쓰기 시작했고, 그 이후로 매일 새로운 것을 배우고 있습니다. 이 프레임워크와 언어가 무엇을 제공하는지 보는 것은 흥미롭습니다.
이 기사에서는 개발 프로세스를 간소화하고 앱에 독특한 터치를 더할 수 있는 잘 알려지지 않은 기능들을 발견한 몇 가지 숨겨진 보석을 공유하고자 합니다. 또한 구현에 대해 자세히 알아보고 패턴에 대해 자세히 알아보겠습니다.
이 기능들과 방법들을 함께 알아봅시다!
Future.any란 무엇인가?
Future.any는 여러 개의 비동기 작업 중 가장 먼저 완료되는 하나의 결과만을 선택하여 반환하는 Dart의 유틸리티 함수입니다.
즉, 여러 Future 중 어떤 것이 가장 먼저 끝나는지에 따라 그 결과가 전체 작업의 결과가 됩니다. 이러한 방식은 일반적으로 네트워크 요청 병렬 처리, 캐시-서버 동시 조회, 사용자 입력 대기 등의 상황에서 앱의 응답성을 높이기 위해 활용됩니다.
내부적으로 Future.any는 주어진 List<Future>를 순회하며 각 Future에 대해 then()을 호출하고,
- 성공 시 동작할 onValue 콜백과
- 실패 시 동작할 onError 콜백을 등록합니다.
가장 먼저 완료된Future가 이 콜백을 실행하게 되며, 해당 시점에서Completer가 완료되고 반환됩니다.
final result = await Future.any([
operationA(),
operationB(),
operationC(),
]);
이 구조는 Completer<T>를 사용하여 수동으로 Future<T>를 생성하고 제어하는 패턴과 유사합니다.
또한 .sync() 생성자를 사용하면 Future가 이미 완료되어 있을 경우 즉시 결과를 반환하여 약간의 최적화를 이룰 수도 있습니다.
중요한 특징과 주의사항
1. “Winner-Takes-All” — But No Cancellation ( 승자 독식 - 그러나 취소는 없다 )
Future.any는 가장 먼저 완료된 Future 하나만 결과로 삼고, 나머지 Future는 무시됩니다.
하지만 무시된다고 해서 취소되는 것은 아닙니다. 나머지 Future들은 여전히 백그라운드에서 실행되고 있으며, CPU, 메모리, 네트워크 자원을 계속 소모할 수 있습니다.
bool _isCancelled = false;
Future<String> fetchData() async {
await Future.delayed(Duration(seconds: 3));
if (_isCancelled) throw "작업이 취소되었습니다.";
return "API 응답";
}
Future<String> heavyTask() async {
for (int i = 0; i < 1000000000; i++) {
if (_isCancelled) throw "파싱 작업 중단됨";
}
return "파일 파싱 완료";
}
void main() async {
final result = await Future.any([
fetchData(),
heavyTask(),
]);
_isCancelled = true; // 나머지 Future 중단 유도
print(result);
}
이처럼 직접 플래그를 사용하거나, Isolate를 명시적으로 kill()하여 리소스를 아끼는 전략이 필요합니다.
2. 커스텀 타임아웃 처리
기존의 Future.timeout() 대신, Future.any와 Future.delayed()를 함께 사용하면 더 유연한 타임아웃 처리가 가능합니다.
final result = await Future.any([
slowOperation(),
Future.delayed(Duration(seconds: 3), () => 'Timeout!'),
]);
print(result); // 3초 이내 응답 없으면 'Timeout!' 반환
특정 작업이 일정 시간 안에 완료되지 않으면 예외 대신 fallback 값을 반환하는 방식으로 UX를 부드럽게 만들 수 있습니다.
- 일반적인 Future.timeout은 시간 초과 시 예외를 발생시키지만, 이 방식은 예외 없이 fallback 값을 반환하거나 다른 처리 로직을 추가할 수 있습니다.
- 즉, 보다 복잡하거나 상황별로 다른 대응을 원할 때, Future.timeout보다 사용자 경험을 더 잘 컨트롤할 수 있습니다.
왜? (with ChatGPT) 상세한 설명이 보고싶다면 아래 더보기를 눌러보자
📌 왜 더 좋은가? (핵심 이유 3가지)
1. timeout()은 "예외" 흐름, 하지만 대부분은 "예외가 아니라 fallback"이 필요함
await fetchData().timeout(Duration(seconds: 3));
- 3초 안에 끝나지 않으면 TimeoutException이 발생하고, try-catch가 필요함
- 예외가 발생하면 이후 흐름이 복잡해짐
- 하지만 대부분의 상황에서는 "타임아웃되면 캐시를 쓰자", "타임아웃되면 기본값을 주자" 같은 흐름이 자연스럽고 기대되는 UX임
2. ✅ Future.any + Future.delayed는 "타임아웃도 하나의 성공 응답처럼 처리"
final result = await Future.any([
fetchFromServer(), // 5초 걸림
Future.delayed(Duration(seconds: 3), () => 'Timeout!') // 3초 뒤 대체값
]);
- 서버 응답이 늦어지면 'Timeout!'이라는 성공값(String) 을 그대로 넘겨줌
- 즉, 에러가 아닌 정상 흐름으로 다음 단계로 넘어갈 수 있음
3. 💪 더 많은 fallback 옵션 추가 가능 (캐시, 기본값, 사용자 안내 등)
final result = await Future.any([
fetchFromServer(),
Future.delayed(Duration(seconds: 3), getCachedData),
Future.delayed(Duration(seconds: 5), () => '기본값'),
]);
- 캐시도 시도하고, 그것마저 없으면 기본값 제공
- 이런 흐름은 Future.timeout()으로는 구현이 매우 번거로움
🎯 결론
- Future.timeout()은 타임아웃 상황을 "실패" 로 처리해야 할 때 적절하옵니다.
- 반면 Future.any + Future.delayed는 타임아웃을 "정상적인 대체 응답" 으로 처리할 수 있어 UX와 코드 흐름이 훨씬 부드럽고, 예외 처리에 의존하지 않으며, 로직 확장성도 뛰어남.
따라서 복잡한 앱 흐름이나 다단계 Fallback, 예외 대신 대체 응답을 원하신다면 Future.any + Future.delayed는 훨씬 더 나은 전략
3. 캐시 → 서버 순 대응
다음은 Flutter 앱에서 자주 활용되는 캐시 우선 → 서버 대체 구조의 예시입니다:
Future<String> fetchFromServer() async {
await Future.delayed(Duration(seconds: 5)); // 느린 응답 시뮬레이션
return "서버 응답";
}
Future<String> getCachedData() async {
return "캐시 데이터";
}
void main() async {
final result = await Future.any([
fetchFromServer(),
Future.delayed(Duration(seconds: 3), getCachedData),
]);
print(result); // 3초 안에 서버 응답 없으면 캐시 사용
}
이 구조는 사용자에게는 빠른 응답을 제공하고, 서버에서는 느린 요청이 계속 백그라운드에서 처리되도록 하여 UX를 지키면서 기능을 보완합니다.
4. 실패를 무시하고 성공만 기다리기
Future.any는 가장 먼저 "완료된 Future"의 결과를 가져오기 때문에, 에러가 먼저 발생하면 전체가 실패하게 됩니다.
그러나 에러를 무시하고 성공한 Future의 결과만 기다리려면, 각 Future 내부에서 직접 예외를 잡아줘야 합니다:
Future<String?> fetchA() async {
try {
await Future.delayed(Duration(seconds: 2));
throw Exception("서버 A 실패");
} catch (_) {
return null; // 실패는 무시
}
}
Future<String?> fetchB() async {
await Future.delayed(Duration(seconds: 3));
return "서버 B 응답";
}
void main() async {
final result = await Future.any([
fetchA(),
fetchB(),
].map((f) => f.catchError((_) => null))); // Future.any는 null을 무시하지 않음
if (result != null) {
print("성공한 응답: $result");
} else {
print("모든 서버가 실패했습니다.");
}
}
이 구조는 "Fail Fast" 전략(“가장 먼저 완료된 Future가 실패(Error)하면 즉시 전체 작업을 중단하고 그 오류로 반환하라.”)이 아닌 "Wait for Success" (“실패는 무시하고, 성공한 작업이 나올 때까지 기다리자.”)전략에 가깝습니다.
Future.any([future]) 쓰면 안 되는 이유
만약 하나의 Future만을 기다리는 상황에서 Future.any([future])처럼 작성하면, 비효율적이며 코드 가독성도 떨어집니다.
// ❌ 비효율적
await Future.any([future]);
// ✅ 간결하고 효율적
await future;
이와 같이 불필요한 컬렉션 생성을 피하라는 것이 Dart 린트 규칙 avoid-unnecessary-collections입니다.
동일하게 addAll([value]), Stream.fromFutures([future]) 등도 add(value), Stream.fromFuture(future)로 바꿔야 코드가 더 효율적이고 명확해집니다.
요약 정리
Future.any는 경쟁 처리에 적합: 가장 빠른 결과를 선택- 취소 처리 필요: 나머지 Future는 계속 실행되므로 리소스 낭비 주의
- 커스텀 타임아웃 구현 가능
- 캐시 → 서버 대응, Fail Fast vs Wait for Success 전략 모두 가능
- 불필요한 List/Set 사용 지양 → 코드 최적화와 린트 준수
scheduleMicrotask — 마이크로태스크 큐에 등록되는 가장 빠른 비동기 작업
Dart의 동시성(concurrency) 모델에는 scheduleMicrotask라는 낮은 수준의 API가 존재합니다. 이 함수는 콜백을 마이크로태스크 큐(microtask queue)에 등록하여, 일반 이벤트 큐(event queue)에 등록된 작업보다 먼저 실행되도록 만듭니다. 이로 인해 scheduleMicrotask는 매우 빠르게, 하지만 비동기로 코드를 실행해야 할 때 유용합니다.
동작 방식 예시
import 'dart:async';
void main() {
Timer.run(() => print('Timer event')); // 일반 이벤트
scheduleMicrotask(() => print('Microtask event')); // 마이크로태스크
print('Main done');
}
출력 결과는 다음과 같습니다:
Main done
Microtask event
Timer event
해석:

print('Main done')은 동기(synchronous) 코드로 바로 실행됩니다.scheduleMicrotask로 등록된 콜백은 마이크로태스크 큐에 들어가며, 일반 이벤트보다 먼저 실행됩니다.Timer.run은 일반 이벤트 큐에 들어가므로 마지막에 실행됩니다.

언제 사용해야 하는가
즉시 비동기 실행이 필요할 때
- 현재 실행 중인 동기 코드가 끝나자마자 어떤 작업을 비동기로 처리하되, 타이머나 네트워크 요청보다 먼저 실행하고 싶다면 scheduleMicrotask가 적합합니다.
연산 분할이 필요할 때
- CPU를 많이 소모하는 작업을 나누어 실행할 경우, 각 연산 조각을 scheduleMicrotask로 큐에 넣음으로써 UI 프레임 드롭 없이 작업을 분산할 수 있습니다.
Zone이 있는 환경에서의 제어
- Dart는 Zone이라는 개념을 통해 비동기 코드의 실행 흐름을 제어합니다. scheduleMicrotask는 현재 Zone이 root인지 아닌지를 확인하여 적절한 방식으로 콜백을 바인딩합니다. 이 바인딩은 Zone 내부에서 에러 처리를 가능하게 하며, 복잡한 비동기 트래킹을 할 때 중요합니다.
내부 구현 분석
void scheduleMicrotask(void Function() callback) {
_Zone currentZone = Zone._current;
if (identical(_rootZone, currentZone)) {
_rootScheduleMicrotask(null, null, _rootZone, callback);
return;
}
Zone.current.scheduleMicrotask(Zone.current.bindCallbackGuarded(callback));
}
설명:
- 현재 Zone이 루트인 경우
_rootScheduleMicrotask를 호출하여 직접 콜백을 등록합니다. - 커스텀 Zone인 경우, 해당 Zone의
bindCallbackGuarded()를 통해 콜백을 등록하며, 이는 에러 처리를 Zone 내부에서 하도록 도와줍니다.
실전 예시: initState에서 context 접근
initState()에서는 일반적으로 BuildContext에 접근하면 위험할 수 있습니다. 이때 scheduleMicrotask를 활용하면, 현재 프레임이 끝난 뒤 안전하게 접근할 수 있습니다.
@override
void initState() {
super.initState();
scheduleMicrotask(() {
final inherited = MyInheritedWidget.of(context);
inherited?.dataService.fetchSomething();
});
}
주의: 마이크로태스크 남용 시 문제
마이크로태스크는 일반 이벤트보다 우선순위가 높기 때문에, scheduleMicrotask를 반복적으로 등록하는 경우 이벤트 루프가 일반 이벤트를 처리할 수 없게 되는 스타베이션(starvation)이 발생할 수 있습니다. 이는 타이머나 네트워크 콜백이 실행되지 않는 문제를 유발할 수 있습니다.
마이크로태스크 분할보다 격리(Isolate)가 더 적합한 경우
만약 연산이 지나치게 무겁고 길다면, 마이크로태스크로 분할하기보다는 Isolate(별도 쓰레드)로 옮기는 것이 낫습니다. scheduleMicrotask는 이벤트 루프를 양보하지 않기 때문에 장기 연산에는 부적합 합니다.
실전 요약
| 상황 | 적절한가? |
|---|---|
| 현재 프레임 이후, 가장 먼저 실행하고 싶을 때 | 매우 적절함 |
| 타이머보다 먼저 실행되기를 원할 때 | 적절함 |
| 이벤트 루프를 블로킹하지 않고 작업을 분할하고 싶을 때 | 적절함 (단, 너무 큰 연산은 지양) |
| 지속적인 마이크로태스크 등록 | 부적절 (이벤트 루프 정지 가능성) |
Pragmas 관련 규칙
Dart에서는 런타임 동작에 힌트를 주기 위해 @pragma 어노테이션을 사용할 수 있습니다. 그러나 유효하지 않은 값을 지정하면 경고가 발생할 수 있습니다.
잘못된 예
@pragma('vm:prefer-inlined') // 오타
Future<String> fn() {
...
}
올바른 예
@pragma('vm:prefer-inline') // 올바른 어노테이션
Future<String> fn() {
...
}
또한
아래와 같은 조건에서는 아무리 어노테이션을 붙여도 효과가 없기 때문에 경고가 발생
- async, async*, sync*로 선언된 메서드
- try-catch 블록이 있는 메서드
- Dart 내부에서 이미 인라인 최적화가 불가능하다고 지정된 몇몇 core library 메서드
잘못된 예
@pragma('vm:prefer-inline')
Future<void> asyncMethod() async {
await good();
}
@pragma('vm:prefer-inline')
void methodWithTry() {
try {
// ...
} catch (_) {
print('error');
}
}
- 여기서 asyncMethod는 async 메서드이기 때문에 절대 inline되지 않으며, 어노테이션은 의미가 없음.
- methodWithTry도 마찬가지로 try-catch 블록 때문에 inline 최적화 대상이 될 수 없음.
올바른 예
@pragma('vm:prefer-inline') // 올바른 어노테이션
Future<String> fn() {
...
}
compute 함수란?
compute는 Flutter에서 UI 메인 스레드를 막지 않고 무거운 연산(CPU 연산)을 처리하기 위해 사용하는 함수입니다. 이 함수는 Dart의 Isolate를 활용하여, 메인 스레드와 분리된 공간에서 작업을 수행한 후 결과만 가져오는 구조이기 때문에, UI가 끊기지 않고 부드럽게 유지될 수 있도록 도와줍니다.
compute 사용 예시: 소수 판별
import 'package:flutter/foundation.dart';
Future<bool> isPrime(int value) {
return compute(_calculateIsPrime, value);
}
bool _calculateIsPrime(int value) {
if (value <= 1) return false;
for (int i = 2; i < value; i++) {
if (value % i == 0) return false;
}
return true;
}
void main() async {
final number = 1000003;
final result = await isPrime(number);
print('$number is prime? $result');
}
- compute는 두 개의 인자를 받습니다: 실행할 함수와 해당 함수에 전달할 값입니다.
- _calculateIsPrime은 꼭 top-level 함수이거나 static 함수여야 하며, BuildContext 같은 복잡한 객체는 사용할 수 없습니다.
- 이 함수는 백그라운드 isolate에서 실행되고, 그 결과만 메인 isolate로 돌아오므로 UI가 멈추지 않습니다.
compute 내부 구조 분석
Future<R> compute<M, R>(
ComputeCallback<M, R> callback,
M message, {
String? debugLabel,
}) {
return isolates.compute<M, R>(callback, message, debugLabel: debugLabel);
}
이 함수는 내부적으로 다음과 같은 작업을 수행합니다:
- Native 환경에선 Isolate.run() 또는 새로운 isolate를 생성합니다.
- message 데이터를 직렬화하여 isolate로 전달합니다.
- 전달된 callback 함수가 새로운 isolate에서 실행됩니다.
- 결과값은 역직렬화되어 메인 isolate로 다시 전달됩니다.
웹에서는 isolate 기능이 완전히 지원되지 않기 때문에, 실제로는 동일한 메인 스레드에서 비동기적으로 실행됩니다. 이 말은 compute를 써도 병렬처리가 되지 않고 여전히 UI를 막을 수 있다는 의미입니다.
compute 사용 시 유의사항
- 메모리는 완전히 분리됨
- 각 isolate는 자신의 힙 메모리를 가지므로, BuildContext, 열린 파일, 채널 등은 전달할 수 없습니다.
- 오직 List, Map, String, int 등 단순한 자료형만 가능합니다.
- 성능 오버헤드 존재
- isolate를 생성하는 데 시간이 조금 걸리므로, 2~3ms로 끝나는 가벼운 작업에는 적합하지 않습니다.
- 너무 자주 호출하면 오히려 성능이 나빠질 수 있습니다.
- debugLabel을 이용한 프로파일링
- DevTools로 성능을 추적할 때 isolate 이름을 지정해주면 어떤 작업이 compute로 실행되었는지 파악하기 쉽습니다.
- 일회성 작업용
- compute는 반복적 작업에 적합하지 않습니다. 반복 작업이 필요하면 별도의 isolate를 직접 만들거나, ReceivePort를 사용하는 구조로 바꿔야 합니다.
실무에서 자주 사용하는 예시
- 대용량 JSON 파싱
Future<Map<String, dynamic>> parseLargeJson(String data) {
return compute(_parseJson, data);
}
Map<String, dynamic> _parseJson(String jsonData) {
return json.decode(jsonData) as Map<String, dynamic>;
}
- 이미지 변환 및 필터링
Future<Uint8List> invertImage(Uint8List bytes) =>
compute(_invertColors, bytes);
Uint8List _invertColors(Uint8List bytes) {
// 무거운 픽셀 처리 작업
return bytes;
}
- 에러 핸들링도 가능
try {
final result = await compute(_canFail, 42);
print('Result: $result');
} catch (e, s) {
print('compute failed: $e');
}
int _canFail(int val) {
if (val == 42) throw 'No 42 allowed!';
return val + 1;
}
웹 환경에서 주의할 점
웹에서는 isolate가 없기 때문에 compute를 쓰더라도 병렬 처리되지 않습니다. 예를 들어:
Future<String> bigStringOp(String data) => compute(_bigLoop, data);
String _bigLoop(String input) {
// 오래 걸리는 루프
return input;
}
이 경우에도 여전히 UI가 막힐 수 있으므로, 웹에서 compute는 큰 의미가 없을 수 있습니다.
@pragma('vm:notify-debugger-on-exception')와 FlutterError.reportError의 활용
Flutter 앱을 개발하면서, 오류가 발생했을 때 로그만 보고 원인을 추적하기 어려울 때가 많습니다. 특히 오류가 try/catch로 잡힌 경우, 디버거가 해당 위치에서 멈추지 않기 때문에 디버깅이 더 어려워집니다.
이런 경우 Dart에서는 @pragma('vm:notify-debugger-on-exception')라는 특별한 주석(프라그마)을 제공하여, 예외가 잡히더라도 디버거가 예외 발생 지점에서 멈추도록 설정할 수 있습니다.
@pragma('vm:notify-debugger-on-exception')
void doSomething() {
try {
methodThatMayThrow();
} catch (exception, stack) {
// 오류 보고
FlutterError.reportError(
FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'example library',
context: ErrorDescription('while doing something'),
),
);
}
}
위 코드를 통해 얻을 수 있는 이점은 다음과 같습니다:
예외 발생 시 디버거 자동 멈춤
- 보통 디버거는 "잡히지 않은 예외"에만 멈춥니다.
- 하지만 @pragma('vm:notify-debugger-on-exception')를 쓰면, 잡힌 예외라도 예외가 발생한 정확한 위치에서 멈출 수 있어, 변수 상태나 스택 트레이스를 정확하게 확인할 수 있습니다.
FlutterError.reportError와의 조합
- FlutterError.reportError()는 예외 정보를 구조화하여 Flutter 프레임워크에 전달하는 함수입니다.
- FlutterErrorDetails 객체를 통해 예외 내용, 스택, 라이브러리 정보, 컨텍스트 설명 등을 함께 전달할 수 있습니다.
- 이 방식은 Sentry나 Firebase Crashlytics 같은 에러 트래킹 툴과도 호환되므로, 개발 중엔 디버깅 + 운영 중엔 로그 수집의 두 마리 토끼를 잡을 수 있습니다.
실제 사용 예시
@pragma('vm:notify-debugger-on-exception')
void riskyOperation() {
try {
someFunctionThatThrows();
} catch (error, stack) {
FlutterError.reportError(
FlutterErrorDetails(
exception: error,
stack: stack,
library: 'sample library',
context: ErrorDescription('performing risky operation'),
),
);
}
}
추가로 알아두면 좋은 점
- 개발 중에만 의미 있음: 이 프라그마는 Dart VM에서만 동작하며, 릴리즈 빌드에서는 효과가 없습니다.
- 선택적으로 사용해야 함: 모든 예외에 이 프라그마를 붙이면 디버깅이 과도해질 수 있으므로, 중요하거나 의심되는 코드에만 붙이는 것이 좋습니다.
- 다른 플랫폼에서는 미지원: 이 프라그마는 Dart VM이 작동하는 모바일, 데스크탑 등에서만 동작하며, 웹 컴파일 등에서는 효과가 없습니다.
구분 역할 관계
| compute() | 무거운 계산을 isolate에서 수행 | 예외가 throw되면 Future로 감싸져 전달됨 |
| @pragma('vm:notify-debugger-on-exception') | 예외가 catch 되더라도 디버거에서 멈춤 | compute 내부 함수에 적용 시 디버깅 보조 수단이 됨 |
runZonedGuarded: 전역 비동기 에러 처리 기법
runZonedGuarded는 Dart에서 모든 비동기 에러를 한 곳에서 잡아낼 수 있도록 도와주는 함수입니다. try-catch처럼 코드에 일일이 에러 처리를 작성할 필요 없이, 하나의 '에러 존(zone)'을 생성하여 그 안의 모든 비동기 에러를 감지할 수 있습니다.
void main() {
runZonedGuarded(() {
runApp(MyApp());
// 예시: 의도적인 비동기 에러 발생
Future.delayed(Duration(seconds: 1), () {
throw Exception('Async error in the zone!');
});
}, (error, stackTrace) {
print('Caught zoned error: $error');
print('Stack trace: $stackTrace');
});
}
핵심 정리:
- Zone 생성: 내부에서 실행되는 모든 코드의 에러를 감지함
- 비동기 에러 감지: Future, Timer, Stream 등에서 발생하는 에러까지 감지 가능
- onError 핸들러: 에러가 발생하면 등록된 콜백으로 전달됨
실무 활용:
- 앱의 main() 함수 감싸기 (전역 에러 수집)
- Crashlytics, Sentry 등과 연동하여 자동 오류 리포트
void main() {
runZonedGuarded(() => runApp(MyApp()), (err, stack) {
sendToCrashlytics(err, stack);
});
}
주의사항:
- 너무 자주 사용하지 말고 앱 전체에 한 번만 적용하는 것이 좋음
- body() 함수가 동기적으로 에러를 던지면, onError만 호출되고 runZonedGuarded는 null 반환
Timeline API: 성능 분석을 위한 수동 프로파일링
Flutter에서는 앱 성능 병목을 찾아내기 위해 dart:developer의 Timeline API를 사용할 수 있습니다. 이 API는 DevTools의 Timeline 뷰에 직접 수행 시간을 기록하여, 어떤 코드가 오래 걸렸는지 시각적으로 확인할 수 있게 해줍니다.
✅ Timeline.startSync / Timeline.finishSync
import 'dart:developer';
void fetchData() {
Timeline.startSync('Fetching Data');
try {
for (int i = 0; i < 1000000; i++) {
// 오래 걸리는 작업
}
} finally {
Timeline.finishSync();
}
}
- 수동으로 start와 finish로 감싸줘야 함
✅ Timeline.timeSync
import 'dart:developer';
void fetchData() {
Timeline.timeSync('Fetching Data', () {
for (int i = 0; i < 1000000; i++) {
// 오래 걸리는 작업
}
});
}
- startSync + finishSync를 자동으로 감싸줌
- 보일러플레이트 줄이기에 유리함
실제 사례:
Future<void> fetchUserProfile() async {
Timeline.timeSync('Fetching User Profile', () async {
final response = await http.get(Uri.parse('https://...'));
if (response.statusCode == 200) {
log('Success');
}
});
}
빌드 성능 측정:
class ProfilePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
Timeline.startSync('Building ProfilePage');
final widget = Scaffold(...);
Timeline.finishSync();
return widget;
}
}
유용한 사용처:
- ListView, GridView 빌드 시간 측정
- setState() 반복 호출 영역 성능 측정
- 무거운 JSON 파싱, SQLite 쿼리 시간 측정
주의사항:
- Debug/Profile 모드에서만 동작 (Release 빌드에서는 무시됨)
- kReleaseMode 조건으로 분기 처리 가능
if (!kReleaseMode) {
Timeline.timeSync('DebugOnly', () => doDebugWork());
}
unawaited
Dart에서는 Future(미래에 완료될 비동기 작업)를 만들었을 때, 보통은 그 Future를 await으로 기다리거나 어떤 방식으로든 명시적으로 처리하는 것이 좋다고 알려져 있어요. 왜냐하면 기다리지 않으면 발생한 오류를 놓칠 수도 있고, 함수가 너무 빨리 끝나서 비정상적인 동작이 생길 수도 있기 때문이에요.
그런데 실제로는 “기다릴 필요가 없는 작업”도 있잖아요? 예를 들어, 앱에서 이벤트 로그를 기록하거나, 캐시를 정리하는 것처럼 결과를 기다릴 필요가 없는 ‘불쏘시개성’ 작업들이요.
이럴 때 쓰는 게 바로 unawaited() 함수예요. 이건 Dart에서 "이 Future는 내가 일부러 기다리지 않는 거야!" 하고 명시적으로 의도를 표현하는 방법이에요.
⚠️ 단, unawaited()는 에러를 막아주지 않아요. 그 Future가 실패하면 여전히 앱에 Unhandled Exception(처리되지 않은 에러) 로 남을 수 있어요.
예제: 이벤트 로그를 기다리지 않고 백그라운드에서 보내기
import 'dart:async';
Future<void> trackEvent(String eventName) async {
print('Tracking event: $eventName');
await Future.delayed(const Duration(milliseconds: 500));
print('Event $eventName tracked.');
}
void main() async {
print('Doing main work...');
// 아래 코드는 결과를 기다릴 필요 없는 작업이므로 unawaited로 감싸줌
unawaited(trackEvent('user_sign_in'));
await Future.delayed(const Duration(milliseconds: 200));
print('Main work done.');
}
위 코드에서 trackEvent는 await하지 않고 바로 실행되는데, 이걸 명확히 하기 위해 unawaited()로 감싸줬어요. 이렇게 하면 코드 리뷰 시에도 "의도적으로 안 기다린 거구나!" 하고 알아볼 수 있어요.
관련 린트 규칙: avoid-async-call-in-sync-function
이 린트 규칙은 "동기 함수 안에서 비동기 함수를 호출하는 걸 피하라"고 경고해요. 왜냐하면 async 함수가 await 없이 호출되면, 오류가 발생해도 처리할 방법이 없거든요. 이런 경우는 대체로 실수일 확률이 높아요.
나쁜 예:
Future<void> asyncValue() async => 'value';
class SomeClass {
SomeClass() {
// 경고 발생: 동기 생성자 안에서 async 호출
asyncValue();
}
void syncFn() => asyncValue(); // 여기도 마찬가지로 경고 발생
}
좋은 예:
Future<void> asyncValue() async => 'value';
class SomeClass {
SomeClass() {
unawaited(asyncValue()); // 명시적으로 기다리지 않겠다고 표현
}
Future<void> asyncMethod() async => asyncValue(); // async 함수 안에서는 괜찮음
}
- Future를 기다리지 않으면 린트 경고가 발생할 수 있음
- 결과가 필요 없는 경우 unawaited(future)로 의도 표현
- unawaited()는 오류를 막아주지 않음 — 오류는 여전히 발생함
- 동기 함수에서 async 호출할 땐 반드시 unawaited 또는 .ignore() 등으로 명시해야 함
추가설명: .ignore()는 결과뿐 아니라 예외도 완전히 무시해버리는 방식이기 때문에, 웬만하면 unawaited()를 사용하는 것이 더 안전합니다.
FutureRecord2
Dart에서는 여러 개의 비동기 작업(Future)을 병렬로 실행한 다음, 모든 작업이 끝날 때까지 기다려야 할 때가 많아요. 예전에는 Future.wait()를 써서 해결했지만, Dart 3에서는 record type이라는 새로운 개념을 활용해 더 안전하고 우아하게 처리할 수 있게 되었어요. 그게 바로 FutureRecord2예요.
이 기능이 하는 일 요약
- 여러 Future를 동시에 실행하고, 모두 완료될 때까지 기다려요.
- 결과를 record(튜플처럼 생긴 구조) 로 반환하고, 각각의 타입 정보를 유지해줘요.
- 에러도 잘 처리해줘요:
- 모든 Future가 성공하면, 각 결과값을 담은 튜플이 와요.
- 하나라도 실패하면 ParallelWaitError가 발생하고, 어떤 Future가 성공했고 어떤 Future가 실패했는지 알 수 있어요.
기본 사용 예제
Future<String> fetchText() async {
await Future.delayed(Duration(seconds: 1));
return "Hello, world!";
}
Future<int> fetchNumber() async {
await Future.delayed(Duration(seconds: 1));
return 42;
}
void main() async {
var (text, number) = await (fetchText(), fetchNumber()).wait;
print('$text - $number'); // Hello, world! - 42
}
왜 Future.wait()보다 좋은가요?
기존 방식:
var results = await Future.wait([fetchText(), fetchNumber()]);
var text = results[0] as String;
var number = results[1] as int;
문제점:
- 결과가 List<dynamic>이라서 타입 캐스팅을 직접 해야 해요
- 인덱스로 접근하니까 가독성이 떨어져요
새 방식:
var (text, number) = await (fetchText(), fetchNumber()).wait;
- 타입 추론이 자동으로 돼요 (text는 String, number는 int로 자동 인식)
- 인덱스 없이 변수 이름으로 받으니 훨씬 읽기 쉬워요
내부 구현 개념
extension FutureRecord2<T1, T2> on (Future<T1>, Future<T2>) {
Future<(T1, T2)> get wait async {
var results = await Future.wait([this.$1, this.$2]);
return (results[0] as T1, results[1] as T2);
}
}
추가설명: Dart의 record 타입은 (String, int)처럼 여러 타입을 묶을 수 있는 구조예요. 이를 활용해서 Future.wait 결과를 더 쉽게 다룰 수 있게 해주는 거예요.
에러가 있는 경우는?
Future<String> fetchText() async {
await Future.delayed(Duration(seconds: 1));
return "Hello, world!";
}
Future<int> fetchNumber() async {
await Future.delayed(Duration(seconds: 1));
throw Exception("Failed to fetch number!");
}
void main() async {
try {
var (text, number) = await (fetchText(), fetchNumber()).wait;
print('$text - $number');
} catch (e) {
if (e is ParallelWaitError) {
print("Some futures failed!");
print("Values: \${e.values}"); // ("Hello, world!", null)
print("Errors: \${e.errors}"); // (null, AsyncError: Exception(...))
}
}
}
- (fetchText(), fetchNumber()).wait
- 두 Future가 동시에 실행됩니다 (병렬 실행).
- fetchText()는 성공하고 "Hello, world!"를 반환합니다.
- fetchNumber()는 실패하며 예외를 던집니다.
- await는 ParallelWaitError 예외를 발생시키고, 바로 아래 catch 블록으로 넘어갑니다
확장성: 두 개 이상도 가능
FutureRecord2, FutureRecord3, FutureRecord4 등 여러 개의 비동기 작업도 같은 방식으로 사용할 수 있어요.
var (user, posts, settings) = await (fetchUser(), fetchPosts(), fetchSettings()).wait;
왜 좋은가?
- Future.wait을 쓰면 결과가 List<dynamic>이라서 타입 추론이 안됨 + 직접 cast 해야 함
- FutureRecord2를 쓰면 결과가 (String, int)처럼 정확히 타입이 유지됨
- 성공/실패 여부를 명확하게 나눠서 처리할 수 있음
'Flutter > Flutter' 카테고리의 다른 글
| <번역/정리> 10 Lesser-Known Dart and Flutter Functionalities You Should Start Using - 2편 (2) | 2025.06.30 |
|---|---|
| Flutter Const 논란 < 과연 const는 효율적인가? > (0) | 2025.02.19 |
| webRTC 실험요약 (0) | 2024.10.16 |
| STFUL Lifecycle (1) | 2024.09.25 |
| build method 와 render tree를 모르고 hot reload를 논하지마라 (4) | 2024.09.25 |
