상황

하이브리드 앱의 웹 코드에는 거의 항상 "지금 iOS인가 안드로이드인가"를 묻는 전역 플래그가 있다. 이름은 대개 isIOS, isAndroid다. 앱 안에서 동작하는 화면이라면 이 플래그가 편하고, 실제로 대부분의 분기가 이걸 쓴다.

그래서 앱 밖(브라우저)에서 앱으로 유도하는 코드가 navigator.userAgent를 직접 파싱하고 있으면, 코드 리뷰에서 자연스럽게 이런 지적이 나온다. "이미 appInfo.isIOS가 있는데 왜 UA를 다시 파싱하나. 통일하자."

지적 자체는 합리적이다. 같은 판단을 두 가지 수단으로 하는 건 보통 정리 대상이다. 문제는 이 경우 그 통일이 불가능하다는 것이고, 그 이유는 취향이 아니라 플래그의 정의에 있다.

문제

플래그를 신뢰할 수 있는지 판단하려면 이름이 아니라 대입식을 봐야 한다. 진입 HTML에서 이렇게 세팅된다.

<script>
  window.isAndroid = !!window.emartApp;
  window.isIOS = !!window.webkit?.messageHandlers?.emartApp;
</script>

window.emartApp은 안드로이드 웹뷰가 addJavascriptInterface로 주입하는 객체고, window.webkit.messageHandlers.*는 WKWebView가 주입하는 메시지 핸들러다. 즉 이 두 플래그가 실제로 대답하는 질문은 "이 기기의 OS가 무엇인가"가 아니라 **"어느 쪽 네이티브 브릿지가 주입되어 있는가"**다.

앱 안에서는 두 질문의 답이 우연히 일치한다. 앱 밖에서는 일치하지 않는다. 브라우저에는 어느 브릿지도 없으므로 **iOS 기기에서도 안드로이드 기기에서도 두 플래그가 모두 false**가 된다. false/false는 "둘 다 아님"이 아니라 "판별 불가"다.

내부 아키텍처 문서에 이 두 줄이 // OS 구분이라는 주석과 함께 정리돼 있었는데, 이 주석이 오해의 출발점이었다. 문서가 코드보다 먼저 읽히는 이상, 부정확한 한 줄 주석은 리뷰어 여러 명의 판단을 같은 방향으로 틀어놓는다.

flowchart TD
    Q["OS 분기가 필요한 코드"]
    Q --> C{"실행 컨텍스트"}
    C -->|앱 웹뷰 내부| IN["브릿지 주입됨<br/>isAndroid / isIOS 중 하나 true<br/>→ 플래그 사용 가능"]
    C -->|브라우저| OUT["브릿지 없음<br/>isAndroid = false<br/>isIOS = false<br/>→ 판별 불가, UA만 유효"]
    IN --> USE["플래그 = 브릿지 존재 여부<br/>(우연히 OS와 일치)"]
    OUT --> UA["userAgent 파싱이 유일 수단"]

접근과 판단

핵심은 플래그의 유효 범위는 호출 경로가 결정한다는 것이다. 문제가 된 함수는 "웹에서 앱 스토어/딥링크로 유도하는" 함수였고, 호출 경로를 역추적하면 이렇게 된다.

  • 라우터 가드가 앱 진입 여부를 확인하는 함수를 호출한다.
  • 그 함수는 앱 안이면 즉시 반환한다(early return).
  • 앱 밖일 때만 앱 유도 함수까지 내려온다.

호출처가 하나뿐인 것까지 확인하면 결론이 나온다. 이 함수가 실행되는 시점은 "앱이 아님"이 이미 확정된 시점이고, 정의상 isAndroid와 isIOS는 둘 다 false다. 플래그로 분기하면 어느 쪽 브랜치도 타지 않는다.

즉 "왜 플래그 대신 UA를 쓰냐"에 대한 답은 "그게 더 낫다고 생각해서"가 아니라 "그 경로에서 플래그는 정보를 갖고 있지 않다"이다. 코드 리뷰에서 취향 논쟁으로 흐를 수 있는 지적을, 호출 그래프 한 번 그려서 사실 확인으로 끝낼 수 있다.

구현

UA 파싱을 유지하기로 했다면, 그다음 나오는 우려는 정해져 있다. "UA는 못 믿는다, 안드로이드가 iOS로 잡힌 적 있다."

이건 감으로 반박할 게 아니라 분기 순서로 검증하면 된다.

