JNI 지옥 없이 Flutter에 네이티브 온-디바이스 모델 통합

발행: (2025년 12월 18일 오후 03:34 GMT+9)
18 min read
원문: Dev.to

Source: Dev.to

나는 이 교훈을 블로그 글이나 컨퍼런스 강연이 아니라, 매 빌드가 성공해도 뭔가 잘못된 느낌이 들던 일주일을 통해 힘들게 배웠다.
앱은 실행되었고, 모델도 정상 작동했다. 하지만 매번 변경할 때마다 마치 눈에 보이지 않는 무언가를 깨뜨릴 수 있는 잘못된 호출 하나만 있으면 된다는 듯이, 매우 불안정하게 느껴졌다. 보통 나는 그때 JNI 지옥에 빠졌다는 것을 알게 된다.

15년 넘게 모바일 런타임과 가까이서 작업하면서 나는 한 가지 진리를 깨달았다:

Cross‑platform UI is easy. Cross‑platform native execution is not.

디바이스 내 모델이 Flutter 앱에 통합될 때, 추상화 경계는 더 이상 예의 바르게 동작하지 않는다. 이제 정확성을 강요한다.

mobile app development Charlotte 에서는 이 문제가 팀이 예상하는 것보다 더 자주 나타난다. 특히 성능, 메모리 제어, 그리고 라이프사이클 정확성이 실제로 중요한 경우에 그렇다.

왜 Flutter는 네이티브 ML 주변에서 불편함을 겪는가

  • Flutter는 렌더링과 상태 관리에 뛰어나다.
  • 스레드, 메모리, 라이프사이클 순서에 대한 엄격한 제어가 필요한 네이티브 실행 경로를 담당하도록 설계되지 않았다.

온‑디바이스 모델은 이 격차를 즉시 드러낸다:

  • Model initialization은 타이밍에 민감하다.
  • Inference는 스레드 친화성에 민감하다.
  • Memory allocation patterns가 중요하다.

Flutter의 기본 브리지는 이러한 세부 사항을 숨기지 않으며, 최악의 방식으로 표면에 드러낸다.

The mistake I see repeatedly: 네이티브 ML을 plugin 문제로 다루고 runtime 문제로 보지 않는 것이다.

JNI의 실제 비용은 구문이 아니다

JNI 자체가 적이 아니다. 실제 비용은 그것이 숨기는 것이다.

  • 모든 JNI 경계 횡단은 thread context, object lifetime, ownership에 대한 불확실성을 도입한다.
  • 대용량 버퍼를 할당하고 결정적인 해제를 기대하는 모델 런타임을 추가하면 그 불확실성이 배가된다.

핫 재시작 후에만 나타난 메모리 누수를 디버깅한 적이 있다.
UI 스레드와 경쟁하면서도 기술적으로 비동기인 JNI 호출 때문에 프레임 드롭을 추적한 적이 있다.

이러한 문제들은 Dart 측에서는 명확히 드러나지 않았다.

JNI는 크게 실패하지 않는다. 천천히 실패한다.

왜 플랫폼 채널은 부하가 걸리면 무너지는가

  • Flutter의 플랫폼 채널은 편리하지만 거칩니다.
  • 채널은 메시지를 직렬화하고, 데이터를 마샬링하며, 요청이 짧고 드물다고 가정합니다.
  • 모델 추론은 이러한 모든 가정을 위반합니다.

처음에 메서드 채널을 통해 네이티브 모델을 연결했을 때는 모든 것이 정상적으로 보였지만, 동시성이 등장하면서 상황이 달라졌습니다:

  • 여러 개의 진행 중인 호출.
  • 추론 도중에 발생하는 라이프사이클 이벤트.

갑자기 순서가 중요해졌고, 채널 추상화는 이를 전혀 드러내지 않았습니다. 디버깅은 Dart에서 Logcat으로, 다시 Dart로 옮겨가며 양쪽 모두 상대방을 완전히 인식하지 못한 상태가 되었습니다.

네이티브 런타임을 명시적으로 소유하기

The turning point for me was deciding that Flutter would never own the model.

  • Flutter → request work.
  • Native code → own execution.

That mental shift simplifies everything. The native layer becomes a service with a clearly defined lifecycle; Flutter becomes a client. Once I stopped trying to make the model feel “Flutter‑native,” the integration stabilized.

Source:

JNI를 피하기보다 교차 횟수를 줄이는 것이 목표

