🐝매일 한입
개발일지📖 11분 읽기

Oftway — 잠긴 폰에서 위치로 깨어날 때마다 죽던 앱, 원인은 세션 키의 Keychain 접근 수준

잠긴 iPhone 에서 위치 이벤트로 깨어난 Oftway 가 2분에 16번 죽었습니다. 로그인 세션 키를 '첫 잠금 해제 뒤 읽기'로 새 이름에 옮기고, 읽을 수 없는 순간에는 새 키를 만들지 않고 계정만 잠시 끄기로 한 이유.

📖 11분 읽기
#Oftway#iOS#Keychain#expo-secure-store#백그라운드 위치#1인개발

2분에 16번 뜬 충돌 창

Oftway 는 기록 버튼을 누르지 않아도 다닌 길을 남기는 앱이라, 하루 대부분을 백그라운드에서 보냅니다. 폰이 주머니 속에서 잠겨 있는 동안 위치 변화나 방문 감지 같은 이벤트가 앱을 깨우고, 네이티브 쪽이 원본 점을 기기 SQLite 에 씁니다(ADR-0004).

10월 25일, TestFlight 의 1.0.0 (6) 빌드를 쓰다가 제가 intent 에 남긴 발의는 이렇습니다. "이따금씩 충돌 모달이 계속 떠서 휴대폰을 사용못하게 할정도로 나오고 있어." 폰의 충돌 기록에는 Oftway 충돌이 17건 있었고, 그중 16건이 15시 36분에서 38분 사이 2분 동안 몰려 있었습니다. 잠긴 폰에서 위치 이벤트로 깨어나면 0.4~1.4초 만에 죽고, 다음 이벤트에 또 깨어나 또 죽는 식이었습니다. 사흘 전 1.0 심사에 낸 것도 이 빌드였습니다.

단서는 적었습니다. 17건 모두 스택이 같았고, 앱 역할은 Non UI 로 화면 없이 깨어난 상태였습니다. Expo 의 오류 복구 큐(errorRecoveryQueue)가 JS 치명 오류를 다시 던진 모양인데, 이 빌드는 Sentry DSN 이 비어 있어서 JS 오류 문구가 어디에도 남지 않았습니다. 그래서 원인은 오류 메시지가 아니라 코드 경로와 시점으로 좁혔다고 intent 에 적어 두었습니다.

세션 키가 잠긴 폰에서 읽히지 않았다

범인은 위치 기록이 아니라 로그인이었습니다. Oftway 의 로그인은 선택 기능이고 서버는 계정만 다룹니다(ADR-0006). 로그인 세션은 암호화한 MMKV 저장소에 두고, 그 암호화 키는 expo-secure-store 로 Keychain 에 넣습니다. 10월 6일 로그인을 처음 넣을 때 키를 읽는 코드는 이랬습니다.

function authEncryptionKey(): string {
  const saved = SecureStore.getItem(AUTH_KEY_NAME);
  if (saved) return saved;
  // MMKV 암호화 키는 16바이트까지 쓴다. 8바이트를 hex로 적어 16글자를 만든다.
  const key = Array.from(Crypto.getRandomBytes(8), (b) => b.toString(16).padStart(2, '0')).join('');
  SecureStore.setItem(AUTH_KEY_NAME, key);
  return key;
}

옵션 없이 저장했으니 키는 기본값인 "잠금 해제 중에만 읽기"(WHEN_UNLOCKED)로 들어가 있었습니다. 폰이 잠겨 있으면 Keychain 이 읽기를 거절하고, 동기 함수인 getItem 은 그 자리에서 오류를 던집니다. 이 읽기가 계정 상태를 들고 있는 AccountProvider 가 그려지는 도중 getSupabase() 안에서 일어나니 앱 전체가 죽었습니다. 로그인하지 않은 사람도 피할 수 없었습니다. 서버 설정이 있는 빌드에서는 로그인 여부와 상관없이 이 키를 읽기 때문입니다.

intent 에는 다른 위험도 하나 적었습니다. 이번에는 오류로 끝났지만, 거절이 null 로 돌아왔다면 이 코드는 키가 없다고 보고 새 키를 만들어 덮어썼을 겁니다. 그러면 앞의 키로 암호화한 세션은 다시는 읽을 수 없습니다. 고치면서 이 길도 같이 막아야 했습니다.

키를 새 이름으로 옮기고, 못 읽으면 계정만 잠시 끈다

키의 접근 수준은 "첫 잠금 해제 뒤 읽기"(AFTER_FIRST_UNLOCK)로 바꿨습니다. 부팅한 뒤 한 번이라도 잠금을 풀었다면 그 뒤로는 잠긴 상태에서도 읽힙니다. 다만 이미 저장된 키는 같은 이름에 덮어써도 접근 수준이 바뀌지 않는다는 것이 코드 주석에 남은 판단이라, 새 이름 oftway.auth.key.v2 로 옮기기로 했습니다. 그 순서를 Keychain 을 인터페이스로 받는 순수 함수 하나에 모았습니다.

