React 개발자가 정리한 Dart & Flutter 개념
React·TypeScript를 주로 쓰다가 Flutter 프로젝트를 하면서 정리한 개념들이다.
Dart 문법
factory 생성자
호출할 때마다 새 인스턴스를 만들지 않아도 되는 생성자다. 일반 생성자와 달리 this에 접근할 수 없고, 본문에서 인스턴스를 직접 return한다.
- 캐싱·싱글턴: 조건에 따라 기존 인스턴스 재사용
- 하위 클래스 반환: 입력값에 따라 다른 구현 클래스 반환
- 생성 전 전처리: 값 검증·가공(JSON 파싱 등) 후 생성
class Logger {
static final _cache = <String, Logger>{};
final String name;
factory Logger(String name) => _cache.putIfAbsent(name, () => Logger._internal(name));
Logger._internal(this.name);
}
일반 생성자의 초기화 리스트(:)에는 복잡한 로직을 넣기 어려우므로, factory에서 전처리를 끝내고 private named constructor에 실제 필드 대입을 맡기는 패턴이 흔하다.
private 멤버와 _internal
Dart에는 private 키워드가 없다. 이름이 _로 시작하면 그 라이브러리(파일) 밖에서 접근할 수 없다. 클래스·메서드·변수·named constructor 모두 같다.
_internal은 키워드가 아니라 관례다(_raw, _create도 가능). 공개 factory 하나만 노출하고 실제 생성은 _internal에 맡겨, 외부에서 전처리를 우회하지 못하게 한다.
getter (get)
로직을 실행하지만 괄호 없이 프로퍼티처럼 읽는 멤버다.
List<Contact> get contacts => List.unmodifiable(_contacts);
group.contacts; // O
group.contacts(); // X
set 짝이 없으면 바깥에서 값을 바꿀 수 없다. 단, 내부 리스트 참조를 그대로 반환하면 바깥에서 add할 수 있으므로 위처럼 List.unmodifiable로 감싼다.
final vs const
final | const | |
|---|---|---|
| 값 결정 시점 | 런타임에 한 번 | 컴파일 타임 |
| 같은 값의 인스턴스 | 별개 | 하나로 재사용(canonicalize) |
final title = widget.title; // 런타임 결정
const size = Size.fromHeight(56); // 컴파일 타임 확정
Flutter에서 const 위젯은 리빌드 때 같은 인스턴스이므로 해당 서브트리를 다시 빌드하지 않고 건너뛸 수 있다. 가능한 곳은 const로 쓴다.
JS/TS의 const는 "재할당 금지"만 뜻하므로 Dart의 final에 가깝다. Dart const(컴파일 타임 상수 + 인스턴스 재사용)에 정확히 대응하는 개념은 JS에 없다.
var vs dynamic
var: 대입한 값으로 타입을 추론해 고정한다. TS의 타입 추론과 같다.dynamic: 타입 검사를 런타임으로 미룬다. TS의any와 같다.
var name = 'kim';
name = 123; // 컴파일 에러
dynamic value = 'kim';
value = 123; // 통과, 잘못된 호출은 런타임에 실패
기본은 var(또는 명시적 타입), dynamic은 JSON 파싱 결과처럼 타입을 특정할 수 없는 곳에만 쓴다. 그마저도 Object?로 받고 타입 검사 후 쓰는 편이 안전하다.
implements vs extends
extends: 부모의 구현까지 물려받는다. 하나만 가능.implements: 구현은 물려받지 않고 "이 멤버들을 직접 구현하겠다"는 계약만 가져온다. 여러 개 가능.
class DetailAppBar extends StatelessWidget implements PreferredSizeWidget {
@override
Size get preferredSize => const Size.fromHeight(56);
// ...
}
Scaffold.appBar의 타입이 PreferredSizeWidget?이라서, 커스텀 AppBar는 preferredSize(Scaffold가 body 영역을 계산할 때 사용)를 직접 제공해야 한다.
~/ 정수 나눗셈
7 ~/ 2; // 3 (int)
7 / 2; // 3.5 (double)
-7 ~/ 2; // -3 (0 방향으로 버림)
Python의 -7 // 2는 -4(내림)이므로 다르다. JS의 Math.trunc(-7 / 2)와 같다.
컬렉션
SplayTreeMap
dart:collection의 키가 항상 정렬된 Map이다. 자기 조정 이진 탐색 트리(splay tree)로 구현되어 있다.
final map = SplayTreeMap<String, int>();
map['banana'] = 2;
map['apple'] = 1;
print(map.keys); // (apple, banana)
| 특징 | 설명 |
|---|---|
| 순회 순서 | 항상 키 정렬 순 |
| 비교 | 기본 Comparable, 커스텀 compare 함수 가능 |
| 성능 | 조회·삽입·삭제 분할 상환 O(log n) (HashMap 평균 O(1)보다 느림) |
| 적합한 경우 | 정렬된 순회가 필요하면서 삽입·삭제가 잦을 때 |
Flutter 위젯과 상태
StatelessWidget vs StatefulWidget
판단 기준: "그려진 뒤 위젯 내부에서 값이 바뀌어 다시 그려야 하는가?"
- 아니다 →
StatelessWidget. 생성자로 받은final값으로 그리기만 한다. - 그렇다 →
StatefulWidget. 내부에서setState()를 호출한다면 확실하다.
React로 치면 useState를 쓰는 컴포넌트가 StatefulWidget이다. 다만 Flutter는 StatefulWidget(불변 설정값)과 State(가변 상태 + build())가 두 클래스로 나뉜다. React처럼 상태는 필요한 지점에만 모으고 나머지는 Stateless로 구성하는 것이 일반적이며, Provider·Riverpod 같은 상태 관리 라이브러리를 쓰면 StatefulWidget이 거의 필요 없어지기도 한다.
ValueNotifier
값 하나를 감싸 바뀔 때 리스너에 알리는 가장 단순한 상태 도구다. ChangeNotifier를 상속하고, value를 바꾸면 notifyListeners()가 호출된다.
final counter = ValueNotifier<int>(0);
counter.addListener(() => print(counter.value));
counter.value = 1; // 리스너 호출
counter.value = 1; // 같은 값(==)이면 호출 안 함
ValueListenableBuilder<int>(
valueListenable: counter,
builder: (context, value, child) => Text('$value'),
)
값이 바뀔 때 builder만 다시 실행되므로 setState()로 위젯 전체를 다시 빌드하는 것보다 범위가 좁다.
ChangeNotifier | ValueNotifier | |
|---|---|---|
| 관리 대상 | 여러 필드 | 값 하나 |
| 사용 | 상속해서 클래스 작성 | ValueNotifier<T>(초기값) 그대로 |
둘 다 리스너를 들고 있으므로 직접 만들었다면 사용이 끝날 때 dispose()한다.
dispose()가 필요한 경우
initState()나 필드 초기화에서 직접 만든 리소스가 스스로 정리되지 않는다면 dispose()에서 해제한다.
final _idController = TextEditingController();
@override
void dispose() {
_idController.dispose();
super.dispose(); // 항상 마지막
}
대상: TextEditingController, ScrollController, AnimationController, FocusNode, 직접 listen()한 StreamSubscription, Timer(특히 Timer.periodic), 직접 만든 ChangeNotifier·ValueNotifier.
앱 전역 싱글턴이나 상태 관리 라이브러리가 주입한 객체는 이 State가 소유한 것이 아니므로 해제하지 않는다.
React useEffect의 cleanup과 비슷하지만, dispose()는 State가 완전히 사라질 때 한 번만 호출된다. "의존값이 바뀔 때마다 정리 후 재실행"은 didUpdateWidget에서 직접 처리한다.
did... 생명주기 메서드
did는 "이미 일어난 일에 대한 사후 반응"이라는 뜻이다.
didUpdateWidget(oldWidget): 부모가 새 설정값으로 위젯을 다시 만들었을 때(같은State유지).oldWidget과widget을 비교해 바뀐 경우에만 작업한다. ReactuseEffect(fn, [deps])의 deps 변경에 대응한다.didChangeDependencies():initState()직후 한 번, 그리고 구독 중인InheritedWidget(Theme, MediaQuery, Provider 등) 값이 바뀔 때마다 호출된다.context를 이용한 초기화는 여기서 한다.didChangeAppLifecycleState:WidgetsBindingObservermixin이 제공. 앱이 백그라운드·포그라운드로 전환될 때 호출된다.
두 메서드가 나뉜 이유는 변화의 출처가 다르기 때문이다. 전자는 직계 부모가 준 값, 후자는 트리 위쪽에서 구독하는 값의 변화다.
initState() → didChangeDependencies() → build()
→ (리빌드) didUpdateWidget() → build() → ... → dispose()
WidgetsFlutterBinding.ensureInitialized()
위젯 레이어와 엔진(네이티브 플랫폼 채널)을 잇는 바인딩을 초기화한다. runApp()이 자동으로 호출하지만, 그 전에 플러그인을 쓰는 비동기 작업이 있으면 직접 먼저 호출해야 한다.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: '.env');
await Firebase.initializeApp(/* ... */);
runApp(const App());
}
이미 초기화되어 있으면 기존 인스턴스를 반환하므로 여러 번 호출해도 안전하다.
자주 쓰는 위젯과 속성
AppBar의 elevation / scrolledUnderElevation
elevation: 스크롤되지 않았을 때 그림자 높이.0이면 평평하다.scrolledUnderElevation: 본문이 AppBar 아래로 스크롤될 때의 그림자 높이. Material 3에서는 스크롤 시 자동으로 생긴다.
그림자 대신 하단 구분선을 쓰는 디자인이라면 둘 다 0으로 끄고 Border를 따로 그린다.
GestureDetector
아무것도 그리지 않는 투명 위젯으로, 자식에 탭·더블탭·롱프레스·드래그 같은 제스처를 붙인다.
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _expanded = !_expanded),
child: const Text('탭하면 접힘'),
)
기본값(deferToChild)에서는 자식의 빈 영역(배경 없는 Text 주변 등)을 눌러도 반응하지 않을 수 있어 opaque를 지정한다. 물결(ripple) 같은 Material 피드백이 필요하면 InkWell을 쓴다.
InteractiveViewer
자식에 핀치 줌, 드래그 이동을 붙이는 SDK 위젯이다.
InteractiveViewer(minScale: 0.5, maxScale: 4.0, child: Image.network(url))
이미지 뷰어, 지도, 도면처럼 확대해서 봐야 하는 화면에만 쓴다. 일반 UI에 씌우면 스크롤 제스처와 충돌한다.
ClampingScrollPhysics / BouncingScrollPhysics
스크롤 끝에 닿았을 때의 동작이다.
ClampingScrollPhysics(Android 기본): 끝에서 멈춘다. 오버스크롤 표시는 Material 3·Android 12 이상에서 늘어나는(stretch) 효과, 이전에는 빛 번짐(glow) 효과다.BouncingScrollPhysics(iOS 기본): 끝을 넘어 당겨졌다가 튕겨 돌아온다.
플랫폼과 상관없이 같은 동작을 원하면 physics를 명시한다.
ListView.separated
항목 사이에 구분자를 자동으로 넣는다(항목 N개 → 구분자 N-1개).
ListView.separated(
itemCount: sites.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) => SiteCard(site: sites[index]),
)
이미 스크롤되는 부모 안에 넣을 때는 shrinkWrap: true와 physics: const NeverScrollableScrollPhysics()를 함께 줘서 콘텐츠 높이만큼만 차지하게 한다. 항목이 많다면 shrinkWrap은 모든 항목을 한 번에 배치하므로 CustomScrollView + SliverList를 고려한다.
네비게이션과 네트워크
Navigator.pop의 두 번째 인자
push로 연 화면이 닫힐 때 결과를 돌려준다.
final result = await Navigator.push<List<DateTime>>(
context,
MaterialPageRoute(builder: (_) => const DateRangePicker()),
);
// 열린 화면 안에서
Navigator.pop(context, [start, end]);
push가 돌려준 Future는 대상 화면이 닫힐 때 pop의 두 번째 인자로 완료된다. React에서 모달을 Promise로 열고 await openModal()로 값을 받는 패턴과 같다. 사용자가 뒤로 가기로 닫으면 null이 오므로 처리해 둔다.
Dio
Dart의 HTTP 클라이언트 패키지로, JS의 axios에 해당한다. 기본 http 패키지보다 인터셉터(토큰 자동 첨부, 401 시 토큰 갱신 후 재시도), JSON 변환, 타임아웃·취소, FormData 파일 업로드 등이 풍부하다. 앱 전체가 Dio 인스턴스 하나를 공유하도록 감싸고, 그 인스턴스에 인증 인터셉터와 로깅을 붙이는 구성이 흔하다.
Dio FormData vs JS FormData
final formData = FormData();
formData.files.add(
MapEntry(
'data',
MultipartFile.fromBytes(jsonBytes, filename: 'blob', contentType: DioMediaType('application', 'json')),
),
);
formData.append('data', new Blob([jsonBytes], { type: 'application/json' }), 'blob');
JS FormData는 append 메서드로 필드를 추가하지만, Dio FormData는 files·fields 리스트를 그대로 노출하므로 List의 add·addAll을 쓴다.