화면의 체크박스나 테마만 바꿨는데 같은 API가 다시 호출된다면, 네트워크 재시도보다 Future를 만드는 위치부터 확인하세요. FutureBuilder가 화면을 그린 횟수와 실제 요청을 시작한 횟수는 같은 지표가 아닙니다. 요청 시작 함수와 화면 갱신을 따로 기록해야 반복 원인을 찾을 수 있습니다.
이 글은 StatefulWidget 안에서 하나의 FutureBuilder로 데이터를 표시하는 경우를 다룹니다. 연결 자체가 실패하는 문제는 기존 API 연결 글에서, 화면을 떠난 뒤 비동기 콜백이 실행되는 문제는 dispose 글에서 다룹니다. 여기서는 같은 화면이 살아 있는 동안 어느 사건에서 새 작업을 시작할지 정합니다.
1. builder 호출 수 대신 요청 시작 지점을 세세요
현재 코드의 데이터 요청 함수가 시작되는 줄에 임시 식별번호와 요청 대상 ID를 남기세요. 토큰·개인정보·응답 원문은 기록하지 않습니다. 이어서 데이터 조건과 관계없는 화면 조작을 한 번 하고, 요청 시작 로그가 늘어나는지 비교합니다. builder 안의 로그만 늘었다면 그것만으로 중복 요청이 발생했다고 판정할 수 없습니다.
| 관찰한 현상 | 구분할 지점 | 다음 확인 |
|---|---|---|
| 단순 화면 조작마다 요청 시작 로그도 늘어남 | build에서 호출한 함수가 매번 새 Future를 만드는지 | FutureBuilder의 future 인자와 State 필드 확인 |
| builder 로그는 늘지만 요청 시작은 그대로 | 이미 가진 Future로 화면을 다시 표시하는지 | 서버 로그와 클라이언트 요청 시작 로그를 대조 |
| 대상 ID를 바꿨는데 이전 정보가 계속 보임 | initState에서 얻은 Future를 계속 보유하는지 | didUpdateWidget에서 실제 조회 조건 변화 비교 |
| 실패하자마자 요청이 끝없이 이어짐 | builder의 오류 분기에서 요청·setState를 실행하는지 | 재시도를 명시적인 사용자 사건으로 분리 |
| 새로고침하지 않았는데 initState 로그까지 반복됨 | 같은 State가 유지되는지 | 부모의 조건부 위젯 교체, 타입과 key 변경 확인 |
2. 요청 함수를 build에서 직접 호출하지 마세요
다음 형태에서 loadProfile(userId)가 호출할 때마다 네트워크 작업을 시작한다면, 부모의 다시 그리기가 조회 사건으로 바뀝니다. 공식 FutureBuilder 설명도 Future를 build보다 앞선 수명주기 단계 등에서 확보하도록 안내합니다.
// build 안에서 매번 새 작업을 만드는 형태
FutureBuilder<String>(
future: loadProfile(userId),
builder: (context, snapshot) {
return Text(snapshot.data ?? '불러오는 중');
},
)
그렇다고 위젯이 다시 그려지는 것을 막는 데 집중할 필요는 없습니다. 테마나 창 크기 변경에도 화면 갱신은 필요합니다. 조회가 필요한 사건에서 Future를 얻어 State 필드에 보관하고, build는 그 필드를 전달하도록 바꾸세요. 함수가 자체 캐시로 같은 Future를 돌려주는 경우에는 실제 요청 수가 다를 수 있으므로 구현도 함께 읽어야 합니다.
3. 첫 조회·대상 변경·다시 시도를 분리한 예제
아래는 lib/main.dart에 놓고 확인할 수 있도록 구성한 설명용 코드입니다. 서버 대신 600ms 지연 함수로 요청 시작 횟수를 기록합니다. 실제 HTTP 전송, API 비용이나 네트워크 취소를 측정하는 코드가 아닙니다. ‘실패 조건’은 의도적으로 오류를 만드는 입력입니다.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: RequestDemoPage()));
int demoRequests = 0;
Future<String> demoLoadProfile(String id) async {
final number = ++demoRequests;
debugPrint('load #$number id=$id');
await Future<void>.delayed(const Duration(milliseconds: 600));
if (id == 'error') throw StateError('설명용 실패');
return '사용자 $id';
}
class RequestDemoPage extends StatefulWidget {
const RequestDemoPage({super.key});
@override
State<RequestDemoPage> createState() => _RequestDemoPageState();
}
class _RequestDemoPageState extends State<RequestDemoPage> {
String _userId = 'A';
int _displayChanges = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Future 생성과 화면 갱신 분리')),
body: SafeArea(
child: Column(
children: [
Wrap(
spacing: 8,
children: [
TextButton(
onPressed: () => setState(() => _userId = 'A'),
child: const Text('A 선택'),
),
TextButton(
onPressed: () => setState(() => _userId = 'B'),
child: const Text('B 선택'),
),
TextButton(
onPressed: () => setState(() => _userId = 'error'),
child: const Text('실패 조건'),
),
TextButton(
onPressed: () => setState(() => _displayChanges++),
child: Text('화면 갱신 $_displayChanges'),
),
],
),
Expanded(
child: Center(
child: ProfilePanel(
userId: _userId,
loadProfile: demoLoadProfile,
),
),
),
],
),
),
);
}
}
class ProfilePanel extends StatefulWidget {
const ProfilePanel({
super.key,
required this.userId,
required this.loadProfile,
});
final String userId;
final Future<String> Function(String) loadProfile;
@override
State<ProfilePanel> createState() => _ProfilePanelState();
}
class _ProfilePanelState extends State<ProfilePanel> {
late Future<String> _profileFuture;
@override
void initState() {
super.initState();
_profileFuture = widget.loadProfile(widget.userId);
}
@override
void didUpdateWidget(covariant ProfilePanel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId) {
_profileFuture = widget.loadProfile(widget.userId);
}
}
void _reload() {
setState(() {
_profileFuture = widget.loadProfile(widget.userId);
});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _profileFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('불러오지 못했습니다.'),
TextButton(onPressed: _reload, child: const Text('다시 시도')),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(snapshot.data ?? '표시할 데이터가 없습니다.'),
TextButton(onPressed: _reload, child: const Text('다시 불러오기')),
],
);
},
);
}
}
_profileFuture를 바꾸는 곳은 첫 초기화, ID 변경, 명시적인 다시 시도 세 군데입니다. 화면 갱신 버튼은 부모 상태만 바꾸므로 같은 ID를 조회할 새 이유가 생기지 않습니다. 실패 화면도 오류를 표시할 뿐 스스로 다시 요청하지 않습니다.
이 예제의 조회 계약은 loadProfile 함수는 유지되고 userId가 조회 대상을 결정한다는 것입니다. 실제 앱에서 API 주소·로그인 세션·repository까지 교체된다면 ID만 비교해서는 부족합니다. 바뀔 수 있는 조회 조건을 명시하고 그 변화도 새 작업의 조건에 포함하세요. build마다 새로 만든 함수 객체가 다르다는 이유만으로 조회를 반복하지 않도록 의미 있는 조건을 비교해야 합니다.
4. initState 한 번과 화면 수명을 구분하세요
initState는 State 객체마다 한 번 호출됩니다. 앱 전체에서 영원히 한 번 호출되는 전역 캐시가 아닙니다. 화면을 제거했다가 다시 만들면 조회가 새로 시작될 수 있습니다. 재방문에서도 값을 유지하려는 요구라면 위젯 밖 데이터 저장소의 보관 기간·만료·사용자 분리 정책을 별도로 정해야 합니다.
같은 위치의 위젯 설정이 바뀌어 State가 유지되는 경우에는 didUpdateWidget에서 이전 ID와 현재 ID를 비교합니다. 이 메서드 다음에는 build가 이어지므로 예제처럼 Future 필드를 교체할 때 별도 setState를 호출하지 않았습니다. 반면 버튼의 콜백에서 필드를 바꿀 때는 setState로 갱신을 알립니다.
로케일이나 다른 InheritedWidget에서 얻는 값이 조회 조건이라면 didChangeDependencies가 적절할 수 있습니다. 다만 의존성이 바뀔 때 다시 호출될 수 있으므로 그 안에서 무조건 조회하지 말고 이전 조회 조건과 비교하세요. 첫 호출만 막는 Boolean은 이후의 정당한 조건 변경까지 누락시킬 수 있습니다.
부모가 계속 새 key를 부여한다면 이전 State가 유지되지 않는지 확인해야 합니다. key와 위젯 타입은 요소 교체에 관여합니다. 중복 요청을 줄이려고 무작정 UniqueKey를 추가하는 대신, 현재 위젯이 왜 교체되는지부터 확인하세요.
5. 이전 결과 표시와 요청 취소는 별도 정책입니다
새 Future로 바꿀 때 snapshot에 이전 데이터가 남을 수 있습니다. 예제는 waiting을 먼저 검사해 다른 사용자 ID의 결과가 로딩 도중 그대로 표시되지 않도록 했습니다. 같은 대상의 수동 새로고침에서 이전 값을 유지하려면 ‘이전 값·갱신 중·실패’ 표시를 명시적으로 설계하세요. hasData 하나만으로 현재 조회 대상의 최신 결과라고 간주하면 안 됩니다.
또한 새 Future를 FutureBuilder에 전달하는 일은 이미 시작한 HTTP 요청을 서버에서 취소하는 일과 다릅니다. 사용자가 A 다음 B를 선택했을 때 A 작업의 자원을 중단할 수 있는지는 네트워크 계층의 취소 기능과 서버 동작을 따로 확인해야 합니다. 직접 await 뒤 State를 변경하는 코드까지 섞여 있다면 mounted와 오래된 응답 처리도 별도로 점검하세요.
이 예제의 다시 불러오기는 읽기 작업을 전제로 합니다. 결제·주문·저장 같은 쓰기 요청을 동일한 재시도 버튼으로 복사하지 마세요. 응답을 못 받았더라도 서버에서 이미 처리했을 수 있으므로 중복 방지 계약과 처리 결과 확인이 필요합니다.
6. 실제 프로젝트에 적용할 확인표
다음은 코드를 적용할 때 사용할 검증 계획입니다. 이 글을 위해 Flutter 앱을 실행해 얻은 결과가 아닙니다. 개발 중의 hot restart나 화면 재생성은 카운터와 State를 초기화할 수 있으므로, 아래 비교는 한 실행 세션에서 진행하세요.
| 조작 | 판정 기준 | 실패하면 볼 곳 |
|---|---|---|
| 첫 화면 열기 | 해당 State의 첫 로더 시작 기록이 한 번 남음 | 부모와 자식 양쪽에서 같은 조회를 시작하는지 |
| A를 유지한 채 화면 갱신 버튼 세 번 | 화면의 숫자는 바뀌고 로더 시작 수는 늘지 않음 | build 안 함수 호출·State 재생성·조회 조건 비교 |
| A에서 B로 한 번 변경 | B 조회가 한 번 시작되고 최종 표시는 B와 대응 | didUpdateWidget의 비교 조건과 이전 값 표시 정책 |
| B가 선택된 채 B 버튼 다시 누르기 | 동일 조건에서는 새 조회를 만들지 않음 | 조건이 같아도 Future를 교체하는지 |
| 실패 조건을 선택하고 기다리기 | 오류 화면에서 자동 재시도가 반복되지 않음 | builder·catch·listener의 부수효과 |
| 오류 화면의 다시 시도 한 번 | 로더 시작이 한 번 늘고 waiting 또는 완료 상태로 이동 | 버튼 콜백과 별도 자동 재시도가 중첩되는지 |
네트워크 계층에서 별도 재시도를 수행한다면 로더 시작 한 번이 HTTP 전송 한 번과 일치하지 않을 수 있습니다. 로더 번호와 전송 번호를 나눠 기록하고, 서버에서 확인한 횟수와 비교하세요. builder 호출 횟수나 로딩 애니메이션이 보인 프레임 수를 고정한 테스트는 피합니다.
수정 전후에 남길 최소 기록
Flutter / Dart 버전:
조회 대상 ID와 실제 변경 조건:
Future를 만드는 위치:
State가 새로 만들어진 횟수:
조작 한 가지와 로더 시작 수의 변화:
실제 HTTP 전송 수 / 서버 처리 수(확인한 경우):
이전 데이터 표시 또는 숨김 정책:
실패 분기와 수동·자동 재시도 경로:
민감정보를 뺀 정확한 오류 문구:
확인 범위와 공식 자료
2026년 10월 21일 아래 Flutter 공식 원문을 읽어 Future의 생성 시점, State 수명주기, snapshot 전환과 위젯 교체 조건을 확인했습니다. 예제와 확인표는 이를 바탕으로 새로 작성한 설명용 자료이며, Flutter SDK 빌드·위젯 테스트·실기기 네트워크 테스트를 수행한 결과는 아닙니다. 실제 SDK와 데이터 계층에 맞게 완료 조건을 확인하세요.
- FutureBuilder: Future 관리와 snapshot 전환
- State.initState
- State.didUpdateWidget
- State.didChangeDependencies
- Widget.key와 요소 갱신
- Flutter 네트워크 데이터 가져오기