Flutter 앱의 위치 관제 서비스를 개발하면서 겪었던 문제와 고민들에 대해 작성해보려고 합니다.

물류 기사앱의 위치 관제 기능을 운영하면서 flutter_foreground_task 기반 포그라운드 서비스를 사용했습니다.
당시 구조는 백그라운드 isolate가 위치를 수집하고, IsolateNameServer로 메인 isolate에 위치 정보를 넘긴 뒤, 메인 isolate가 서버로 API 전송을 담당하는 방식이었습니다.
위치 관제 문제 발견
간헐적으로 위치 관제 서비스가 끊기는 문제가 있었습니다.원인을 추적하던 중,
“Android에서 액티비티가 죽었는데, 포그라운드 서비스만 살아있는 경우, Flutter에서 어떻게 동작할까?” 라는 의문이 생겼습니다. 앱을 스와이프로 종료하면 Main Isolate는 내려갈 것입니다. Background Isolate를 띄운 주체도 Main인데, Main이 죽으면 BG도 같이 죽어야 하지 않나 라는 생각이 들었습니다.
정답은 flutter_foreground_task 가 Headless Flutter Engine을 별도로 띄워, Main isolate가 없어도 Background isolate가 계속 실행될 수 있게 합니다.

그렇다면, 지금 구조에서 main isolate가 죽고, background isolate만 살아있는 상황에 위치 정보를 서버로 보내지 못하는 것은 당연한 것이었습니다.
Background Isolate에서 직접 전송할 수 있도록 변경하자.
Main에서 위치를 받아 전송하는 구조에서, Background Isolate가 직접 전송하도록 변경하고자 했습니다. 겉보기엔 한 줄짜리 변경이지만, 그 한 줄 뒤에 인증 전체가 따라왔습니다.
Flutter Isolate는 메모리를 공유하지 않는다.
싱글 스레드 구조인 isolate는 isolate끼리 메모리 공유를 하지않습니다. Main이 들고 있던 JWT 및 데이터 전송을 위해 필요한 context를 Background에서도 갖고 있어야했습니다.
동시 Refresh 문제
만약, 두 isolate에서 동시에 토큰 갱신을 요청하면 한쪽에서 로그아웃이 필연적으로 발생할 수밖에 없었습니다.
기사 앱의 특성상 관제와 화면 조작을 동시에 하는 비율이 80% 이상으로, Refresh를 동시 요청할 가능성이 더 높았습니다.
Refresh Token Rotation일 때
Main과 Background 동시 refresh 경쟁