JNI를 완전히 피하려는 것은 비현실적이며, 교차 횟수를 줄이는 것은 실현 가능하다.

  • 경계 설계: Flutter는 intent만을 전송하고, 데이터를 전송하지 않는다.
  • 구성: 한 번만 수행된다.
  • 실행: 네이티브에서 수행된다.
  • 결과: 최소한의 신호 또는 불투명한 레퍼런스로 돌아온다.

큰 텐서는 절대 경계를 넘지 않는다.
중간 상태도 절대 경계를 넘지 않는다.
Flutter는 진행 상황을 폴링하지 않고, 완료 신호를 받는다.

교차 횟수가 적을수록 런타임이 예기치 않은 동작을 할 가능성도 줄어든다.

스레딩은 대부분의 통합이 실패하는 지점

  • 디바이스 내 추론은 메인 스레드에서 떨어져 있어야 합니다—명백하지만 지속적으로 위반됩니다.
  • 덜 명백한 점: Flutter와 공유되는 스레드 풀도 여전히 경쟁을 일으킬 수 있습니다. 네이티브 추론이 엔진이 사용하는 풀에서 실행된다면, 문제를 단순히 옮긴 것에 불과합니다.

Solution: 모델 실행을 전용 실행기 또는 디스패치 큐에 격리하십시오. 이 격리는 협상할 수 없으며, 추론 스파이크가 렌더링이나 입력 처리를 방해하는 것을 방지합니다. 이 분리가 이루어지면 프레임 안정성이 즉시 향상됩니다.

메모리 소유권은 명확해야 합니다

Flutter의 가비지 컬렉션 기반 세계는 네이티브 메모리 관리와 깔끔하게 매핑되지 않습니다.

  • 네이티브 모델 런타임은 종종 명시적 소유권을 기대합니다.
  • 버퍼는 적절한 시점에 해제되어야 합니다.
  • 컨텍스트는 결정적으로 파괴되어야 합니다.

모범 사례: 경계 너머로 소유권을 전달하지 않도록 합니다.

  • 네이티브 코드가 할당합니다.
  • 네이티브 코드가 해제합니다.
  • Flutter는 최대한 불투명한 핸들만 보유합니다.

이러한 규율은 압력이 가해질 때만 나타나는 누수와 충돌의 전체 클래스를 제거합니다.

라이프사이클은 조용한 살인자

앱 라이프사이클 이벤트는 준비가 되었든 안 되었든 찾아옵니다:

  • 백그라운드 전환 / 포그라운드 전환
  • 저메모리 신호
  • 프로세스 종료

네이티브 모델 런타임이 이러한 이벤트에 명시적으로 연결되지 않으면 결국 오작동하게 됩니다. 백그라운드 전환 후에도 메모리를 계속 차지하거나 재개 후 초기화 경쟁이 발생하는 모델을 본 적이 있습니다.

해결책: 라이프사이클 소유권을 네이티브 코드에 넘겨 주세요. 네이티브 코드는 플랫폼 라이프사이클 이벤트에 직접 구독하고 모델을 적절히 관리해야 합니다. Flutter가 그 책임을 중재해서는 안 됩니다.

여기서는 핫 리로드가 당신의 친구가 아닙니다

핫 리로드는 생산성을 높여주는 선물이지만—함정이 되기도 합니다.

  • 네이티브 상태는 Dart 상태와 달리 리로드 후에도 살아남습니다.
  • 모델 런타임이 지속됩니다.
  • 스레드가 계속 실행됩니다.
  • 레퍼런스가 오래되어 무효화됩니다.

내 접근 방식: 핫 리로드를 네이티브 모델 통합에 대해 지원되지 않는 것으로 간주합니다. 개발 중에는 네이티브 코드 경로를 건드릴 때 전체 재시작을 강제합니다. 이 제약은 혼합 라이프사이클 상태에만 존재하는 유령을 쫓는 데 소요되는 시간을 절약해 줍니다.

왜 FFI가 종종 JNI보다 온‑디바이스 모델에 더 좋은가

가능할 때는 FFI‑기반 통합을 JNI 브리지보다 선호합니다. 그 이유는:

  • 직접 호출이 소유권 의미를 더 명확하게 합니다.
  • FFI는 JNI의 추가적인 간접 호출 및 스레드‑컨텍스트 조작을 피합니다.
  • Dart의 네이티브 확장과 더 잘 맞으며 성능이 더 좋을 수 있습니다.