const ua = navigator.userAgent;

if (/Android/i.test(ua)) {
  // 플레이스토어 / 안드로이드 스킴
} else if (/iPhone|iPad|iPod/i.test(ua)) {
  // 앱스토어 / iOS 유니버설 링크
}
// 그 외: 아무 동작 없음

안드로이드를 먼저 검사하고 else if로 iOS를 받으면, UA 문자열에 Android가 들어 있는 한 iOS 분기로 샐 경로가 구조적으로 없다. "안드로이드가 iOS로 잡힌다"는 시나리오는 이 순서에서 성립하지 않는다.

남는 실제 오탐 케이스는 반대 방향, iPadOS 13+의 데스크톱 모드다. 이 경우 UA가 Macintosh로 나오기 때문에 두 정규식 모두 매칭되지 않는다. 결과는 "엉뚱한 스토어로 보냄"이 아니라 "아무 일도 일어나지 않음"이다. 오작동이 아니라 무반응으로 떨어지는 실패 모드라면, 유도 링크 같은 보조 기능에서는 수용 가능한 수준이다. 결제나 인증처럼 반드시 분기해야 하는 경로라면 navigator.maxTouchPoints > 1 같은 보강이 필요하지만, 그건 요구되는 신뢰도가 다른 문제다.

한 가지 더. 같은 코드베이스에 웹→앱 유도 경로가 둘 이상 있는 경우가 흔하다. 직접 스킴을 조립하는 함수와, 어트리뷰션 SDK가 제공하는 딥링크 헬퍼가 공존하는 식이다. 겉보기에는 후자로 통합하면 코드가 줄어들 것 같지만, 통합 전에 파라미터 표면을 대조해야 한다. 실제로 SDK 헬퍼는 앱 미설치 시 노출되는 확인 문구가 하드코딩이라, 호출부가 넘기는 커스텀 안내 문구를 받을 수 없었다. 단순 치환이 불가능하고, 통합하려면 문구 요구사항 자체를 포기하거나 SDK를 감싸는 층을 새로 만들어야 한다. 어느 쪽이든 "코드가 줄어든다"는 애초의 이득은 사라진다.

결과

  • 리뷰 지적("UA 대신 플래그 쓰자")에 대해 취향이 아닌 구조적 근거로 답변이 정리됐다. 플래그가 그 경로에서 항상 false라는 사실 하나로 논쟁이 종료된다.
  • UA 오탐 우려는 분기 순서로 차단되고, 남는 실패 모드(iPadOS 데스크톱 모드)는 오작동이 아니라 무반응임을 명시해 수용 범위를 합의했다.
  • 두 유도 경로의 통합은 파라미터 표면 대조 결과 보류했다. 커스텀 문구 요구사항을 만족시키지 못하는 치환은 리팩토링이 아니라 기능 축소다.
  • 부정확한 // OS 구분 주석은 아키텍처 문서에서 교정 대상으로 남겼다. 문서의 한 줄이 리뷰어 여러 명의 판단을 같은 방향으로 틀어놓는 비용이 실측됐기 때문이다.

배운 것

  • 부울 전역 플래그는 이름이 아니라 대입식을 봐야 한다. isIOS라는 이름은 "OS가 iOS인가"를 약속하지만 !!window.webkit?.messageHandlers?.x는 전혀 다른 것을 측정한다. 이름과 측정값이 앱 안에서 우연히 일치할 때 이 괴리는 오래 숨는다.
  • 환경 감지 플래그는 감지 수단에 따라 유효 구간이 다르다. 객체 존재로 감지하는 플래그는 그 객체가 주입되는 구간에서만, UA로 감지하는 값은 UA를 신뢰할 수 있는 구간에서만, 서버가 주입하는 값은 그 렌더 경로에서만 유효하다. "어느 코드 경로에서 읽는가"를 같이 확인해야 판단이 선다.
  • 불신 주장은 실패 모드까지 내려가서 검증한다. "UA는 못 믿는다"는 주장은 참이지만, 실제 오탐 케이스가 무엇이고 그 케이스에서 코드가 어떻게 떨어지는지(오작동인가 무반응인가)까지 내려가야 수용 여부를 판단할 수 있다. 추상적 불신은 아무것도 결정하지 못한다.
  • 중복 제거는 파라미터 표면부터 대조한다. 같은 일을 하는 두 함수처럼 보여도 한쪽이 받지 못하는 입력이 하나라도 있으면 치환이 아니라 기능 축소다.