- Main이 refresh token A로 갱신 → 성공, 서버는 B 발급
- 거의 동시에 Background도 A로 요청
- 서버는 A를 이미 소비 → 실패 (
RT_EXPIRED등) - BG가 "세션 만료"로 처리하면 → 운행 중인데 로그아웃처럼 보일 수 있음
해결 방안 1. 살아 있는 한쪽에서만 refresh를 담당하자.
주체를 한쪽에서만 가져가기 위해, Main isolate만 살아있을때는 Main에서 토큰을 갱신하고 Background isolate가 살아있을때는 Background에서 무조건 갱신하는 방법이었습니다.
Main이 Background 생존 여부를 계속 추적해야했고, 생명주기 분기, 예외 처리가 복잡하여 포기했습니다.
해결 방안 2. 양쪽에서 각자 Refresh + 방어 로직
각 Isolate가 독립적으로 갱신하되, refresh 시작 전 FSS에 "갱신 중" 플래그를 쓰고, 상대방 플래그가 감지되면 최대 4초를 기다린 뒤 FSS에서 새 토큰을 읽어오는 방식이었습니다.
문제는 플래그 확인과 플래그 세우기 사이가 원자적이지 않다는 점이었습니다. 진짜 mutex는 "확인 → 점유"가 한 번에 일어나지만, FSS 기반 방식은 그 사이에 필연적으로 IO 시간이 존재합니다. Main이 플래그를 읽고 "없다"고 판단한 뒤 쓰기를 완료하기 전에, Background가 동일하게 읽으면 둘 다 동시에 진입하게 됩니다.
4초 대기도 마찬가지였습니다. EncryptedSharedPreferences IO 지연과 네트워크 상황에 따라 4초가 충분하지 않을 수 있고, Timeout과 실제 실패를 구분할 방법도 없었습니다. race condition을 줄이는 것이지 없애는 게 아니었고, 운행 중 로그아웃이 가끔 발생하는 버그는 재현과 원인 추적이 모두 어렵기 때문에 이 방안은 채택하지 않았습니다.
(채택) 해결 방안 3. Android Native에서 refresh만 mutex로 직렬화
문제의 출발이 Android FGS 생명주기와 Flutter isolate 모델의 차이였기 때문에, 문제가 발생하는 레이어인 Android 프로세스 단위에서 직접 직렬화하는 방향을 선택했습니다. HTTP·비즈니스 로직은 Dart에 그대로 두고, Native는 refresh 동시성 체크만 담당하도록 역할을 최소화했습니다.
다만 팀에서 Android 개발 경험이 있는 건 저뿐이었고, Native 코드가 추가되면 다른 팀원이 이해하고 유지보수할 수 있을지가 걱정이었습니다. 팀원과 논의한 결과, "동시성 체크만 하는 얇은 레이어"라면 충분히 감당 가능하다는 결론이 나왔습니다. Flutter 비즈니스 로직은 그대로 두고 Native는 mutex 하나만 관리하면 되니 코드 볼륨이 크지 않았고, 오히려 Flutter 레이어에서 복잡한 방어 로직을 유지하는 것보다 훨씬 관리하기 편했습니다.
토큰 싱크 — FSS(Flutter Secure Storage) + Hive
refresh 동시성과 별개로, 갱신 후 양쪽 메모리의 토큰을 맞추는 문제가 남았습니다.
Hive만으로는 cross-isolate 싱크가 어렵다
Hive는 "읽을 때 isolate 메모리 로드 → 쓸 때 메모리 먼저 갱신 → 나중에 파일 반영" 방식입니다. 한쪽이 새 토큰을 써도, 다른 쪽은 다시 HiveBox를 열기 전까지 옛값을 씁니다.
매번 FSS를 사용하기에는 IO 작업에 대한 리소스가 크다.
Flutter Secure Storage는 파일을 읽고 쓰기 때문에, FSS로 계속 관리하는 것보다는 Hive를 함께 관리하여, 효율적으로 관리할 수 있도록 했습니다.
FSS 저장 + IsolateNameServer 이벤트 + Native mutex
결과.

흐름
- 한쪽에서 refresh 성공
- FSS에 새 토큰 저장
- IsolateNameServer로
TOKEN_UPDATED전송 - 수신 측은 FSS에서 읽어 자신의 Hive에 반영
동시에 refresh API가 두 번 나가는 것은 FSS만으로 막기 어렵고, Native mutex가 담당합니다.
후기.
간략하게 설명하기 위해 3가지 방안만 이야기했지만 실제로는 메모리 공유가 어려운 Cross Isolate 환경에서 토큰 동시성 문제를 해결하기 위해 4단계의 고민을 거치게 되었습니다.
| # | 방안 | 장점 | 단점 | 내 판단 |
|---|---|---|---|---|
| 1 | Main만 API 전송 (현행 유지) | 인증 책임 한곳, 구조 단순 | Main 종료 시 관제 전송 불가 — 근본 원인 미해결 | 구조적 한계로 기각 |
| 2 | 양쪽이 각자 토큰 + 독립 refresh | 구현 직관, 빠름 | 동시 refresh → 인증 꼬임·오탐 로그아웃 | 프로덕션 리스크로 기각 |
| 3 | 살아 있는 isolate가 refresh 담당 (상태 기반) | 상태에 맞는 담당자 | 생존 상태 추적·분기·예외가 폭발, 유지보수 부담 | “돌아가지만 운영이 버겁다”로 보류 |
| 4 | Flutter lock + pre-check / post-failure 방어 | 네이티브 없이, 크로스플랫폼 | 타임아웃 vs 실제 refresh 중 vs 네트워크 실패 구분 어려움, 방어 코드 비대화 | 증상 완화는 가능, 근본 직렬화는 아님 |
| 5 | Android native mutex로 refresh만 직렬화 | 문제 레이어(Android)에 최소 개입, Flutter 분기 덜 흔듦 | 플랫폼 분기, 팀 네이티브 이해도 필요 | 네이티브 코드를 최소한으로 가져가고, 코드가 가장 깔끔해짐 |
Cross Isolate 환경에서 background isolate만 살아있을 수 있다는 특수한 상황을 마주하면서, Flutter 레이어만 봐서는 풀리지 않는 문제가 있다는 걸 배웠습니다. 플랫폼을 이해한 만큼 선택지가 넓어졌고, 그 선택지 중에서 팀이 함께 이해하고 유지할 수 있는 구조를 고르는 것이 결국 좋은 설계라는 걸 다시 한번 느꼈습니다.