TL;DR

  1. 네이티브 ML을 플러터 플러그인이 아닌 런타임 서비스로 취급한다.
  2. JNI 교차를 최소화하고, 데이터를 보내기보다 의도를 전달한다.
  3. 추론을 전용 스레드/실행기에서 격리한다.
  4. 메모리 소유권을 네이티브 쪽에 유지한다.
  5. 네이티브 라이프사이클을 플러터가 아니라 플랫폼 이벤트에 연결한다.
  6. 네이티브 변경 시 핫‑리로드를 피하고 전체 재시작을 사용한다.
  7. 보다 명확한 의미와 낮은 오버헤드를 위해 가능하면 FFI를 선호한다.

이 가이드라인을 따르면, 깨지기 쉬운 “JNI‑헬” 경험을 안정적이고 유지보수 가능한 Flutter와 온‑디바이스 머신‑러닝 모델 간 통합으로 바꿀 수 있습니다.

객체 마샬링 레이어 제거

객체 마샬링 레이어를 제거합니다. 성능 특성을 보다 예측 가능하게 만듭니다.

특히 C 또는 C++ 기반 추론 엔진을 사용할 때 그렇습니다. 호출 스택이 투명해집니다. 디버깅이 다시 정상적으로 이루어집니다.

JNI도 여전히 그 역할이 있지만, FFI는 ML 워크로드에 중요한 방식으로 하드웨어에 더 가깝게 느껴집니다.

Flutter 차단 없이 모델 초기화 처리

초기화는 무겁습니다. Flutter의 시작을 차단해서는 안 됩니다.

모델을 지연(lazy)하지만 의도적으로 초기화합니다. 첫 프레임에서가 아니라. 첫 상호작용에서도가 아니라. UI가 정착된 후의 유휴 시간에.

네이티브 코드가 이 타이밍을 제어합니다. Flutter는 준비가 완료되면 알려받습니다.

이 접근 방식은 콜드 스타트 페널티를 피하면서 필요할 때 추론을 반응성 있게 유지합니다.

경계 너머의 오류 처리

오류는 경계를 깔끔하게 넘겨야 하며, 그렇지 않으면 전혀 넘겨서는 안 됩니다.

JNI를 통해 네이티브 예외를 던지는 것은 실수이며, 그 결과로 크래시가 발생합니다. 대신, 네이티브 실패를 명시적인 상태로 변환합니다.

Flutter는 상태에 반응하고, 네이티브 코드는 원인을 처리합니다.

이러한 분리는 오류 처리를 결정론적이고 디버깅하기 쉽게 유지합니다.

아키텍처가 정직할 때 디버깅이 더 쉬워지는 이유

이와 같이 통합을 재구성한 후 가장 눈에 띄는 변화는 심리적인 것이었습니다.

디버깅이 대립적인 느낌을 잃었습니다. 로그가 의미 있게 보였습니다. 스레드 추적이 기대와 일치했습니다. 성능 문제도 재현 가능해졌습니다.

그때 나는 아키텍처가 결국 자신이 하는 일을 정직하게 드러낸다는 것을 알게 되었습니다.

단순하게 보이게 만드는 함정

팀은 종종 깔끔한 Dart API 뒤에 복잡성을 숨기려고 합니다. 이러한 충동은 이해할 수 있습니다. 하지만 위험하기도 합니다.

네이티브 머신러닝은 복잡합니다. 그렇지 않은 척하면 복잡성이 실패 모드로 전이될 뿐입니다.

저는 미래의 유지보수자가 자신이 건드리는 것이 무엇인지 이해할 수 있도록 충분히 현실을 드러내는 방법을 배웠습니다. 명확한 경계. 명확한 소유권. 명확한 비용.

Flutter가 여전히 올바른 선택인 경우

이 모든에도 불구하고 Flutter는 여전히 강력한 선택이다: 렌더링 속도, 개발자 생산성, 크로스‑플랫폼 도달 범위.

핵심은 Flutter의 추상화가 끝나는 지점을 존중하는 것이다. 디바이스 내 모델은 그 경계 너머에 존재한다.

그 경계를 존중하면 통합이 고통스럽기보다 예측 가능해진다.

경험과 함께 앉다

15년이 지난 지금, 나는 정확성을 희생하면서까지 우아함을 추구하지 않는다. 나는 스트레스 상황에서도 데모와 동일하게 동작하는 시스템을 추구한다.

JNI 지옥 없이 네이티브 온‑디바이스 모델을 Flutter에 통합하는 것은 영리한 트릭에 관한 것이 아니다. 이는 시스템의 일부가 명시적인 제어를 필요로 한다는 것을 받아들이는 것이다.

Flutter가 가장 잘하는 일을 하도록 허용하고, 네이티브 코드가 실행을 담당하도록 신뢰할 때, 결과는 마법과 같지는 않다. 그것은 더 나은 것이다.

안정적이다.

Back to Blog

관련 글

더 보기 »