Flutter 앱이 무한 로딩만 했는데, 요청은 서버에 가지도 않았습니다
회원가입 화면에서 본인인증 버튼을 누르면 로딩 표시만 계속 돌았습니다. 본인인증 위탁사의 인증창은 끝내 뜨지 않았습니다. 고객사 서비스의 안드로이드 회원앱에서 생긴 일이고, 새로 가입하려는 사람이 첫 단계에서 더 나아가지 못하는 상태였습니다.
이런 증상을 보면 보통 서버부터 의심합니다. 응답이 늦거나 외부 인증 서비스와의 연동이 실패했다고 짐작하기 쉽습니다. 이번에는 둘 다 아니었습니다. 요청이 서버에 도착하지도 않았습니다.
에러 없이 조용히 멈추는 모바일 앱 증상은 처음이 아니었습니다. 새 iOS 에서 푸시 토큰이 에러 없이 null 로 오던 일도 겉으로는 아무 문제가 없어 보였습니다.
서버 로그에 아무것도 없다는 것이 단서였습니다
서버 로그를 먼저 봤는데 이상한 요청이 들어온 흔적이 없었습니다. 실패한 요청도, 느린 요청도 없었습니다. 서버가 응답을 안 준 것이 아니라 앱이 요청을 안 보냈을 가능성이 커졌습니다. 그래서 서버 대신 기기를 보기로 했습니다.
테스트폰(안드로이드 16)에 스토어에서 받은 회원앱을 설치하고 USB 로 연결했습니다. 'logcat'(안드로이드 기기의 실시간 로그)으로 앱 로그를 직접 보니, 본인인증 화면에 들어가는 순간 이 예외가 찍혔습니다.
BadPaddingException: BAD_DECRYPT암호화된 값을 풀다가 실패했다는 뜻입니다. 예외가 난 자리는 '인증 인터셉터'였습니다. 앱이 보내는 모든 요청을 가로채 로그인 토큰을 헤더에 붙이는 공통 코드입니다. 이 코드가 '보안저장소'(기기의 암호화 저장 공간)에서 토큰을 읽다가 복호화에 실패했습니다.
문제는 그 예외를 아무 데서도 처리하지 않았다는 점입니다. 토큰을 붙이는 단계에서 멈췄으니 요청은 출발하지 못했습니다. 화면은 오지 않을 응답을 기다리며 로딩만 계속 돌렸습니다. 서버 로그가 비어 있던 이유가 이것이었습니다.
모든 요청이 지나가는 공통 코드에서 처리되지 않은 예외는 화면 하나의 버그로 끝나지 않습니다. 그 뒤에 오는 요청이 전부 멈춥니다.

앱 데이터를 지우는 것이 가장 빠른 확인이었습니다
원인이 저장된 값인지 확인하는 방법은 간단했습니다. 앱 설정에서 앱 데이터를 지우고 다시 본인인증 화면에 들어갔습니다. 이번에는 위탁사 인증창까지 정상으로 떴고, 로그에서도 요청이 나가는 것을 확인했습니다.
저장값이 사라지자 문제도 사라졌으니, 원인은 코드 흐름이 아니라 기기에 남아 있던 값 쪽입니다. 다만 이건 사용자에게 시킬 수 있는 해결책이 아닙니다. 사용자는 로딩이 멈추지 않는 이유를 모르고, 앱 데이터를 지울 생각도 하지 않습니다. 앱이 스스로 빠져나와야 했습니다.
저장값이 왜 풀리지 않았는지는 확인하지 못했습니다
여기서부터는 추정입니다. 스토어에서 앱을 다시 설치할 때 안드로이드 '자동 백업'이 암호화된 저장값을 복원했을 것으로 봅니다. 그 값을 풀 'Keystore' 키(기기 안에만 보관되는 암호화 키)는 복원되지 않고 새로 만들어졌을 겁니다. 값은 예전 키로 암호화돼 있는데 키는 새것이니 복호화가 실패합니다.
재현해서 증명하지는 않았습니다. OS 업데이트나 저장소 라이브러리 버전 변경이 원인일 수도 있습니다. 그래서 수정을 이 추정에만 기대지 않기로 했습니다.

고치는 방법 세 가지를 놓고 골랐습니다
방법 | 좋은 점 | 걸리는 점 | 결정 |
|---|---|---|---|
A. 복호화에 실패한 값만 건너뛴다 | 고칠 범위가 작다 | 다른 값도 같은 이유로 못 읽을 수 있어 다른 자리에서 같은 문제가 다시 난다 | 채택 안 함 |
B. 읽기가 실패하면 저장값을 전부 지우고 비로그인으로 처리한다 | 원인과 상관없이 앱이 빠져나온다 | 사용자가 다시 로그인해야 한다 | 채택 |
C. 자동 백업을 끈다 | 추정한 원인을 막는다 | 추정이 틀리면 효과가 없다 | B 와 함께 채택 |
B 와 C 를 같이 넣은 이유는 원인을 확인하지 못했기 때문입니다. C 는 추정한 원인을 막고, B 는 추정이 틀려도 앱을 지켜 줍니다. 로그인 토큰은 다시 받으면 되는 값이라 살리려 애쓸 이유가 없었습니다.
// 개념 코드 — 읽기 실패를 비로그인으로 바꾼다
try {
token = await storage.read(key: 'token');
} catch (e) {
await storage.deleteAll(); // 실패한 값 하나만 지우지 않는다
token = null; // 비로그인 상태로 진행
}자동 백업은 앱 설정 파일에서 android:allowBackup="false" 로 껐습니다. 같은 결함이 관리자앱 안드로이드판에도 있어 함께 고쳤습니다. 토큰만이 아니라 비밀번호 변경 여부 플래그, 선택한 지점, 기기 ID 처럼 보안저장소에서 읽는 값 전부에 같은 처리를 넣었습니다.

이 처리가 맞지 않는 경우도 있습니다
전부 지우는 처리는 저장값이 다시 받을 수 있는 것일 때만 맞습니다. 기기에만 있는 사용자 데이터를 보안저장소에 두었다면 지우는 순간 데이터를 잃습니다
자동 백업을 끄면 기기를 바꾼 사용자는 다시 로그인해야 합니다. 로그인 유지가 중요한 앱이라면 백업 대상에서 보안저장소만 빼는 쪽이 낫습니다
수정한 회원앱 새 버전은 아직 스토어 심사 중입니다. 실제 사용자 환경에서 증상이 사라졌는지는 확인 전입니다
무한 로딩을 만나면 이 순서로 봅니다
서버 로그에 그 요청이 들어왔는지부터 봅니다. 흔적이 없으면 앱이 요청을 안 보낸 것입니다
기기를 연결해 앱 로그를 직접 봅니다. 서버만 보면 앱 안에서 난 예외는 보이지 않습니다
앱 데이터를 지우고 다시 해 봅니다. 그래서 풀리면 원인은 저장된 값 쪽입니다
모든 요청이 지나가는 공통 코드에 처리되지 않은 예외가 있는지 봅니다
보안저장소 읽기를 네트워크 호출처럼 실패할 수 있는 작업으로 다룹니다
암호화 저장소는 한 번 써 두면 언제든 읽을 수 있는 곳이 아닙니다. 기기 교체, 백업 복원, OS 업데이트로 키와 값이 어긋날 수 있습니다. 이번 일에서 비용이 컸던 것은 복호화 실패 자체보다, 그 실패가 아무 표시 없이 무한 로딩으로 바뀌었다는 점입니다. 읽지 못한 값은 고치려 하지 말고 지운 뒤 사용자를 다시 로그인 화면으로 보내는 편이 낫습니다.