Flutter Gotchas + Workarounds
이 프로젝트에서 부딪힌 Flutter 함정과 해결법.
1. BackdropFilter 한계 + Glass UI
1.1. bottomNavigationBar 슬롯에서 blur 안 됨
Scaffold(bottomNavigationBar: ...) 의 슬롯은 별도 compositing layer 라 BackdropFilter 가 body 콘텐츠를 못 봄 → solid 색으로만 보임.
해결: 같은 layer 에 배치.
Scaffold(
body: Stack(
children: [
pages,
Positioned(bottom: 0, child: navBar),
],
),
)1.2. 흰 배경 위 흰 tint 가 회색이 됨
라이트 배경 + 흰 tint alpha 0.45 = 어두운 텍스트가 비쳐 회색 인상.
해결: ColorFilter.matrix 로 vibrancy boost (saturation 1.5 + brightness +30~50). iOS UIBlurEffect.systemMaterial 의 native vibrancy 흉내.
final s = 1.5; // saturation
final b = 48.0; // brightness
const lumR = 0.213, lumG = 0.715, lumB = 0.072;
final vibrancyFilter = ColorFilter.matrix(<double>[
s + (1-s)*lumR, (1-s)*lumG, (1-s)*lumB, 0, b,
(1-s)*lumR, s + (1-s)*lumG, (1-s)*lumB, 0, b,
(1-s)*lumR, (1-s)*lumG, s + (1-s)*lumB, 0, b,
0, 0, 0, 1, 0,
]);
BackdropFilter(
filter: ImageFilter.compose(
outer: vibrancyFilter,
inner: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
),
child: Container(color: Colors.white.withValues(alpha: 0.62), ...),
)1.3. 트레이드오프 레이블
| 노브 | 효과 | 주의 |
|---|---|---|
sigma | 높을수록 콘텐츠 추상화 | 30+ 면 단색 (blur 의미 잃음) |
alpha | 낮을수록 투명 | 너무 낮추면 어두운 콘텐츠 비쳐 회색 |
saturation | 높을수록 vivid | 1.8+ 면 워시드 |
brightness | 높을수록 검정도 light gray | +75+ 면 콘텐츠 사라짐 |
1.4. Native 가 진짜 필요할 때
liquid_glass_renderer(pub.dev): 0.2.0-dev.4, "실험용 프로덕션 금지" 명시. 사용 비추.- iOS:
UiKitView+UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) - Android:
RenderEffect.createBlurEffect(API 31+) + BackdropFilter fallback - PlatformView 라 1~2시간 작업, 의존성 0. 진짜 native 품질 원하면 이 길.
1.5. dartbrief 적용값 (PR #22)
floating glass pill 하단 네비 — alpha 0.80, sigma 0.85, sat 1.47, brightness +48.
2. Phosphor Icons + 한글 글리프 충돌
증상
- Android 에서
phosphor_flutter도입 후 한글 자모 분리 표시 - 예: "종목명 또는 종목코드 검색" → "조모며 또느 조모크드 거새"
- iOS 는 정상
원인
ThemeData에fontFamily/textTheme명시 안 함 → Material default- Android 는 보통 Roboto + NotoSansCJK auto-fallback 으로 한글 정상
- 그러나 phosphor 등 패키지 폰트가 default 매칭에 끼어들어 자모 글리프 잘못 매칭
- iOS 는 'Apple SD Gothic Neo' fallback 정상 작동
해결 — fontFamily 강제 + textTheme override
// app_theme.dart
const fontFallback = <String>[
'Roboto', // Android default — NotoSansCJK auto-fallback
'Apple SD Gothic Neo', // iOS
'sans-serif',
];
final base = Typography.material2021(platform: TargetPlatform.android);
final overriddenTextTheme = base.black.apply(
fontFamilyFallback: fontFallback,
);
return ThemeData(
fontFamily: 'Roboto',
fontFamilyFallback: fontFallback,
textTheme: overriddenTextTheme,
// ...
);핵심:
fontFamily: 'Roboto'명시 — 패키지 폰트가 default 잡지 못하게textTheme.apply(fontFamilyFallback)— TextField 등 모든 텍스트에 fallback 전파- ThemeData 의 fontFamilyFallback 만으로는 textTheme 자동 전파 안 되는 케이스 있음 → 명시적 override 필요
진단 팁
한글이 자모 단위 (ㅈㅗㅁㅗㅁㅕ 처럼) 로 분리돼 보이면 → 폰트 폴백 매칭 문제 거의 확실. 인코딩 / IME 문제 아님.
3. Local 튜닝 패널 패턴
UI 시각 파라미터 (alpha / blur / saturation / margin 등) 튜닝이 끝없이 길어질 때 — 코드 수정 → 빌드 → 확인 루프 깨고 사용자가 직접 슬라이더로 조정.
Why
- 빌드 한 번에 1 분+ 소요
- 사용자가 원하는 정확한 값을 본인도 모름 (보고 정해야 함)
- AI 추정 + 트레이드오프 → 무한 루프
패턴
- Riverpod NotifierProvider — 튜닝 파라미터 state holder
- Bottom-sheet modal + Slider — 실시간 값 조정
- Dart 코드 스니펫 + 클립보드 복사 — 결정된 값 박기 편하게
- local 빌드만 long-press hidden trigger —
appFlavor.isDev체크 - prod 에선
onLongPress: null— 트리거 자체 비활성
예시 구현 (mobile/lib/app/nav_bar_tuning.dart, PR #22)
class FooTuning {
const FooTuning({this.alpha = 0.80, this.sigma = 12.0});
final double alpha;
final double sigma;
FooTuning copyWith({double? alpha, double? sigma}) =>
FooTuning(alpha: alpha ?? this.alpha, sigma: sigma ?? this.sigma);
String toDartSnippet() => 'alpha: $alpha, sigma: $sigma';
}
class FooTuningController extends Notifier<FooTuning> {
@override FooTuning build() => const FooTuning();
void setAlpha(double v) => state = state.copyWith(alpha: v);
void reset() => state = const FooTuning();
}
final fooTuningProvider =
NotifierProvider<FooTuningController, FooTuning>(FooTuningController.new);
// 위젯에서
GestureDetector(
onLongPress: isDev ? () => showFooTuningPanel(context) : null,
child: Container(...), // 튜닝 대상
)주의
- 튜닝 끝나면 default 값을 코드에 박고 패널은 그대로 둘지 제거할지 결정
- 보통 local only 라 그대로 둬도 prod 영향 없음
4. iOS Flavor별 Entitlements 분리
증상
Apple Sign In, App Attest 등 iOS 네이티브 기능은 Entitlements 파일로 capability를 선언한다. Flutter 멀티 플레이버(dev/prod) 환경에서 단일 Runner.entitlements 만 쓰면 dev 빌드가 prod 프로비저닝 프로파일을 참조해 서명 실패.
해결 — 플레이버별 entitlements + xcconfig 연결
ios/Runner/
├── Runner.entitlements ← prod (Release/Profile 기본)
├── Runner-Dev.entitlements ← dev (Debug-Dev, Release-Dev, Profile-Dev)
└── RunnerProfile.entitlements ← Profile 빌드 (App Store 배포용)각 xcconfig 파일에 CODE_SIGN_ENTITLEMENTS 추가:
# ios/Flutter/Debug-Dev.xcconfig
CODE_SIGN_ENTITLEMENTS = Runner/Runner-Dev.entitlements
# ios/Flutter/Release-Dev.xcconfig
CODE_SIGN_ENTITLEMENTS = Runner/Runner-Dev.entitlements
# ios/Flutter/Profile-Dev.xcconfig
CODE_SIGN_ENTITLEMENTS = Runner/Runner-Dev.entitlementsProd flavor xcconfig(Debug-Prod.xcconfig, Release-Prod.xcconfig, Profile-Prod.xcconfig)도 동일하게 명시적으로 설정:
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlementsEntitlements 키 구분
| 키 | dev | prod/profile |
|---|---|---|
com.apple.developer.applesignin | [Default] | [Default] |
com.apple.developer.devicecheck.appattest-environment | development | production |
주의
- Xcode automatic signing이 entitlement 감지 시 프로비저닝 프로파일 자동 재생성 — Apple Dev Console에서 해당 App ID에 capability 먼저 활성화해야 함
RunnerProfile.entitlements는 Xcode가 Profile 빌드 시 자동 생성하므로 git에 포함해 관리sign_in_with_apple패키지는 iOS 전용 — Android에서 버튼 노출 시 "argument must be provided" 에러.if (defaultTargetPlatform == TargetPlatform.iOS)조건부 렌더링 필수
5. iOS APNs 엔타이틀먼트 + Provisioning Profile 캐시 (PR #44, 2026-05-06)
증상
PR #44 에서 FCM 푸시 추가하며 aps-environment 엔타이틀먼트만 추가 후 빌드 → iOS 실기기에서 앱이 네이티브 런치스크린(흰/하늘색 배경)에서 그대로 멈춤. Flutter runApp() 자체가 호출되지 않음. 디바이스 콘솔에는 별다른 크래시 로그 없음.
원인
- Apple Developer Portal 의 App ID 에 Push Notifications capability 먼저 활성화 필요
~/Library/Developer/Xcode/UserData/Provisioning Profiles/의 캐시된 프로파일은 capability 변경 전 발급된 것 → 새 capability 미포함 상태로 재사용됨- 앱이
aps-environment를 가졌는데 프로파일은 push 미포함 → iOS 가 APNs 등록 시도하다 hang. Firebase Messaging swizzle 한application:didFinishLaunchingWithOptions:가 끝나지 못해runApp()까지 도달 못 함
해결
# 1. Apple Developer Portal: Identifiers → 해당 App ID → Capabilities → Push Notifications 체크
# 2. 캐시된 프로파일 백업/삭제 (Xcode 자동 재발급 트리거)
mkdir -p /tmp/profile-backup
mv ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/*.mobileprovision /tmp/profile-backup/
# 3. 재빌드 — Xcode 가 새 capability 포함된 프로파일을 발급받음
make run-dev진단 팁
런치스크린에서 멈추면 (Flutter 스플래시 색이 아니라 native LaunchScreen.storyboard 의 색):
runApp()도달 못 함 —bootstrap()의 await 어딘가에서 hang- 가능성 1:
Firebase.initializeApp/FirebaseMessagingswizzle 이 APNs 대기 중 - 가능성 2:
FirebaseAppCheck.instance.activate(appleProvider: AppleProvider.debug)가 실기기에서 debug token 미등록 시 hang
관련 파일
ios/Runner/Runner-Dev.entitlements—<key>aps-environment</key><string>development</string>ios/Runner/Runner.entitlements— prod 는productionios/Runner/RunnerProfile.entitlements— profile 빌드는production
6. Android Tailscale IP cleartext allowlist (PR #44)
증상
외부 망(원격 근무 / 카페)에서 Android 실기기 → 로컬 백엔드 연결 시 브라우저는 잘 되는데 앱만 timeout / 카카오 로그인 페이지 안 뜸 / 구글 로그인 토스트 에러.
원인
Android 9+ 부터 cleartext HTTP 기본 차단. dev 빌드의 android/app/src/dev/res/xml/network_security_config.xml 에 Tailscale IP 가 등록 안 돼 있으면 앱은 거부, 브라우저는 자체 stack 으로 우회.
해결
<!-- android/app/src/dev/res/xml/network_security_config.xml -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">100.116.160.73</domain> <!-- Mac Tailscale IP -->
</domain-config>
</network-security-config>Tailscale CGNAT 대역(100.64.0.0/10)은 wildcard 지원 안 함 — 개별 IP 명시 필요.
Tailscale DNS 충돌 (cellular)
Android Tailscale 앱 기본값 "Use Tailscale DNS" 가 켜져 있고 셀룰러 망에서 Tailscale DNS 해상도 실패 → 모든 hostname 쿼리 hang.
증상: gojni 로그에 dns udp query: waiting for response... context deadline exceeded, health(warnable=dns-forward-failing).
해결: Tailscale 앱 → 설정 → Use Tailscale DNS OFF. 우리 백엔드는 IP 직접 호출이라 영향 없음.
7. Riverpod controller / Page lifecycle 분리 패턴 (PR #54)
Analytics / 외부 SDK 호출을 어디에 둘지 결정할 때 자주 부딪힌 패턴들. 봇 리뷰 7라운드에서 식별. 현재 진실은 auth_controller.dart / disclosure_detail_controller.dart 참조.
7.1. Page 의 mounted 가드만으로는 부족 — controller-level ownership
증상: 로그인 성공 → AuthController.refetch() await 후 LoginPage 가 analytics.logLogin 송출. 그러나 refetch 가 길어지면 GoRouter redirect 가 LoginPage 를 dispose 시키고, page 의 ref.read 접근 시 crash. mounted 가드를 추가하면 logLogin 이 누락됨.
해결: page lifecycle 과 무관한 controller 안에서 송출.
// auth_controller.dart
Future<void> handleAuthToken(String token, {required String provider}) async {
await ref.read(tokenStorageProvider).write(token);
await refetch();
if (state.isLoggedIn) {
await ref.read(analyticsServiceProvider).logLogin(provider);
}
}호출 측 (LoginPage) 은 한 줄:
await ref.read(authControllerProvider.notifier)
.handleAuthToken(token, provider: 'kakao');
// mounted / isLoggedIn / logLogin 모두 controller 안으로 흡수됨Why: Controller (Riverpod Notifier) 는 Provider scope 동안 살아있고, page dispose 와 독립. 이벤트 누락 / crash 둘 다 0.
7.2. setUserId ↔ logLogout race
증상: logout 시 setUserId(null) 이 logLogout 보다 먼저 반영되면 logout 이벤트가 익명에 귀속 — 이탈 funnel 깨짐.
해결: 순차 await + 명시적 logout 플래그.
class AuthController extends Notifier<AuthState> {
bool _isLoggingOut = false;
@override
AuthState build() {
ref.read(apiClientProvider).onUnauthorized(() async {
state = state.copyWith(loading: false, clearUser: true);
// 401 콜백도 식별자 정리하지만 — 명시적 logout 중이면 우회.
if (!_isLoggingOut) {
await ref.read(analyticsServiceProvider).setUserId(null);
}
});
// ...
}
Future<void> logout() async {
_isLoggingOut = true;
try {
// ... 토큰 제거, /auth/logout 호출 ...
final analytics = ref.read(analyticsServiceProvider);
await analytics.logLogout(); // 1. 식별자 살아있을 때 송출
await analytics.setUserId(null); // 2. 그 후 해제
} finally {
_isLoggingOut = false;
}
}
}Why _isLoggingOut: /auth/logout 호출이 401 응답할 수 있고 (이미 만료된 토큰), onUnauthorized 콜백이 발동해 setUserId(null) 가 logLogout 보다 먼저 실행될 수 있음. 플래그로 콜백을 우회시켜야 logout 이 식별 상태로 송출됨.
7.3. await 후 ref 접근 — _disposed 플래그로 가드
증상: controller.reveal() 안에서 await revealAnalysis() 후 ref.read(analyticsServiceProvider).logRevealResult(...) 호출. await 동안 화면 이탈 → controller dispose → 호출 시 "Tried to use Provider after it was disposed".
해결 패턴 1 — 미리 캡처:
Future<void> reveal() async {
final analytics = ref.read(analyticsServiceProvider); // dispose 전 capture
try {
final result = await repo.revealAnalysis(rceptNo);
if (_disposed) {
await analytics.logRevealResult(
rceptNo: rceptNo, outcome: RevealOutcome.cancelled);
return;
}
// ... state 갱신 ...
await analytics.logRevealResult(rceptNo: rceptNo, outcome: RevealOutcome.success);
} catch (e) {
if (_disposed) {
await analytics.logRevealResult(
rceptNo: rceptNo, outcome: RevealOutcome.cancelled);
} else {
await analytics.logRevealResult(
rceptNo: rceptNo, outcome: RevealOutcome.error);
}
}
}_disposed 는 controller 내부 bool, ref.onDispose 에서 true 로 set.
해결 패턴 2 — 화면 이벤트 fire-and-forget: 송출 결과를 await 할 필요 없으면 unawaited(ref.read(...).logX(...)) 로 화면 흐름 차단도 X. (예: watchlist_add)
7.4. 폴링 이벤트 dedup
증상: Inspect 메타가 pending 인 동안 폴링 (5→10→15→20s backoff, 이후 60s 고정) → 같은 analyze_inspect 이벤트가 여러 번 송출. funnel 분모 오염.
해결: 직전 status 를 instance 변수로 기억해 변할 때만 송출.
class DisclosureDetailController extends Notifier<DetailState> {
String? _lastInspectStatus;
Future<void> _applyMetaState(InspectMeta meta) async {
if (_lastInspectStatus != meta.status) {
_lastInspectStatus = meta.status;
await ref.read(analyticsServiceProvider).logAnalyzeInspect(
rceptNo: rceptNo, status: meta.status, isShort: meta.isShort);
}
// ... state 갱신 ...
}
}같은 패턴은 polling 으로 동일한 backend status 가 반복 도착하는 모든 이벤트에 적용 가능.
7.5. 봇 리뷰 라운드 간 모순 권고
같은 봇 (Codex) 이 PR #54 에서 라운드 2 와 라운드 7 에 상반된 권고:
- 라운드 2 P1: 클라이언트 측
ad_reward_earned송출 → SSV 실패 시 false-positive → "제거" - 라운드 7 P1: 클라이언트 측정 제거됐는데 백엔드 측정도 없음 → "어디 한쪽에 복구"
판단 룰:
- 봇은 각 라운드의 코드 상태만 보고 즉시적 결함을 지적 — 큰 그림 (한 라운드 fix 가 다른 trade-off 를 만든다는) 은 못 봄
- 두 권고가 충돌하면 PR scope 판단 — 이 PR 의 본래 의도가 뭔가, 권고를 만족시키려면 다른 PR scope 가 필요한가
- 반박이 합리적이면 👎 + 반박 코멘트 (왜 별도 PR scope 인지, 어떤 인프라가 필요한지 명시) + 임시 공백 복구 commitment
PR #54 의 ad_reward 케이스는 백엔드 SSV 핸들러에서 Firebase Measurement Protocol / BigQuery 기록이 정답이지만 app_instance_id 인프라 필요 → 별도 PR. 라운드 7 P1 은 👎 + 반박, 후속 PR 명시.
8. lazy auth (PR #57, 2026-05-08)
8.1. showModalBottomSheet + StatefulShellRoute 의 inner navigator → floating overlay 가 모달 위에 보이는 문제
증상: auth gate 모달이 떴는데 _MainShell 의 floating glass pill nav bar (공시/알림/더보기) 가 모달의 소셜 로그인 버튼 위로 보임.
원인: _MainShell 의 Stack 구조:
Stack(
children: [
Positioned.fill(child: navigationShell), // inner navigator 들 여기
if (!keyboardOpen)
Positioned(bottom: 0, child: _GlassPillNavBar(...)), // ← 같은 Stack child
],
)showModalBottomSheet 의 기본 useRootNavigator: false 는 가장 가까운 Navigator 에 push — 이 경우 StatefulShellRoute 의 branch 별 inner navigator. 모달 barrier 는 그 inner navigator 의 stack 만 덮으므로, 같은 Stack 의 별도 child 인 nav pill 은 가려지지 않음.
해결: useRootNavigator: true 로 root navigator 에 push — _MainShell 밖의 최상위 layer 라 nav pill 포함 모든 shell UI 위로 모달이 덮임.
showModalBottomSheet<_AuthGateResult>(
context: context,
isScrollControlled: true,
useRootNavigator: true, // ★
...
)동일 패턴 적용 대상: floating overlay (FAB, persistent banner, custom nav) 가 있는 모든 화면의 모달 호출. fullscreen dialog (Navigator.push(MaterialPageRoute(fullscreenDialog: true))) 도 같은 이슈 — useRootNavigator 또는 Navigator.of(rootNavigator: true) 로 해결.
8.2. autoDispose family Notifier 캡처 → invalidate 후 stale instance 호출
증상: "Cannot use the Ref ... after it has been disposed" 예외 또는 액션이 조용히 무시됨. lazy auth 게이트 통과 직후 onAuthenticated 콜백에서 발생.
원인: widget build 시점에 ref.read(provider.notifier) 로 캡처:
@override
Widget build(BuildContext context) {
final detailCtrl = ref.read(disclosureDetailControllerProvider(rceptNo).notifier);
// ...
Future<void> handleReveal() async {
await requireAuth(context, ref, AuthGateAction.reveal, detailCtrl.reveal);
// ^^^^^^^^^^^^^^^^
// 캡처된 stale 인스턴스
}
}requireAuth 가 게이트 통과 → AuthController.handleAuthToken → _invalidateAuthScoped() 가 disclosure detail / feedback family 를 invalidate. 캡처된 detailCtrl 은 dispose 됐지만 클로저가 그 인스턴스를 들고 있어 호출 시 죽은 ref 접근.
해결: 콜백 내부에서 ref.read(...) 다시 호출 — async 경계를 넘는 모든 notifier 접근은 캡처 X, lazy lookup 패턴.
Future<void> handleReveal() async {
await requireAuth(context, ref, AuthGateAction.reveal, () async {
await ref
.read(disclosureDetailControllerProvider(rceptNo).notifier) // ★ 다시 read
.reveal();
});
}룰 일반화: "build 에서 ref.read 한 notifier 를 async 콜백에 직접 넘기지 말 것." async gap 사이에 invalidate 가 발생할 수 있는 모든 경로 (auth 전이, push 알림으로 인한 상태 reset, scope 외 provider 조작) 에서 동일 위험.
8.3. autoDispose family invalidate 시 family 전체 처리
ref.invalidate(disclosureDetailControllerProvider) — family 의 alive 한 모든 인스턴스를 invalidate (특정 key 만 지정도 가능: ref.invalidate(provider(key))). auth 전이로 모든 사용자별 상태를 한 번에 비울 때 family 전체 invalidate 가 가장 단순.
8.4. AuthState 의 bootstrapped 가드
앱 cold start 직후 첫 /auth/me 가 끝나기 전 (저장 토큰은 유효하지만 user == null) 에 보호 액션을 누르면 requireAuth 가 게이트를 잘못 노출. requireAuth 입구에서 bootstrapped 까지 짧게 폴링 후 결정 (timeout 시 네트워크 장애 가능 → 그냥 진행, 게이트가 dismissable 라 사용자 dismiss 가능).
Future<void> requireAuth(...) async {
var waited = 0;
while (!ref.read(authControllerProvider).bootstrapped && waited < 2000) {
await Future<void>.delayed(const Duration(milliseconds: 50));
waited += 50;
if (!context.mounted) return;
}
// ... isLoggedIn 검사 + 게이트 분기
}8.5. 401 콜백에서 invalidate 가 만드는 무한 루프
ApiClient.onUnauthorized 콜백에서 auth-scoped provider 를 무조건 invalidate 하면, 비로그인 상태에서 그 provider 들이 build → API 호출 → 또 401 → 콜백 재진입 → invalidate → ... 무한 루프 → 짧게 IP rate limit 도달 → 정상 요청도 429.
해결: logged-in → logged-out 전이 일 때만 invalidate.
ref.read(apiClientProvider).onUnauthorized(() async {
final wasLoggedIn = state.isLoggedIn;
state = state.copyWith(loading: false, clearUser: true);
if (!_isLoggingOut) {
await ref.read(analyticsServiceProvider).setUserId(null);
if (wasLoggedIn) {
_invalidateAuthScoped(); // ★ 전이일 때만
}
}
});