Skip to content

Flutter Gotchas + Workarounds

이 프로젝트에서 부딪힌 Flutter 함정과 해결법.

1. BackdropFilter 한계 + Glass UI

1.1. bottomNavigationBar 슬롯에서 blur 안 됨

Scaffold(bottomNavigationBar: ...) 의 슬롯은 별도 compositing layer 라 BackdropFilter 가 body 콘텐츠를 못 봄 → solid 색으로만 보임.

해결: 같은 layer 에 배치.

dart
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 흉내.

dart
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높을수록 vivid1.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 는 정상

원인

  • ThemeDatafontFamily / textTheme 명시 안 함 → Material default
  • Android 는 보통 Roboto + NotoSansCJK auto-fallback 으로 한글 정상
  • 그러나 phosphor 등 패키지 폰트가 default 매칭에 끼어들어 자모 글리프 잘못 매칭
  • iOS 는 'Apple SD Gothic Neo' fallback 정상 작동

해결 — fontFamily 강제 + textTheme override

dart
// 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 추정 + 트레이드오프 → 무한 루프

패턴

  1. Riverpod NotifierProvider — 튜닝 파라미터 state holder
  2. Bottom-sheet modal + Slider — 실시간 값 조정
  3. Dart 코드 스니펫 + 클립보드 복사 — 결정된 값 박기 편하게
  4. local 빌드만 long-press hidden triggerappFlavor.isDev 체크
  5. prod 에선 onLongPress: null — 트리거 자체 비활성

예시 구현 (mobile/lib/app/nav_bar_tuning.dart, PR #22)

dart
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.entitlements

Prod flavor xcconfig(Debug-Prod.xcconfig, Release-Prod.xcconfig, Profile-Prod.xcconfig)도 동일하게 명시적으로 설정:

CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements

Entitlements 키 구분

devprod/profile
com.apple.developer.applesignin[Default][Default]
com.apple.developer.devicecheck.appattest-environmentdevelopmentproduction

주의

  • 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() 자체가 호출되지 않음. 디바이스 콘솔에는 별다른 크래시 로그 없음.

원인

  1. Apple Developer Portal 의 App ID 에 Push Notifications capability 먼저 활성화 필요
  2. ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ 의 캐시된 프로파일은 capability 변경 전 발급된 것 → 새 capability 미포함 상태로 재사용됨
  3. 앱이 aps-environment 를 가졌는데 프로파일은 push 미포함 → iOS 가 APNs 등록 시도하다 hang. Firebase Messaging swizzle 한 application:didFinishLaunchingWithOptions: 가 끝나지 못해 runApp() 까지 도달 못 함

해결

bash
# 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 / FirebaseMessaging swizzle 이 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 는 production
  • ios/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 으로 우회.

해결

xml
<!-- 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 후 LoginPageanalytics.logLogin 송출. 그러나 refetch 가 길어지면 GoRouter redirect 가 LoginPage 를 dispose 시키고, page 의 ref.read 접근 시 crash. mounted 가드를 추가하면 logLogin 이 누락됨.

해결: page lifecycle 과 무관한 controller 안에서 송출.

dart
// 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) 은 한 줄:

dart
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. setUserIdlogLogout race

증상: logout 시 setUserId(null)logLogout 보다 먼저 반영되면 logout 이벤트가 익명에 귀속 — 이탈 funnel 깨짐.

해결: 순차 await + 명시적 logout 플래그.

dart
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. awaitref 접근 — _disposed 플래그로 가드

증상: controller.reveal() 안에서 await revealAnalysis()ref.read(analyticsServiceProvider).logRevealResult(...) 호출. await 동안 화면 이탈 → controller dispose → 호출 시 "Tried to use Provider after it was disposed".

해결 패턴 1 — 미리 캡처:

dart
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 변수로 기억해 변할 때만 송출.

dart
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: 클라이언트 측정 제거됐는데 백엔드 측정도 없음 → "어디 한쪽에 복구"

판단 룰:

  1. 봇은 각 라운드의 코드 상태만 보고 즉시적 결함을 지적 — 큰 그림 (한 라운드 fix 가 다른 trade-off 를 만든다는) 은 못 봄
  2. 두 권고가 충돌하면 PR scope 판단 — 이 PR 의 본래 의도가 뭔가, 권고를 만족시키려면 다른 PR scope 가 필요한가
  3. 반박이 합리적이면 👎 + 반박 코멘트 (왜 별도 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 구조:

dart
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 위로 모달이 덮임.

dart
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) 로 캡처:

dart
@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 패턴.

dart
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 가능).

dart
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.

dart
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();  // ★ 전이일 때만
    }
  }
});