rebuild는 다시 그리는 것이 아니다
상태가 바뀌면 위젯 트리를 다시 만듭니다. 위젯은 화면 그 자체가 아니라 화면을 어떻게 그릴지 적은 설명서에 가깝고, 실제 렌더링 객체는 필요한 부분만 갱신됩니다. rebuild가 자주 일어나는 것 자체는 설계된 동작이므로, 문제는 횟수가 아니라 rebuild 안에서 비싼 계산을 하고 있는지입니다.
PRACTICAL LANGUAGE GUIDE
Dart와 Flutter의 크로스플랫폼 제품 개발, 상태 관리, 비동기 처리와 면접 준비를 정리한 실무 가이드입니다.
Dart를 언어 자체가 좋아서 고르는 사람은 드뭅니다. 대부분은 Flutter가 먼저 있고, 그래서 Dart를 쓰게 됩니다. 이 순서를 인정하고 보면 판단 기준이 단순해집니다. 화면 하나를 만들어 iOS와 Android에 같은 모양으로 올려야 하고, 그 화면을 앞으로 자주 바꿀 예정이라면 유지할 코드가 한 벌이라는 점이 그대로 일정 차이로 나타납니다. 디자인 시스템을 팀이 직접 정의하는 제품일수록 이 이점이 큽니다. 플랫폼이 주는 기본 컴포넌트를 따라가는 대신 픽셀을 직접 그리기 때문에, 두 플랫폼의 화면이 어긋나는 일이 줄어듭니다.
반대로 잘 맞지 않는 자리도 분명합니다. 카메라 파이프라인, 백그라운드 위치 추적, 최신 시스템 기능처럼 플랫폼 API를 깊게 다루는 기능은 결국 네이티브 코드를 쓰고 채널로 연결해야 합니다. 그 순간 한 벌이던 코드가 세 벌이 되고, 두 플랫폼의 동작 차이를 다시 마주합니다. 팀에 이미 iOS와 Android 인력이 각각 있다면 Flutter로 합치는 결정이 오히려 서로의 강점을 버리는 선택이 될 수 있습니다. 모바일 전반의 학습 순서를 잡는 중이라면 모바일 개발 로드맵을 함께 보세요.
Dart 코드는 isolate 하나 안에서 단일 스레드로 실행됩니다. 이벤트 루프가 큐에 쌓인 작업을 하나씩 꺼내 처리하고, 그동안 다른 코드는 끼어들지 못합니다. 자바나 코틀린에서 오면 여기서 감각이 어긋납니다. 락도 없고 동기화 블록도 없는데 경쟁 상태가 생기지 않는 이유는, 두 코드가 같은 변수를 같은 순간에 건드릴 방법이 아예 없기 때문입니다. 대신 다른 대가를 치릅니다. 한 함수가 오래 돌면 그 시간 동안 화면도 멈춥니다.
async와 await는 이 그림을 바꾸지 않습니다. await는 "여기서 멈추고 나머지는 결과가 도착한 뒤에 이어서 실행하라"는 표시일 뿐, 작업을 다른 코어로 보내지 않습니다. 네트워크 응답을 기다리는 동안 화면이 부드러운 이유는 병렬 실행이 아니라 기다리는 동안 이벤트 루프가 다른 일을 처리하기 때문입니다. 그래서 큰 JSON을 파싱하거나 이미지를 변환하는 CPU 작업에 await를 붙여도 프레임 드롭은 사라지지 않습니다. 이 차이는 언어별 비동기 비교에서 다른 런타임과 나란히 놓고 보면 더 분명해집니다.
진짜로 동시에 돌려야 하면 isolate를 새로 띄웁니다. 다만 isolate는 메모리를 공유하지 않습니다. 서로 메시지를 주고받을 뿐이고, 넘기는 값은 복사됩니다. 큰 데이터를 계속 오가게 만들면 계산에서 번 시간을 전달 비용으로 반납합니다. 무거운 파싱이나 압축처럼 입력과 출력이 명확하고 호출 빈도가 낮은 작업에 한정해서 쓰는 편이 안전합니다.
비동기 결과를 표현하는 두 타입의 구분은 문법이 아니라 사건의 성격에서 나옵니다. 값이 한 번 도착하고 끝나면 Future, 시간에 따라 여러 번 도착하면 Stream입니다. 상품 상세를 한 번 불러오는 요청은 Future, 검색어 입력, 위치 갱신, 실시간 알림은 Stream입니다. Stream을 쓰기로 했다면 구독을 언제 끊을지, 화면이 사라진 뒤에도 이벤트가 들어오면 무엇을 할지까지 같이 정해야 합니다. 취소하지 않은 구독은 조용히 살아남아 없어진 화면의 상태를 갱신하려 듭니다.
널 안전성도 여기에 얽힙니다. Dart는 타입에 물음표가 없으면 그 값은 null이 될 수 없고, 컴파일러가 이를 끝까지 확인합니다. 비동기 데이터는 도착 전에 값이 없는 구간이 반드시 존재하므로, 로딩과 실패와 성공을 하나의 nullable 변수로 뭉개면 화면 코드가 금방 지저분해집니다. 상태를 별도 타입으로 나눠 두면 위젯 쪽은 분기만 하면 됩니다.
Future<List<Product>> loadProducts(Api api) async {
final response = await api.get('/products');
if (response.statusCode != 200) {
throw StateError('상품을 불러오지 못했습니다');
}
return (response.json as List)
.map((json) => Product.fromJson(json))
.toList(growable: false);
}이 함수가 Future를 돌려주고 실패를 예외로 던지는 이유는 호출하는 화면 쪽에서 로딩, 성공, 실패 세 상태를 그대로 그릴 수 있게 하기 위해서입니다. HTTP 상태 확인과 JSON 변환을 한 곳에 모아 두면 위젯에는 파싱 코드가 남지 않고, 실패 원인도 이 지점에서만 다루면 됩니다. growable: false로 고정 길이 리스트를 만드는 것은 이 데이터가 화면에서 변경될 값이 아니라는 신호이기도 합니다.
MENTAL MODEL
상태가 바뀌면 위젯 트리를 다시 만듭니다. 위젯은 화면 그 자체가 아니라 화면을 어떻게 그릴지 적은 설명서에 가깝고, 실제 렌더링 객체는 필요한 부분만 갱신됩니다. rebuild가 자주 일어나는 것 자체는 설계된 동작이므로, 문제는 횟수가 아니라 rebuild 안에서 비싼 계산을 하고 있는지입니다.
const 생성자로 만든 위젯은 값이 같으면 다시 만들지 않고 재사용됩니다. 바뀌지 않는 아이콘, 여백, 텍스트 스타일에 const를 붙여 두면 rebuild 범위가 자연스럽게 좁아집니다. 습관이 되면 성능 튜닝을 따로 하지 않아도 기본 비용이 낮아집니다.
메모리를 공유하지 않고 메시지로만 대화합니다. 전역 변수를 함께 보거나 락으로 보호하는 방식은 존재하지 않습니다. 계산 결과만 주고받는 구조로 설계할 수 있을 때 꺼내고, 그렇지 않으면 작업을 잘게 쪼개 이벤트 루프에 나눠 넣는 편이 낫습니다.
일상 작업은 flutter와 dart 명령 두 개를 중심으로 돕니다. 패키지는 pub.dev에서 가져오고 의존성은 pubspec 파일에 적습니다. 개발 중에는 hot reload가 흐름을 크게 바꿉니다. 코드를 저장하면 앱 상태를 유지한 채 화면만 갱신되므로, 세 단계쯤 들어가야 나오는 화면을 매번 처음부터 다시 타고 들어갈 필요가 없습니다. 다만 상태가 꼬였을 때는 hot reload가 원인을 감추기도 하므로, 이상하다 싶으면 재시작해서 같은 증상이 나오는지 먼저 확인합니다.
테스트는 세 층으로 나뉩니다. 순수 로직은 일반 단위 테스트로, 화면은 위젯 테스트로, 전체 흐름은 통합 테스트로 확인합니다. 위젯 테스트는 실기기 없이 빠르게 돌기 때문에 목록 렌더링, 빈 상태, 에러 상태 같은 분기를 검증하기에 적당합니다. 여기에 정적 분석을 붙여 두면 널 관련 실수나 안 쓰는 코드가 리뷰 전에 걸립니다. 프레임이 튀거나 메모리가 계속 늘면 DevTools에서 타임라인과 위젯 리빌드를 확인하는 것이 가장 빠릅니다.
출시는 결국 플랫폼 두 곳을 각각 상대하는 일입니다. Android는 서명 키와 번들 설정을, iOS는 인증서와 프로비저닝, 스토어 심사를 따로 통과해야 합니다. 코드가 한 벌이라고 해서 릴리스 작업까지 한 번으로 줄지는 않습니다. 첫 릴리스에서 이 절차를 한 번 끝까지 통과시켜 두고, 이후에는 버전 올리기와 스토어 문구 갱신만 반복하는 형태로 만들어 두면 일정이 안정됩니다.
지금 배울 이유가 있는지, 아니면 몇 달 뒤가 나은지 아래 항목으로 판단해 보세요.
INTERVIEW PREP
Future는 한 번 도착하는 결과, Stream은 시간에 따라 여러 번 변하는 이벤트입니다. 요청 하나에는 Future, 소켓·검색어·실시간 데이터에는 취소와 backpressure 전략을 고려한 Stream을 선택한다고 답합니다.
아닙니다. rebuild 자체는 설계된 동작입니다. 비싼 계산·큰 하위 트리·불필요한 상태 범위가 문제인지 측정하고, const·작은 위젯·선택적 구독으로 변경 범위를 줄이는 것이 핵심입니다.
NEXT STEP