export function authKey(keychain: Keychain, makeKey: () => string): string | null {
  let legacy: string | null;
  try {
    const saved = keychain.get(AUTH_KEY_NAME);
    if (saved) return saved;
    legacy = keychain.get(LEGACY_AUTH_KEY_NAME);
  } catch {
    return null;
  }
  const key = legacy ?? makeKey();
  try {
    keychain.set(AUTH_KEY_NAME, key);
  } catch {
    // 옛 키는 그대로 쓰고 다음에 다시 옮긴다. 저장하지 못한 새 키는 쓰지 않는다.
    return legacy;
  }
  // (옛 키 지우기 — 실패해도 새 이름을 먼저 읽으니 괜찮다)
  return key;
}

규칙은 두 가지입니다. 읽다가 거절당하면 null 을 돌려주고 새 키를 만들지 않습니다. 옮길 때는 새 이름에 먼저 쓰고, 그게 성공한 뒤에만 옛 키를 지웁니다. 어느 순간에 앱이 죽더라도 키가 하나도 없는 때가 생기지 않게 하려는 순서입니다. 옛 키는 여전히 잠금 해제 중에만 읽히니, 실제로 옮겨지는 때는 업데이트를 받고 앱을 한 번 앞에서 열었을 때입니다.

null 을 받는 쪽도 바꿨습니다. authStorage() 와 getSupabase() 는 키가 없으면 null 을 돌려주되 그 결과를 기억하지 않아, 다음 호출에서 다시 시도합니다. AccountProvider 는 클라이언트를 못 열었을 때 앱이 앞으로 오기를 기다렸다가 다시 엽니다.

    if (!supabase) {
      // 잠긴 폰에서 백그라운드로 깨어나 세션 키를 못 읽었으면 앱이 앞으로 올 때 다시 연다(background-keychain)
      if (!env.supabaseUrl) return;
      const retry = AppState.addEventListener('change', (state) => {
        if (state === 'active') setAttempt((n) => n + 1);
      });
      return () => retry.remove();
    }

재부팅 뒤 첫 잠금 해제 전처럼 AFTER_FIRST_UNLOCK 으로도 못 읽는 순간에는 이 길로 계정만 잠시 꺼집니다. 위치는 기기 밖으로 나가지 않고 계정과도 연결되지 않으니(ADR-0001·0006), 계정이 꺼져 있어도 기록 처리는 그대로 이어 갈 수 있습니다.

intent 의 제약에는 버린 길도 하나 적혀 있습니다. 접근 수준에 ThisDeviceOnly 를 붙이지 않았습니다. 기존 키와 백업 범위를 똑같이 두기 위해서입니다. 세션을 암호화 저장소에 두는 원칙(account intent §5-3)도 그대로이고, 바뀐 것은 키를 언제 읽을 수 있느냐 하나입니다. 다른 저장 방식을 따져 본 기록은 남아 있지 않습니다.

확인한 것과 남은 것

커밋에 들어간 테스트는 가짜 Keychain 으로 여섯 경우를 봅니다.

  • 새 이름의 키가 있으면 그대로 쓴다
  • 옛 키만 있으면 새 이름으로 옮기고 옛 키를 지운다
  • 키가 없으면 새로 만들어 저장한다
  • 잠긴 폰(읽기가 던짐)에서는 null 이고 새 키를 만들지 않는다
  • 옮기기 저장이 실패하면 옛 키를 그대로 쓰고 지우지 않는다
  • 저장하지 못한 새 키는 쓰지 않는다

수정은 같은 날 PR #78 로 main 에 합쳐졌습니다. 서버와 SQLite 스키마는 건드리지 않아서 intent 의 범위는 OTA 로 내는 것입니다. 하지만 plan 의 셋째 줄 "production OTA" 와 넷째 줄 "OTA 를 받은 뒤 잠긴 채 걸어도 충돌 기록이 늘지 않는지 폰 충돌 기록으로 확인" 은 아직 레포에 결과가 적혀 있지 않습니다. 오늘까지의 기록은 합친 데까지입니다.

하나 더 남는 것은 재부팅 직후입니다. ADR-0004 의 실측 기록에는 재부팅 뒤 잠금 해제 전에 앱이 실행된 흔적이 2번 있었고, 재부팅이었는지는 확인하지 못해 출시 전에 일부러 확인하겠다고 적혀 있습니다. 이번 수정으로 그 순간에도 앱은 죽지 않고 계정만 꺼지는 설계가 됐지만, 실기기에서 본 기록은 아직 없습니다.

Oftway 는 따로 기록하지 않아도 다닌 길이 지도에 남고, 자주 다닌 길일수록 짙어지는 발자취 일기입니다. 위치는 폰 밖으로 나가지 않습니다. 지금은 iOS 출시를 준비하고 있습니다.

📚 관련 글

💬 댓글